import { createHash, randomBytes } from 'node:crypto'; import type { AgentKey, Role } from '@wellformed/shared'; import { newId } from '../engine/ids.js'; import type { ControlDb } from './db.js'; /** * Agent keys. * * The credential an AI assistant presents to reach one workspace. Same storage * discipline as sessions and invites — 32 random bytes, kept only as a SHA-256 * hash, shown once — but long-lived and scoped, which is what makes it usable * by a program that has no browser and no way to answer a password prompt. * * The scope is the point. A session opens every workspace its account belongs * to; a key opens exactly one. So handing an assistant a key is a decision * about one workspace, and revoking it is too. */ /** Marks the secret as ours in a log, a leak scanner or a pasted config file. */ export const AGENT_KEY_PREFIX = 'wf_'; function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } export interface AgentKeyRecord { id: string; accountId: string; workspaceId: string; label: string; ceiling: Role; createdAt: string; lastUsedAt: string | null; expiresAt: string | null; revokedAt: string | null; } interface KeyRow { id: string; account_id: string; workspace_id: string; label: string; ceiling: string; created_at: string; last_used_at: string | null; expires_at: string | null; revoked_at: string | null; } const COLUMNS = 'id, account_id, workspace_id, label, ceiling, created_at, last_used_at, expires_at, revoked_at'; function toRecord(row: KeyRow): AgentKeyRecord { return { id: row.id, accountId: row.account_id, workspaceId: row.workspace_id, label: row.label, ceiling: row.ceiling as Role, createdAt: row.created_at, lastUsedAt: row.last_used_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, }; } export interface NewAgentKey { accountId: string; workspaceId: string; label: string; ceiling: Role; /** Days until it stops working on its own. Omitted means until revoked. */ expiresInDays?: number | null; } export interface IssuedAgentKey { record: AgentKeyRecord; /** Returned once, at creation. Only its hash is stored. */ secret: string; } export function createAgentKey(control: ControlDb, input: NewAgentKey): IssuedAgentKey { const secret = AGENT_KEY_PREFIX + randomBytes(32).toString('base64url'); const id = newId('key'); const expiresAt = input.expiresInDays && input.expiresInDays > 0 ? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60_000).toISOString() : null; control.sqlite .prepare( `INSERT INTO agent_key (id, token_hash, account_id, workspace_id, label, ceiling, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) .run( id, hashToken(secret), input.accountId, input.workspaceId, input.label.trim(), input.ceiling, expiresAt, ); return { record: getAgentKey(control, id)!, secret }; } export function getAgentKey(control: ControlDb, id: string): AgentKeyRecord | null { const row = control.sqlite .prepare<[string], KeyRow>(`SELECT ${COLUMNS} FROM agent_key WHERE id = ?`) .get(id); return row ? toRecord(row) : null; } /** * Resolve a presented secret to a live key. * * Null for unknown, revoked and expired alike — a caller learns only that it * did not work, never which of the three it was. */ export function resolveAgentKey(control: ControlDb, presented: string | undefined): AgentKeyRecord | null { if (!presented || !presented.startsWith(AGENT_KEY_PREFIX)) return null; const row = control.sqlite .prepare<[string], KeyRow>(`SELECT ${COLUMNS} FROM agent_key WHERE token_hash = ?`) .get(hashToken(presented)); if (!row) return null; const record = toRecord(row); if (record.revokedAt) return null; if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now()) return null; return record; } /** Bump `last_used_at`, at most once a minute per key. */ const lastTouch = new Map(); export function touchAgentKey(control: ControlDb, id: string): void { const now = Date.now(); const previous = lastTouch.get(id) ?? 0; if (now - previous < 60_000) return; lastTouch.set(id, now); control.sqlite.prepare(`UPDATE agent_key SET last_used_at = datetime('now') WHERE id = ?`).run(id); } export function revokeAgentKey(control: ControlDb, id: string): boolean { const info = control.sqlite .prepare(`UPDATE agent_key SET revoked_at = datetime('now') WHERE id = ? AND revoked_at IS NULL`) .run(id); return info.changes === 1; } export interface ListOptions { workspaceId: string; /** Restrict to one account's keys. Omitted lists the whole workspace. */ accountId?: string; } /** * List keys, newest first, with the holder's name joined in. * * Revoked keys are dropped rather than shown struck through: a key that no * longer works is not a thing to manage, and the audit trail is where the * history of what it did already lives. */ export function listAgentKeys(control: ControlDb, options: ListOptions): AgentKey[] { const params: unknown[] = [options.workspaceId]; let scope = ''; if (options.accountId) { scope = ' AND k.account_id = ?'; params.push(options.accountId); } return control.sqlite .prepare( `SELECT k.id, k.account_id, k.workspace_id, k.label, k.ceiling, k.created_at, k.last_used_at, k.expires_at, k.revoked_at, a.name AS account_name FROM agent_key k JOIN account a ON a.id = k.account_id WHERE k.workspace_id = ? AND k.revoked_at IS NULL${scope} ORDER BY k.created_at DESC`, ) .all(...params) .map((row) => ({ id: row.id, label: row.label, ceiling: row.ceiling as Role, createdAt: row.created_at, lastUsedAt: row.last_used_at, expiresAt: row.expires_at, accountId: row.account_id, accountName: row.account_name, })); } /** Housekeeping: revoked and long-expired keys are not history worth keeping. */ export function pruneStaleAgentKeys(control: ControlDb): number { const info = control.sqlite .prepare( `DELETE FROM agent_key WHERE revoked_at <= datetime('now', '-30 days') OR expires_at <= datetime('now', '-30 days')`, ) .run(); return info.changes; }