/** * The broker side of the hook-owned-state accessor (hook-owned-state tasks * 3.1-3.4, design option C). * * A hook runs out of process and reaches its own persistent state only through * the wire (see `hook-protocol.ts` `StoreCallFrame`). This module is the thing * those frames land on: two stores over `(db, moduleId, manifest, masterKey)` * — `secrets`, validated against `manifest.secrets.declares` and encrypted, * and `config`, validated against `variables.owns` where `source: hook` and * stored plaintext in `module_configs`. * * Validation is against the MANIFEST, not the database. The whole point * (design D2) is that a typo in a hook is a loud error naming the module and * the declared set, never a phantom row some later reader mistakes for state. * * The transaction is applied inside one `db.transaction` (same mechanism as * IPAM allocation in `variables/context.ts`). The buffering half lives * child-side in the runner proxy, so a throwing `fn` sends no frame at all; * what arrives here is a finished operation list that either applies whole or * throws whole. * * Execution functions (Rule 10.1) — each method performs its own storage * effects; construction is pure planning. */ import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import type { DbClient } from '../db/client'; import { moduleConfigs, modules, secrets } from '../db/schema'; import { type EncryptedSecret, decryptSecret, encryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { deleteModuleConfig, upsertModuleConfig } from '../services/module-config'; import type { BufferedStoreOp } from './hook-protocol'; /** * The manifest surface the stores validate against, read out of * `modules.manifestData` and narrowed. Everything else in the manifest is * irrelevant here — this is deliberately a projection, not a parse of the * whole schema, so a manifest feature the stores never read cannot break a * hook run. */ const StoreManifestSchema = z.object({ secrets: z .object({ declares: z.array(z.object({ name: z.string() }).passthrough()).optional() }) .optional(), variables: z .object({ owns: z .array(z.object({ name: z.string(), source: z.string().optional() }).passthrough()) .optional(), }) .optional(), }); /** The two stores a hook may touch, keyed as the wire names them. */ export interface HookStores { readonly secrets: HookStoreBackend; readonly config: HookStoreBackend; /** Diagnostic mirror of what each store validates against. */ readonly declaredSecretNames: string[]; readonly declaredConfigNames: string[]; } /** * One store's answering half. Shaped like `HookStore` from * `@celilo/capabilities` except that `transaction` takes the operation list * the child buffered — the buffering happens there, the atomicity here. */ export interface HookStoreBackend { get(name: string): Promise; set(name: string, value: string): Promise; delete(name: string): Promise; applyTransaction(ops: readonly BufferedStoreOp[]): Promise; } /** * Throw for an undeclared name, naming the module and the declared set * (task 3.4 — ERROR, not warning, design D2). */ function undeclared( moduleId: string, kind: 'secret' | 'hook-owned config', name: string, declared: string[], ): Error { const declaredList = declared.length > 0 ? declared.join(', ') : '(none)'; const where = kind === 'secret' ? `Add '${name}' to secrets.declares in the module's manifest.yml.` : `Add '${name}' to variables.owns with source: hook in the module's manifest.yml.`; return new Error( `Module '${moduleId}' has no declared ${kind} '${name}'. Declared ${kind} names: ${declaredList}. ${where}`, ); } /** Secrets store: encrypted rows in the `secrets` table, keyed `(moduleId, name)`. */ function createSecretsStore(db: DbClient, moduleId: string, declared: string[]): HookStoreBackend { const assertDeclared = (name: string) => { if (!declared.includes(name)) throw undeclared(moduleId, 'secret', name, declared); }; const upsert = (name: string, encrypted: EncryptedSecret) => { const existing = db .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .get(); if (existing) { db.update(secrets) .set({ encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, updatedAt: new Date(), }) .where(eq(secrets.id, existing.id)) .run(); } else { db.insert(secrets) .values({ moduleId, name, ...encrypted }) .run(); } }; return { async get(name) { assertDeclared(name); const row = db .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .get(); if (!row) return undefined; const masterKey = await getOrCreateMasterKey(); return decryptSecret( { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag }, masterKey, ); }, async set(name, value) { assertDeclared(name); const masterKey = await getOrCreateMasterKey(); upsert(name, encryptSecret(value, masterKey)); }, async delete(name) { assertDeclared(name); db.delete(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .run(); }, async applyTransaction(ops) { // Validate EVERY name before writing ANY row: a batch with one undeclared // name must discard whole, not half-apply then throw (task 3.7). for (const entry of ops) assertDeclared(entry.name); const masterKey = await getOrCreateMasterKey(); db.transaction((tx) => { for (const entry of ops) { if (entry.op === 'set') { upsertTx( tx as unknown as DbClient, entry.name, encryptSecret(entry.value ?? '', masterKey), ); } else { tx.delete(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, entry.name))) .run(); } } }); }, }; function upsertTx(tx: DbClient, name: string, encrypted: EncryptedSecret): void { const existing = tx .select() .from(secrets) .where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, name))) .get(); if (existing) { tx.update(secrets) .set({ encryptedValue: encrypted.encryptedValue, iv: encrypted.iv, authTag: encrypted.authTag, updatedAt: new Date(), }) .where(eq(secrets.id, existing.id)) .run(); } else { tx.insert(secrets) .values({ moduleId, name, ...encrypted }) .run(); } } } /** Config store: plaintext rows in `module_configs`, `source: hook`. */ function createConfigStore(db: DbClient, moduleId: string, declared: string[]): HookStoreBackend { const assertDeclared = (name: string) => { if (!declared.includes(name)) { throw undeclared(moduleId, 'hook-owned config', name, declared); } }; return { async get(name) { assertDeclared(name); const row = db .select() .from(moduleConfigs) .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, name))) .get(); // Hook-owned rows are always written through `set`, so `valueJson` holds // a JSON string literal of exactly what the hook sent. Absent means never // written; null valueJson is a legacy row shape no hook-owned key can // have (the source column did not exist when those were written). if (!row?.valueJson) return undefined; const parsed: unknown = JSON.parse(row.valueJson); return typeof parsed === 'string' ? parsed : JSON.stringify(parsed); }, async set(name, value) { assertDeclared(name); upsertModuleConfig(db, moduleId, name, value, 'hook'); }, async delete(name) { assertDeclared(name); deleteModuleConfig(db, moduleId, name); }, async applyTransaction(ops) { for (const entry of ops) assertDeclared(entry.name); db.transaction((tx) => { // Same narrowing the secrets store applies below: drizzle hands the // callback a transaction handle, and module-config's writers accept // the full client it derives from. const txDb = tx as unknown as DbClient; for (const entry of ops) { if (entry.op === 'set') { upsertModuleConfig(txDb, moduleId, entry.name, entry.value ?? '', 'hook'); } else { deleteModuleConfig(txDb, moduleId, entry.name); } } }); }, }; } /** * Build both stores for a module from its stored manifest. * * The master key is NOT touched here — it loads lazily inside the operations * that need it, so building stores for a hook that never writes a secret has * no key-file side effect. * * Execution function — reads the module row. Throws when the module row or its * manifest is missing, because a hook for a module celilo cannot describe is a * run that cannot be attributed. */ export async function createHookStores(db: DbClient, moduleId: string): Promise { const module = db.select().from(modules).where(eq(modules.id, moduleId)).get(); if (!module) { throw new Error(`Cannot build hook stores: module '${moduleId}' not found.`); } const parsed = StoreManifestSchema.safeParse(module.manifestData); if (!parsed.success) { throw new Error( `Cannot build hook stores for '${moduleId}': stored manifest does not match the expected shape (${parsed.error.issues[0]?.message ?? 'invalid'}).`, ); } const declaredSecretNames = (parsed.data.secrets?.declares ?? []).map((s) => s.name); const declaredConfigNames = (parsed.data.variables?.owns ?? []) .filter((v) => v.source === 'hook') .map((v) => v.name); return { secrets: createSecretsStore(db, moduleId, declaredSecretNames), config: createConfigStore(db, moduleId, declaredConfigNames), declaredSecretNames, declaredConfigNames, }; }