From 312fd496e8a2f61154640a324b35cdba77d74ddf Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sat, 14 Nov 2026 00:54:52 +0000 Subject: [PATCH] Identity moves: auth_at/auth_identity_id claims, proofTier, identity_moves table and moveIdentities on both stores proofTier is the one freshness rule (unlink now reads it); the sign_in callback stamps auth_at and auth_identity_id; moveIdentities is the only writer that changes linked_identities.account_id, decided by one shared decideMove. Co-Authored-By: Claude Sonnet 5.5 --- src/app-profiles.ts | 9 +++ src/app.ts | 10 ++- src/claims.ts | 47 +++++++++++ src/contracts.ts | 4 + src/store/identity-move.ts | 88 +++++++++++++++++++++ src/store/in-memory.ts | 83 ++++++++++++++++++++ src/store/postgres.ts | 155 +++++++++++++++++++++++++++++++++++++ src/store/schema.ts | 23 ++++++ src/store/types.ts | 70 +++++++++++++++++ 9 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 src/store/identity-move.ts diff --git a/src/app-profiles.ts b/src/app-profiles.ts index c1d5661..a77f76a 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -65,6 +65,13 @@ export interface AppProfile { * Absent for every app that may not assert. */ stepUpAssertion?: { methods: readonly string[] }; + /** + * The app may move linked identities between two accounts it holds proofs + * for (POST /v1/apps/{app}/identity-moves). `accountProofMethods` are the + * asserted step_up_method labels that prove a whole account rather than one + * login; each must be one of stepUpAssertion.methods. Absent: the app may not move. + */ + identityMoves?: { accountProofMethods: readonly string[] }; } /** The membership signal shared by every app that gates on GameCult membership. */ @@ -164,6 +171,8 @@ const bifrostProfile: AppProfile = { allowedReturnOrigins: ["https://bifrost.gamecult.org"], startRequiresAppCaller: true, stepUpAssertion: { methods: ["bifrost-passkey", "bifrost-email-code"] }, + // A passkey proves the account; an email code proves an inbox, which is one login's worth at most. + identityMoves: { accountProofMethods: ["bifrost-passkey"] }, capabilities: [ { key: "member_access", diff --git a/src/app.ts b/src/app.ts index 9e8c334..29fdf19 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,7 +24,7 @@ import { type OAuthStartRequest, type Provider, } from "./contracts.js"; -import { issueAccessClaim, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js"; +import { issueAccessClaim, proofCovers, proofTier, signAssertedStepUpClaim, STEP_UP_PROOF_TTL_SECONDS, type IssueAccessClaimInput } from "./claims.js"; import { FixedWindowBudget, type FixedWindowLimit } from "./fixed-window.js"; import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint, isUsableConnectionTarget } from "./app-profiles.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js"; @@ -1976,8 +1976,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise nowS || nowS - provedAt > STEP_UP_PROOF_TTL_SECONDS) { + if (proofTier(claims, nowS) !== "strong") { reply.code(403); return { error: "step_up_required", diff --git a/src/claims.ts b/src/claims.ts index 5e1d3b0..8ddd423 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -14,6 +14,9 @@ import { type CreateSessionInput, type HeimdallStore } from "./store/types.js"; /** How long a step-up sign-in's proof stays good for an action that demands it (identity unlink). */ export const STEP_UP_PROOF_TTL_SECONDS = 600; +/** How long a plain sign-in's proof stays good for an action that accepts a weak proof (an identity move). */ +export const AUTH_PROOF_TTL_SECONDS = 300; + function nowEpochSeconds(): number { return Math.floor(Date.now() / 1000); } @@ -55,6 +58,10 @@ export interface IssueAccessClaimInput { stepUpAt?: number; /** Required with stepUpAt (`provider:`); the claim never carries one without the other. */ stepUpMethod?: string; + /** Epoch seconds of the sign-in that minted this token, with authIdentityId (both or neither). Only a sign_in callback sets them; a refresh, link or connect never does. */ + authAt?: number; + /** The linked_identities.id the sign-in came through. */ + authIdentityId?: string; } export interface IssuedAccessClaimResult { @@ -144,6 +151,11 @@ export async function issueAccessClaim(options: { } } + if (options.input.authAt !== undefined && options.input.authIdentityId !== undefined) { + claimSet.auth_at = options.input.authAt; + claimSet.auth_identity_id = options.input.authIdentityId; + } + if (options.input.displayName) { claimSet.display_name = options.input.displayName; } @@ -229,3 +241,38 @@ export function signAssertedStepUpClaim(options: { }; return { accessToken: signJwt(claimSet, options.keys), claimSet, stepUpAt }; } + +/** + * How fresh a bearer's proof of the member is, from its claims alone: + * `strong` when a step-up (Heimdall's or the app's asserted one) happened in + * the last STEP_UP_PROOF_TTL_SECONDS, else `weak` when the sign-in did in the + * last AUTH_PROOF_TTL_SECONDS, else null. The one freshness rule: identity + * unlink and identity moves both read it. + */ +export function proofTier(claims: Record, nowS: number): "strong" | "weak" | null { + const within = (at: unknown, window: number) => typeof at === "number" && at <= nowS && nowS - at <= window; + if (within(claims.step_up_at, STEP_UP_PROOF_TTL_SECONDS)) return "strong"; + if (within(claims.auth_at, AUTH_PROOF_TTL_SECONDS)) return "weak"; + return null; +} + +/** + * What a fresh proof covers of its account: `account` (every login) only for a + * strong proof whose step_up_method the app's profile lists as an account + * proof; otherwise the one login the sign-in came through (its id); otherwise + * nothing. A proof through login X never speaks for the account's other logins. + */ +export function proofCovers( + claims: Record, + accountProofMethods: readonly string[], + nowS: number +): "account" | string | null { + if ( + proofTier(claims, nowS) === "strong" && + typeof claims.step_up_method === "string" && + accountProofMethods.includes(claims.step_up_method) + ) { + return "account"; + } + return typeof claims.auth_identity_id === "string" ? claims.auth_identity_id : null; +} diff --git a/src/contracts.ts b/src/contracts.ts index d7a521c..78a3a60 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -162,6 +162,10 @@ export interface AccessClaimPayload { * exactly when step_up_at is; never carried by a refresh. */ step_up_method?: string; + /** Epoch seconds of the sign-in this access token was minted by, with auth_identity_id; absent unless a sign_in callback minted it, and never carried by a refresh. */ + auth_at?: number; + /** The linked_identities.id the sign-in came through; present exactly when auth_at is. It is the only record of which login proved the session, and it describes the sign-in, not an asserted factor. */ + auth_identity_id?: string; display_name?: string; app: { slug: AppSlug; diff --git a/src/store/identity-move.ts b/src/store/identity-move.ts new file mode 100644 index 0000000..7b80d43 --- /dev/null +++ b/src/store/identity-move.ts @@ -0,0 +1,88 @@ +import { type Provider } from "../contracts.js"; +import { type MoveIdentitiesInput, type StoredIdentityMove, type StoredLinkedIdentity } from "./types.js"; + +/** Everything a store read under its locks that the move rules decide on. */ +export interface MoveFacts { + /** The identity_moves row already holding input.moveId. */ + existing: StoredIdentityMove | null; + /** The move input.reversesMoveId names, and whether a reversal of it already exists. */ + forward: { move: StoredIdentityMove; reversed: boolean } | null; + /** Every proof session still carries its sid, app, account and access revision and has not expired. */ + proofSessionsLive: boolean; + sourceIdentities: readonly StoredLinkedIdentity[]; + destinationProviders: ReadonlySet; + /** An account with no identity and an unreversed move from it. */ + sourceIsAlias: boolean; + destinationIsAlias: boolean; +} + +export type MoveDecision = + | { kind: "replayed"; move: StoredIdentityMove } + | { kind: "refused"; result: { kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible" } | { kind: "provider_conflict"; provider: Provider } } + | { kind: "move"; identities: StoredLinkedIdentity[] }; + +/** + * The one statement of the move rules, in the order they are decided. Both + * stores gather MoveFacts under their locks and apply the answer, so memory + * and Postgres cannot disagree about what a move refuses. + */ +export function decideMove(input: MoveIdentitiesInput, facts: MoveFacts): MoveDecision { + const refuse = (kind: "move_id_conflict" | "proof_lapsed" | "already_moved" | "identity_not_found" | "identity_not_proved" | "not_reversible"): MoveDecision => ({ kind: "refused", result: { kind } }); + + const { existing } = facts; + if (existing) { + return existing.appSlug === input.appSlug && + existing.fromAccountId === input.fromAccountId && + existing.toAccountId === input.toAccountId && + existing.reversesMoveId === input.reversesMoveId + ? { kind: "replayed", move: existing } + : refuse("move_id_conflict"); + } + if (!facts.proofSessionsLive) return refuse("proof_lapsed"); + + const held = new Set(facts.sourceIdentities.map((identity) => identity.id)); + if (input.reversesMoveId !== undefined) { + const forward = facts.forward; + if ( + !forward || + forward.reversed || + forward.move.appSlug !== input.appSlug || + forward.move.reversesMoveId !== undefined || + forward.move.toAccountId !== input.fromAccountId || + forward.move.fromAccountId !== input.toAccountId || + !forward.move.moved.every((moved) => held.has(moved.id)) + ) { + return refuse("not_reversible"); + } + } else if (facts.sourceIsAlias || facts.destinationIsAlias) { + return refuse("already_moved"); + } + + let chosen: StoredLinkedIdentity[]; + if (input.identityIds === "all") { + chosen = [...facts.sourceIdentities]; + } else { + if (!input.identityIds.every((id) => held.has(id))) return refuse("identity_not_found"); + const wanted = new Set(input.identityIds); + chosen = facts.sourceIdentities.filter((identity) => wanted.has(identity.id)); + } + if (chosen.length === 0) return refuse("identity_not_found"); + + if (input.covers !== "account") { + const covered = new Set(input.covers); + if (!chosen.every((identity) => covered.has(identity.id))) return refuse("identity_not_proved"); + } + const clash = chosen.find((identity) => facts.destinationProviders.has(identity.provider)); + if (clash) return { kind: "refused", result: { kind: "provider_conflict", provider: clash.provider } }; + return { kind: "move", identities: chosen }; +} + +/** The history a move writes about what it carried. */ +export function movedRecord(identities: readonly StoredLinkedIdentity[]): StoredIdentityMove["moved"] { + return identities.map((identity) => ({ + id: identity.id, + provider: identity.provider, + providerUserId: identity.providerUserId, + credentialStripped: identity.accessTokenEncrypted !== undefined || identity.refreshTokenEncrypted !== undefined, + })); +} diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index b35decb..401201c 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -26,9 +26,13 @@ import { type StoredLinkedIdentity, type StoredSession, type StoredPrivateCommandReceipt, + type MoveIdentitiesInput, + type MoveResult, + type StoredIdentityMove, type UnlinkResult, type UpsertLinkedIdentityInput, } from "./types.js"; +import { decideMove, movedRecord } from "./identity-move.js"; function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; @@ -63,6 +67,7 @@ export class InMemoryStore implements HeimdallStore { private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); private readonly consentReceipts: StoredConsentReceipt[] = []; + private readonly identityMoves = new Map(); async ensureSchema(): Promise { return; @@ -202,6 +207,84 @@ export class InMemoryStore implements HeimdallStore { return { kind: "unlinked", identity: clone(target) }; } + /** No await between the reads and the writes: the event loop is the lock. */ + async moveIdentities(input: MoveIdentitiesInput): Promise { + const at = Date.parse(input.at); + const forwardMove = input.reversesMoveId !== undefined ? this.identityMoves.get(input.reversesMoveId) : undefined; + const sourceIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.fromAccountId); + const destinationIdentities = [...this.linkedIdentities.values()].filter((identity) => identity.accountId === input.toAccountId); + const decision = decideMove(input, { + existing: this.identityMoves.get(input.moveId) ?? null, + forward: forwardMove ? { move: forwardMove, reversed: this.isReversed(forwardMove.id) } : null, + proofSessionsLive: input.proofSessions.every((proof) => { + const session = this.sessions.get(proof.sessionId); + return ( + session !== undefined && + session.appSlug === proof.appSlug && + session.accountId === proof.accountId && + session.accessRevision === proof.accessRevision && + Date.parse(session.expiresAt) > at + ); + }), + sourceIdentities: clone(sourceIdentities), + destinationProviders: new Set(destinationIdentities.map((identity) => identity.provider)), + sourceIsAlias: sourceIdentities.length === 0 && this.hasUnreversedMoveFrom(input.fromAccountId), + destinationIsAlias: destinationIdentities.length === 0 && this.hasUnreversedMoveFrom(input.toAccountId), + }); + if (decision.kind === "replayed") return { kind: "replayed", move: clone(decision.move) }; + if (decision.kind === "refused") return decision.result; + + for (const identity of decision.identities) { + const row = this.linkedIdentities.get(identity.id)!; + row.accountId = input.toAccountId; + row.updatedAt = input.at; + delete row.accessTokenEncrypted; + delete row.refreshTokenEncrypted; + delete row.tokenExpiresAt; + delete row.refreshFailedAt; + } + for (const session of this.sessions.values()) { + if (session.accountId === input.fromAccountId && Date.parse(session.expiresAt) > at) { + session.expiresAt = input.at; + session.lastSeenAt = input.at; + session.accessRevision += 1; + } + } + for (const row of [...this.pendingLinks.values(), ...this.authCompletions.values()]) { + if (row.accountId === input.fromAccountId && !row.consumedAt && Date.parse(row.expiresAt) > at) row.expiresAt = input.at; + } + const move: StoredIdentityMove = { + id: input.moveId, + appSlug: input.appSlug, + fromAccountId: input.fromAccountId, + toAccountId: input.toAccountId, + ...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}), + moved: movedRecord(decision.identities), + proof: clone(input.proof), + createdAt: input.at, + }; + this.identityMoves.set(move.id, clone(move)); + return { kind: "moved", move }; + } + + async findIdentityMove(moveId: string): Promise { + return clone(this.identityMoves.get(moveId) ?? null); + } + + async resolveAccount(accountId: string): Promise { + if ([...this.linkedIdentities.values()].some((identity) => identity.accountId === accountId)) return accountId; + const unreversed = [...this.identityMoves.values()].filter((move) => move.fromAccountId === accountId && !this.isReversed(move.id)); + return unreversed.at(-1)?.toAccountId ?? accountId; + } + + private isReversed(moveId: string): boolean { + return [...this.identityMoves.values()].some((move) => move.reversesMoveId === moveId); + } + + private hasUnreversedMoveFrom(accountId: string): boolean { + return [...this.identityMoves.values()].some((move) => move.fromAccountId === accountId && !this.isReversed(move.id)); + } + async updateLinkedIdentityCredential( id: string, decide: (locked: StoredLinkedIdentity) => Promise, diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 0b05f61..8482a8a 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { Pool, type PoolClient, type QueryResultRow } from "pg"; import { type AppSlug, type HeimdallAuthAttemptStatus, type LinkedIdentityInput, type Provider } from "../contracts.js"; +import { decideMove, movedRecord, type MoveFacts } from "./identity-move.js"; import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "./schema.js"; import { type CreateAccountInput, @@ -19,6 +20,9 @@ import { type CreatePrivateCommandReceiptInput, type HeimdallStore, IdentityHeldElsewhereError, + type MoveIdentitiesInput, + type MoveResult, + type StoredIdentityMove, OAUTH_STATE_PRUNE_BATCH, oauthStatePruneCutoffMs, type StoredAccount, @@ -175,6 +179,42 @@ function mapAccountRow(row: AccountRow): StoredAccount { return account; } +interface IdentityMoveRow extends QueryResultRow { + id: string; + app_slug: AppSlug; + from_account_id: string; + to_account_id: string; + reverses_move_id: string | null; + moved_json: StoredIdentityMove["moved"]; + proof_json: StoredIdentityMove["proof"]; + created_at: string; +} + +async function selectIdentityMove(db: Pick, moveId: string): Promise { + const result = await db.query("SELECT * FROM identity_moves WHERE id = $1", [moveId]); + const row = result.rows[0]; + if (!row) return null; + return { + id: row.id, + appSlug: row.app_slug, + fromAccountId: row.from_account_id, + toAccountId: row.to_account_id, + ...(row.reverses_move_id ? { reversesMoveId: row.reverses_move_id } : {}), + moved: row.moved_json, + proof: row.proof_json, + createdAt: new Date(row.created_at).toISOString(), + }; +} + +/** A move from this account that no reversal names: the account's identities went elsewhere. */ +async function hasUnreversedMoveFrom(db: Pick, accountId: string): Promise { + const result = await db.query( + "SELECT 1 FROM identity_moves m WHERE m.from_account_id = $1 AND NOT EXISTS (SELECT 1 FROM identity_moves r WHERE r.reverses_move_id = m.id) LIMIT 1", + [accountId] + ); + return (result.rowCount ?? 0) > 0; +} + function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity { const identity: StoredLinkedIdentity = { id: row.id, @@ -457,6 +497,9 @@ export function samePredicate(declared: string, actual: string, keyColumns: stri * expired oauth_state_uses rows (which needs SELECT for its WHERE), and the one * linked_identities row an unlink names (unlinkLinkedIdentity, the only * delete there; it needs SELECT for its WHERE and for the account's count). + * identity_moves is append-only. moveIdentities is the statement that updates + * linked_identities.account_id (its one writer), sessions, pending_links and + * auth_completions, all of which already hold UPDATE. * audit_events is written and never read back. */ export const STORE_TABLE_PRIVILEGES: Readonly> = { @@ -471,6 +514,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + try { + // Both accounts, in id order, before anything is read: opposite moves queue here instead of deadlocking. + await client.query("SELECT id FROM accounts WHERE id = ANY($1) ORDER BY id FOR UPDATE", [[input.fromAccountId, input.toAccountId]]); + const existing = await selectIdentityMove(client, input.moveId); + let forward: MoveFacts["forward"] = null; + if (input.reversesMoveId !== undefined) { + const forwardMove = await selectIdentityMove(client, input.reversesMoveId); + const reversal = forwardMove ? await client.query("SELECT 1 FROM identity_moves WHERE reverses_move_id = $1", [forwardMove.id]) : null; + forward = forwardMove ? { move: forwardMove, reversed: (reversal?.rowCount ?? 0) > 0 } : null; + } + let proofSessionsLive = true; + for (const proof of input.proofSessions) { + const live = await client.query( + "SELECT 1 FROM sessions WHERE id = $1 AND app_slug = $2 AND account_id = $3 AND access_revision = $4 AND expires_at > $5", + [proof.sessionId, proof.appSlug, proof.accountId, proof.accessRevision, input.at] + ); + if (!live.rowCount) proofSessionsLive = false; + } + const source = await client.query( + "SELECT * FROM linked_identities WHERE account_id = $1 ORDER BY created_at ASC, id ASC FOR UPDATE", + [input.fromAccountId] + ); + const destination = await client.query<{ provider: Provider }>("SELECT provider FROM linked_identities WHERE account_id = $1", [input.toAccountId]); + const sourceIdentities = source.rows.map(mapLinkedIdentityRow); + const decision = decideMove(input, { + existing, + forward, + proofSessionsLive, + sourceIdentities, + destinationProviders: new Set(destination.rows.map((row) => row.provider)), + sourceIsAlias: sourceIdentities.length === 0 && (await hasUnreversedMoveFrom(client, input.fromAccountId)), + destinationIsAlias: destination.rowCount === 0 && (await hasUnreversedMoveFrom(client, input.toAccountId)), + }); + if (decision.kind !== "move") { + await client.query("ROLLBACK"); + return decision.kind === "replayed" ? { kind: "replayed", move: decision.move } : decision.result; + } + const ids = decision.identities.map((identity) => identity.id); + await client.query( + `UPDATE linked_identities + SET account_id = $2, access_token_encrypted = NULL, refresh_token_encrypted = NULL, + token_expires_at = NULL, refresh_failed_at = NULL, updated_at = $3 + WHERE id = ANY($1) AND account_id = $4`, + [ids, input.toAccountId, input.at, input.fromAccountId] + ); + await client.query( + "UPDATE sessions SET expires_at = $2, last_seen_at = $2, access_revision = access_revision + 1 WHERE account_id = $1 AND expires_at > $2", + [input.fromAccountId, input.at] + ); + for (const table of ["pending_links", "auth_completions"]) { + await client.query( + `UPDATE ${table} SET expires_at = $2 WHERE account_id = $1 AND consumed_at IS NULL AND expires_at > $2`, + [input.fromAccountId, input.at] + ); + } + const move: StoredIdentityMove = { + id: input.moveId, + appSlug: input.appSlug, + fromAccountId: input.fromAccountId, + toAccountId: input.toAccountId, + ...(input.reversesMoveId !== undefined ? { reversesMoveId: input.reversesMoveId } : {}), + moved: movedRecord(decision.identities), + proof: input.proof, + createdAt: input.at, + }; + try { + await client.query( + `INSERT INTO identity_moves (id, app_slug, from_account_id, to_account_id, reverses_move_id, moved_json, proof_json, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [move.id, move.appSlug, move.fromAccountId, move.toAccountId, nullable(move.reversesMoveId), JSON.stringify(move.moved), JSON.stringify(move.proof), move.createdAt] + ); + } catch (error) { + // The row is the arbiter of a race the locks did not serialise (a reversal of the same move through another pool). + if ((error as { code?: string }).code !== "23505") throw error; + await client.query("ROLLBACK"); + const winner = await this.findIdentityMove(input.moveId); + if (!winner) return { kind: "not_reversible" }; + const again = decideMove(input, { existing: winner, forward: null, proofSessionsLive: true, sourceIdentities: [], destinationProviders: new Set(), sourceIsAlias: false, destinationIsAlias: false }); + return again.kind === "replayed" ? { kind: "replayed", move: again.move } : { kind: "move_id_conflict" }; + } + await client.query("COMMIT"); + return { kind: "moved", move }; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } + } finally { + client.release(); + } + } + + async findIdentityMove(moveId: string): Promise { + return selectIdentityMove(this.pool, moveId); + } + + async resolveAccount(accountId: string): Promise { + const held = await this.pool.query("SELECT 1 FROM linked_identities WHERE account_id = $1 LIMIT 1", [accountId]); + if (held.rowCount) return accountId; + const moved = await this.pool.query<{ to_account_id: string }>( + `SELECT m.to_account_id FROM identity_moves m + WHERE m.from_account_id = $1 AND NOT EXISTS (SELECT 1 FROM identity_moves r WHERE r.reverses_move_id = m.id) + ORDER BY m.created_at DESC, m.id DESC LIMIT 1`, + [accountId] + ); + return moved.rows[0]?.to_account_id ?? accountId; + } + async updateLinkedIdentityCredential( id: string, decide: (locked: StoredLinkedIdentity) => Promise, diff --git a/src/store/schema.ts b/src/store/schema.ts index 3678122..ff1fcf2 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -216,6 +216,29 @@ CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx CREATE INDEX IF NOT EXISTS consent_receipts_account_idx ON consent_receipts(account_id, created_at); +-- One row per identity move, append-only: no UPDATE and no DELETE, and no +-- ON DELETE CASCADE, so the record outlives nothing it names. moved_json is +-- [{id, provider, providerUserId, credentialStripped}]; proof_json is +-- {from: [{tier, method, identityId}], to: {...} | null}. Never a token, sid, +-- email or name. A move is reversed once: the second row naming it conflicts. +CREATE TABLE IF NOT EXISTS identity_moves ( + id TEXT PRIMARY KEY, + app_slug TEXT NOT NULL, + from_account_id TEXT NOT NULL REFERENCES accounts(id), + to_account_id TEXT NOT NULL REFERENCES accounts(id), + reverses_move_id TEXT REFERENCES identity_moves(id), + moved_json JSONB NOT NULL, + proof_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS identity_moves_reverses_unique_idx + ON identity_moves(reverses_move_id) + WHERE reverses_move_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS identity_moves_from_idx + ON identity_moves(from_account_id); + CREATE TABLE IF NOT EXISTS audit_events ( id TEXT PRIMARY KEY, account_id TEXT, diff --git a/src/store/types.ts b/src/store/types.ts index c044161..68459c3 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -59,6 +59,60 @@ export type UnlinkResult = | { kind: "last_identity" } | { kind: "not_found" }; +/** One login a move carried; `credentialStripped` is history written at the move (the row held a token the move dropped). */ +export interface MovedIdentity { + id: string; + provider: Provider; + providerUserId: string; + credentialStripped: boolean; +} + +/** What each side's proofs were, as tiers and methods only: never a token, sid, email or name. */ +export interface IdentityMoveProof { + from: { tier: "strong" | "weak"; method: string | null; identityId: string | null }[]; + to: { tier: "strong" | "weak"; method: string | null; identityId: string | null } | null; +} + +/** The append-only record of one move. */ +export interface StoredIdentityMove { + id: string; + appSlug: AppSlug; + fromAccountId: string; + toAccountId: string; + reversesMoveId?: string; + moved: MovedIdentity[]; + proof: IdentityMoveProof; + createdAt: string; +} + +export interface MoveIdentitiesInput { + moveId: string; + appSlug: AppSlug; + fromAccountId: string; + toAccountId: string; + /** The logins to carry, or every login the source holds. */ + identityIds: readonly string[] | "all"; + /** What the caller's from-proofs cover: the whole account, or exactly these logins. */ + covers: "account" | readonly string[]; + /** Sessions the proofs rested on; each must still be live when the transaction commits. */ + proofSessions: readonly { appSlug: AppSlug; sessionId: string; accountId: string; accessRevision: number }[]; + reversesMoveId?: string; + proof: IdentityMoveProof; + at: string; +} + +/** What moveIdentities decided. Refusals write nothing. */ +export type MoveResult = + | { kind: "moved"; move: StoredIdentityMove } + | { kind: "replayed"; move: StoredIdentityMove } + | { kind: "move_id_conflict" } + | { kind: "proof_lapsed" } + | { kind: "already_moved" } + | { kind: "identity_not_found" } + | { kind: "identity_not_proved" } + | { kind: "provider_conflict"; provider: Provider } + | { kind: "not_reversible" }; + export interface UpsertLinkedIdentityInput { id?: string; accountId: string; @@ -339,6 +393,22 @@ export interface HeimdallStore { * `not_found`). */ unlinkLinkedIdentity(accountId: string, identityId: string): Promise; + /** + * The only writer that sets a linked identity's account to another account, + * in one transaction: lock both accounts (in id order), answer a repeated + * moveId from its row, re-check the proof sessions, refuse what the rules + * refuse, move the rows with their credentials stripped, revoke the source's + * sessions and expire its pending links and unredeemed completions, and + * append the identity_moves row. A reversal (`reversesMoveId`) moves back + * exactly what the forward move carried. The destination never loses an identity. + */ + moveIdentities(input: MoveIdentitiesInput): Promise; + findIdentityMove(moveId: string): Promise; + /** + * One hop, derived and never stored: the destination of an unreversed move + * from `accountId` when it now holds no identity, else `accountId` itself. + */ + resolveAccount(accountId: string): Promise; /** * Serialise decisions about one row's credential. `decide` receives the row * as read under the lock (never a caller's snapshot) and the decision is