feat(server): migration grants and a grant-bound migration assertion
Purpose
A one-time export job will upload historical sessions from Neon to the 96Bulls ingest API under each original user's identity. It must not reuse POST /v1/tokens/relayhistory-assertion, whose key can mint for any org (see the separate finding below). This PR adds a narrow, grant-bound, migration-only assertion.
Contract
Scopes
relayauth:assertion:create:migration: carried, alone, by the dedicated minting key.relayauth:api-key:manage:*: required to create or revoke a grant (same as API-key management).
POST /v1/migration-grants (bearer or x-api-key)
{ "apiKeyId": "...", "planDigest": "<64 lowercase hex>", "audience": "https://ingest.example.com",
"allowedUserIds": ["user_a"], "notAfter": "<ISO 8601, future, <= 7 days>" }
ā 201 { grantId, orgId, apiKeyId, planDigest, audience, allowedUserIds, notAfter, createdBy, createdAt, revokedAt: null }
Refusals: insufficient_scope 403; api_key_not_found 404 (unknown or other org); api_key_revoked 409; migration_scope_missing 400 (includes wildcard-only keys); migration_key_not_dedicated 400 (extra scopes, or a workspace token); invalid_plan_digest, invalid_audience, invalid_allowed_users (1 to 1000), invalid_not_after 400; source_user_not_found / source_user_org_mismatch / source_user_not_human / source_user_inactive 403 with userId.
POST /v1/migration-grants/:grantId/revoke: same scope, same org. ā 200 grant with revokedAt; 404 migration_grant_not_found; 409 migration_grant_revoked.
POST /v1/tokens/migration-assertion (x-api-key = the dedicated key)
{ "grantId": "mgr_...", "sourceUserId": "user_a", "workspaceId": "ws_...", "nonce": "[A-Za-z0-9_-]{16,128}", "expiresIn": 60 }
ā 201 { accessToken, accessTokenExpiresAt, tokenType: "Bearer" }, with no refresh token.
Claims: sub: agent_relayhistory_migration, org = grant org, wks = workspaceId, sponsorId = sourceUserId, sponsorChain: [sourceUserId, sub], scopes: ["rth:sync"], aud: [grant.audience], token_type: access, jti, sid, exp - iat <= 60 and exp <= grant.notAfter, meta: { tokenClass: "migration_assertion", grantId, planDigest, nonce, sourceUserId, actorId, actorOrgId, workspaceId }.
Refusals: insufficient_scope 403 (including a relayhistory-scoped key); assertion_key_required 403 (bearer token, workspace token, or a wildcard key without the literal scope); migration_grant_not_found 404 (unknown or other org); migration_grant_key_mismatch, migration_grant_revoked, migration_grant_expired, source_user_not_allowed, source_user_* 403; invalid_nonce, invalid_expires_in, grantId_required, sourceUserId_required, workspaceId_required 400.
Security properties
- Only the one key a grant names can mint under it. That key must be an unrevoked operator API key of the grant's org whose only scope is the migration scope, so it cannot create its own grants.
- There is no cross-org path. The grant org is the caller's org at creation, and a mint from another org's key reads the grant as not found.
- Source users fail closed. At grant creation every allowed user, and on every mint the source user, must be an active human RelayAuth identity of the grant's org. There is no fallback to the allow-list alone. Retiring, suspending, deleting, re-typing or re-homing the identity after the grant stops further mints.
- The assertion is bound to one audience (exact https origin; private http only behind
RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN=true, and no loopback http without it), carries onlyrth:sync, lives at most 60s and never past the grant deadline, and has no refresh token. Because itsaudnever contains the sharedrelayhistoryvalue, a History verifier refuses it. - The relayhistory scope cannot mint a migration assertion, and the migration scope cannot mint a relayhistory assertion. Both directions are tested.
- Grant create and revoke, and every mint, persist atomically with their audit entry (
migration_grant.created,migration_grant.revoked, andtoken.issuedon resourcemigration-assertion). Every refusal after authentication is audited withresult: deniedand the reason, always in the caller's org. - The threat model is documented in
docs/self-hosting.mdunder "Migration assertions / Threat model".
Not included
- No verifier replay ledger. RelayAuth does not record
jtiornonceconsumption. Single-use enforcement, plus theaud,expandplanDigestchecks, is the 96Bulls ingest verifier's job. - No deploy, no package publish, no migration applied to any real database, and no keys or grants created anywhere.
- No change to the cloud repo's
AuthStorageadapter.migrationGrantsis optional onAuthStorage, and the routes answer 501migration_grants_unsupporteduntil an adapter provides it.
Separate security finding for explicit review (not changed here)
POST /v1/tokens/relayhistory-assertion takes orgId and sponsorId from the request body, and nothing ties them to the calling key's org. Any key with relayauth:assertion:create:relayhistory, including the self-host bootstrap key, can mint rth:read/rth:sync assertions for any org and any sponsor. The existing test asserts this (tokens-route.test.ts, around line 2306: actorOrgId: "org_tokens_route", requestedOrgId: "org_relayhistory_target"). This may be the intended trust model for a single control plane, but it deserves an explicit decision. This PR leaves that endpoint untouched; the only change in its code path is that normalizeRelayhistoryAssertionOrigin is now exported for reuse.
Notes for reviewers
- Adds two audit actions to
@relayauth/typesAuditAction, and to the server's three action allow-lists and the OpenAPI enum. - The source-user refusal codes distinguish "unknown" from "other org" (as requested). A grant administrator can therefore learn that an identity id exists in another org. This is noted in the threat model.
- A revoked grant does not invalidate assertions already minted under it (each lives at most 60s).
Validation
On Node 22.14, matching CI:
turbo build,turbo typecheck: passturbo test --force: all 15 tasks pass. The server suite is 733/733, including 47 new tests insrc/__tests__/migration-assertion.test.ts.- Root
tests/*.test.ts: 8/8.scripts/check-contract-surface.sh: pass.
š¤ 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
claudesessiona94ee5a0Ā· opened viagh pr createĀ· last active 2026-10-30