feat(migration): idempotent, recoverable migration grant creation

Draft
AG
13d ago
+1376-81

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-Key header on grant create: 32 to 128 characters of A-Za-z0-9_-, with at least 8 distinct characters. A 24-byte base64url value or a UUIDv4 is accepted. A missing key is a 400 idempotency_key_required and a malformed key is a 400 invalid_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 generated grantId, which is stripped from the plan and its entries.
  • Atomic commit. The receipt, the grant and the one migration_grant.created audit row commit in a single BEGIN IMMEDIATE transaction. 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, createdAt and revokedAt. The status is 201 with Idempotent-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_conflict and writes a denied audit row that carries the key hash.
  • Recovery works while RELAYAUTH_ENABLE_MIGRATION_ASSERTIONS is off, and after the grant has expired or been revoked. The stored record is returned unchanged; it is never extended or recreated. The notAfter window 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 403 migration_assertions_disabled, and minting stays disabled.
  • Migration 0014_migration_grant_idempotency.sql adds a new table migration_grant_idempotency_receipts and does not modify 0012 or 0013.
  • Storage interface (breaking for external adapters, on purpose). createWithAudit now requires idempotency and returns created, replayed or conflict. The new method findByIdempotencyKey returns 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.md documents 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, root tests/*.test.ts 8/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 Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Agent Relay sessions

  • claude session 719b5435 · opened via gh pr create · last active 2026-10-30