Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/nextjs-cts-refusal-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions .changeset/protect-ffi-auth-error-code.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions .changeset/protect-ffi-client-0-42-3.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .changeset/usage-limit-refusal-guidance.md
Original file line number Diff line number Diff line change
@@ -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.
195 changes: 195 additions & 0 deletions packages/cli/src/commands/auth/__tests__/failure.test.ts
Original file line number Diff line number Diff line change
@@ -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',
)
})
})
108 changes: 108 additions & 0 deletions packages/cli/src/commands/auth/__tests__/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
])
})
})
Loading
Loading