From 42647412816baaff53943050966d71fb154dccc9 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sun, 12 Apr 2026 16:29:29 +0200 Subject: [PATCH] Clear stale SDK config on uninstall and invalid client (#19608) ## Summary - After a successful `uninstall`, clear all app registration config (`appRegistrationId`, `appRegistrationClientId`, `appAccessToken`, `appRefreshToken`) so the next `dev` run doesn't hit "app not found" errors from leftover credentials - On app re-registration (`ensureAppRegistration`), clear stale `appAccessToken`/`appRefreshToken` that belonged to the previous registration - On OAuth token refresh failure, only clear config when the server returns `invalid_client` (registration was deleted), not on transient failures like network issues - Extract a shared `parse-server-error` utility for consistently parsing both GraphQL error codes (`extensions.code`/`subCode`) and OAuth error responses (`error`/`error_description`) --- .../src/cli/operations/uninstall.ts | 8 +++ ...re-app-access-token-is-valid-or-refresh.ts | 18 ++++++ .../utilities/auth/ensure-app-registration.ts | 26 ++++----- .../cli/utilities/error/parse-server-error.ts | 55 +++++++++++++++++++ 4 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-sdk/src/cli/utilities/error/parse-server-error.ts diff --git a/packages/twenty-sdk/src/cli/operations/uninstall.ts b/packages/twenty-sdk/src/cli/operations/uninstall.ts index 3237af33d9..489633fcc5 100644 --- a/packages/twenty-sdk/src/cli/operations/uninstall.ts +++ b/packages/twenty-sdk/src/cli/operations/uninstall.ts @@ -16,6 +16,7 @@ const innerAppUninstall = async ( ConfigService.setActiveRemote(options.remote); } + const configService = new ConfigService(); const apiService = new ApiService(); const manifest = await readManifestFromFile(options.appPath); @@ -48,6 +49,13 @@ const innerAppUninstall = async ( }; } + await configService.setConfig({ + appRegistrationId: undefined, + appRegistrationClientId: undefined, + appAccessToken: undefined, + appRefreshToken: undefined, + }); + return { success: true, data: undefined }; }; diff --git a/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-access-token-is-valid-or-refresh.ts b/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-access-token-is-valid-or-refresh.ts index 19f552cd4c..ece33a9a8c 100644 --- a/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-access-token-is-valid-or-refresh.ts +++ b/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-access-token-is-valid-or-refresh.ts @@ -1,4 +1,5 @@ import { type ConfigService } from '@/cli/utilities/config/config-service'; +import { isOAuthInvalidClientError } from '@/cli/utilities/error/parse-server-error'; import { exchangeCredentialsForTokens } from './exchange-credentials-for-tokens'; @@ -50,6 +51,23 @@ export const ensureAppAccessTokenIsValidOrRefresh = async ( return data.access_token; } + + try { + const body = await response.json(); + + if (isOAuthInvalidClientError(body)) { + await configService.setConfig({ + appRegistrationId: undefined, + appRegistrationClientId: undefined, + appAccessToken: undefined, + appRefreshToken: undefined, + }); + + return undefined; + } + } catch { + // Non-JSON error response (e.g. proxy 502) — fall through to credential exchange + } } if (credentials) { diff --git a/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-registration.ts b/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-registration.ts index 5f61cc9ed9..1ee53ef797 100644 --- a/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-registration.ts +++ b/packages/twenty-sdk/src/cli/utilities/auth/ensure-app-registration.ts @@ -1,19 +1,6 @@ import { type ApiService } from '@/cli/utilities/api/api-service'; import { type ConfigService } from '@/cli/utilities/config/config-service'; -import { isDefined, isPlainObject } from 'twenty-shared/utils'; - -const isAlreadyClaimedError = (error: unknown): boolean => { - if (!isPlainObject(error)) { - return false; - } - - const { extensions } = error as { extensions?: { subCode?: string } }; - - return ( - isDefined(extensions) && - extensions.subCode === 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED' - ); -}; +import { hasGraphQLErrorSubCode } from '@/cli/utilities/error/parse-server-error'; export const ensureAppRegistration = async ( apiService: ApiService, @@ -35,6 +22,8 @@ export const ensureAppRegistration = async ( await configService.setConfig({ appRegistrationId: applicationRegistration.id, appRegistrationClientId: applicationRegistration.oAuthClientId, + appAccessToken: undefined, + appRefreshToken: undefined, }); return { @@ -44,7 +33,12 @@ export const ensureAppRegistration = async ( }; } - if (!isAlreadyClaimedError(createResult.error)) { + const isAlreadyClaimed = hasGraphQLErrorSubCode( + createResult.error, + 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED', + ); + + if (!isAlreadyClaimed) { const errorDetail = createResult.error instanceof Error ? createResult.error.message @@ -70,6 +64,8 @@ export const ensureAppRegistration = async ( await configService.setConfig({ appRegistrationId: registration.id, appRegistrationClientId: registration.oAuthClientId, + appAccessToken: undefined, + appRefreshToken: undefined, }); const rotateResult = diff --git a/packages/twenty-sdk/src/cli/utilities/error/parse-server-error.ts b/packages/twenty-sdk/src/cli/utilities/error/parse-server-error.ts new file mode 100644 index 0000000000..5014dfd1b9 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/error/parse-server-error.ts @@ -0,0 +1,55 @@ +import { isPlainObject } from 'twenty-shared/utils'; + +type GraphQLErrorEntry = { + message?: string; + extensions?: { + code?: string; + subCode?: string; + }; +}; + +type OAuthErrorBody = { + error?: string; + error_description?: string; +}; + +const asGraphQLErrorEntry = (value: unknown): GraphQLErrorEntry | undefined => { + if (!isPlainObject(value)) { + return undefined; + } + + return value as GraphQLErrorEntry; +}; + +export const getGraphQLErrorCode = (error: unknown): string | undefined => { + return asGraphQLErrorEntry(error)?.extensions?.code; +}; + +export const getGraphQLErrorSubCode = (error: unknown): string | undefined => { + return asGraphQLErrorEntry(error)?.extensions?.subCode; +}; + +export const hasGraphQLErrorSubCode = ( + error: unknown, + subCode: string, +): boolean => { + return getGraphQLErrorSubCode(error) === subCode; +}; + +export const isGraphQLNotFoundError = (error: unknown): boolean => { + return getGraphQLErrorCode(error) === 'NOT_FOUND'; +}; + +export const getOAuthError = (body: unknown): string | undefined => { + if (!isPlainObject(body)) { + return undefined; + } + + const { error } = body as OAuthErrorBody; + + return typeof error === 'string' ? error : undefined; +}; + +export const isOAuthInvalidClientError = (body: unknown): boolean => { + return getOAuthError(body) === 'invalid_client'; +};