9001078cb2
## I have read the CONTRIBUTING.md file. YES ## What kind of change does this PR introduce? Fix (CLI) — `twenty deploy` now detects an expired/invalid API key on the active remote and offers an interactive re-auth flow (TTY only). In non-TTY contexts the behavior is unchanged: a clear error and a non-zero exit. Fixes #20197 ## What is the current behavior? After a workspace DB reset, key revocation, or workspace deletion, both `twenty deploy` and (effectively) `twenty dev` fail with: ``` Upload failed: Token has expired. ``` The message is technically correct but gives the user no way forward. They have to know to mint a new key from **Settings → Developers** and re-run `twenty remote add --local --api-key <NEW_KEY>`. This came up while testing PR #20181 and is the same friction on any DB reset, key revocation, or workspace deletion. ## What is the new behavior? Two changes, layered: ### (1) Better error message + remediation hint When the upload returns a 401 or its message matches a token-expired pattern (`/token has expired|unauthori[sz]ed|invalid api key/i`), `appDeploy` now prints: ``` Your API key for remote "local" is no longer valid (the workspace may have been reset, or the key was revoked). Re-authenticate with: twenty remote:add --as local --api-key <NEW_KEY> Generate a new key at: <SERVER_URL>/settings/developers ``` ### (2) Interactive re-auth prompt (TTY only) If the process is attached to a TTY, after the hint is printed the user is prompted: ``` Re-authenticate now? (Y/n) ``` - **Yes** → re-validate the token (it may have been refreshed externally), and if still invalid, instruct the user to re-run `remote:add`. The original `appDeploy` is then retried once. - **No** → the original `DEPLOY_FAILED` error is surfaced (same code, better message). - **Non-TTY (CI, scripts, redirects)** → the prompt is suppressed entirely. The user gets the hint and a non-zero exit, preserving scriptable behavior. **No change** to existing CI scripts. ## Acceptance criteria | Scenario | Before | After | |---|---|---| | Happy path deploy | ✅ works | ✅ works (no change) | | Deploy with expired key (TTY) | generic error, exit 1 | hint + prompt, retry on Y, error on N | | Deploy with expired key (CI / no-TTY) | generic error, exit 1 | hint + exit 1 (no prompt, scriptable) | | Deploy with unrelated error (e.g. 500) | generic error, exit 1 | unchanged (no false positive on the matcher) | ## Reproduction 1. Spin up Twenty, mint an API key, run `twenty deploy` — confirm the happy path. 2. Reset the DB (`core.appToken` cleared) and re-run `twenty deploy` — confirm the new hint + prompt fire and the retry succeeds. 3. Repeat step 2 in a non-TTY context (e.g. `twenty deploy < /dev/null` or via `script -qc ''`) — confirm the prompt is suppressed and the scriptable exit-1 behavior is preserved. ## Implementation notes - **`FileApi.uploadAppTarball`** now tags 401 responses with an `isAuthError: true` flag on the failing `ApiResponse`. The existing `error` string is still populated so callers that don't check the flag continue to work — **additive, no breaking change**. - **`FailingApiResponse<TError>`** gained an optional `isAuthError?: boolean` field. The other `ApiResponse` call sites in the SDK don't need to set it. - **`@/cli/utilities/auth/reauth-helper.ts`** is new. It owns: - `isTokenExpiredMessage(...)` — pure matcher, easy to unit-test, used as a backstop if a non-401 message still says "expired" (GraphQL returns 200 with errors in some cases). - `promptForReauthentication(remoteName)` — TTY-gated `inquirer.confirm` prompt that re-validates the token and either returns `'reauthenticated'`, `'declined'`, or `'non-interactive'`. - **`@/cli/operations/deploy.ts`** is the single call site that wires the helper. The helper is structured so it can be reused from the dev orchestrator's upload step (a follow-up) without changes. - **New unit test** at `__tests__/reauth-helper.test.ts` covers the matcher: positive cases, negative cases, case-insensitivity, and nullish input. ## Out of scope (per the issue) - Long-lived dev tokens for `--local` remotes. - Web-based OAuth login flow for the CLI (the existing `authenticate(...)` flow in `remote.ts` is fine; the prompt here just tells the user to re-run it). ## Files changed ``` packages/twenty-sdk/src/cli/operations/deploy.ts | 33 ++++++++ packages/twenty-sdk/src/cli/utilities/api/api-response-type.ts | 1 + packages/twenty-sdk/src/cli/utilities/api/file-api.ts | 8 +++ packages/twenty-sdk/src/cli/utilities/auth/__tests__/reauth-helper.test.ts | 34 ++++++++++ packages/twenty-sdk/src/cli/utilities/auth/reauth-helper.ts | 61 ++++++++++++++++++ 5 files changed, 137 insertions(+) ``` Happy to address feedback and split this into two PRs (hint-only first, prompt-on-top) if the maintainers prefer a smaller first cut. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: martmull <martmull@hotmail.fr>
319 lines
8.2 KiB
TypeScript
319 lines
8.2 KiB
TypeScript
import { isNonEmptyString } from '@sniptt/guards';
|
|
import axios, {
|
|
type AxiosInstance,
|
|
type AxiosResponse,
|
|
type InternalAxiosRequestConfig,
|
|
} from 'axios';
|
|
import chalk from 'chalk';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { promptForReauthentication } from '@/cli/utilities/auth/reauth-helper';
|
|
import { ConfigService } from '@/cli/utilities/config/config-service';
|
|
|
|
export class ApiClient {
|
|
readonly client: AxiosInstance;
|
|
readonly configService: ConfigService;
|
|
private readonly tokenOverride?: string;
|
|
readonly serverUrlOverride?: string;
|
|
private reauthAttempted: boolean = false;
|
|
|
|
constructor(options?: {
|
|
disableInterceptors?: boolean;
|
|
serverUrl?: string;
|
|
token?: string;
|
|
skipAuth?: boolean;
|
|
}) {
|
|
const {
|
|
disableInterceptors = false,
|
|
serverUrl,
|
|
token,
|
|
skipAuth = false,
|
|
} = options || {};
|
|
this.configService = new ConfigService();
|
|
this.tokenOverride = token;
|
|
this.serverUrlOverride = serverUrl;
|
|
this.client = axios.create();
|
|
|
|
this.client.interceptors.request.use(async (config) => {
|
|
const twentyConfig = await this.configService.getConfig();
|
|
|
|
config.baseURL = this.serverUrlOverride ?? twentyConfig.apiUrl;
|
|
|
|
if (!config.headers.Authorization && !skipAuth) {
|
|
const authToken = await this.resolveAuthToken();
|
|
|
|
if (authToken) {
|
|
config.headers.Authorization = `Bearer ${authToken}`;
|
|
}
|
|
}
|
|
|
|
return config;
|
|
});
|
|
|
|
if (disableInterceptors) {
|
|
return;
|
|
}
|
|
|
|
this.client.interceptors.response.use(
|
|
async (response) => {
|
|
// Handle auth errors returned as GraphQL errors in HTTP 200 responses
|
|
if (
|
|
response.status === 200 &&
|
|
response.data?.errors &&
|
|
Array.isArray(response.data.errors)
|
|
) {
|
|
const hasAuthError = response.data.errors.some(
|
|
(error: { message?: string; extensions?: { code?: string } }) =>
|
|
error.extensions?.code === 'UNAUTHENTICATED' ||
|
|
error.extensions?.code === 'FORBIDDEN' ||
|
|
(typeof error.message === 'string' &&
|
|
error.message.toLowerCase().includes('unauthenticated')),
|
|
);
|
|
|
|
if (hasAuthError) {
|
|
if (!this.reauthAttempted) {
|
|
const retried = await this.tryReauthenticateAndRetry(
|
|
response.config,
|
|
);
|
|
|
|
if (retried) {
|
|
return retried;
|
|
}
|
|
}
|
|
|
|
const authError = new Error(
|
|
'Authentication failed: GraphQL auth error in response',
|
|
) as Error & { response: typeof response };
|
|
authError.response = response;
|
|
throw authError;
|
|
}
|
|
}
|
|
|
|
return response;
|
|
},
|
|
async (error) => {
|
|
if (error.response?.status === 401 && error.config) {
|
|
if (!this.reauthAttempted) {
|
|
const retried = await this.tryReauthenticateAndRetry(error.config);
|
|
|
|
if (retried) {
|
|
return retried;
|
|
}
|
|
}
|
|
} else if (error.response?.status === 403) {
|
|
console.error(
|
|
chalk.red(
|
|
'Access denied. Check your API key and workspace permissions.',
|
|
),
|
|
);
|
|
} else if (error.code === 'ECONNREFUSED') {
|
|
console.error(
|
|
chalk.red('Cannot connect to Twenty server. Is it running?'),
|
|
);
|
|
}
|
|
throw error;
|
|
},
|
|
);
|
|
}
|
|
|
|
private async tryReauthenticateAndRetry(
|
|
config: InternalAxiosRequestConfig,
|
|
): Promise<AxiosResponse | null> {
|
|
// Prevent recursion: only attempt reauth once per client instance
|
|
this.reauthAttempted = true;
|
|
|
|
const remoteName = ConfigService.getActiveRemote();
|
|
console.error(`Authentication failed on remote "${remoteName}"`);
|
|
|
|
const outcome = await promptForReauthentication(remoteName);
|
|
|
|
if (outcome === 'reauthenticated') {
|
|
const authToken = await this.resolveAuthToken();
|
|
|
|
if (authToken) {
|
|
config.headers.Authorization = `Bearer ${authToken}`;
|
|
|
|
return this.client.request(config);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async getFrontendUrl(): Promise<string | null> {
|
|
try {
|
|
const response = await this.client.get(
|
|
'/.well-known/oauth-authorization-server',
|
|
{ headers: { Accept: 'application/json' } },
|
|
);
|
|
const authorizationEndpoint = response.data?.authorization_endpoint;
|
|
|
|
if (!isNonEmptyString(authorizationEndpoint)) {
|
|
return null;
|
|
}
|
|
|
|
return new URL(authorizationEndpoint).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getWorkspaceFrontendUrl(): Promise<string | null> {
|
|
const workspaceFrontendUrl = await this.getCurrentWorkspaceFrontendUrl();
|
|
|
|
if (isDefined(workspaceFrontendUrl)) {
|
|
return workspaceFrontendUrl;
|
|
}
|
|
|
|
return this.getFrontendUrl();
|
|
}
|
|
|
|
private async getCurrentWorkspaceFrontendUrl(): Promise<string | null> {
|
|
try {
|
|
const query = `
|
|
query CurrentWorkspaceForFrontendUrl {
|
|
currentWorkspace {
|
|
workspaceUrls {
|
|
subdomainUrl
|
|
customUrl
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const response = await this.client.post(
|
|
'/metadata',
|
|
{ query },
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: '*/*',
|
|
},
|
|
},
|
|
);
|
|
|
|
const workspaceUrls =
|
|
response.data?.data?.currentWorkspace?.workspaceUrls;
|
|
const workspaceFrontendUrl = isNonEmptyString(workspaceUrls?.customUrl)
|
|
? workspaceUrls.customUrl
|
|
: workspaceUrls?.subdomainUrl;
|
|
|
|
if (!isNonEmptyString(workspaceFrontendUrl)) {
|
|
return null;
|
|
}
|
|
|
|
return new URL(workspaceFrontendUrl).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async validateAuth(): Promise<{ authValid: boolean; serverUp: boolean }> {
|
|
try {
|
|
const query = `
|
|
query CurrentWorkspace {
|
|
currentWorkspace {
|
|
id
|
|
}
|
|
}
|
|
`;
|
|
|
|
const response = await this.client.post(
|
|
'/metadata',
|
|
{
|
|
query,
|
|
},
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: '*/*',
|
|
},
|
|
},
|
|
);
|
|
|
|
return {
|
|
authValid: response.status === 200 && !response.data.errors,
|
|
serverUp: response.status === 200,
|
|
};
|
|
} catch (error) {
|
|
if (axios.isAxiosError(error) && error.response) {
|
|
return {
|
|
authValid: false,
|
|
serverUp: true,
|
|
};
|
|
}
|
|
|
|
return {
|
|
authValid: false,
|
|
serverUp: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
async refreshToken(): Promise<string | null> {
|
|
const config = await this.configService.getConfig();
|
|
|
|
if (
|
|
!config.twentyCLIRefreshToken ||
|
|
!config.twentyCLIRegistrationClientId
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const tokenResponse = await axios.post(`${config.apiUrl}/oauth/token`, {
|
|
grant_type: 'refresh_token',
|
|
refresh_token: config.twentyCLIRefreshToken,
|
|
client_id: config.twentyCLIRegistrationClientId,
|
|
});
|
|
|
|
const { access_token: newAccessToken, refresh_token: newRefreshToken } =
|
|
tokenResponse.data;
|
|
|
|
await this.configService.setConfig({
|
|
twentyCLIAccessToken: newAccessToken,
|
|
...(newRefreshToken ? { twentyCLIRefreshToken: newRefreshToken } : {}),
|
|
});
|
|
|
|
return newAccessToken;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async resolveAuthToken(): Promise<string | undefined> {
|
|
if (this.tokenOverride) {
|
|
return this.tokenOverride;
|
|
}
|
|
|
|
const config = await this.configService.getConfig();
|
|
const cliToken = config.twentyCLIAccessToken;
|
|
|
|
if (cliToken && this.isTokenExpired(cliToken)) {
|
|
const refreshed = await this.refreshToken();
|
|
|
|
if (refreshed) {
|
|
return refreshed;
|
|
}
|
|
}
|
|
|
|
return cliToken ?? config.apiKey;
|
|
}
|
|
|
|
private isTokenExpired(token: string): boolean {
|
|
try {
|
|
const payload = JSON.parse(
|
|
Buffer.from(token.split('.')[1], 'base64').toString(),
|
|
);
|
|
|
|
const EXPIRATION_MARGIN_IN_SECONDS = 30;
|
|
|
|
return (
|
|
payload.exp * 1_000 < Date.now() + EXPIRATION_MARGIN_IN_SECONDS * 1_000
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|