From c26c0b9d71c03bada3ea2c24ddd297c88576048a Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sat, 11 Apr 2026 11:24:28 +0200 Subject: [PATCH] Use app's own OAuth credentials for CoreApiClient generation (#19563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **SDK (`dev` & `dev --once`)**: After app registration, the CLI now obtains an `APPLICATION_ACCESS` token via `client_credentials` grant using the app's own `clientId`/`clientSecret`, and uses that token for CoreApiClient schema introspection — instead of the user's `config.accessToken` which returns the full unscoped schema. - **Config**: `oauthClientSecret` is now persisted alongside `oauthClientId` in `~/.twenty/config.json` when creating a new app registration, so subsequent `dev`/`dev --once` runs can obtain fresh app tokens without re-registration. - **CI action**: `spawn-twenty-app-dev-test` now outputs a proper `API_KEY` JWT (signed with the seeded dev workspace secret) instead of the previous hardcoded `ACCESS` token — giving consumers a real API key rather than a user session token. ## Motivation When developing Twenty apps, `yarn twenty dev` was using the CLI user's OAuth token for GraphQL schema introspection during CoreApiClient generation. This token (type `ACCESS`) has no `applicationId` claim, so the server returns the **full workspace schema** — including all objects — rather than the scoped schema the app should see at runtime (filtered by `applicationId`). This caused a discrepancy: the generated CoreApiClient contained fields the app couldn't actually query at runtime with its `APPLICATION_ACCESS` token. By switching to `client_credentials` grant, the SDK now introspects with the same token type the app will use in production, ensuring the generated client accurately reflects the app's runtime capabilities. --- .../spawn-twenty-app-dev-test/action.yml | 4 +- .../src/constants/template/vitest.config.ts | 3 +- .../examples/hello-world/vitest.config.ts | 3 +- .../examples/postcard/vitest.config.ts | 3 +- .../fixtures/invalid-app/yarn.lock | 0 .../fixtures/minimal-app/yarn.lock | 0 .../integration/utils/setup-app-dev-mocks.ts | 9 ++- .../twenty-sdk/src/cli/commands/remote.ts | 4 +- .../twenty-sdk/src/cli/operations/dev-once.ts | 47 ++++++------ .../src/cli/operations/login-oauth.ts | 6 +- .../twenty-sdk/src/cli/operations/login.ts | 6 +- .../src/cli/utilities/api/api-client.ts | 19 +++-- .../src/cli/utilities/api/api-service.ts | 16 +++- .../src/cli/utilities/api/application-api.ts | 51 ++++++++++++- .../src/cli/utilities/api/schema-api.ts | 10 +-- .../auth/resolve-app-access-token.ts | 62 +++++++++++++++ .../build/common/file-upload-watcher.ts | 19 +++-- .../cli/utilities/client/client-service.ts | 8 +- .../cli/utilities/config/config-service.ts | 50 ++++++++---- .../generate-api-client-orchestrator-step.ts | 7 +- .../steps/register-app-orchestrator-step.ts | 76 +++++-------------- packages/twenty-sdk/vitest.e2e.config.ts | 2 +- 22 files changed, 263 insertions(+), 142 deletions(-) create mode 100644 packages/twenty-apps/fixtures/invalid-app/yarn.lock create mode 100644 packages/twenty-apps/fixtures/minimal-app/yarn.lock create mode 100644 packages/twenty-sdk/src/cli/utilities/auth/resolve-app-access-token.ts diff --git a/.github/actions/spawn-twenty-app-dev-test/action.yml b/.github/actions/spawn-twenty-app-dev-test/action.yml index bfc9767777..4ee25e0863 100644 --- a/.github/actions/spawn-twenty-app-dev-test/action.yml +++ b/.github/actions/spawn-twenty-app-dev-test/action.yml @@ -15,8 +15,8 @@ outputs: description: 'URL where the Twenty test server can be reached' value: http://localhost:2021 api-key: - description: 'API key for the Twenty test instance' - value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4 + description: 'API key (type: API_KEY) for the seeded Twenty dev workspace' + value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc runs: using: 'composite' diff --git a/packages/create-twenty-app/src/constants/template/vitest.config.ts b/packages/create-twenty-app/src/constants/template/vitest.config.ts index 9c698b8340..243b9dbc58 100644 --- a/packages/create-twenty-app/src/constants/template/vitest.config.ts +++ b/packages/create-twenty-app/src/constants/template/vitest.config.ts @@ -17,8 +17,7 @@ export default defineConfig({ TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020', TWENTY_API_KEY: process.env.TWENTY_API_KEY ?? - // Tim Apple (admin) access token for twenty-app-dev - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc', }, }, }); diff --git a/packages/twenty-apps/examples/hello-world/vitest.config.ts b/packages/twenty-apps/examples/hello-world/vitest.config.ts index 9c698b8340..243b9dbc58 100644 --- a/packages/twenty-apps/examples/hello-world/vitest.config.ts +++ b/packages/twenty-apps/examples/hello-world/vitest.config.ts @@ -17,8 +17,7 @@ export default defineConfig({ TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020', TWENTY_API_KEY: process.env.TWENTY_API_KEY ?? - // Tim Apple (admin) access token for twenty-app-dev - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc', }, }, }); diff --git a/packages/twenty-apps/examples/postcard/vitest.config.ts b/packages/twenty-apps/examples/postcard/vitest.config.ts index 9c698b8340..243b9dbc58 100644 --- a/packages/twenty-apps/examples/postcard/vitest.config.ts +++ b/packages/twenty-apps/examples/postcard/vitest.config.ts @@ -17,8 +17,7 @@ export default defineConfig({ TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020', TWENTY_API_KEY: process.env.TWENTY_API_KEY ?? - // Tim Apple (admin) access token for twenty-app-dev - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc', }, }, }); diff --git a/packages/twenty-apps/fixtures/invalid-app/yarn.lock b/packages/twenty-apps/fixtures/invalid-app/yarn.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/twenty-apps/fixtures/minimal-app/yarn.lock b/packages/twenty-apps/fixtures/minimal-app/yarn.lock new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts index 1b3001baea..67f96d6b70 100644 --- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts +++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/setup-app-dev-mocks.ts @@ -20,7 +20,8 @@ const mockApiService = { id: 'mock-registration-id', oAuthClientId: 'mock-client-id', }, - clientSecret: 'mock-client-secret', + accessToken: 'mock-app-access-token', + refreshToken: 'mock-app-refresh-token', }, }), createDevelopmentApplication: vi.fn().mockResolvedValue({ @@ -56,6 +57,12 @@ vi.mock('@/cli/utilities/file/file-uploader', () => ({ }, })); +vi.mock('@/cli/utilities/auth/resolve-app-access-token', () => ({ + ensureValidAppAccessTokenOrRefresh: vi + .fn() + .mockResolvedValue('mock-app-access-token'), +})); + vi.mock('@/cli/utilities/client/client-service', () => ({ ClientService: class { generateCoreClient = vi.fn().mockResolvedValue(undefined); diff --git a/packages/twenty-sdk/src/cli/commands/remote.ts b/packages/twenty-sdk/src/cli/commands/remote.ts index 7fa4717c33..839c3a0b5a 100644 --- a/packages/twenty-sdk/src/cli/commands/remote.ts +++ b/packages/twenty-sdk/src/cli/commands/remote.ts @@ -170,7 +170,7 @@ export const registerRemoteCommands = (program: Command): void => { for (const remoteName of remotes) { const config = await configService.getConfigForRemote(remoteName); - const authMethod = config.accessToken + const authMethod = config.twentyCLIAccessToken ? 'oauth' : config.apiKey ? 'api-key' @@ -230,7 +230,7 @@ export const registerRemoteCommands = (program: Command): void => { const activeRemote = ConfigService.getActiveRemote(); const config = await configService.getConfig(); - const authMethod = config.accessToken + const authMethod = config.twentyCLIAccessToken ? 'oauth' : config.apiKey ? 'api-key' diff --git a/packages/twenty-sdk/src/cli/operations/dev-once.ts b/packages/twenty-sdk/src/cli/operations/dev-once.ts index d117795111..6167014e0f 100644 --- a/packages/twenty-sdk/src/cli/operations/dev-once.ts +++ b/packages/twenty-sdk/src/cli/operations/dev-once.ts @@ -7,6 +7,7 @@ import { runTypecheck } from '@/cli/utilities/build/common/typecheck-plugin'; import { buildAndValidateManifest } from '@/cli/utilities/build/manifest/build-and-validate-manifest'; import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest-update-checksums'; import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer'; +import { ensureValidAppAccessTokenOrRefresh } from '@/cli/utilities/auth/resolve-app-access-token'; import { ClientService } from '@/cli/utilities/client/client-service'; import { ConfigService } from '@/cli/utilities/config/config-service'; import { formatSyncErrorEvents } from '@/cli/utilities/dev/orchestrator/steps/format-sync-error-events'; @@ -119,41 +120,34 @@ const innerAppDevOnce = async ( onProgress?.('Registering application...'); const configService = new ConfigService(); - const registrationResult = - await apiService.findApplicationRegistrationByUniversalIdentifier( - manifest.application.universalIdentifier, - ); - if (!registrationResult.success) { - return { - success: false, - error: { - code: APP_ERROR_CODES.SYNC_FAILED, - message: `Failed to check app registration: ${serializeError(registrationResult.error)}`, - }, - }; - } + const appAccessToken = + await ensureValidAppAccessTokenOrRefresh(configService); - if (!registrationResult.data) { - const createRegistrationResult = - await apiService.createApplicationRegistration({ - name: manifest.application.displayName, - universalIdentifier: manifest.application.universalIdentifier, - }); + if (!appAccessToken) { + const createResult = await apiService.createApplicationRegistration({ + name: manifest.application.displayName, + universalIdentifier: manifest.application.universalIdentifier, + }); - if (!createRegistrationResult.success) { + if (!createResult.success) { return { success: false, error: { code: APP_ERROR_CODES.SYNC_FAILED, - message: `Failed to create app registration: ${serializeError(createRegistrationResult.error)}`, + message: `Failed to create app registration: ${serializeError(createResult.error)}`, }, }; } + const { applicationRegistration, accessToken, refreshToken } = + createResult.data; + await configService.setConfig({ - oauthClientId: - createRegistrationResult.data.applicationRegistration.oAuthClientId, + appRegistrationId: applicationRegistration.id, + appRegistrationClientId: applicationRegistration.oAuthClientId, + appAccessToken: accessToken, + appRefreshToken: refreshToken, }); } @@ -236,15 +230,18 @@ const innerAppDevOnce = async ( }; } + // Generate the CoreApiClient using an APPLICATION_ACCESS token so the + // server returns the app-scoped schema (objects defined by this app). onProgress?.('Generating API client...'); try { - const config = await configService.getConfig(); + const appAccessToken = + await ensureValidAppAccessTokenOrRefresh(configService); const clientService = new ClientService(); await clientService.generateCoreClient({ appPath, - authToken: config.accessToken, + appAccessToken, }); } catch (error) { return { diff --git a/packages/twenty-sdk/src/cli/operations/login-oauth.ts b/packages/twenty-sdk/src/cli/operations/login-oauth.ts index a8fb03af3c..7187a5dbd0 100644 --- a/packages/twenty-sdk/src/cli/operations/login-oauth.ts +++ b/packages/twenty-sdk/src/cli/operations/login-oauth.ts @@ -106,9 +106,9 @@ const innerAuthLoginOAuth = async ( await configService.setConfig({ apiUrl, - accessToken, - refreshToken, - oauthClientId: clientId, + twentyCLIAccessToken: accessToken, + twentyCLIRefreshToken: refreshToken, + twentyCLIRegistrationClientId: clientId, }); const apiService = new ApiService({ diff --git a/packages/twenty-sdk/src/cli/operations/login.ts b/packages/twenty-sdk/src/cli/operations/login.ts index 0aaa05478b..52a9f447d1 100644 --- a/packages/twenty-sdk/src/cli/operations/login.ts +++ b/packages/twenty-sdk/src/cli/operations/login.ts @@ -23,9 +23,9 @@ const innerAuthLogin = async ( await configService.setConfig({ apiUrl, apiKey, - accessToken: undefined, - refreshToken: undefined, - oauthClientId: undefined, + twentyCLIAccessToken: undefined, + twentyCLIRefreshToken: undefined, + twentyCLIRegistrationClientId: undefined, }); const apiService = new ApiService(); diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts index f88568bee3..5f769862ac 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-client.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-client.ts @@ -115,23 +115,26 @@ export class ApiClient { async refreshToken(): Promise { const config = await this.configService.getConfig(); - if (!config.refreshToken || !config.oauthClientId) { + if ( + !config.twentyCLIRefreshToken || + !config.twentyCLIRegistrationClientId + ) { return null; } try { const tokenResponse = await axios.post(`${config.apiUrl}/oauth/token`, { grant_type: 'refresh_token', - refresh_token: config.refreshToken, - client_id: config.oauthClientId, + refresh_token: config.twentyCLIRefreshToken, + client_id: config.twentyCLIRegistrationClientId, }); const { access_token: newAccessToken, refresh_token: newRefreshToken } = tokenResponse.data; await this.configService.setConfig({ - accessToken: newAccessToken, - ...(newRefreshToken ? { refreshToken: newRefreshToken } : {}), + twentyCLIAccessToken: newAccessToken, + ...(newRefreshToken ? { twentyCLIRefreshToken: newRefreshToken } : {}), }); return newAccessToken; @@ -146,9 +149,9 @@ export class ApiClient { } const config = await this.configService.getConfig(); - const accessToken = config.accessToken; + const cliToken = config.twentyCLIAccessToken; - if (accessToken && this.isTokenExpired(accessToken)) { + if (cliToken && this.isTokenExpired(cliToken)) { const refreshed = await this.refreshToken(); if (refreshed) { @@ -156,7 +159,7 @@ export class ApiClient { } } - return accessToken ?? config.apiKey; + return cliToken ?? config.apiKey; } private isTokenExpired(token: string): boolean { diff --git a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts index c03c1588a0..6aa6cb8381 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/api-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/api-service.ts @@ -52,6 +52,16 @@ export class ApiService { return this.applicationApi.createApplicationRegistration(...args); } + rotateApplicationRegistrationClientSecret( + ...args: Parameters< + ApplicationApi['rotateApplicationRegistrationClientSecret'] + > + ) { + return this.applicationApi.rotateApplicationRegistrationClientSecret( + ...args, + ); + } + createDevelopmentApplication( ...args: Parameters ) { @@ -70,12 +80,14 @@ export class ApiService { return this.applicationApi.syncMarketplaceCatalog(); } - getSchema(options?: { authToken?: string }): Promise> { + getSchema(options?: { + appAccessToken?: string; + }): Promise> { return this.schemaApi.getSchema(options); } getMetadataSchema(options?: { - authToken?: string; + appAccessToken?: string; }): Promise> { return this.schemaApi.getMetadataSchema(options); } diff --git a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts index 7752faaf6d..0da089b74f 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/application-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/application-api.ts @@ -109,7 +109,8 @@ export class ApplicationApi { universalIdentifier: string; oAuthClientId: string; }; - clientSecret: string; + accessToken: string; + refreshToken: string; }> > { try { @@ -121,7 +122,8 @@ export class ApplicationApi { universalIdentifier oAuthClientId } - clientSecret + accessToken + refreshToken } } `; @@ -159,6 +161,51 @@ export class ApplicationApi { } } + async rotateApplicationRegistrationClientSecret( + id: string, + ): Promise> { + try { + const mutation = ` + mutation RotateApplicationRegistrationClientSecret($id: String!) { + rotateApplicationRegistrationClientSecret(id: $id) { + clientSecret + } + } + `; + + const response = await this.client.post( + '/metadata', + { + query: mutation, + variables: { id }, + }, + { + headers: { + 'Content-Type': 'application/json', + Accept: '*/*', + }, + }, + ); + + if (response.data.errors) { + return { + success: false, + error: response.data.errors[0], + }; + } + + return { + success: true, + data: response.data.data.rotateApplicationRegistrationClientSecret, + }; + } catch (error) { + return { + success: false, + error, + }; + } + } + async createDevelopmentApplication(input: { universalIdentifier: string; name: string; diff --git a/packages/twenty-sdk/src/cli/utilities/api/schema-api.ts b/packages/twenty-sdk/src/cli/utilities/api/schema-api.ts index 5cc0a92121..6f6097d5d8 100644 --- a/packages/twenty-sdk/src/cli/utilities/api/schema-api.ts +++ b/packages/twenty-sdk/src/cli/utilities/api/schema-api.ts @@ -6,20 +6,20 @@ export class SchemaApi { constructor(private readonly client: AxiosInstance) {} async getSchema(options?: { - authToken?: string; + appAccessToken?: string; }): Promise> { return this.introspectEndpoint('/graphql', options); } async getMetadataSchema(options?: { - authToken?: string; + appAccessToken?: string; }): Promise> { return this.introspectEndpoint('/metadata', options); } private async introspectEndpoint( endpoint: string, - options?: { authToken?: string }, + options?: { appAccessToken?: string }, ): Promise> { try { const introspectionQuery = getIntrospectionQuery(); @@ -29,8 +29,8 @@ export class SchemaApi { Accept: '*/*', }; - if (options?.authToken) { - headers.Authorization = `Bearer ${options.authToken}`; + if (options?.appAccessToken) { + headers.Authorization = `Bearer ${options.appAccessToken}`; } const response = await this.client.post( diff --git a/packages/twenty-sdk/src/cli/utilities/auth/resolve-app-access-token.ts b/packages/twenty-sdk/src/cli/utilities/auth/resolve-app-access-token.ts new file mode 100644 index 0000000000..16cd7ab1f8 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/auth/resolve-app-access-token.ts @@ -0,0 +1,62 @@ +import { type ConfigService } from '@/cli/utilities/config/config-service'; + +const EXPIRATION_MARGIN_MS = 30_000; + +const isTokenExpired = (token: string): boolean => { + try { + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64').toString(), + ); + + return payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_MS; + } catch { + return false; + } +}; + +/** + * Returns a valid appAccessToken from config, refreshing it first if expired. + */ +export const ensureValidAppAccessTokenOrRefresh = async ( + configService: ConfigService, +): Promise => { + const config = await configService.getConfig(); + + if (!config.appAccessToken) { + return undefined; + } + + if (!isTokenExpired(config.appAccessToken)) { + return config.appAccessToken; + } + + if (!config.appRefreshToken || !config.appRegistrationClientId) { + return undefined; + } + + const response = await fetch(`${config.apiUrl}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: config.appRefreshToken, + client_id: config.appRegistrationClientId, + }), + }); + + if (!response.ok) { + return undefined; + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + }; + + await configService.setConfig({ + appAccessToken: data.access_token, + ...(data.refresh_token ? { appRefreshToken: data.refresh_token } : {}), + }); + + return data.access_token; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts b/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts index c907673fe5..208c12e52a 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/file-upload-watcher.ts @@ -39,15 +39,18 @@ export class FileUploadWatcher { } async start(): Promise { - const rootPaths = this.watchPaths.map((watchPath) => - join(this.appPath, watchPath), - ); + const rootPaths = ( + await Promise.all( + this.watchPaths.map(async (watchPath) => { + const fullPath = join(this.appPath, watchPath); - for (const rootPath of rootPaths) { - const exists = await pathExists(rootPath); - if (!exists) { - return; - } + return (await pathExists(fullPath)) ? fullPath : null; + }), + ) + ).filter((p): p is string => p !== null); + + if (rootPaths.length === 0) { + return; } this.watcher = chokidar.watch(rootPaths, { diff --git a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts index eb826a0329..811df733d5 100644 --- a/packages/twenty-sdk/src/cli/utilities/client/client-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/client/client-service.ts @@ -21,12 +21,14 @@ export class ClientService { async generateCoreClient({ appPath, - authToken, + appAccessToken, }: { appPath: string; - authToken?: string; + appAccessToken?: string; }): Promise { - const coreSchemaResponse = await this.apiService.getSchema({ authToken }); + const coreSchemaResponse = await this.apiService.getSchema({ + appAccessToken, + }); if (!coreSchemaResponse.success) { throw new Error( diff --git a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts index 41a175318e..a02ab31aec 100644 --- a/packages/twenty-sdk/src/cli/utilities/config/config-service.ts +++ b/packages/twenty-sdk/src/cli/utilities/config/config-service.ts @@ -8,9 +8,16 @@ import { getConfigPath } from '@/cli/utilities/config/get-config-path'; export type RemoteConfig = { apiUrl: string; apiKey?: string; - accessToken?: string; - refreshToken?: string; - oauthClientId?: string; + // CLI OAuth app credentials (from `yarn twenty remote add`) + twentyCLIRegistrationId?: string; + twentyCLIRegistrationClientId?: string; + twentyCLIAccessToken?: string; + twentyCLIRefreshToken?: string; + // App registration credentials (from `createApplicationRegistration`) + appRegistrationId?: string; + appRegistrationClientId?: string; + appAccessToken?: string; + appRefreshToken?: string; }; type PersistedConfig = { @@ -53,7 +60,7 @@ export class ConfigService { // TODO: Remove after 2026-04-30 — migrates legacy config format // (profiles, top-level keys, applicationAccessToken/applicationRefreshToken) - // to the current format (remotes, accessToken/refreshToken) + // to the current format (remotes, twentyCLIAccessToken/twentyCLIRefreshToken) private async migrateConfigIfNeeded( raw: Record, ): Promise { @@ -78,11 +85,20 @@ export class ConfigService { ): RemoteConfig => ({ apiUrl: str(source.apiUrl) ?? '', apiKey: str(source.apiKey), - accessToken: - str(source.accessToken) ?? str(source.applicationAccessToken), - refreshToken: - str(source.refreshToken) ?? str(source.applicationRefreshToken), - oauthClientId: str(source.oauthClientId), + twentyCLIRegistrationClientId: + str(source.twentyCLIRegistrationClientId) ?? str(source.oauthClientId), + twentyCLIAccessToken: + str(source.twentyCLIAccessToken) ?? + str(source.accessToken) ?? + str(source.applicationAccessToken), + twentyCLIRefreshToken: + str(source.twentyCLIRefreshToken) ?? + str(source.refreshToken) ?? + str(source.applicationRefreshToken), + appRegistrationId: str(source.appRegistrationId), + appRegistrationClientId: str(source.appRegistrationClientId), + appAccessToken: str(source.appAccessToken), + appRefreshToken: str(source.appRefreshToken), }); const profiles = @@ -142,11 +158,17 @@ export class ConfigService { } return { - apiUrl: remoteConfig.apiUrl ?? defaultConfig.apiUrl, + apiUrl: remoteConfig.apiUrl || defaultConfig.apiUrl, apiKey: remoteConfig.apiKey, - accessToken: remoteConfig.accessToken, - refreshToken: remoteConfig.refreshToken, - oauthClientId: remoteConfig.oauthClientId, + twentyCLIRegistrationId: remoteConfig.twentyCLIRegistrationId, + twentyCLIRegistrationClientId: + remoteConfig.twentyCLIRegistrationClientId, + twentyCLIAccessToken: remoteConfig.twentyCLIAccessToken, + twentyCLIRefreshToken: remoteConfig.twentyCLIRefreshToken, + appRegistrationId: remoteConfig.appRegistrationId, + appRegistrationClientId: remoteConfig.appRegistrationClientId, + appAccessToken: remoteConfig.appAccessToken, + appRefreshToken: remoteConfig.appRefreshToken, }; } catch { return defaultConfig; @@ -163,7 +185,7 @@ export class ConfigService { raw.remotes = {}; } - const currentRemote = raw.remotes[remote] || { apiUrl: '' }; + const currentRemote = raw.remotes[remote] ?? this.getDefaultConfig(); raw.remotes[remote] = { ...currentRemote, ...config }; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts index 23c517c3b1..274a9f6383 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/generate-api-client-orchestrator-step.ts @@ -1,3 +1,4 @@ +import { ensureValidAppAccessTokenOrRefresh } from '@/cli/utilities/auth/resolve-app-access-token'; import { type ClientService } from '@/cli/utilities/client/client-service'; import { type ConfigService } from '@/cli/utilities/config/config-service'; import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; @@ -32,11 +33,13 @@ export class GenerateApiClientOrchestratorStep { this.notify(); try { - const config = await this.configService.getConfig(); + const appAccessToken = await ensureValidAppAccessTokenOrRefresh( + this.configService, + ); await this.clientService.generateCoreClient({ appPath: input.appPath, - authToken: config.accessToken, + appAccessToken, }); step.status = 'done'; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts index de1ae293c0..27a0486850 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/steps/register-app-orchestrator-step.ts @@ -1,13 +1,9 @@ import { type ApiService } from '@/cli/utilities/api/api-service'; +import { ensureValidAppAccessTokenOrRefresh } from '@/cli/utilities/auth/resolve-app-access-token'; import { type ConfigService } from '@/cli/utilities/config/config-service'; import { type OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state'; import { type Manifest } from 'twenty-shared/application'; -export type RegisterAppOrchestratorStepOutput = { - applicationRegistrationId: string | null; - clientId: string | null; -}; - export class RegisterAppOrchestratorStep { private apiService: ApiService; private configService: ConfigService; @@ -31,62 +27,42 @@ export class RegisterAppOrchestratorStep { this.notify = notify; } - async execute(input: { - manifest: Manifest; - }): Promise { - const universalIdentifier = input.manifest.application.universalIdentifier; + async execute(input: { manifest: Manifest }): Promise { + const existingToken = await ensureValidAppAccessTokenOrRefresh( + this.configService, + ); - const findResult = - await this.apiService.findApplicationRegistrationByUniversalIdentifier( - universalIdentifier, - ); - - if (!findResult.success) { + if (existingToken) { this.state.applyStepEvents([ - { - message: 'Failed to check app registration', - status: 'warning', - }, + { message: 'App registration found in config', status: 'info' }, ]); this.notify(); - return { applicationRegistrationId: null, clientId: null }; - } - - if (findResult.data) { - this.state.applyStepEvents([ - { - message: `App registration found: ${findResult.data.name}`, - status: 'info', - }, - ]); - this.notify(); - - return { - applicationRegistrationId: findResult.data.id, - clientId: findResult.data.oAuthClientId, - }; + return; } const createResult = await this.apiService.createApplicationRegistration({ name: input.manifest.application.displayName, - universalIdentifier, + universalIdentifier: input.manifest.application.universalIdentifier, }); if (!createResult.success || !createResult.data) { this.state.applyStepEvents([ - { - message: 'Failed to create app registration', - status: 'warning', - }, + { message: 'Failed to create app registration', status: 'warning' }, ]); this.notify(); - return { applicationRegistrationId: null, clientId: null }; + return; } + const { applicationRegistration, accessToken, refreshToken } = + createResult.data; + await this.configService.setConfig({ - oauthClientId: createResult.data.applicationRegistration.oAuthClientId, + appRegistrationId: applicationRegistration.id, + appRegistrationClientId: applicationRegistration.oAuthClientId, + appAccessToken: accessToken, + appRefreshToken: refreshToken, }); this.state.applyStepEvents([ @@ -95,24 +71,14 @@ export class RegisterAppOrchestratorStep { status: 'success', }, { - message: `Client ID: ${createResult.data.applicationRegistration.oAuthClientId}`, + message: `Client ID: ${applicationRegistration.oAuthClientId}`, status: 'info', }, { - message: `Client Secret: ${createResult.data.clientSecret}`, - status: 'warning', - }, - { - message: - 'Credentials saved to config. The secret will not be shown again.', - status: 'warning', + message: 'Credentials saved to config.', + status: 'info', }, ]); this.notify(); - - return { - applicationRegistrationId: createResult.data.applicationRegistration.id, - clientId: createResult.data.applicationRegistration.oAuthClientId, - }; } } diff --git a/packages/twenty-sdk/vitest.e2e.config.ts b/packages/twenty-sdk/vitest.e2e.config.ts index a60b30184d..05ac6ba339 100644 --- a/packages/twenty-sdk/vitest.e2e.config.ts +++ b/packages/twenty-sdk/vitest.e2e.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2021', TWENTY_API_KEY: process.env.TWENTY_API_KEY ?? - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc', }, setupFiles: ['src/cli/__tests__/constants/setupTest.ts'], globalSetup: undefined,