feat(api): make API token minting retry-safe - #834
Conversation
Add optional Idempotency-Key support to POST /v1/tokens. An identical retry replays the original 201 — including the one-time plaintext token — and mints exactly one token; a changed request returns 409 idempotency_key_reused. The replay body carries the plaintext token, so it is sealed with AES-GCM via the workspace secrets key ring before it touches D1, and minting fails closed (503) when no encryption key is configured. Authorization is re-checked on every attempt, so a de-membered caller cannot recover a token by replay. Reuses the idempotency_requests table and its retention sweep (operation token.create.v1); no migration required. Extracts shared idempotency primitives into idempotency-core.ts. Refs #829
🦋 Changeset detectedLatest commit: ed0b0a3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (2)
🚫 Excluded labels (none allowed) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Build the token row and its one-time response once; the plain and idempotent paths now differ only in persistence (unconditional insert vs claim+batch). Removes the duplicated response object and the createToken indirection in the route.
In plain terms
Minting an API token is a one-shot: the server stores only a hash, so the
plaintext token is shown exactly once. If a mint request times out or the
connection drops on the way back, a naive client retry either creates a second
token or — worse — the caller never sees the token at all and has to start over.
This makes
POST /v1/tokensretry-safe: send anIdempotency-Keyand anidentical retry replays the original response, including the original one-time
token, while minting exactly one token.
What it does / what it is not
Idempotency-Keyheader, behavior is unchanged — theoriginal one-shot mint.
original
201withIdempotency-Replayed: trueand the original plaintexttoken. Only one
auth_tokensrow is ever created.409 idempotency_key_reused. A concurrentin-flight request →
409 idempotency_request_in_progresswithRetry-After: 1.sealed with AES-GCM via the existing workspace secrets key ring
(
apps/api/src/secrets.ts) before it touches D1. Nothing stores the token inthe clear.
returns
503 secrets_key_unconfiguredand writes neither a token nor a replayrecord. The existing secrets model is unchanged; no secrets added to source.
after the full session + membership + role + scope + rate-limit gate, so a
caller who lost workspace access cannot recover a token by replaying a key.
(workspace, session user, token.create.v1), so users andworkspaces never share replay records.
idempotency_requeststable(
operation = token.create.v1) and its daily retention sweep. Sharedprimitives were pulled into
idempotency-core.ts.How to try it
The JS client's
mintWorkspaceTokennow accepts an optionalidempotencyKey.Technical notes
release-on-failure, all in one
db.batchon a primary-constrained session(
primaryDbFor). D1 writes stay unbounded; only the standalone replay lookupis
boundedRead, so the write batch is never deadline-raced.{scopes (sorted), label, ttlSeconds}; the computedabsolute expiry is excluded so retries within the window still match.
dbFor/primaryDbFor;env.DBis never toucheddirectly.
createTokenwas split intobuildTokenRecord+prepareTokenInsert(behavior unchanged) so the row can be inserted inside the idempotency batch.
Test plan
pnpm test:api— full API suite (2255 passed)apps/api/test/token-idempotency.test.ts(9) — replay, no-plaintext-at-rest,reused conflict, principal/workspace isolation, fail-closed (no key), invalid
key, expired-key reuse, key rotation (previous key decrypts), bounded stalled
replay without racing the write batch
apps/api/src/routes/tokens.test.tsidempotency block (5) — identicalretry replays + one row, changed-body 409, 503 fail-closed, de-membered user
cannot replay, one-shot preserved without a key
packages/uploadsclient suite (1313) incl. newmint-token.test.tsheaderpnpm --filter @uploads/api typecheckand@buildinternet/uploads typecheckgit diff --checkcleanapps/web/@astrojs/check); API + client checks above are green.Refs #829