feat: surface a CipherStash usage-limit refusal with a code and a remedy - #894
feat: surface a CipherStash usage-limit refusal with a code and a remedy#894tobyhede wants to merge 4 commits into
Conversation
🦋 Changeset detectedLatest commit: d61f40f The changes in this PR will be included in the next version bump. This PR includes changesets to release 19 packages
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 |
| 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.', | ||
| ], | ||
| ]) |
There was a problem hiding this comment.
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.
| 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>, | ||
| } |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
b9f504e to
a76bb7e
Compare
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
a76bb7e to
82b3ad1
Compare
|
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: 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 ( 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 |
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:
authCodesays what it is,messagesays what to do, andurlsays where. Two codes mean "stop":USAGE_LIMIT_EXCEEDED(over the allowance, upgrade the plan) andORG_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 boundaryauthCode,helpandurlalongside the existingcode, on both bindings and on each item of a partial bulk decrypt.getAuthErrorCode()reads it back;ProtectAuthErrorCodetypes it.authCodeis a separate field fromcodebecause the two sets have different owners.codeis this package's own closed set, pinned by a test against the Rust source. The auth set belongs to the upstreamstack-authcrate and grows on its own release schedule, soProtectAuthErrorCodeis deliberately open — compare with===, do notswitchexhaustively over it.config.authStrategyhas its owntype,helpandurlrelayed 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.cipherstash-client,cts-common,stack-auth,stack-profile— they release together). That release is where the whole taxonomy comes from.@cipherstash/stackauthCode, with the remedy folded intomessageand the link onurl.stack-auth0.42.3 attaches ahelpand aurlto 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. Aconfig.authStrategysupplying its own help is honoured rather than overridden.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:helpreached a caller only as prose andurlby no path at all.LockContext.identify()is fixed. It calls the token service directly, so a402was 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 loginandenvprint the remedy, and no longer answer a billing refusal with "runstash auth loginand try again" — a fresh login cannot mint a credential being withheld on billing grounds.envreports the terminal refusals under their own--jsoncodes rather thansession_invalidornot_logged_in.codeis 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.--jsonerror envelope gains an optionalhint, previously printed only on the interactive path — absent for exactly the automated consumers--jsonexists for.@cipherstash/nextjsgetCtsToken()reported a failure asFailed to fetch CTS token:and nothing else: it readstatusText, 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 surfacesauthCode.Skills and changesets —
stash-auth(canonical for auth),stash-cliandstash-encryptionupdated; four changesets.One detail worth knowing: how a refusal is read off the wire
Three places read a
402themselves — 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:access_deniedto stay standards-compliant, so the body alone cannot say what it is.402carrying a refusal is JSON, with the code in acs_codefield. Every other failure from that endpoint is plain text, so the body is read as text exactly once and parsed defensively — calling.json()on a401throws a parse error that replaces the real failure.402that 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-ffibinding 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 anorg_idclaim on every token, and real encrypt/decrypt/keyset/audit round-trips pass.cargo fmt --checkclean.@cipherstash/protect-ffi(JS) — 105.@cipherstash/nextjs— 24.stash— the auth and env suites, 107.pnpm run code:checkexits 0.Stated rather than omitted: the full
stashsuite 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 onmain, 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) andpackages/stack/src/identity/index.ts(how a402is 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/authrather than the Rust client: aconfig.authStrategyyou construct yourself, and thestashCLI. 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/wizardhas two sites that still read onlyfailure.error.messageand drophelp. Same one-line defect, different published package; left out to keep this reviewable.stash auth loginreportsUSAGE_LIMIT_EXCEEDEDon--jsonwherestash envreportsusage_limit_exceeded. Real inconsistency, butlogin'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.USAGE_LIMIT_EXCEEDEDends "then retry", which reads as the wrong signal without the upgrade clause in front of it.