diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 5b6fb33af..af9d4e58c 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -9,6 +9,12 @@ import { isCloudflareAccessChallenge, } from './cf-access.ts'; import type { CliLogger } from './log-file.ts'; +import { + formatPlatformApiError, + parsePlatformErrorBody, + platformErrorMessage, + type ProjectPermission, +} from './platform-errors.ts'; import { normalizePlatformApiUrl } from './platform-registry.ts'; import type { DeployAssetUploadRequest, DeployEvent, DeployUploadPayload } from './types.ts'; @@ -37,25 +43,8 @@ export function readCliVersion(): string | null { const VOID_CLI_VERSION = readCliVersion(); -export type PlatformErrorPayload = { - error?: string; - message?: string; - deployment?: string; - minVersion?: string; - clientVersion?: string; -}; - -export function parsePlatformErrorBody(body: string): PlatformErrorPayload | null { - try { - const parsed = JSON.parse(body); - if (!parsed || typeof parsed !== 'object') { - return null; - } - return parsed as PlatformErrorPayload; - } catch { - return null; - } -} +export { parsePlatformErrorBody } from './platform-errors.ts'; +export type { PlatformErrorPayload } from './platform-errors.ts'; /** * Detects the `cli_outdated` 426 response shape emitted by the platform's @@ -79,16 +68,9 @@ export class PlatformApiError extends Error { readonly body: string; readonly code?: string; - constructor(action: string, status: number, body: string) { + constructor(action: string, status: number, body: string, permission?: ProjectPermission) { const parsed = parsePlatformErrorBody(body); - // cli_outdated: surface the server-supplied upgrade message verbatim - // instead of the raw `: ` default. The message - // already includes the `npm i -g void@latest` hint. - const message = - status === 426 && parsed?.error === 'cli_outdated' && parsed.message - ? parsed.message - : `${action}: ${status} ${body}`; - super(message); + super(formatPlatformApiError(action, status, body, permission)); this.name = 'PlatformApiError'; this.status = status; this.body = body; @@ -96,8 +78,17 @@ export class PlatformApiError extends Error { } } -async function throwPlatformApiError(action: string, res: Response): Promise { - throw new PlatformApiError(action, res.status, await res.text()); +async function throwPlatformApiError( + action: string, + res: Response, + permission?: ProjectPermission, +): Promise { + throw new PlatformApiError( + action, + res.status, + await res.text(), + isCloudflareAccessChallenge(res) ? undefined : permission, + ); } export function isExpiredTokenError(error: unknown): boolean { @@ -107,6 +98,14 @@ export function isExpiredTokenError(error: unknown): boolean { return false; } +function isPreflightRejection(error: unknown): boolean { + return ( + error instanceof PlatformApiError && + (([401, 403].includes(error.status) && !!parsePlatformErrorBody(error.body)?.error) || + isCliOutdatedError(error)) + ); +} + export type ProjectResponse = { url?: string; id: string; @@ -621,7 +620,7 @@ export class PlatformClient { headers: { ...this.authHeaders }, }); if (!res.ok) { - await throwPlatformApiError('Failed to list project tokens', res); + await throwPlatformApiError('Failed to list project tokens', res, 'owner'); } return res.json(); } @@ -636,7 +635,7 @@ export class PlatformClient { body: JSON.stringify(input), }); if (!res.ok) { - await throwPlatformApiError('Failed to create project token', res); + await throwPlatformApiError('Failed to create project token', res, 'owner'); } return res.json(); } @@ -646,7 +645,7 @@ export class PlatformClient { headers: { ...this.authHeaders }, }); if (!res.ok) { - await throwPlatformApiError('Failed to list project members', res); + await throwPlatformApiError('Failed to list project members', res, 'read'); } const body = (await res.json()) as { members: Array }; return body.members; @@ -657,7 +656,7 @@ export class PlatformClient { headers: { ...this.authHeaders }, }); if (!res.ok) { - await throwPlatformApiError('Failed to list project invitations', res); + await throwPlatformApiError('Failed to list project invitations', res, 'members'); } const body = (await res.json()) as { invitations: Array }; return body.invitations; @@ -678,7 +677,7 @@ export class PlatformClient { body: JSON.stringify({ email, role }), }); if (!res.ok) { - await throwPlatformApiError('Failed to invite project member', res); + await throwPlatformApiError('Failed to invite project member', res, 'members'); } return res.json(); } @@ -697,7 +696,7 @@ export class PlatformClient { }, ); if (!res.ok) { - await throwPlatformApiError('Failed to renew project token', res); + await throwPlatformApiError('Failed to renew project token', res, 'owner'); } return res.json(); } @@ -716,7 +715,7 @@ export class PlatformClient { }, ); if (!res.ok) { - await throwPlatformApiError('Failed to change project member role', res); + await throwPlatformApiError('Failed to change project member role', res, 'members'); } return res.json(); } @@ -727,7 +726,7 @@ export class PlatformClient { { method: 'DELETE', headers: { ...this.authHeaders } }, ); if (!res.ok) { - await throwPlatformApiError('Failed to revoke project token', res); + await throwPlatformApiError('Failed to revoke project token', res, 'owner'); } return res.json(); } @@ -738,7 +737,7 @@ export class PlatformClient { { method: 'DELETE', headers: { ...this.authHeaders } }, ); if (!res.ok) { - await throwPlatformApiError('Failed to remove project member', res); + await throwPlatformApiError('Failed to remove project member', res, 'members'); } } @@ -748,7 +747,7 @@ export class PlatformClient { { method: 'DELETE', headers: { ...this.authHeaders } }, ); if (!res.ok) { - await throwPlatformApiError('Failed to revoke project invitation', res); + await throwPlatformApiError('Failed to revoke project invitation', res, 'members'); } } @@ -794,7 +793,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to list deployments', res); + await throwPlatformApiError('Failed to list deployments', res, 'read'); } return res.json(); @@ -826,7 +825,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Rollback failed', res); + await throwPlatformApiError('Rollback failed', res, 'deploy'); } return res.json(); @@ -847,7 +846,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Cancel deployment failed', res); + await throwPlatformApiError('Cancel deployment failed', res, 'deploy'); } return res.json(); @@ -887,8 +886,13 @@ export class PlatformClient { }); if (!res.ok) { - const error = new PlatformApiError('Preflight failed', res.status, await res.text()); - if (isExpiredTokenError(error)) { + const error = new PlatformApiError( + 'Preflight failed', + res.status, + await res.text(), + isCloudflareAccessChallenge(res) ? undefined : 'deploy', + ); + if (isPreflightRejection(error)) { throw error; } // Preflight not supported or error — fall back to uploading everything @@ -897,7 +901,7 @@ export class PlatformClient { return res.json(); } catch (error) { - if (isExpiredTokenError(error)) { + if (isPreflightRejection(error)) { throw error; } // Network error — fall back to uploading everything @@ -936,7 +940,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to request upload URLs', res); + await throwPlatformApiError('Failed to request upload URLs', res, 'deploy'); } const json = (await res.json()) as { @@ -1022,15 +1026,7 @@ export class PlatformClient { // in flight (possibly the earlier attempt of this same deploy, // or a concurrent one). Surface it as a structured error event // naming the deployment rather than a bare HTTP failure. - let parsed: { error?: string; deployment?: string } | null = null; - try { - const raw: unknown = JSON.parse(body); - if (raw !== null && typeof raw === 'object') { - parsed = raw as { error?: string; deployment?: string }; - } - } catch { - // Non-JSON 409 — fall through to the generic error below. - } + const parsed = parsePlatformErrorBody(body); if (parsed?.deployment) { cliLog?.warn('deploy_conflict', { url, @@ -1041,14 +1037,19 @@ export class PlatformClient { event: 'error', step: 'finalize', message: - `${parsed.error ?? 'deploy in progress'}: deployment ${parsed.deployment} — ` + + `${platformErrorMessage(parsed) ?? platformErrorMessage({ message: parsed.error }) ?? 'deploy in progress'}: deployment ${parsed.deployment} — ` + 'it may be an earlier attempt of this deploy or a concurrent one; ' + 'inspect with `void project status` before redeploying', }; return; } } - throw new PlatformApiError('Deploy failed', res.status, body); + throw new PlatformApiError( + 'Deploy failed', + res.status, + body, + isCloudflareAccessChallenge(res) ? undefined : 'deploy', + ); } const reader = res.body!.getReader(); @@ -1113,7 +1114,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to list secrets', res); + await throwPlatformApiError('Failed to list secrets', res, 'configure'); } return res.json(); @@ -1133,7 +1134,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Failed to set secret', res); + await throwPlatformApiError('Failed to set secret', res, 'configure'); } return res.json(); @@ -1148,7 +1149,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to delete project', res); + await throwPlatformApiError('Failed to delete project', res, 'owner'); } return res.json(); @@ -1169,7 +1170,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to get migration status', res); + await throwPlatformApiError('Failed to get migration status', res, 'read'); } return res.json(); @@ -1197,7 +1198,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to apply remote migrations', res); + await throwPlatformApiError('Failed to apply remote migrations', res, 'configure'); } return res.json(); @@ -1217,7 +1218,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to rename remote migrations', res); + await throwPlatformApiError('Failed to rename remote migrations', res, 'configure'); } return res.json(); @@ -1234,7 +1235,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to add domain', res); + await throwPlatformApiError('Failed to add domain', res, 'settings'); } return res.json(); @@ -1248,7 +1249,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to list domains', res); + await throwPlatformApiError('Failed to list domains', res, 'read'); } return res.json(); @@ -1262,7 +1263,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to get domain status', res); + await throwPlatformApiError('Failed to get domain status', res, 'read'); } return res.json(); @@ -1277,7 +1278,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to remove domain', res); + await throwPlatformApiError('Failed to remove domain', res, 'settings'); } return res.json(); @@ -1295,7 +1296,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Failed to delete secret', res); + await throwPlatformApiError('Failed to delete secret', res, 'configure'); } return res.json(); @@ -1309,7 +1310,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to get resources', res); + await throwPlatformApiError('Failed to get resources', res, 'read'); } return res.json(); @@ -1324,7 +1325,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to create prerender token', res); + await throwPlatformApiError('Failed to create prerender token', res, 'deploy'); } const body = (await res.json()) as { token: string }; @@ -1342,7 +1343,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to provision database', res); + await throwPlatformApiError('Failed to provision database', res, 'configure'); } return res.json(); @@ -1359,7 +1360,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to update database URL', res); + await throwPlatformApiError('Failed to update database URL', res, 'configure'); } return res.json(); @@ -1373,7 +1374,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to fetch database URL', res); + await throwPlatformApiError('Failed to fetch database URL', res, 'owner'); } return res.json(); @@ -1390,7 +1391,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Cache purge failed', res); + await throwPlatformApiError('Cache purge failed', res, 'deploy'); } return res.json(); @@ -1421,7 +1422,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to fetch logs', res); + await throwPlatformApiError('Failed to fetch logs', res, 'read'); } const json = (await res.json()) as { data: Array }; @@ -1449,7 +1450,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to fetch requests', res); + await throwPlatformApiError('Failed to fetch requests', res, 'read'); } const json = (await res.json()) as { data: Array }; @@ -1465,7 +1466,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to list builds', res); + await throwPlatformApiError('Failed to list builds', res, 'read'); } const json = (await res.json()) as { builds: Array }; @@ -1484,7 +1485,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Failed to fetch build logs', res); + await throwPlatformApiError('Failed to fetch build logs', res, 'read'); } return res.json(); @@ -1540,7 +1541,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to connect GitHub repository', res); + await throwPlatformApiError('Failed to connect GitHub repository', res, 'settings'); } return res.json(); @@ -1587,7 +1588,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to get GitHub connection', res); + await throwPlatformApiError('Failed to get GitHub connection', res, 'read'); } return res.json(); @@ -1611,7 +1612,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to update GitHub connection', res); + await throwPlatformApiError('Failed to update GitHub connection', res, 'settings'); } return res.json(); @@ -1626,7 +1627,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to disconnect GitHub repository', res); + await throwPlatformApiError('Failed to disconnect GitHub repository', res, 'settings'); } return res.json(); @@ -1718,7 +1719,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to fetch email usage', res); + await throwPlatformApiError('Failed to fetch email usage', res, 'read'); } return res.json(); @@ -1737,7 +1738,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to fetch email logs', res); + await throwPlatformApiError('Failed to fetch email logs', res, 'read'); } return res.json(); @@ -1749,7 +1750,7 @@ export class PlatformClient { { headers: { ...this.authHeaders } }, ); if (!res.ok) { - await throwPlatformApiError('Failed to fetch email operation', res); + await throwPlatformApiError('Failed to fetch email operation', res, 'read'); } return res.json(); } @@ -1762,7 +1763,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to list email destinations', res); + await throwPlatformApiError('Failed to list email destinations', res, 'read'); } return res.json(); @@ -1782,7 +1783,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to add email destination', res); + await throwPlatformApiError('Failed to add email destination', res, 'settings'); } // 201 registered the address just now; 200 found it already on the @@ -1805,7 +1806,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Failed to remove email destination', res); + await throwPlatformApiError('Failed to remove email destination', res, 'settings'); } return res.json(); @@ -1820,7 +1821,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to read email settings', res); + await throwPlatformApiError('Failed to read email settings', res, 'read'); } return res.json(); @@ -1834,7 +1835,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to list email domains', res); + await throwPlatformApiError('Failed to list email domains', res, 'read'); } return res.json(); @@ -1859,7 +1860,7 @@ export class PlatformClient { }); if (!res.ok) { - await throwPlatformApiError('Failed to add email domain', res); + await throwPlatformApiError('Failed to add email domain', res, 'settings'); } return res.json(); @@ -1876,7 +1877,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Failed to fetch email domain status', res); + await throwPlatformApiError('Failed to fetch email domain status', res, 'read'); } return res.json(); @@ -1897,7 +1898,7 @@ export class PlatformClient { }, ); if (!res.ok) { - await throwPlatformApiError('Failed to rotate email connection secret', res); + await throwPlatformApiError('Failed to rotate email connection secret', res, 'settings'); } return res.json(); } @@ -1914,7 +1915,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Failed to sync email domain', res); + await throwPlatformApiError('Failed to sync email domain', res, 'settings'); } return res.json(); @@ -1932,7 +1933,7 @@ export class PlatformClient { ); if (!res.ok) { - await throwPlatformApiError('Failed to remove email domain', res); + await throwPlatformApiError('Failed to remove email domain', res, 'settings'); } return res.json(); diff --git a/packages/void/src/cli/create-project.ts b/packages/void/src/cli/create-project.ts index 14f3c0d5f..28436dd2f 100644 --- a/packages/void/src/cli/create-project.ts +++ b/packages/void/src/cli/create-project.ts @@ -32,8 +32,7 @@ export async function createProjectWithSlugRetry( return project; } catch (error) { if (error instanceof PlatformApiError && isRetryableCreateError(error)) { - const reason = error.code || error.body || `HTTP ${error.status}`; - log.error(`project: Could not create project '${slug}': ${reason}.`); + log.error(`project: ${error.message}`); defaultSlug = slug; continue; } diff --git a/packages/void/src/cli/deploy-errors.ts b/packages/void/src/cli/deploy-errors.ts index 1e69c8dd2..b3717ed3d 100644 --- a/packages/void/src/cli/deploy-errors.ts +++ b/packages/void/src/cli/deploy-errors.ts @@ -1,4 +1,5 @@ import { isCliOutdatedError, parsePlatformErrorBody, PlatformApiError } from './client.ts'; +import { platformErrorMessage } from './platform-errors.ts'; const BODY_SNIPPET_MAX = 200; @@ -58,8 +59,8 @@ export function formatConnectionLostMessage( * Three cases: * 1. `PlatformApiError` with 409 + a JSON body carrying `{error, deployment}` * — surface the server-supplied error and prefer the body's deployment id. - * 2. Any other `PlatformApiError` — surface as `Deploy failed (HTTP ): ` - * where detail is the parsed `error`/`message` field or a trimmed body snippet. + * 2. Any other `PlatformApiError` — retain the client's actionable message and + * structured error fields; opaque provider failures use a trimmed body snippet. * 3. Anything else — preserve the original message under a `Connection lost * during deploy: ` prefix so the caller can still tell network, * parse, and transport errors apart. @@ -71,7 +72,7 @@ export function formatStreamDeployError( ): Error { if (err instanceof PlatformApiError) { const parsed = parsePlatformErrorBody(err.body); - const parsedError = parsed?.error ?? parsed?.message ?? null; + const parsedError = platformErrorMessage(parsed) ?? parsed?.error ?? null; const parsedDeployment = parsed?.deployment ?? null; const effectiveDeploymentId = parsedDeployment ?? deploymentId; @@ -80,24 +81,21 @@ export function formatStreamDeployError( // `npm i -g void@latest`, so adding a status prefix would only obscure // the actionable line. if (isCliOutdatedError(err) && parsed?.message) { - return new Error(appendTrailingLines(parsed.message, effectiveDeploymentId, logPath)); + err.message = appendTrailingLines(err.message, effectiveDeploymentId, logPath); + return err; } - if (err.status === 409 && parsedError) { - return new Error( - appendTrailingLines( - `deploy: Deploy failed: ${parsedError}`, - effectiveDeploymentId, - logPath, - ), - ); + if (parsedError || err.status === 401 || err.status === 403 || parsed !== null) { + err.message = appendTrailingLines(`deploy: ${err.message}`, effectiveDeploymentId, logPath); + return err; } const detail = parsedError ?? trimSnippet(err.body); const base = detail ? `deploy: Deploy failed (HTTP ${err.status}): ${detail}` : `deploy: Deploy failed (HTTP ${err.status}).`; - return new Error(appendTrailingLines(base, effectiveDeploymentId, logPath)); + err.message = appendTrailingLines(base, effectiveDeploymentId, logPath); + return err; } const original = err instanceof Error ? err.message : String(err); diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index e584b460e..3b4689209 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -17,6 +17,7 @@ import { import { collectGitDeployMetadata } from './git-metadata.ts'; import type { GithubArgs } from './github-args.ts'; import { pc } from './output.ts'; +import { platformErrorMessage } from './platform-errors.ts'; import { readProjectConfig } from './project.ts'; import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; @@ -62,9 +63,6 @@ export function describeConnectError(status: number, errorCode: string | undefin if (status === 400 && code.includes('not accessible')) { return 'Repository not accessible by this installation. Check the App has access to it.'; } - if (status === 403) { - return "You don't have access to this project."; - } if (status === 404) { return 'Project or installation not found.'; } @@ -86,6 +84,19 @@ export function describeUpdateError(status: number, errorCode: string | undefine return describeConnectError(status, code); } +function describeGithubClientError( + error: PlatformApiError, + describe: typeof describeConnectError, +): string | null { + const body = parsePlatformErrorBody(error.body); + // The client knows whether this was a project read, settings change, or an + // account-level GitHub operation. A 403 alone does not identify the cause. + if (platformErrorMessage(body) || error.status === 403) { + return error.message; + } + return describe(error.status, error.code); +} + /** * Extract `owner/repo` from a `https://github.com/owner/repo(.git)?` URL. * Returns null for non-GitHub URLs or null input. @@ -787,8 +798,7 @@ export async function runConnect( } // Organization participant: prove per-repo access via an authorize round-trip, // then retry the connect EXACTLY ONCE with the resulting grant. This MUST - // come before the generic `describeConnectError` branch below, which maps - // ANY 403 to "no project access" and would otherwise swallow this signal. + // come before the generic error display so users can authorize the repository. const grant = await runGithubConnectAuthorize( client, installation.installation_id, @@ -800,8 +810,12 @@ export async function runConnect( codeVerifier: grant.codeVerifier, }); } catch (retryErr) { - if (retryErr instanceof PlatformApiError && retryErr.status === 403) { - // A fresh grant should be accepted; a 403 here means it was rejected + if ( + retryErr instanceof PlatformApiError && + retryErr.status === 403 && + retryErr.code === 'invalid connect grant' + ) { + // A fresh grant should be accepted; this code means it was rejected // (expired, or the browser identity differs from the linked one). Do // NOT fall through to the generic 403 "no project access" message. log.error( @@ -812,9 +826,7 @@ export async function runConnect( throw retryErr; } } else { - const body = parsePlatformErrorBody(err.body); - const code = body?.error ?? body?.message ?? ''; - const message = describeConnectError(err.status, code); + const message = describeGithubClientError(err, describeConnectError); if (message !== null) { log.error(`connect: ${message}`); process.exit(1); @@ -918,9 +930,7 @@ export async function runGithubUpdate( current = await client.getGithubConnection(projectId); } catch (err) { if (err instanceof PlatformApiError) { - const body = parsePlatformErrorBody(err.body); - const code = body?.error ?? body?.message ?? ''; - const message = describeUpdateError(err.status, code); + const message = describeGithubClientError(err, describeUpdateError); if (message !== null) { log.error(`update: ${message}`); process.exit(1); @@ -1045,9 +1055,7 @@ export async function runGithubUpdate( updated = await client.updateGithubConnection(projectId, patch); } catch (err) { if (err instanceof PlatformApiError) { - const body = parsePlatformErrorBody(err.body); - const code = body?.error ?? body?.message ?? ''; - const message = describeUpdateError(err.status, code); + const message = describeGithubClientError(err, describeUpdateError); if (message !== null) { log.error(`update: ${message}`); process.exit(1); @@ -1142,9 +1150,7 @@ export async function runStatus( connection = await client.getGithubConnection(projectId); } catch (err) { if (err instanceof PlatformApiError) { - const body = parsePlatformErrorBody(err.body); - const code = body?.error ?? body?.message ?? ''; - const message = describeUpdateError(err.status, code); + const message = describeGithubClientError(err, describeUpdateError); if (message !== null) { log.error(`status: ${message}`); process.exit(1); @@ -1234,7 +1240,7 @@ export async function runGithubDisconnect( log.info('This project is not connected to a GitHub repository. Nothing to disconnect.'); return; } - const message = describeConnectError(err.status, code); + const message = describeGithubClientError(err, describeConnectError); if (message !== null) { log.error(`disconnect: ${message}`); process.exit(1); @@ -1276,9 +1282,7 @@ export async function runGithubDisconnect( await client.deleteGithubConnection(projectId); } catch (err) { if (err instanceof PlatformApiError) { - const body = parsePlatformErrorBody(err.body); - const code = body?.error ?? body?.message ?? ''; - const message = describeConnectError(err.status, code); + const message = describeGithubClientError(err, describeConnectError); if (message !== null) { log.error(`disconnect: ${message}`); process.exit(1); diff --git a/packages/void/src/cli/platform-errors.ts b/packages/void/src/cli/platform-errors.ts new file mode 100644 index 000000000..deb7b3b67 --- /dev/null +++ b/packages/void/src/cli/platform-errors.ts @@ -0,0 +1,156 @@ +import { stripVTControlCharacters } from 'node:util'; + +export type PlatformErrorPayload = { + error?: string; + message?: string; + deployment?: string; + minVersion?: string; + clientVersion?: string; +}; + +export type ProjectPermission = 'read' | 'configure' | 'deploy' | 'settings' | 'members' | 'owner'; + +export function parsePlatformErrorBody(body: string): PlatformErrorPayload | null { + try { + const parsed: unknown = JSON.parse(body); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + const result: PlatformErrorPayload = {}; + for (const key of ['error', 'message', 'deployment', 'minVersion', 'clientVersion'] as const) { + const value = (parsed as Record)[key]; + if (typeof value === 'string') { + result[key] = value; + } + } + return result; + } catch { + return null; + } +} + +function plainExplanation(value: string | undefined): string | undefined { + const text = value + ? stripVTControlCharacters(value) + // Keep line breaks and tabs, but not control characters that rewrite the terminal. + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ' ') + .trim() + : undefined; + // Error fields should contain an explanation, not another serialized response + // or an upstream login/error page. Keep the original body on PlatformApiError. + return text && !text.startsWith('{') && !text.startsWith('[') && !/<[^>]+>/.test(text) + ? text + : undefined; +} + +export function platformErrorMessage(payload: PlatformErrorPayload | null): string | undefined { + const message = plainExplanation(payload?.message); + return message && + !/^(forbidden|unauthorized|access denied|permission denied)[.!]?$/i.test(message) + ? message + : undefined; +} + +function unexplainedRejection(status: number): string { + if (status === 401) { + return 'Authentication was rejected. Check the credentials for the selected platform and any access gateway in front of it.'; + } + if (status === 403) { + return 'Access was denied by the platform or its access gateway. Check the selected platform and ask its administrator to confirm your access.'; + } + return 'The platform did not provide an explanation. Retry the operation; if it still fails, contact the platform administrator.'; +} + +function permissionMessage(permission?: ProjectPermission): string { + if (permission === 'owner') { + return 'Only the project owner can perform this action. Ask the owner to run it using their platform login.'; + } + if (permission === 'read') { + return 'This action requires project membership (reader or higher). Ask a project admin or owner for access.'; + } + if (permission === 'settings' || permission === 'members') { + return 'This action requires admin or owner access. Ask a project admin or owner to confirm your access.'; + } + if (permission === 'configure' || permission === 'deploy') { + return 'This action requires collaborator, admin, or owner access. Ask a project admin or owner to confirm your access.'; + } + return 'Ask the platform administrator to check your access to this action.'; +} + +const rejectionMessages: Record = { + 'invalid or expired token': + 'The platform reported an invalid or expired token. Sign in again with `void connect `, or replace `VOID_TOKEN` if you supplied it.', + 'missing authorization header': + 'A platform login or token is required. Sign in with `void connect `, or supply a valid `VOID_TOKEN` for that platform.', + 'session revoked; sign in again': + 'Your platform session was revoked. Sign in again with `void connect `, or replace `VOID_TOKEN` if you supplied it.', + 'user not found': + 'This account is no longer available. Contact the platform administrator or sign in with an active account on this platform.', + 'deploy credential revoked or expired': + 'This project deploy credential was revoked or expired. Ask the project owner to issue a replacement and update the credential used by your automation.', + 'deploy token build is no longer active': + 'This build is no longer active. Start a new build to obtain a valid deployment credential.', + github_identity_required: + 'Link your GitHub account with `void github link`, then retry this operation.', + connect_authorization_required: + 'This GitHub repository requires authorization. Run `void github connect` in an interactive terminal to authorize access, then retry.', + 'invalid connect grant': + 'GitHub authorization failed or expired. Run `void github connect` again.', + 'installation access revoked': + 'Access to this GitHub installation was revoked. Ask its administrator to restore access, then run `void github join`.', + 'account suspended': + 'This account is suspended. Contact the platform administrator to restore access.', + 'project owner suspended': + 'The project owner is suspended. Ask the owner to contact the platform administrator.', + 'project is being deleted': + 'This project is being deleted. Wait for deletion to finish before creating a replacement.', + 'registered user not found': + 'No registered account matches that email address. Ask the recipient to sign in to this platform first, then invite their registered email.', + 'the project owner cannot be invited or changed as a member': + 'The project owner cannot be invited or changed through team membership commands. Choose another member.', + 'the project owner cannot leave the project': + 'The project owner cannot leave. Ask a platform administrator to transfer ownership first.', + 'user is already a project member': + 'This user is already a project member. Use `void project team list` to find their user ID, then `void project team role` to change their role.', + 'project member not found': + 'This user is no longer a project member. Run `void project team list` to check current members.', + 'project has too many pending invitations': + 'This project has reached its pending invitation limit. Run `void project team invitations` and revoke invitations that are no longer needed.', +}; + +export function formatPlatformApiError( + action: string, + status: number, + body: string, + permission?: ProjectPermission, +): string { + const parsed = parsePlatformErrorBody(body); + const message = platformErrorMessage(parsed); + if (status === 426 && parsed?.error === 'cli_outdated' && message) { + return message; + } + if (!parsed) { + if (status === 401 || status === 403) { + const plain = plainExplanation(body); + const explanation = + plain && !/^(forbidden|unauthorized|access denied|permission denied)[.!]?$/i.test(plain) + ? plain.replace(/\s+/g, ' ').slice(0, 240) + : unexplainedRejection(status); + return `${action} (HTTP ${status}): ${explanation}`; + } + // Other opaque provider failures retain the existing diagnostics behavior. + return `${action}: ${status} ${body}`; + } + const detail = + message ?? + (status === 403 && parsed.error === 'forbidden' + ? `Access denied. ${permissionMessage(permission)}` + : ((parsed.error && Object.hasOwn(rejectionMessages, parsed.error) + ? rejectionMessages[parsed.error] + : undefined) ?? + (status === 401 && ['unauthorized', 'missing token'].includes(parsed.error ?? '') + ? rejectionMessages['invalid or expired token'] + : plainExplanation(parsed.error)))); + return `${action} (HTTP ${status}): ${detail || unexplainedRejection(status)}`; +} diff --git a/packages/void/src/cli/project-team.ts b/packages/void/src/cli/project-team.ts index 4e3d7fa2b..10882ac0d 100644 --- a/packages/void/src/cli/project-team.ts +++ b/packages/void/src/cli/project-team.ts @@ -7,6 +7,7 @@ import { type ProjectMemberResponse, } from './client.ts'; import { cliTitle } from './output.ts'; +import { parsePlatformErrorBody, platformErrorMessage } from './platform-errors.ts'; import type { ProjectArgs } from './project-args.ts'; import { readProjectConfig } from './project.ts'; import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; @@ -77,6 +78,11 @@ function invitationResponseError(error: unknown, apiUrl: string): unknown { if (!(error instanceof PlatformApiError)) { return error; } + // New APIs explain the rejection, including scoped-token and suspension cases. + // Do not mistake every 403 for an invitation addressed to another account. + if (platformErrorMessage(parsePlatformErrorBody(error.body))) { + return error; + } let message: string; if (error.code === 'project invitation not found') { message = `Invitation not found on ${apiUrl}. Check VOID_API_URL if set, connect to the platform in your invitation with \`void connect \`, then run \`void project team pending\` to check the invitation ID. If it is missing, ask a project administrator for a new invitation.`; @@ -91,7 +97,8 @@ function invitationResponseError(error: unknown, apiUrl: string): unknown { } else { return error; } - return new Error(message, { cause: error }); + error.message = message; + return error; } export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs): Promise { diff --git a/packages/void/src/runtime/remote/client.ts b/packages/void/src/runtime/remote/client.ts index b7aeab032..f438b6629 100644 --- a/packages/void/src/runtime/remote/client.ts +++ b/packages/void/src/runtime/remote/client.ts @@ -1,5 +1,86 @@ import { cfAccessHeaders } from '../../shared/cf-access.ts'; +function readableMessage(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + /* eslint-disable no-control-regex -- Strip ANSI sequences before removing remaining control characters. */ + const text = value + .replace(/(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/g, '') + .replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g, '') + .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') + .trim(); + /* eslint-enable no-control-regex */ + if (!text || /^[{[]/.test(text) || /<\/?[a-z!][^>]*>/i.test(text)) { + return undefined; + } + return text.length > 500 ? `${text.slice(0, 500)}…` : text; +} + +function isGenericPermissionMessage(message: string): boolean { + return /^(?:(?:HTTP\s*)?(?:401|403)\s*[:-]?\s*)?(?:forbidden|unauthorized|access denied|permission denied|invalid or expired token)[.!]?$/i.test( + message, + ); +} + +async function proxyError(response: Response): Promise { + // Error pages are untrusted and can be large. Read only enough for a diagnostic. + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let text = ''; + let remaining = 16_384; + try { + while (reader && remaining > 0) { + const { done, value } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value.subarray(0, remaining), { stream: true }); + remaining -= value.byteLength; + } + text += decoder.decode(); + } catch { + // An unreadable body should not hide the HTTP status or recovery guidance. + } finally { + await reader?.cancel().catch(() => {}); + reader?.releaseLock(); + } + + let message: string | undefined; + let error: string | undefined; + try { + const body: unknown = JSON.parse(text); + if (body && typeof body === 'object' && !Array.isArray(body)) { + const payload = body as Record; + message = readableMessage(payload.message); + error = readableMessage(payload.error); + } + } catch { + if (!response.headers.get('content-type')?.includes('text/html')) { + error = readableMessage(text); + } + } + + if (message && isGenericPermissionMessage(message)) { + message = undefined; + } + if (!message && error && !isGenericPermissionMessage(error)) { + message = error; + } + if (!message) { + if (response.status === 401) { + message = + 'Remote access could not be authenticated. Sign in to the connected platform again, or restart the build to obtain a fresh prerender token.'; + } else if (response.status === 403) { + message = + 'Remote access could not be authorized. Check the selected platform and project, then ask a project admin or owner to confirm your access. If your access is correct, retry shortly.'; + } else { + message = error ?? 'The remote service returned an unreadable error. Retry the request.'; + } + } + return new Error(`remote: Proxy request failed with ${response.status}: ${message}`); +} + /** * HTTP client for the remote bindings proxy (proxy.void.cloud). * @@ -37,8 +118,7 @@ export class ProxyClient { redirect: 'manual', }); if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(`remote: Proxy request failed with ${res.status}: '${text}'.`); + throw await proxyError(res); } return res; } @@ -70,8 +150,7 @@ export class ProxyClient { redirect: 'manual', }); if (!res.ok) { - const text = await res.text().catch(() => res.statusText); - throw new Error(`remote: Proxy request failed with ${res.status}: '${text}'.`); + throw await proxyError(res); } return res; } diff --git a/packages/void/test/unit/create-project.test.ts b/packages/void/test/unit/create-project.test.ts index 1c19d1bfe..33ec01e1a 100644 --- a/packages/void/test/unit/create-project.test.ts +++ b/packages/void/test/unit/create-project.test.ts @@ -95,6 +95,33 @@ describe('promptAndCreateProject', () => { }); }); + it('keeps actionable rejection guidance when prompting for a replacement slug', async () => { + const { promptAndCreateProject } = await import('../../src/cli/create-project.ts'); + const { PlatformApiError } = await import('../../src/cli/client.ts'); + const client = createClient(); + client.createProject + .mockRejectedValueOnce( + new PlatformApiError( + 'Failed to create project', + 403, + JSON.stringify({ + error: 'forbidden', + message: 'Short slugs require a paid plan. Choose a name with at least six characters.', + }), + ), + ) + .mockResolvedValueOnce({ id: 'proj_new', slug: 'longer-app' }); + mockText.mockResolvedValueOnce('short').mockResolvedValueOnce('longer-app'); + + await promptAndCreateProject(root, client); + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('Choose a name with at least six characters.'), + ); + expect(mockError.mock.calls[0]?.[0]).not.toContain('{'); + expect(client.createProject).toHaveBeenNthCalledWith(2, 'longer-app'); + }); + // The platform caps slugs at 56 characters (the `+noreply@` sender // has to fit RFC 5321's 64-octet local-part) and answers 400. Like every // other validation 400, that must re-prompt rather than abort the deploy. diff --git a/packages/void/test/unit/github-cmd.test.ts b/packages/void/test/unit/github-cmd.test.ts index 363369c23..3aae869ad 100644 --- a/packages/void/test/unit/github-cmd.test.ts +++ b/packages/void/test/unit/github-cmd.test.ts @@ -28,9 +28,9 @@ describe('describeConnectError', () => { ); }); - it('returns forbidden message for 403', () => { + it('leaves 403 explanations to the operation-aware client', () => { const msg = describeConnectError(403, 'forbidden'); - expect(msg).toBe("You don't have access to this project."); + expect(msg).toBeNull(); }); it('returns not-found message for 404 / "project not found"', () => { @@ -67,7 +67,7 @@ describe('describeUpdateError', () => { }); it('falls back to the connect mapping for 403', () => { - expect(describeUpdateError(403, 'forbidden')).toBe("You don't have access to this project."); + expect(describeUpdateError(403, 'forbidden')).toBeNull(); }); it('returns null for an unrecognised status / code', () => { diff --git a/packages/void/test/unit/github-connect-cmd.test.ts b/packages/void/test/unit/github-connect-cmd.test.ts index b4e1030c8..53c76e568 100644 --- a/packages/void/test/unit/github-connect-cmd.test.ts +++ b/packages/void/test/unit/github-connect-cmd.test.ts @@ -292,6 +292,40 @@ describe('runConnect — non-owner member connect-authorization round-trip', () expect(exitSpy).not.toHaveBeenCalled(); }); + it('preserves a project permission rejection after GitHub authorization', async () => { + (process.stdin as { isTTY?: boolean }).isTTY = true; + const rejection = new PlatformApiError( + 'Failed to connect GitHub repository', + 403, + JSON.stringify({ + error: 'forbidden', + message: + 'Changing project settings requires admin or owner access. Ask a project admin to update your access.', + }), + 'settings', + ); + const connectGithub = vi + .fn() + .mockRejectedValueOnce(authRequired()) + .mockRejectedValueOnce(rejection); + const { promise, getGithubConnectAuthorizeUrl } = driveConnect({ + connectGithub, + args: { executor: 'container' }, + callbackQuery: (cb) => { + const hit = new URL(cb); + hit.searchParams.set('connect_grant', 'fresh-grant'); + return hit; + }, + }); + await expect(promise).rejects.toBe(rejection); + expect(rejection.message).toContain('Ask a project admin'); + expect(getGithubConnectAuthorizeUrl).toHaveBeenCalledTimes(1); + expect(connectGithub).toHaveBeenCalledTimes(2); + expect(errorSpy).not.toHaveBeenCalledWith( + expect.stringContaining('GitHub authorization failed or expired'), + ); + }); + it('exits(1) with the auth-failed message (NOT "no project access") when the retry is rejected with 403', async () => { (process.stdin as { isTTY?: boolean }).isTTY = true; const connectGithub = vi diff --git a/packages/void/test/unit/github-disconnect-cmd.test.ts b/packages/void/test/unit/github-disconnect-cmd.test.ts index a252f9574..78838ee0d 100644 --- a/packages/void/test/unit/github-disconnect-cmd.test.ts +++ b/packages/void/test/unit/github-disconnect-cmd.test.ts @@ -155,6 +155,7 @@ describe('runGithubDisconnect', () => { 'Failed to get GitHub connection', 403, JSON.stringify({ error: 'forbidden' }), + 'read', ); const { client, deleteGithubConnection } = makeClient({ getGithubConnection: vi.fn(async () => { @@ -170,7 +171,35 @@ describe('runGithubDisconnect', () => { }), ).rejects.toThrow('process.exit(1)'); - expect(errorSpy).toHaveBeenCalledWith("disconnect: You don't have access to this project."); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('project membership (reader or higher)'), + ); + expect(deleteGithubConnection).not.toHaveBeenCalled(); + }); + + it('preserves the server explanation for a GitHub read rejection', async () => { + const rejection = new PlatformApiError( + 'Failed to get GitHub connection', + 403, + JSON.stringify({ + error: 'forbidden', + message: 'This credential cannot read GitHub settings. Use your platform login.', + }), + 'read', + ); + const { client, deleteGithubConnection } = makeClient({ + getGithubConnection: vi.fn(async () => { + throw rejection; + }), + }); + await expect( + runGithubDisconnect('/project', client, { + subcommand: 'disconnect', + projectName: slug, + yes: true, + }), + ).rejects.toThrow('process.exit(1)'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Use your platform login.')); expect(deleteGithubConnection).not.toHaveBeenCalled(); }); diff --git a/packages/void/test/unit/platform-permission-errors.test.ts b/packages/void/test/unit/platform-permission-errors.test.ts new file mode 100644 index 000000000..3ea04b5b4 --- /dev/null +++ b/packages/void/test/unit/platform-permission-errors.test.ts @@ -0,0 +1,288 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { PlatformApiError, PlatformClient, parsePlatformErrorBody } from '../../src/cli/client.ts'; +import { formatStreamDeployError } from '../../src/cli/deploy-errors.ts'; + +vi.mock('../../src/cli/cf-access.ts', () => ({ + ensureCloudflaredToken: vi.fn(), + ensurePlatformAccessToken: vi.fn(), + isCloudflareAccessChallenge: () => false, +})); +afterEach(() => vi.unstubAllGlobals()); +const client = () => new PlatformClient('test-token', 'https://api.example.test'); +const project = 'project_1'; +const collaborator = 'collaborator, admin, or owner'; +const admin = 'admin or owner'; +const owner = 'Only the project owner'; +const reader = 'project membership (reader or higher)'; + +describe('PlatformClient permission errors', () => { + const cases: Array<[string, (client: PlatformClient) => Promise, string]> = [ + ['list secrets', (c) => c.listSecrets(project), collaborator], + ['set secret', (c) => c.setSecret(project, 'NAME', 'private-value'), collaborator], + ['delete secret', (c) => c.deleteSecret(project, 'NAME'), collaborator], + ['add domain', (c) => c.addDomain(project, 'example.test'), admin], + ['remove domain', (c) => c.removeDomain(project, 'domain_1'), admin], + ['list domains', (c) => c.listDomains(project), reader], + ['domain status', (c) => c.getDomainStatus(project, 'domain_1'), reader], + ['provision database', (c) => c.provisionDatabase(project, 'postgres://private'), collaborator], + ['database URL write', (c) => c.setDatabaseUrl(project, 'postgres://private'), collaborator], + ['database URL read', (c) => c.getDatabaseUrl(project), owner], + ['migration status', (c) => c.getMigrationStatus(project), reader], + ['migrate', (c) => c.applyRemoteMigrations(project, []), collaborator], + ['rename migrations', (c) => c.renameMigrations(project, []), collaborator], + ['rollback', (c) => c.rollback(project, 'deploy_1'), collaborator], + ['cancel', (c) => c.cancelDeployment(project, 'deploy_1'), collaborator], + ['purge', (c) => c.purgeCache(project), collaborator], + ['upload URLs', (c) => c.requestUploadUrls(project, []), collaborator], + ['prerender credential', (c) => c.createPrerenderToken(project), collaborator], + ['team roster', (c) => c.listProjectMembers(project), reader], + ['team invitation list', (c) => c.listProjectInvitations(project), admin], + ['team invite', (c) => c.inviteProjectMember(project, 'target@example.test', 'reader'), admin], + ['team role', (c) => c.updateProjectMemberRole(project, 'user_1', 'reader'), admin], + ['team remove', (c) => c.removeProjectMember(project, 'user_1'), admin], + ['team revoke', (c) => c.revokeProjectInvitation(project, 'invitation_1'), admin], + ['token list', (c) => c.listProjectTokens(project), owner], + ['token create', (c) => c.createProjectToken(project, {}), owner], + ['token renew', (c) => c.renewProjectToken(project, 'token_1'), owner], + ['token revoke', (c) => c.revokeProjectToken(project, 'token_1'), owner], + ['project delete', (c) => c.deleteProject(project), owner], + ['deployments', (c) => c.listDeployments(project), reader], + ['resources', (c) => c.getResources(project), reader], + ['builds', (c) => c.listBuilds(project), reader], + ['build logs', (c) => c.getBuildLogs(project, 'build_1'), reader], + ['GitHub status', (c) => c.getGithubConnection(project), reader], + [ + 'GitHub connect', + (c) => + c.connectGithub(project, { + installationId: '1', + repoFullName: 'owner/repo', + branch: 'main', + }), + admin, + ], + ['GitHub update', (c) => c.updateGithubConnection(project, { branch: 'main' }), admin], + ['GitHub disconnect', (c) => c.deleteGithubConnection(project), admin], + ['email usage', (c) => c.getEmailUsage(project), reader], + ['email destination add', (c) => c.addEmailDestination(project, 'target@example.test'), admin], + ['email domain removal', (c) => c.removeEmailDomain(project, 'example.test'), admin], + ]; + it.each(cases)( + 'explains required access for %s on older APIs', + async (_name, operation, requirement) => { + const body = JSON.stringify({ error: 'forbidden' }); + const fetch = vi.fn(async () => new Response(body, { status: 403 })); + vi.stubGlobal('fetch', fetch); + const error = await operation(client()).catch((error: unknown) => error); + expect(error).toBeInstanceOf(PlatformApiError); + expect(error).toMatchObject({ status: 403, code: 'forbidden', body }); + expect((error as Error).message).toContain(requirement); + expect((error as Error).message).toMatch(/Failed|failed/); + expect((error as Error).message).not.toMatch(/\{"|auth logout|private-value|postgres:\/\//); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + it('prefers the server explanation without claiming the current role', async () => { + const body = JSON.stringify({ + error: 'forbidden', + message: 'This deployment token cannot read secrets. Use your platform login.', + }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(body, { status: 403 })), + ); + const error = await client() + .listSecrets(project) + .catch((error: unknown) => error); + expect(error).toMatchObject({ status: 403, code: 'forbidden', body }); + expect((error as Error).message).toContain('Failed to list secrets'); + expect((error as Error).message).toContain('Use your platform login.'); + expect((error as Error).message).not.toContain('collaborator'); + }); + + it('keeps account and repository denials distinct from project role requirements', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({ error: 'repository access revoked' }, { status: 403 })), + ); + await expect( + client().connectGithub(project, { + installationId: '1', + repoFullName: 'owner/repo', + branch: 'main', + }), + ).rejects.toThrow('repository access revoked'); + await expect(client().listGithubInstallations()).rejects.not.toThrow('admin or owner'); + }); + + it.each(['account suspended', 'project owner suspended'])( + 'explains %s without changing accounts', + (code) => { + const error = new PlatformApiError( + 'Failed to list secrets', + 403, + JSON.stringify({ error: code }), + 'configure', + ); + expect(error.message).toContain('platform administrator'); + expect(error.message).not.toMatch(/collaborator|auth logout/); + expect(error.code).toBe(code); + }, + ); + + it('retains opaque denial bodies for diagnostics without dumping HTML', () => { + const body = 'Cloudflare Access denied'; + const error = new PlatformApiError('Read failed', 403, body, 'read'); + expect(error.body).toBe(body); + expect(error.message).toContain('access gateway'); + expect(error.message).not.toMatch(/|reader|collaborator/); + expect(formatStreamDeployError(error, null).message).not.toContain(''); + }); + + it.each(['', '{}', '[]', '{"message":{}}', '{"message":"","error":42}', '{malformed'])( + 'explains an empty/malformed denial without raw JSON: %s', + (body) => { + const error = new PlatformApiError('Read failed', 403, body, 'read'); + expect(error.body).toBe(body); + expect(error.message).toContain('confirm your access'); + expect(error.message).not.toMatch(/[{}]|\[\]|undefined|\[object Object\]/); + }, + ); + + it('keeps useful provider text and rejects invalid error field types', () => { + const provider = 'Access policy requires a service token.'; + expect(new PlatformApiError('Read failed', 403, provider, 'read').message).toContain(provider); + expect(parsePlatformErrorBody('[]')).toBeNull(); + const malformed = new PlatformApiError( + 'Read failed', + 403, + '{"message":{"private":"value"},"error":42}', + ); + expect(malformed.code).toBeUndefined(); + expect(malformed.message).not.toContain('[object Object]'); + }); + + it.each([ + { error: 'forbidden', message: 'upstream login' }, + { error: 'forbidden', message: '{"error":"upstream"}' }, + { error: '

Denied

', message: [] }, + { error: '{"error":"upstream"}', message: '["denied"]' }, + ])('never renders nested markup or JSON error fields: %j', (payload) => { + const body = JSON.stringify(payload); + const error = new PlatformApiError('Read failed', 403, body); + expect(error.body).toBe(body); + expect(error.code).toBe(payload.error); + expect(error.message).not.toMatch(/[<>[\]{}]/); + }); + + it('removes terminal escape sequences from server and provider explanations', () => { + const server = new PlatformApiError( + 'Read failed', + 403, + JSON.stringify({ + error: 'forbidden', + message: '\u001b[31mAsk the owner for access.\u001b[0m', + }), + ); + const provider = new PlatformApiError( + 'Read failed', + 403, + '\u001b[31mA gateway service token is required.\u001b[0m', + ); + expect(server.message).toContain('Ask the owner for access.'); + expect(provider.message).toContain('gateway service token'); + expect(server.message + provider.message).not.toContain('\u001b'); + }); + + it.each([ + [401, 'session revoked; sign in again', 'void connect'], + [401, 'user not found', 'active account'], + [401, 'deploy credential revoked or expired', 'project owner'], + [403, 'deploy token build is no longer active', 'Start a new build'], + [403, 'github_identity_required', 'void github link'], + [403, 'connect_authorization_required', 'interactive terminal'], + [403, 'invalid connect grant', 'void github connect'], + ])('gives recovery for legacy %s %s', (status, code, recovery) => { + const error = new PlatformApiError('Request failed', status, JSON.stringify({ error: code })); + expect(error.code).toBe(code); + expect(error.message).toContain(recovery); + expect(error.message).not.toContain('auth logout'); + }); + + it.each(['invalid or expired token', 'unauthorized', 'missing token'])( + 'explains recovery for legacy 401 %s', + (code) => { + const error = new PlatformApiError('Read failed', 401, JSON.stringify({ error: code })); + expect(error.code).toBe(code); + expect(error.message).toContain('void connect '); + expect(error.message).toContain('VOID_TOKEN'); + expect(error.message).not.toContain('auth logout'); + }, + ); + + it.each(['', 'Login required', '{"message":{}}'])( + 'explains opaque 401 without misdiagnosing a role: %s', + (body) => { + const error = new PlatformApiError('Read failed', 401, body, 'read'); + expect(error.message).toContain('credentials'); + expect(error.message).not.toMatch(/|reader|admin|\{"/); + expect(error.status).toBe(401); + }, + ); + + it('preserves useful server messages and metadata through deploy formatting', () => { + const body = JSON.stringify({ + error: 'forbidden', + message: 'Deploying requires collaborator access. Ask a project admin to update your access.', + }); + const original = new PlatformApiError('Deploy failed', 403, body, 'deploy'); + const formatted = formatStreamDeployError(original, 'deploy_1', '/tmp/void.log'); + expect(formatted).toBe(original); + expect(formatted).toMatchObject({ code: 'forbidden', status: 403, body }); + expect(formatted.message).toContain('Ask a project admin'); + expect(formatted.message).toContain('Deployment: deploy_1'); + expect(formatted.message).toContain('Detailed log: /tmp/void.log'); + }); +}); + +describe('deploy preflight rejections', () => { + const assets = { '/index.html': { hash: 'hash', size: 1 } }; + it.each([ + [403, { error: 'forbidden' }], + [403, { error: 'project owner suspended' }], + [401, { error: 'invalid or expired token' }], + [426, { error: 'cli_outdated', message: 'Upgrade the CLI.' }], + ])('stops after an explicit HTTP %s rejection', async (status, body) => { + const fetch = vi.fn(async () => Response.json(body, { status })); + vi.stubGlobal('fetch', fetch); + await expect(client().preflight(project, assets)).rejects.toMatchObject({ + status, + code: body.error, + }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it.each([404, 500])('retains the optional optimization fallback for HTTP %s', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('unsupported or unavailable', { status })), + ); + await expect(client().preflight(project, assets)).resolves.toEqual({ + needed: ['/index.html'], + total: 1, + skipped: 0, + }); + }); + it('retains the fallback for opaque provider responses and network errors', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(new Response('provider response', { status: 403 })) + .mockRejectedValueOnce(new TypeError('fetch failed')), + ); + await expect(client().preflight(project, assets)).resolves.toMatchObject({ skipped: 0 }); + await expect(client().preflight(project, assets)).resolves.toMatchObject({ skipped: 0 }); + }); +}); diff --git a/packages/void/test/unit/project-team-command.test.ts b/packages/void/test/unit/project-team-command.test.ts index 08f33e848..fab2205e0 100644 --- a/packages/void/test/unit/project-team-command.test.ts +++ b/packages/void/test/unit/project-team-command.test.ts @@ -2,6 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { join } from 'pathslash'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { PlatformApiError } from '../../src/cli/client.ts'; import { platformCredentialAccount, savePlatformConnection, @@ -450,5 +451,59 @@ describe('project team target selection', () => { }); await expect(result).rejects.toThrow(message); await expect(result).rejects.toThrow(recovery); + await expect(result).rejects.toBeInstanceOf(PlatformApiError); + await expect(result).rejects.toMatchObject({ + status, + code: error, + body: JSON.stringify({ error }), + }); + }); + + it.each([ + ['forbidden', 'This deployment token cannot respond to invitations. Use your platform login.'], + ['account suspended', 'This account is suspended. Contact the platform administrator.'], + ])('preserves the server explanation for invitation rejection %s', async (code, message) => { + const root = rootWithPlatform('void'); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + const body = JSON.stringify({ error: code, message }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(body, { status: 403 })), + ); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const error = await runProjectCommand(root, { + subcommand: 'team', + action: 'accept', + invitationId: 'pinv_1', + }).catch((error: unknown) => error); + expect(error).toBeInstanceOf(PlatformApiError); + expect(error).toMatchObject({ status: 403, code, body }); + expect((error as Error).message).toContain(message); + expect((error as Error).message).not.toContain('auth logout'); + }); + + it('explains an existing-member conflict from an older API', async () => { + const root = rootWithPlatform('void'); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ error: 'user is already a project member' }, { status: 409 }), + ), + ); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await expect( + runProjectCommand(root, { + subcommand: 'team', + action: 'invite', + email: 'target@example.test', + role: 'reader', + projectSlug: '', + }), + ).rejects.toThrow('void project team role'); }); }); diff --git a/packages/void/test/unit/remote/client.test.ts b/packages/void/test/unit/remote/client.test.ts index a77185dd3..98562af45 100644 --- a/packages/void/test/unit/remote/client.test.ts +++ b/packages/void/test/unit/remote/client.test.ts @@ -19,6 +19,155 @@ describe('ProxyClient', () => { vi.restoreAllMocks(); }); + describe.each(['fetch', 'stream'] as const)('%s error diagnostics', (method) => { + function request() { + const client = new ProxyClient(PROXY_URL, TOKEN, PROJECT_ID); + return method === 'fetch' + ? client.fetch('/d1/query', { binding: 'DB' }) + : client.stream('/r2/get', { method: 'POST', body: JSON.stringify({ binding: 'FILES' }) }); + } + + it('prefers server recovery guidance over the machine error and private metadata', async () => { + setFetchMock(() => + Response.json( + { + error: 'forbidden', + message: 'Ask a project admin to confirm your access, then retry.', + debug: { credential: 'private-data' }, + }, + { status: 403 }, + ), + ); + await expect(request()).rejects.toThrow( + 'remote: Proxy request failed with 403: Ask a project admin to confirm your access, then retry.', + ); + }); + + it.each([ + [ + 401, + JSON.stringify({ error: 'invalid or expired token' }), + 'Sign in to the connected platform again', + ], + [ + 403, + JSON.stringify({ error: 'forbidden', message: ' ' }), + 'Check the selected platform and project', + ], + [ + 403, + 'private access page', + 'Check the selected platform and project', + ], + [ + 403, + JSON.stringify({ message: 'private access page' }), + 'Check the selected platform and project', + ], + [ + 401, + JSON.stringify({ message: { private: 'details' } }), + 'Sign in to the connected platform again', + ], + [403, '{"unparseable": "private details"', 'Check the selected platform and project'], + [403, 'Forbidden', 'Check the selected platform and project'], + [403, JSON.stringify({ message: 'forbidden' }), 'Check the selected platform and project'], + [ + 403, + JSON.stringify({ message: 'HTTP 403 Forbidden' }), + 'Check the selected platform and project', + ], + [403, JSON.stringify({ error: 'Access denied' }), 'Check the selected platform and project'], + ])('gives safe recovery guidance for status %s and body %s', async (status, body, guidance) => { + setFetchMock(() => new Response(body, { status })); + const error = await request().catch((error: Error) => error); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain(guidance); + expect((error as Error).message).not.toMatch(/private||\{"/); + }); + + it.each([ + [404, JSON.stringify({ error: 'Binding DB is not deployed' }), 'Binding DB is not deployed'], + [ + 409, + JSON.stringify({ + error: 'Write result is unknown; check the stored value before retrying', + }), + 'Write result is unknown; check the stored value before retrying', + ], + [ + 403, + JSON.stringify({ error: 'Daily resource budget exceeded' }), + 'Daily resource budget exceeded', + ], + [502, 'Upstream database unavailable', 'Upstream database unavailable'], + ])('preserves meaningful failures for status %s', async (status, body, expected) => { + setFetchMock(() => new Response(body, { status })); + await expect(request()).rejects.toThrow( + `remote: Proxy request failed with ${status}: ${expected}`, + ); + }); + + it('does not dump an HTML server error page', async () => { + setFetchMock( + () => new Response('

private gateway trace

', { status: 502 }), + ); + await expect(request()).rejects.toThrow( + 'remote: Proxy request failed with 502: The remote service returned an unreadable error. Retry the request.', + ); + }); + + it('strips ANSI formatting and hyperlinks from server messages', async () => { + setFetchMock(() => + Response.json( + { + message: + '\u001b[31mAsk an admin\u001b[0m to review \u001b]8;;https://private.example\u0007your access\u001b]8;;\u0007.', + }, + { status: 403 }, + ), + ); + await expect(request()).rejects.toThrow( + 'remote: Proxy request failed with 403: Ask an admin to review your access.', + ); + }); + + it('still explains authentication when the error body cannot be read', async () => { + setFetchMock( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.error(new Error('body transport failed')); + }, + }), + { status: 401 }, + ), + ); + await expect(request()).rejects.toThrow('Sign in to the connected platform again'); + }); + + it('bounds diagnostics and cancels oversized error bodies', async () => { + const cancel = vi.fn(); + setFetchMock( + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('x'.repeat(20_000))); + }, + cancel, + }), + { status: 502 }, + ), + ); + const error = await request().catch((error: Error) => error); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message.length).toBeLessThan(600); + expect(cancel).toHaveBeenCalledTimes(1); + }); + }); + describe('fetch()', () => { it('sends correct headers and injects projectId into body', async () => { const fetchMock = setFetchMock(async () => Response.json({ results: [] })); diff --git a/platform/meta/design-docs/0081-project-permission-errors.md b/platform/meta/design-docs/0081-project-permission-errors.md new file mode 100644 index 000000000..3dcaf69de --- /dev/null +++ b/platform/meta/design-docs/0081-project-permission-errors.md @@ -0,0 +1,56 @@ +--- +title: Actionable project permission errors +status: implemented +created: 2026-09-23 +--- + +# Actionable project permission errors + +Project roles are enforced by the API, but several rejected CLI operations show +raw `403 {"error":"forbidden"}` output. Users need the attempted action, the +required access, and a useful next step. Deployment and invitation responses +already provide some guidance; the remaining paths should meet the same standard. + +## Contract + +- Preserve HTTP statuses and existing machine-readable `error` values. Add a + separate `message` field to project access denials and expected team errors. +- Derive required access from the checked capability. Do not infer the caller's + actual role or disclose project/member details in a denied response. +- Cover initial checks and repeated checks under transaction locks. Credential + scope, inactive builds, suspension, owner-only operations and invitation + conflicts require different recovery instructions. +- Have CLI and dashboard presentation prefer meaningful messages, with safe + fallbacks for older APIs. Preserve structured error properties for callers and + specialized invitation/account guidance. Do not print JSON or HTML as ordinary + permission-error copy. + +## Work and verification + +1. Audit API capability checks, direct rejection branches and CLI formatting + exceptions. Inventory permission-specific UI fallback behavior. +2. Implement additive server messages, consistent CLI formatting and dashboard + recovery copy without changing permission decisions or widening access. +3. Run focused CLI/dashboard tests and API integration cases covering every + project capability, owner-only actions, scope rejection and access changes + during operations. Verify rejected writes do not change project state. +4. Run workspace typechecking, formatting/lint and the final full test suite. + Investigate failures or slow/hanging tests rather than treating them as green. +5. Update user guidance only where recovery steps or workflows need explanation. + +## Progress + +- Confirmed raw permission formatting in PlatformApiError and dashboard helpers. +- Found additional formatting bypasses in deployment streams, GitHub commands, + transaction rechecks, email operations and administrator middleware. +- Added capability-specific API guidance, credential and invitation recovery, + consistent CLI/dashboard formatting, remote proxy messages, and admin recovery + links. Existing access decisions, HTTP statuses and JSON error codes remain. +- Regression coverage checks reader mutation denials, owner-only credentials, + transaction and deployment rechecks, unchanged state after rejected writes, + old API responses, malformed bodies and command-specific formatting. +- `vp run check` passed. Final `vpr test:all` passed all 563 test files: + 10,840 tests passed and 21 skipped, together with builds, lint and formatting. + Validation temporarily excluded an obsolete ignored scratch fixture and used a + fresh kitchen-sink local D1 database; both original fixtures were restored. +- No deployment or package release is part of this change's validation. diff --git a/platform/packages/api/src/admin/middleware/auth.ts b/platform/packages/api/src/admin/middleware/auth.ts index 1edcfd098..05b0b983c 100644 --- a/platform/packages/api/src/admin/middleware/auth.ts +++ b/platform/packages/api/src/admin/middleware/auth.ts @@ -5,6 +5,7 @@ import { validateHumanSession } from '../../auth/sessions.ts'; import { getUser, getFirstAdmin } from '../../db.ts'; import { type ApiEnv, isDevBypass } from '../../env.ts'; import { isAdminSuspended } from '../../suspension.ts'; +import { adminAccessRejected, adminRequestRejected } from './request-rejection.ts'; export const adminAuthMiddleware: MiddlewareHandler = async (context, next) => { // CSRF defense: every admin write is state-changing and authenticated by a @@ -20,7 +21,7 @@ export const adminAuthMiddleware: MiddlewareHandler = async (context, ne const requestHost = new URL(context.req.url).host; const sourceHost = origin ? safeHost(origin) : referer ? safeHost(referer) : null; if (sourceHost === null || sourceHost !== requestHost) { - return context.json({ error: 'cross-site request blocked' }, 403); + return adminRequestRejected(context, 'cross-site request blocked', 'json'); } } @@ -49,8 +50,11 @@ export const adminAuthMiddleware: MiddlewareHandler = async (context, ne } const user = await getUser(context.get('db'), payload.sub); - if (!user || user.role !== 'admin' || isAdminSuspended(user.suspended_reason)) { - return context.html('

403 Forbidden

Admin access required.

', 403); + if (!user || user.role !== 'admin') { + return adminAccessRejected(context, 'required'); + } + if (isAdminSuspended(user.suspended_reason)) { + return adminAccessRejected(context, 'suspended'); } context.set('user', user); diff --git a/platform/packages/api/src/admin/middleware/csrf.ts b/platform/packages/api/src/admin/middleware/csrf.ts index 612f17580..c537083de 100644 --- a/platform/packages/api/src/admin/middleware/csrf.ts +++ b/platform/packages/api/src/admin/middleware/csrf.ts @@ -1,5 +1,6 @@ import type { MiddlewareHandler } from 'hono'; import type { ApiEnv } from '../../env.ts'; +import { adminRequestRejected } from './request-rejection.ts'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); @@ -13,7 +14,7 @@ export const adminCsrfMiddleware: MiddlewareHandler = async (context, ne const origin = context.req.header('Origin'); if (origin) { if (parseOrigin(origin) !== expectedOrigin) { - return context.text('Forbidden: invalid request origin.', 403); + return adminRequestRejected(context, 'Forbidden: invalid request origin.', 'text'); } await next(); return; @@ -21,7 +22,7 @@ export const adminCsrfMiddleware: MiddlewareHandler = async (context, ne const referer = context.req.header('Referer'); if (!referer || parseOrigin(referer) !== expectedOrigin) { - return context.text('Forbidden: missing request origin.', 403); + return adminRequestRejected(context, 'Forbidden: missing request origin.', 'text'); } await next(); }; diff --git a/platform/packages/api/src/admin/middleware/request-rejection.ts b/platform/packages/api/src/admin/middleware/request-rejection.ts new file mode 100644 index 000000000..7e848d71f --- /dev/null +++ b/platform/packages/api/src/admin/middleware/request-rejection.ts @@ -0,0 +1,35 @@ +import type { Context } from 'hono'; +import type { ApiEnv } from '../../env.ts'; + +export function adminAccessRejected( + context: Context, + reason: 'required' | 'suspended', +): Response { + if (reason === 'suspended') { + return context.html( + '

Administrator access suspended

Contact another platform administrator to restore access.

Sign in with another account

', + 403, + ); + } + return context.html( + '

Administrator access required

Sign in with an administrator account for this platform.

Sign in with another account

', + 403, + ); +} + +export function adminRequestRejected( + context: Context, + error: string, + format: 'json' | 'text', +): Response { + const message = 'Return to the admin page, refresh it, and try again.'; + if (context.req.header('Accept')?.includes('text/html')) { + return context.html( + '

Request could not be verified

Return to the admin page, refresh it, and try again.

Return to admin

', + 403, + ); + } + return format === 'json' + ? context.json({ error, message }, 403) + : context.text(`${error} ${message}`, 403); +} diff --git a/platform/packages/api/src/admin/operation-error.tsx b/platform/packages/api/src/admin/operation-error.tsx index 6d35378cd..0effa0a09 100644 --- a/platform/packages/api/src/admin/operation-error.tsx +++ b/platform/packages/api/src/admin/operation-error.tsx @@ -7,5 +7,17 @@ export const renderOperationError: ErrorHandler = (error, context) => { if (!(error instanceof OperatorError)) { throw error; } - return context.html(

{error.message}

, error.status); + return context.html( + <> +

{error.message}

+

+ {error.status === 401 ? ( + Sign in again + ) : ( + Return to admin + )} +

+ , + error.status, + ); }; diff --git a/platform/packages/api/src/admin/routes/login.ts b/platform/packages/api/src/admin/routes/login.ts index 8f21a628d..68636ecad 100644 --- a/platform/packages/api/src/admin/routes/login.ts +++ b/platform/packages/api/src/admin/routes/login.ts @@ -24,6 +24,7 @@ import { import { escapeHtml } from '../../email.ts'; import type { ApiEnv } from '../../env.ts'; import { isAdminSuspended } from '../../suspension.ts'; +import { adminAccessRejected } from '../middleware/request-rejection.ts'; const app = new Hono(); type AdminProvider = 'github' | 'google'; @@ -231,11 +232,11 @@ export async function handleAdminOAuthCallback(context: Context): Promis ); } - if (!user || user.role !== 'admin' || isAdminSuspended(user.suspended_reason)) { - return context.html( - '

403 Forbidden

Admin access required. You must have an admin account to sign in.

', - 403, - ); + if (!user || user.role !== 'admin') { + return adminAccessRejected(context, 'required'); + } + if (isAdminSuspended(user.suspended_reason)) { + return adminAccessRejected(context, 'suspended'); } const jwt = await signJwt({ sub: user.id, login, aud: 'admin' }, context.env.JWT_SECRET); diff --git a/platform/packages/api/src/admin/routes/platform.tsx b/platform/packages/api/src/admin/routes/platform.tsx index d044f708e..ba35cc9df 100644 --- a/platform/packages/api/src/admin/routes/platform.tsx +++ b/platform/packages/api/src/admin/routes/platform.tsx @@ -25,6 +25,20 @@ const app = new Hono(); // `void-*` Worker in the same account. app.use('*', async (context, next) => { if (context.env.PLATFORM_INSTALLATION_ID) { + if (context.req.header('Accept')?.includes('text/html')) { + return context.html( + <> +

Manage this platform with the CLI

+

+ To upgrade this self-hosted Void platform, run void platform upgrade. +

+

+ Return to admin +

+ , + 501, + ); + } return context.json( { error: 'platform capability unavailable', diff --git a/platform/packages/api/src/email/connection-service.ts b/platform/packages/api/src/email/connection-service.ts index 92a3fb90e..0eb043b9c 100644 --- a/platform/packages/api/src/email/connection-service.ts +++ b/platform/packages/api/src/email/connection-service.ts @@ -5,6 +5,7 @@ import { generateId } from '../id.ts'; import { ProjectAccessDeniedError, ProjectNotFoundError, + PROJECT_PERMISSION_MESSAGES, requireProjectCapability, } from '../project-access.ts'; import { decryptPlatformCredential, encryptPlatformCredential } from '../project-secrets.ts'; @@ -38,6 +39,7 @@ export class EmailOperationError extends Error { constructor( message: string, readonly status = 409, + readonly userMessage?: string, ) { super(message); } @@ -440,7 +442,13 @@ async function requireProjectManagedEmailAccess( } } catch (error) { if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { - throw new EmailOperationError('forbidden', 403); + throw new EmailOperationError( + 'forbidden', + 403, + error instanceof ProjectAccessDeniedError + ? error.userMessage + : PROJECT_PERMISSION_MESSAGES.settings, + ); } throw error; } @@ -634,7 +642,11 @@ async function resumeBlockedEmailOperationTx( } if (route && actor.kind === 'owner') { if (!route.project_id || route.managed_by !== 'owner') { - throw new EmailOperationError('forbidden', 403); + throw new EmailOperationError( + 'forbidden', + 403, + 'This email connection is managed by the platform. Contact a platform administrator to change it.', + ); } await requireProjectManagedEmailAccess(tx, route.project_id, actor); } @@ -2487,7 +2499,11 @@ async function requestExistingOperation( } if (actor.kind === 'owner') { if (!project || route.managed_by !== 'owner') { - throw new EmailOperationError('forbidden', 403); + throw new EmailOperationError( + 'forbidden', + 403, + 'This email connection is managed by the platform. Contact a platform administrator to change it.', + ); } } if ( diff --git a/platform/packages/api/src/email/domain-service.ts b/platform/packages/api/src/email/domain-service.ts index 083225db1..d4a7ac30c 100644 --- a/platform/packages/api/src/email/domain-service.ts +++ b/platform/packages/api/src/email/domain-service.ts @@ -88,7 +88,10 @@ export function serializeEmailRoute( } function serviceError(context: Context, error: unknown): Response { if (error instanceof EmailOperationError) { - return context.json({ error: error.message }, error.status as 400); + return context.json( + { error: error.message, ...(error.userMessage ? { message: error.userMessage } : {}) }, + error.status as 400, + ); } throw error; } diff --git a/platform/packages/api/src/middleware/auth.ts b/platform/packages/api/src/middleware/auth.ts index fb31fea06..72f53f19d 100644 --- a/platform/packages/api/src/middleware/auth.ts +++ b/platform/packages/api/src/middleware/auth.ts @@ -80,7 +80,10 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = const header = context.req.header('Authorization'); if (!header?.startsWith('Bearer ')) { - return context.json({ error: 'unauthorized' }, 401); + return context.json( + { error: 'unauthorized', message: 'Sign in to this platform before continuing.' }, + 401, + ); } const token = header.slice(7); @@ -89,22 +92,49 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = // audience, then accept only these two audiences. const payload = await verifyJwt(token, context.env.JWT_SECRET); if (!payload || (payload.aud !== 'api' && payload.aud !== 'deploy')) { - return context.json({ error: 'invalid or expired token' }, 401); + return context.json( + { + error: 'invalid or expired token', + message: + 'Your sign-in or credential is invalid or has expired. Sign in again, or replace the credential used by your automation.', + }, + 401, + ); } if ( payload.aud === 'api' && !(await validateHumanSession(context.get('db'), payload, context.get('companyIdentity'))) ) { - return context.json({ error: 'session revoked; sign in again' }, 401); + return context.json( + { + error: 'session revoked; sign in again', + message: + 'Your session is no longer active. Sign in to this platform again before continuing.', + }, + 401, + ); } const user = await getUser(context.get('db'), payload.sub); if (!user) { - return context.json({ error: 'user not found' }, 401); + return context.json( + { + error: 'user not found', + message: + 'This account is no longer available. Sign in with an active account on this platform.', + }, + 401, + ); } if (isAdminSuspended(user.suspended_reason)) { - return context.json({ error: 'account suspended' }, 403); + return context.json( + { + error: 'account suspended', + message: 'Your account is suspended. Contact a platform administrator to restore access.', + }, + 403, + ); } context.set('user', user); @@ -114,7 +144,14 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = if (payload.aud === 'deploy') { if (!payload.project) { - return context.json({ error: 'invalid or expired token' }, 401); + return context.json( + { + error: 'invalid or expired token', + message: + 'This deploy credential is invalid. Ask the project owner to issue a replacement.', + }, + 401, + ); } context.set('deployScope', payload.project); // Bind the token to its build (when present). The deploy endpoint rejects a @@ -141,7 +178,14 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = .limit(1) .then((rows) => rows[0]); if (!credential) { - return context.json({ error: 'deploy credential revoked or expired' }, 401); + return context.json( + { + error: 'deploy credential revoked or expired', + message: + 'This deploy credential has expired or been revoked. Ask the project owner to issue a new credential and update your deployment configuration.', + }, + 401, + ); } context.set('deployCredential', payload.credential); } @@ -149,7 +193,14 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = // real `void deploy` flow needs, and only for its own project. The // deploy.ts cross-project guard remains as defense-in-depth. if (!isDeployTokenAllowed(context.req.method, context.req.path, payload.project)) { - return context.json({ error: 'forbidden' }, 403); + return context.json( + { + error: 'forbidden', + message: + 'This credential can only deploy to its assigned project. Use a credential issued for the selected project, or sign in with your account to manage project settings and members.', + }, + 403, + ); } // A deploy token bound to a build is only valid while that build is in // flight. Once the build is superseded/cancelled/failed/complete the token @@ -167,7 +218,14 @@ export const authMiddleware: MiddlewareHandler = async (context, next) = !ACTIVE_BUILD_STATUSES.includes(boundBuild.status) || boundBuild.project_id !== payload.project ) { - return context.json({ error: 'deploy token build is no longer active' }, 403); + return context.json( + { + error: 'deploy token build is no longer active', + message: + 'This build is no longer active. Start a new build to obtain a valid deployment credential.', + }, + 403, + ); } } } diff --git a/platform/packages/api/src/project-access.ts b/platform/packages/api/src/project-access.ts index 13ffde753..e7b2d00cb 100644 --- a/platform/packages/api/src/project-access.ts +++ b/platform/packages/api/src/project-access.ts @@ -18,6 +18,31 @@ const PROJECT_ROLE_CAPABILITIES = { owner: ['read', 'deploy', 'configure', 'settings', 'members', 'delete'], } as const satisfies Record>; +// These messages describe requirements, not the caller's role or private project data. +export const PROJECT_PERMISSION_MESSAGES = { + read: 'You need project access to view this project. Ask a project admin or owner to invite you.', + deploy: + 'Deploying requires the collaborator, admin, or owner role. Ask a project admin or owner to update your access.', + configure: + 'Changing project configuration requires the collaborator, admin, or owner role. Ask a project admin or owner to update your access.', + settings: + 'Changing project settings requires the admin or owner role. Ask a project admin or owner to update your access.', + members: + 'Managing project members and invitations requires the admin or owner role. Ask a project admin or owner to update your access.', + delete: 'Only the project owner can delete this project. Ask the owner to delete it.', + tokens: + 'Only the project owner can manage deploy tokens. Ask the owner to create or manage a token.', + 'database-url': + 'Only the project owner can view the database connection URL. Ask the owner to share the connection details you need.', + 'owner-suspended': + "This project's owner account is suspended. Ask the owner to contact the platform administrator to restore access.", + 'not-found': + 'This project could not be found. Check the project ID or refresh your project list.', +} as const satisfies Record< + ProjectCapability | 'tokens' | 'database-url' | 'owner-suspended' | 'not-found', + string +>; + function hasProjectCapability(role: ProjectRole, capability: ProjectCapability): boolean { return (PROJECT_ROLE_CAPABILITIES[role] as ReadonlyArray).includes(capability); } @@ -56,6 +81,7 @@ async function selectProjectAccess( export class ProjectNotFoundError extends Error { readonly status = 404 as const; + readonly userMessage = PROJECT_PERMISSION_MESSAGES['not-found']; constructor() { super('project not found'); @@ -66,7 +92,10 @@ export class ProjectNotFoundError extends Error { export class ProjectAccessDeniedError extends Error { readonly status = 403 as const; - constructor(message = 'forbidden') { + constructor( + message = 'forbidden', + readonly userMessage: string = PROJECT_PERMISSION_MESSAGES.read, + ) { super(message); this.name = 'ProjectAccessDeniedError'; } @@ -74,11 +103,18 @@ export class ProjectAccessDeniedError extends Error { export class ProjectOwnerSuspendedError extends ProjectAccessDeniedError { constructor() { - super('project owner suspended'); + super('project owner suspended', PROJECT_PERMISSION_MESSAGES['owner-suspended']); this.name = 'ProjectOwnerSuspendedError'; } } +export function projectAccessErrorBody(error: ProjectNotFoundError | ProjectAccessDeniedError): { + error: string; + message: string; +} { + return { error: error.message, message: error.userMessage }; +} + /** * Assert a live project role and an owner not manually suspended. The owner is * returned separately so plan/quota callers do not consult the actor. @@ -94,7 +130,7 @@ export async function requireProjectCapability( throw new ProjectNotFoundError(); } if (!row.memberRole || !hasProjectCapability(row.memberRole, capability)) { - throw new ProjectAccessDeniedError(); + throw new ProjectAccessDeniedError('forbidden', PROJECT_PERMISSION_MESSAGES[capability]); } if (isAdminSuspended(row.owner.suspended_reason)) { throw new ProjectOwnerSuspendedError(); diff --git a/platform/packages/api/src/routes/auth-oidc.ts b/platform/packages/api/src/routes/auth-oidc.ts index 8f675fa81..0893bd958 100644 --- a/platform/packages/api/src/routes/auth-oidc.ts +++ b/platform/packages/api/src/routes/auth-oidc.ts @@ -14,6 +14,7 @@ import { getRepoInstallation, } from '../github/installation-token.ts'; import { verifyGithubOidcToken } from '../github/oidc-verify.ts'; +import { PROJECT_PERMISSION_MESSAGES } from '../project-access.ts'; import { isAdminSuspended } from '../suspension.ts'; import { getPlanLimits } from '../tiers.ts'; @@ -180,7 +181,10 @@ app.post('/github-oidc', async (context) => { return context.json({ error: 'connection owner not found' }, 401); } if (isAdminSuspended(user.suspended_reason)) { - return context.json({ error: 'project owner suspended' }, 403); + return context.json( + { error: 'project owner suspended', message: PROJECT_PERMISSION_MESSAGES['owner-suspended'] }, + 403, + ); } // Only branch pushes to the connected branch may mint a deploy token. This @@ -318,7 +322,10 @@ app.post('/github-oidc', async (context) => { return context.json({ error: 'project is being deleted' }, 409); } if (result.outcome === 'owner-suspended') { - return context.json({ error: 'project owner suspended' }, 403); + return context.json( + { error: 'project owner suspended', message: PROJECT_PERMISSION_MESSAGES['owner-suspended'] }, + 403, + ); } const build = result.build; // The build helper re-reads and locks the project, so `build.user_id` is the diff --git a/platform/packages/api/src/routes/delete.ts b/platform/packages/api/src/routes/delete.ts index 46268dedb..1487b2450 100644 --- a/platform/packages/api/src/routes/delete.ts +++ b/platform/packages/api/src/routes/delete.ts @@ -1,6 +1,10 @@ import { Hono } from 'hono'; import type { ApiEnv } from '../env.ts'; -import { ProjectAccessDeniedError, ProjectNotFoundError } from '../project-access.ts'; +import { + ProjectAccessDeniedError, + ProjectNotFoundError, + projectAccessErrorBody, +} from '../project-access.ts'; import { ProjectOperationInProgressError, teardownProject } from '../provisioning/cleanup.ts'; import { requireProjectRouteCapability } from './project-access.ts'; @@ -27,7 +31,7 @@ app.delete('/:id', async (context) => { })); } catch (error) { if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { - return context.json({ error: error.message }, error.status); + return context.json(projectAccessErrorBody(error), error.status); } if (error instanceof ProjectOperationInProgressError) { return context.json({ error: error.message }, 409); diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index 9887a5547..7149facd6 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -90,6 +90,8 @@ import { createDeployLogger, type DeployLogger } from '../observability/logger.t import { ProjectAccessDeniedError, ProjectOwnerSuspendedError, + PROJECT_PERMISSION_MESSAGES, + projectAccessErrorBody, requireProjectCapability, } from '../project-access.ts'; import { ensureProjectIngress } from '../project-ingress.ts'; @@ -2007,10 +2009,7 @@ app.post('/:id/deploy/upload-urls', async (context) => { }); } catch (err) { if (err instanceof ProjectAccessDeniedError) { - return context.json( - { error: err instanceof ProjectOwnerSuspendedError ? err.message : 'forbidden' }, - 403, - ); + return context.json(projectAccessErrorBody(err), 403); } if (err instanceof Error && err.message === UPLOAD_URLS_PROJECT_DELETING) { return context.json({ error: 'project is being deleted' }, 410); @@ -2077,7 +2076,14 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise): Promise 0 && { migrations: migrationsApplied }), }); } catch (err) { - const raw = err instanceof Error ? err.message : String(err); + const raw = + err instanceof ProjectAccessDeniedError + ? err.userMessage + : err instanceof Error + ? err.message + : String(err); const msg = sanitizeDeployErrorMessage(raw); if (msg !== raw) { logger.error('catch', 'raw_error_before_sanitization', err, { @@ -5689,10 +5704,16 @@ app.post('/:id/deploy/preflight', async (context) => { return context.json({ error: 'project not found' }, 404); } if (resolution.kind === 'forbidden') { - return context.json({ error: 'forbidden' }, 403); + return context.json({ error: 'forbidden', message: PROJECT_PERMISSION_MESSAGES.deploy }, 403); } if (resolution.kind === 'owner-suspended') { - return context.json({ error: 'project owner suspended' }, 403); + return context.json( + { + error: 'project owner suspended', + message: PROJECT_PERMISSION_MESSAGES['owner-suspended'], + }, + 403, + ); } if (resolution.kind === 'deleting') { return context.json({ error: 'project is being deleted' }, 410); diff --git a/platform/packages/api/src/routes/email.ts b/platform/packages/api/src/routes/email.ts index 97673095d..c43844dd5 100644 --- a/platform/packages/api/src/routes/email.ts +++ b/platform/packages/api/src/routes/email.ts @@ -34,7 +34,11 @@ import { } from '../email/settings.ts'; import type { ApiEnv } from '../env.ts'; import { isForeignKeyViolation, isUniqueViolation } from '../pg-errors.ts'; -import { ProjectAccessDeniedError, ProjectNotFoundError } from '../project-access.ts'; +import { + ProjectAccessDeniedError, + ProjectNotFoundError, + projectAccessErrorBody, +} from '../project-access.ts'; import { runEmailDestinationCleanup } from '../provisioning/email-destination-cleanup.ts'; import { addDestination, @@ -703,7 +707,7 @@ function serializeDestination(row: EmailDestination) { function projectAccessErrorResponse(context: Context, error: unknown): Response | null { if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { - return context.json({ error: error.message }, error.status); + return context.json(projectAccessErrorBody(error), error.status); } return null; } diff --git a/platform/packages/api/src/routes/project-access.ts b/platform/packages/api/src/routes/project-access.ts index e24d0c160..9d81b9b0f 100644 --- a/platform/packages/api/src/routes/project-access.ts +++ b/platform/packages/api/src/routes/project-access.ts @@ -4,6 +4,7 @@ import type { ApiEnv } from '../env.ts'; import { ProjectAccessDeniedError, ProjectNotFoundError, + projectAccessErrorBody, requireProjectCapability, type ProjectAccess, } from '../project-access.ts'; @@ -19,7 +20,7 @@ export async function requireProjectRouteCapability( return await requireProjectCapability(db, projectId, context.get('user').id, capability); } catch (error) { if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { - return context.json({ error: error.message }, error.status); + return context.json(projectAccessErrorBody(error), error.status); } throw error; } diff --git a/platform/packages/api/src/routes/project-team.ts b/platform/packages/api/src/routes/project-team.ts index c4c1cae40..79e33617a 100644 --- a/platform/packages/api/src/routes/project-team.ts +++ b/platform/packages/api/src/routes/project-team.ts @@ -17,6 +17,7 @@ import { import { sendProjectInvitationEmail } from '../email.ts'; import type { ApiEnv } from '../env.ts'; import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; +import { PROJECT_PERMISSION_MESSAGES } from '../project-access.ts'; import type { ProjectInvitation, ProjectInvitationStatus, @@ -102,7 +103,7 @@ function mutationErrorResponse(context: Context, error: unknown): Respon if (!(error instanceof ProjectTeamMutationError)) { return null; } - const messages: Record = { + const errors: Record = { 'project-not-found': 'project not found', 'project-deleting': 'project is being deleted', 'target-user-not-found': 'registered user not found', @@ -117,7 +118,35 @@ function mutationErrorResponse(context: Context, error: unknown): Respon 'owner-suspended': 'project owner suspended', forbidden: 'forbidden', }; - return context.json({ error: messages[error.reason] }, error.status); + const messages: Record = { + 'project-not-found': PROJECT_PERMISSION_MESSAGES['not-found'], + 'project-deleting': + 'This project is being deleted. Wait for deletion to finish and select another project.', + 'target-user-not-found': + 'No eligible registered account matches that email. Check the address and ask the person to sign in to this platform before inviting them.', + 'owner-conflict': + 'The project owner already has full access and cannot be managed as a team member. Manage other members instead.', + 'member-conflict': + 'This account is already a project member. Refresh the member list to review its access.', + 'member-not-found': + 'This project member could not be found. Refresh the member list and send a new invitation if access is still needed.', + 'invitation-not-found': + 'This invitation could not be found. Check your pending invitations or ask a project admin or owner to send a new one.', + 'invitation-not-pending': + 'This invitation is no longer pending. If you still need access, ask a project admin or owner to send a new invitation.', + 'invitation-not-target': + 'This invitation belongs to a different account. Sign in with the account that received it, or ask a project admin or owner to invite your current account.', + 'invitation-expired': + 'This invitation has expired. Ask a project admin or owner to resend it, then accept the renewed invitation.', + 'pending-invitation-limit': + 'This project has reached its pending invitation limit. Revoke unused invitations or wait for them to expire, then try again.', + 'owner-suspended': PROJECT_PERMISSION_MESSAGES['owner-suspended'], + forbidden: PROJECT_PERMISSION_MESSAGES.members, + }; + return context.json( + { error: errors[error.reason], message: messages[error.reason] }, + error.status, + ); } async function deliverInvitation( @@ -353,7 +382,14 @@ export const projectTeamRoutes = new Hono() ); const user = await getUser(context.get('db'), member.user_id); if (!user) { - return context.json({ error: 'project member not found' }, 404); + return context.json( + { + error: 'project member not found', + message: + 'This project member could not be found. Refresh the member list and send a new invitation if access is still needed.', + }, + 404, + ); } return context.json({ member: memberJson(member, user) }); } catch (error) { @@ -399,7 +435,14 @@ export const projectTeamRoutes = new Hono() return access; } if (access.role === 'owner') { - return context.json({ error: 'the project owner cannot leave the project' }, 409); + return context.json( + { + error: 'the project owner cannot leave the project', + message: + 'The project owner cannot leave. Ask a platform administrator to transfer ownership before leaving the project.', + }, + 409, + ); } try { const { cancelledBuilds } = await removeProjectMember(context.get('db'), { diff --git a/platform/packages/api/src/routes/project-tokens.ts b/platform/packages/api/src/routes/project-tokens.ts index a24d339bc..ec91f2d25 100644 --- a/platform/packages/api/src/routes/project-tokens.ts +++ b/platform/packages/api/src/routes/project-tokens.ts @@ -3,6 +3,7 @@ import { Hono, type Context } from 'hono'; import { PROJECT_CREDENTIAL_MAX_DAYS, signProjectCredentialToken } from '../auth/deploy-token.ts'; import { getProject, type DbOrTx } from '../db.ts'; import type { ApiEnv } from '../env.ts'; +import { PROJECT_PERMISSION_MESSAGES } from '../project-access.ts'; import { projectDeployCredentials, projects } from '../schema.ts'; import type { Project } from '../types.ts'; @@ -73,10 +74,13 @@ function publicCredential(row: typeof projectDeployCredentials.$inferSelect) { app.get('/:projectId/tokens', async (context) => { const project = await ownedProject(context); if (project === 'forbidden') { - return context.json({ error: 'forbidden' }, 403); + return context.json({ error: 'forbidden', message: PROJECT_PERMISSION_MESSAGES.tokens }, 403); } if (!project) { - return context.json({ error: 'project not found' }, 404); + return context.json( + { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] }, + 404, + ); } const rows = await context .get('db') @@ -91,7 +95,10 @@ app.get('/:projectId/tokens', async (context) => { app.post('/:projectId/tokens', async (context) => { const projectId = context.req.param('projectId'); if (!projectId) { - return context.json({ error: 'project not found' }, 404); + return context.json( + { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] }, + 404, + ); } const body = await context.req .json<{ name?: unknown; expiresInDays?: unknown }>() @@ -132,13 +139,23 @@ app.post('/:projectId/tokens', async (context) => { return created!; }); if (row === 'forbidden') { - return context.json({ error: 'forbidden' }, 403); + return context.json({ error: 'forbidden', message: PROJECT_PERMISSION_MESSAGES.tokens }, 403); } if (row === 'project-deleting') { - return context.json({ error: 'project is being deleted' }, 409); + return context.json( + { + error: 'project is being deleted', + message: + 'This project is being deleted. Wait for deletion to finish and select another project.', + }, + 409, + ); } if (!row) { - return context.json({ error: 'project not found' }, 404); + return context.json( + { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] }, + 404, + ); } return context.json({ ...publicCredential(row), token }, 201); }); @@ -146,7 +163,10 @@ app.post('/:projectId/tokens', async (context) => { app.post('/:projectId/tokens/:credentialId/renew', async (context) => { const projectId = context.req.param('projectId'); if (!projectId) { - return context.json({ error: 'project not found' }, 404); + return context.json( + { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] }, + 404, + ); } const body = await context.req .json<{ expiresInDays?: unknown }>() @@ -212,16 +232,33 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { return replacement!; }); if (result === 'project-not-found') { - return context.json({ error: 'project not found' }, 404); + return context.json( + { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] }, + 404, + ); } if (result === 'forbidden') { - return context.json({ error: 'forbidden' }, 403); + return context.json({ error: 'forbidden', message: PROJECT_PERMISSION_MESSAGES.tokens }, 403); } if (result === 'project-deleting') { - return context.json({ error: 'project is being deleted' }, 409); + return context.json( + { + error: 'project is being deleted', + message: + 'This project is being deleted. Wait for deletion to finish and select another project.', + }, + 409, + ); } if (!result) { - return context.json({ error: 'active credential not found' }, 404); + return context.json( + { + error: 'active credential not found', + message: + 'This active deploy token could not be found. Refresh the token list; it may have been revoked or renewed.', + }, + 404, + ); } return context.json({ ...publicCredential(result), token }); }); @@ -229,7 +266,10 @@ app.post('/:projectId/tokens/:credentialId/renew', async (context) => { app.delete('/:projectId/tokens/:credentialId', async (context) => { const projectId = context.req.param('projectId'); if (!projectId) { - return context.json({ error: 'project not found' }, 404); + return context.json( + { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] }, + 404, + ); } const user = context.get('user'); const row = await context.get('db').transaction(async (tx) => { @@ -254,13 +294,23 @@ app.delete('/:projectId/tokens/:credentialId', async (context) => { return revoked ?? null; }); if (row === 'project-not-found') { - return context.json({ error: 'project not found' }, 404); + return context.json( + { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] }, + 404, + ); } if (row === 'forbidden') { - return context.json({ error: 'forbidden' }, 403); + return context.json({ error: 'forbidden', message: PROJECT_PERMISSION_MESSAGES.tokens }, 403); } if (!row) { - return context.json({ error: 'active credential not found' }, 404); + return context.json( + { + error: 'active credential not found', + message: + 'This active deploy token could not be found. Refresh the token list; it may have been revoked or renewed.', + }, + 404, + ); } return context.json({ ok: true, id: row.id }); }); diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index da1b09a10..c25296215 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -53,6 +53,7 @@ import { type ApiEnv, isDevBypass } from '../env.ts'; import { validateSubmittedExecutionPlans } from '../migrations/execution-plan.ts'; import { ensureTrackingTable, queryD1, runRemoteMigrations } from '../migrations/remote-runner.ts'; import { getProjectDeploymentWorkerName, getProjectQueueName } from '../naming.ts'; +import { PROJECT_PERMISSION_MESSAGES } from '../project-access.ts'; import { encryptProjectSecret, decryptProjectSecret } from '../project-secrets.ts'; import { CloudflareApiError, @@ -198,13 +199,24 @@ class RollbackError extends Error { * concatenation are not safe to copy-paste. */ public readonly destructiveMigrations?: ReadonlyArray, + public readonly userMessage?: string, ) { super(message); this.name = 'RollbackError'; } } -async function getProjectAccessErrorMessage(response: Response): Promise { +async function getProjectAccessErrorBody( + response: Response, +): Promise<{ error: string; message: string }> { + const fallback = + response.status === 404 + ? { error: 'project not found', message: PROJECT_PERMISSION_MESSAGES['not-found'] } + : { + error: 'forbidden', + message: + 'You do not have permission for this action. Ask a project admin or owner to check your access.', + }; try { const body: unknown = await response.json(); if ( @@ -213,12 +225,18 @@ async function getProjectAccessErrorMessage(response: Response): Promise 'error' in body && typeof body.error === 'string' ) { - return body.error; + return { + error: body.error, + message: + 'message' in body && typeof body.message === 'string' && body.message.trim() + ? body.message + : fallback.message, + }; } } catch { // Preserve the established fallback if a future access response is not JSON. } - return response.status === 404 ? 'project not found' : 'forbidden'; + return fallback; } class DatabaseUrlPersistenceError extends Error { @@ -811,7 +829,14 @@ const app = new Hono() return minted; } if (!minted) { - return context.json({ error: 'deploy token build is no longer active' }, 403); + return context.json( + { + error: 'deploy token build is no longer active', + message: + 'This build is no longer active. Start a new build to obtain a valid deployment credential.', + }, + 403, + ); } token = minted; } else { @@ -972,9 +997,12 @@ const app = new Hono() txDb, ); if (lockedAccess instanceof Response) { + const denied = await getProjectAccessErrorBody(lockedAccess); throw new RollbackError( lockedAccess.status as 403 | 404, - await getProjectAccessErrorMessage(lockedAccess), + denied.error, + undefined, + denied.message, ); } const lockedProject = lockedAccess.project; @@ -1076,9 +1104,12 @@ const app = new Hono() txDb, ); if (lockedAccess instanceof Response) { + const denied = await getProjectAccessErrorBody(lockedAccess); throw new RollbackError( lockedAccess.status as 403 | 404, - await getProjectAccessErrorMessage(lockedAccess), + denied.error, + undefined, + denied.message, ); } @@ -1691,7 +1722,10 @@ const app = new Hono() err.status, ); } else { - rollbackErrorResponse = context.json({ error: err.message }, err.status); + rollbackErrorResponse = context.json( + { error: err.message, ...(err.userMessage ? { message: err.userMessage } : {}) }, + err.status, + ); } } else if (!rollbackCommitted) { rollbackErrorToThrow = err; @@ -1916,9 +1950,7 @@ const app = new Hono() if (lockedAccess instanceof Response) { return { status: lockedAccess.status as 403 | 404, - body: { - error: await getProjectAccessErrorMessage(lockedAccess), - }, + body: await getProjectAccessErrorBody(lockedAccess), }; } @@ -2117,7 +2149,7 @@ const app = new Hono() status: lockedAccess.status as 403 | 404, body: { ok: false, - error: await getProjectAccessErrorMessage(lockedAccess), + ...(await getProjectAccessErrorBody(lockedAccess)), }, }; } @@ -3106,7 +3138,10 @@ const app = new Hono() return access; } if (access.role !== 'owner') { - return context.json({ error: 'forbidden' }, 403); + return context.json( + { error: 'forbidden', message: PROJECT_PERMISSION_MESSAGES['database-url'] }, + 403, + ); } const row = await getProjectDatabaseUrl(context.get('db'), projectId); diff --git a/platform/packages/api/test/integration/admin-bootstrap.test.ts b/platform/packages/api/test/integration/admin-bootstrap.test.ts index e26473a41..ea91132fa 100644 --- a/platform/packages/api/test/integration/admin-bootstrap.test.ts +++ b/platform/packages/api/test/integration/admin-bootstrap.test.ts @@ -1,3 +1,4 @@ +import { eq } from 'drizzle-orm'; import { afterAll, afterEach, @@ -148,6 +149,9 @@ describe('first administrator dashboard login', () => { const response = await login(); expect(response.status).toBe(403); expect(response.headers.get('Set-Cookie')).not.toContain('admin_token='); + const html = await response.text(); + expect(html).toContain('Sign in with an administrator account for this platform.'); + expect(html).toContain('href="/admin/logout"'); expect(await env.db.select().from(users)).toHaveLength(0); expect(await env.db.select().from(oauthAccounts)).toHaveLength(0); }, @@ -176,6 +180,29 @@ describe('first administrator dashboard login', () => { expect(await env.db.select().from(oauthAccounts)).toHaveLength(1); }); + it('gives suspended administrators accurate recovery guidance without signing them in', async () => { + const admin = await createTestUser( + env.db, + 'suspended-owner', + 'github', + '4242', + 'free', + 'admin', + ); + await env.db + .update(users) + .set({ suspended_reason: 'private review details' }) + .where(eq(users.id, admin.id)); + const response = await login(); + expect(response.status).toBe(403); + expect(response.headers.get('Set-Cookie')).not.toContain('admin_token='); + const html = await response.text(); + expect(html).toContain('Administrator access suspended'); + expect(html).toContain('Contact another platform administrator to restore access.'); + expect(html).toContain('href="/admin/logout"'); + expect(html).not.toContain('private review details'); + }); + it('requires the initiating browser cookie before creating the owner', async () => { const request = await startLogin(); request.headers.delete('Cookie'); diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 58ba0ecd1..beac93f80 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -84,6 +84,10 @@ describe('auth middleware', () => { const user = await createTestUser(env.db, 'regular'); const res = await req('/admin/', user.adminJwt); expect(res.status).toBe(403); + const html = await res.text(); + expect(html).toContain('Sign in with an administrator account for this platform.'); + expect(html).toContain('href="/admin/logout"'); + expect(html).not.toContain('suspended'); }); test('allows admin user', async () => { @@ -109,6 +113,11 @@ describe('auth middleware', () => { const res = await req('/admin/', admin.adminJwt); expect(res.status).toBe(403); + const html = await res.text(); + expect(html).toContain('Administrator access suspended'); + expect(html).toContain('Contact another platform administrator to restore access.'); + expect(html).toContain('href="/admin/logout"'); + expect(html).not.toContain('admin_suspended'); }); test('allows an administrator suspended only by a usage limit', async () => { @@ -154,6 +163,40 @@ describe('auth middleware', () => { }); expect(res.status).toBe(403); }); + + test.each(['https://attacker.example.com/private', 'http://localhost'])( + 'renders recovery navigation for rejected browser writes from %s', + async (origin) => { + const admin = await createTestUser( + env.db, + 'admin-recovery', + 'github', + undefined, + 'free', + 'admin', + ); + // The same host with a different scheme passes the first guard and reaches the + // stricter CSRF middleware; both guards must give browser users a way back. + const res = await app.fetch( + new Request('https://localhost/admin/signup-control/toggle', { + method: 'POST', + headers: { + Cookie: `admin_token=${admin.adminJwt}`, + Origin: origin, + Accept: 'text/html,application/xhtml+xml', + }, + }), + env, + ); + expect(res.status).toBe(403); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Return to the admin page, refresh it, and try again.'); + expect(html).toContain('href="/admin/"'); + expect(html).not.toContain(origin); + expect(await env.db.select().from(operatorEvents)).toHaveLength(0); + }, + ); }); describe('GET /admin/ (dashboard)', () => { @@ -1934,6 +1977,9 @@ describe('POST /admin/signup-control/add + delete', () => { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } as Record, }); expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain('href="/admin/"'); + expect(html).toContain('Return to admin'); }); test('rejects delete with out-of-bounds index', async () => { diff --git a/platform/packages/api/test/integration/admin-email.test.ts b/platform/packages/api/test/integration/admin-email.test.ts index 7cdb47c4b..f88f1a8dc 100644 --- a/platform/packages/api/test/integration/admin-email.test.ts +++ b/platform/packages/api/test/integration/admin-email.test.ts @@ -195,6 +195,7 @@ describe('admin write guard (cross-origin)', () => { expect(res.status).toBe(403); expect((await res.json()) as { error: string }).toEqual({ error: 'cross-site request blocked', + message: 'Return to the admin page, refresh it, and try again.', }); // The guard must run before the handler, not merely alongside it. expect(await env.ROUTING.get(`email-suspended:${projectId}`)).toBeNull(); diff --git a/platform/packages/api/test/integration/api-delete.test.ts b/platform/packages/api/test/integration/api-delete.test.ts index ef815451d..94c29da5a 100644 --- a/platform/packages/api/test/integration/api-delete.test.ts +++ b/platform/packages/api/test/integration/api-delete.test.ts @@ -135,7 +135,10 @@ describe('DELETE /projects/:id', () => { ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/Ask (a project admin or owner|the owner)/), + }); expect(transferred).toBe(true); expect(cf).not.toHaveBeenCalled(); expect(await db.getProject(env.db, project.id)).toMatchObject({ @@ -166,7 +169,10 @@ describe('DELETE /projects/:id', () => { env, ); expect(res.status).toBe(404); - expect(await res.json()).toEqual({ error: 'project not found' }); + expect(await res.json()).toEqual({ + error: 'project not found', + message: expect.stringMatching(/check the project ID/i), + }); }); it('returns 403 when user does not own project', async () => { @@ -183,7 +189,10 @@ describe('DELETE /projects/:id', () => { env, ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/Ask (a project admin or owner|the owner)/), + }); }); it('returns 409 while a rollback lease protects external compensation', async () => { diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 0c36757b5..39c367d4d 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -355,7 +355,7 @@ describe('POST /projects/:id/deploy', () => { const res = await app.fetch(await buildDeployRequest(env, 'proj_nonexistent', user.jwt), env); expect(res.status).toBe(404); - expect(await res.json()).toEqual({ error: 'project not found' }); + expect(await res.json()).toEqual({ error: 'project not found', message: expect.any(String) }); }); it('returns 403 when user does not own project', async () => { @@ -366,7 +366,7 @@ describe('POST /projects/:id/deploy', () => { const res = await app.fetch(await buildDeployRequest(env, project.id, bob.jwt), env); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ error: 'forbidden', message: expect.any(String) }); }); it('denies readers and bills collaborator deploys to the project owner', async () => { @@ -512,7 +512,9 @@ describe('POST /projects/:id/deploy', () => { expect(response.status).toBe(200); const events = await readAllEvents(response); expect(events.some((event) => isDeployEvent(event) && event.event === 'done')).toBe(false); - expect(requireEvent(events, 'error').message).toBe('forbidden'); + expect(requireEvent(events, 'error').message).toMatch( + /collaborator.*project admin or owner/i, + ); expect(revoked).toBe(true); const [deployment] = await env.db .select({ status: deployments.status }) @@ -2074,7 +2076,7 @@ describe('POST /projects/:id/deploy', () => { expect(events.at(-1)).toMatchObject({ event: 'error', step: 'migration', - message: 'forbidden', + message: expect.stringMatching(/collaborator.*project admin or owner/i), }); expect(migrateCalls).toBe(0); } finally { @@ -3220,7 +3222,7 @@ describe('Deploy serialization (concurrent deploy guard)', () => { ); expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'forbidden' }); + expect(await response.json()).toEqual({ error: 'forbidden', message: expect.any(String) }); expect(downgraded).toBe(true); expect(await getDeployment(env.db, 'dpl_cancel_access_race')).toMatchObject({ status: 'uploading', diff --git a/platform/packages/api/test/integration/api-domains.test.ts b/platform/packages/api/test/integration/api-domains.test.ts index 93de15e26..d8cefc546 100644 --- a/platform/packages/api/test/integration/api-domains.test.ts +++ b/platform/packages/api/test/integration/api-domains.test.ts @@ -198,7 +198,10 @@ describe('POST /projects/:id/domains', () => { ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/Ask (a project admin or owner|the owner)/), + }); expect( await env.db.select().from(domains).where(eq(domains.hostname, 'access-race.example.com')), ).toEqual([]); diff --git a/platform/packages/api/test/integration/api-me.test.ts b/platform/packages/api/test/integration/api-me.test.ts index fe1db661f..7dfbbf775 100644 --- a/platform/packages/api/test/integration/api-me.test.ts +++ b/platform/packages/api/test/integration/api-me.test.ts @@ -37,7 +37,10 @@ describe('GET /me', () => { it('returns 401 without auth header', async () => { const res = await app.fetch(new Request('http://localhost/me'), env); expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'unauthorized' }); + expect(await res.json()).toEqual({ + error: 'unauthorized', + message: expect.stringMatching(/sign in to this platform/i), + }); }); it('returns 401 with invalid JWT', async () => { @@ -50,6 +53,7 @@ describe('GET /me', () => { expect(res.status).toBe(401); expect(await res.json()).toEqual({ error: 'invalid or expired token', + message: expect.stringMatching(/sign in again.*replace the credential/i), }); }); @@ -90,7 +94,10 @@ describe('GET /me', () => { env, ); expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'user not found' }); + expect(await res.json()).toEqual({ + error: 'user not found', + message: expect.stringMatching(/sign in with an active account/i), + }); }); it('returns 403 when user is admin-suspended', async () => { @@ -107,7 +114,10 @@ describe('GET /me', () => { env, ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'account suspended' }); + expect(await res.json()).toEqual({ + error: 'account suspended', + message: expect.stringMatching(/contact a platform administrator/i), + }); }); it('allows usage-suspended user through', async () => { diff --git a/platform/packages/api/test/integration/api-migrations.test.ts b/platform/packages/api/test/integration/api-migrations.test.ts index 115d6b474..80a8b52aa 100644 --- a/platform/packages/api/test/integration/api-migrations.test.ts +++ b/platform/packages/api/test/integration/api-migrations.test.ts @@ -699,7 +699,10 @@ describe('POST /projects/:id/migrations/apply', () => { expect(suspended).toBe(true); expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await response.json()).toEqual({ + error: 'project owner suspended', + message: expect.stringMatching(/contact the platform administrator/i), + }); }); it('returns 400 when no D1 resource exists', async () => { diff --git a/platform/packages/api/test/integration/api-oidc.test.ts b/platform/packages/api/test/integration/api-oidc.test.ts index a5e9015a1..adb9297e4 100644 --- a/platform/packages/api/test/integration/api-oidc.test.ts +++ b/platform/packages/api/test/integration/api-oidc.test.ts @@ -652,7 +652,10 @@ describe('POST /auth/github-oidc', () => { const res = await exchange(app, env, { oidcToken: 'good', project: project.slug }); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'project owner suspended' }); + expect(await res.json()).toEqual({ + error: 'project owner suspended', + message: expect.any(String), + }); expect(await getProjectBuilds(env.db, project.id)).toEqual([]); expect(checkRunCreateCount).toBe(0); }); diff --git a/platform/packages/api/test/integration/api-project-access-routes.test.ts b/platform/packages/api/test/integration/api-project-access-routes.test.ts index 82ae44fdc..3df434778 100644 --- a/platform/packages/api/test/integration/api-project-access-routes.test.ts +++ b/platform/packages/api/test/integration/api-project-access-routes.test.ts @@ -2,7 +2,19 @@ import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { verifyPrerenderToken } from '../../src/auth/deploy-token.ts'; import app from '../../src/index.ts'; -import { projectMembers, projectOwnershipPeriods, users } from '../../src/schema.ts'; +import { + deployments, + domains, + projectAccessEvents, + projectDeployCredentials, + projectInvitations, + projectMembers, + projectOwnershipPeriods, + projects, + projectSecrets, + resources, + users, +} from '../../src/schema.ts'; import { JWT_SECRET, clearTables, @@ -30,14 +42,214 @@ beforeEach(async () => { vi.unstubAllGlobals(); }); -function authed(path: string, jwt: string, method = 'GET'): Request { +function authed(path: string, jwt: string, method = 'GET', body?: unknown): Request { return new Request(`http://localhost${path}`, { method, - headers: { Authorization: `Bearer ${jwt}` }, + headers: { + Authorization: `Bearer ${jwt}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(method !== 'GET' && body !== undefined ? { body: JSON.stringify(body) } : {}), }); } +async function projectState() { + return Promise.all([ + env.db.select().from(projects), + env.db.select().from(projectMembers), + env.db.select().from(projectInvitations), + env.db.select().from(projectAccessEvents), + env.db.select().from(projectDeployCredentials), + env.db.select().from(deployments), + env.db.select().from(projectSecrets), + env.db.select().from(domains), + env.db.select().from(resources), + ]); +} + describe('project route membership capabilities', () => { + it.each([ + ['POST', '/deploy', 'Deploying requires the collaborator, admin, or owner role'], + ['POST', '/deploy/preflight', 'Deploying requires the collaborator, admin, or owner role'], + ['POST', '/deploy/upload-urls', 'Deploying requires the collaborator, admin, or owner role'], + ['POST', '/prerender-token', 'Deploying requires the collaborator, admin, or owner role'], + ['POST', '/rollback', 'Deploying requires the collaborator, admin, or owner role'], + [ + 'POST', + '/deployments/missing/cancel', + 'Deploying requires the collaborator, admin, or owner role', + ], + [ + 'PUT', + '/secrets/TEST_SECRET', + 'Changing project configuration requires the collaborator, admin, or owner role', + ], + [ + 'DELETE', + '/secrets/TEST_SECRET', + 'Changing project configuration requires the collaborator, admin, or owner role', + ], + [ + 'POST', + '/migrations/apply', + 'Changing project configuration requires the collaborator, admin, or owner role', + ], + [ + 'PUT', + '/database-url', + 'Changing project configuration requires the collaborator, admin, or owner role', + ], + ['POST', '/domains', 'Changing project settings requires the admin or owner role'], + [ + 'POST', + '/invitations', + 'Managing project members and invitations requires the admin or owner role', + ], + [ + 'PATCH', + '/members/missing', + 'Managing project members and invitations requires the admin or owner role', + ], + [ + 'DELETE', + '/members/missing', + 'Managing project members and invitations requires the admin or owner role', + ], + ['DELETE', '', 'Only the project owner can delete this project'], + ])('explains reader denial for %s %s without side effects', async (method, suffix, guidance) => { + const owner = await createTestUser(env.db, 'private-owner'); + const reader = await createTestUser(env.db, 'reader'); + const project = await createTestProject(env.db, owner.id, 'private-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }); + const before = await projectState(); + const provider = vi.fn(() => { + throw new Error('Denied request must not contact a provider'); + }); + vi.stubGlobal('fetch', provider); + const routingWrite = vi.spyOn(env.ROUTING, 'put'); + const response = await app.fetch( + authed( + `/projects/${project.id}${suffix}`, + reader.jwt, + method, + method === 'DELETE' + ? undefined + : { + value: 'must-not-be-written', + role: 'admin', + email: 'nobody@example.com', + }, + ), + env, + ); + + expect(response.status).toBe(403); + const body = (await response.json()) as { error: string; message: string }; + expect(body).toEqual({ error: 'forbidden', message: expect.stringContaining(guidance) }); + expect(body.message).toMatch(/Ask (a project admin or owner|the owner)/); + for (const privateValue of [owner.id, owner.login, project.id, project.slug]) { + expect(JSON.stringify(body)).not.toContain(privateValue); + } + expect(await projectState()).toEqual(before); + expect(provider).not.toHaveBeenCalled(); + expect(routingWrite).not.toHaveBeenCalled(); + }); + + it.each(['reader', 'collaborator', 'admin', 'nonmember'] as const)( + 'explains owner-only token access to a %s without changing credentials', + async (role) => { + const owner = await createTestUser(env.db, 'private-owner'); + const actor = await createTestUser(env.db, 'actor'); + const project = await createTestProject(env.db, owner.id, 'private-project'); + if (role !== 'nonmember') { + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: actor.id, + role, + added_by_user_id: owner.id, + }); + } + await env.db.insert(projectDeployCredentials).values({ + id: 'existing-credential', + project_id: project.id, + issued_by: owner.id, + name: 'private-token-name', + expires_at: new Date(Date.now() + 86_400_000).toISOString(), + }); + const before = await projectState(); + for (const [method, suffix] of [ + ['GET', '/tokens'], + ['POST', '/tokens'], + ['POST', '/tokens/existing-credential/renew'], + ['DELETE', '/tokens/existing-credential'], + ]) { + const response = await app.fetch( + authed(`/projects/${project.id}${suffix}`, actor.jwt, method), + env, + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'forbidden', + message: + 'Only the project owner can manage deploy tokens. Ask the owner to create or manage a token.', + }); + expect(await projectState()).toEqual(before); + } + }, + ); + + it('does not guess a nonmember role or expose private details in read denials', async () => { + const owner = await createTestUser(env.db, 'private-owner'); + const stranger = await createTestUser(env.db, 'stranger'); + const project = await createTestProject(env.db, owner.id, 'private-project'); + const before = await projectState(); + const denied = await app.fetch(authed(`/projects/${project.id}`, stranger.jwt), env); + expect(denied.status).toBe(403); + expect(await denied.json()).toEqual({ + error: 'forbidden', + message: + 'You need project access to view this project. Ask a project admin or owner to invite you.', + }); + const missing = await app.fetch(authed('/projects/missing', stranger.jwt), env); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + error: 'project not found', + message: + 'This project could not be found. Check the project ID or refresh your project list.', + }); + expect(await projectState()).toEqual(before); + }); + + it('explains owner suspension to a member without exposing the suspension reason', async () => { + const owner = await createTestUser(env.db, 'private-owner'); + const reader = await createTestUser(env.db, 'reader'); + const project = await createTestProject(env.db, owner.id, 'private-project'); + await env.db.insert(projectMembers).values({ + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }); + await env.db + .update(users) + .set({ suspended_reason: 'private policy violation details' }) + .where(eq(users.id, owner.id)); + const before = await projectState(); + const response = await app.fetch(authed(`/projects/${project.id}`, reader.jwt), env); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'project owner suspended', + message: + "This project's owner account is suspended. Ask the owner to contact the platform administrator to restore access.", + }); + expect(await projectState()).toEqual(before); + }); + it('lists shared projects with role and owner and grants readers view routes', async () => { const owner = await createTestUser(env.db, 'owner'); const reader = await createTestUser(env.db, 'reader'); diff --git a/platform/packages/api/test/integration/api-project-team.test.ts b/platform/packages/api/test/integration/api-project-team.test.ts index 032a9f4e3..94410effd 100644 --- a/platform/packages/api/test/integration/api-project-team.test.ts +++ b/platform/packages/api/test/integration/api-project-team.test.ts @@ -109,7 +109,12 @@ describe('project team API', () => { env, ); expect(forbidden.status).toBe(403); - expect(await forbidden.json()).toEqual({ error: 'forbidden' }); + expect(await forbidden.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching( + /requires the admin or owner role.*Ask a project admin or owner/, + ), + }); }); it('stops container builds when an authorizing admin is downgraded', async () => { @@ -173,7 +178,10 @@ describe('project team API', () => { env, ); expect(unknown.status).toBe(404); - expect(await unknown.json()).toEqual({ error: 'registered user not found' }); + expect(await unknown.json()).toEqual({ + error: 'registered user not found', + message: expect.stringMatching(/Check the address.*sign in to this platform/), + }); const created = await invite(project.id, owner.jwt, 'target@example.com', 'collaborator'); expect(created.delivery).toEqual({ status: 'not_configured' }); @@ -222,7 +230,10 @@ describe('project team API', () => { env, ); expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await response.json()).toEqual({ + error: 'project owner suspended', + message: expect.stringMatching(/owner account is suspended.*platform administrator/), + }); expect(await env.db.select().from(projectMembers)).toEqual([]); await env.db @@ -260,14 +271,20 @@ describe('project team API', () => { env, ); expect(createResponse.status).toBe(409); - expect(await createResponse.json()).toEqual({ error: 'project is being deleted' }); + expect(await createResponse.json()).toEqual({ + error: 'project is being deleted', + message: expect.stringMatching(/Wait for deletion to finish/), + }); const acceptResponse = await app.fetch( request(`/project-invitations/${invitation.id}/accept`, invited.jwt, { method: 'POST' }), env, ); expect(acceptResponse.status).toBe(409); - expect(await acceptResponse.json()).toEqual({ error: 'project is being deleted' }); + expect(await acceptResponse.json()).toEqual({ + error: 'project is being deleted', + message: expect.stringMatching(/Wait for deletion to finish/), + }); expect(await env.db.select().from(projectMembers)).toEqual([]); expect(await env.db.select().from(projectInvitations)).toMatchObject([ @@ -312,7 +329,10 @@ describe('project team API', () => { ); expect(suspended).toBe(true); expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await response.json()).toEqual({ + error: 'project owner suspended', + message: expect.stringMatching(/owner account is suspended.*platform administrator/), + }); expect(await env.db.select().from(projectInvitations)).toEqual([]); }); @@ -347,7 +367,10 @@ describe('project team API', () => { ); expect(suspended).toBe(true); expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await response.json()).toEqual({ + error: 'project owner suspended', + message: expect.stringMatching(/owner account is suspended.*platform administrator/), + }); expect(await env.db.select().from(projectMembers)).toHaveLength(1); }); @@ -378,6 +401,7 @@ describe('project team API', () => { expect(inviteOwner.status).toBe(409); expect(await inviteOwner.json()).toEqual({ error: 'the project owner cannot be invited or changed as a member', + message: expect.stringMatching(/owner already has full access.*Manage other members/), }); const inviteMember = await app.fetch( @@ -388,7 +412,10 @@ describe('project team API', () => { env, ); expect(inviteMember.status).toBe(409); - expect(await inviteMember.json()).toEqual({ error: 'user is already a project member' }); + expect(await inviteMember.json()).toEqual({ + error: 'user is already a project member', + message: expect.stringMatching(/already a project member.*Refresh the member list/), + }); const ambiguous = await app.fetch( request(`/projects/${project.id}/invitations`, owner.jwt, { @@ -398,7 +425,10 @@ describe('project team API', () => { env, ); expect(ambiguous.status).toBe(404); - expect(await ambiguous.json()).toEqual({ error: 'registered user not found' }); + expect(await ambiguous.json()).toEqual({ + error: 'registered user not found', + message: expect.stringMatching(/Check the address.*sign in to this platform/), + }); expect(await env.db.select().from(projectInvitations)).toEqual([]); }); @@ -467,7 +497,10 @@ describe('project team API', () => { env, ); expect(replay.status).toBe(409); - expect(await replay.json()).toEqual({ error: 'project invitation is no longer pending' }); + expect(await replay.json()).toEqual({ + error: 'project invitation is no longer pending', + message: expect.stringMatching(/no longer pending.*send a new invitation/), + }); expect( await env.db .select() @@ -495,6 +528,8 @@ describe('project team API', () => { const source = await createTestProject(env.db, owner.id, 'source-project'); const other = await createTestProject(env.db, owner.id, 'other-project'); const invitation = await invite(source.id, owner.jwt, 'target@example.com', 'reader'); + const beforeInvitations = await env.db.select().from(projectInvitations); + const beforeEvents = await env.db.select().from(projectAccessEvents); for (const action of ['accept', 'decline']) { const response = await app.fetch( @@ -504,6 +539,14 @@ describe('project team API', () => { env, ); expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'forbidden', + message: + 'This invitation belongs to a different account. Sign in with the account that received it, or ask a project admin or owner to invite your current account.', + }); + expect(await env.db.select().from(projectInvitations)).toEqual(beforeInvitations); + expect(await env.db.select().from(projectAccessEvents)).toEqual(beforeEvents); + expect(await env.db.select().from(projectMembers)).toEqual([]); } const crossProjectResend = await app.fetch( request(`/projects/${other.id}/invitations/${invitation.id}/resend`, owner.jwt, { @@ -512,6 +555,10 @@ describe('project team API', () => { env, ); expect(crossProjectResend.status).toBe(404); + expect(await crossProjectResend.json()).toEqual({ + error: 'project invitation not found', + message: expect.stringMatching(/Check your pending invitations.*send a new one/), + }); const crossProjectRevoke = await app.fetch( request(`/projects/${other.id}/invitations/${invitation.id}`, owner.jwt, { method: 'DELETE', @@ -519,6 +566,12 @@ describe('project team API', () => { env, ); expect(crossProjectRevoke.status).toBe(404); + expect(await crossProjectRevoke.json()).toEqual({ + error: 'project invitation not found', + message: expect.stringMatching(/Check your pending invitations.*send a new one/), + }); + expect(await env.db.select().from(projectInvitations)).toEqual(beforeInvitations); + expect(await env.db.select().from(projectAccessEvents)).toEqual(beforeEvents); const targetAccept = await app.fetch( request(`/project-invitations/${invitation.id}/accept`, target.jwt, { method: 'POST' }), @@ -563,12 +616,21 @@ describe('project team API', () => { env, ); expect(revoked.status).toBe(204); + const beforeInvitations = await env.db.select().from(projectInvitations); + const beforeEvents = await env.db.select().from(projectAccessEvents); const acceptRevoked = await app.fetch( request(`/project-invitations/${first.id}/accept`, target.jwt, { method: 'POST' }), env, ); expect(acceptRevoked.status).toBe(409); + expect(await acceptRevoked.json()).toEqual({ + error: 'project invitation is no longer pending', + message: expect.stringMatching(/no longer pending.*send a new invitation/), + }); + expect(await env.db.select().from(projectInvitations)).toEqual(beforeInvitations); + expect(await env.db.select().from(projectAccessEvents)).toEqual(beforeEvents); + expect(await env.db.select().from(projectMembers)).toEqual([]); }); it('rate-limits create, refresh, and resend delivery while preserving invitations', async () => { @@ -775,6 +837,10 @@ describe('project team API', () => { env, ); expect(ownerLeave.status).toBe(409); + expect(await ownerLeave.json()).toEqual({ + error: 'the project owner cannot leave the project', + message: expect.stringMatching(/Ask a platform administrator to transfer ownership/), + }); const events = await env.db .select() @@ -818,7 +884,10 @@ describe('project team API', () => { env, ); expect(acceptExpired.status).toBe(409); - expect(await acceptExpired.json()).toEqual({ error: 'project invitation has expired' }); + expect(await acceptExpired.json()).toEqual({ + error: 'project invitation has expired', + message: expect.stringMatching(/expired.*resend.*renewed invitation/), + }); expect( ( await env.db.select().from(projectInvitations).where(eq(projectInvitations.id, expired.id)) @@ -936,6 +1005,7 @@ describe('project team API', () => { expect(response.status).toBe(409); expect(await response.json()).toEqual({ error: 'project has too many pending invitations', + message: expect.stringMatching(/pending invitation limit.*Revoke unused invitations/), }); expect(await env.db.select().from(projectInvitations)).toEqual(before); expect(await liveInvitationCount(project.id)).toBe(100); @@ -1090,6 +1160,7 @@ describe('project team API', () => { expect(response.status).toBe(409); expect(await response.json()).toEqual({ error: 'the project owner cannot be invited or changed as a member', + message: expect.stringMatching(/owner already has full access.*Manage other members/), }); expect(await env.db.select().from(projectMembers)).toEqual([]); }); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 26a3e9eb4..605c8901c 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -65,6 +65,10 @@ describe('GET /projects', () => { it('returns 401 without auth', async () => { const res = await app.fetch(new Request('http://localhost/projects'), env); expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: 'unauthorized', + message: expect.stringMatching(/sign in to this platform/i), + }); }); it('returns 401 when user has been deleted', async () => { @@ -78,7 +82,10 @@ describe('GET /projects', () => { env, ); expect(res.status).toBe(401); - expect(await res.json()).toEqual({ error: 'user not found' }); + expect(await res.json()).toEqual({ + error: 'user not found', + message: expect.stringMatching(/sign in with an active account/i), + }); }); it('returns 403 when user is admin-suspended', async () => { @@ -95,7 +102,10 @@ describe('GET /projects', () => { env, ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'account suspended' }); + expect(await res.json()).toEqual({ + error: 'account suspended', + message: expect.stringMatching(/contact a platform administrator/i), + }); }); it('allows usage-suspended user to list projects', async () => { @@ -299,7 +309,10 @@ describe('GET /projects/:id', () => { env, ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'project owner suspended' }); + expect(await res.json()).toEqual({ + error: 'project owner suspended', + message: expect.any(String), + }); }); it('returns 403 when user does not own the project', async () => { @@ -314,7 +327,7 @@ describe('GET /projects/:id', () => { env, ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ error: 'forbidden', message: expect.any(String) }); }); it('returns 404 for nonexistent project', async () => { @@ -391,7 +404,10 @@ describe('project CI credential ownership', () => { env, ); expect(memberResponse.status).toBe(403); - expect(await memberResponse.json()).toEqual({ error: 'forbidden' }); + expect(await memberResponse.json()).toEqual({ + error: 'forbidden', + message: expect.any(String), + }); const missingResponse = await app.fetch( credentialRequest( @@ -404,7 +420,10 @@ describe('project CI credential ownership', () => { env, ); expect(missingResponse.status).toBe(404); - expect(await missingResponse.json()).toEqual({ error: 'project not found' }); + expect(await missingResponse.json()).toEqual({ + error: 'project not found', + message: expect.any(String), + }); } }); @@ -855,7 +874,10 @@ describe('POST /projects/:id/prerender-token', () => { expect(suspended).toBe(true); expect(response.status).toBe(403); - expect(await response.json()).toEqual({ error: 'project owner suspended' }); + expect(await response.json()).toEqual({ + error: 'project owner suspended', + message: expect.any(String), + }); }); it("does not mint a token for another user's project", async () => { @@ -933,7 +955,7 @@ describe('PUT /projects/:id/database-url', () => { const res = await app.fetch(request, env); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ error: 'forbidden', message: expect.any(String) }); expect(downgraded).toBe(true); expect(cf).not.toHaveBeenCalled(); expect( @@ -1229,7 +1251,7 @@ describe('GET /projects/:id/database-url', () => { ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ error: 'forbidden', message: expect.any(String) }); }); it('round-trips the connection string via POST /:id/database', async () => { diff --git a/platform/packages/api/test/integration/api-revalidate.test.ts b/platform/packages/api/test/integration/api-revalidate.test.ts index 524e0f2e4..b5b72bbaf 100644 --- a/platform/packages/api/test/integration/api-revalidate.test.ts +++ b/platform/packages/api/test/integration/api-revalidate.test.ts @@ -150,7 +150,10 @@ describe('POST /projects/:id/revalidate', () => { const res = await responsePromise; expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/Ask (a project admin or owner|the owner)/), + }); expect(await env.ISR_CACHE.get(key)).toBe('cached'); expect( fetchMock.mock.calls.filter(([url]) => String(url).includes('/purge_cache')), diff --git a/platform/packages/api/test/integration/api-secrets.test.ts b/platform/packages/api/test/integration/api-secrets.test.ts index d94f3f92f..deff6a930 100644 --- a/platform/packages/api/test/integration/api-secrets.test.ts +++ b/platform/packages/api/test/integration/api-secrets.test.ts @@ -346,7 +346,10 @@ describe('project secrets API', () => { ); expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: 'forbidden' }); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/Ask (a project admin or owner|the owner)/), + }); }); it('validates secret name', async () => { diff --git a/platform/packages/api/test/integration/deploy-token-scope.test.ts b/platform/packages/api/test/integration/deploy-token-scope.test.ts index acd91c0b2..8852610e9 100644 --- a/platform/packages/api/test/integration/deploy-token-scope.test.ts +++ b/platform/packages/api/test/integration/deploy-token-scope.test.ts @@ -312,6 +312,10 @@ describe('deploy token scope enforcement', () => { env, ); expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/credential.*assigned project.*sign in with your account/i), + }); }); it('rejects PUT /projects/:id/secrets/:name (secret rewrite)', async () => { @@ -329,6 +333,10 @@ describe('deploy token scope enforcement', () => { env, ); expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/credential.*assigned project.*sign in with your account/i), + }); }); it('rejects POST /projects/:id/github (repoint GitHub connection)', async () => { @@ -346,6 +354,10 @@ describe('deploy token scope enforcement', () => { env, ); expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/credential.*assigned project.*sign in with your account/i), + }); }); it('rejects a P-scoped token used against a different project (cross-project)', async () => { @@ -365,6 +377,10 @@ describe('deploy token scope enforcement', () => { env, ); expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: 'forbidden', + message: expect.stringMatching(/credential.*assigned project.*sign in with your account/i), + }); }); // A deploy token may be bound to the build it was minted for. Once that build @@ -471,6 +487,10 @@ describe('deploy token scope enforcement', () => { env, ); expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: 'deploy token build is no longer active', + message: expect.stringMatching(/start a new build/i), + }); }); } } diff --git a/platform/packages/api/test/unit/admin-operation-error.test.ts b/platform/packages/api/test/unit/admin-operation-error.test.ts new file mode 100644 index 000000000..fbe3d385c --- /dev/null +++ b/platform/packages/api/test/unit/admin-operation-error.test.ts @@ -0,0 +1,48 @@ +import { Hono } from 'hono'; +import { describe, expect, it } from 'vite-plus/test'; +import { renderOperationError } from '../../src/admin/operation-error.tsx'; +import type { ApiEnv } from '../../src/env.ts'; +import { OperatorError } from '../../src/operator/common.ts'; + +describe('admin operation rejection rendering', () => { + it('preserves the explanation and status while escaping text and offering a safe return link', async () => { + const app = new Hono().onError(renderOperationError).post('/action', () => { + throw new OperatorError(409, 'Cannot remove .', 'conflict', { + privateDetail: 'not public', + }); + }); + const response = await app.request('/action', { method: 'POST' }); + expect(response.status).toBe(409); + const html = await response.text(); + expect(html).toContain('Cannot remove <script>owner</script>.'); + expect(html).toContain('href="/admin/"'); + expect(html).not.toContain('