feat(migration): idempotent, recoverable migration grant creation
Stacked on #111 (feat/migration-grant-pair-binding @ 2adf414a). This is for the RelayHistory to 96Bulls migration.
Problem
POST /v1/migration-grants generates a random grantId, and planDigest covers that id. The grant commits before the response is sent, and nothing lets a caller look it up again. If the response is lost, the grant is orphaned: it is live, but the caller can't find it.
Change
- Required
Idempotency-Keyheader on grant create: 32 to 128 characters ofA-Za-z0-9_-, with at least 8 distinct characters. A 24-byte base64url value or a UUIDv4 is accepted. A missing key is a 400idempotency_key_requiredand a malformed key is a 400invalid_idempotency_key. Both refusals are audited and never echo the key. - The raw key is never stored. The receipt holds a domain-separated SHA-256 over (org, caller subject, key), with
PRIMARY KEY (org_id, key_hash). That means "unique per org". It also means a key used by another org or another caller hashes to an unrelated value and is simply absent, so there is no existence signal. - Fingerprint binding. The receipt stores the creating caller and a SHA-256 of the canonical pre-grant request:
apiKeyId,audience,notAfter, and the canonical plan and pairs. It covers everything except the generatedgrantId, which is stripped from the plan and its entries. - Atomic commit. The receipt, the grant and the one
migration_grant.createdaudit row commit in a singleBEGIN IMMEDIATEtransaction. The receipt is checked again inside that transaction, so racing creates produce one grant and the rest are replays. - Replay (same key, same caller, same fingerprint) returns the original grant: same
grantId,planDigest,notAfter,createdAtandrevokedAt. The status is 201 withIdempotent-Replayed: true. Nothing is written and there is no second create audit. The existing pattern has no replay audit, so none was added. - Conflict (same key, different fingerprint) returns 409
idempotency_key_conflictand writes a denied audit row that carries the key hash. - Recovery works while
RELAYAUTH_ENABLE_MIGRATION_ASSERTIONSis off, and after the grant has expired or been revoked. The stored record is returned unchanged; it is never extended or recreated. ThenotAfterwindow check and the private-http audience policy apply only to a fresh create. While the switch is off, everything other than a recovery or a same-caller conflict still gets the existing 403migration_assertions_disabled, and minting stays disabled. - Migration
0014_migration_grant_idempotency.sqladds a new tablemigration_grant_idempotency_receiptsand does not modify 0012 or 0013. - Storage interface (breaking for external adapters, on purpose).
createWithAuditnow requiresidempotencyand returnscreated,replayedorconflict. The new methodfindByIdempotencyKeyreturns a result only for an exact org, key hash and caller match. An adapter that hasn't implemented the receipt fails to compile instead of silently skipping idempotency. The SQLite and memory backends are both implemented. - SDK and types: no change. The request body shape is unchanged (the key is a header), and the SDK has no grant-create client.
docs/self-hosting.mddocuments the header and its retry semantics.
What #111 passed that this keeps
Authentication and relayauth:api-key:manage:* authorization still come before any lookup. Dedicated-key, source-user, plan-org, size and TTL gates are unchanged on the fresh-create path. The disabled switch still blocks creation and minting. Every refusal is still audited.
Tests (26 new, in packages/server/src/__tests__/migration-assertion.test.ts)
idempotency: a lost create response is recovered by retrying the same key(1)idempotency: concurrent same-key creates commit exactly one grant(3: http with 8 parallel requests, storage race on sqlite, storage race on memory, including a storage-level conflict)idempotency: same key with a different request is an audited 409(6: notAfter, audience, apiKeyId, plan entries, plan exported_at)idempotency: a key from another org or another caller is treated as absent(1)idempotency: a failure mid-transaction leaves no receipt, grant or audit(3: receipt insert, audit insert)idempotency: recovery works while disabled, expired or revoked without changing the grant(4: disabled, expired, revoked)idempotency: the key is required, high entropy, and refusals are audited(6: missing, too short, too long, low entropy, bad characters)idempotency: the raw key never appears in the database, audit, responses or logs(1: scans every table, the audit query, every response body and header, and every captured console line)
Validation (local; hosted CI only triggers for base main)
- Focused
migration-assertion.test.ts: 65/65 pass (39 existing plus 26 new) npm test(turbo, 16/16 tasks; every test task was a cache miss): server 753/753, sdk 170/170, ai 70/70, core 19/19, migrate 18/18, relayauth 12/12, types 1/1, roottests/*.test.ts8/8- Server
test:e2e: 40 pass, 0 fail, 93 skipped (gated on a live environment) turbo typecheck --force: 11/11;turbo build --force: 9/9
Nothing was merged, published, deployed or run against anything live, and no credentials were used.
Independent SOC2 re-review pending.
🤖 Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Agent Relay sessions
claudesession719b5435· opened viagh pr create· last active 2026-10-30