feat: surface CTS usage-limit refusals with the dashboard remedy (CIP-3727) - #894
Draft
tobyhede wants to merge 1 commit into
Draft
feat: surface CTS usage-limit refusals with the dashboard remedy (CIP-3727)#894tobyhede wants to merge 1 commit into
tobyhede wants to merge 1 commit into
Conversation
…-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 detectedLatest commit: 3cffacb The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
402with: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::AuthandError::ZeroKMSin protect-ffi are both#[error(transparent)]with no#[diagnostic(code(..))], so the whole stack-auth taxonomy arrived as an untypedError— no code, and nomiettehelp, since help is not part of an error'sDisplay. 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
@cipherstash/protect-ffiError::auth_error()matches the two shapes an auth failure takes; both bindings and eachdecryptBulkFallibleitem now setauthCodeandhelp.getAuthErrorCode()+ProtectAuthErrorCoderead them back@cipherstash/stackauthCode; the remedy is folded intomessage, including the dashboard URL the CTS response does not carry. Native andwasm-inline, plusEncryption()'s thrown errorstashauth login/envprint the remedy and stop advising a re-login for a billing refusal;envreportsusage_limit_exceededrather thansession_invalidstash-auth(canonical) gains the taxonomy;stash-encryptionandstash-clipoint at itTwo shapes, not one
Every ZeroKMS operation resolves its credential first (
ZeroKMS::get_token), so an issuance refusal arrives wrapped inzerokms::Error::Authhaving never made a ZeroKMS request.Error::Authis the direct path. Matching the variant rather than the message is the rule the existingcodecontract already states — an upstream rename is a compile error here, not a silent downgrade toUNKNOWN.Why
authCodeis a separate field fromcodecodeis protect-ffi's own closedProtectErrorCodeset, pinned byerrorCodes.test.tsagainst the#[diagnostic(code(..))]attributes inlib.rs— every member has one. The auth set belongs tostack-authand 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, soProtectAuthErrorCodeis deliberately an open union: narrow with===, don'tswitchexhaustively.Why the remedy goes in
messagethrow 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.authCodeis there so a retry loop can branch;messageis there so a human can act.ORG_NOT_PROVISIONEDis 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 thehelpthatstack-authwrote —MISSING_WORKSPACE_CRNnamingCS_WORKSPACE_CRN, and so on — which previously never reached anyone.Inert until the stack-auth pin moves
packages/protect-ffi/crates/protect-ffi/Cargo.tomlpinsstack-auth =0.42.0, which has noUsageLimitExceededvariant — #2120 adds it. Everything here works today for the codes 0.42 does emit (the droppedhelptext 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.typeis 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 afalse.Changesets
@cipherstash/stack+stashas a normal changeset. The protect-ffi one is parked as.changeset/protect-ffi-auth-error-code.md.deferredperAGENTS.md— those packages still publish fromcipherstash/protectjs-ffi, andscripts/lint-no-ffi-changeset.mjsfails CI on a live one. Verified green.Testing
ProtectErrorCode).cargo fmt --checkclean;clippy --all-targets -D warningsclean on host andwasm32-unknown-unknown.pnpm test— 13/13 tasks. New: 5 protect-ffi, 16 stack (11 unit + 5 end-to-end throughEncryption()andencrypt()with protect-ffi mocked), 11 CLI.errorCodes.test.tscase compares the wasm error-helper exports declared inwasm.rs'stypescript_custom_sectionagainst the runtime re-exportscripts/inline-wasm.mjsappends. 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 — aTypeErrorin an edge function with a green build. Confirmed it fails on drift, not just passes on agreement.dist/wasm/*.d.tsregenerated viabuild:wasm. That also surfaced unrelated wasm-bindgen churn (ReadableStreamTypevisibility, closure symbol renames inprotect_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:checkcannot run on this machine — Biome rejects the nestedbiome.jsonin 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-existingas nevercasts inwasm-inline.tsthat this branch does not touch.Not covered
packages/wizardhas two more sites (agent/fetch-prompt.ts,lib/prerequisites.ts) that still drophelp. Same one-line defect, different published package — left out to keep this reviewable, happy to fold in.