diff --git a/.changeset/nextjs-cts-refusal-diagnostics.md b/.changeset/nextjs-cts-refusal-diagnostics.md new file mode 100644 index 000000000..004cbb71d --- /dev/null +++ b/.changeset/nextjs-cts-refusal-diagnostics.md @@ -0,0 +1,33 @@ +--- +'@cipherstash/nextjs': minor +--- + +Report what the CipherStash token service actually said when it refuses a token. + +`getCtsToken()` reported a non-2xx response as `Failed to fetch CTS token: ` and +nothing else. It read `statusText`, which is the empty string over HTTP/2 — so +the message ended at the colon — and it discarded the response body, taking any +refusal code with it. A billing refusal was indistinguishable from a bad token, +and the accompanying log said "contact support", which is the wrong advice for +an organisation that needs to upgrade a plan. + +The failure now names the status and quotes what the service returned, and the +refusal code is surfaced on a new optional `authCode` field of +`GetCtsTokenResponse` — `USAGE_LIMIT_EXCEEDED` for an organisation over its +allowance, `ORG_NOT_PROVISIONED` for one not registered with the usage system. +Both are terminal: retrying cannot clear either. Unknown `402` codes are +declined so a future payment-required response does not inherit the wrong +classification. + +The body is read as text exactly once and then parsed defensively, never with +`response.json()`. The two shapes are not the same shape: a `402` is JSON, while +every other failure from this endpoint is `text/plain` (a `401` is the bare +string `Authorization failed: InvalidToken`), and `.json()` on one of those +throws a `SyntaxError` that replaces the real failure with a parse error. A +response that is not a recognisable CipherStash refusal — a gateway or WAF +answering in front of the service — still reports its status and body rather +than being reported as a billing problem it is not. + +This package does not depend on `@cipherstash/stack`, so it carries the code +rather than a copy of that package's remedy text — look the remedy up from +`authCode` if you need to render one. diff --git a/.changeset/protect-ffi-auth-error-code.md b/.changeset/protect-ffi-auth-error-code.md new file mode 100644 index 000000000..4dd3439af --- /dev/null +++ b/.changeset/protect-ffi-auth-error-code.md @@ -0,0 +1,16 @@ +--- +'@cipherstash/protect-ffi': minor +--- + +Carry `stack-auth` diagnostics across the JavaScript boundary. Errors that +originate in `stack-auth` now expose `authCode`, `help`, and `url` on both the +native and WASM bindings, alongside protect-ffi's existing `code` field. + +The boundary remains deliberately thin: `Error::Auth` and `Error::ZeroKMS` are +transparent miette diagnostics, so stack-auth continues to own the message, +instructions, and destination URL. Protect-ffi only serializes those fields and +reads the stable auth code from the typed `AuthError`; it does not classify the +message or maintain its own remedy taxonomy. + +`getAuthErrorCode(err)` reads the new field and `ProtectAuthErrorCode` types it. +The auth taxonomy is separate from protect-ffi's closed `ProtectErrorCode` set. diff --git a/.changeset/protect-ffi-client-0-42-3.md b/.changeset/protect-ffi-client-0-42-3.md new file mode 100644 index 000000000..3584a789d --- /dev/null +++ b/.changeset/protect-ffi-client-0-42-3.md @@ -0,0 +1,17 @@ +--- +'@cipherstash/protect-ffi': patch +--- + +Move the CipherStash client crates to `0.42.3` — `cipherstash-client`, +`cts-common`, `stack-auth` and `stack-profile`, which release in lockstep. + +This is the release that raises the usage-denial taxonomy. `stack-auth` gained +typed `UsageLimitExceeded` / `OrgNotProvisioned` errors with a `help` and a +`url` on each, a shared classifier for a `402` from any credential-issuance +path, and a 60-second sticky cache so a refused organisation stops re-issuing +the same doomed request at its own request rate. Together they are what makes +`authCode` on a failure report a billing refusal as one, rather than as a +generic server error a retry loop will hammer. + +It also carries a ZeroKMS change requiring `org_id` on every token. The client +side decodes claims without requiring it, so this is transparent here. diff --git a/.changeset/usage-limit-refusal-guidance.md b/.changeset/usage-limit-refusal-guidance.md new file mode 100644 index 000000000..31eb32531 --- /dev/null +++ b/.changeset/usage-limit-refusal-guidance.md @@ -0,0 +1,22 @@ +--- +'@cipherstash/stack': minor +'stash': minor +--- + +Surface CipherStash token-service refusals as typed diagnostics. + +`@cipherstash/stack` operation and initialization failures now carry +`authCode`, `help`, and `url` from stack-auth. The message remains stack-auth's +original diagnostic message; Stack does not copy or rewrite its instructions. +Callers can branch on `USAGE_LIMIT_EXCEEDED` or `ORG_NOT_PROVISIONED`, render +`help`, and link to `url`. + +`LockContext.identify()` also recognizes those two codes on a genuine CTS +`402`, while declining malformed or unknown responses. Legacy valid JSON +responses without `cs_code` retain the historical usage-limit classification. + +`stash auth login` and `stash env` now consume `@cipherstash/auth` 0.44.0's +typed failures. They print the upstream diagnostic guidance, preserve its URL, +avoid suggesting another login for terminal account refusals, and expose +terminal codes on the JSON stream. The JSON error envelope gains an optional +`hint` for the upstream guidance. diff --git a/packages/cli/src/commands/auth/__tests__/failure.test.ts b/packages/cli/src/commands/auth/__tests__/failure.test.ts new file mode 100644 index 000000000..2b58959d5 --- /dev/null +++ b/packages/cli/src/commands/auth/__tests__/failure.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest' +import { + authFailureCliCode, + authFailureHint, + authFailureMessage, +} from '../failure.js' + +const LOGIN_HINT = 'Run `stash auth login` and try again.' + +/** + * An `AuthFailure` as `@cipherstash/auth` returns it. + * + * `type` is optional because the module's own `RenderableFailure` widens it to + * `string | undefined` — so `undefined` and `''` are both shapes the lookups + * have to survive, not hypotheticals the type system rules out. + */ +const failure = (type: string | undefined, message: string, help?: string) => ({ + ...(type === undefined ? {} : { type }), + error: new Error(message), + ...(help ? { help } : {}), +}) + +describe('authFailureMessage', () => { + it("appends stack-auth's help to the diagnosis", () => { + // `miette` help is not part of an error's `Display`, so the remedy was + // dropped at every call site: "Not authenticated" with no mention of how + // to authenticate. + expect( + authFailureMessage( + failure( + 'NOT_AUTHENTICATED', + 'Not authenticated', + 'Log in with `stash auth login`.', + ), + ), + ).toBe('Not authenticated. Log in with `stash auth login`.') + }) + + it('leaves a failure without help exactly as it was', () => { + expect(authFailureMessage(failure('INVALID_CLIENT', 'bad client'))).toBe( + 'bad client', + ) + }) + + it('does not double a terminal full stop', () => { + expect( + authFailureMessage(failure('SERVER_ERROR', 'Boom.', 'Try later.')), + ).toBe('Boom. Try later.') + }) + + // A CTS diagnosis is a sentence written by whoever raised it, and `.` is not + // the only way one ends. `'Insufficient balance. Please upgrade your plan.'` + // is the code path everyone tested; `'Upgrade now!'` is the one that came + // back as `'Upgrade now!. See the dashboard.'` + it.each([ + ['a full stop', 'Boom.', 'Boom. Try later.'], + ['an exclamation mark', 'Upgrade now!', 'Upgrade now! Try later.'], + [ + 'a question mark', + 'Insufficient balance?', + 'Insufficient balance? Try later.', + ], + ['no punctuation at all', 'Boom', 'Boom. Try later.'], + ])('joins help to a diagnosis ending in %s', (_what, message, expected) => { + expect( + authFailureMessage(failure('SERVER_ERROR', message, 'Try later.')), + ).toBe(expected) + }) + + it('is the help alone when the diagnosis is empty', () => { + // Otherwise the separator is all that survives: '. Try later.' + expect(authFailureMessage(failure('SERVER_ERROR', '', 'Try later.'))).toBe( + 'Try later.', + ) + }) +}) + +describe('authFailureHint', () => { + it('sends a usage-limit refusal to the dashboard instead of to login', () => { + // The whole reason this function exists. `LOGIN_HINT` is the right advice + // for a stale session and wrong for a billing refusal — a fresh login + // cannot mint a credential CTS is withholding on billing grounds. + const hint = authFailureHint( + failure( + 'USAGE_LIMIT_EXCEEDED', + 'Insufficient balance.', + 'Upgrade at https://dashboard.cipherstash.com/billing.', + ), + LOGIN_HINT, + ) + + expect(hint).toContain('https://dashboard.cipherstash.com') + expect(hint).not.toContain('auth login') + }) + + it('sends an unprovisioned org to support, not to billing', () => { + // A 402 has two causes and they need different remedies: an org over its + // allowance upgrades, an org the usage system has never heard of has + // nothing to buy. + const hint = authFailureHint( + failure( + 'ORG_NOT_PROVISIONED', + 'Not provisioned.', + 'Contact https://cipherstash.com/support.', + ), + LOGIN_HINT, + ) + + expect(hint).toContain('https://cipherstash.com/support') + expect(hint).not.toContain('dashboard.cipherstash.com') + }) + + it('keeps the caller-supplied hint for an ordinary auth failure', () => { + expect( + authFailureHint(failure('EXPIRED_TOKEN', 'Token expired'), LOGIN_HINT), + ).toBe(LOGIN_HINT) + }) + + it('has no hint of its own when the caller supplies none', () => { + expect( + authFailureHint(failure('EXPIRED_TOKEN', 'Token expired')), + ).toBeUndefined() + }) + + // `type` is `string | undefined` by design (see `RenderableFailure`), so all + // three of these reach the lookup. An empty type is the one that bit: the + // old `(failure.type && MAP.get(failure.type)) ?? fallback` short-circuited + // to `''`, which is not nullish, so `??` never reached the fallback and + // `stash env` built a `MintError` with `hint: ''` — suppressed by its own + // `if (failure.hint)` guard, i.e. no hint at all. + it.each([ + ['an empty type', ''], + ['an absent type', undefined], + ['a type this CLI has never heard of', 'SOME_FUTURE_CODE'], + ])('falls back to the caller hint for %s', (_what, type) => { + expect(authFailureHint(failure(type, 'boom'), LOGIN_HINT)).toBe(LOGIN_HINT) + }) +}) + +describe('authFailureCliCode', () => { + // The JSON stream's `code` is the only machine-readable field on it. An + // agent that reads `session_invalid` runs `stash auth login` and comes + // straight back here — which is the loop for BOTH terminal codes, not just + // the billing one. + it.each([ + ['USAGE_LIMIT_EXCEEDED', 'usage_limit_exceeded'], + ['ORG_NOT_PROVISIONED', 'org_not_provisioned'], + ])('reports %s as its own terminal code', (type, expected) => { + expect( + authFailureCliCode(failure(type, 'refused'), 'session_invalid'), + ).toBe(expected) + }) + + it.each([ + ['an ordinary auth failure', 'EXPIRED_TOKEN'], + ['an empty type', ''], + ['an absent type', undefined], + ['a type this CLI has never heard of', 'SOME_FUTURE_CODE'], + ])('keeps the caller-supplied code for %s', (_what, type) => { + expect(authFailureCliCode(failure(type, 'boom'), 'session_invalid')).toBe( + 'session_invalid', + ) + }) + + it('has a CLI code for every code that gets a terminal hint', () => { + // The two tables are what drifted: `ORG_NOT_PROVISIONED` had a hint saying + // "logging in again will not clear this" while its code still said + // `session_invalid`. Adding a terminal code has to land in both. + for (const type of ['USAGE_LIMIT_EXCEEDED', 'ORG_NOT_PROVISIONED']) { + expect( + authFailureHint( + failure(type, 'refused', 'Upstream remedy.'), + LOGIN_HINT, + ), + ).toBe('Upstream remedy.') + expect( + authFailureCliCode(failure(type, 'refused'), 'session_invalid'), + ).not.toBe('session_invalid') + } + }) +}) + +describe('the pinned auth taxonomy', () => { + it('maps a usage refusal to terminal guidance and a stable CLI code', () => { + const refusal = failure( + 'USAGE_LIMIT_EXCEEDED', + 'Insufficient balance.', + 'Upgrade the plan.', + ) + expect(authFailureHint(refusal, LOGIN_HINT)).not.toBe(LOGIN_HINT) + expect(authFailureCliCode(refusal, 'session_invalid')).toBe( + 'usage_limit_exceeded', + ) + }) +}) diff --git a/packages/cli/src/commands/auth/__tests__/login.test.ts b/packages/cli/src/commands/auth/__tests__/login.test.ts index d18a9906a..beae86903 100644 --- a/packages/cli/src/commands/auth/__tests__/login.test.ts +++ b/packages/cli/src/commands/auth/__tests__/login.test.ts @@ -274,3 +274,111 @@ describe('login — interactive (non-json) failure handling', () => { expect(clack.log.error).toHaveBeenCalledWith('poll boom') }) }) + +describe('login — a CTS usage-limit refusal', () => { + /** The 402 CTS answers with when the organisation is over its allowance. */ + const usageLimit = () => ({ + failure: { + type: 'USAGE_LIMIT_EXCEEDED', + error: new Error('Insufficient balance. Please upgrade your plan.'), + help: 'The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry.', + url: 'https://dashboard.cipherstash.com/billing', + }, + }) + + /** The other 402: the org isn't registered with the usage system at all. */ + const notProvisioned = () => ({ + failure: { + type: 'ORG_NOT_PROVISIONED', + error: new Error('Organization is not provisioned.'), + help: 'The organisation is not registered with the usage system.', + url: 'https://cipherstash.com/support', + }, + }) + + it('points the user at the dashboard rather than at another login', async () => { + // Logging in again cannot mint a credential CTS is withholding on billing + // grounds, so the default "run `stash auth login`" hint would send the + // user round a loop that has no exit. + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(usageLimit()) + spyExit() + + await expect( + login('us-east-1.aws', undefined, { json: false }), + ).rejects.toThrow('process.exit') + + expect(clack.log.info).toHaveBeenCalledWith( + expect.stringContaining('https://dashboard.cipherstash.com'), + ) + }) + + it("carries stack-auth's remedy into the message", async () => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(usageLimit()) + spyExit() + + await expect( + login('us-east-1.aws', undefined, { json: false }), + ).rejects.toThrow('process.exit') + + expect(clack.log.error).toHaveBeenCalledWith( + expect.stringContaining('used its allowance'), + ) + }) + + it('gives an agent the code to branch on', async () => { + // The JSON stream carries `code` separately, so a consumer can stop + // retrying without parsing English. + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(usageLimit()) + spyExit() + const out = captureJsonLines() + + await expect( + login('us-east-1.aws', undefined, { json: true }), + ).rejects.toThrow('process.exit') + + expect(out.lines()[0]).toMatchObject({ + status: 'error', + code: 'USAGE_LIMIT_EXCEEDED', + }) + }) + + // `--json` exists FOR agent consumers, and they are the ones who cannot see + // the clack `log.info` line. Leaving the remedy off this stream puts the + // dashboard URL exactly where nobody reading the stream can find it. + it.each([ + ['USAGE_LIMIT_EXCEEDED', usageLimit, 'https://dashboard.cipherstash.com'], + ['ORG_NOT_PROVISIONED', notProvisioned, 'https://cipherstash.com/support'], + ])('carries the %s remedy on the --json stream', async (code, mk, remedy) => { + authMock.beginDeviceCodeFlow.mockResolvedValueOnce(mk()) + spyExit() + const out = captureJsonLines() + + await expect( + login('us-east-1.aws', undefined, { json: true }), + ).rejects.toThrow('process.exit') + + const event = out.lines()[0] + expect(event).toMatchObject({ status: 'error', code }) + expect(event.hint).toEqual(expect.stringContaining(remedy)) + }) + + it('leaves the --json error envelope hint-free for an ordinary failure', async () => { + // Additive means additive: an auth failure with no terminal remedy emits + // the same three-key envelope it always did. + authMock.beginDeviceCodeFlow.mockResolvedValueOnce( + failure('EXPIRED_TOKEN', 'Token expired'), + ) + spyExit() + const out = captureJsonLines() + + await expect( + login('us-east-1.aws', undefined, { json: true }), + ).rejects.toThrow('process.exit') + + expect(Object.keys(out.lines()[0] as object).sort()).toEqual([ + 'code', + 'message', + 'status', + ]) + }) +}) diff --git a/packages/cli/src/commands/auth/events.ts b/packages/cli/src/commands/auth/events.ts index ceef85a4b..100b2dc88 100644 --- a/packages/cli/src/commands/auth/events.ts +++ b/packages/cli/src/commands/auth/events.ts @@ -14,9 +14,24 @@ export function emitJsonEvent(event: Record): void { } /** - * Emit the shared `{ status: 'error', code, message }` envelope. The single - * source of truth for how a failure surfaces on the NDJSON stream. + * Emit the shared `{ status: 'error', code, message }` envelope, plus `hint` + * when the failure carries one. The single source of truth for how a failure + * surfaces on the NDJSON stream. + * + * `hint` is the same remedy the interactive path prints as a follow-up line — + * "upgrade the plan at dashboard.cipherstash.com", "contact support" — and it + * belongs here because `--json` exists FOR consumers that never see the clack + * output. Omitting the key entirely when there is no hint keeps the envelope + * byte-identical for every failure that had none, so this is additive: an + * existing parser sees `status`/`code`/`message` exactly as before. + * + * Any `{cli}` placeholder must be resolved by the caller — an unsubstituted + * token is not machine-readable guidance. */ -export function emitJsonError(code: string, message: string): void { - emitJsonEvent({ status: 'error', code, message }) +export function emitJsonError( + code: string, + message: string, + hint?: string, +): void { + emitJsonEvent({ status: 'error', code, message, ...(hint ? { hint } : {}) }) } diff --git a/packages/cli/src/commands/auth/failure.ts b/packages/cli/src/commands/auth/failure.ts new file mode 100644 index 000000000..b74757209 --- /dev/null +++ b/packages/cli/src/commands/auth/failure.ts @@ -0,0 +1,122 @@ +/** + * Rendering for an `@cipherstash/auth` `AuthFailure` — the shape every CTS + * interaction in this CLI returns on the failure arm. + * + * Two things were being dropped at every call site. + * + * **`help`.** Every `AuthError` in stack-auth carries `miette` help — the + * sentence that says what to actually do — and `miette` help is not part of an + * error's `Display`. `failure.error.message` therefore prints the diagnosis + * without the remedy: "Not authenticated" with no mention of `stash auth + * login`, "Insufficient balance. Please upgrade your plan." with no mention of + * where a plan is upgraded. + * + * **The distinction between a fixable failure and a billing one.** The default + * hint on these paths is "run `stash auth login` and try again", which is + * correct for a stale session and actively misleading for an organisation over + * its usage limit: re-authenticating cannot mint a credential CTS is refusing + * on billing grounds, so the user burns a login round trip and lands back here. + */ + +/** + * The `AuthFailure` fields this module reads. + * + * Structural rather than an import of `AuthFailure` itself so this renderer + * remains tolerant of future auth codes. + */ +type RenderableFailure = { + type?: string + error: { message: string } + help?: string + url?: string +} + +/** + * Account refusals for which another login cannot help. Their guidance comes + * from stack-auth's `help` and `url`; the CLI owns no copy of that prose. + */ +const TERMINAL_AUTH_CODES: ReadonlySet = new Set([ + 'USAGE_LIMIT_EXCEEDED', + 'ORG_NOT_PROVISIONED', +]) + +/** + * The `--json` `code` to report for each terminal refusal, replacing whatever + * generic code the call site would otherwise use. + * + * `code` is the only machine-readable field on the error envelope, so it has to + * agree with the hint: reporting `session_invalid` while the hint says "logging + * in again will not clear this" tells an agent to re-login and land straight + * back here. `__tests__/failure.test.ts` asserts the terminal set and CLI code + * mapping stay in step. + */ +const TERMINAL_CLI_CODES: ReadonlyMap = new Map([ + ['USAGE_LIMIT_EXCEEDED', 'usage_limit_exceeded'], + ['ORG_NOT_PROVISIONED', 'org_not_provisioned'], +]) + +/** + * Sentence-ending punctuation, for deciding whether the joiner below owes the + * diagnosis a full stop. + * + * `.` alone was not enough: a CTS diagnosis is a sentence written by whoever + * raised it, and `'Upgrade now!'` came out as `'Upgrade now!. See the + * dashboard.'` A question mark has the same problem. + * + * This is local presentation logic for the CLI. Stack preserves diagnostic + * fields separately and does not join its message with help text. + */ +const TERMINAL_PUNCTUATION = /[.!?]$/ + +/** + * What went wrong, plus the remedy stack-auth attached to it. + * + * Falls back to the bare message when the failure carries no help, so nothing + * gains a trailing separator it did not have before — and to the bare help when + * there is no message, so the separator is not the only thing that survives. + */ +export function authFailureMessage(failure: RenderableFailure): string { + const { message } = failure.error + if (!failure.help) return message + if (!message) return failure.help + return TERMINAL_PUNCTUATION.test(message) + ? `${message} ${failure.help}` + : `${message}. ${failure.help}` +} + +/** + * The hint to show for this failure — the caller's default, unless the failure + * is one no retry can clear. + * + * `type` is `string | undefined` (see {@link RenderableFailure}), and the + * lookup has to survive both. It is written as a `?? ''` key rather than + * `failure.type && …` because that form short-circuits an EMPTY type to `''`, + * which `??` does not rescue — `stash env` then built a `MintError` with + * `hint: ''` and its own `if (failure.hint)` guard swallowed the hint whole. + * + * @param fallback the hint that applies to an ordinary auth failure + */ +export function authFailureHint( + failure: RenderableFailure, + fallback?: string, +): string | undefined { + if (!TERMINAL_AUTH_CODES.has(failure.type ?? '')) return fallback + return [failure.help, failure.url].filter(Boolean).join(' ') || undefined +} + +/** + * The `--json` `code` to report for this failure — the caller's own, unless the + * failure is one no retry can clear. + * + * Pairs with {@link authFailureHint}: whenever that returns a terminal remedy, + * this returns the matching terminal code, so the stream's machine-readable + * field and its prose cannot disagree about whether re-login is worth trying. + * + * @param fallback the code that applies to an ordinary auth failure + */ +export function authFailureCliCode( + failure: RenderableFailure, + fallback: string, +): string { + return TERMINAL_CLI_CODES.get(failure.type ?? '') ?? fallback +} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 96f755740..3c3351963 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,9 +1,44 @@ import auth from '@cipherstash/auth' import * as p from '@clack/prompts' import { emitJsonError, emitJsonEvent } from './events.js' +import { authFailureHint, authFailureMessage } from './failure.js' const { beginDeviceCodeFlow, bindClientDevice } = auth +/** + * Report a CTS failure and exit non-zero, on whichever stream this run uses. + * + * One function for all three unwrap sites so the `help` text and the + * terminal-condition hint (see `./failure.js`) cannot be attached to two of + * them and forgotten on the third — which is how they came to be missing from + * all three. + * + * Both streams get the same three things: the diagnosis, the machine-readable + * `code`, and — for a refusal no retry can clear — the remedy. The remedy is on + * the JSON stream too because `--json` exists for consumers that never see the + * clack output, and the dashboard URL lives in the hint rather than in CTS's + * own prose; emitting `code` alone would leave the one field that names where + * to go visible only to the humans who did not ask for JSON. + */ +function reportAuthFailure( + failure: { type?: string; error: { message: string }; help?: string }, + fallbackCode: string, + json: boolean, +): never { + const hint = authFailureHint(failure) + if (json) { + emitJsonError( + failure.type ?? fallbackCode, + authFailureMessage(failure), + hint, + ) + } else { + p.log.error(authFailureMessage(failure)) + if (hint) p.log.info(hint) + } + process.exit(1) +} + export interface LoginOptions { /** * Emit newline-delimited JSON events instead of pretty clack output, so an @@ -12,7 +47,9 @@ export interface LoginOptions { * { status: 'authorization_required', userCode, verificationUri, * verificationUriComplete, expiresIn } — emitted immediately * { status: 'authorized', expiresAt, expiresAtIso } — on success - * { status: 'error', code?, message } — on failure + * { status: 'error', code?, message, hint? } — on failure + * (`hint` is present only when the failure carries a remedy — e.g. a + * terminal CTS refusal naming dashboard.cipherstash.com) */ json?: boolean /** @@ -45,15 +82,7 @@ export async function login( // surface the failure `type` (machine-readable) + message on the JSON stream. const pending = await beginDeviceCodeFlow(region, 'cli') if (pending.failure) { - if (json) { - emitJsonError( - pending.failure.type ?? 'begin_failed', - pending.failure.error.message, - ) - } else { - p.log.error(pending.failure.error.message) - } - process.exit(1) + reportAuthFailure(pending.failure, 'begin_failed', json) } const flow = pending.data @@ -86,15 +115,7 @@ export async function login( const authResult = await flow.pollForToken() if (authResult.failure) { s?.stop('Authorization failed.') - if (json) { - emitJsonError( - authResult.failure.type ?? 'poll_failed', - authResult.failure.error.message, - ) - } else { - p.log.error(authResult.failure.error.message) - } - process.exit(1) + reportAuthFailure(authResult.failure, 'poll_failed', json) } s?.stop('Authenticated!') @@ -124,16 +145,8 @@ export async function bindDevice(opts: BindDeviceOptions = {}) { // `@cipherstash/auth` `0.41` — a failure no longer throws. const result = await bindClientDevice() if (result.failure) { - if (json) { - emitJsonError( - result.failure.type ?? 'bind_failed', - result.failure.error.message, - ) - } else { - s?.stop('Failed to bind your device to the default Keyset!') - p.log.error(result.failure.error.message) - } - process.exit(1) + if (!json) s?.stop('Failed to bind your device to the default Keyset!') + reportAuthFailure(result.failure, 'bind_failed', json) } if (json) { diff --git a/packages/cli/src/commands/env/__tests__/env.test.ts b/packages/cli/src/commands/env/__tests__/env.test.ts index 01bf8acde..eec287ae1 100644 --- a/packages/cli/src/commands/env/__tests__/env.test.ts +++ b/packages/cli/src/commands/env/__tests__/env.test.ts @@ -168,6 +168,188 @@ function lastError(): string { return String(clack.log.error.mock.calls.at(-1)?.[0]) } +/** The most recent p.log.info message — where the hint is rendered. */ +function lastInfo(): string { + return String(clack.log.info.mock.calls.at(-1)?.[0]) +} + +/** The last NDJSON line the command wrote, parsed. */ +function lastJson(): Record { + return JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string) +} + +describe('envCommand — a terminal refusal at profile load', () => { + /** + * `fromProfile()` itself fails — no session on disk, or one CTS will not + * honour. + */ + function stubStrategyFailure(type: string, message: string) { + authMock.DeviceSessionStrategy.fromProfile.mockReturnValue({ + failure: { type, error: new Error(message) }, + }) + } + + // The profile-load arm renders a terminal hint too, so its `code` has to + // agree with it. It used to report `not_logged_in` while the hint said + // logging in again would not help. + it('reports the refusal code rather than not_logged_in', async () => { + stubStrategyFailure('USAGE_LIMIT_EXCEEDED', 'Over the limit.') + + await expectExit(envCommand({ name: 'x', json: true }), 1) + + expect(lastJson()).toMatchObject({ + status: 'error', + code: 'usage_limit_exceeded', + }) + }) + + it('still reports not_logged_in for an ordinary profile failure', async () => { + stubStrategyFailure('NOT_AUTHENTICATED', 'No profile.') + + await expectExit(envCommand({ name: 'x', json: true }), 1) + + expect(lastJson()).toMatchObject({ status: 'error', code: 'not_logged_in' }) + }) +}) + +describe('envCommand — a CTS refusal at session renewal', () => { + /** + * `fromProfile()` succeeds (there IS a session on disk) and the renewal is + * what CTS refuses — the shape a 402 actually takes here. + */ + function stubTokenFailure( + type: string, + message: string, + help?: string, + url?: string, + ) { + authMock.DeviceSessionStrategy.fromProfile.mockReturnValue({ + data: { + getToken: vi.fn(async () => ({ + failure: { + type, + error: new Error(message), + ...(help ? { help } : {}), + ...(url ? { url } : {}), + }, + })), + }, + }) + } + + const TERMINAL = [ + { + type: 'USAGE_LIMIT_EXCEEDED', + message: 'Insufficient balance. Please upgrade your plan.', + code: 'usage_limit_exceeded', + remedy: 'https://dashboard.cipherstash.com', + help: 'Upgrade the plan from the CipherStash dashboard, then retry.', + url: 'https://dashboard.cipherstash.com/billing', + }, + { + type: 'ORG_NOT_PROVISIONED', + message: 'Organization is not provisioned.', + code: 'org_not_provisioned', + remedy: 'https://cipherstash.com/support', + help: 'Contact CipherStash support.', + url: 'https://cipherstash.com/support', + }, + ] as const + + // `code` is the only machine-readable field on the stream. An agent that + // reads `session_invalid` runs `stash auth login` and comes straight back + // here — a loop with no exit, for BOTH of these. + it.each(TERMINAL)( + '$type reports $code on the --json stream, not session_invalid', + async ({ type, message, code }) => { + stubTokenFailure(type, message) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + + expect(lastJson()).toMatchObject({ status: 'error', code }) + }, + ) + + // `--json` exists for agent consumers, and they are exactly the ones who + // never see the clack `log.info` line the hint was being printed on. + it.each(TERMINAL)( + '$type carries its remedy on the --json stream', + async ({ type, message, remedy, help, url }) => { + stubTokenFailure(type, message, help, url) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + + const hint = String(lastJson().hint) + expect(hint).toContain(remedy) + }, + ) + + it.each(TERMINAL)( + '$type still prints its remedy interactively', + async ({ type, message, remedy, help, url }) => { + stubTokenFailure(type, message, help, url) + + await expectExit(envCommand({ name: 'x' }), 1) + + expect(lastError()).toContain('Could not refresh your session') + expect(lastInfo()).toContain(remedy) + expect(lastInfo()).not.toContain('auth login') + }, + ) + + it("folds stack-auth's help into the message on both streams", async () => { + stubTokenFailure( + 'USAGE_LIMIT_EXCEEDED', + 'Insufficient balance. Please upgrade your plan.', + 'Upgrade the plan from the CipherStash dashboard, then retry.', + ) + + await expectExit(envCommand({ name: 'x', json: true }), 1) + + expect(String(lastJson().message)).toContain( + 'Upgrade the plan from the CipherStash dashboard', + ) + }) + + it('leaves an ordinary session failure on session_invalid, with the login hint', async () => { + stubTokenFailure('EXPIRED_TOKEN', 'Token expired') + + await expectExit(envCommand({ name: 'x', json: true }), 1) + + const event = lastJson() + expect(event).toMatchObject({ status: 'error', code: 'session_invalid' }) + // `{cli}` is resolved before it reaches the stream — a placeholder is not + // machine-readable guidance. + expect(event.hint).toBe('Run `npx stash auth login` and try again.') + }) + + it('keeps the login hint when there is no session at all', async () => { + // The `fromProfile` arm: `authFailureHint(failure, LOGIN_HINT)` must reach + // the fallback here rather than returning the empty string an `''` type + // used to produce. + authMock.DeviceSessionStrategy.fromProfile.mockReturnValue({ + failure: { type: '', error: new Error('No profile found') }, + }) + + await expectExit(envCommand({ name: 'x' }), 1) + + expect(lastError()).toContain('Not logged in') + expect(lastInfo()).toContain('npx stash auth login') + }) + + it('emits no hint key for a failure that has no hint', async () => { + // Additive means additive — `unexpected_argument` carries no hint and its + // envelope keeps the three keys it always had. + await expectExit(envCommand({ unexpectedArg: 'oops', json: true }), 1) + + expect(Object.keys(lastJson()).sort()).toEqual([ + 'code', + 'message', + 'status', + ]) + }) +}) + describe('envCommand — pre-mint argv failures (all credential-free)', () => { it('fails non-interactively without --name, before touching the profile', async () => { await expectExit(envCommand({}), 1) diff --git a/packages/cli/src/commands/env/index.ts b/packages/cli/src/commands/env/index.ts index 4087f9ae1..8115c5b8b 100644 --- a/packages/cli/src/commands/env/index.ts +++ b/packages/cli/src/commands/env/index.ts @@ -7,6 +7,11 @@ import { CliExit } from '../../cli/exit.js' import { isInteractive } from '../../config/tty.js' import { messages } from '../../messages.js' import { emitJsonError, emitJsonEvent } from '../auth/events.js' +import { + authFailureCliCode, + authFailureHint, + authFailureMessage, +} from '../auth/failure.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' const { DeviceSessionStrategy } = auth @@ -82,13 +87,16 @@ export async function envCommand(options: EnvOptions = {}): Promise { 'mint_failed', err instanceof Error ? err.message : String(err), ) + // The hint reaches BOTH streams. It is the field that names the remedy — + // the dashboard, support, `auth login` — and `--json` is read by agents, + // who are exactly the consumers that never see the clack line. `{cli}` is + // resolved first: a placeholder is not machine-readable guidance. + const hint = failure.hint?.replaceAll('{cli}', cliRef) if (json) { - emitJsonError(failure.code, failure.message) + emitJsonError(failure.code, failure.message, hint) } else { p.log.error(failure.message, CHROME) - if (failure.hint) { - p.log.info(failure.hint.replaceAll('{cli}', cliRef), CHROME) - } + if (hint) p.log.info(hint, CHROME) } throw new CliExit(1) } @@ -334,18 +342,32 @@ async function mintCredentials(keyName: string): Promise { // 1. Device session from ~/.cipherstash (written by `stash auth login`). const strategyResult = DeviceSessionStrategy.fromProfile() if (strategyResult.failure) { + // `authFailureCliCode` for the same reason as the renewal arm below: this + // arm already calls `authFailureHint`, so it can print "logging in again + // will not clear this" — and pairing that with a hardcoded + // `not_logged_in` is the exact code/prose disagreement the two tables + // exist to prevent. An agent reads `code`, runs `stash auth login`, and + // arrives back here. throw new MintError( - 'not_logged_in', - `Not logged in: ${strategyResult.failure.error.message}`, - LOGIN_HINT, + authFailureCliCode(strategyResult.failure, 'not_logged_in'), + `Not logged in: ${authFailureMessage(strategyResult.failure)}`, + authFailureHint(strategyResult.failure, LOGIN_HINT), ) } const tokenResult = await strategyResult.data.getToken() if (tokenResult.failure) { + // The renewal CTS can refuse on billing grounds rather than credential + // ones. `LOGIN_HINT` is wrong advice for that: a fresh login mints nothing + // an organisation over its usage limit is allowed to have, so + // `authFailureHint` sends the user to the dashboard instead — and + // `authFailureCliCode` moves the machine-readable code off `session_invalid` + // in lockstep, for every terminal refusal rather than just the billing one. + // An agent branching on `session_invalid` runs `stash auth login` and comes + // straight back here. throw new MintError( - 'session_invalid', - `Could not refresh your session: ${tokenResult.failure.error.message}`, - LOGIN_HINT, + authFailureCliCode(tokenResult.failure, 'session_invalid'), + `Could not refresh your session: ${authFailureMessage(tokenResult.failure)}`, + authFailureHint(tokenResult.failure, LOGIN_HINT), ) } const { token, workspaceId, issuer, services } = tokenResult.data diff --git a/packages/nextjs/__tests__/cts-refusal.test.ts b/packages/nextjs/__tests__/cts-refusal.test.ts new file mode 100644 index 000000000..7091aca00 --- /dev/null +++ b/packages/nextjs/__tests__/cts-refusal.test.ts @@ -0,0 +1,322 @@ +/** + * `getCtsToken()` (and the `fetchCtsToken` under it) against a CTS that says no. + * + * This package talks to `POST /api/authorize` directly — the same endpoint + * `LockContext.identify()` in `@cipherstash/stack` calls, and the same one that + * answers a billing refusal with a `402`. `fetch` RESOLVES for a `402`: nothing + * throws, so the failure has to be read off the status and the body. + * + * Credential-free: `fetch` is stubbed, so there is no CTS round-trip. + * + * The bodies below are the ones CTS actually sends, and the two shapes are not + * the same shape: + * + * - A **402** is JSON — `AuthorizeErrorBody` in `cts-web/src/authorize/mod.rs`, + * i.e. `{"error":"usage_limit_exceeded","error_description":"...", + * "cs_code":"USAGE_LIMIT_EXCEEDED"}`. + * - **Everything else** is plain text. A live probe answers `401` with the bare + * string `Authorization failed: InvalidToken`, and over HTTP/2 with an EMPTY + * `statusText` — which is the whole reason this file exists. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// No CipherStash session cookie in the request: that is the branch of +// `getCtsToken` which exchanges the supplied OIDC token with CTS. +vi.mock('next/headers', () => ({ + cookies: vi.fn(async () => ({ get: () => undefined })), +})) + +vi.mock('../../utils/logger', () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})) + +import { logger } from '../../utils/logger' +import { getCtsToken } from '../src/index' + +/** + * A CTS response as `fetch` would resolve it. `Response` leaves `statusText` + * empty unless one is passed, which is exactly what the live endpoint does over + * HTTP/2 — so the pre-fix message really did carry nothing. + */ +const ctsResponds = ( + status: number, + body: string, + contentType = 'text/plain; charset=utf-8', +) => + vi.fn( + async () => + new Response(body, { + status, + headers: { 'content-type': contentType }, + }), + ) + +/** A CTS success: the `{ accessToken, expiry }` shape `/api/authorize` mints. */ +const ctsIssuesToken = () => + vi.fn( + async () => + new Response( + JSON.stringify({ accessToken: 'cts-token', expiry: 1_900_000_000 }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + +/** Every string this failure was logged with, in call order. */ +const loggedMessages = () => + [...vi.mocked(logger.debug).mock.calls, ...vi.mocked(logger.error).mock.calls] + .flat() + .filter((arg): arg is string => typeof arg === 'string') + +beforeEach(() => { + process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' + vi.clearAllMocks() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +/** The 402 body CTS sends, as `AuthorizeErrorBody` serialises it. */ +const refusalBody = ( + error: string, + csCode: string | undefined, + description: string, +) => + JSON.stringify({ + error, + error_description: description, + // `cs_code` is `skip_serializing_if = "Option::is_none"` upstream, so an + // absent one is an absent KEY, not a null. + ...(csCode ? { cs_code: csCode } : {}), + }) + +describe('getCtsToken(): a CTS refusal reaches the caller as one', () => { + it('surfaces a usage-limit 402 with its refusal code and what CTS said', async () => { + // Pre-fix this returned the bare string "Failed to fetch CTS token: " — + // `statusText` is empty over HTTP/2, so the caller got a failure with no + // status, no body, and no code to branch on. + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody( + 'usage_limit_exceeded', + 'USAGE_LIMIT_EXCEEDED', + 'Workspace has exceeded its usage limit and cannot issue an access token', + ), + 'application/json', + ), + ) + + const result = await getCtsToken('a-user-jwt') + + expect(result.success).toBe(false) + expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(result.error).toContain('402') + // The service's own sentence, not the JSON envelope around it. + expect(result.error).toContain('Workspace has exceeded its usage limit') + expect(result.error).not.toContain('error_description') + }) + + it('distinguishes an unprovisioned org from an over-limit one', async () => { + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody( + 'org_not_provisioned', + 'ORG_NOT_PROVISIONED', + 'Organisation is not provisioned in the usage system and cannot issue an access token', + ), + 'application/json', + ), + ) + + const result = await getCtsToken('a-user-jwt') + + expect(result.authCode).toBe('ORG_NOT_PROVISIONED') + expect(result.error).toContain('Organisation is not provisioned') + }) + + it('declines an unknown refusal code', async () => { + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody('access_denied', 'SOME_FUTURE_REFUSAL', 'Nope.'), + 'application/json', + ), + ) + + const result = await getCtsToken('a-user-jwt') + + expect(result.authCode).toBeUndefined() + expect(result.error).toContain('Nope.') + }) + + it('defaults a pre-cs_code 402 to the usage limit', async () => { + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody('usage_limit_exceeded', undefined, 'Over the limit.'), + 'application/json', + ), + ) + + const result = await getCtsToken('a-user-jwt') + + expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('classifies a legacy OAuth 402 without cs_code as the usage limit', async () => { + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody('access_denied', undefined, 'Over the limit.'), + 'application/json', + ), + ) + + const result = await getCtsToken('a-user-jwt') + + expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('reads a bodyless 402 as the usage limit', async () => { + vi.stubGlobal('fetch', ctsResponds(402, '')) + + const result = await getCtsToken('a-user-jwt') + + expect(result.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('degrades to a plain error when the 402 did not come from CTS', async () => { + // A gateway, WAF or proxy in front of CTS answers with prose or a page of + // HTML, not an `AuthorizeErrorBody`. That must produce an honest error, + // never an invented code — reporting it as a billing refusal sends the + // caller to a billing page for something a retry would have cleared. + vi.stubGlobal('fetch', ctsResponds(402, 'Payment Required')) + + const result = await getCtsToken('a-user-jwt') + + expect(result.success).toBe(false) + expect(result.authCode).toBeUndefined() + expect(result.error).toContain('402') + expect(result.error).toContain('Payment Required') + }) + + it('logs the reason instead of an empty statusText', async () => { + // The defect was in the logs as much as in the return value: both lines + // reported `statusText`, or nothing at all. + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody( + 'usage_limit_exceeded', + 'USAGE_LIMIT_EXCEEDED', + 'Workspace has exceeded its usage limit and cannot issue an access token', + ), + 'application/json', + ), + ) + + await getCtsToken('a-user-jwt') + + const logged = loggedMessages() + expect(logged.some((message) => message.includes('402'))).toBe(true) + expect( + logged.some((message) => + message.includes('Workspace has exceeded its usage limit'), + ), + ).toBe(true) + // Nothing may be logged as a message that trails off into an empty + // `statusText`. + expect(logged.every((message) => !/[:\s]$/.test(message))).toBe(true) + }) +}) + +describe('getCtsToken(): other non-2xx statuses are not mislabelled', () => { + // Verbatim from a live probe of the real endpoint — an expired or malformed + // user JWT is by far the likeliest way to land here, and it must not read as + // a billing problem. + const cases: ReadonlyArray<[number, string]> = [ + [401, 'Authorization failed: InvalidToken'], + [403, 'Forbidden'], + [500, 'Internal Server Error'], + ] + + for (const [status, body] of cases) { + it(`keeps a ${status} as a plain CTS token failure`, async () => { + vi.stubGlobal('fetch', ctsResponds(status, body)) + + const result = await getCtsToken('a-user-jwt') + + expect(result.success).toBe(false) + expect(result.authCode).toBeUndefined() + // The status and what the server actually said — the two things a caller + // needs, and neither of which survived before. + expect(result.error).toContain(String(status)) + expect(result.error).toContain(body) + // No billing language on a failure that is not a billing failure. + expect(result.error).not.toMatch(/billing|upgrade|dashboard/i) + }) + } + + it('survives an empty error body without trailing punctuation debris', async () => { + vi.stubGlobal('fetch', ctsResponds(502, '')) + + const result = await getCtsToken('a-user-jwt') + + expect(result.success).toBe(false) + expect(result.error).toContain('502') + expect(result.error).not.toMatch(/[:\s]$/) + }) + + it('caps a runaway body and does not read a code out of it', async () => { + // A gateway in front of CTS answers with a page of HTML, not a sentence. + // The quote is capped so it does not become the whole error, and nothing + // in it is mistaken for a refusal code. + const padding = ''.padEnd(600, 'x') + + vi.stubGlobal('fetch', ctsResponds(402, padding)) + + const result = await getCtsToken('a-user-jwt') + + expect(result.authCode).toBeUndefined() + expect(result.error).not.toContain(padding) + expect(result.error?.length).toBeLessThan(padding.length) + }) +}) + +describe('getCtsToken(): the 2xx paths are unchanged', () => { + it('still returns the token on the happy path', async () => { + vi.stubGlobal('fetch', ctsIssuesToken()) + + const result = await getCtsToken('a-user-jwt') + + expect(result.success).toBe(true) + expect(result.error).toBeUndefined() + expect(result.ctsToken).toEqual({ + accessToken: 'cts-token', + expiry: 1_900_000_000, + }) + }) + + it('still rejects on a 2xx whose body is not JSON', async () => { + // Pinned as-is, NOT fixed here: a 200 with an unparseable body throws out of + // `fetchCtsToken` today, and the refusal fix deliberately leaves the 2xx + // path alone. Recorded so a later change to it is a decision rather than an + // accident. + vi.stubGlobal('fetch', ctsResponds(200, 'not json at all')) + + await expect(getCtsToken('a-user-jwt')).rejects.toThrow() + }) +}) diff --git a/packages/nextjs/__tests__/nextjs.test.ts b/packages/nextjs/__tests__/nextjs.test.ts index e03df244c..ba0099fdd 100644 --- a/packages/nextjs/__tests__/nextjs.test.ts +++ b/packages/nextjs/__tests__/nextjs.test.ts @@ -1,17 +1,26 @@ +/** + * The cookie/session surface: `getCtsToken`, `resetCtsToken`, `protectMiddleware`. + * + * This file predates the jseql -> protect rebrand and had been dead ever since: + * the package carried no `test` script, so nothing ran it, and it had drifted + * far enough that it could not even be collected (a `vi.mock` factory closing + * over a `const` declared below it — `Cannot access 'mockReset' before + * initialization`). Its assertions had drifted too: `getCtsToken` has returned + * `{ success, ctsToken }` / `{ success, error }` rather than the bare token or + * `null` for several majors. Repaired here alongside wiring the script up, so + * the CTS refusal coverage in `cts-refusal.test.ts` actually runs. + * + * `setCtsToken` is mocked through `../src/cts`, which is the module + * `protectMiddleware` imports it from — mocking the barrel it is re-exported + * through would not intercept the call. + */ import { type NextRequest, NextResponse } from 'next/server' -// cts.test.ts -import { afterEach, describe, expect, it, type Mock, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// --------------------------------------------- -// 1) Mock next/headers before importing it -// --------------------------------------------- vi.mock('next/headers', () => ({ - cookies: vi.fn(), // We'll override in tests with mockReturnValueOnce(...) + cookies: vi.fn(), })) -// --------------------------------------------- -// 2) Mock logger before importing it -// --------------------------------------------- vi.mock('../../utils/logger', () => ({ logger: { debug: vi.fn(), @@ -21,82 +30,110 @@ vi.mock('../../utils/logger', () => ({ }, })) -// --------------------------------------------- -// 3) Prepare your spies for partial mocking -// (must be declared before vi.mock("../src/")) -// --------------------------------------------- -const mockReset = vi.fn() -const mockSetCtsToken = vi.fn() - -// --------------------------------------------- -// 4) Partial-mock ../src/ BEFORE importing -// anything from that module -// --------------------------------------------- -vi.mock('../src/', async () => { - // Re-import actual code so that only certain exports are overridden - const actual = await vi.importActual('../src/') - return { - ...actual, - resetCtsToken: mockReset, - setCtsToken: mockSetCtsToken, - } -}) +vi.mock('../src/cts', () => ({ + fetchCtsToken: vi.fn(), + setCtsToken: vi.fn(), +})) -// --------------------------------------------- -// 5) Now import after the mock is declared -// --------------------------------------------- import { cookies } from 'next/headers' import { logger } from '../../utils/logger' +import { fetchCtsToken, setCtsToken } from '../src/cts' import { CS_COOKIE_NAME, type CtsToken, getCtsToken, protectMiddleware, resetCtsToken, -} from '../src/' +} from '../src/index' + +/** + * An unsigned JWT carrying just a `sub`. `decodeJwt` does not verify, so this is + * enough to exercise the subject comparison in `protectMiddleware`. + */ +const jwtWithSubject = (sub: string) => + [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify({ sub })).toString('base64url'), + '', + ].join('.') + +/** Stub `cookies()` for the server-component path. */ +const requestCookie = (value: string | undefined) => { + vi.mocked(cookies).mockResolvedValue({ + get: () => (value === undefined ? undefined : { value }), + // biome-ignore lint/suspicious/noExplicitAny: only `get` is exercised here + } as any) +} + +/** A `NextRequest` carrying (or not carrying) a CipherStash session cookie. */ +const requestWithSession = (sessionValue?: string) => + ({ + cookies: { + get: vi.fn((name: string) => + name === CS_COOKIE_NAME && sessionValue !== undefined + ? { value: sessionValue } + : undefined, + ), + }, + }) as unknown as NextRequest + +beforeEach(() => { + vi.mocked(setCtsToken).mockImplementation( + async (_oidcToken: string, res?: NextResponse) => + res ?? NextResponse.next(), + ) +}) -describe('getCtsToken', () => { - afterEach(() => { - vi.clearAllMocks() - }) +afterEach(() => { + vi.clearAllMocks() +}) - it('should return the parsed token if the cookie is present', async () => { - const mockCookieValue: CtsToken = { - accessToken: 'fake_token', - expiry: 999999, - } - ;(cookies as unknown as Mock).mockReturnValueOnce({ - get: vi.fn().mockReturnValue({ value: JSON.stringify(mockCookieValue) }), - }) +describe('getCtsToken', () => { + it('returns the parsed token when the session cookie is present', async () => { + const ctsToken: CtsToken = { accessToken: 'fake_token', expiry: 999999 } + requestCookie(JSON.stringify(ctsToken)) - const token = await getCtsToken() + const result = await getCtsToken() - expect(token).toEqual(mockCookieValue) + expect(result).toEqual({ success: true, ctsToken }) expect(logger.debug).not.toHaveBeenCalledWith( 'No CipherStash session cookie found in the request.', ) }) - it('should return null if the cookie is not present', async () => { - ;(cookies as unknown as Mock).mockReturnValueOnce({ - get: vi.fn().mockReturnValue(undefined), - }) + it('reports a failure when there is no cookie and no JWT to exchange', async () => { + requestCookie(undefined) - const token = await getCtsToken() + const result = await getCtsToken() - expect(token).toBeNull() + expect(result).toEqual({ + success: false, + error: 'No CipherStash session cookie found in the request.', + }) expect(logger.debug).toHaveBeenCalledWith( 'No CipherStash session cookie found in the request.', ) }) -}) -describe('resetCtsToken', () => { - afterEach(() => { - vi.clearAllMocks() + it('exchanges the supplied JWT with CTS when there is no cookie', async () => { + requestCookie(undefined) + vi.mocked(fetchCtsToken).mockResolvedValue({ + success: true, + ctsToken: { accessToken: 'minted', expiry: 1 }, + }) + + const result = await getCtsToken('a-user-jwt') + + expect(fetchCtsToken).toHaveBeenCalledWith('a-user-jwt') + expect(result).toEqual({ + success: true, + ctsToken: { accessToken: 'minted', expiry: 1 }, + }) }) +}) - it('should delete the token cookie on the provided NextResponse', () => { +describe('resetCtsToken', () => { + it('deletes the session cookie on the provided NextResponse', () => { const response = NextResponse.next() const mockDelete = vi.spyOn(response.cookies, 'delete') @@ -106,50 +143,68 @@ describe('resetCtsToken', () => { expect(updatedResponse).toBe(response) }) - it('should create a new NextResponse if none is provided', () => { + it('creates a new NextResponse when none is provided', () => { const response = resetCtsToken() expect(response).toBeInstanceOf(NextResponse) - // Confirm the cookie is cleared expect(response.cookies.get(CS_COOKIE_NAME)?.value).toBe('') }) }) describe('protectMiddleware', () => { - afterEach(() => { - vi.clearAllMocks() + it('sets a session when a JWT is supplied and there is no session cookie', async () => { + const req = requestWithSession(undefined) + + await protectMiddleware('a-user-jwt', req) + + expect(setCtsToken).toHaveBeenCalledWith('a-user-jwt', undefined) }) - function createMockRequest(hasCookie: boolean) { - return { - cookies: { has: vi.fn().mockReturnValue(hasCookie) }, - } as unknown as NextRequest - } + it('leaves the session alone when the JWT and session are the same user', async () => { + // The CTS access token's `sub` is the OIDC one prefixed with `CS|`. + const session: CtsToken = { + accessToken: jwtWithSubject('CS|user-1'), + expiry: 999999, + } + const req = requestWithSession(JSON.stringify(session)) + const res = NextResponse.next() - it('should call setCtsToken if oidcToken is provided and there is no session cookie', async () => { - const mockOidcToken = 'valid_token' - const mockReq = createMockRequest(false) + const result = await protectMiddleware(jwtWithSubject('user-1'), req, res) + + expect(setCtsToken).not.toHaveBeenCalled() + expect(result).toBe(res) + }) + + it('re-mints the session when the JWT belongs to a different user', async () => { + const session: CtsToken = { + accessToken: jwtWithSubject('CS|user-1'), + expiry: 999999, + } + const req = requestWithSession(JSON.stringify(session)) + const oidcToken = jwtWithSubject('user-2') - await protectMiddleware(mockOidcToken, mockReq) + await protectMiddleware(oidcToken, req) - expect(mockSetCtsToken).toHaveBeenCalledWith(mockOidcToken) + expect(setCtsToken).toHaveBeenCalledWith(oidcToken, undefined) }) - it('should reset the cts token if oidcToken is not provided but cookie is present', async () => { - const mockReq = createMockRequest(true) + it('resets the session when no JWT is supplied but a session cookie exists', async () => { + const session: CtsToken = { accessToken: 'whatever', expiry: 999999 } + const req = requestWithSession(JSON.stringify(session)) - await protectMiddleware('', mockReq) + const result = await protectMiddleware('', req) expect(logger.debug).toHaveBeenCalledWith( 'The JWT token was undefined, so the CipherStash session was reset.', ) - expect(mockReset).toHaveBeenCalled() + expect(result.cookies.get(CS_COOKIE_NAME)?.value).toBe('') + expect(setCtsToken).not.toHaveBeenCalled() }) - it('should return NextResponse.next() if none of the conditions are met', async () => { - const mockReq = createMockRequest(false) + it('passes the request through when there is neither a JWT nor a session', async () => { + const req = requestWithSession(undefined) - const response = await protectMiddleware('', mockReq) + const response = await protectMiddleware('', req) expect(response).toBeInstanceOf(NextResponse) expect(logger.debug).toHaveBeenCalledWith( diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 875636071..948e606ad 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -35,7 +35,8 @@ "build": "tsup", "typecheck": "tsc --noEmit -p tsconfig.json", "dev": "tsup --watch", - "release": "tsup" + "release": "tsup", + "test": "vitest run" }, "devDependencies": { "@clerk/nextjs": "catalog:security", diff --git a/packages/nextjs/src/cts/index.ts b/packages/nextjs/src/cts/index.ts index d79a9b051..6d54903d1 100644 --- a/packages/nextjs/src/cts/index.ts +++ b/packages/nextjs/src/cts/index.ts @@ -6,6 +6,132 @@ import { type GetCtsTokenResponse, } from '../index' +/** + * How much of an error body to quote back. A refusal is one sentence; a gateway + * failing in front of CTS can be a page of HTML, and that belongs in nobody's + * error string. + */ +const MAX_BODY_CHARS = 300 + +const KNOWN_REFUSALS: ReadonlySet = new Set([ + 'USAGE_LIMIT_EXCEEDED', + 'ORG_NOT_PROVISIONED', +]) + +/** + * Classify a `/api/authorize` refusal, mirroring `classify_issuance_failure` in + * CipherStash's `stack-auth` crate so this package and the Rust client cannot + * disagree about what the same response means. + * + * A `402` carrying a usage refusal is JSON: + * + * ```json + * {"error":"usage_limit_exceeded", + * "error_description":"Workspace has exceeded its usage limit and cannot issue an access token", + * "cs_code":"USAGE_LIMIT_EXCEEDED"} + * ``` + * + * The rules, in the order they are applied: + * + * - **Status decides, not the body.** Only a `402` is a usage refusal. The + * OAuth issuance paths must report one as `access_denied` to stay RFC 6749 + * compliant, so the body alone cannot be trusted to say what it is. + * - **An empty body reads as `USAGE_LIMIT_EXCEEDED`.** Deployments predating + * `cs_code` sent a bodyless `402`, and that only ever meant the usage limit. + * - **A non-empty body must parse as a JSON object.** A `402` that is HTML, or + * valid JSON that is not an object, did not come from CTS — it came from a + * proxy, WAF or gateway in front of it, and reporting that as a billing + * refusal sends the caller to a billing page for something a retry clears. + * - **`cs_code` must name a known account refusal.** Unknown codes decline so + * a future use of `402` does not inherit today's classification. + * - **`cs_code` absent defaults to `USAGE_LIMIT_EXCEEDED`.** This preserves + * compatibility with deployments predating the taxonomy field, including + * OAuth responses whose `error` remains `access_denied`. + * + * Note what this deliberately does NOT do: call `response.json()`. Only the + * `402` is JSON. Every other failure from this endpoint is plain text — a `401` + * is the bare string `Authorization failed: InvalidToken` — and `.json()` on + * one throws a `SyntaxError` that displaces the real failure. The body is read + * as text exactly once and parsed defensively. + */ +function readCtsRefusal( + status: number, + raw: string, +): { authCode?: string; description?: string } { + if (status !== 402) return {} + + const trimmed = raw.trim() + if (!trimmed) return { authCode: 'USAGE_LIMIT_EXCEEDED' } + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return {} + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return {} + } + + const body = parsed as Record + const field = (name: string): string | undefined => { + const value = body[name] + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : undefined + } + + const description = field('error_description') + + // Presence is decided on the raw value, not on a string projection of it: a + // non-string `cs_code` must not read as absent and fall through to the + // `error` arm, which is the inversion of what this guard is for. + if ('cs_code' in body) { + const code = field('cs_code') + return code && KNOWN_REFUSALS.has(code) + ? { authCode: code, description } + : { description } + } + + return { authCode: 'USAGE_LIMIT_EXCEEDED', description } +} + +/** + * Turn a non-2xx `/api/authorize` response into a failure a caller can act on: + * the status, what CTS actually said, and — when CTS refused for a reason with a + * code — that code. + * + * `statusText` is deliberately not reported. It is the empty string over + * HTTP/2, which is what CTS speaks, so the message this used to produce was + * `Failed to fetch CTS token: ` and nothing else — no status, no reason, no + * code. + * + * No remedy text is attached here. The authoritative guidance travels as + * `help`/`url` on failures produced by stack-auth, but this package reaches CTS + * over plain HTTP. Depending on `@cipherstash/auth` solely for its lookup would + * also pull platform binaries into middleware that otherwise has no + * CipherStash runtime dependency. Preserve that boundary: propagate the code + * and let the caller choose its own guidance. + */ +async function ctsRefusalError( + response: Response, +): Promise<{ error: string; authCode?: string }> { + const raw = await response.text().catch(() => '') + const { authCode, description } = readCtsRefusal(response.status, raw) + + // Prefer the service's own `error_description` over the whole envelope: on a + // classified refusal it is the sentence a human wants, and quoting the raw + // JSON around it adds nothing a caller cannot get from `authCode`. + const body = (description ?? raw.trim()).slice(0, MAX_BODY_CHARS) + + const context = `Failed to fetch CTS token: the CipherStash API returned ${response.status}` + + return { + error: body ? `${context}: ${body}` : `${context}.`, + ...(authCode ? { authCode } : {}), + } +} + /** * Extracts the workspace ID from a CRN string. * CRN format: crn:region.aws:ID @@ -65,16 +191,26 @@ export const fetchCtsToken = async (oidcToken: string): GetCtsTokenResponse => { }), }) + // `fetch` only rejects on a transport failure, so a CTS refusal RESOLVES: + // a `402` carrying `cs_code: USAGE_LIMIT_EXCEEDED` arrives here as an + // ordinary response and + // has to be read off the status. if (!ctsResponse.ok) { - logger.debug(`Failed to fetch CTS token: ${ctsResponse.statusText}`) + const failure = await ctsRefusalError(ctsResponse) + logger.debug(failure.error) + + // The blanket "please contact support" this used to end on was wrong for the + // failure most worth telling apart: a billing refusal is cleared by a human + // with a billing page, not by a support ticket, and no amount of retrying + // moves it. The reason now travels with the message instead. logger.error( - 'There was an issue communicating with the CipherStash CTS API, the CipherStash session was not set. If the issue persists, please contact support.', + `There was an issue communicating with the CipherStash CTS API, the CipherStash session was not set. ${failure.error}`, ) return { success: false, - error: `Failed to fetch CTS token: ${ctsResponse.statusText}`, + ...failure, } } @@ -92,10 +228,17 @@ export const setCtsToken = async (oidcToken: string, res?: NextResponse) => { const cts_token = ctsResponse.ctsToken if (!cts_token) { - logger.debug(`Failed to fetch CTS token: ${ctsResponse.error}`) + // No re-prefixing: `error` already opens with "Failed to fetch CTS token" + // and now carries the status, what CTS said, and any refusal code with it. + const reason = ctsResponse.error ?? 'no reason was reported' + + logger.debug(reason) + // Same reasoning as `fetchCtsToken` above: the reason travels with the + // message rather than a blanket instruction to contact support, which is the + // wrong advice for a refusal only a billing change can clear. logger.error( - 'There was an issue fetching the CipherStash session, the CipherStash session was not set. If the issue persists, please contact support.', + `There was an issue fetching the CipherStash session, the CipherStash session was not set. ${reason}`, ) return res ?? NextResponse.next() diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 9f5523607..73a4df30e 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -27,11 +27,24 @@ export type GetCtsTokenResponse = Promise< | { success: boolean error: string + /** + * The refusal code CTS attached, when it refused for a reason the caller + * can branch on — `USAGE_LIMIT_EXCEEDED` for a billing refusal, for + * example. Parsed out of the `/api/authorize` error body; `undefined` for + * every failure that did not carry one, which includes every failure that + * never reached CTS. + * + * NOT validated against a known set: the taxonomy belongs to CipherStash + * token service and ships on its own release train, so a code newer than + * this build must still reach you. + */ + authCode?: string ctsToken?: never } | { success: boolean error?: never + authCode?: never ctsToken: CtsToken } > diff --git a/packages/protect-ffi/Cargo.lock b/packages/protect-ffi/Cargo.lock index da2eba24a..9ea0210c7 100644 --- a/packages/protect-ffi/Cargo.lock +++ b/packages/protect-ffi/Cargo.lock @@ -516,9 +516,9 @@ dependencies = [ [[package]] name = "cipherstash-client" -version = "0.42.2" +version = "0.42.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59857c1279c8799ce67edde5f300b5decae5d0075b4c8f53d864a0a7ec8a5d01" +checksum = "a1d748e6b3aa180c940e9d77592dc0bde09165dacb0b3046d8778d7958c8596b" dependencies = [ "aes-gcm-siv", "anyhow", @@ -577,9 +577,9 @@ dependencies = [ [[package]] name = "cipherstash-config" -version = "0.42.2" +version = "0.42.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94efb31c4b6cc951f2ed2c2a953393ba34136c37c5ddd022a8da732f174e532" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" dependencies = [ "bitflags", "serde", @@ -589,9 +589,9 @@ dependencies = [ [[package]] name = "cipherstash-core" -version = "0.42.2" +version = "0.42.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef863de8a96e98320a2cd49b36f8235cfcc5f2fc40fdb2c05e31dddf598512f" +checksum = "317a43580d9ca0b7f9eb3c22346d25ed8ae951690e065c3dc14f4a66bb9ccf8c" dependencies = [ "getrandom 0.2.17", "hmac", @@ -818,9 +818,9 @@ dependencies = [ [[package]] name = "cts-common" -version = "0.42.2" +version = "0.42.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576c82618990e693abe4dfbd06dada0bcb45884a4514405851eec6d82818f52c" +checksum = "32943c66cd3ab157144d4d872e46c4023a0dcc055af2fbe7e38ce8122e73a78c" dependencies = [ "arrayvec", "base32", @@ -3380,9 +3380,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stack-auth" -version = "0.42.2" +version = "0.42.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a9ac43060af7605899754daa3de2e26302c2ab4d9249a8bc16157bc53dad65" +checksum = "6303b3d19e5078a6a9d269b218365f693fa807d2d957f6103a1852658bbea284" dependencies = [ "aquamarine", "base64", @@ -3408,9 +3408,9 @@ dependencies = [ [[package]] name = "stack-profile" -version = "0.42.2" +version = "0.42.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ce7ca95d8e688a35e86e0682293a2085d9b1f26e7ca5b489009c6ee0c6967a" +checksum = "42735655af804919228fa55352afd8bcb76d742348f0a03480c1773f2cf2d8a2" dependencies = [ "dirs", "gethostname", @@ -4982,9 +4982,9 @@ dependencies = [ [[package]] name = "zerokms-protocol" -version = "0.12.28" +version = "0.12.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29486723fdd2bdb0c234174dc242051a46ac0c82935ccaa39e9781dbf7bcc6d2" +checksum = "abaae50355d4d1b03373044e8f85e245d9f9975f9289f628bf3a0f4feae3168e" dependencies = [ "base64", "cipherstash-config", diff --git a/packages/protect-ffi/crates/protect-ffi/Cargo.toml b/packages/protect-ffi/crates/protect-ffi/Cargo.toml index fe450fb3d..b6c9f9b83 100644 --- a/packages/protect-ffi/crates/protect-ffi/Cargo.toml +++ b/packages/protect-ffi/crates/protect-ffi/Cargo.toml @@ -20,8 +20,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] base85 = "2.0.0" chrono = { version = "0.4.42", default-features = false, features = ["serde"] } -cipherstash-client = { version = "=0.42.2", features = ["tokio"] } -cts-common = { version = "=0.42.2", default-features = false } +cipherstash-client = { version = "=0.42.3", features = ["tokio"] } +cts-common = { version = "=0.42.3", default-features = false } # In-tree, not from crates.io. This is the anti-skew guarantee the EQL import # was for: the Rust that emits EQL payloads and the SQL that stores them now # move together, because they are the same commit. A registry pin here lets the @@ -35,7 +35,7 @@ cts-common = { version = "=0.42.2", default-features = false } # workspaces stay separate deliberately — see Phase 3 of # `docs/plans/2026-08-13-eql-monorepo-absorption.md`. eql-bindings = { path = "../../../eql/crates/eql-bindings" } -stack-auth = { version = "=0.42.2" } +stack-auth = { version = "=0.42.3" } hex = "0.4.3" # Carries the error code across the FFI boundary (#146). `derive` is the only # feature wanted: `fancy` pulls in the terminal-size / colour-support crates the @@ -54,7 +54,7 @@ zeroize = { version = "1.8", features = ["derive"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] neon = { version = "1", features = ["serde", "tokio"] } -stack-profile = { version = "=0.42.2" } +stack-profile = { version = "=0.42.3" } tokio = { version = "1", features = ["full"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/packages/protect-ffi/crates/protect-ffi/src/lib.rs b/packages/protect-ffi/crates/protect-ffi/src/lib.rs index 3cca59cc5..f433a7b90 100644 --- a/packages/protect-ffi/crates/protect-ffi/src/lib.rs +++ b/packages/protect-ffi/crates/protect-ffi/src/lib.rs @@ -292,8 +292,10 @@ pub enum Error { #[error(transparent)] ZeroKMSBuilder(#[from] ZeroKMSBuilderError), #[error(transparent)] + #[diagnostic(transparent)] Auth(#[from] AuthError), #[error(transparent)] + #[diagnostic(transparent)] ZeroKMS(#[from] zerokms::Error), #[error(transparent)] TypeParse(#[from] TypeParseError), @@ -401,6 +403,14 @@ pub enum Error { InvalidSteVecSelector, } +pub(crate) struct Diagnostic { + pub(crate) message: String, + pub(crate) code: Option, + pub(crate) auth_code: Option, + pub(crate) help: Option, + pub(crate) url: Option, +} + impl Error { /// `Some` when a deserialization failure is an unknown `queryOp`. /// @@ -465,8 +475,16 @@ impl Error { /// implementation as its primary message; the code comes from the /// variant's `#[diagnostic(code(..))]`. Keeping the extraction together /// makes the Neon, wasm, and fallible-bulk representations agree. - pub(crate) fn diagnostic_parts(&self) -> (String, Option) { - (self.to_string(), self.error_code()) + pub(crate) fn diagnostic_parts(&self) -> Diagnostic { + Diagnostic { + message: self.to_string(), + code: self.error_code(), + auth_code: self + .auth_error() + .map(|error| error.error_code().to_string()), + help: miette::Diagnostic::help(self).map(|help| help.to_string()), + url: miette::Diagnostic::url(self).map(|url| url.to_string()), + } } /// The `ProtectErrorCode` this error crosses the boundary with, if it has @@ -478,6 +496,13 @@ impl Error { pub(crate) fn error_code(&self) -> Option { miette::Diagnostic::code(self).map(|code| code.to_string()) } + + fn auth_error(&self) -> Option<&AuthError> { + match self { + Self::Auth(error) | Self::ZeroKMS(zerokms::Error::Auth(error)) => Some(error), + _ => None, + } + } } /// JS-backed [`AuthStrategy`] for the Neon build. @@ -939,6 +964,12 @@ enum DecryptResult { /// matches the declared `code?: ProtectErrorCode`. #[serde(skip_serializing_if = "Option::is_none")] code: Option, + #[serde(rename = "authCode", skip_serializing_if = "Option::is_none")] + auth_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + help: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, }, } @@ -947,10 +978,13 @@ impl DecryptResult { /// step — the two are read off the same value here rather than one being /// re-derived from the other later. fn from_error(err: &Error) -> Self { - let (message, code) = err.diagnostic_parts(); + let diagnostic = err.diagnostic_parts(); Self::Error { - error: message, - code, + error: diagnostic.message, + code: diagnostic.code, + auth_code: diagnostic.auth_code, + help: diagnostic.help, + url: diagnostic.url, } } } @@ -2017,14 +2051,21 @@ async fn do_encrypt_query_bulk( /// shape `wasm.rs` already uses — and maps once on the way out. #[cfg(not(target_arch = "wasm32"))] fn into_js_error(err: Error) -> impl for<'cx> TryIntoJs<'cx, Value = JsError> { - let (message, code) = err.diagnostic_parts(); + let diagnostic = err.diagnostic_parts(); extract::with(move |cx: &mut Cx| -> JsResult { - let error = cx.error(message)?; + let error = cx.error(diagnostic.message)?; // Left unset rather than set to null when absent, so `'code' in err` // answers the question a caller is actually asking. - if let Some(code) = code { - let code = cx.string(code); - error.set(cx, "code", code)?; + for (key, value) in [ + ("code", diagnostic.code), + ("authCode", diagnostic.auth_code), + ("help", diagnostic.help), + ("url", diagnostic.url), + ] { + if let Some(value) = value { + let value = cx.string(value); + error.set(cx, key, value)?; + } } Ok(error) }) @@ -2144,15 +2185,28 @@ async fn decrypt_bulk_fallible( let value = js_plaintext_into_js(cx, data)?; obj.set(cx, "data", value)?; } - DecryptResult::Error { error, code } => { + DecryptResult::Error { + error, + code, + auth_code, + help, + url, + } => { let message = cx.string(error); obj.set(cx, "error", message)?; // Left unset rather than set to null when absent, so // the item matches the declared // `code?: ProtectErrorCode`. - if let Some(code) = code { - let code = cx.string(code); - obj.set(cx, "code", code)?; + for (key, value) in [ + ("code", code), + ("authCode", auth_code), + ("help", help), + ("url", url), + ] { + if let Some(value) = value { + let value = cx.string(value); + obj.set(cx, key, value)?; + } } } } @@ -2316,6 +2370,50 @@ mod tests { mod error_codes { use super::*; + #[test] + fn auth_diagnostics_are_transparent() { + let err = + Error::Auth(stack_auth::UsageLimitExceeded("Over the limit".to_string()).into()); + + let diagnostic = err.diagnostic_parts(); + assert_eq!( + diagnostic.auth_code.as_deref(), + Some("USAGE_LIMIT_EXCEEDED") + ); + assert_eq!(diagnostic.message, "Over the limit"); + assert_eq!( + diagnostic.help.as_deref(), + Some("The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry.") + ); + assert_eq!( + diagnostic.url.as_deref(), + Some("https://dashboard.cipherstash.com/billing") + ); + + assert_eq!( + miette::Diagnostic::help(&err).map(|help| help.to_string()), + Some("The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry.".to_string()) + ); + assert_eq!( + miette::Diagnostic::url(&err).map(|url| url.to_string()), + Some("https://dashboard.cipherstash.com/billing".to_string()) + ); + } + + #[test] + fn zerokms_auth_diagnostics_are_transparent() { + let err = Error::ZeroKMS(zerokms::Error::Auth( + stack_auth::OrgNotProvisioned("Not provisioned".to_string()).into(), + )); + + let diagnostic = err.diagnostic_parts(); + assert_eq!(diagnostic.auth_code.as_deref(), Some("ORG_NOT_PROVISIONED")); + assert_eq!( + diagnostic.url.as_deref(), + Some("https://cipherstash.com/support") + ); + } + fn config(err: ConfigError) -> Option { Error::from(err).error_code() } diff --git a/packages/protect-ffi/crates/protect-ffi/src/wasm.rs b/packages/protect-ffi/crates/protect-ffi/src/wasm.rs index 42ef31397..f7752a2b8 100644 --- a/packages/protect-ffi/crates/protect-ffi/src/wasm.rs +++ b/packages/protect-ffi/crates/protect-ffi/src/wasm.rs @@ -170,7 +170,9 @@ export type { export type { EncryptedV3, EncryptedV3Query } from "../../lib/eql-v3.js"; export { PROTECT_ERROR_CODES, + getAuthErrorCode, isProtectErrorCode, + type ProtectAuthErrorCode, type ProtectErrorCode, } from "./errors.js"; "#; @@ -683,12 +685,25 @@ pub async fn decrypt_bulk_fallible( DecryptResult::Success { data } => { set_prop(&obj, "data", &plaintext_to_js(data)?)?; } - DecryptResult::Error { error, code } => { + DecryptResult::Error { + error, + code, + auth_code, + help, + url, + } => { set_prop(&obj, "error", &JsValue::from_str(error))?; // Left unset rather than set to null when absent, so the item // matches the declared `code?: ProtectErrorCode`. - if let Some(code) = code { - set_prop(&obj, "code", &JsValue::from_str(code))?; + for (key, value) in [ + ("code", code), + ("authCode", auth_code), + ("help", help), + ("url", url), + ] { + if let Some(value) = value { + set_prop(&obj, key, &JsValue::from_str(value))?; + } } } } @@ -1084,13 +1099,20 @@ fn js_error(msg: &str) -> JsValue { /// `src/errors.ts` used to do — and could only do for the Neon entry, since /// this build's thrown errors never reached that wrapper (#146). fn error_to_js(e: Error) -> JsValue { - let (message, code) = e.diagnostic_parts(); - let err = js_sys::Error::new(&message); - if let Some(code) = code { - // Infallible in practice: `err` is a fresh, extensible JS object. A - // failure here still yields a correct error, just without the code, - // which beats masking the original failure with a `Reflect` one. - let _ = js_sys::Reflect::set(&err, &JsValue::from_str("code"), &JsValue::from_str(&code)); + let diagnostic = e.diagnostic_parts(); + let err = js_sys::Error::new(&diagnostic.message); + for (key, value) in [ + ("code", diagnostic.code), + ("authCode", diagnostic.auth_code), + ("help", diagnostic.help), + ("url", diagnostic.url), + ] { + if let Some(value) = value { + // Infallible in practice: `err` is a fresh, extensible JS object. A + // failure here still yields a correct error, just without the code, + // which beats masking the original failure with a `Reflect` one. + let _ = js_sys::Reflect::set(&err, &JsValue::from_str(key), &JsValue::from_str(&value)); + } } err.into() } diff --git a/packages/protect-ffi/dist/wasm/errors.d.ts b/packages/protect-ffi/dist/wasm/errors.d.ts index d13a29709..c7ab96ee6 100644 --- a/packages/protect-ffi/dist/wasm/errors.d.ts +++ b/packages/protect-ffi/dist/wasm/errors.d.ts @@ -15,6 +15,53 @@ */ export declare const PROTECT_ERROR_CODES: readonly ["INVARIANT_VIOLATION", "UNKNOWN_QUERY_OP", "UNKNOWN_COLUMN", "MISSING_INDEX", "INVALID_QUERY_INPUT", "SHORT_MATCH_NEEDLE", "INVALID_JSON_PATH", "STE_VEC_REQUIRES_JSON_CAST_AS", "MATCH_REQUIRES_TEXT", "UNSUPPORTED_CONFIG_VERSION", "INVALID_EQL_VERSION", "EQL_V3_UNSUPPORTED_COLUMN", "EQL_V3_CONVERSION_FAILED", "INVALID_CIPHERTEXT", "UNKNOWN"]; export type ProtectErrorCode = (typeof PROTECT_ERROR_CODES)[number]; +/** + * The auth taxonomy code on a failure that came from `stack-auth` — CTS + * refused to issue or renew the service token every ZeroKMS request carries. + * + * Deliberately a separate field from {@link ProtectErrorCode} rather than more + * members of it. That set is closed and owned HERE: `errorCodes.test.ts` pins + * it against the `#[diagnostic(code(..))]` attributes in + * `crates/protect-ffi/src/lib.rs`, and every member has one. The auth set is + * owned by `stack-auth` and versioned on its own release train, so folding the + * two together would either break that test or force this package to re-declare + * a taxonomy it does not decide. + * + * So the type is open on purpose — `(string & {})` keeps editor completion for + * the named members without rejecting a code from a newer `stack-auth` than the + * one this build pinned. Narrow with a `===` against a literal; do not + * `switch` exhaustively. + * + * Only the two that carry a caller-actionable remedy are named. The rest of the + * set (`NOT_AUTHENTICATED`, `WORKSPACE_MISMATCH`, `EXPIRED_TOKEN`, …) still + * arrives, and is documented in `@cipherstash/auth`'s `AuthFailure` union. + * + * - `USAGE_LIMIT_EXCEEDED` — the organisation has used its allowance for the + * current billing period. **Not** retryable and **not** a credentials + * problem: nothing clears it until the plan is upgraded. + * - `ORG_NOT_PROVISIONED` — the organisation is not registered with the usage + * system at all. There is no plan to upgrade; it needs support. + * + * Both arrive alongside `help` — the remedy text — and `url`, the link that + * goes with it, when the failure carries one. The two are one remedy split + * across two fields, on the same error and on a `decryptBulkFallible` item; + * whichever the failure has is set, and a field with no value is absent rather + * than empty. + * + * A failure raised by `config.authStrategy` is reconstructed as a typed + * `stack-auth` error from its `type`, message, and structured variant payload. + * Known codes therefore receive that variant's diagnostic guidance; unknown + * codes become `CUSTOM`. Caller-supplied `help` and `url` are not forwarded. + */ +export type ProtectAuthErrorCode = 'USAGE_LIMIT_EXCEEDED' | 'ORG_NOT_PROVISIONED' | (string & {}); +/** + * Read the auth taxonomy code off a thrown FFI error, if it has one. + * + * Present only when the failure came from `stack-auth`; every other failure + * leaves the field unset, so `undefined` means "not an auth failure" rather + * than "an auth failure with no code". + */ +export declare function getAuthErrorCode(error: unknown): ProtectAuthErrorCode | undefined; /** * True when `value` is one of this library's error codes. * diff --git a/packages/protect-ffi/dist/wasm/protect_ffi.d.ts b/packages/protect-ffi/dist/wasm/protect_ffi.d.ts index 5e1053212..de69dbd65 100644 --- a/packages/protect-ffi/dist/wasm/protect_ffi.d.ts +++ b/packages/protect-ffi/dist/wasm/protect_ffi.d.ts @@ -85,7 +85,9 @@ export type { export type { EncryptedV3, EncryptedV3Query } from "../../lib/eql-v3.js"; export { PROTECT_ERROR_CODES, + getAuthErrorCode, isProtectErrorCode, + type ProtectAuthErrorCode, type ProtectErrorCode, } from "./errors.js"; diff --git a/packages/protect-ffi/scripts/inline-wasm.mjs b/packages/protect-ffi/scripts/inline-wasm.mjs index 1f6d4c585..231a8722d 100644 --- a/packages/protect-ffi/scripts/inline-wasm.mjs +++ b/packages/protect-ffi/scripts/inline-wasm.mjs @@ -27,8 +27,13 @@ if (!exportMatch) { ) } const exportList = exportMatch[1].trim() +// Must stay in step with the errors.js re-export block in the +// `typescript_custom_section` in `crates/protect-ffi/src/wasm.rs` — that block +// is what declares these to a consumer, and a name declared there but missing +// here is a type that promises a runtime export the bundle does not have. +// `errorCodes.test.ts` compares the two. const errorHelperExport = - 'export { PROTECT_ERROR_CODES, isProtectErrorCode } from "./errors.js";' + 'export { PROTECT_ERROR_CODES, getAuthErrorCode, isProtectErrorCode } from "./errors.js";' // wasm-bindgen owns the main stub and cannot re-export arbitrary JavaScript // values from a TypeScript custom section. Add the runtime half of the error diff --git a/packages/protect-ffi/src/errorCodes.test.ts b/packages/protect-ffi/src/errorCodes.test.ts index d99a68498..92fea83f5 100644 --- a/packages/protect-ffi/src/errorCodes.test.ts +++ b/packages/protect-ffi/src/errorCodes.test.ts @@ -100,3 +100,49 @@ describe('error codes', () => { } }) }) + +/** + * The wasm bundle declares its error-helper exports in one place and produces + * them in another, and neither knows about the other. + * + * `crates/protect-ffi/src/wasm.rs` carries a `typescript_custom_section` whose + * errors.js re-export block is what a consumer's TypeScript sees. + * The runtime half is appended to wasm-pack's output by + * `scripts/inline-wasm.mjs`, because wasm-bindgen cannot re-export arbitrary + * JavaScript values from a custom section. + * + * A name in the first and not the second is a declared export that does not + * exist at runtime — a `TypeError` in an edge function, with a green build. + */ +describe('wasm error-helper exports', () => { + // Anchored per file rather than one loose pattern over both, for the reason + // CODE_ATTRIBUTE above is anchored: a doc comment that quotes the statement + // it is describing would otherwise be scraped as the statement, and the test + // would compare prose to code. In `wasm.rs` the block starts a line; in + // `inline-wasm.mjs` it is a single-quoted JavaScript string. + const declared = /^export \{([^}]*)\} from "\.\/errors\.js";$/m.exec( + read('crates/protect-ffi/src/wasm.rs'), + ) + const emitted = /'export \{([^}]*)\} from "\.\/errors\.js";'/.exec( + read('scripts/inline-wasm.mjs'), + ) + + /** Value exports only — a `type` specifier has no runtime counterpart. */ + const names = (block: RegExpExecArray | null) => + (block?.[1] ?? '') + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0 && !name.startsWith('type ')) + .sort() + + it('finds both re-export blocks', () => { + // Without this the comparison below passes vacuously — two empty sets are + // equal, and a regex that stopped matching would read as agreement. + expect(names(declared).length).toBeGreaterThan(0) + expect(names(emitted).length).toBeGreaterThan(0) + }) + + it('declares exactly the names the inline bundle re-exports', () => { + expect(names(declared)).toEqual(names(emitted)) + }) +}) diff --git a/packages/protect-ffi/src/errors.test.ts b/packages/protect-ffi/src/errors.test.ts index 3e90233ac..4f6698238 100644 --- a/packages/protect-ffi/src/errors.test.ts +++ b/packages/protect-ffi/src/errors.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isProtectErrorCode, PROTECT_ERROR_CODES } from './errors.js' +import { + getAuthErrorCode, + isProtectErrorCode, + PROTECT_ERROR_CODES, +} from './errors.js' describe('isProtectErrorCode', () => { it('accepts every declared code', () => { @@ -85,3 +89,55 @@ describe('no message-shape routing', () => { expect(isProtectErrorCode((err as { code?: unknown }).code)).toBe(false) }) }) + +describe('getAuthErrorCode', () => { + /** + * What both bindings throw for a stack-auth failure: the ordinary Error, + * plus `authCode` and the remedy text stack-auth wrote — see + * `Error::auth_error` in `crates/protect-ffi/src/lib.rs`. + */ + const authThrown = (message: string, authCode: string) => + Object.assign(new Error(message), { authCode }) + + it('reads the code off an auth failure', () => { + const err: unknown = authThrown( + 'Insufficient balance. Please upgrade your plan.', + 'USAGE_LIMIT_EXCEEDED', + ) + + expect(getAuthErrorCode(err)).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('is undefined for a failure that did not come from auth', () => { + // The distinction the field exists to make: absent means "not an auth + // failure", which is why it is unset rather than null. + const err: unknown = Object.assign(new Error('column not found'), { + code: 'UNKNOWN_COLUMN', + }) + + expect(getAuthErrorCode(err)).toBeUndefined() + }) + + it('does not confuse `code` for `authCode`', () => { + // The two taxonomies are separate on purpose — see `ProtectAuthErrorCode`. + const err: unknown = Object.assign(new Error('boom'), { code: 'UNKNOWN' }) + + expect(getAuthErrorCode(err)).toBeUndefined() + }) + + it('survives non-objects and non-string codes', () => { + expect(getAuthErrorCode(undefined)).toBeUndefined() + expect(getAuthErrorCode(null)).toBeUndefined() + expect(getAuthErrorCode('USAGE_LIMIT_EXCEEDED')).toBeUndefined() + expect(getAuthErrorCode({ authCode: 42 })).toBeUndefined() + }) + + it('accepts a code this build has never heard of', () => { + // The set is stack-auth's and moves on its own release train, so a newer + // code than the pinned crate must still reach the caller rather than + // being filtered to undefined. + expect(getAuthErrorCode({ authCode: 'SOME_FUTURE_CODE' })).toBe( + 'SOME_FUTURE_CODE', + ) + }) +}) diff --git a/packages/protect-ffi/src/errors.ts b/packages/protect-ffi/src/errors.ts index d5af3a721..3b4ab8a34 100644 --- a/packages/protect-ffi/src/errors.ts +++ b/packages/protect-ffi/src/errors.ts @@ -35,6 +35,64 @@ export type ProtectErrorCode = (typeof PROTECT_ERROR_CODES)[number] const KNOWN_CODES: ReadonlySet = new Set(PROTECT_ERROR_CODES) +/** + * The auth taxonomy code on a failure that came from `stack-auth` — CTS + * refused to issue or renew the service token every ZeroKMS request carries. + * + * Deliberately a separate field from {@link ProtectErrorCode} rather than more + * members of it. That set is closed and owned HERE: `errorCodes.test.ts` pins + * it against the `#[diagnostic(code(..))]` attributes in + * `crates/protect-ffi/src/lib.rs`, and every member has one. The auth set is + * owned by `stack-auth` and versioned on its own release train, so folding the + * two together would either break that test or force this package to re-declare + * a taxonomy it does not decide. + * + * So the type is open on purpose — `(string & {})` keeps editor completion for + * the named members without rejecting a code from a newer `stack-auth` than the + * one this build pinned. Narrow with a `===` against a literal; do not + * `switch` exhaustively. + * + * Only the two that carry a caller-actionable remedy are named. The rest of the + * set (`NOT_AUTHENTICATED`, `WORKSPACE_MISMATCH`, `EXPIRED_TOKEN`, …) still + * arrives, and is documented in `@cipherstash/auth`'s `AuthFailure` union. + * + * - `USAGE_LIMIT_EXCEEDED` — the organisation has used its allowance for the + * current billing period. **Not** retryable and **not** a credentials + * problem: nothing clears it until the plan is upgraded. + * - `ORG_NOT_PROVISIONED` — the organisation is not registered with the usage + * system at all. There is no plan to upgrade; it needs support. + * + * Both arrive alongside `help` — the remedy text — and `url`, the link that + * goes with it, when the failure carries one. The two are one remedy split + * across two fields, on the same error and on a `decryptBulkFallible` item; + * whichever the failure has is set, and a field with no value is absent rather + * than empty. + * + * A failure raised by `config.authStrategy` is reconstructed as a typed + * `stack-auth` error from its `type`, message, and structured variant payload. + * Known codes therefore receive that variant's diagnostic guidance; unknown + * codes become `CUSTOM`. Caller-supplied `help` and `url` are not forwarded. + */ +export type ProtectAuthErrorCode = + | 'USAGE_LIMIT_EXCEEDED' + | 'ORG_NOT_PROVISIONED' + | (string & {}) + +/** + * Read the auth taxonomy code off a thrown FFI error, if it has one. + * + * Present only when the failure came from `stack-auth`; every other failure + * leaves the field unset, so `undefined` means "not an auth failure" rather + * than "an auth failure with no code". + */ +export function getAuthErrorCode( + error: unknown, +): ProtectAuthErrorCode | undefined { + if (typeof error !== 'object' || error === null) return undefined + const { authCode } = error as { authCode?: unknown } + return typeof authCode === 'string' ? authCode : undefined +} + /** * True when `value` is one of this library's error codes. * diff --git a/packages/protect-ffi/src/index.cts b/packages/protect-ffi/src/index.cts index 0d9b01dd0..38616c972 100644 --- a/packages/protect-ffi/src/index.cts +++ b/packages/protect-ffi/src/index.cts @@ -28,8 +28,10 @@ export * from './eql-v3.js' import type { EncryptedV3Query } from './eql-v3.js' export { + getAuthErrorCode, isProtectErrorCode, PROTECT_ERROR_CODES, + type ProtectAuthErrorCode, type ProtectErrorCode, } from './errors.js' diff --git a/packages/protect-ffi/src/index.types.test.ts b/packages/protect-ffi/src/index.types.test.ts index 5a33cdc57..37cf8fabe 100644 --- a/packages/protect-ffi/src/index.types.test.ts +++ b/packages/protect-ffi/src/index.types.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AuthStrategy, + DecryptResult, EncryptedV3Query, EncryptQueryOptions, Indexes, @@ -8,6 +9,7 @@ import type { QueryPayload, TextSearchOreQuery, TextSearchQuery, + TokenResultEnvelope, } from './index.cjs' // Every index that can be configured via `Indexes` must also be targetable @@ -118,4 +120,53 @@ describe('AuthStrategy', () => { expect(typeof success.getToken).toBe('function') expect(typeof failure.getToken).toBe('function') }) + + it('accepts the remedy fields an AuthFailure carries', () => { + // A strategy written in TypeScript must accept the complete public + // `@cipherstash/auth` failure shape, which declares `help` and `url` on + // every member of its `AuthFailure` union. Rust reconstructs the matching + // stack-auth variant rather than forwarding those two values verbatim. + // + // Typed as the envelope rather than through `AuthStrategy`: a union return + // type relaxes excess-property checking, so the same literal inside a + // `getToken` passes whether or not the fields are declared, and the test + // would pin nothing. + const failure: TokenResultEnvelope = { + failure: { + type: 'USAGE_LIMIT_EXCEEDED', + error: new Error('Insufficient balance. Please upgrade your plan.'), + help: 'Upgrade the plan at https://dashboard.cipherstash.com', + url: 'https://dashboard.cipherstash.com', + }, + } + const strategy: AuthStrategy = { getToken: async () => failure } + + expect(typeof strategy.getToken).toBe('function') + }) +}) + +// The failure arm of a per-item `decryptBulkFallible` result is built from the +// same Rust `Diagnostic` as a thrown error (`DecryptResult::from_error`), so it +// carries the same fields. A field the Rust can set and the type does not name +// is a value a caller cannot read without an assertion. +describe('DecryptResult', () => { + it('names every field the Rust failure arm can set', () => { + const failure: DecryptResult = { + error: 'Insufficient balance. Please upgrade your plan.', + code: 'UNKNOWN', + authCode: 'USAGE_LIMIT_EXCEEDED', + help: 'Upgrade the plan at https://dashboard.cipherstash.com', + url: 'https://dashboard.cipherstash.com', + } + + expect(failure).toBeDefined() + }) + + it('keeps every diagnostic field optional', () => { + // Absent, not null: both bindings omit a field the error has no value for, + // so `error` alone must remain a complete failure item. + const failure: DecryptResult = { error: 'invalid ciphertext' } + + expect(failure).toBeDefined() + }) }) diff --git a/packages/protect-ffi/src/types.ts b/packages/protect-ffi/src/types.ts index 341936f5f..b1c4c6e61 100644 --- a/packages/protect-ffi/src/types.ts +++ b/packages/protect-ffi/src/types.ts @@ -26,11 +26,32 @@ import type { CredentialOpts } from './credentials.js' import type { EncryptedV3 } from './eql-v3.js' -import type { ProtectErrorCode } from './errors.js' +import type { ProtectAuthErrorCode, ProtectErrorCode } from './errors.js' export type DecryptResult = | { data: JsPlaintext } - | { error: string; code?: ProtectErrorCode } + | { + error: string + code?: ProtectErrorCode + /** @see {@link ProtectAuthErrorCode} */ + authCode?: ProtectAuthErrorCode + /** + * What to do about the failure, when it carries a remedy — the `miette` + * help of the auth error underneath, or the `help` the JS auth strategy + * supplied with its rejection. + * + * @see {@link ProtectAuthErrorCode} + */ + help?: string + /** + * Where to go to do it: the other half of {@link help}, carried + * separately because that is how both the `miette` diagnostic surface and + * `@cipherstash/auth`'s `AuthFailure` carry it. + * + * @see {@link ProtectAuthErrorCode} + */ + url?: string + } export type EncryptPayload = { plaintext: JsPlaintext @@ -366,7 +387,25 @@ export type TokenResult = { token: string } */ export type TokenResultEnvelope = | { data: TokenResult; failure?: undefined } - | { failure: { type?: string; error?: Error }; data?: undefined } + | { + /** + * An `@cipherstash/auth` `AuthFailure`: `{ type, error, help?, url? }` + * plus any per-variant payload (`WORKSPACE_MISMATCH`'s + * `expected`/`actual`). + * + * `type` and `error.message` select and populate the `AuthError` this is + * reconstructed into. `help` and `url` are accepted because they are + * fields on `@cipherstash/auth`'s public failure shape, but the resulting + * diagnostic guidance comes from the selected `stack-auth` variant. + */ + failure: { + type?: string + error?: Error + help?: string + url?: string + } + data?: undefined + } /** * Auth strategy shape compatible with `@cipherstash/auth` strategies (e.g. diff --git a/packages/stack/__tests__/auth-failure-propagation.test.ts b/packages/stack/__tests__/auth-failure-propagation.test.ts new file mode 100644 index 000000000..07c4003d4 --- /dev/null +++ b/packages/stack/__tests__/auth-failure-propagation.test.ts @@ -0,0 +1,233 @@ +/** + * End-to-end proof that a CTS usage-limit refusal reaches a caller as + * something they can act on, on the two paths they actually meet it. + * + * The failure originates at token issuance: every ZeroKMS operation resolves a + * service token first, so CTS answering `402 USAGE_LIMIT_EXCEEDED` means no + * ZeroKMS request is made at all. protect-ffi surfaces that as a thrown `Error` + * with `authCode` and stack-auth's `help` (see `Error::auth_error` in + * `packages/protect-ffi/crates/protect-ffi/src/lib.rs`); this asserts the SDK + * folds the dashboard remedy into `message` and keeps the code for branching. + * + * Credential-free: protect-ffi is mocked, so there is no CTS round-trip. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** What CTS's 402 body says, verbatim — the whole message a caller had before. */ +const CTS_MESSAGE = 'Insufficient balance. Please upgrade your plan.' + +/** The shape protect-ffi throws for a stack-auth failure. */ +const usageLimitRefusal = () => + Object.assign(new Error(CTS_MESSAGE), { + authCode: 'USAGE_LIMIT_EXCEEDED', + // `help` and `url` verbatim from `stack-auth` 0.42.3's + // `#[diagnostic(help(..), url(..))]` on `UsageLimitExceeded`. They are the + // authoritative remedy, and the two halves land in different places: `help` + // is folded into the message, `url` reaches the caller as its own field. + help: 'The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry.', + url: 'https://dashboard.cipherstash.com/billing', + }) + +vi.mock('@cipherstash/protect-ffi', async (importOriginal) => ({ + // `isProtectErrorCode` is real: `getErrorCode` runs it over the thrown + // error's `code`, and stubbing it would let a wrong answer here pass. + ...(await importOriginal()), + newClient: vi.fn(async () => ({ __mock: 'client' })), + encrypt: vi.fn(async () => { + throw usageLimitRefusal() + }), +})) + +import * as ffi from '@cipherstash/protect-ffi' +import { encryptedTable, types } from '@/encryption/v3' +import { Encryption } from '@/index' + +const users = encryptedTable('users', { + email: types.TextEq('email'), +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +/** `Encryption()` throws rather than returning a `Result`, so catch to inspect. */ +async function initFailure() { + vi.mocked(ffi.newClient).mockRejectedValueOnce(usageLimitRefusal()) + try { + await Encryption({ schemas: [users] }) + } catch (thrown) { + return thrown as Error & { authCode?: string } + } + throw new Error('expected Encryption() to reject') +} + +describe('a usage-limit refusal at client init', () => { + it("keeps stack-auth's message and remedy separate", async () => { + const error = await initFailure() + + expect(error.message).toBe(`[encryption]: ${CTS_MESSAGE}`) + expect(error.help).toContain('Upgrade the plan') + }) + + it('hands over the billing link as its own field', async () => { + // `url` is the other half of the same remedy and is never folded into the + // message, so it reaches a caller only here. Before this it reached them + // by no path at all. + const error = await initFailure() + + expect(error.url).toBe('https://dashboard.cipherstash.com/billing') + }) + + it('keeps the code branchable on the thrown error', async () => { + const error = await initFailure() + + expect(error.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) +}) + +describe('any failure at client init', () => { + /** + * The full diagnostic protect-ffi throws: the `Display` message, its own + * `ProtectErrorCode`, and `miette`'s `help` / `url`, which are not part of + * `Display` and so reach JS only as fields. + * + * `url` is inert until protect-ffi lands it, and is asserted here anyway: + * a mapper that enumerates the fields it knows drops the next one silently, + * which is how `help` was lost in the first place. + */ + const fullDiagnostic = () => + Object.assign(new Error('encrypt config is invalid'), { + code: 'UNSUPPORTED_CONFIG_VERSION', + help: 'Regenerate the config with a supported version.', + url: 'https://cipherstash.com/docs/errors/unsupported-config-version', + }) + + /** + * `Encryption()` throws rather than returning a `Result`, and a thrown + * failure must be classifiable the same way a returned one is. Before this, + * only `authCode` rode across: `code` was dropped by the mapper (which had no + * field for it) and `help` / `url` never left protect-ffi's error at all, so + * an init failure could not be triaged the way an operation failure could. + */ + async function throwsFrom(rejection: unknown) { + vi.mocked(ffi.newClient).mockRejectedValueOnce(rejection) + try { + await Encryption({ schemas: [users] }) + } catch (thrown) { + return thrown as Error & { + code?: string + authCode?: string + help?: string + url?: string + } + } + throw new Error('expected Encryption() to reject') + } + + it('carries the protect-ffi error code onto the thrown error', async () => { + expect((await throwsFrom(fullDiagnostic())).code).toBe( + 'UNSUPPORTED_CONFIG_VERSION', + ) + }) + + it('carries `help` onto the thrown error', async () => { + const error = await throwsFrom(fullDiagnostic()) + + // The structured field survives initialization independently of the + // upstream diagnostic message. + expect(error.help).toBe('Regenerate the config with a supported version.') + }) + + it('carries a `url` it has no built-in knowledge of', async () => { + expect((await throwsFrom(fullDiagnostic())).url).toBe( + 'https://cipherstash.com/docs/errors/unsupported-config-version', + ) + }) + + it('sets nothing it was not given', async () => { + const error = await throwsFrom(new Error('plain failure')) + + expect(error.message).toContain('plain failure') + for (const key of ['code', 'authCode', 'help', 'url']) { + expect(error).not.toHaveProperty(key) + } + }) + + // "Absent, never empty" is now stated in three places — protect-ffi + // normalises empty to absent at the boundary, `failureDiagnostics` drops + // empty strings, and both entries are asserted on it. An empty `help` is not + // a remedy and an empty `url` is not a link, and the difference is invisible + // to `if (err.help)` but not to `'help' in err` or to anything serialising + // the error onward. This entry carried `help: ''` for a day; nothing failed. + it('omits an empty help or url rather than carrying the empty string', async () => { + const error = await throwsFrom( + Object.assign(new Error('boom'), { help: '', url: '' }), + ) + + expect(error).not.toHaveProperty('help') + expect(error).not.toHaveProperty('url') + }) + + // `url` is a real channel — protect-ffi relays whatever miette or a JS + // strategy supplied. As of stack-auth 0.42.3 the two terminal refusals set + // `url(..)` as well as `help(..)`, so a usage-limit refusal now arrives with + // both; the rest of the taxonomy still sets only `help(..)`, and an absent + // url must stay an absent KEY rather than one holding `undefined`. + it('passes a url through, and leaves it absent when there is none', async () => { + const withUrl = await throwsFrom( + Object.assign(new Error('boom'), { + url: 'https://cipherstash.com/docs/errors/some-code', + }), + ) + expect(withUrl.url).toBe('https://cipherstash.com/docs/errors/some-code') + + // The shape a usage-limit refusal actually has: help AND url. + const usageLimit = await throwsFrom(usageLimitRefusal()) + expect(usageLimit.help).toBeDefined() + expect(usageLimit.url).toBe('https://dashboard.cipherstash.com/billing') + + // A code that carries help and no url — most of the taxonomy. + const helpOnly = await throwsFrom( + Object.assign(new Error('Not authenticated'), { + authCode: 'NOT_AUTHENTICATED', + help: 'Run `stash auth login`.', + }), + ) + expect(helpOnly.help).toBeDefined() + expect(helpOnly).not.toHaveProperty('url') + }) + + it('carries the whole diagnostic for a usage-limit refusal too', async () => { + const error = await throwsFrom(usageLimitRefusal()) + + expect(error.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(error.help).toBe( + 'The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry.', + ) + }) +}) + +describe('a usage-limit refusal on an operation', () => { + it('is an EncryptionError carrying the same remedy and code', async () => { + const client = await Encryption({ schemas: [users] }) + + const result = await client.encrypt('person@example.com', { + column: users.email, + table: users, + }) + + expect(result.failure?.type).toBe('EncryptionError') + expect(result.failure?.message).toBe(CTS_MESSAGE) + expect(result.failure?.help).toContain('Upgrade the plan') + expect(result.failure?.url).toBe( + 'https://dashboard.cipherstash.com/billing', + ) + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('leaves `code` to protect-ffi, which claims none for an auth failure', () => { + // The two taxonomies stay separate: `code` is protect-ffi's closed + // `ProtectErrorCode` set, `authCode` is stack-auth's open one. + expect(usageLimitRefusal()).not.toHaveProperty('code') + }) +}) diff --git a/packages/stack/__tests__/bulk-decrypt-item-failure.test.ts b/packages/stack/__tests__/bulk-decrypt-item-failure.test.ts new file mode 100644 index 000000000..2d5dffc30 --- /dev/null +++ b/packages/stack/__tests__/bulk-decrypt-item-failure.test.ts @@ -0,0 +1,126 @@ +/** + * Per-item failures in `bulkDecrypt`, on the native entry. + * + * `decryptBulkFallible` reports success or failure PER ITEM — one undecryptable + * row does not fail the call — and since this branch a failed item carries the + * same diagnostic the whole-call failure does: `code`, plus `authCode` and + * `help` when CTS refused the token behind the request. `bulkDecrypt` returns + * `{ data }` in that case (the call succeeded; some rows did not), so the row + * IS the only surface those fields have. Copying only `error` across made the + * new per-item fields unreachable from `@cipherstash/stack`. + * + * Credential-free: protect-ffi is mocked, so there is no ZeroKMS round-trip. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const CTS_MESSAGE = 'Insufficient balance. Please upgrade your plan.' +const CTS_HELP = 'Upgrade the plan from the CipherStash dashboard, then retry.' + +vi.mock('@cipherstash/protect-ffi', async (importOriginal) => ({ + ...(await importOriginal()), + newClient: vi.fn(async () => ({ __mock: 'client' })), + decryptBulkFallible: vi.fn(async () => [{ data: 'plain' }]), +})) + +import * as ffi from '@cipherstash/protect-ffi' +import { encryptedTable, types } from '@/encryption/v3' +import { Encryption } from '@/index' + +const users = encryptedTable('users', { email: types.TextEq('email') }) + +const ct = () => ({ v: 3, i: { t: 'users', c: 'email' }, c: 'x' }) as never + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('a per-item decrypt failure', () => { + it('carries the auth code and help onto the row', async () => { + vi.mocked(ffi.decryptBulkFallible).mockResolvedValueOnce([ + { error: CTS_MESSAGE, authCode: 'USAGE_LIMIT_EXCEEDED', help: CTS_HELP }, + ]) + + const client = await Encryption({ schemas: [users] }) + const result = await client.bulkDecrypt([{ id: 'row-1', data: ct() }]) + + const row = result.data?.[0] + expect(row).toMatchObject({ + id: 'row-1', + error: CTS_MESSAGE, + authCode: 'USAGE_LIMIT_EXCEEDED', + help: CTS_HELP, + }) + }) + + it('keeps the protect-ffi error code it always had a field for', async () => { + vi.mocked(ffi.decryptBulkFallible).mockResolvedValueOnce([ + { error: 'bad ciphertext', code: 'INVALID_CIPHERTEXT' }, + ]) + + const client = await Encryption({ schemas: [users] }) + const result = await client.bulkDecrypt([{ data: ct() }]) + + expect(result.data?.[0]).toMatchObject({ + error: 'bad ciphertext', + code: 'INVALID_CIPHERTEXT', + }) + }) + + // The mapping must carry the FFI's diagnostic through rather than enumerate + // it: protect-ffi is adding `url` alongside `help` on the same miette + // surface, and a field list here would silently drop it until someone + // remembered to come back. Pinned with a key this build does not know so the + // test still proves the shape after `url` lands. + it('carries a diagnostic field this build has never heard of', async () => { + vi.mocked(ffi.decryptBulkFallible).mockResolvedValueOnce([ + { + error: CTS_MESSAGE, + authCode: 'USAGE_LIMIT_EXCEEDED', + url: 'https://cipherstash.com/docs/errors/usage-limit', + somethingLater: 'still here', + }, + ] as never) + + const client = await Encryption({ schemas: [users] }) + const result = await client.bulkDecrypt([{ data: ct() }]) + + expect(result.data?.[0]).toMatchObject({ + url: 'https://cipherstash.com/docs/errors/usage-limit', + somethingLater: 'still here', + }) + }) + + it('leaves a successful row exactly as it was', async () => { + vi.mocked(ffi.decryptBulkFallible).mockResolvedValueOnce([ + { data: 'person@example.com' }, + ]) + + const client = await Encryption({ schemas: [users] }) + const result = await client.bulkDecrypt([{ id: 'row-1', data: ct() }]) + + expect(result.data?.[0]).toEqual({ + id: 'row-1', + data: 'person@example.com', + }) + }) + + // Null inputs keep their position and stay bare — the mapping change must not + // leak diagnostic keys onto rows that never reached the FFI. + it('keeps null inputs positional and undecorated', async () => { + vi.mocked(ffi.decryptBulkFallible).mockResolvedValueOnce([ + { error: CTS_MESSAGE, authCode: 'USAGE_LIMIT_EXCEEDED', help: CTS_HELP }, + ]) + + const client = await Encryption({ schemas: [users] }) + const result = await client.bulkDecrypt([ + { id: 'a', data: null }, + { id: 'b', data: ct() }, + ]) + + expect(result.data?.[0]).toEqual({ id: 'a', data: null }) + expect(result.data?.[1]).toMatchObject({ + id: 'b', + authCode: 'USAGE_LIMIT_EXCEEDED', + }) + }) +}) diff --git a/packages/stack/__tests__/error-discriminated-union.test-d.ts b/packages/stack/__tests__/error-discriminated-union.test-d.ts index 7bb162986..7c15972c7 100644 --- a/packages/stack/__tests__/error-discriminated-union.test-d.ts +++ b/packages/stack/__tests__/error-discriminated-union.test-d.ts @@ -36,11 +36,18 @@ describe('StackError discriminated union (errors as const)', () => { } case EncryptionErrorTypes.EncryptionError: case EncryptionErrorTypes.DecryptionError: - // `code` exists only on these branches — proves narrowing works. + // `code` reaches these branches from protect-ffi — proves narrowing + // works. (`ClientInitError` carries it too, for the same reason; + // `LockContextError` and `CtsTokenError` do not, because neither + // comes from protect-ffi.) return error.code ?? error.message case EncryptionErrorTypes.LockContextError: - case EncryptionErrorTypes.CtsTokenError: return error.message + case EncryptionErrorTypes.CtsTokenError: + // Narrowing here reaches `authCode`: `LockContext.identify()` calls + // CTS over HTTP itself, so a billing refusal surfaces on this branch + // rather than through protect-ffi. + return error.authCode ?? error.message default: { const _exhaustive: never = error return _exhaustive diff --git a/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts b/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts index 4f8d237a0..4bd618f9a 100644 --- a/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts +++ b/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts @@ -50,6 +50,24 @@ export const encryptQueryBulk = (): never => { export const isEncrypted = (): boolean => false +/** + * NOT a stub — the real predicate, re-exported. + * + * `src/wasm-inline.ts` validates `failure.code` against the closed + * `ProtectErrorCode` set with this, exactly as the native entry does. Stubbing + * it would let a wrong answer pass: a hand-written `() => true` republishes + * `ECONNRESET` as an encryption error code and the test that exists to catch + * that would go green. + * + * Imported from the package root rather than `/wasm-inline` — this file IS the + * stand-in for that subpath, whose runtime target (`protect_ffi_inline.js`) is + * a wasm-pack output that only exists after a Rust build. The root resolves to + * the built `lib/`, and the two are the same function from the same + * `src/errors.ts`. The no-native-import rule that shapes `src/wasm-inline.ts` + * is about what lands in the shipped bundle; nothing here is shipped. + */ +export { isProtectErrorCode } from '@cipherstash/protect-ffi' + export const newClient = (): never => { throw new Error( '[test stub]: protect-ffi/wasm-inline newClient not implemented', diff --git a/packages/stack/__tests__/identify-cts-refusal.test.ts b/packages/stack/__tests__/identify-cts-refusal.test.ts new file mode 100644 index 000000000..f510be49f --- /dev/null +++ b/packages/stack/__tests__/identify-cts-refusal.test.ts @@ -0,0 +1,333 @@ +/** + * `LockContext.identify()` against a CTS that says no. + * + * This is the one path in the SDK that talks to CTS directly — `POST + * /api/authorize`, the very endpoint that answers a billing refusal with a + * `402` — so it is the one path where the refusal arrives as an HTTP response + * rather than as a thrown protect-ffi error with `authCode` already on it. + * `fetch` RESOLVES for a 402: nothing throws, so a `withResult` wrapper alone + * sees success and the failure has to be read off the status. + * + * Credential-free: `fetch` is stubbed, so there is no CTS round-trip. + * + * The error bodies below are the ones CTS actually sends, and the two shapes + * are not the same shape: + * + * - A **402** is JSON — `AuthorizeErrorBody` in `cts-web/src/authorize/mod.rs`, + * i.e. `{"error":"usage_limit_exceeded","error_description":"...", + * "cs_code":"USAGE_LIMIT_EXCEEDED"}`. `cs_code` carries the taxonomy code; + * `error` is the lowercase OAuth-ish one. + * - **Everything else** is `(StatusCode, self.to_string())` — plain text. A + * live probe of `POST /api/authorize` answers `401` with `Authorization + * failed: InvalidToken` and `422` with a serde deserialisation message, and + * those are carried verbatim below. + * + * That asymmetry is the whole reason the reader takes `.text()` once and parses + * defensively rather than calling `.json()`: on the plain-text majority + * `.json()` throws a `SyntaxError` that displaces the real failure. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EncryptionErrorTypes } from '@/errors' +import { LockContext } from '@/identity' + +/** Build a CTS response with a body and status, as `fetch` would resolve it. */ +const ctsResponds = ( + status: number, + body: string, + contentType = 'text/plain; charset=utf-8', +) => + vi.fn( + async () => + new Response(body, { + status, + headers: { 'content-type': contentType }, + }), + ) + +/** A CTS success: the `{ accessToken, expiry }` shape `/api/authorize` mints. */ +const ctsIssuesToken = () => + vi.fn( + async () => + new Response( + JSON.stringify({ accessToken: 'cts-token', expiry: 1_900_000_000 }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + +beforeEach(() => { + process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +/** The 402 body CTS sends, as `AuthorizeErrorBody` serialises it. */ +const refusalBody = ( + error: string, + csCode: string | undefined, + description: string, +) => + JSON.stringify({ + error, + error_description: description, + // `cs_code` is `skip_serializing_if = "Option::is_none"` upstream, so an + // absent one is an absent KEY, not a null. + ...(csCode ? { cs_code: csCode } : {}), + }) + +describe('identify(): a CTS refusal reaches the caller as one', () => { + it('surfaces a usage-limit 402 with its code and message', async () => { + // Pre-fix this returned "The response from the CipherStash API did not + // contain an access token. Please contact support." — a support ticket for + // a billing state, with no code to branch on and no dashboard to visit. + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody( + 'usage_limit_exceeded', + 'USAGE_LIMIT_EXCEEDED', + 'Workspace has exceeded its usage limit and cannot issue an access token', + ), + 'application/json', + ), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.type).toBe(EncryptionErrorTypes.CtsTokenError) + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + // The service's own sentence, not the JSON envelope around it. + expect(result.failure?.message).toContain( + 'Workspace has exceeded its usage limit', + ) + expect(result.failure?.message).not.toContain('error_description') + expect(result.failure?.message).not.toContain('contact support') + }) + + it('distinguishes an unprovisioned org from a usage limit', async () => { + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody( + 'org_not_provisioned', + 'ORG_NOT_PROVISIONED', + 'Organisation is not provisioned in the usage system and cannot issue an access token', + ), + 'application/json', + ), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.authCode).toBe('ORG_NOT_PROVISIONED') + expect(result.failure?.message).toContain('not provisioned') + }) + + it('declines an unknown refusal code', async () => { + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody('access_denied', 'SOME_FUTURE_REFUSAL', 'Nope.'), + 'application/json', + ), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.authCode).toBeUndefined() + expect(result.failure?.message).toContain('Nope.') + expect(result.failure?.message).not.toContain('cipherstash.com') + }) + + it('defaults a pre-cs_code 402 to the usage limit', async () => { + // Deployments predating `cs_code` send a valid JSON body without it. The + // status and absent key select the legacy default; `error` is not a + // CipherStash taxonomy field and is deliberately ignored. + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody('usage_limit_exceeded', undefined, 'Over the limit.'), + 'application/json', + ), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('classifies a legacy OAuth 402 without cs_code as the usage limit', async () => { + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + refusalBody('access_denied', undefined, 'Over the limit.'), + 'application/json', + ), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('reads a bodyless 402 as the usage limit', async () => { + // The one shape a 402 from CTS can take without being JSON: pre-`cs_code` + // deployments sent no body at all, and that only ever meant this. + vi.stubGlobal('fetch', ctsResponds(402, '')) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(result.failure?.message).toContain('returned 402') + }) + + it('declines a 402 that did not come from CTS', async () => { + // A gateway, WAF or proxy in front of CTS answers with a page of HTML, not + // an `AuthorizeErrorBody`. Reporting that as a billing refusal sends the + // caller to a billing page for something a retry would have cleared — and + // on the Rust path it would sticky-cache a permanent denial. Mirrors + // `classify_issuance_failure`, which declines a non-JSON 402 for the same + // reason. + const padding = ''.padEnd(600, 'x') + + vi.stubGlobal('fetch', ctsResponds(402, padding)) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.type).toBe(EncryptionErrorTypes.CtsTokenError) + expect(result.failure?.authCode).toBeUndefined() + expect(result.failure?.message).not.toContain('dashboard.cipherstash.com') + // Still quoted, but capped — a page of HTML is nobody's `Error.message`. + expect(result.failure?.message).not.toContain(padding) + expect(result.failure?.message).toContain('402') + }) + + it('declines a 402 whose `cs_code` is not a string', async () => { + // Presence is decided on the raw value: `{"cs_code": 42}` must not read as + // absent and fall through to the `error` arm, which is the inversion of + // what that guard is for. + vi.stubGlobal( + 'fetch', + ctsResponds( + 402, + JSON.stringify({ + error: 'usage_limit_exceeded', + error_description: 'Over the limit.', + cs_code: 42, + }), + 'application/json', + ), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.authCode).toBeUndefined() + expect(result.failure?.message).toContain('Over the limit.') + }) +}) + +describe('identify(): other non-2xx statuses are not mislabelled', () => { + // Verbatim from a live probe of the real endpoint — an expired or malformed + // user JWT is by far the likeliest way to land here, and it must not read as + // a billing problem. + const cases: ReadonlyArray<[number, string]> = [ + [401, 'Authorization failed: InvalidToken'], + [403, 'Forbidden'], + [500, 'Internal Server Error'], + ] + + for (const [status, body] of cases) { + it(`keeps a ${status} as a plain CTS token failure`, async () => { + vi.stubGlobal('fetch', ctsResponds(status, body)) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.type).toBe(EncryptionErrorTypes.CtsTokenError) + expect(result.failure?.authCode).toBeUndefined() + // The status and what the server actually said — the two things a + // caller needs and neither of which survived before. + expect(result.failure?.message).toContain(String(status)) + expect(result.failure?.message).toContain(body) + // No billing remedy on a failure that is not a billing failure. + expect(result.failure?.message).not.toContain('dashboard.cipherstash.com') + expect(result.failure?.message).not.toContain('billing') + }) + } + + it('survives an empty error body without trailing punctuation debris', async () => { + vi.stubGlobal('fetch', ctsResponds(502, '')) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.type).toBe(EncryptionErrorTypes.CtsTokenError) + expect(result.failure?.message).toContain('502') + expect(result.failure?.message).not.toMatch(/[:\s]$/) + }) +}) + +describe('identify(): the 2xx paths are unchanged', () => { + it('still reports a 200 with no access token as exactly that', async () => { + // The original message is correct HERE and nowhere else: a 200 whose body + // has no token really is a malformed response worth a support ticket. + vi.stubGlobal( + 'fetch', + ctsResponds(200, JSON.stringify({ expiry: 1 }), 'application/json'), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.type).toBe(EncryptionErrorTypes.CtsTokenError) + expect(result.failure?.message).toBe( + 'The response from the CipherStash API did not contain an access token. Please contact support.', + ) + expect(result.failure?.authCode).toBeUndefined() + }) + + it('reports a 200 with an unparseable body as a failure, not a throw', async () => { + vi.stubGlobal('fetch', ctsResponds(200, 'not json at all')) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.type).toBe(EncryptionErrorTypes.CtsTokenError) + expect(result.failure?.authCode).toBeUndefined() + }) + + it('still resolves a token on the happy path', async () => { + vi.stubGlobal('fetch', ctsIssuesToken()) + + const lockContext = new LockContext() + const result = await lockContext.identify('a-user-jwt') + + expect(result.failure).toBeUndefined() + expect(result.data).toBe(lockContext) + + const stored = await lockContext.getLockContext() + expect(stored.data?.ctsToken).toEqual({ + accessToken: 'cts-token', + expiry: 1_900_000_000, + }) + }) +}) + +describe('identify(): a transport failure is still a transport failure', () => { + it('reports a rejected fetch with its own message and no auth code', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('fetch failed: ECONNREFUSED') + }), + ) + + const result = await new LockContext().identify('a-user-jwt') + + expect(result.failure?.type).toBe(EncryptionErrorTypes.CtsTokenError) + expect(result.failure?.message).toBe('fetch failed: ECONNREFUSED') + expect(result.failure?.authCode).toBeUndefined() + }) +}) diff --git a/packages/stack/__tests__/operation-failure-diagnostics.test.ts b/packages/stack/__tests__/operation-failure-diagnostics.test.ts new file mode 100644 index 000000000..55190c93d --- /dev/null +++ b/packages/stack/__tests__/operation-failure-diagnostics.test.ts @@ -0,0 +1,234 @@ +/** + * What a failed operation carries besides its message. + * + * `message` preserves the upstream diagnosis. These are what a program acts + * on — and until now an operation failure carried only two of the four: the + * mappers enumerated `code` and `authCode` by hand, so `help` and `url` reached + * callers by no path at all. A field the bindings populate and the SDK drops is worse + * than one that does not exist, because it reads as supported. + * + * `Encryption()` (which throws) already carried all four via + * `initDiagnostics`; this is the returned-`Result` half of the same contract. + * + * Credential-free: protect-ffi is mocked. `isProtectErrorCode` is deliberately + * left REAL — `code` is a closed set, and stubbing its validator would let a + * widened set pass unnoticed. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** A protect-ffi-shaped encrypted payload, so model ops detect the field. */ +const enc = () => ({ v: 2, i: { t: 'users', c: 'email' }, c: 'ciphertext' }) + +/** + * The full diagnostic protect-ffi throws: the `Display` message, its own + * `ProtectErrorCode`, and `miette`'s `help` / `url`, neither of which is part + * of `Display` and so reach JS only as fields. + * + * Same fixture the init-path suite uses, so the two halves of the contract are + * demonstrably the same shape. + */ +const fullDiagnostic = () => + Object.assign(new Error('encrypt config is invalid'), { + code: 'UNSUPPORTED_CONFIG_VERSION', + help: 'Regenerate the config with a supported version.', + url: 'https://cipherstash.com/docs/errors/unsupported-config-version', + }) + +/** + * What the mocked binding rejects with. Swapped per test. + * + * Held in a `vi.hoisted` box because the `vi.mock` factory below is hoisted + * above every top-level binding in this file — a plain `let` is unreachable + * from inside it. + */ +const mockState = vi.hoisted(() => ({ + rejection: (): unknown => new Error('rejection not set'), +})) + +vi.mock('@cipherstash/protect-ffi', async (importOriginal) => { + const rejects = async () => { + throw mockState.rejection() + } + return { + ...(await importOriginal()), + newClient: async () => ({ __mock: 'client' }), + encrypt: rejects, + decrypt: rejects, + encryptBulk: rejects, + decryptBulk: rejects, + decryptBulkFallible: rejects, + encryptQuery: rejects, + encryptQueryBulk: rejects, + } +}) + +import type { EncryptionClient } from '@/encryption' +import { encryptedTable, types } from '@/eql/v3' +import { Encryption } from '@/index' + +const users = encryptedTable('users', { email: types.TextEq('email') }) + +const LOCK_CONTEXT = { identityClaim: ['sub'] } + +/** + * One entry per operation module under `encryption/operations/`. Each is run + * twice — plain and `.withLockContext()` — because every one of those modules + * carries TWO failure mappers (the operation and its lock-context sibling), + * and a change applied to one and not the other is exactly the drift this + * pins. + */ +const OPERATIONS: ReadonlyArray< + // biome-ignore lint/suspicious/noExplicitAny: exercising the chainable builders + readonly [string, (client: EncryptionClient) => any] +> = [ + [ + 'encrypt', + (c) => c.encrypt('a@b.com', { column: users.email, table: users }), + ], + ['decrypt', (c) => c.decrypt(enc())], + [ + 'bulkEncrypt', + (c) => + c.bulkEncrypt([{ id: '1', plaintext: 'a@b.com' }], { + column: users.email, + table: users, + }), + ], + ['bulkDecrypt', (c) => c.bulkDecrypt([{ id: '1', data: enc() }])], + ['encryptModel', (c) => c.encryptModel({ id: '1', email: 'a@b.com' }, users)], + ['decryptModel', (c) => c.decryptModel({ id: '1', email: enc() })], + [ + 'bulkEncryptModels', + (c) => c.bulkEncryptModels([{ id: '1', email: 'a@b.com' }], users), + ], + [ + 'bulkDecryptModels', + (c) => c.bulkDecryptModels([{ id: '1', email: enc() }]), + ], + [ + 'encryptQuery', + (c) => c.encryptQuery('a@b.com', { column: users.email, table: users }), + ], + [ + 'encryptQuery (batch)', + (c) => + c.encryptQuery([{ value: 'a@b.com', column: users.email, table: users }]), + ], +] + +let client: EncryptionClient + +beforeEach(async () => { + mockState.rejection = fullDiagnostic + process.env.CS_WORKSPACE_CRN = 'crn:ap-southeast-2.aws:test-workspace' + client = await Encryption({ schemas: [users] }) +}) + +describe('every operation failure carries the whole diagnostic', () => { + for (const [name, run] of OPERATIONS) { + it(`${name} carries code, help and url`, async () => { + const result = await run(client) + + expect(result.failure?.code).toBe('UNSUPPORTED_CONFIG_VERSION') + expect(result.failure?.help).toBe( + 'Regenerate the config with a supported version.', + ) + expect(result.failure?.url).toBe( + 'https://cipherstash.com/docs/errors/unsupported-config-version', + ) + expect(result.failure?.message).toBe('encrypt config is invalid') + }) + + it(`${name} carries them through .withLockContext() too`, async () => { + const result = await run(client).withLockContext(LOCK_CONTEXT) + + expect(result.failure?.code).toBe('UNSUPPORTED_CONFIG_VERSION') + expect(result.failure?.help).toBe( + 'Regenerate the config with a supported version.', + ) + expect(result.failure?.url).toBe( + 'https://cipherstash.com/docs/errors/unsupported-config-version', + ) + }) + + it(`${name} still pins code to the closed set, and authCode to the open one`, async () => { + // Spreading a shared reader must not have relaxed either rule at any + // mapper: `code` stays protect-ffi's closed set (so Node's own + // `ECONNRESET` is dropped), `authCode` stays `@cipherstash/auth`'s open + // one (so a code newer than this build still lands). + mockState.rejection = () => + Object.assign(new Error('socket hang up'), { + code: 'ECONNRESET', + authCode: 'SOME_FUTURE_CODE', + }) + + const result = await run(client) + + expect(result.failure).not.toHaveProperty('code') + expect(result.failure?.authCode).toBe('SOME_FUTURE_CODE') + }) + } +}) + +describe('the two taxonomies keep their own rules', () => { + it('pins `code` to the closed protect-ffi set', async () => { + // Node sets `code` on its own errors. `ECONNRESET` is not an encryption + // error code and must not be reported as one — the validation this shares + // with `getErrorCode` is not allowed to weaken. + mockState.rejection = () => + Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }) + + const result = await client.encrypt('a@b.com', { + column: users.email, + table: users, + }) + + expect(result.failure?.code).toBeUndefined() + expect(result.failure?.message).toContain('socket hang up') + }) + + it('leaves `authCode` open — a code newer than this build still lands', async () => { + // The auth taxonomy belongs to `@cipherstash/auth` and ships on its own + // release train, so it is passed through unvalidated. + mockState.rejection = () => + Object.assign(new Error('refused'), { authCode: 'SOME_FUTURE_CODE' }) + + const result = await client.encrypt('a@b.com', { + column: users.email, + table: users, + }) + + expect(result.failure?.authCode).toBe('SOME_FUTURE_CODE') + }) + + it('sets no key the error did not carry', async () => { + // An absent key and a key set to `undefined` read the same to + // `if (failure.code)` but not to `'code' in failure`, and never to a + // caller serialising the failure. Omit rather than write `undefined`. + mockState.rejection = () => new Error('plain failure') + + const result = await client.encrypt('a@b.com', { + column: users.email, + table: users, + }) + + expect(result.failure?.message).toContain('plain failure') + for (const key of ['code', 'authCode', 'help', 'url']) { + expect(result.failure).not.toHaveProperty(key) + } + }) + + it('ignores a non-string help or url', async () => { + mockState.rejection = () => + Object.assign(new Error('boom'), { help: 42, url: { href: 'nope' } }) + + const result = await client.encrypt('a@b.com', { + column: users.email, + table: users, + }) + + expect(result.failure).not.toHaveProperty('help') + expect(result.failure).not.toHaveProperty('url') + expect(result.failure?.message).toBe('boom') + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-auth-failure.test.ts b/packages/stack/__tests__/wasm-inline-auth-failure.test.ts new file mode 100644 index 000000000..695c437a5 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-auth-failure.test.ts @@ -0,0 +1,462 @@ +/** + * The WASM entry's half of the CTS usage-limit story. + * + * `__tests__/auth-failure-propagation.test.ts` proves it for the native entry. + * This is the same proof for `@cipherstash/stack/wasm-inline` — the entry a + * Cloudflare Workers / Deno / Supabase Edge caller uses — because a billing + * refusal is entry-agnostic: it happens at token issuance, before any ZeroKMS + * request, so an edge caller meets it exactly as often as a Node one. + * + * Three things are asserted here that the native suite cannot cover: + * + * 1. **Non-`Error` rejections.** wasm-bindgen rejects with the raw `JsValue` + * the Rust side produced and this build exports no error class, so a + * failure can arrive as a plain string or object. `toError` coerces it, and + * must not lose `authCode` / `help` on the way — it already preserves + * `code`. + * 2. **`Encryption()` at init.** It throws rather than returning a `Result`, + * and is where a usage-limit refusal surfaces first. + * 3. **Per-item bulk-decrypt failures**, which carry their own `authCode` / + * `help` since the batch call itself still resolves. + * + * Credential-free: the FFI and the auth strategy are both mocked. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** What CTS's 402 body says, verbatim. */ +const CTS_MESSAGE = 'Insufficient balance. Please upgrade your plan.' + +/** + * The `miette` help and url `stack-auth` 0.42.3 attaches to + * `UsageLimitExceeded`, verbatim. They are one remedy in two halves: `help` is + * folded into the message, `url` reaches the caller only as its own field. + */ +const CTS_HELP = 'Upgrade the plan from the CipherStash dashboard, then retry.' +const CTS_URL = 'https://dashboard.cipherstash.com/billing' + +/** The part of the remedy that lands in the message. */ +const REMEDY = 'Upgrade the plan' + +const ffi = vi.hoisted(() => ({ + newClient: vi.fn(async () => ({ handle: 'wasm-client' })), + encrypt: vi.fn(async () => ({ v: 3, i: {}, c: 'ct' })), + decrypt: vi.fn(async () => 'plain'), + isEncrypted: vi.fn(() => true), + encryptQuery: vi.fn(async () => ({ v: 3, i: {} })), + encryptQueryBulk: vi.fn(async () => [{ v: 3, i: {} }]), + encryptBulk: vi.fn(async () => [{ v: 3, i: {}, c: 'ct' }]), + decryptBulkFallible: vi.fn(async () => [{ data: 'plain' }]), +})) +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), + ...ffi, +})) +vi.mock('@cipherstash/auth/wasm-inline', () => ({ + AccessKeyStrategy: { + create: vi.fn(() => ({ + data: { getToken: async () => ({ token: 'test' }) }, + })), + }, + OidcFederationStrategy: {}, +})) + +import { encryptedTable, types } from '../src/eql/v3' +import { Encryption } from '../src/wasm-inline' + +const users = encryptedTable('users', { email: types.TextEq('email') }) + +/** + * The shape `error_to_js` produces in `crates/protect-ffi/src/wasm.rs`: a real + * JS `Error` with the diagnostic fields `Reflect.set` onto it. + */ +const usageLimitError = () => + Object.assign(new Error(CTS_MESSAGE), { + authCode: 'USAGE_LIMIT_EXCEEDED', + help: CTS_HELP, + url: CTS_URL, + }) + +/** + * The same refusal as a bare object. wasm-bindgen hands back whatever the Rust + * side threw, and the WASM build ships no error class to `instanceof` against, + * so this is the shape `toError` exists to rescue. + */ +const usageLimitObject = () => ({ + error: CTS_MESSAGE, + authCode: 'USAGE_LIMIT_EXCEEDED', + help: CTS_HELP, + url: CTS_URL, +}) + +const ct = () => ({ v: 3, i: { t: 'users', c: 'email' }, c: 'x' }) as never + +async function client() { + return Encryption({ + schemas: [users], + config: { + workspaceCrn: 'crn:test:ws', + accessKey: 'test-key', + clientId: 'id', + clientKey: 'key', + }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('a usage-limit refusal on a wasm-inline operation', () => { + it('carries the code and the remedy when the FFI rejects with an Error', async () => { + ffi.encrypt.mockRejectedValueOnce(usageLimitError()) + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.type).toBe('EncryptionError') + expect(result.failure?.message).toBe(CTS_MESSAGE) + expect(result.failure?.help).toBe(CTS_HELP) + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + // The bug: `withResult` runs `onException` (`toError`) BEFORE the failure + // mapper, so the mapper only ever sees the synthesized `Error`. `toError` + // copies `code` across and nothing else, so an object-shaped refusal reached + // an edge caller with `authCode: undefined` and no dashboard in the message + // — while the identical refusal on Node carried both. + it('carries them when the FFI rejects with a bare object', async () => { + ffi.encrypt.mockRejectedValueOnce(usageLimitObject()) + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(result.failure?.message).toContain(CTS_MESSAGE) + expect(result.failure?.message).toContain(REMEDY) + }) + + // `help` is the rest of the taxonomy's remedy text — the codes this package + // writes no remedy of its own for still have one, and it is dropped unless + // `toError` carries the field. + it("carries stack-auth's own help across the same coercion", async () => { + ffi.decrypt.mockRejectedValueOnce({ + error: 'Not authenticated', + authCode: 'NOT_AUTHENTICATED', + help: 'Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY`.', + }) + + const c = await client() + const result = await c.decrypt(ct()) + + expect(result.failure?.type).toBe('DecryptionError') + expect(result.failure?.authCode).toBe('NOT_AUTHENTICATED') + expect(result.failure?.message).toContain('stash login') + }) + + // The neighbouring shapes must not regress: a string rejection has nothing + // to carry, and `code` must still survive the coercion it always did. + it('leaves a string rejection alone — nothing to carry, message intact', async () => { + ffi.encrypt.mockRejectedValueOnce('boom from rust') + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.message).toBe('boom from rust') + expect(result.failure?.authCode).toBeUndefined() + }) + + it('still carries `code` off a bare object, and claims no authCode', async () => { + ffi.encrypt.mockRejectedValueOnce({ + code: 'UNKNOWN_COLUMN', + detail: 'bad domain', + }) + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.code).toBe('UNKNOWN_COLUMN') + expect(result.failure?.authCode).toBeUndefined() + }) +}) + +describe('a usage-limit refusal at wasm-inline client init', () => { + /** `Encryption()` throws rather than returning a `Result`. */ + async function initFailure(rejection: unknown) { + ffi.newClient.mockRejectedValueOnce(rejection) + try { + await client() + } catch (thrown) { + return thrown as Error & { authCode?: string } + } + throw new Error('expected Encryption() to reject') + } + + // The bug: `wasmNewClient` was called bare, with no `wasmResult` / `toFailure` + // around it, so the rejection propagated verbatim — while the native entry + // folded in the remedy and attached the code at the same point. + it("keeps stack-auth's message and remedy separate", async () => { + const error = await initFailure(usageLimitError()) + + expect(error.message).toBe(`[encryption]: ${CTS_MESSAGE}`) + expect(error.help).toBe(CTS_HELP) + // The link is the other half of the remedy and is deliberately NOT in the + // message — it reaches the caller as a field. + expect(error.url).toBe(CTS_URL) + expect(error.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('keeps the code branchable on the thrown error', async () => { + const error = await initFailure(usageLimitError()) + + expect(error.authCode).toBe('USAGE_LIMIT_EXCEEDED') + }) + + it('prefixes like every other throw from this factory', async () => { + const error = await initFailure(usageLimitError()) + + expect(error.message).toContain('[encryption]:') + }) + + it('does not let a foreign `code` reach the thrown init error', async () => { + // `code` is the one carried key with a CLOSED type, and the init path + // reached the caller through `carryDiagnostics` alone — which copies keys + // structurally, on purpose, so it cannot drop a field protect-ffi adds + // next. That left this seam accepting anything: a fetch failing inside a + // JS auth strategy rejects with `ECONNRESET`, and the caller got it + // wearing `ProtectErrorCode`. `toFailure` had always screened it, so the + // two seams of this entry disagreed about the same check. + const error = await initFailure( + Object.assign(new Error('socket hang up'), { + code: 'ECONNRESET', + authCode: 'SOME_FUTURE_REFUSAL', + }), + ) + + expect(error).not.toHaveProperty('code') + // The open set is untouched: a code newer than this build still arrives. + expect(error.authCode).toBe('SOME_FUTURE_REFUSAL') + }) + + it('keeps a real protect-ffi code on the thrown init error', async () => { + const error = await initFailure( + Object.assign(new Error('bad config'), { + code: 'UNSUPPORTED_CONFIG_VERSION', + }), + ) + + expect(error.code).toBe('UNSUPPORTED_CONFIG_VERSION') + }) + + it('rescues a bare-object refusal at init too', async () => { + const error = await initFailure(usageLimitObject()) + + expect(error.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(error.message).toContain(REMEDY) + }) + + it('leaves a non-auth init failure its own message', async () => { + const error = await initFailure(new Error('encrypt config is invalid')) + + expect(error.message).toContain('encrypt config is invalid') + expect(error.message).not.toContain(REMEDY) + expect(error.authCode).toBeUndefined() + }) + + // `help` and `url` are one remedy split across two fields, and `url` is the + // one a boundary quietly drops after growing support for the first. It is + // not prospective: `@cipherstash/auth` declares `help?` / `url?` on + // `FailureBase`, so a JS strategy can supply a url today and protect-ffi + // relays it verbatim. `carryDiagnostics` copies fields rather than naming + // them, so this holds for whatever it gains next — asserted, not assumed. + it('carries the whole miette remedy, url included', async () => { + const error = (await initFailure( + Object.assign(new Error(CTS_MESSAGE), { + authCode: 'USAGE_LIMIT_EXCEEDED', + help: CTS_HELP, + url: CTS_URL, + }), + )) as Error & { help?: string; url?: string } + + expect(error.help).toBe(CTS_HELP) + expect(error.url).toBe(CTS_URL) + }) + + it('sets no diagnostic field the failure did not carry', async () => { + const error = await initFailure(new Error('plain failure')) + + for (const key of ['code', 'authCode', 'help', 'url']) { + expect(error).not.toHaveProperty(key) + } + }) +}) + +/** + * The diagnostic an OPERATION failure carries, as distinct from a thrown init + * error. `toFailure` named `code` and `authCode` and stopped there, so `help` + * reached an edge caller only folded into prose and `url` reached them by no + * path at all — while the init error two describes up carried both. Routing + * through the shared `failureDiagnostics` is what closed that. + */ +describe('the diagnostic on a wasm-inline operation failure', () => { + async function failureFrom(rejection: unknown) { + ffi.encrypt.mockRejectedValueOnce(rejection) + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + return result.failure + } + + it('carries help and url alongside the codes', async () => { + const failure = await failureFrom( + Object.assign(new Error(CTS_MESSAGE), { + code: 'UNKNOWN_COLUMN', + authCode: 'USAGE_LIMIT_EXCEEDED', + help: CTS_HELP, + url: CTS_URL, + }), + ) + + expect(failure).toMatchObject({ + code: 'UNKNOWN_COLUMN', + authCode: 'USAGE_LIMIT_EXCEEDED', + help: CTS_HELP, + url: CTS_URL, + }) + }) + + it('omits an empty help or url rather than carrying the empty string', async () => { + const failure = await failureFrom( + Object.assign(new Error('boom'), { help: '', url: '' }), + ) + + expect(failure).not.toHaveProperty('help') + expect(failure).not.toHaveProperty('url') + }) + + it('sets no diagnostic key the failure did not carry', async () => { + const failure = await failureFrom(new Error('plain failure')) + + for (const key of ['code', 'authCode', 'help', 'url']) { + expect(failure).not.toHaveProperty(key) + } + }) + + // The billing codes are the shape that actually occurs: as of stack-auth + // 0.42.3 `UsageLimitExceeded` sets both `help(..)` and `url(..)`, so a + // usage-limit refusal carries both halves of the remedy. `url` is the half + // that reaches a caller ONLY as a field — it is never folded into the + // message — so a boundary dropping it loses the link entirely. + it('carries help and url for a usage-limit refusal', async () => { + const failure = await failureFrom(usageLimitError()) + + expect(failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(failure?.help).toBe(CTS_HELP) + expect(failure?.url).toBe(CTS_URL) + }) + + // Most of the taxonomy still sets only `help(..)`, and an absent url must + // stay an absent KEY rather than one holding `undefined`. + it('leaves url absent for a code that carries none', async () => { + const failure = await failureFrom( + Object.assign(new Error('Not authenticated'), { + authCode: 'NOT_AUTHENTICATED', + help: 'Run `stash auth login`.', + }), + ) + + expect(failure?.help).toBe('Run `stash auth login`.') + expect(failure).not.toHaveProperty('url') + }) +}) + +/** + * A billing refusal during a bulk decrypt fails the WHOLE call, never one row. + * + * That is structural, not incidental. `DecryptResult::from_error` is the only + * constructor of the failure arm, and it fills `authCode` only for + * `Error::Auth` / `Error::ZeroKMS(Auth)` — neither of which can reach it. The + * three things that do are a ciphertext parse failure, a `Plaintext` / + * `JsPlaintext` conversion, and a `RecordDecryptError`, each a variant of its + * own. The token is resolved once for the call, and its refusal leaves through + * `?` on `decrypt_fallible` (`crates/protect-ffi/src/lib.rs`, and the identical + * wasm twin in `src/wasm.rs`), so the batch fails outright. + * + * So these assert the refusal on the path it actually takes — a rejection — + * and that the per-row path claims nothing auth-shaped. + */ +describe('a usage-limit refusal during a wasm-inline bulk decrypt', () => { + it('fails the whole call with the code and the remedy', async () => { + ffi.decryptBulkFallible.mockRejectedValueOnce(usageLimitError()) + + const c = await client() + const result = await c.bulkDecrypt([ct(), ct()]) + + expect(result.failure?.type).toBe('DecryptionError') + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(result.failure?.help).toBe(CTS_HELP) + }) + + it('does the same for the model decrypt engine', async () => { + ffi.decryptBulkFallible.mockRejectedValueOnce(usageLimitError()) + + const c = await client() + const result = await c.bulkDecryptModels([{ email: ct() }], users) + + expect(result.failure?.type).toBe('DecryptionError') + expect(result.failure?.authCode).toBe('USAGE_LIMIT_EXCEEDED') + expect(result.failure?.message).toBe(CTS_MESSAGE) + expect(result.failure?.help).toBe(CTS_HELP) + }) + + it('claims no authCode for per-row failures, and names every one', async () => { + // Per-row failures are parse / type / record-decrypt only. The aggregate + // reports each index and its `code`, and does NOT invent an auth code from + // rows that cannot carry one. + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { error: 'bad ciphertext', code: 'INVALID_CIPHERTEXT' }, + { error: 'not a plaintext', code: 'UNKNOWN' }, + ] as never) + + const c = await client() + const result = await c.bulkDecrypt([ct(), ct()]) + + expect(result.failure?.type).toBe('DecryptionError') + expect(result.failure?.authCode).toBeUndefined() + expect(result.failure?.message).toContain('[0] (INVALID_CIPHERTEXT)') + expect(result.failure?.message).toContain('[1] (UNKNOWN)') + expect(result.failure?.message).toContain('bad ciphertext') + expect(result.failure?.message).toContain('not a plaintext') + }) + + it('names the failing field for the model engine', async () => { + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { error: 'bad ciphertext', code: 'INVALID_CIPHERTEXT' }, + ] as never) + + const c = await client() + const result = await c.bulkDecryptModels([{ email: ct() }], users) + + expect(result.failure?.authCode).toBeUndefined() + expect(result.failure?.message).toContain('email') + expect(result.failure?.message).toContain('INVALID_CIPHERTEXT') + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-bulk.test.ts b/packages/stack/__tests__/wasm-inline-bulk.test.ts index 1f2ef6377..60bd3ab71 100644 --- a/packages/stack/__tests__/wasm-inline-bulk.test.ts +++ b/packages/stack/__tests__/wasm-inline-bulk.test.ts @@ -28,7 +28,15 @@ const ffi = vi.hoisted(() => ({ ciphertexts.map((_, n) => ({ data: `plain-${n}` })), ), })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ffi) +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), + ...ffi, +})) vi.mock('@cipherstash/auth/wasm-inline', () => ({ AccessKeyStrategy: { create: vi.fn(() => ({ diff --git a/packages/stack/__tests__/wasm-inline-column-name.test.ts b/packages/stack/__tests__/wasm-inline-column-name.test.ts index 36e89f9f4..94fadb252 100644 --- a/packages/stack/__tests__/wasm-inline-column-name.test.ts +++ b/packages/stack/__tests__/wasm-inline-column-name.test.ts @@ -10,7 +10,13 @@ vi.mock('@cipherstash/auth/wasm-inline', () => ({ }, })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), decrypt: vi.fn(), encrypt: vi.fn(), isEncrypted: vi.fn(), diff --git a/packages/stack/__tests__/wasm-inline-models.test.ts b/packages/stack/__tests__/wasm-inline-models.test.ts index d0d5a56bf..86cd7948c 100644 --- a/packages/stack/__tests__/wasm-inline-models.test.ts +++ b/packages/stack/__tests__/wasm-inline-models.test.ts @@ -29,7 +29,15 @@ const ffi = vi.hoisted(() => ({ ciphertexts.map((_, n) => ({ data: `plain-${n}` })), ), })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ffi) +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), + ...ffi, +})) vi.mock('@cipherstash/auth/wasm-inline', () => ({ AccessKeyStrategy: { create: vi.fn(() => ({ diff --git a/packages/stack/__tests__/wasm-inline-new-client.test.ts b/packages/stack/__tests__/wasm-inline-new-client.test.ts index b99aa0caa..990181a46 100644 --- a/packages/stack/__tests__/wasm-inline-new-client.test.ts +++ b/packages/stack/__tests__/wasm-inline-new-client.test.ts @@ -25,7 +25,13 @@ vi.mock('@cipherstash/auth/wasm-inline', () => ({ OidcFederationStrategy: class {}, })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), newClient: vi.fn(async () => ({ __mock: 'wasm-client' })), encrypt: vi.fn(), decrypt: vi.fn(), diff --git a/packages/stack/__tests__/wasm-inline-query.test.ts b/packages/stack/__tests__/wasm-inline-query.test.ts index b9f7724b3..fcb32c637 100644 --- a/packages/stack/__tests__/wasm-inline-query.test.ts +++ b/packages/stack/__tests__/wasm-inline-query.test.ts @@ -18,7 +18,15 @@ const ffi = vi.hoisted(() => ({ queries.map((_, n) => ({ v: 3, n })), ), })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ffi) +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), + ...ffi, +})) vi.mock('@cipherstash/auth/wasm-inline', () => ({ AccessKeyStrategy: { create: vi.fn(() => ({ diff --git a/packages/stack/__tests__/wasm-inline-result-contract.test.ts b/packages/stack/__tests__/wasm-inline-result-contract.test.ts index 23bd4429c..251731679 100644 --- a/packages/stack/__tests__/wasm-inline-result-contract.test.ts +++ b/packages/stack/__tests__/wasm-inline-result-contract.test.ts @@ -19,7 +19,15 @@ const ffi = vi.hoisted(() => ({ encryptBulk: vi.fn(async () => [{ v: 3, i: {}, c: 'ct' }]), decryptBulkFallible: vi.fn(async () => [{ data: 'plain' }]), })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ffi) +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), + ...ffi, +})) vi.mock('@cipherstash/auth/wasm-inline', () => ({ AccessKeyStrategy: { create: vi.fn(() => ({ @@ -174,3 +182,81 @@ describe('wasm-inline Result contract — failure path', () => { expect(result.failure?.message).toBe('[object Object]') }) }) + +/** + * `failure.code` is typed `ProtectErrorCode` — a CLOSED set, owned by + * protect-ffi and pinned there by `errorCodes.test.ts`. Both entries have to + * honour that or the field means one thing on Node and another on Workers. + * + * The native entry validates (`getErrorCode` in + * `src/encryption/helpers/error-code.ts`) precisely because a `code` property + * on a thrown object is not evidence of anything: Node stamps `ECONNRESET`, + * `ENOTFOUND` and `MODULE_NOT_FOUND` onto its own errors, and on this entry a + * rejection is whatever wasm-bindgen was handed. Reading the property without + * checking the value republished those as encryption error codes. + * + * `authCode` is deliberately NOT validated, on either entry: that set belongs + * to `@cipherstash/auth`, ships on its own release train, and is typed open so + * a code newer than the pinned build still reaches the caller. + */ +describe('wasm-inline failure.code is the closed protect-ffi set', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + async function codeFrom(rejection: unknown) { + ffi.encrypt.mockRejectedValueOnce(rejection) + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + return result.failure?.code + } + + it.each([ + ['a real protect-ffi code', 'UNKNOWN_COLUMN'], + ['another one', 'INVALID_CIPHERTEXT'], + ])('keeps %s', async (_label, code) => { + expect(await codeFrom(Object.assign(new Error('boom'), { code }))).toBe( + code, + ) + }) + + it.each([ + ["Node's socket errors", 'ECONNRESET'], + ['a module resolution failure', 'MODULE_NOT_FOUND'], + ['anything else wearing a code', 'EQL_X'], + ])('drops %s, exactly as the native entry does', async (_label, code) => { + expect( + await codeFrom(Object.assign(new Error('boom'), { code })), + ).toBeUndefined() + }) + + it('drops an unrecognised code off a bare-object rejection too', async () => { + // The path `carryDiagnostics` widened: every own key of an arbitrary + // rejection now reaches the failure mapper, so more foreign `code` + // properties arrive here than before. + expect( + await codeFrom({ error: 'boom', code: 'ECONNRESET' }), + ).toBeUndefined() + }) + + it('leaves authCode open — a code newer than this build still lands', async () => { + ffi.encrypt.mockRejectedValueOnce( + Object.assign(new Error('boom'), { + code: 'ECONNRESET', + authCode: 'SOME_FUTURE_AUTH_CODE', + }), + ) + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.code).toBeUndefined() + expect(result.failure?.authCode).toBe('SOME_FUTURE_AUTH_CODE') + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index 9c3931299..d8220c59d 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -24,7 +24,13 @@ vi.mock('@cipherstash/auth/wasm-inline', () => ({ OidcFederationStrategy: class {}, })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), newClient: vi.fn(), encrypt: vi.fn(), decrypt: vi.fn(), diff --git a/packages/stack/__tests__/wasm-inline-v3.test.ts b/packages/stack/__tests__/wasm-inline-v3.test.ts index 67410fe82..5dd3b7184 100644 --- a/packages/stack/__tests__/wasm-inline-v3.test.ts +++ b/packages/stack/__tests__/wasm-inline-v3.test.ts @@ -30,7 +30,13 @@ vi.mock('@cipherstash/auth/wasm-inline', () => ({ OidcFederationStrategy: class {}, })) -vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ +vi.mock('@cipherstash/protect-ffi/wasm-inline', async (importOriginal) => ({ + // Partial, not total: `readErrorCode` validates `failure.code` against the + // closed `ProtectErrorCode` set with the real `isProtectErrorCode`, and a + // hand-written stand-in would let a wrong answer through. + ...(await importOriginal< + typeof import('@cipherstash/protect-ffi/wasm-inline') + >()), newClient: vi.fn(async () => ({ __mock: 'wasm-client' })), encrypt: vi.fn(), decrypt: vi.fn(), diff --git a/packages/stack/src/encryption/helpers/auth-failure.ts b/packages/stack/src/encryption/helpers/auth-failure.ts new file mode 100644 index 000000000..d6684b01a --- /dev/null +++ b/packages/stack/src/encryption/helpers/auth-failure.ts @@ -0,0 +1,42 @@ +import type { + ProtectAuthErrorCode, + ProtectErrorCode, +} from '@cipherstash/protect-ffi' + +export function failureMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function authFailureCode( + error: unknown, +): ProtectAuthErrorCode | undefined { + if (typeof error !== 'object' || error === null) return undefined + const { authCode } = error as { authCode?: unknown } + return typeof authCode === 'string' ? authCode : undefined +} + +export type FailureDiagnostics = { + code?: ProtectErrorCode + authCode?: ProtectAuthErrorCode + help?: string + url?: string +} + +export function failureDiagnostics( + error: unknown, + readCode: (error: unknown) => ProtectErrorCode | undefined, +): FailureDiagnostics { + const diagnostics: FailureDiagnostics = {} + + const code = readCode(error) + if (code) diagnostics.code = code + + const authCode = authFailureCode(error) + if (authCode) diagnostics.authCode = authCode + + const { help, url } = (error ?? {}) as { help?: unknown; url?: unknown } + if (typeof help === 'string' && help !== '') diagnostics.help = help + if (typeof url === 'string' && url !== '') diagnostics.url = url + + return diagnostics +} diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index 4073fcf13..9f97e32e0 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -1,9 +1,18 @@ import { type Result, withResult } from '@byteslice/result' import { newClient } from '@cipherstash/protect-ffi' import { validate as uuidValidate } from 'uuid' +import { + failureDiagnostics, + failureMessage, +} from '@/encryption/helpers/auth-failure' +import { getErrorCode } from '@/encryption/helpers/error-code' import type { AnyV3Table } from '@/eql/v3' import { buildEncryptConfig } from '@/eql/v3' -import { type EncryptionError, EncryptionErrorTypes } from '@/errors' +import { + type ClientInitError, + type EncryptionError, + EncryptionErrorTypes, +} from '@/errors' // `LockContext` is imported type-only so the TSDoc {@link} references in the // comments below resolve; it is erased at compile time. import type { LockContext } from '@/identity' @@ -64,6 +73,30 @@ export { export const noClientError = () => new Error('The Encryption client has not been initialized.') +/** + * Everything a client-init failure carries besides its message. + * + * `message` stays the source diagnostic text. These are what a program acts + * on, and none of them + * survived initialization before: the mapper kept `authCode` alone, so an init + * failure could not be triaged the way an operation failure could — even though + * it is the FIRST place a caller meets a CTS refusal, since `newClient` + * resolves a service token before any operation exists to fail. + * + * This was a second implementation of the shared reader for a while, and drifted + * within a day: it wrote `help: ''` where the shared one omits the key. "Absent, + * never empty" is asserted by protect-ffi at the boundary, by + * `failureDiagnostics`, and by the tests on both entries — three statements of + * one rule, so there is no room for a fourth implementation of it here. + * + * `getErrorCode` is this entry's closed-set `code` reader; `wasm-inline.ts` + * passes its own. See {@link failureDiagnostics} for why that is a parameter. + */ +const initDiagnostics = ( + error: unknown, +): Omit => + failureDiagnostics(error, getErrorCode) + /** Reject legacy or structurally invalid schemas at the runtime boundary. */ function assertV3Schemas(schemas: readonly AnyV3Table[]): void { for (const table of schemas) { @@ -97,7 +130,7 @@ class NativeEncryptionClient { * Initializes the NativeEncryptionClient with the provided configuration. * @internal * @param config - The configuration object for initializing the client. - * @returns A promise that resolves to a {@link Result} containing the initialized NativeEncryptionClient or an {@link EncryptionError}. + * @returns A promise that resolves to a {@link Result} containing the initialized NativeEncryptionClient or a {@link ClientInitError}. **/ async initialize(config: { encryptConfig: EncryptConfig @@ -107,7 +140,11 @@ class NativeEncryptionClient { clientKey?: string keyset?: KeysetIdentifier authStrategy?: AuthStrategy - }): Promise> { + // `ClientInitError`, not the `EncryptionError` base: this only ever + // produces the one member, and the base declares no `help` / `url`, so + // widening here would hide from the compiler two fields the failure really + // carries — and `Encryption()` below re-throws whatever this holds. + }): Promise> { return await withResult( async () => { const validated: EncryptConfig = encryptConfigSchema.parse( @@ -149,9 +186,13 @@ class NativeEncryptionClient { logger.debug('Successfully initialized the Encryption client.') return this }, + // The first place a caller meets a CTS refusal: `newClient` resolves a + // service token, so an organisation over its usage limit fails here + // rather than on the first encrypt. (error: unknown) => ({ type: EncryptionErrorTypes.ClientInitError, - message: (error as Error).message, + message: failureMessage(error), + ...initDiagnostics(error), }), ) } @@ -933,7 +974,19 @@ export async function Encryption(config: { }) if (result.failure) { - throw new Error(`[encryption]: ${result.failure.message}`) + // `Encryption()` throws rather than returning a `Result`, so the whole + // diagnostic has to ride on the thrown error or it is lost on the one path + // a caller is most likely to meet a CTS refusal on — a usage-limit failure + // surfaces at init, before any operation exists to return a `{ failure }` + // from. The remedy stays structured on `.help` / `.url`; this makes a thrown init failure + // classifiable the same way a returned operation failure is. + // + // Everything except the two fields the `Error` already expresses is + // carried, rather than a named subset: `initDiagnostics` decides what a + // failure holds, and enumerating it a second time here is how a field + // added to one and not the other goes missing. + const { type: _type, message, ...diagnostics } = result.failure + throw Object.assign(new Error(`[encryption]: ${message}`), diagnostics) } return createEncryptionClient(result.data, ...schemas) diff --git a/packages/stack/src/encryption/operations/batch-encrypt-query.ts b/packages/stack/src/encryption/operations/batch-encrypt-query.ts index cd0e764e0..c358df571 100644 --- a/packages/stack/src/encryption/operations/batch-encrypt-query.ts +++ b/packages/stack/src/encryption/operations/batch-encrypt-query.ts @@ -5,6 +5,10 @@ import { type QueryPayload, } from '@cipherstash/protect-ffi' import { formatEncryptedResult } from '@/encryption/helpers' +import { + failureDiagnostics, + failureMessage, +} from '@/encryption/helpers/auth-failure' import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { @@ -159,8 +163,8 @@ export class BatchEncryptQueryOperation extends EncryptionOperation< log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) @@ -227,8 +231,8 @@ export class BatchEncryptQueryOperationWithLockContext extends EncryptionOperati log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) diff --git a/packages/stack/src/encryption/operations/bulk-decrypt-models.ts b/packages/stack/src/encryption/operations/bulk-decrypt-models.ts index 4b38615ac..403aa82d8 100644 --- a/packages/stack/src/encryption/operations/bulk-decrypt-models.ts +++ b/packages/stack/src/encryption/operations/bulk-decrypt-models.ts @@ -1,4 +1,8 @@ import { type Result, withResult } from '@byteslice/result' +import { + failureDiagnostics, + failureMessage, +} from '@/encryption/helpers/auth-failure' import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { type LockContextInput, resolveLockContext } from '@/identity' @@ -51,8 +55,8 @@ export class BulkDecryptModelsOperation< log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) @@ -121,8 +125,8 @@ export class BulkDecryptModelsOperationWithLockContext< log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) diff --git a/packages/stack/src/encryption/operations/bulk-decrypt.ts b/packages/stack/src/encryption/operations/bulk-decrypt.ts index def9de4da..413452660 100644 --- a/packages/stack/src/encryption/operations/bulk-decrypt.ts +++ b/packages/stack/src/encryption/operations/bulk-decrypt.ts @@ -4,6 +4,10 @@ import { type DecryptResult, decryptBulkFallible, } from '@cipherstash/protect-ffi' +import { + failureDiagnostics, + failureMessage, +} from '@/encryption/helpers/auth-failure' import { getErrorCode } from '@/encryption/helpers/error-code' import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { @@ -52,7 +56,12 @@ const mapDecryptedDataToResult = ( } else { const decryptResult = decryptedData[decryptedIndex] if ('error' in decryptResult) { - result[i] = { id: encryptedPayloads[i].id, error: decryptResult.error } + // Spread, not `{ error: decryptResult.error }`: a failed row carries + // `code` and — since CTS refusals are propagated — `authCode` / `help` + // too, and the batch call itself resolved, so this row is the ONLY + // place they can reach the caller. Naming the keys would drop whatever + // protect-ffi adds next (a `url` beside `help` is already coming). + result[i] = { ...decryptResult, id: encryptedPayloads[i].id } } else { result[i] = { id: encryptedPayloads[i].id, data: decryptResult.data } } @@ -111,8 +120,8 @@ export class BulkDecryptOperation extends EncryptionOperation log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) @@ -184,8 +193,8 @@ export class BulkDecryptOperationWithLockContext extends EncryptionOperation log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) @@ -227,8 +231,8 @@ export class BulkEncryptOperationWithLockContext extends EncryptionOperation { log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) @@ -133,8 +137,8 @@ export class DecryptOperationWithLockContext extends EncryptionOperation { log.set({ errorCode: getErrorCode(error) ?? 'unknown' }) return { type: EncryptionErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), + message: failureMessage(error), + ...failureDiagnostics(error, getErrorCode), } }, ) @@ -166,8 +170,8 @@ export class EncryptOperationWithLockContext extends EncryptionOperation = new Set([ + 'USAGE_LIMIT_EXCEEDED', + 'ORG_NOT_PROVISIONED', +]) + +/** + * Classify a `/api/authorize` refusal, mirroring `classify_issuance_failure` in + * `stack-auth` (`src/error.rs`) so this seam and the Rust client cannot + * disagree about what the same response means. + * + * This is the only place in the SDK that reads a CTS response itself, so it is + * the only place that has to know the wire shape. Everywhere else the refusal + * arrives through protect-ffi with `authCode`, `help` and `url` already + * attached by `stack-auth`. + * + * A `402` carrying a usage refusal is JSON (`AuthorizeErrorBody` in + * `cts-web/src/authorize/mod.rs`): + * + * ```json + * {"error":"usage_limit_exceeded", + * "error_description":"Workspace has exceeded its usage limit and cannot issue an access token", + * "cs_code":"USAGE_LIMIT_EXCEEDED"} + * ``` + * + * The rules, in the order they are applied: + * + * - **Status decides, not the body.** Only a `402` is a usage refusal. The + * OAuth issuance paths must report one as `access_denied` to stay RFC 6749 + * compliant, so the body alone cannot be trusted to say what it is. + * - **An empty body reads as `USAGE_LIMIT_EXCEEDED`.** Deployments predating + * `cs_code` sent a bodyless `402`, and that only ever meant the usage limit. + * - **A non-empty body must parse as a JSON object.** A `402` that is HTML, or + * valid JSON that is not an object, did not come from CTS — it came from a + * proxy, WAF or gateway in front of it. Declining sends the caller down the + * generic path; asserting a billing remedy over a gateway failure sends them + * to a billing page for something a retry would have cleared. + * - **`cs_code` must name a known account refusal.** Unknown codes decline so + * a future use of `402` does not inherit today's billing classification. + * - **`cs_code` absent defaults to `USAGE_LIMIT_EXCEEDED`.** This preserves + * compatibility with deployments predating the taxonomy field, including + * OAuth responses whose `error` remains `access_denied`. + * + * Note what this deliberately does NOT do: call `response.json()`. Only the + * `402` is JSON. Every other failure from this endpoint is + * `(StatusCode, self.to_string())` — a `401` is the bare string + * `Authorization failed: InvalidToken` — and `.json()` on one throws a + * `SyntaxError` that displaces the real failure. The body is read as text + * exactly once and parsed defensively. + */ +function readCtsRefusal( + status: number, + raw: string, +): { authCode?: string; description?: string } { + if (status !== 402) return {} + + const trimmed = raw.trim() + if (!trimmed) return { authCode: 'USAGE_LIMIT_EXCEEDED' } + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return {} + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return {} + } + + const body = parsed as Record + const field = (name: string): string | undefined => { + const value = body[name] + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : undefined + } + + const description = field('error_description') + + // Presence is decided on the raw value, not on a string projection of it: a + // non-string `cs_code` must not read as absent and fall through to the + // `error` arm, which is the inversion of what this guard is for. + if ('cs_code' in body) { + const code = field('cs_code') + return code && KNOWN_REFUSALS.has(code) + ? { authCode: code, description } + : { description } + } + + return { authCode: 'USAGE_LIMIT_EXCEEDED', description } +} + +/** + * Turn a non-2xx `/api/authorize` response into the failure a caller can act + * on: the status, what CTS actually said, and — when CTS refused on billing + * grounds — the code to branch on plus the remedy naming where to fix it. + */ +async function ctsRefusalFailure(response: Response): Promise { + const raw = await response.text().catch(() => '') + const { authCode, description } = readCtsRefusal(response.status, raw) + + // Prefer the service's own `error_description` over the whole envelope: on a + // classified refusal it is the sentence a human wants, and quoting the raw + // JSON around it adds nothing a caller cannot get from `authCode`. + const body = (description ?? raw.trim()).slice(0, MAX_BODY_CHARS) + + const context = `The CipherStash API returned ${response.status} for the CTS token request` + + return { + type: EncryptionErrorTypes.CtsTokenError, + message: body ? `${context}: ${body}` : `${context}.`, + ...(authCode ? { authCode } : {}), + } +} + export type CtsRegions = 'ap-southeast-2' export type IdentifyOptions = { @@ -157,6 +278,19 @@ export class LockContext { return ctsFetchResult } + // `fetch` only rejects on a transport failure, so the block above sees a + // network error and nothing else: a CTS `402` carrying + // `cs_code: USAGE_LIMIT_EXCEEDED` RESOLVES, and without this check the body would + // fall through to the token parse below and be reported as a malformed + // response ("did not contain an access token. Please contact support.") — + // a support ticket for a billing state, or a JSON syntax error for a + // plain-text one. This is the only place in the SDK that talks to CTS + // directly; everywhere else the refusal arrives through protect-ffi with + // `authCode` already attached. + if (!ctsFetchResult.data.ok) { + return { failure: await ctsRefusalFailure(ctsFetchResult.data) } + } + const identifiedLockContext = await withResult( async () => { const ctsToken = (await ctsFetchResult.data.json()) as CtsToken diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index c63295d32..d48f46a22 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -4,6 +4,7 @@ import type { EncryptedPayload as CipherStashEncryptedPayload, EncryptedQuery as CipherStashEncryptedQuery, EncryptedV3Query as CipherStashEncryptedV3Query, + DecryptResult as FfiDecryptResult, JsPlaintext, newClient, QueryOpName, @@ -435,7 +436,28 @@ export type BulkDecryptPayload = Array<{ id?: string; data: Encrypted | null }> export type BulkDecryptedData = Array> type DecryptionSuccess = { error?: never; data: T; id?: string } -type DecryptionError = { error: T; id?: string; data?: never } + +/** + * Everything protect-ffi reports about a failed row BESIDES the message: + * `code`, plus the `authCode` / `help` it sets when CipherStash's token + * service refused the token behind the request. + * + * Derived from the FFI's own `DecryptResult` rather than restated, so a field + * added there (a `url` alongside `help`, say) arrives here without an edit. + * `bulkDecrypt` returns `{ data }` when only some rows failed — the call + * succeeded — so the row is the only surface these fields have, and a mapping + * that named the keys it knew would drop the next one silently. + */ +type FfiDecryptDiagnostic = Omit< + Extract, + 'error' +> + +type DecryptionError = { + error: T + id?: string + data?: never +} & FfiDecryptDiagnostic /** * Result type for individual items in bulk decrypt operations. diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 87896b1a8..4fcef150f 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -90,6 +90,7 @@ import { type OidcFederationStrategy, } from '@cipherstash/auth/wasm-inline' import { + isProtectErrorCode, decrypt as wasmDecrypt, decryptBulkFallible as wasmDecryptBulkFallible, encrypt as wasmEncrypt, @@ -99,6 +100,13 @@ import { isEncrypted as wasmIsEncrypted, newClient as wasmNewClient, } from '@cipherstash/protect-ffi/wasm-inline' +// Type-only against protect-ffi, so it is safe to bundle here — which is also +// why `failureDiagnostics` takes this entry's own `code` reader as an argument +// rather than importing one. See {@link readErrorCode} below. +import { + failureDiagnostics, + failureMessage, +} from '@/encryption/helpers/auth-failure' import { resolveIndexType } from '@/encryption/helpers/infer-index-type' import { prepareBulkModelsForOperation, @@ -465,26 +473,50 @@ export type WasmResult = | { data?: never; failure: EncryptionError } /** - * Read an FFI error code STRUCTURALLY. - * - * Deliberately not `@/encryption/helpers/error-code`: that narrows with - * `instanceof` against the native `ProtectError`, which is a runtime VALUE - * import of `@cipherstash/protect-ffi`. protect-ffi is not in tsup's - * `noExternal`, so importing it here put a bare `@cipherstash/protect-ffi` - * specifier into `dist/wasm-inline.js` — the native NAPI entry, in the one - * bundle that exists to avoid it. On Workers / Edge the non-`node` condition - * resolves that specifier to a module exporting no `ProtectError` at all. - * - * A structural read is also the only thing that could ever work here: the WASM - * build ships no error class, so `instanceof` never matches on this path - * regardless. A `code` string is all there is to find. + * Read an FFI error code STRUCTURALLY, and validate it. + * + * Deliberately not `@/encryption/helpers/error-code`: that module imports + * `@cipherstash/protect-ffi` — the native NAPI specifier — as a runtime value, + * and protect-ffi is not in tsup's `noExternal`, so importing it here put that + * bare specifier into `dist/wasm-inline.js`, the one bundle that exists to + * avoid it. On Workers / Edge the non-`node` condition resolves it to a module + * with none of the exports this file wants. + * + * A structural READ is also the only thing that could work here: the WASM build + * ships no error class, so `instanceof` never matches on this path regardless. + * + * The CHECK, though, is the same one the native entry makes, against the same + * list — `isProtectErrorCode` comes from `@cipherstash/protect-ffi/wasm-inline`, + * the module this file already imports every FFI call from, so it adds no + * specifier to the bundle. (`scripts/inline-wasm.mjs` appends the runtime + * `export { PROTECT_ERROR_CODES, getAuthErrorCode, isProtectErrorCode } from + * "./errors.js"` to the inlined build for exactly this kind of use, and + * protect-ffi's `errorCodes.test.ts` holds it in step with the declarations.) + * + * It has to be checked, not merely read: `code` is typed `ProtectErrorCode`, a + * CLOSED set, and the presence of a `code` property is evidence of nothing. + * Node stamps `ECONNRESET` / `MODULE_NOT_FOUND` onto its own errors, a + * wasm-bindgen rejection is whatever Rust threw, and {@link carryDiagnostics} + * now copies every own key off an arbitrary rejection — so without this the + * edge entry republished foreign strings wearing a type that promises one of + * fifteen known values. An unrecognised code is DROPPED rather than mapped to + * `UNKNOWN`, matching `getErrorCode`: `UNKNOWN` is a real member of the set and + * means protect-ffi itself could not classify the failure, which is a different + * statement from "this did not come from protect-ffi". + * + * `authCode` gets the opposite treatment on purpose — see `authFailureCode` in + * `@/encryption/helpers/auth-failure`. That set is `@cipherstash/auth`'s, ships + * on its own release train, and is typed open, so a code newer than this build + * must still reach the caller. + * + * Passed BY REFERENCE to `failureDiagnostics`, which is the only reason that + * helper takes a reader instead of calling one: the check has to happen inside + * the entry that can resolve `isProtectErrorCode` at runtime. */ function readErrorCode(error: unknown): EncryptionError['code'] { if (typeof error !== 'object' || error === null) return undefined const { code } = error as { code?: unknown } - return typeof code === 'string' - ? (code as EncryptionError['code']) - : undefined + return isProtectErrorCode(code) ? code : undefined } function toFailure( @@ -492,8 +524,17 @@ function toFailure( ): (error: unknown) => EncryptionError { return (error: unknown) => ({ type, - message: error instanceof Error ? error.message : String(error), - code: readErrorCode(error), + // Keep the upstream diagnostic message intact on both entries; structured + // guidance is carried by `help` and `url` below. + message: failureMessage(error), + // Spread the whole diagnostic rather than naming fields: this mapper listed + // `code` and `authCode`, so `help` reached a caller only as prose and `url` + // by no path at all — while the thrown init error carried both. `readCode` + // is passed in because the closed-set check needs protect-ffi's + // `isProtectErrorCode` AT RUNTIME, and this entry has to reach it through + // `@cipherstash/protect-ffi/wasm-inline`; a direct import inside the helper + // would put a specifier into both bundles. + ...failureDiagnostics(error, readErrorCode), }) } @@ -531,14 +572,73 @@ function toError(ex: unknown): Error { message = safeString(ex) } - // Carry a structural `code` onto the synthesized Error so `toFailure` can - // still surface it. Without this the conversion loses it: `withResult` runs - // `onException` FIRST, so the mapper only ever sees this fresh Error, and - // `failure.code` could never be populated on this entry at all. - const error = new Error(message) as Error & { code?: string } - const code = readErrorCode(ex) - if (code) error.code = code - return error + // Carry the rejection's diagnostic fields onto the synthesized Error so + // `toFailure` can still surface them. Without this the conversion loses + // them: `withResult` runs `onException` FIRST, so the mapper only ever sees + // this fresh Error, and `failure.code` / `failure.authCode` could never be + // populated on this entry at all. + return carryDiagnostics(ex, new Error(message)) +} + +/** + * Field names that describe the Error itself rather than the failure, and so + * must not be copied off a rejection by {@link carryDiagnostics}. `message` in + * particular: {@link toError} has already decided what the message is, and on + * the object path that decision (the serialized rejection) shows the reader + * every field, which a bare `message` would hide. + */ +const NOT_DIAGNOSTIC = new Set(['message', 'name', 'stack']) + +/** + * Copy a rejection's diagnostic fields onto `target`. + * + * Deliberately NOT an enumerated list of known keys. protect-ffi sets `code`, + * `authCode`, `help` and `url` on a thrown error (`error_to_js` in + * `crates/protect-ffi/src/wasm.rs`) — and `url` is the proof this has to be + * structural rather than a field list: it is live today, because + * `@cipherstash/auth` declares `help?` / `url?` on `FailureBase` and a JS + * strategy's own values are relayed verbatim, yet both seams here were + * discarding it. A mapper that names the fields it knows drops the next one + * silently, which is exactly how `authCode` and `help` were lost after `code` + * was handled. + * + * Reads are individually guarded: `withResult` invokes `onException` bare + * inside its catch, so a throwing getter on a rejection would escape the + * Result contract entirely and reject the call — the same hazard + * {@link safeString} exists for. + */ +function carryDiagnostics(source: unknown, target: T): T { + if (typeof source !== 'object' || source === null) return target + const fields = source as Record + for (const key of Object.keys(fields)) { + if (NOT_DIAGNOSTIC.has(key)) continue + try { + const value = fields[key] + if (value !== undefined) Object.assign(target, { [key]: value }) + } catch { + // A field that cannot be read is a field that cannot be carried; losing + // it beats turning a failure into a rejection. + } + } + + // `code` is the one copied key with a CLOSED type, so it is the one key the + // structural copy above cannot be trusted with. Everything else is either + // open (`authCode`) or untyped prose (`help`, `url`), but `code` is declared + // `ProtectErrorCode`, and Node stamps `ECONNRESET` / `MODULE_NOT_FOUND` onto + // its own errors — so a rejection from a fetch inside a JS auth strategy + // would otherwise republish a foreign string wearing that type. + // + // `toFailure` has always run `readErrorCode` for exactly this reason; the + // thrown-init path reached the caller through `carryDiagnostics` alone and + // did not, which left the two seams of this entry disagreeing about a check + // the native entry applies to both. Dropping an unrecognised code (rather + // than mapping it to `UNKNOWN`) matches `readErrorCode` — see its docblock + // for why those two are different statements. + if ('code' in target && readErrorCode(target) === undefined) { + delete (target as { code?: unknown }).code + } + + return target } /** @@ -574,6 +674,30 @@ function wasmResult( return withResult(operation, toFailure(type), { onException: toError }) } +/** + * The error {@link Encryption} throws when the FFI refuses to build a client. + * + * Client construction is the one operation on this entry that is NOT a Result: + * `Encryption()` throws, on both entries, so the diagnostic has to ride on an + * `Error` instead of a `{ failure }`. This gives it the same three things the + * native factory gives it — the `[encryption]:` prefix every throw from these + * factories carries, plus the FFI's fields (`code`, `authCode`, `help`, …) + * copied across so a caller can branch + * on a billing refusal rather than parse prose. + * + * {@link toError} first, so an object- or string-shaped rejection still yields + * a readable `Error`; `cause` keeps the original for anything not carried. + */ +function clientInitError(ex: unknown): Error { + const coerced = toError(ex) + return carryDiagnostics( + coerced, + new Error(`[encryption]: ${failureMessage(coerced)}`, { + cause: coerced, + }), + ) +} + /** * Guard the positional contract the bulk methods rely on. * @@ -648,10 +772,48 @@ function assertBatchLength(op: string, received: number, sent: number): void { * One item of a `decryptBulkFallible` response: the decrypted plaintext, or * this item's own failure (the batch call itself still resolves). Shared by * {@link WasmEncryptionClient.bulkDecrypt} and the model decrypt engine. + * + * The failure arm mirrors protect-ffi's `DecryptResult`. Declared locally for + * the same reason {@link WasmPlaintext} is — protect-ffi is not in tsup's + * `noExternal`, so a type imported from it here is fine but a runtime one is + * not — which means it has to be kept in step by hand. + * + * `authCode` is on the wire (`DecryptResult::Error` in + * `crates/protect-ffi/src/lib.rs` carries it, and both serializers write it) so + * it is declared here, but NOTHING should branch on it: no per-item failure can + * be an auth one. `DecryptResult::from_error` is the arm's only constructor, + * and it populates `authCode` only for `Error::Auth` / `Error::ZeroKMS(Auth)`, + * which cannot reach it — the three per-item sources are a ciphertext parse + * failure, a `Plaintext`/`JsPlaintext` conversion, and a `RecordDecryptError`, + * each its own variant. A token refusal is resolved once for the whole call and + * leaves via `?` on `decrypt_fallible`, failing the batch outright, which + * arrives here as a rejection and is handled by {@link toFailure} like any + * other. Both engines are written this way; they do not diverge. + * + * `help` and `url` are a different matter — a parse or type error CAN carry + * miette help, so those are reachable per item. Nothing renders them yet; see + * the note at the bulk-decrypt aggregate. */ -type FallibleDecryptItem = - | { data: WasmPlaintext } - | { error: string; code?: string } +type FallibleDecryptFailure = { + error: string + code?: string + authCode?: string + help?: string + url?: string +} + +type FallibleDecryptItem = { data: WasmPlaintext } | FallibleDecryptFailure + +/** + * The code to print for one failed item. Shared by the two aggregate builders + * so they cannot render the same thing two ways. + * + * `code` only: see {@link FallibleDecryptFailure} for why `authCode` is never + * there to print. + */ +function failureCode(item: FallibleDecryptFailure): string { + return item.code ? ` (${item.code})` : '' +} /** * The JS property paths of `table`'s date-like columns (`cast_as: 'date' | @@ -1158,11 +1320,18 @@ export class WasmEncryptionClient { // on `failure.code` — a batch has no single code, and inventing one from // the first failure would be wrong — but dropping it entirely would lose // the only machine-meaningful part of a row's error. + // + // Nothing auth-shaped is hoisted onto the aggregate, because a per-item + // failure is never an auth one — see `FallibleDecryptFailure`. A billing + // refusal fails this whole call and arrives as a rejection instead, where + // `toFailure` gives it the code and the remedy. + // + // A row's `help` / `url` ARE reachable and are not rendered yet; that + // belongs with the shared failure-diagnostics work, not here. const failures: string[] = [] for (const { result, at } of placed) { if ('error' in result) { - const code = result.code ? ` (${result.code})` : '' - failures.push(` [${at}]${code}: ${result.error}`) + failures.push(` [${at}]${failureCode(result)}: ${result.error}`) continue } out[at] = result.data @@ -1427,10 +1596,9 @@ export class WasmEncryptionClient { const failures: string[] = [] results.forEach((result, i) => { if ('error' in result) { - const code = result.code ? ` (${result.code})` : '' const field = fields[i] failures.push( - ` ${label(field.modelIndex, field.fieldKey)}${code}: ${result.error}`, + ` ${label(field.modelIndex, field.fieldKey)}${failureCode(result)}: ${result.error}`, ) } }) @@ -1569,15 +1737,28 @@ export async function Encryption( // asks you to build. Verified against the 0.31 wasm build: `cast_as: 'string'` // and `cast_as: 'text'` both get past config parsing to authentication, where // 0.30 rejected the former with ``unknown variant `string` ``. - const client = await wasmNewClient({ - authStrategy: strategy, - encryptConfig, - clientOpts: { - clientId: clientConfig.clientId, - clientKey: clientConfig.clientKey, - }, - eqlVersion: 3, - }) + // + // The rejection is NOT allowed to propagate raw. `newClient` resolves a + // service token, so this is where an organisation over its usage allowance + // meets CipherStash's refusal — the same failure, on the same line, as the + // native entry's `initialize`, which preserves the upstream diagnostic + // fields. Leaving it bare here gave a Workers/Deno caller no structured + // guidance and — for the object-shaped rejections this entry has to expect — + // a thrown value with no `.message` at all. + let client: Awaited> + try { + client = await wasmNewClient({ + authStrategy: strategy, + encryptConfig, + clientOpts: { + clientId: clientConfig.clientId, + clientKey: clientConfig.clientKey, + }, + eqlVersion: 3, + }) + } catch (ex) { + throw clientInitError(ex) + } // `INTERNAL_CONSTRUCT` is module-scoped, so this factory is the only // code that can build a `WasmEncryptionClient` — external callers hit diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4648cbc7f..4ae3e15b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,26 +7,26 @@ settings: catalogs: repo: '@cipherstash/auth': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.44.0 + version: 0.44.0 '@cipherstash/auth-darwin-arm64': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.44.0 + version: 0.44.0 '@cipherstash/auth-darwin-x64': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.44.0 + version: 0.44.0 '@cipherstash/auth-linux-arm64-gnu': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.44.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-gnu': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.44.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-musl': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.44.0 + version: 0.44.0 '@cipherstash/auth-win32-x64-msvc': - specifier: 0.42.0 - version: 0.42.0 + specifier: 0.44.0 + version: 0.44.0 '@types/node': specifier: 22.20.1 version: 22.20.1 @@ -219,7 +219,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.42.0(@cipherstash/auth-darwin-arm64@0.42.0)(@cipherstash/auth-darwin-x64@0.42.0)(@cipherstash/auth-linux-arm64-gnu@0.42.0)(@cipherstash/auth-linux-x64-gnu@0.42.0)(@cipherstash/auth-linux-x64-musl@0.42.0)(@cipherstash/auth-win32-x64-msvc@0.42.0) + version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) '@cipherstash/eql': specifier: workspace:* version: link:../eql/packages/eql @@ -275,22 +275,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 packages/eql/packages/eql: devDependencies: @@ -412,7 +412,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.42.0(@cipherstash/auth-darwin-arm64@0.42.0)(@cipherstash/auth-darwin-x64@0.42.0)(@cipherstash/auth-linux-arm64-gnu@0.42.0)(@cipherstash/auth-linux-x64-gnu@0.42.0)(@cipherstash/auth-linux-x64-musl@0.42.0)(@cipherstash/auth-win32-x64-msvc@0.42.0) + version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) '@cipherstash/protect-ffi': specifier: workspace:* version: link:.. @@ -458,7 +458,7 @@ importers: version: 0.2.0 '@cipherstash/auth': specifier: catalog:repo - version: 0.42.0(@cipherstash/auth-darwin-arm64@0.42.0)(@cipherstash/auth-darwin-x64@0.42.0)(@cipherstash/auth-linux-arm64-gnu@0.42.0)(@cipherstash/auth-linux-x64-gnu@0.42.0)(@cipherstash/auth-linux-x64-musl@0.42.0)(@cipherstash/auth-win32-x64-msvc@0.42.0) + version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) '@cipherstash/protect-ffi': specifier: workspace:* version: link:../protect-ffi @@ -532,22 +532,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 packages/stack-drizzle: dependencies: @@ -707,7 +707,7 @@ importers: version: 0.117.1(zod@3.25.76) '@cipherstash/auth': specifier: catalog:repo - version: 0.42.0(@cipherstash/auth-darwin-arm64@0.42.0)(@cipherstash/auth-darwin-x64@0.42.0)(@cipherstash/auth-linux-arm64-gnu@0.42.0)(@cipherstash/auth-linux-x64-gnu@0.42.0)(@cipherstash/auth-linux-x64-musl@0.42.0)(@cipherstash/auth-win32-x64-msvc@0.42.0) + version: 0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0) '@clack/prompts': specifier: 1.7.0 version: 1.7.0 @@ -745,22 +745,22 @@ importers: optionalDependencies: '@cipherstash/auth-darwin-arm64': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-darwin-x64': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-arm64-gnu': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-gnu': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-linux-x64-musl': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 '@cipherstash/auth-win32-x64-msvc': specifier: catalog:repo - version: 0.42.0 + version: 0.44.0 packages: @@ -982,48 +982,48 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/auth-darwin-arm64@0.42.0': - resolution: {integrity: sha512-8pDvjk2Sftdkh8/TQfBJJV3exHJT8HWWEVkJGXyFRaUrgvn5eDAXfx0RbSgGNfkPmj1ac1DsQ753Tgx/+N/xEQ==} + '@cipherstash/auth-darwin-arm64@0.44.0': + resolution: {integrity: sha512-5BJ1r5LXZ814oPT22PKDoEPugPaWQ8Y1TWdjQwhJ+N/XsPaPDXeV+Fy0g4CYxnp60+JiA8AJGXd4tujbHipHSw==} cpu: [arm64] os: [darwin] - '@cipherstash/auth-darwin-x64@0.42.0': - resolution: {integrity: sha512-tv5fE8pPbJ+PdCWRKwQ1KdJYGDxhaZ2oDDGFnmZkSXgzprN/1yayhJbt6uv6SYjXyeUAKhwT4UUGiowslmfQ1w==} + '@cipherstash/auth-darwin-x64@0.44.0': + resolution: {integrity: sha512-LeC8TBheva3u5UndhyjGc9IweYSkuY9MZKw7zAPZnWrPiIczsefPfkCXoEu6LLGYUZZNee8Nl1LwpPtkZE5xoA==} cpu: [x64] os: [darwin] - '@cipherstash/auth-linux-arm64-gnu@0.42.0': - resolution: {integrity: sha512-urEes/2CIIzFXQz3GlLLofaS9EcIRczMKO0o3z2rvU5NsHBtRhFnHum/JwnhNdq3XF1bEyUfG3gGuFqJOPbZHQ==} + '@cipherstash/auth-linux-arm64-gnu@0.44.0': + resolution: {integrity: sha512-jJLydQhKX98j+6yAqC9DtgdL/XHRWU5w2PSZnr/fJMAnXYfE/sw9tocvEm8K3qkreFuVkDXiJjttYW6s0EWdtQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@cipherstash/auth-linux-x64-gnu@0.42.0': - resolution: {integrity: sha512-2UNbPQ3NxRxl+hK+QpmLAYzTw829tEiDBv/xpWXW3ymosruJUarIH4BneMv3tFKKMs8V3pSC+P6VM8JALRWI9Q==} + '@cipherstash/auth-linux-x64-gnu@0.44.0': + resolution: {integrity: sha512-vatcPWcJZtaezhHG+sEm18BojCL2vpME4AVIDtsKywN2DyJgxuScX02Hso4XW7MM/IjeH0AZhE3kEGWkWUhkGQ==} cpu: [x64] os: [linux] libc: [glibc] - '@cipherstash/auth-linux-x64-musl@0.42.0': - resolution: {integrity: sha512-OpjkyRpxudnEKuJhbR/3+0ldsZRs2asI75WM4ZixXBEkFoALpVfwY7oukf1YmvNVR04A6QTMxC7XlkuWvxf7Ag==} + '@cipherstash/auth-linux-x64-musl@0.44.0': + resolution: {integrity: sha512-rA08b++pIukcHTQhJ9fWw5fzlgDgQ0CtLbtrkuW+0fkVY1VrdKO5GjhxMS7BY4aXkR+62Crjl61rQvbQOjOBHg==} cpu: [x64] os: [linux] libc: [musl] - '@cipherstash/auth-win32-x64-msvc@0.42.0': - resolution: {integrity: sha512-fvcpTtY6LYCSVfimWNNuzeiV3mRvcGXzHdWNzHLeIvtOoMSdOi4eaUHLRN9Tivyxg3mR0bgDuJbRqJ0N95Xerw==} + '@cipherstash/auth-win32-x64-msvc@0.44.0': + resolution: {integrity: sha512-Yhviqyrwl8DVt7EDdA/MJw0A8sNGRT6iH/xiGm85Wu5wQWFOeTYEry7DMWJ5dLB31qXxeKymjb6SQoFPK3hbQQ==} cpu: [x64] os: [win32] - '@cipherstash/auth@0.42.0': - resolution: {integrity: sha512-rPpYxOs/Xb1Bfen/yoGZZFwDWJhqQFO7Q9QJ6O5HcMt6THx2zVSQ6tQzH2liIhdNLsYFIj9S4HxmDJy2B0408g==} + '@cipherstash/auth@0.44.0': + resolution: {integrity: sha512-NwMWM7nPsdPAFeGCMpXkmsjo+2MaQ/W2sHwD14eGqahC2SG8Jg3Zr4agPruKYhPO4Z1CUwLcDBbSg9Lglsy+QA==} peerDependencies: - '@cipherstash/auth-darwin-arm64': 0.42.0 - '@cipherstash/auth-darwin-x64': 0.42.0 - '@cipherstash/auth-linux-arm64-gnu': 0.42.0 - '@cipherstash/auth-linux-x64-gnu': 0.42.0 - '@cipherstash/auth-linux-x64-musl': 0.42.0 - '@cipherstash/auth-win32-x64-msvc': 0.42.0 + '@cipherstash/auth-darwin-arm64': 0.44.0 + '@cipherstash/auth-darwin-x64': 0.44.0 + '@cipherstash/auth-linux-arm64-gnu': 0.44.0 + '@cipherstash/auth-linux-x64-gnu': 0.44.0 + '@cipherstash/auth-linux-x64-musl': 0.44.0 + '@cipherstash/auth-win32-x64-msvc': 0.44.0 peerDependenciesMeta: '@cipherstash/auth-darwin-arm64': optional: true @@ -3961,34 +3961,34 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 - '@cipherstash/auth-darwin-arm64@0.42.0': + '@cipherstash/auth-darwin-arm64@0.44.0': optional: true - '@cipherstash/auth-darwin-x64@0.42.0': + '@cipherstash/auth-darwin-x64@0.44.0': optional: true - '@cipherstash/auth-linux-arm64-gnu@0.42.0': + '@cipherstash/auth-linux-arm64-gnu@0.44.0': optional: true - '@cipherstash/auth-linux-x64-gnu@0.42.0': + '@cipherstash/auth-linux-x64-gnu@0.44.0': optional: true - '@cipherstash/auth-linux-x64-musl@0.42.0': + '@cipherstash/auth-linux-x64-musl@0.44.0': optional: true - '@cipherstash/auth-win32-x64-msvc@0.42.0': + '@cipherstash/auth-win32-x64-msvc@0.44.0': optional: true - '@cipherstash/auth@0.42.0(@cipherstash/auth-darwin-arm64@0.42.0)(@cipherstash/auth-darwin-x64@0.42.0)(@cipherstash/auth-linux-arm64-gnu@0.42.0)(@cipherstash/auth-linux-x64-gnu@0.42.0)(@cipherstash/auth-linux-x64-musl@0.42.0)(@cipherstash/auth-win32-x64-msvc@0.42.0)': + '@cipherstash/auth@0.44.0(@cipherstash/auth-darwin-arm64@0.44.0)(@cipherstash/auth-darwin-x64@0.44.0)(@cipherstash/auth-linux-arm64-gnu@0.44.0)(@cipherstash/auth-linux-x64-gnu@0.44.0)(@cipherstash/auth-linux-x64-musl@0.44.0)(@cipherstash/auth-win32-x64-msvc@0.44.0)': dependencies: '@byteslice/result': 0.3.0 optionalDependencies: - '@cipherstash/auth-darwin-arm64': 0.42.0 - '@cipherstash/auth-darwin-x64': 0.42.0 - '@cipherstash/auth-linux-arm64-gnu': 0.42.0 - '@cipherstash/auth-linux-x64-gnu': 0.42.0 - '@cipherstash/auth-linux-x64-musl': 0.42.0 - '@cipherstash/auth-win32-x64-msvc': 0.42.0 + '@cipherstash/auth-darwin-arm64': 0.44.0 + '@cipherstash/auth-darwin-x64': 0.44.0 + '@cipherstash/auth-linux-arm64-gnu': 0.44.0 + '@cipherstash/auth-linux-x64-gnu': 0.44.0 + '@cipherstash/auth-linux-x64-musl': 0.44.0 + '@cipherstash/auth-win32-x64-msvc': 0.44.0 '@clack/core@1.4.3': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8ac49a998..93ac525a3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,13 +41,13 @@ catalogs: # "Failed to load native binding" (the 1.0.0-rc.2 B1 bug). Enforced by # e2e/tests/supply-chain.e2e.test.ts; Dependabot ignores all seven # names — bump them together, manually. - '@cipherstash/auth': 0.42.0 - '@cipherstash/auth-darwin-arm64': 0.42.0 - '@cipherstash/auth-darwin-x64': 0.42.0 - '@cipherstash/auth-linux-arm64-gnu': 0.42.0 - '@cipherstash/auth-linux-x64-gnu': 0.42.0 - '@cipherstash/auth-linux-x64-musl': 0.42.0 - '@cipherstash/auth-win32-x64-msvc': 0.42.0 + '@cipherstash/auth': 0.44.0 + '@cipherstash/auth-darwin-arm64': 0.44.0 + '@cipherstash/auth-darwin-x64': 0.44.0 + '@cipherstash/auth-linux-arm64-gnu': 0.44.0 + '@cipherstash/auth-linux-x64-gnu': 0.44.0 + '@cipherstash/auth-linux-x64-musl': 0.44.0 + '@cipherstash/auth-win32-x64-msvc': 0.44.0 '@types/node': 22.20.1 tsup: 8.5.1 tsx: 4.23.12 diff --git a/skills/stash-auth/SKILL.md b/skills/stash-auth/SKILL.md index 84ead6083..750b2497c 100644 --- a/skills/stash-auth/SKILL.md +++ b/skills/stash-auth/SKILL.md @@ -91,6 +91,65 @@ X is not a member of workspace Y", "No OIDC provider found for issuer: …", organisation is over its usage limit. A 402 is a billing problem, not a credentials problem — don't rotate keys over it. +### The billing refusal, and why it needs its own handling + +A token failure is normally transient (network, an expired token about to be +renewed) or a credentials problem (wrong key, wrong workspace) — both worth +retrying or re-authenticating. A 402 is neither: **nothing the process can do +clears it.** A retry loop that treats every token failure alike will hammer a +condition only a human with a billing page can resolve. + +So it is separately identifiable. A failure that came from CTS carries +`authCode` alongside the usual `type` and `code`, with instructions on `help` +and the link that goes with them on `url`: + +```typescript +const result = await client.encrypt(value, { column, table }) +if (result.failure?.authCode === 'USAGE_LIMIT_EXCEEDED') { + // Stop retrying. `result.failure.help` says what to do, + // `result.failure.url` is where to do it. +} +``` + +The remedy and link remain structured on `help` and `url`; `message` stays the +original diagnosis owned by stack-auth. + +Two codes mean "stop", and they need different remedies: + +| `authCode` | What it means | What clears it | +|---|---|---| +| `USAGE_LIMIT_EXCEEDED` | The organisation has used its allowance for the current billing period | Upgrade the plan at [dashboard.cipherstash.com/billing](https://dashboard.cipherstash.com/billing) | +| `ORG_NOT_PROVISIONED` | The organisation isn't registered with the usage system at all | Nothing you can buy — [contact support](https://cipherstash.com/support) | + +`authCode` is set for every other CTS failure too (`NOT_AUTHENTICATED`, +`WORKSPACE_MISMATCH`, `EXPIRED_TOKEN`, …), and where one of those carries +remedy text — `MISSING_WORKSPACE_CRN` naming `CS_WORKSPACE_CRN`, say — it now +reaches `help` rather than being dropped. Treat the set as **open** — it +belongs to `@cipherstash/auth` and grows on its own release train, so compare +with `===` rather than switching exhaustively over it. + +`Encryption()` throws rather than returning a `Result`, so at client init the +same code rides on the thrown error: `(err as { authCode?: string }).authCode`. + +Two of those fields are new alongside `authCode`. `help` is the remedy text and +`url` is its destination; both come directly from the upstream diagnostic. + +**For the two terminal codes, read both.** As of `stack-auth` 0.42.3 CipherStash +attaches a `help` *and* a `url` to each. Render both when presenting guidance. + +One shape to know: a refusal met by `LockContext.identify()` is an HTTP response +rather than a thrown error, so its failure carries `type` and `authCode` but no +`code` — the closed protect-ffi code set does not apply to a direct HTTP call. +Branch on `authCode` there, not `code`. + +On the CLI, `stash env` reports these under their own codes rather than +`session_invalid` — `usage_limit_exceeded` and `org_not_provisioned` — and points +at the dashboard or at support instead of telling you to log in again. On +`--json` the remedy arrives in a `hint` field, not in `message`. +`stash auth login --json` retains the upstream uppercase spellings +`USAGE_LIMIT_EXCEEDED` and `ORG_NOT_PROVISIONED`; consumers of both command +streams should normalize that casing difference. + ## The strategies From `@cipherstash/auth`, re-exported by `@cipherstash/stack` so no separate diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 53b2d684f..ace23e94a 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -82,7 +82,7 @@ npx stash auth login --json --region us-east-1 | `{ status: "authorization_required", userCode, verificationUri, verificationUriComplete, expiresIn }` | Emitted immediately. **Show `verificationUriComplete` to the user and wait.** | | `{ status: "authorized", expiresAt, expiresAtIso }` | The human approved. | | `{ status: "device_bound" }` | Device bound to the default keyset. Done. | -| `{ status: "error", code, message }` | Failure. Exit code 1. | +| `{ status: "error", code, message, hint? }` | Failure. Exit code 1. `hint` is present only when the failure has a remedy; `{cli}` in it is already resolved. | Operationally: after printing `authorization_required` the command **blocks, polling, until the human approves or the code expires** (`expiresIn` is @@ -142,7 +142,7 @@ When a required value is missing in a non-TTY context, the command exits non-zer **`plan` and `impl` need `--target` in a non-TTY.** Their agent-target picker reads from `/dev/tty`. Without `--target` they print a "no agent selected" hint and exit 0 *without performing the handoff*. `init` and `status` adapt automatically and are safe anywhere. -**Exit codes.** `1` on failure; `0` when a user cancels a prompt. In `--json` mode an `{ "status": "error", "code", "message" }` line is emitted before exiting 1. +**Exit codes.** `1` on failure; `0` when a user cancels a prompt. In `--json` mode an `{ "status": "error", "code", "message", "hint"? }` line is emitted before exiting 1. `hint` appears only when the failure carries a remedy — read it, because for a terminal failure it is the part that says retrying cannot help. **`stash status --json` has a stable shape** — `{ initialized, planExists, observedFromDb, active[], completed[] }`, each quest carrying `{ table, column, path, title, progress, complete, nextMove, objectives[] }`. It will not change without a major version bump. Prefer it over parsing `--plain`. @@ -681,8 +681,31 @@ Things to know: - **Non-interactive runs require `--name`** — without it the command exits 1 with an actionable message before touching the network, and `--write` refuses to overwrite an existing file (also before anything is minted). - In `--json` mode failures arrive as `{ status: "error", code, message }` + In `--json` mode failures arrive as `{ status: "error", code, message, hint? }` on stdout. +- **`usage_limit_exceeded` is not a session problem.** The command renews the + device session before minting anything, and CipherStash refuses that renewal + with a 402 when the organisation is over its billing allowance. That case + reports `usage_limit_exceeded` rather than `session_invalid`, and points at + [dashboard.cipherstash.com/billing](https://dashboard.cipherstash.com/billing) rather than at + `stash auth login` — logging in again cannot mint a credential that is being + withheld on billing grounds. Same for `stash auth login` itself, which fails + with `USAGE_LIMIT_EXCEEDED` on the `--json` stream. + + The spelling follows each command's existing JSON convention: `auth login` + emits the upstream uppercase `USAGE_LIMIT_EXCEEDED`, while `env` emits the + lowercase CLI code `usage_limit_exceeded`. Treat them as the same condition + when consuming both streams. + + **`org_not_provisioned` is the sibling case**, and equally terminal: the + organisation is not registered with the usage system at all, so there is no + plan to upgrade and it goes to [support](https://cipherstash.com/support). `stash env` reports it + under its own code rather than `session_invalid`, for the same reason — + branching on `code` is how an agent decides whether a re-login is worth + attempting, and both of these answer no. + + On `--json`, the remedy for either arrives in `hint`, not in `message`. See + the `stash-auth` skill for the full taxonomy. - **`--json` + `--write` compose**: the file is written and the JSON confirmation (`{ status: "written", path, … }`) is deliberately secret-free, so captured CI logs never contain the key. diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 85ff50d06..7bd477031 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -861,6 +861,8 @@ if (result.failure) { `StackError` is a discriminated union of all the error types above, enabling exhaustive `switch` handling. `EncryptionErrorTypes` provides runtime constants for each error type string. Use `getErrorMessage(error: unknown): string` to safely extract a message from any thrown value. +**Don't retry on `authCode`.** A failure whose cause was CipherStash's token service also carries `authCode` — and two of its values mean no retry can ever succeed: `USAGE_LIMIT_EXCEEDED` (the organisation is over its billing allowance) and `ORG_NOT_PROVISIONED`. The `message` names the remedy and `url` carries the link; `authCode` is there so a retry loop can stop. `type` cannot express this — a billing refusal surfaces as `ClientInitError` or `EncryptionError` like any other failure. See the `stash-auth` skill, which is canonical for the auth failure taxonomy. + ```typescript import { EncryptionErrorTypes, type StackError, getErrorMessage } from "@cipherstash/stack/errors"