/** * T0 (v0.8 MVP) — flat account store + manual provisioning. Deliberately minimal: no * immutable-record ceremony (that arrives in CP1/T3-T4, which RETIRE this shim). It exists * only to gate the café demo. All INV5 guarantees (hashes-at-rest, constant-time compare, * raw-secret-returned-once) are honoured and re-shipped properly in CP1. * * Backing store is injectable. The default is in-memory; production points it at the * `db/flat.sqlite.sql` schema (SQLite) — an integration seam, not exercised in unit tests. */ import { randomUUID, randomBytes } from 'node:crypto' import { hashSecret, verifySecret } from './util/hash.js' export interface FlatAccount { readonly accountId: string readonly subdomain: string readonly agentTokenHash: string readonly clientTokenHash: string } export interface FlatBackend { insert(row: FlatAccount): Promise bySubdomain(subdomain: string): Promise } /** In-memory backend (default). SQLite backend is the production swap (see db/flat.sqlite.sql). */ export function inMemoryFlatBackend(): FlatBackend { const rows = new Map() return { async insert(row) { if (rows.has(row.subdomain)) throw new Error(`subdomain already provisioned: ${row.subdomain}`) rows.set(row.subdomain, row) }, async bySubdomain(subdomain) { return rows.get(subdomain) ?? null }, } } export interface FlatStore { provisionFlat(subdomain: string): Promise<{ accountId: string; agentToken: string; clientToken: string }> lookupBySubdomain(subdomain: string): Promise verifyAgentToken(subdomain: string, raw: string): Promise } const newToken = (): string => randomBytes(32).toString('base64url') export function createFlatStore(backend: FlatBackend = inMemoryFlatBackend()): FlatStore { return { async provisionFlat(subdomain) { const agentToken = newToken() const clientToken = newToken() const row: FlatAccount = { accountId: randomUUID(), subdomain, agentTokenHash: hashSecret(agentToken), // hash at rest — raw discarded (INV5) clientTokenHash: hashSecret(clientToken), } await backend.insert(row) // Raw tokens returned exactly ONCE at mint time; never persisted. return { accountId: row.accountId, agentToken, clientToken } }, async lookupBySubdomain(subdomain) { return backend.bySubdomain(subdomain) }, async verifyAgentToken(subdomain, raw) { const row = await backend.bySubdomain(subdomain) if (row === null) return false return verifySecret(raw, row.agentTokenHash) // constant-time compare of hashes }, } }