diff --git a/packages/twenty-front/src/hooks/useNavigateSettings.ts b/packages/twenty-front/src/hooks/useNavigateSettings.ts index 7889399da4..e1fb794c55 100644 --- a/packages/twenty-front/src/hooks/useNavigateSettings.ts +++ b/packages/twenty-front/src/hooks/useNavigateSettings.ts @@ -1,4 +1,5 @@ import { useOpenSettingsMenu } from '@/navigation/hooks/useOpenSettings'; +import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { type SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; @@ -7,19 +8,22 @@ export const useNavigateSettings = () => { const navigate = useNavigate(); const { openSettingsMenu } = useOpenSettingsMenu(); - return ( - to: T, - params?: Parameters>[1], - queryParams?: Record, - options?: { - replace?: boolean; - state?: any; - }, - hash?: string, - ) => { - openSettingsMenu(); + return useCallback( + ( + to: T, + params?: Parameters>[1], + queryParams?: Record, + options?: { + replace?: boolean; + state?: any; + }, + hash?: string, + ) => { + openSettingsMenu(); - const path = getSettingsPath(to, params, queryParams, hash); - return navigate(path, options); - }; + const path = getSettingsPath(to, params, queryParams, hash); + return navigate(path, options); + }, + [navigate, openSettingsMenu], + ); }; diff --git a/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts b/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts index ebbc6c7a68..48522dfa33 100644 --- a/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts +++ b/packages/twenty-front/src/modules/navigation/hooks/useOpenSettings.ts @@ -5,6 +5,7 @@ import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/n import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { useCallback } from 'react'; import { useLocation } from 'react-router-dom'; export const useOpenSettingsMenu = () => { @@ -22,7 +23,7 @@ export const useOpenSettingsMenu = () => { currentMobileNavigationDrawerState, ); - const openSettingsMenu = () => { + const openSettingsMenu = useCallback(() => { if (isSettingsPage) { return; } @@ -31,7 +32,16 @@ export const useOpenSettingsMenu = () => { setIsNavigationDrawerExpanded(true); setNavigationMemorizedUrl(location.pathname + location.search); setCurrentMobileNavigationDrawer('settings'); - }; + }, [ + isSettingsPage, + isNavigationDrawerExpanded, + location.pathname, + location.search, + setCurrentMobileNavigationDrawer, + setIsNavigationDrawerExpanded, + setNavigationDrawerExpandedMemorized, + setNavigationMemorizedUrl, + ]); return { openSettingsMenu }; }; diff --git a/packages/twenty-front/src/modules/settings/playground/components/RestPlayground.tsx b/packages/twenty-front/src/modules/settings/playground/components/RestPlayground.tsx index d79c34b4b9..cc20cf65c7 100644 --- a/packages/twenty-front/src/modules/settings/playground/components/RestPlayground.tsx +++ b/packages/twenty-front/src/modules/settings/playground/components/RestPlayground.tsx @@ -1,7 +1,8 @@ +import { RestPlaygroundSchemaFetchEffect } from '@/settings/playground/components/RestPlaygroundSchemaFetchEffect'; import { playgroundApiKeyState } from '@/settings/playground/states/playgroundApiKeyState'; import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useContext, lazy, Suspense } from 'react'; +import { useContext, useState, lazy, Suspense } from 'react'; import { styled } from '@linaria/react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { SettingsPath } from 'twenty-shared/types'; @@ -52,50 +53,59 @@ type RestPlaygroundProps = { export const RestPlayground = ({ onError, schema }: RestPlaygroundProps) => { const { theme, colorScheme } = useContext(ThemeContext); const playgroundApiKey = useAtomStateValue(playgroundApiKeyState); + const [specContent, setSpecContent] = useState(null); if (!playgroundApiKey) { onError(); return null; } + const fallback = ( + + + + ); + return ( - - - - } - > - + {specContent === null ? ( + fallback + ) : ( + + - + authentication: { + http: { + bearer: { token: playgroundApiKey }, + }, + }, + baseServerURL: REACT_APP_SERVER_BASE_URL + '/' + schema, + forceDarkModeState: colorScheme === 'dark' ? 'dark' : 'light', + hideClientButton: true, + hideDarkModeToggle: true, + hideModels: schema === 'metadata', + pathRouting: { + basePath: getSettingsPath(SettingsPath.RestPlayground, { + schema, + }), + }, + }} + /> + + )} ); }; diff --git a/packages/twenty-front/src/modules/settings/playground/components/RestPlaygroundSchemaFetchEffect.tsx b/packages/twenty-front/src/modules/settings/playground/components/RestPlaygroundSchemaFetchEffect.tsx new file mode 100644 index 0000000000..4231ab7b90 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/playground/components/RestPlaygroundSchemaFetchEffect.tsx @@ -0,0 +1,40 @@ +import { type PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas'; +import { useEffect } from 'react'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; + +type RestPlaygroundSchemaFetchEffectProps = { + schema: PlaygroundSchemas; + apiKey: string; + onSchemaLoaded: (document: object | null) => void; + onError: () => void; +}; + +// Fetch via header so the token is never in a URL (logs, history, Referer). +export const RestPlaygroundSchemaFetchEffect = ({ + schema, + apiKey, + onSchemaLoaded, + onError, +}: RestPlaygroundSchemaFetchEffectProps) => { + useEffect(() => { + onSchemaLoaded(null); + + const abortController = new AbortController(); + + fetch(`${REACT_APP_SERVER_BASE_URL}/rest/open-api/${schema}`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: abortController.signal, + }) + .then((response) => (response.ok ? response.json() : Promise.reject())) + .then(onSchemaLoaded) + .catch((error) => { + if (error?.name !== 'AbortError') { + onError(); + } + }); + + return () => abortController.abort(); + }, [schema, apiKey, onSchemaLoaded, onError]); + + return null; +}; diff --git a/packages/twenty-front/src/pages/settings/developers/playground/SettingsRestPlayground.tsx b/packages/twenty-front/src/pages/settings/developers/playground/SettingsRestPlayground.tsx index 723706f619..6d8a9e602f 100644 --- a/packages/twenty-front/src/pages/settings/developers/playground/SettingsRestPlayground.tsx +++ b/packages/twenty-front/src/pages/settings/developers/playground/SettingsRestPlayground.tsx @@ -2,6 +2,7 @@ import { RestPlayground } from '@/settings/playground/components/RestPlayground' import { PlaygroundSchemas } from '@/settings/playground/types/PlaygroundSchemas'; import { FullScreenContainer } from '@/ui/layout/fullscreen/components/FullScreenContainer'; import { Trans } from '@lingui/react/macro'; +import { useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; @@ -17,6 +18,10 @@ export const SettingsRestPlayground = () => { navigateSettings(SettingsPath.ApiWebhooks); }; + const handleError = useCallback(() => { + navigateSettings(SettingsPath.ApiWebhooks); + }, [navigateSettings]); + return ( { { children: REST }, ]} > - navigateSettings(SettingsPath.ApiWebhooks)} - /> + ); }; diff --git a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts index 183437c37c..fcf5957e29 100644 --- a/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts +++ b/packages/twenty-server/src/engine/core-modules/jwt/services/jwt-wrapper.service.ts @@ -7,7 +7,6 @@ import { import { createHash } from 'crypto'; -import { type Request as ExpressRequest } from 'express'; import * as jwt from 'jsonwebtoken'; import { ExtractJwt, type JwtFromRequestFunction } from 'passport-jwt'; import { isDefined } from 'twenty-shared/utils'; @@ -131,18 +130,6 @@ export class JwtWrapperService { } extractJwtFromRequest(): JwtFromRequestFunction { - return (request: ExpressRequest) => { - // First try to extract token from Authorization header - const tokenFromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(request); - - if (tokenFromHeader) { - return tokenFromHeader; - } - - // If not found in header, try to extract from URL query parameter - // This is for edge cases where we don't control the origin request - // (e.g. the REST API playground) - return ExtractJwt.fromUrlQueryParameter('token')(request); - }; + return ExtractJwt.fromAuthHeaderAsBearerToken(); } } diff --git a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts index 8d8d8903d4..d5ffc6bb6f 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/open-api.service.ts @@ -100,12 +100,7 @@ export class OpenApiService { serverUrlFallback: `${request.protocol}://${request.get('host')}`, }); - const tokenFromQuery = request.query.token; - const schema = baseSchema( - 'core', - baseUrl, - typeof tokenFromQuery === 'string' ? tokenFromQuery : undefined, - ); + const schema = baseSchema('core', baseUrl); const workspace = await this.getWorkspaceFromRequest(request); @@ -263,12 +258,7 @@ export class OpenApiService { serverUrlFallback: `${request.protocol}://${request.get('host')}`, }); - const tokenFromQuery = request.query.token; - const schema = baseSchema( - 'metadata', - baseUrl, - typeof tokenFromQuery === 'string' ? tokenFromQuery : undefined, - ); + const schema = baseSchema('metadata', baseUrl); const workspace = await this.getWorkspaceFromRequest(request); diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts index 5d622bd2ee..9dd855b09e 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/base-schema.utils.ts @@ -7,7 +7,6 @@ export const API_Version = 'v0.1'; export const baseSchema = ( schemaName: 'core' | 'metadata', serverUrl: string, - token?: string, ): OpenAPIV3_1.Document => { return { openapi: '3.1.1', @@ -31,6 +30,10 @@ curl -H 'Authorization: Bearer ' /rest/core/companies Tokens can be generated in Settings → Playground and are workspace-scoped. +> **Never put your token in a URL.** Tokens passed as query parameters end up in +> server access logs, browser history, and \`Referer\` headers, which is why the +> API only accepts tokens from the \`Authorization\` header. + ## Filters @@ -101,24 +104,25 @@ order_by=id[AscNullsFirst],createdAt[DescNullsLast] ## Usage with LLMs -You can use AI to generate code based on the OpenAPI schema with the following URLs: +The recommended way to give an LLM agent (Claude Desktop, Cursor, Windsurf, …) +access to your workspace is the **Twenty MCP server**, not this OpenAPI schema. +The MCP server exposes typed tools the agent can call directly with proper +header-based auth (OAuth or API key), no tokens in URLs. + +Configure it from **Settings → AI → MCP** in your workspace. The endpoint is: \`\`\`text -Core: ${serverUrl}/rest/open-api/core?token=${token ?? ''} -Metadata: ${serverUrl}/rest/open-api/metadata?token=${token ?? ''} +${serverUrl}/mcp \`\`\` -Quick prompt example (Cursor or any agent): +If you specifically need the raw OpenAPI document (for code generation, type +generation, etc.), download it locally with a header-authenticated request and +hand the file to your tool — never paste a tokenized URL into a chat: -\`\`\`text -Here is an OpenAPI schema for the Twenty REST API:\n${serverUrl}/rest/open-api/core?token=${token ?? ''} - -Use it to list companies created after 2024-01-01, ordered by createdAt desc, and include only 20 results. +\`\`\`bash +curl -H 'Authorization: Bearer ' \\ + ${serverUrl}/rest/open-api/${schemaName} > twenty-${schemaName}.json \`\`\` - -Notes: -- Treat the token like a secret; prefer a short-lived Playground token. -- Most editors can fetch and process the schema even if it's large. `, termsOfService: 'https://github.com/twentyhq/twenty?tab=coc-ov-file#readme',