Skip to content

feat: surface a CipherStash usage-limit refusal with a code and a remedy - #894

Open
tobyhede wants to merge 4 commits into
mainfrom
toby/cip-3727-propagate-cts-usage-limit-refusals
Open

feat: surface a CipherStash usage-limit refusal with a code and a remedy#894
tobyhede wants to merge 4 commits into
mainfrom
toby/cip-3727-propagate-cts-usage-limit-refusals

Conversation

@tobyhede

@tobyhede tobyhede commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

CipherStash meters usage per organisation. When one goes over its allowance, the CipherStash Token Service — the component that issues the short-lived token behind every encrypt and decrypt — stops issuing tokens and answers 402 Payment Required.

Until now that reached a caller as an untyped error carrying one sentence of prose. Nothing in it said which organisation was affected, where to fix it, or that the condition was permanent, so a client retrying on failure — the sensible default — would retry forever against something only a human with a billing page can clear.

A refusal now arrives as a typed, branchable thing: authCode says what it is, message says what to do, and url says where. Two codes mean "stop": USAGE_LIMIT_EXCEEDED (over the allowance, upgrade the plan) and ORG_NOT_PROVISIONED (not registered for billing at all, which needs support, not a purchase). Telling the second to upgrade sends the user somewhere that cannot help them, which is why they are separate codes rather than one.

Changes

@cipherstash/protect-ffi — the Rust/JavaScript boundary

  • A thrown error now sets authCode, help and url alongside the existing code, on both bindings and on each item of a partial bulk decrypt. getAuthErrorCode() reads it back; ProtectAuthErrorCode types it.
  • authCode is a separate field from code because the two sets have different owners. code is this package's own closed set, pinned by a test against the Rust source. The auth set belongs to the upstream stack-auth crate and grows on its own release schedule, so ProtectAuthErrorCode is deliberately open — compare with ===, do not switch exhaustively over it.
  • A failure raised by a caller-supplied config.authStrategy has its own type, help and url relayed verbatim rather than rebuilt from the pinned crate's enum, which maps only ten of twenty-one codes and would replace a strategy's own remedy with someone else's.
  • Client crates move to 0.42.3 (cipherstash-client, cts-common, stack-auth, stack-profile — they release together). That release is where the whole taxonomy comes from.

@cipherstash/stack

  • Every failure that came from the token service carries authCode, with the remedy folded into message and the link on url.
  • The remedy text is upstream's. stack-auth 0.42.3 attaches a help and a url to both terminal refusals, so this package prefers those and keeps its own copy only as a fallback for the one path that cannot receive them. A config.authStrategy supplying its own help is honoured rather than overridden.
  • Diagnostics (code, authCode, help, url) are attached by one shared helper rather than enumerated at each of twenty failure mappers. Enumeration is how two of the four went missing before: help reached a caller only as prose and url by no path at all.
  • LockContext.identify() is fixed. It calls the token service directly, so a 402 was not an exception — it fell through to a body with no token and reported "the response did not contain an access token, please contact support", which is the misleading message this PR exists to remove.

stash (CLI)

  • auth login and env print the remedy, and no longer answer a billing refusal with "run stash auth login and try again" — a fresh login cannot mint a credential being withheld on billing grounds.
  • env reports the terminal refusals under their own --json codes rather than session_invalid or not_logged_in. code is the only machine-readable field on that stream, so reporting a stale session sends an automated caller round a re-login loop it cannot win.
  • The --json error envelope gains an optional hint, previously printed only on the interactive path — absent for exactly the automated consumers --json exists for.

@cipherstash/nextjs

  • getCtsToken() reported a failure as Failed to fetch CTS token: and nothing else: it read statusText, which is empty over HTTP/2, and discarded the body along with any code in it. It now names the status, quotes what the service said, and surfaces authCode.

Skills and changesetsstash-auth (canonical for auth), stash-cli and stash-encryption updated; four changesets.

One detail worth knowing: how a refusal is read off the wire

Three places read a 402 themselves — the Rust client, LockContext.identify(), and the Next.js helper. They now follow the same rules, because three readers disagreeing about one response is a bug waiting to happen:

  • The status decides, not the body. The OAuth token endpoints must report a usage refusal as access_denied to stay standards-compliant, so the body alone cannot say what it is.
  • A 402 carrying a refusal is JSON, with the code in a cs_code field. Every other failure from that endpoint is plain text, so the body is read as text exactly once and parsed defensively — calling .json() on a 401 throws a parse error that replaces the real failure.
  • A 402 that is not valid CipherStash JSON is declined, not classified. That is a proxy, WAF or gateway answering in front of the service, and reporting it as a billing problem sends the user to a billing page for something a retry would have cleared.

Verification

CI on this branch: 25 checks pass, 0 fail (10 skip — they are gated on packages/eql/, which this branch does not touch).

Locally, against a protect-ffi binding compiled fresh at 0.42.3:

  • @cipherstash/stack — 1220/1220, including the 159 live tests that talk to real CipherStash infrastructure with credentials. That live run is what clears the other half of 0.42.3: it also makes ZeroKMS require an org_id claim on every token, and real encrypt/decrypt/keyset/audit round-trips pass.
  • Rust — 332 tests, cargo fmt --check clean.
  • @cipherstash/protect-ffi (JS) — 105.
  • @cipherstash/nextjs — 24.
  • stash — the auth and env suites, 107.
  • Repo guards — the remedy-copy sync check and the parked-changeset check, 10.
  • pnpm run code:check exits 0.

Stated rather than omitted: the full stash suite fails 21 tests locally, all of them resolving @cipherstash/eql/sql. That is a build output this working copy does not have; the identical failure reproduces on main, and CI builds it, which is why CI is green. It is unrelated to this change.

Related

Refs cipherstash/cipherstash-suite#2120 — the server-side half, which defines the taxonomy and the response bodies this PR reads.

Review notes

Start at packages/stack/src/encryption/helpers/auth-failure.ts (what the remedy is and where it comes from) and packages/stack/src/identity/index.ts (how a 402 is classified). The three commits are split by layer — boundary, SDK, consumers — and each stands on its own; reviewing in order should read as one argument.

Not live yet, and no follow-up needed here. Two of the four surfaces reach the token service through the npm package @cipherstash/auth rather than the Rust client: a config.authStrategy you construct yourself, and the stash CLI. Its latest release predates the taxonomy, so those paths still see a generic server error until it ships. The code is already correct for that release and starts reporting the codes the moment it lands. Everything going through the Rust client is live today.

Deliberately deferred:

  • packages/wizard has two sites that still read only failure.error.message and drop help. Same one-line defect, different published package; left out to keep this reviewable.
  • stash auth login reports USAGE_LIMIT_EXCEEDED on --json where stash env reports usage_limit_exceeded. Real inconsistency, but login's code for an auth failure has always been the raw type, which predates this work and covers every code rather than these two. Collapsing the spellings changes an existing machine-readable contract and belongs in its own change.
  • Two things worth asking upstream for: the crate knows which failures are retryable but does not expose it across the boundary, so the CLI still hardcodes which codes are terminal; and the upstream help for USAGE_LIMIT_EXCEEDED ends "then retry", which reads as the wrong signal without the upgrade clause in front of it.

@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d61f40f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@cipherstash/nextjs Minor
@cipherstash/protect-ffi Minor
@cipherstash/stack Minor
stash Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/bench Patch
@cipherstash/stack-prisma Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Minor
@cipherstash/protect-ffi-darwin-x64 Minor
@cipherstash/protect-ffi-darwin-arm64 Minor
@cipherstash/protect-ffi-win32-x64-msvc Minor
@cipherstash/protect-ffi-linux-x64-gnu Minor
@cipherstash/protect-ffi-linux-arm64-gnu Minor
@cipherstash/protect-ffi-linux-x64-musl Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@tobyhede
tobyhede marked this pull request as ready for review August 17, 2026 01:35
@tobyhede
tobyhede requested a review from a team as a code owner August 17, 2026 01:35
Comment on lines +1 to +35
import type { ProtectAuthErrorCode } from '@cipherstash/protect-ffi'

/**
* The remedies this package owns, keyed by the `stack-auth` code that CTS's
* refusal arrives with.
*
* These exist because the two conditions below are the ones a caller can
* neither retry away nor fix with credentials, and the message alone does not
* say so. CTS answers a usage-limit refusal with a 402 whose body reads
* "Insufficient balance. Please upgrade your plan." — accurate, but it names no
* dashboard, and by the time it has crossed CTS -> stack-auth -> ZeroKMS ->
* protect-ffi it is one sentence with no context on where "upgrade" happens. A
* well-behaved client that treats every token failure as transient will retry
* against a condition only a human with a billing page can clear.
*
* `stack-auth` attaches its own `help` for these (see `UsageLimitExceeded` in
* that crate), which {@link authRemedy} falls back to. What it cannot carry is
* a URL, which is exactly the part a developer reading a stack trace needs, so
* the text here supersedes it rather than appending to it — two sentences
* saying "upgrade your plan" in slightly different words is worse than one.
*
* A `Map`, not a `Record`: {@link ProtectAuthErrorCode} is an open union (the
* set belongs to `stack-auth` and moves on its own release train), so a
* `Record` over it degenerates to "every string is required".
*/
const AUTH_REMEDIES: 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 retry — this is a billing condition, so retrying without upgrading cannot succeed, and rotating credentials will not help.',
],
[
'ORG_NOT_PROVISIONED',
'Your CipherStash organisation is not registered with the usage system, so there is no plan to upgrade. Contact [email protected] — retrying cannot succeed.',
],
])

@coderdan coderdan Aug 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I think this logic belongs in stack_auth. Otherwise, you're going to have to repeat it for any other consumer, including proxy.

Comment on lines +465 to +476
pub(crate) struct Diagnostic {
/// The `Display` text. Becomes `err.message`.
pub(crate) message: String,
/// This crate's own `ProtectErrorCode`, from the variant's
/// `#[diagnostic(code(..))]`. Absent becomes `UNKNOWN` on the JS side.
pub(crate) code: Option<String>,
/// The auth taxonomy code, when the failure came from stack-auth. Becomes
/// `err.authCode`. See [`Error::auth_error`].
pub(crate) auth_code: Option<String>,
/// The auth error's `miette` help text. Becomes `err.help`.
pub(crate) help: Option<String>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the benefit of defining our own diagnostic versus just using the one built into miette? I suspect it's because of the auth_code field, which, probably isn't necessary.

@coderdan coderdan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I think this PR is the wrong approach. It's doing too much heavy lifting. The error type in stack_auth already implements miette::Diagnostic, So I think we can just add #[diagnostic(transparent)] to the AuthError enum variant on the protect_ffi Error. The stack/protect-ffi layer should be really thin.

StackAuth should include a code, message and instructions on the diagnostic for when CTS returns a 402. Then any consumer of stack_auth, including Proxy would get the same response.

@tobyhede
tobyhede force-pushed the toby/cip-3727-propagate-cts-usage-limit-refusals branch 2 times, most recently from b9f504e to a76bb7e Compare August 26, 2026 03:52
A failure raised by CipherStash's token service — a refusal to issue or renew
the service token every ZeroKMS request carries — reached JavaScript as an
untyped `Error` with nothing but prose. `Error::Auth` and `Error::ZeroKMS` are
both `#[error(transparent)]` with no `#[diagnostic(code(..))]`, and `miette`
help is not part of an error's `Display`, so both the classification and the
remedy were dropped at the boundary.

A thrown error now sets `authCode`, `help` and `url` alongside the existing
`code`, on both bindings and on each item of `decryptBulkFallible`.
`getAuthErrorCode(err)` reads it back and `ProtectAuthErrorCode` types it.

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

`url` is the other half of the same `miette` surface as `help`, and both seams
were reading the key and discarding it. Not hypothetical: `@cipherstash/auth`
declares both on every member of its `AuthFailure` union, so a JavaScript
`config.authStrategy` could set a `url` and watch it vanish.
`TokenResultEnvelope`'s failure arm is widened to match — it declared only
`{ type, error }`, so a TypeScript-authored strategy could not even spell the
two fields both seams were already reading.

Where the failure came from a `config.authStrategy`, its own `type`, `help` and
`url` are relayed VERBATIM rather than reconstructed. `AuthError::from_error_code`
maps ten of the twenty-one codes and answers the rest with `Custom`, and even
for a code it does map it rebuilds stack-auth's own help — so reconstruction
alone would replace a strategy's remedy with someone else's, and would flatten
a code newer than the pinned crate to `CUSTOM`. A pinned crate can always lag
the taxonomy; the relay is what makes that survivable.

The client crates move to 0.42.3 in the same change, because that is the release
the whole taxonomy comes from: `stack-auth` gained typed `UsageLimitExceeded` /
`OrgNotProvisioned` errors carrying a `help` and a `url` each, one shared
classifier for a `402` from any issuance path, and a 60-second sticky cache so a
refused organisation stops re-issuing the same doomed request at its own request
rate. `cipherstash-client`, `cts-common` and `stack-profile` release in lockstep
and move with it. It also carries a ZeroKMS change requiring `org_id` on every
token; the client decodes claims without requiring it, so that is transparent
here.

Claude-Session: https://claude.ai/code/session_01PS9J6pu3FmmQvJHxwTVJUc
…an act on

When an organisation is over its usage allowance, CipherStash's token service
refuses to issue or renew the service token behind every operation. That reached
a caller as bare prose: it named no dashboard, and nothing in it said 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.

Every failure that came from the token service now carries `authCode`, with the
remedy folded into `message` and the link on `url`:

```typescript
const result = await client.encrypt(value, { column, table })
if (result.failure?.authCode === 'USAGE_LIMIT_EXCEEDED') {
  // Stop retrying. `message` says what to do, `url` says where.
}
```

`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. Treat the set as OPEN — it belongs to
`@cipherstash/auth` and grows on its own release train, so compare with `===`.

The remedy text is `stack-auth`'s own. Both terminal refusals carry a
`#[diagnostic(help(..), url(..))]`, so the authoritative copy is the one already
on the failure, and this package keeps its own only as a fallback for the one
seam that cannot receive it — see `identify()` below. That also means a
`config.authStrategy` supplying its own help is honoured rather than overridden.

Alongside the message, a failure carries its diagnostics as fields — `code`,
`authCode`, `help` and `url` — via one shared `failureDiagnostics`, replacing
what would otherwise be twenty enumerated mapper sites. Enumeration is how two
of the four went missing before: `help` reached a caller only as prose and `url`
by no path at all. Only keys the failure actually has are set, never a key
holding `undefined`. `code` is validated against protect-ffi's closed set on the
`wasm-inline` entry as on the native one, and on the thrown-init path as well as
the Result path, so a stray `ECONNRESET` from a fetch inside a JS auth strategy
cannot arrive wearing that type.

`LockContext.identify()` is fixed too, and is the one place in the SDK that
talks to CTS itself — so it is the only place that has to know the wire shape. A
`402` carrying a usage refusal is JSON (`{"error":"usage_limit_exceeded",
"error_description":"…","cs_code":"USAGE_LIMIT_EXCEEDED"}`) while every other
failure from that endpoint is plain text, so the body is read with `.text()`
exactly once and parsed defensively — `.json()` on a `401` throws a SyntaxError
that displaces the real failure. Classification mirrors `classify_issuance_failure`
in `stack-auth` so the two cannot disagree about the same response: the status
decides, an empty body reads as the usage limit, and a non-JSON or non-object
body DECLINES rather than reporting a gateway or WAF failure as a billing one.
Before this, a `402` fell through to a body with no token and was reported as
"the response did not contain an access token, please contact support" — the
misleading message this change exists to remove.

Public types gain optional fields, all additive: `ClientInitError` gains `code`,
`help` and `url`; `CtsTokenError` gains `authCode`; the base `EncryptionError`
gains `help` and `url`; and per-row decrypt failures carry their diagnostics.

Claude-Session: https://claude.ai/code/session_01PS9J6pu3FmmQvJHxwTVJUc
Both of these reach the token service without going through
`@cipherstash/stack`, so both had to learn the refusal for themselves.

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 the two terminal refusals under their own codes,
`usage_limit_exceeded` and `org_not_provisioned`, rather than `session_invalid`
or `not_logged_in`. `code` is the only machine-readable field on the `--json`
stream, so reporting a stale session sends an agent round a re-login loop it
cannot win. Both failure arms do this — the one that loads the device session as
well as the one that renews it — because both render a terminal hint, and a hint
saying "logging in again will not clear this" beside a code meaning "log in
again" is a contradiction the reader has to resolve.

The `--json` error envelope gains an optional `hint` carrying that remedy. It
was previously printed only on the interactive path, which left the dashboard
URL absent for exactly the automated consumers `--json` exists for.

The hint text is a near-twin of the SDK's rather than an import of it:
`@cipherstash/stack` is only a PEER dependency here, and neither the remedy map
nor its joiner is on any of that package's exports, so there is nothing public
to import even where it does resolve. The WORDING is meant to differ — one
stands alone on a terminal line, the other is folded mid-sentence into
`failure.message`. What must not differ is where each code sends the user and
which codes are terminal, because a user can observe the contradiction by
running the CLI and the SDK against the same organisation.
`scripts/__tests__/auth-remedy-copies-in-sync.test.mjs` pins that and ignores
the prose.

@cipherstash/nextjs
-------------------
`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, quotes what the service returned, and surfaces
the code on a new optional `authCode` field of `GetCtsTokenResponse`. Reading
and classification mirror `LockContext.identify()` and `classify_issuance_failure`
exactly, including declining a `402` that did not come from CipherStash. 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.

Claude-Session: https://claude.ai/code/session_01PS9J6pu3FmmQvJHxwTVJUc
@tobyhede
tobyhede force-pushed the toby/cip-3727-propagate-cts-usage-limit-refusals branch from a76bb7e to 82b3ad1 Compare August 26, 2026 04:00
@tobyhede tobyhede changed the title feat: surface CTS usage-limit refusals with the dashboard remedy (CIP-3727) feat: surface a CipherStash usage-limit refusal with a code and a remedy Aug 26, 2026
@tobyhede
tobyhede requested a review from coderdan August 26, 2026 04:04
@tobyhede

Copy link
Copy Markdown
Contributor Author

Force-pushed a history rewrite, and it changed more than commit hashes — flagging it since the diff itself did not move.

Four commits became three, split by layer rather than by chronology:

feat(protect-ffi): carry the auth taxonomy across the JS boundary
feat(stack): surface a CipherStash billing refusal as something you can act on
feat(cli,nextjs): report a CipherStash billing refusal as one

What went away was churn, not content: a commit that guessed at the wire format and a later one that corrected it, a remedy map written to override upstream and a later flip to prefer upstream, two rounds of review fixes, and a formatting commit forced by a Biome bump on main.

The rewrite is content-neutral — the tree hash is byte-identical before and after (44b4f9a2c851404ad25632e70dd736c5f99baca1), so everything CI had already verified still holds. Each commit also stands on its own: the first passes 332 Rust and 105 JS tests alone, the second 1220 stack tests alone.

The title and body are rewritten too. The old ones described the pre-0.42.3 design — they claimed the dashboard link was folded into message (it travels on url now), quoted remedy text this branch no longer authors, and said the work was inert pending a pin move that has since happened. Both also referenced an internal issue tracker ID, which should not be on a public repo.

@tobyhede
tobyhede requested a review from freshtonic August 26, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants