Heimdall/src/store/schema.ts
Heimdall f1ddc2fffc Refuse an unbound or replayed state with a page back to the app, and keep use records through the skew
Browsers (Accept: text/html) refused for state_not_bound, state_already_launched
or state_already_used get the handoff page linking to return_to, re-checked
against the app's allowed origins; other callers keep the JSON code. A callback
with no binding denies the attempt its signed state names (pending only).
The memory store prunes a use record at exp + 30 s, as the state itself is
accepted that long; the schema comment says the same.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 17:16:38 +00:00

278 lines
10 KiB
TypeScript

export const CREATE_SCHEMA_SQL = `
-- Runtime app registration (POST /v1/apps) minted a client_secret_hash that
-- nothing ever verified, and the registered_apps table it fed was
-- unreachable dead code even after that writer was deleted: every HTTP route
-- already enumerates the four built-in app slugs in its own JSON schema, so a
-- registered row could never be resolved from a request (R21.3). Drop the
-- table on any store built before this schema too.
DROP TABLE IF EXISTS registered_apps;
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
display_name TEXT,
primary_email TEXT
);
CREATE TABLE IF NOT EXISTS linked_identities (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
username TEXT,
display_name TEXT,
primary_email TEXT,
access_token_encrypted TEXT,
refresh_token_encrypted TEXT,
token_expires_at TIMESTAMPTZ,
scopes TEXT NOT NULL DEFAULT '',
profile_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE(provider, provider_user_id)
);
CREATE INDEX IF NOT EXISTS linked_identities_account_id_idx
ON linked_identities(account_id);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
app_slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
claims_json JSONB NOT NULL,
access_revision INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_account_id_idx
ON sessions(account_id);
CREATE TABLE IF NOT EXISTS auth_attempts (
handle TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
provider TEXT NOT NULL,
mode TEXT NOT NULL,
return_to TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
denial_code TEXT
);
CREATE INDEX IF NOT EXISTS auth_attempts_lookup_idx
ON auth_attempts(app_slug, expires_at, status);
CREATE TABLE IF NOT EXISTS private_command_receipts (
app_slug TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
status TEXT NOT NULL,
content_schema TEXT NOT NULL,
envelope_base64 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (app_slug, idempotency_key)
);
CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx
ON private_command_receipts(expires_at);
CREATE TABLE IF NOT EXISTS pending_links (
id TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
identity_json JSONB NOT NULL,
return_to TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS auth_completions (
code TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
provider TEXT NOT NULL,
mode TEXT NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
return_to TEXT NOT NULL,
payload_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx
ON auth_completions(app_slug, expires_at, consumed_at);
-- Correlation handle for the browser_completion handoff. Before the
-- caller-identity cut, the completion code itself was the caller-chosen
-- attemptId (an unauthenticated account-takeover primitive); the code is now
-- always minted by the store, and attempt_id lets the private command plane
-- redeem the completion it created without ever needing the code.
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS attempt_id TEXT;
-- The connection target the signed state named (connection.targetId). Redeem
-- compares it against the target the redeeming app states.
ALTER TABLE auth_completions ADD COLUMN IF NOT EXISTS target_id TEXT;
-- One attempt handle may bind at most one unconsumed completion (R21.1): a
-- second completion for the same handle would let the by-attempt UPDATE in
-- consumeAuthCompletionByAttempt match more than one row, silently consuming
-- and discarding all but the one it returns. The predicate is partial rather
-- than a plain UNIQUE(app_slug, attempt_id) because a NULL attempt_id (no
-- correlation handle) and a *consumed*, already-settled completion must not
-- collide with a later one.
DROP INDEX IF EXISTS auth_completions_attempt_idx;
CREATE UNIQUE INDEX IF NOT EXISTS auth_completions_attempt_unconsumed_unique_idx
ON auth_completions(app_slug, attempt_id)
WHERE attempt_id IS NOT NULL AND consumed_at IS NULL;
-- One use per (state jti, phase): a signed OAuth state launches once and
-- completes once. The first INSERT for a pair wins; ON CONFLICT DO NOTHING
-- reports every later claim as no row. The Postgres store deletes nothing, so rows
-- stay like consumed auth_completions; a state is accepted until expires_at + 30 s
-- of skew, so a row may be pruned only after that and is never consulted again.
CREATE TABLE IF NOT EXISTS oauth_state_uses (
jti TEXT NOT NULL,
phase TEXT NOT NULL,
app_slug TEXT NOT NULL,
used_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (jti, phase)
);
CREATE TABLE IF NOT EXISTS capability_grants (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
scope_type TEXT NOT NULL,
scope_id TEXT,
capability TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL,
expires_at TIMESTAMPTZ,
note TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS capability_grants_lookup_idx
ON capability_grants(account_id, scope_type, scope_id, status);
CREATE TABLE IF NOT EXISTS entitlement_snapshots (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
scope TEXT NOT NULL,
evaluated_at TIMESTAMPTZ NOT NULL,
is_allowed BOOLEAN NOT NULL,
reason_code TEXT NOT NULL,
reason_detail TEXT,
raw_summary_json JSONB NOT NULL,
UNIQUE(account_id, provider, scope)
);
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
account_id TEXT,
session_id TEXT,
app_slug TEXT,
event_type TEXT NOT NULL,
event_payload_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS audit_events_lookup_idx
ON audit_events(account_id, session_id, app_slug, created_at);
`;
const schemaText = CREATE_SCHEMA_SQL.replace(/\r\n/g, "\n");
const createdTables = [...schemaText.matchAll(/^CREATE TABLE IF NOT EXISTS (\w+) \(\n([\s\S]*?)\n\);/gm)].map((match) => ({
table: match[1]!,
lines: match[2]!.split("\n").map((line) => line.trim().replace(/,$/, "")),
}));
/** Sorted and comma-separated, the form the schema check compares key columns in. */
function columnList(text: string): string {
return text.split(",").map((column) => column.trim()).sort().join(",");
}
/** The columns of a `PRIMARY KEY (a, b)` or `UNIQUE(a, b)` line, as a column list. */
function keyColumns(line: string, keyword: "PRIMARY KEY" | "UNIQUE"): string | undefined {
const match = new RegExp(`^${keyword}\\s*\\(([^)]*)\\)`).exec(line);
return match ? columnList(match[1]!) : undefined;
}
/*
* What every start must find before it serves, read from the SQL above so the
* two cannot drift. A start that applies the schema checks as well, because
* CREATE ... IF NOT EXISTS leaves a damaged table as it is. Indexes that only
* speed lookups are not required; the unique ones are, by shape, in
* REQUIRED_KEYS.
*
* Out of scope, each able to make a write fail on a database that passes the
* check: CHECK constraints; triggers; row-level security; unique indexes
* beyond the required keys, or stricter than them, including the leftovers of
* an interrupted concurrent index build (a valid extra one refuses rows the
* store expects to write, and even an invalid one that is ready is enforced
* on writes); NOT NULL columns the schema does not declare and that have no
* default, which the store's INSERTs leave out; and SELECT granted column by
* column when a statement reads `SELECT *` or `RETURNING *`, which needs
* SELECT on every column the table has, not only the declared ones.
*/
/** Every table the schema creates. */
export const REQUIRED_TABLES = createdTables.map(({ table }) => table);
/**
* Every column a created table declares and every column the schema adds to
* an existing table, each with its declared type, and whether it is nullable:
* the store reads and writes values as that type, and leaves a nullable
* column null, so a column retyped or made NOT NULL under it fails its writes.
*/
export const REQUIRED_COLUMNS = [
...createdTables.flatMap(({ table, lines }) =>
lines
.filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line))
.map((line) => {
const [, column, type] = /^(\w+) (\w+)/.exec(line)!;
return { table, column: column!, type: type!, nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(line) };
})
),
...[...schemaText.matchAll(/^ALTER TABLE (\w+) ADD COLUMN IF NOT EXISTS (\w+) (\w+)([^;]*);/gm)].map((match) => ({
table: match[1]!,
column: match[2]!,
type: match[3]!,
nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(match[4]!),
})),
];
/**
* Every unique key the schema makes: the primary keys and unique constraints
* of the created tables, and each unique index it creates, with that index's
* predicate as the schema writes it (empty for none). Any index of the right
* shape serves, whatever its name; PostgresStore.checkSchema says what the
* right shape is.
*/
export const REQUIRED_KEYS = [
...createdTables.flatMap(({ table, lines }) =>
lines.flatMap((line) => {
const columns =
/^(\w+) .*\bPRIMARY KEY\b/.exec(line)?.[1] ?? keyColumns(line, "PRIMARY KEY") ?? keyColumns(line, "UNIQUE");
return columns ? [{ table, columns, predicate: "" }] : [];
})
),
...[...schemaText.matchAll(/^CREATE UNIQUE INDEX IF NOT EXISTS \w+\s+ON (\w+)\(([^)]*)\)(?:\s+WHERE ([^;]+))?;/gm)].map((match) => ({
table: match[1]!,
columns: columnList(match[2]!),
predicate: (match[3] ?? "").replace(/\s+/g, " ").trim(),
})),
];