Skip to content

feat(api): make API token minting retry-safe - #834

Merged
Zach Dunn (zachdunn) merged 2 commits into
mainfrom
claude/retry-safe-token-minting-894662
Aug 24, 2026
Merged

feat(api): make API token minting retry-safe#834
Zach Dunn (zachdunn) merged 2 commits into
mainfrom
claude/retry-safe-token-minting-894662

Conversation

@zachdunn

Copy link
Copy Markdown
Member

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/tokens retry-safe: send an Idempotency-Key and an
identical retry replays the original response, including the original one-time
token, while minting exactly one token.

What it does / what it is not

  • Opt-in. Without an Idempotency-Key header, behavior is unchanged — the
    original one-shot mint.
  • Identical retry (same key, same effective request) within 24h replays the
    original 201 with Idempotency-Replayed: true and the original plaintext
    token. Only one auth_tokens row is ever created.
  • Same key + a changed request → 409 idempotency_key_reused. A concurrent
    in-flight request → 409 idempotency_request_in_progress with Retry-After: 1.
  • Encrypted at rest. The replay body carries the plaintext token, so it is
    sealed with AES-GCM via the existing workspace secrets key ring
    (apps/api/src/secrets.ts) before it touches D1. Nothing stores the token in
    the clear.
  • Fails closed. If no encryption key is configured, an idempotent mint
    returns 503 secrets_key_unconfigured and writes neither a token nor a replay
    record. The existing secrets model is unchanged; no secrets added to source.
  • Authorization is re-checked on every attempt. The idempotency step runs
    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.
  • Keys are scoped by (workspace, session user, token.create.v1), so users and
    workspaces never share replay records.
  • No migration required. Reuses the existing idempotency_requests table
    (operation = token.create.v1) and its daily retention sweep. Shared
    primitives were pulled into idempotency-core.ts.
  • Deferred: upload idempotency (needs coordinated D1/R2 recovery).

How to try it

KEY=$(uuidgen)
# First call mints; an identical retry with the same key replays the same token.
uploads api POST /v1/tokens --header "Idempotency-Key: $KEY" \
  --data '{"grants":[{"workspace":"acme","scopes":["files:read","files:write"]}]}'

The JS client's mintWorkspaceToken now accepts an optional idempotencyKey.

Technical notes

  • Claim key → insert token (gated on owning the claim) → store encrypted 201 →
    release-on-failure, all in one db.batch on a primary-constrained session
    (primaryDbFor). D1 writes stay unbounded; only the standalone replay lookup
    is boundedRead, so the write batch is never deadline-raced.
  • The fingerprint normalizes {scopes (sorted), label, ttlSeconds}; the computed
    absolute expiry is excluded so retries within the window still match.
  • All D1 access goes through dbFor/primaryDbFor; env.DB is never touched
    directly. createToken was split into buildTokenRecord + prepareTokenInsert
    (behavior unchanged) so the row can be inserted inside the idempotency batch.
  • Migration deliberately not run (file-only policy) — none was needed here.

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.ts idempotency block (5) — identical
    retry replays + one row, changed-body 409, 503 fail-closed, de-membered user
    cannot replay, one-shot preserved without a key
  • packages/uploads client suite (1313) incl. new mint-token.test.ts header
  • pnpm --filter @uploads/api typecheck and @buildinternet/uploads typecheck
  • oxlint + oxfmt on changed files; git diff --check clean
  • Full monorepo typecheck not run here (Node 24 engine unmet locally for
    apps/web/@astrojs/check); API + client checks above are green.

Refs #829

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-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ed0b0a3

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

This PR includes changesets to release 1 package
Name Type
@buildinternet/uploads Patch

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

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (2)
  • coderabbit:review
  • review
🚫 Excluded labels (none allowed) (1)
  • wip

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2cea5c87-f2d3-491d-b67a-0cf4ea31201a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@zachdunn
Zach Dunn (zachdunn) marked this pull request as ready for review August 24, 2026 18:11
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.
@zachdunn
Zach Dunn (zachdunn) merged commit d6e49f7 into main Aug 24, 2026
4 checks passed
@zachdunn
Zach Dunn (zachdunn) deleted the claude/retry-safe-token-minting-894662 branch August 24, 2026 18:19
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