Skip to content

feat: surface CTS usage-limit refusals with the dashboard remedy (CIP-3727) - #894

Draft
tobyhede wants to merge 1 commit into
mainfrom
toby/cip-3727-propagate-cts-usage-limit-refusals
Draft

feat: surface CTS usage-limit refusals with the dashboard remedy (CIP-3727)#894
tobyhede wants to merge 1 commit into
mainfrom
toby/cip-3727-propagate-cts-usage-limit-refusals

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

Part of CIP-3727 — the client-side half. The server-side taxonomy is cipherstash-suite#2120.

The problem

When an organisation is over its usage allowance, CTS refuses to issue or renew the service token behind every operation, answering 402 with:

Insufficient balance. Please upgrade your plan.

That is the entire thing a caller got. It names no dashboard, and nothing in it says that retrying — or rotating credentials — cannot help. A well-behaved retry loop hammers a condition only a human with a billing page can clear.

The prose was all there was because nothing else crossed the boundary. Error::Auth and Error::ZeroKMS in protect-ffi are both #[error(transparent)] with no #[diagnostic(code(..))], so the whole stack-auth taxonomy arrived as an untyped Error — no code, and no miette help, since 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, and it was being dropped for every auth failure, not just this one.

What changed

Package Change
@cipherstash/protect-ffi Error::auth_error() matches the two shapes an auth failure takes; both bindings and each decryptBulkFallible item now set authCode and help. getAuthErrorCode() + ProtectAuthErrorCode read them back
@cipherstash/stack Every failure carries authCode; the remedy is folded into message, including the dashboard URL the CTS response does not carry. Native and wasm-inline, plus Encryption()'s thrown error
stash auth login / env print the remedy and stop advising a re-login for a billing refusal; env reports usage_limit_exceeded rather than session_invalid
skills stash-auth (canonical) gains the taxonomy; stash-encryption and stash-cli point at it

Two shapes, not one

Every ZeroKMS operation resolves its credential first (ZeroKMS::get_token), so an issuance refusal arrives wrapped in zerokms::Error::Auth having never made a ZeroKMS request. Error::Auth is the direct path. Matching the variant rather than the message is the rule the existing code contract already states — an upstream rename is a compile error here, not a silent downgrade to UNKNOWN.

Why authCode is a separate field from code

code is protect-ffi's own closed ProtectErrorCode set, pinned by errorCodes.test.ts against the #[diagnostic(code(..))] attributes in lib.rs — every member has one. The auth set belongs to stack-auth and ships on its own release train. Folding them together would either break that test or force protect-ffi to re-declare a taxonomy it does not decide, so ProtectAuthErrorCode is deliberately an open union: narrow with ===, don't switch exhaustively.

Why the remedy goes in message

throw new Error(failure.message) is how these get surfaced in practice — it is what this repo's own JSDoc examples do. Guidance parked in a sibling field is guidance nobody reads at the moment it is needed. authCode is there so a retry loop can branch; message is there so a human can act.

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 gets 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. Telling it to upgrade sends the user somewhere that cannot help. Every other code falls back to the help that stack-auth wrote — MISSING_WORKSPACE_CRN naming CS_WORKSPACE_CRN, and so on — which previously never reached anyone.

Inert until the stack-auth pin moves

packages/protect-ffi/crates/protect-ffi/Cargo.toml pins stack-auth =0.42.0, which has no UsageLimitExceeded variant — #2120 adds it. Everything here works today for the codes 0.42 does emit (the dropped help text is fixed immediately for all of them) and starts carrying the billing codes the moment that pin moves. No follow-up in this repo is required for that to happen.

The CLI's string comparisons route through a widening helper (authFailureCode) for the same reason: failure.type is a closed union in the pinned @cipherstash/auth, so comparing it to 'USAGE_LIMIT_EXCEEDED' at a call site is a type error rather than a false.

Changesets

@cipherstash/stack + stash as a normal changeset. The protect-ffi one is parked as .changeset/protect-ffi-auth-error-code.md.deferred per AGENTS.md — those packages still publish from cipherstash/protectjs-ffi, and scripts/lint-no-ffi-changeset.mjs fails CI on a live one. Verified green.

Testing

  • Rust: 315 unit tests pass (5 new, covering both wrapper shapes, the help text, and that an auth error claims no ProtectErrorCode). cargo fmt --check clean; clippy --all-targets -D warnings clean on host and wasm32-unknown-unknown.
  • JS: full workspace pnpm test — 13/13 tasks. New: 5 protect-ffi, 16 stack (11 unit + 5 end-to-end through Encryption() and encrypt() with protect-ffi mocked), 11 CLI.
  • CLI e2e: the pty-driven suite, 108 passing.
  • A new errorCodes.test.ts case compares the wasm error-helper exports declared in wasm.rs's typescript_custom_section against the runtime re-export scripts/inline-wasm.mjs appends. Neither knew about the other, and a name in the first but not the second is a declared export that does not exist at runtime — a TypeError in an edge function with a green build. Confirmed it fails on drift, not just passes on agreement.
  • dist/wasm/*.d.ts regenerated via build:wasm. That also surfaced unrelated wasm-bindgen churn (ReadableStreamType visibility, closure symbol renames in protect_ffi_bg.wasm.d.ts) from a newer toolchain than last generated these; I reverted that so the diff is only this change. Worth its own PR.

pnpm run code:check cannot run on this machine — Biome rejects the nested biome.json in each git-excluded .claude/worktrees/* checkout, which predates this branch and does not exist in CI. Ran Biome directly over the changed files instead: no errors, and the only two warnings are pre-existing as never casts in wasm-inline.ts that this branch does not touch.

Not covered

packages/wizard has two more sites (agent/fetch-prompt.ts, lib/prerequisites.ts) that still drop help. Same one-line defect, different published package — left out to keep this reviewable, happy to fold in.

…-3727)

When an organisation is over its usage allowance, CTS 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.

The prose was all there was because nothing else crossed the boundary.
`Error::Auth` and `Error::ZeroKMS` in protect-ffi are both
`#[error(transparent)]` with no `#[diagnostic(code(..))]`, so the whole
stack-auth taxonomy arrived as an untyped `Error` — no code, and no miette
`help`, since 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.

protect-ffi: `Error::auth_error()` matches the two shapes an auth failure
takes — every ZeroKMS operation resolves its credential first, so an issuance
refusal arrives wrapped in `zerokms::Error::Auth` having never made a request.
Both bindings and each `decryptBulkFallible` item now set `authCode` and
`help`; `getAuthErrorCode` reads it back. The code is read off the variant,
never the message, which is the rule the existing `code` contract already
states: a rename upstream is a compile error, not a silent downgrade.

`authCode` is a separate field from `code` rather than more members of it.
`code` is this package's closed `ProtectErrorCode` set, pinned by
errorCodes.test.ts against the `#[diagnostic]` attributes; the auth set
belongs to stack-auth and ships on its own release train, so
`ProtectAuthErrorCode` is deliberately open.

stack: every failure carries `authCode`, and the remedy is folded into
`message` — `throw new Error(failure.message)` is how these get surfaced in
practice, so guidance parked anywhere else is guidance nobody reads at the
moment it is needed. USAGE_LIMIT_EXCEEDED gets this package's own text with
the dashboard URL, which the CTS response does not carry. ORG_NOT_PROVISIONED
gets the opposite advice: no plan exists to upgrade, so it goes to support.
Everything else falls back to stack-auth's `help`. Native and wasm-inline
both, plus `Encryption()`, which throws rather than returning a Result and so
attaches `authCode` to the thrown error.

cli: `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 being withheld on
billing grounds. `stash env` reports `usage_limit_exceeded` rather than
`session_invalid`. The comparisons route through a widening helper so they
compile against the pinned `@cipherstash/auth`, whose union does not name the
code yet.

The billing codes themselves land with stack-auth 0.43
(cipherstash/cipherstash-suite#2120); the pin here is =0.42.0. Everything
above works today for the codes 0.42 emits and starts carrying the billing
ones the moment that pin moves.

The protect-ffi changeset is parked as `.md.deferred` per AGENTS.md — those
packages still publish from cipherstash/protectjs-ffi.
@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3cffacb

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

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack Minor
stash Minor
@cipherstash/bench Patch
@cipherstash/stack-drizzle Minor
@cipherstash/stack-prisma Minor
@cipherstash/stack-supabase Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard 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

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.

1 participant