Skip to content
Open
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
32 changes: 32 additions & 0 deletions .changeset/protect-ffi-auth-error-code.md.deferred
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@cipherstash/protect-ffi': minor
---

Carry a failure's auth taxonomy across the JavaScript boundary. A thrown error
that originated in `stack-auth` — CipherStash's token service refusing to issue
or renew the service token every ZeroKMS request carries — now sets `authCode`
and `help` alongside the existing `code`, on both bindings and on each item of
`decryptBulkFallible`. `getAuthErrorCode(err)` reads it back, and
`ProtectAuthErrorCode` types it.

Nothing carried it before. `Error::Auth` and `Error::ZeroKMS` are both
`#[error(transparent)]` with no `#[diagnostic(code(..))]`, so the whole auth
taxonomy arrived as an untyped `Error` with nothing but prose — no code, and no
`help`, since `miette` help is not part of an error's `Display`. That is the
half of each of those errors that says what to do about it.

The case that made it matter is `USAGE_LIMIT_EXCEEDED`: an organisation over
its billing allowance is neither a credentials problem nor a transient one, and
a caller could not tell it apart from either. A well-behaved retry loop would
hammer a condition only a human with a billing page can clear.

`authCode` is a separate field from `code`, not more members of it. `code` is
this package's own closed `ProtectErrorCode` set, pinned by `errorCodes.test.ts`
against the `#[diagnostic(code(..))]` attributes in `crates/protect-ffi/src/lib.rs`;
the auth set belongs to `stack-auth` and ships on its own release train, so
`ProtectAuthErrorCode` is deliberately an open union — narrow it with `===`,
don't `switch` exhaustively.

The code is read off the `AuthError` variant, never off the message, which is
the same rule the `code` contract already states: a rename upstream is a
compile error rather than a silent downgrade to `UNKNOWN`.
48 changes: 48 additions & 0 deletions .changeset/usage-limit-refusal-guidance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
'@cipherstash/stack': minor
'stash': minor
---

Surface a CipherStash billing refusal as something you can act on, instead of a
sentence with no remedy in it.

When an organisation is over its usage allowance, CipherStash's token service
refuses to issue or renew the service token behind every operation, answering
`402` with `Insufficient balance. Please upgrade your plan.` That reached a
caller as bare prose: it names no dashboard, and nothing in it says that
retrying — or rotating credentials — cannot help. A well-behaved retry loop
would hammer a condition only a human with a billing page can clear.

**`@cipherstash/stack`.** Every failure that came from the token service now
carries `authCode`, and its `message` carries the remedy — including the
dashboard URL, which the underlying response does not have. It is folded into
`message` rather than parked in a sibling field because `throw new
Error(failure.message)` is how these are surfaced in practice, so guidance
anywhere else is guidance nobody reads at the moment it is needed.

```typescript
const result = await client.encrypt(value, { column, table })
if (result.failure?.authCode === 'USAGE_LIMIT_EXCEEDED') {
// Stop retrying. `result.failure.message` names dashboard.cipherstash.com.
}
```

`ORG_NOT_PROVISIONED` is the other terminal code, and needs the opposite advice:
the organisation is not registered with the usage system at all, so there is no
plan to upgrade and it goes to support. The remaining codes
(`NOT_AUTHENTICATED`, `WORKSPACE_MISMATCH`, `EXPIRED_TOKEN`, …) are populated
too, and where one of them carries remedy text — `MISSING_WORKSPACE_CRN` naming
`CS_WORKSPACE_CRN`, say — that text now reaches the message instead of being
dropped at the FFI boundary. Both entries are covered — native and
`wasm-inline` — as is
`Encryption()`, which throws rather than returning a `Result` and so attaches
`authCode` to the thrown error.

Treat the set as open: it belongs to `@cipherstash/auth` and grows on its own
release train, so compare with `===` rather than switching exhaustively.

**`stash`.** `stash auth login` and `stash env` print the remedy alongside the
diagnosis, and no longer answer a billing refusal with "run `stash auth login`
and try again" — a fresh login cannot mint a credential that is being withheld
on billing grounds. `stash env` reports it as `usage_limit_exceeded` rather than
`session_invalid`.
96 changes: 96 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,96 @@
import { describe, expect, it } from 'vitest'
import {
authFailureCode,
authFailureHint,
authFailureMessage,
} from '../failure.js'

const LOGIN_HINT = 'Run `stash auth login` and try again.'

/** An `AuthFailure` as `@cipherstash/auth` returns it. */
const failure = (type: string, message: string, help?: string) => ({
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.')
})
})

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.'),
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.'),
LOGIN_HINT,
)

expect(hint).toContain('support@cipherstash.com')
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()
})
})

describe('authFailureCode', () => {
it('widens the code so a call site can compare it to an unreleased one', () => {
// The pinned `@cipherstash/auth` union does not name USAGE_LIMIT_EXCEEDED
// yet; comparing `failure.type` to it directly is a type error rather than
// a `false`. Routing through here keeps the CLI correct on both sides of
// that dependency bump.
expect(
authFailureCode(failure('USAGE_LIMIT_EXCEEDED', 'Insufficient balance.')),
).toBe('USAGE_LIMIT_EXCEEDED')
})
})
57 changes: 57 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,60 @@ 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.',
},
})

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, without the prose hint', 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',
})
})
})
94 changes: 94 additions & 0 deletions packages/cli/src/commands/auth/failure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* 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, because `type` has
* to be compared against codes the pinned `@cipherstash/auth` does not declare
* yet — `USAGE_LIMIT_EXCEEDED` and `ORG_NOT_PROVISIONED` ship with the CTS
* usage-limit work, and against the closed union those comparisons are a type
* error rather than a `false`. Widening here keeps the CLI correct on both
* sides of that bump instead of gating it on a dependency release.
*/
type RenderableFailure = {
type?: string
error: { message: string }
help?: string
}

/**
* Hints that supersede the caller's default, keyed by CTS refusal code.
*
* Both of these are terminal for the CLI: nothing the user can type clears
* them, so the hint has to send them somewhere else entirely.
*/
const TERMINAL_HINTS: ReadonlyMap<string, string> = new Map([
[
'USAGE_LIMIT_EXCEEDED',
'Your CipherStash organisation has used its allowance for the current billing period. Upgrade the plan at https://dashboard.cipherstash.com and try again — logging in again will not clear this.',
],
[
'ORG_NOT_PROVISIONED',
'Your CipherStash organisation is not registered with the usage system, so there is no plan to upgrade. Contact support@cipherstash.com — logging in again will not clear this.',
],
])

/**
* The failure's CTS code, widened to a plain string.
*
* The widening is the point — see {@link RenderableFailure}. Comparing
* `failure.type` to `'USAGE_LIMIT_EXCEEDED'` at a call site is a type error
* against the pinned `@cipherstash/auth`, whose union does not name it yet;
* routed through here it is an ordinary string comparison that starts
* returning `true` the day the dependency ships the code.
*/
export function authFailureCode(
failure: RenderableFailure,
): string | undefined {
return failure.type
}

/**
* 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.
*/
export function authFailureMessage(failure: RenderableFailure): string {
const { message } = failure.error
if (!failure.help) return message
return message.endsWith('.')
? `${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.
*
* @param fallback the hint that applies to an ordinary auth failure
*/
export function authFailureHint(
failure: RenderableFailure,
fallback?: string,
): string | undefined {
return (failure.type && TERMINAL_HINTS.get(failure.type)) ?? fallback
}
Loading
Loading