Heimdall/src/store/postgres.ts

804 lines
24 KiB
TypeScript
Raw Normal View History

2026-04-26 12:50:07 +01:00
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";
2026-04-26 12:50:07 +01:00
import { CREATE_SCHEMA_SQL } from "./schema.js";
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type CreateAuthCompletionInput,
2026-04-26 12:50:07 +01:00
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CreateEntitlementSnapshotInput,
type CreateSessionInput,
type CreatePrivateCommandReceiptInput,
2026-04-26 12:50:07 +01:00
type HeimdallStore,
type StoredAccount,
type StoredAuthAttempt,
type StoredAuthCompletion,
2026-04-26 12:50:07 +01:00
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type StoredSession,
type StoredPrivateCommandReceipt,
2026-04-26 12:50:07 +01:00
type UpsertLinkedIdentityInput,
} from "./types.js";
interface AccountRow extends QueryResultRow {
id: string;
created_at: string;
last_seen_at: string;
display_name: string | null;
primary_email: string | null;
}
interface LinkedIdentityRow extends QueryResultRow {
id: string;
account_id: string;
provider: Provider;
provider_user_id: string;
username: string | null;
display_name: string | null;
primary_email: string | null;
access_token_encrypted: string | null;
refresh_token_encrypted: string | null;
token_expires_at: string | null;
scopes: string;
profile_json: Record<string, unknown>;
created_at: string;
updated_at: string;
}
interface GrantRow extends QueryResultRow {
id: string;
account_id: string;
scope_type: "global" | "app";
scope_id: AppSlug | null;
capability: string;
source: string;
status: "active" | "revoked";
expires_at: string | null;
note: string | null;
created_at: string;
updated_at: string;
}
interface SessionRow extends QueryResultRow {
id: string;
account_id: string;
app_slug: AppSlug;
created_at: string;
last_seen_at: string;
expires_at: string;
access_revision: number;
claims_json: Record<string, unknown>;
}
interface AuthCompletionRow extends QueryResultRow {
code: string;
attempt_id: string | null;
app_slug: AppSlug;
provider: Provider;
mode: "sign_in" | "link" | "connect";
account_id: string;
session_id: string;
return_to: string;
payload_json: Record<string, unknown>;
created_at: string | Date;
expires_at: string | Date;
consumed_at: string | Date | null;
}
interface AuthAttemptRow extends QueryResultRow {
handle: string;
app_slug: AppSlug;
provider: Provider;
mode: "sign_in" | "link" | "connect";
return_to: string;
status: HeimdallAuthAttemptStatus;
created_at: string | Date;
expires_at: string | Date;
completed_at: string | Date | null;
consumed_at: string | Date | null;
denial_code: string | null;
}
interface PrivateCommandReceiptRow extends QueryResultRow {
app_slug: AppSlug;
idempotency_key: string;
request_fingerprint: string;
status: string;
content_schema: string;
envelope_base64: string;
created_at: string | Date;
expires_at: string | Date;
}
2026-04-26 12:50:07 +01:00
function expectRow<T>(row: T | undefined, label: string): T {
if (!row) {
throw new Error(`${label} query returned no rows.`);
}
return row;
}
function nullable<T>(value: T | undefined): T | null {
return value ?? null;
}
function normalizeTimestamp(value: string | Date): string {
return value instanceof Date ? value.toISOString() : value;
}
2026-04-26 12:50:07 +01:00
function parseScopes(value: string): string[] {
return value.split(" ").map((item) => item.trim()).filter(Boolean);
}
function mapAccountRow(row: AccountRow): StoredAccount {
const account: StoredAccount = {
id: row.id,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
};
if (row.display_name) {
account.displayName = row.display_name;
}
if (row.primary_email) {
account.primaryEmail = row.primary_email;
}
return account;
}
function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity {
const identity: StoredLinkedIdentity = {
id: row.id,
accountId: row.account_id,
provider: row.provider,
providerUserId: row.provider_user_id,
scopes: parseScopes(row.scopes),
profileJson: row.profile_json,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
if (row.username) {
identity.username = row.username;
}
if (row.display_name) {
identity.displayName = row.display_name;
}
if (row.primary_email) {
identity.primaryEmail = row.primary_email;
}
if (row.access_token_encrypted) {
identity.accessTokenEncrypted = row.access_token_encrypted;
}
if (row.refresh_token_encrypted) {
identity.refreshTokenEncrypted = row.refresh_token_encrypted;
}
if (row.token_expires_at) {
identity.tokenExpiresAt = row.token_expires_at;
}
return identity;
}
function mapGrantRow(row: GrantRow): StoredCapabilityGrant {
const grant: StoredCapabilityGrant = {
id: row.id,
accountId: row.account_id,
scopeType: row.scope_type,
capability: row.capability,
source: row.source,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
if (row.scope_id) {
grant.scopeId = row.scope_id;
}
if (row.expires_at) {
grant.expiresAt = row.expires_at;
}
if (row.note) {
grant.note = row.note;
}
return grant;
}
function mapSessionRow(row: SessionRow): StoredSession {
return {
id: row.id,
accountId: row.account_id,
appSlug: row.app_slug,
createdAt: row.created_at,
lastSeenAt: row.last_seen_at,
expiresAt: row.expires_at,
accessRevision: row.access_revision,
claimsJson: row.claims_json,
};
}
function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
const completion: StoredAuthCompletion = {
code: row.code,
appSlug: row.app_slug,
provider: row.provider,
mode: row.mode,
accountId: row.account_id,
sessionId: row.session_id,
returnTo: row.return_to,
createdAt: normalizeTimestamp(row.created_at),
expiresAt: normalizeTimestamp(row.expires_at),
payloadJson: row.payload_json,
};
if (row.attempt_id) {
completion.attemptId = row.attempt_id;
}
if (row.consumed_at) {
completion.consumedAt = normalizeTimestamp(row.consumed_at);
}
return completion;
}
function mapPrivateCommandReceiptRow(row: PrivateCommandReceiptRow): StoredPrivateCommandReceipt {
return {
appSlug: row.app_slug,
idempotencyKey: row.idempotency_key,
requestFingerprint: row.request_fingerprint,
status: row.status,
contentSchema: row.content_schema,
envelopeBase64: row.envelope_base64,
createdAt: normalizeTimestamp(row.created_at),
expiresAt: normalizeTimestamp(row.expires_at),
};
}
function mapAuthAttemptRow(row: AuthAttemptRow): StoredAuthAttempt {
const attempt: StoredAuthAttempt = {
handle: row.handle,
appSlug: row.app_slug,
provider: row.provider,
mode: row.mode,
returnTo: row.return_to,
status: row.status,
createdAt: normalizeTimestamp(row.created_at),
expiresAt: normalizeTimestamp(row.expires_at),
};
if (row.completed_at) attempt.completedAt = normalizeTimestamp(row.completed_at);
if (row.consumed_at) attempt.consumedAt = normalizeTimestamp(row.consumed_at);
if (row.denial_code) attempt.denialCode = row.denial_code;
return attempt;
}
2026-04-26 12:50:07 +01:00
export class PostgresStore implements HeimdallStore {
constructor(private readonly pool: Pick<Pool, "query" | "end">) {}
async ensureSchema(): Promise<void> {
await this.pool.query(CREATE_SCHEMA_SQL);
}
/** Opens a connection and runs a trivial query, so a start that does not apply the schema still proves the URL. */
async checkConnection(): Promise<void> {
await this.pool.query("SELECT 1");
}
2026-04-26 12:50:07 +01:00
async close(): Promise<void> {
await this.pool.end();
}
async createAccount(input: CreateAccountInput): Promise<StoredAccount> {
const id = input.id ?? randomUUID();
const result = await this.pool.query<AccountRow>(
`
INSERT INTO accounts (id, created_at, last_seen_at, display_name, primary_email)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`,
[id, input.createdAt, input.lastSeenAt, nullable(input.displayName), nullable(input.primaryEmail)]
);
return mapAccountRow(expectRow(result.rows[0], "createAccount"));
}
async touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise<void> {
await this.pool.query(
`
UPDATE accounts
SET last_seen_at = $2,
display_name = COALESCE($3, display_name),
primary_email = COALESCE($4, primary_email)
WHERE id = $1
`,
[accountId, at, nullable(updates?.displayName), nullable(updates?.primaryEmail)]
);
}
async findAccountById(accountId: string): Promise<StoredAccount | null> {
const result = await this.pool.query<AccountRow>(
`
SELECT *
FROM accounts
WHERE id = $1
LIMIT 1
`,
[accountId]
);
return result.rowCount ? mapAccountRow(expectRow(result.rows[0], "findAccountById")) : null;
}
2026-04-26 12:50:07 +01:00
async findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredAccount | null> {
const result = await this.pool.query<AccountRow>(
`
SELECT accounts.*
FROM accounts
INNER JOIN linked_identities ON linked_identities.account_id = accounts.id
WHERE linked_identities.provider = $1
AND linked_identities.provider_user_id = $2
LIMIT 1
`,
[provider, providerUserId]
);
return result.rowCount ? mapAccountRow(expectRow(result.rows[0], "findAccountByLinkedIdentity")) : null;
}
async findStoredLinkedIdentity(provider: Provider, providerUserId: string): Promise<StoredLinkedIdentity | null> {
const result = await this.pool.query<LinkedIdentityRow>(
`
SELECT *
FROM linked_identities
WHERE provider = $1
AND provider_user_id = $2
LIMIT 1
`,
[provider, providerUserId]
);
return result.rowCount ? mapLinkedIdentityRow(expectRow(result.rows[0], "findStoredLinkedIdentity")) : null;
}
2026-04-29 15:00:55 +01:00
async findStoredLinkedIdentityForAccount(accountId: string, provider: Provider): Promise<StoredLinkedIdentity | null> {
const result = await this.pool.query<LinkedIdentityRow>(
`
SELECT *
FROM linked_identities
WHERE account_id = $1
AND provider = $2
ORDER BY updated_at DESC
LIMIT 1
`,
[accountId, provider]
);
return result.rowCount ? mapLinkedIdentityRow(expectRow(result.rows[0], "findStoredLinkedIdentityForAccount")) : null;
}
async listStoredLinkedIdentitiesForAccount(accountId: string): Promise<StoredLinkedIdentity[]> {
const result = await this.pool.query<LinkedIdentityRow>(
`
SELECT *
FROM linked_identities
WHERE account_id = $1
ORDER BY created_at ASC
`,
[accountId]
);
return result.rows.map(mapLinkedIdentityRow);
}
2026-04-26 12:50:07 +01:00
async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise<StoredLinkedIdentity> {
const id = input.id ?? randomUUID();
const result = await this.pool.query<LinkedIdentityRow>(
`
INSERT INTO linked_identities (
id,
account_id,
provider,
provider_user_id,
username,
display_name,
primary_email,
access_token_encrypted,
refresh_token_encrypted,
token_expires_at,
scopes,
profile_json,
created_at,
updated_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12, $13, $14
)
ON CONFLICT (provider, provider_user_id)
DO UPDATE SET
account_id = EXCLUDED.account_id,
username = EXCLUDED.username,
display_name = EXCLUDED.display_name,
primary_email = EXCLUDED.primary_email,
access_token_encrypted = EXCLUDED.access_token_encrypted,
refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
token_expires_at = EXCLUDED.token_expires_at,
scopes = EXCLUDED.scopes,
profile_json = EXCLUDED.profile_json,
updated_at = EXCLUDED.updated_at
RETURNING *
`,
[
id,
input.accountId,
input.provider,
input.providerUserId,
nullable(input.username),
nullable(input.displayName),
nullable(input.primaryEmail),
nullable(input.accessTokenEncrypted),
nullable(input.refreshTokenEncrypted),
nullable(input.tokenExpiresAt),
input.scopes.join(" "),
JSON.stringify(input.profileJson),
input.createdAt,
input.updatedAt,
]
);
return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity"));
}
async listLinkedIdentitiesForAccount(accountId: string): Promise<LinkedIdentityInput[]> {
const result = await this.pool.query<LinkedIdentityRow>(
`
SELECT *
FROM linked_identities
WHERE account_id = $1
ORDER BY created_at ASC
`,
[accountId]
);
return result.rows.map((row) => {
const linkedIdentity: LinkedIdentityInput = {
provider: row.provider,
providerUserId: row.provider_user_id,
};
if (row.username) {
linkedIdentity.username = row.username;
}
if (row.display_name) {
linkedIdentity.displayName = row.display_name;
}
return linkedIdentity;
});
}
async createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant> {
const id = input.id ?? randomUUID();
const result = await this.pool.query<GrantRow>(
`
INSERT INTO capability_grants (
id, account_id, scope_type, scope_id, capability,
source, status, expires_at, note, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`,
[
id,
input.accountId,
input.scopeType,
nullable(input.scopeId),
input.capability,
input.source,
input.status,
nullable(input.expiresAt),
nullable(input.note),
input.createdAt,
input.updatedAt,
]
);
return mapGrantRow(expectRow(result.rows[0], "createCapabilityGrant"));
}
async listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]> {
const result = await this.pool.query<GrantRow>(
`
SELECT *
FROM capability_grants
WHERE account_id = $1
AND status = 'active'
AND (expires_at IS NULL OR expires_at > $3)
AND (
scope_type = 'global'
OR (scope_type = 'app' AND scope_id = $2)
)
ORDER BY created_at ASC
`,
[accountId, appSlug, at]
);
return result.rows.map(mapGrantRow);
}
async createSession(input: CreateSessionInput): Promise<StoredSession> {
const result = await this.pool.query<SessionRow>(
`
INSERT INTO sessions (
id, account_id, app_slug, created_at, last_seen_at,
expires_at, claims_json, access_revision
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id)
DO UPDATE SET
last_seen_at = EXCLUDED.last_seen_at,
expires_at = EXCLUDED.expires_at,
claims_json = EXCLUDED.claims_json,
access_revision = EXCLUDED.access_revision
WHERE sessions.account_id = EXCLUDED.account_id
AND sessions.app_slug = EXCLUDED.app_slug
AND sessions.access_revision <= EXCLUDED.access_revision
2026-04-26 12:50:07 +01:00
RETURNING *
`,
[
input.id,
input.accountId,
input.appSlug,
input.createdAt,
input.lastSeenAt,
input.expiresAt,
JSON.stringify(input.claimsJson),
input.accessRevision,
]
);
return mapSessionRow(expectRow(result.rows[0], "createSession"));
}
async findSession(appSlug: AppSlug, sessionId: string): Promise<StoredSession | null> {
const result = await this.pool.query<SessionRow>(
"SELECT * FROM sessions WHERE app_slug = $1 AND id = $2",
[appSlug, sessionId],
);
return result.rowCount ? mapSessionRow(expectRow(result.rows[0], "findSession")) : null;
}
async revokeSession(
appSlug: AppSlug,
sessionId: string,
accountId: string,
expectedAccessRevision: number,
at: string,
): Promise<StoredSession | null> {
const result = await this.pool.query<SessionRow>(
`UPDATE sessions
SET last_seen_at = $5, expires_at = $5, access_revision = access_revision + 1
WHERE app_slug = $1 AND id = $2 AND account_id = $3 AND access_revision = $4
RETURNING *`,
[appSlug, sessionId, accountId, expectedAccessRevision, at],
);
return result.rowCount ? mapSessionRow(expectRow(result.rows[0], "revokeSession")) : null;
}
async createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt> {
const handle = input.handle ?? randomUUID();
const result = await this.pool.query<AuthAttemptRow>(
`INSERT INTO auth_attempts (
handle, app_slug, provider, mode, return_to, status, created_at, expires_at
) VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7) RETURNING *`,
[handle, input.appSlug, input.provider, input.mode, input.returnTo, input.createdAt, input.expiresAt],
);
return mapAuthAttemptRow(expectRow(result.rows[0], "createAuthAttempt"));
}
async findAuthAttempt(appSlug: AppSlug, handle: string): Promise<StoredAuthAttempt | null> {
const result = await this.pool.query<AuthAttemptRow>(
"SELECT * FROM auth_attempts WHERE app_slug = $1 AND handle = $2",
[appSlug, handle],
);
return result.rowCount ? mapAuthAttemptRow(expectRow(result.rows[0], "findAuthAttempt")) : null;
}
async updateAuthAttempt(
appSlug: AppSlug,
handle: string,
update: { status: HeimdallAuthAttemptStatus; at: string; denialCode?: string },
): Promise<StoredAuthAttempt | null> {
const result = await this.pool.query<AuthAttemptRow>(
`UPDATE auth_attempts SET
status = $3,
completed_at = CASE WHEN $3 = 'completed' THEN $4 ELSE completed_at END,
consumed_at = CASE WHEN $3 = 'consumed' THEN $4 ELSE consumed_at END,
denial_code = COALESCE($5, denial_code)
WHERE app_slug = $1 AND handle = $2
AND (
status = $3 OR
(status = 'pending' AND $3 IN ('completed', 'denied', 'expired')) OR
(status = 'completed' AND $3 = 'consumed')
)
RETURNING *`,
[appSlug, handle, update.status, update.at, nullable(update.denialCode)],
);
if (result.rowCount) return mapAuthAttemptRow(expectRow(result.rows[0], "updateAuthAttempt"));
return await this.findAuthAttempt(appSlug, handle);
}
async createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt> {
const result = await this.pool.query<PrivateCommandReceiptRow>(
`INSERT INTO private_command_receipts (
app_slug, idempotency_key, request_fingerprint, status,
content_schema, envelope_base64, created_at, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (app_slug, idempotency_key) DO NOTHING
RETURNING *`,
[
input.appSlug,
input.idempotencyKey,
input.requestFingerprint,
input.status,
input.contentSchema,
input.envelopeBase64,
input.createdAt,
input.expiresAt,
],
);
const existing = result.rowCount
? mapPrivateCommandReceiptRow(expectRow(result.rows[0], "createPrivateCommandReceipt"))
: await this.findPrivateCommandReceipt(input.appSlug, input.idempotencyKey);
if (!existing) throw new Error("Private command receipt conflict returned no record.");
if (existing.requestFingerprint !== input.requestFingerprint) {
throw new Error("Idempotency key was reused with different command content.");
}
return existing;
}
async findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null> {
const result = await this.pool.query<PrivateCommandReceiptRow>(
"SELECT * FROM private_command_receipts WHERE app_slug = $1 AND idempotency_key = $2",
[appSlug, idempotencyKey],
);
return result.rowCount
? mapPrivateCommandReceiptRow(expectRow(result.rows[0], "findPrivateCommandReceipt"))
: null;
}
async createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion> {
// The code is always minted here; nothing upstream may choose it.
const code = randomUUID();
const result = await this.pool.query<AuthCompletionRow>(
`
INSERT INTO auth_completions (
code, attempt_id, app_slug, provider, mode, account_id, session_id,
return_to, payload_json, created_at, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`,
[
code,
input.attemptId ?? null,
input.appSlug,
input.provider,
input.mode,
input.accountId,
input.sessionId,
input.returnTo,
JSON.stringify(input.payloadJson),
input.createdAt,
input.expiresAt,
]
);
return mapAuthCompletionRow(expectRow(result.rows[0], "createAuthCompletion"));
}
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
const result = await this.pool.query<AuthCompletionRow>(
`
UPDATE auth_completions
SET consumed_at = $3
WHERE code = $1
AND app_slug = $2
AND consumed_at IS NULL
AND expires_at > $3
RETURNING *
`,
[code, appSlug, at]
);
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletion")) : null;
}
async consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise<StoredAuthCompletion | null> {
const result = await this.pool.query<AuthCompletionRow>(
`
UPDATE auth_completions
SET consumed_at = $3
WHERE attempt_id = $1
AND app_slug = $2
AND consumed_at IS NULL
AND expires_at > $3
RETURNING *
`,
[attemptId, appSlug, at]
);
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null;
}
2026-04-26 12:50:07 +01:00
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
await this.pool.query(
`
INSERT INTO entitlement_snapshots (
id, account_id, provider, scope, evaluated_at,
is_allowed, reason_code, reason_detail, raw_summary_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (account_id, provider, scope)
DO UPDATE SET
evaluated_at = EXCLUDED.evaluated_at,
is_allowed = EXCLUDED.is_allowed,
reason_code = EXCLUDED.reason_code,
reason_detail = EXCLUDED.reason_detail,
raw_summary_json = EXCLUDED.raw_summary_json
`,
[
randomUUID(),
input.accountId,
input.provider,
input.scope,
input.evaluatedAt,
input.isAllowed,
input.reasonCode,
nullable(input.reasonDetail),
JSON.stringify(input.rawSummaryJson),
]
);
}
async createAuditEvent(input: CreateAuditEventInput): Promise<void> {
await this.pool.query(
`
INSERT INTO audit_events (
id, account_id, session_id, app_slug, event_type,
event_payload_json, created_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`,
[
input.id ?? randomUUID(),
nullable(input.accountId),
nullable(input.sessionId),
nullable(input.appSlug),
input.eventType,
JSON.stringify(input.eventPayloadJson),
input.createdAt,
]
);
}
}
export function createPostgresStore(databaseUrl: string): PostgresStore {
2026-04-26 12:50:07 +01:00
const pool = new Pool({
connectionString: databaseUrl,
});
return new PostgresStore(pool);
}