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`)
This commit is contained in:
Charles Bochet
2026-04-12 16:29:29 +02:00
committed by GitHub
parent 6e259d3ded
commit 4264741281
4 changed files with 92 additions and 15 deletions
@@ -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 };
};
@@ -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) {
@@ -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 =
@@ -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';
};