diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 007d2d8..485630a 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -13,7 +13,8 @@ import { } from './repos-employees' import { assignModule, createModule, getClientModule, getModule, listClientModules, - listClientsByModule, listModules, listPrices, revealModulePassword, setModulePassword, + listClientsByModule, listModules, listPrices, revealModulePassword, revealModuleSecret, + setModuleFieldValues, setModulePassword, setModuleSecret, setPrice, updateClientModule, updateModule, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch, type PriceInput, @@ -562,6 +563,46 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) + // D21: module-defined NON-secret field values — staff-editable like other client_module data. + r.put('/client-modules/:id/fields', requireAuth, async (req, res) => { + const id = String(req.params['id'] ?? '') + if ((await getClientModule(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Client module not found' }); return + } + try { + const values = (req.body as { values?: Record }).values ?? {} + res.json({ ok: true, clientModule: await setModuleFieldValues(db, staffId(res), id, values) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // D21: set/clear one secret field — managerial, encrypted, audited (plaintext never logged). + r.post('/client-modules/:id/secrets', requireAuth, async (req, res) => { + const viewer = res.locals['staff'] as { id: string; role: string } + if (!isManagerial(viewer.role)) { + res.status(403).json({ ok: false, error: 'Owner or manager only' }); return + } + try { + const { key, value } = req.body as { key?: unknown; value?: unknown } + if (typeof key !== 'string' || typeof value !== 'string') throw new Error('key and value must be strings') + res.json({ ok: true, clientModule: await setModuleSecret(db, viewer.id, String(req.params['id'] ?? ''), key, value, gmail().keyHex) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/client-modules/:id/reveal-secret', requireAuth, async (req, res) => { + const viewer = res.locals['staff'] as { id: string; role: string } + if (!isManagerial(viewer.role)) { + res.status(403).json({ ok: false, error: 'Owner or manager only' }); return + } + try { + const key = (req.body as { key?: unknown }).key + if (typeof key !== 'string') throw new Error('key must be a string') + res.json({ ok: true, value: await revealModuleSecret(db, viewer.id, String(req.params['id'] ?? ''), key, gmail().keyHex) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) // ---------- client branches (D20) ---------- r.get('/clients/:id/branches', requireAuth, async (req, res) => { diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 316d437..f7503a7 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -94,7 +94,12 @@ CREATE TABLE IF NOT EXISTS module ( sac TEXT NOT NULL DEFAULT '998313', -- IT services default; CA session confirms per module allowed_kinds TEXT NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]', multi_subscription INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, - quote_content TEXT NOT NULL DEFAULT '[]' -- JSON string[]: "what's included" lines on quotes + quote_content TEXT NOT NULL DEFAULT '[]', -- JSON string[]: "what's included" lines on quotes + -- D21: the module DECLARES its own operational fields (config over code). JSON + -- FieldDef[] { key, label, type, options?, required?, help?, secret?, sort? }. + -- The per-client values live on client_module (field_values + secrets_enc), + -- validated + rendered against this — a new module/field needs no code change. + field_spec TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS module_price_book ( id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', @@ -111,7 +116,12 @@ CREATE TABLE IF NOT EXISTS client_module ( -- (HQ_SECRET_KEY), reveal-only + audited, never in list payloads — like the -- client DB password. details is JSON [{label, value}] free-form key/values. provider TEXT, username TEXT, password_enc TEXT, - details TEXT NOT NULL DEFAULT '[]', remark TEXT + details TEXT NOT NULL DEFAULT '[]', remark TEXT, + -- D21 module-defined fields: field_values is a JSON map { fieldKey: value } for the + -- module's non-secret declared fields; secrets_enc is ONE AES-256-GCM blob over a JSON + -- map { fieldKey: plaintext } for its secret fields (N secrets, one encrypt; reveal + -- audited, never in list payloads). Keys are validated against the module's field_spec. + field_values TEXT NOT NULL DEFAULT '{}', secrets_enc TEXT ); CREATE TABLE IF NOT EXISTS client_branch ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, name TEXT NOT NULL, @@ -311,7 +321,7 @@ function migrate(db: SqliteRaw): void { } // D20 per-service operational data on client_module (APEX parity). const cmCols = db.prepare(`PRAGMA table_info(client_module)`).all() as { name: string }[] - for (const col of ['provider', 'username', 'password_enc', 'remark']) { + for (const col of ['provider', 'username', 'password_enc', 'remark', 'secrets_enc']) { if (!cmCols.some((c) => c.name === col)) { db.exec(`ALTER TABLE client_module ADD COLUMN ${col} TEXT`) } @@ -319,6 +329,14 @@ function migrate(db: SqliteRaw): void { if (!cmCols.some((c) => c.name === 'details')) { db.exec(`ALTER TABLE client_module ADD COLUMN details TEXT NOT NULL DEFAULT '[]'`) } + // D21 module-defined field schema. + if (!cmCols.some((c) => c.name === 'field_values')) { + db.exec(`ALTER TABLE client_module ADD COLUMN field_values TEXT NOT NULL DEFAULT '{}'`) + } + const modCols2 = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[] + if (!modCols2.some((c) => c.name === 'field_spec')) { + db.exec(`ALTER TABLE module ADD COLUMN field_spec TEXT NOT NULL DEFAULT '[]'`) + } rebuildStaffUserRoleCheck(db) rebuildReminderRuleKindCheck(db) } diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 3f73658..3dfa859 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -207,6 +207,14 @@ CREATE TABLE IF NOT EXISTS ticket ( created_by text NOT NULL, created_at text NOT NULL, source text NOT NULL DEFAULT 'hq' ); +`, + }, + { + id: '003-module-field-schema', + sql: ` +ALTER TABLE module ADD COLUMN IF NOT EXISTS field_spec text NOT NULL DEFAULT '[]'; +ALTER TABLE client_module ADD COLUMN IF NOT EXISTS field_values text NOT NULL DEFAULT '{}'; +ALTER TABLE client_module ADD COLUMN IF NOT EXISTS secrets_enc text; `, }, ] diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index ad2de1d..f97f7ad 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -9,16 +9,59 @@ export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage' const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage'] +/** + * A field a module declares (D21). `secret: true` fields are stored encrypted + * (client_module.secrets_enc) and are reveal-only; everything else lands in + * client_module.field_values. `type` drives the UI input + light validation. + */ +export type FieldType = 'text' | 'secret' | 'date' | 'number' | 'select' | 'boolean' +export interface FieldDef { + key: string; label: string; type: FieldType + options?: string[] // for type 'select' + required?: boolean; help?: string; sort?: number +} +const FIELD_TYPES: FieldType[] = ['text', 'secret', 'date', 'number', 'select', 'boolean'] +const KEY_RE = /^[a-z][a-z0-9_]*$/ + +/** Validate a module's declared field list — keys unique + slug-shaped, types known. */ +export function validateFieldSpec(spec: unknown): FieldDef[] { + if (!Array.isArray(spec)) throw new Error('field_spec must be an array of field definitions') + const seen = new Set() + return spec.map((raw, i) => { + const f = raw as Partial + if (typeof f.key !== 'string' || !KEY_RE.test(f.key)) { + throw new Error(`field #${i + 1}: key must be lower_snake_case (got ${String(f.key)})`) + } + if (seen.has(f.key)) throw new Error(`duplicate field key: ${f.key}`) + seen.add(f.key) + if (typeof f.label !== 'string' || f.label.trim() === '') throw new Error(`field ${f.key}: label required`) + if (typeof f.type !== 'string' || !FIELD_TYPES.includes(f.type)) { + throw new Error(`field ${f.key}: type must be one of ${FIELD_TYPES.join(', ')}`) + } + if (f.type === 'select' && (!Array.isArray(f.options) || f.options.length === 0)) { + throw new Error(`field ${f.key}: select needs a non-empty options list`) + } + return { + key: f.key, label: f.label.trim(), type: f.type, + ...(f.options !== undefined ? { options: f.options } : {}), + ...(f.required !== undefined ? { required: !!f.required } : {}), + ...(f.help !== undefined ? { help: String(f.help) } : {}), + ...(f.sort !== undefined ? { sort: Number(f.sort) } : {}), + } + }) +} + export interface Module { id: string; code: string; name: string; sac: string allowedKinds: Kind[]; multiSubscription: boolean; active: boolean quoteContent: string[] // optional "what's included" lines that flow onto quotes + fieldSpec: FieldDef[] // D21: the operational fields this module declares } interface ModuleRow { id: string; code: string; name: string; sac: string allowed_kinds: string; multi_subscription: number; active: number - quote_content: string + quote_content: string; field_spec: string } function toModule(r: ModuleRow): Module { @@ -28,12 +71,14 @@ function toModule(r: ModuleRow): Module { multiSubscription: r.multi_subscription === 1, active: r.active === 1, quoteContent: JSON.parse(r.quote_content) as string[], + fieldSpec: JSON.parse(r.field_spec ?? '[]') as FieldDef[], } } export interface ModuleInput { code: string; name: string; sac?: string allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[] + fieldSpec?: FieldDef[] } export async function getModule(db: DB, id: string): Promise { @@ -52,12 +97,13 @@ export async function createModule(db: DB, userId: string, input: ModuleInput): if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`) } const id = uuidv7() + const fieldSpec = input.fieldSpec !== undefined ? validateFieldSpec(input.fieldSpec) : [] await db.run( - `INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content, field_spec) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, id, input.code, input.name, input.sac ?? '998313', JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0, - JSON.stringify(input.quoteContent ?? []), + JSON.stringify(input.quoteContent ?? []), JSON.stringify(fieldSpec), ) const mod = (await getModule(db, id))! await writeAudit(db, userId, 'create', 'module', id, undefined, mod) @@ -67,6 +113,7 @@ export async function createModule(db: DB, userId: string, input: ModuleInput): export interface ModulePatch { name?: string; sac?: string; allowedKinds?: Kind[] multiSubscription?: boolean; active?: boolean; quoteContent?: string[] + fieldSpec?: FieldDef[] } export async function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Promise { @@ -85,6 +132,7 @@ export async function updateModule(db: DB, userId: string, id: string, patch: Mo if (patch.multiSubscription !== undefined) { sets.push('multi_subscription=?'); args.push(patch.multiSubscription ? 1 : 0) } if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } if (patch.quoteContent !== undefined) { sets.push('quote_content=?'); args.push(JSON.stringify(patch.quoteContent)) } + if (patch.fieldSpec !== undefined) { sets.push('field_spec=?'); args.push(JSON.stringify(validateFieldSpec(patch.fieldSpec))) } if (sets.length > 0) { args.push(id) await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args) @@ -177,6 +225,10 @@ export interface ClientModule { details: ServiceDetail[]; remark: string | null /** True when an encrypted portal password is stored (UI shows •••• + Reveal). */ hasPassword: boolean + /** D21 module-defined field values (non-secret), keyed by the module's field keys. */ + fieldValues: Record + /** D21: which secret fields have a value stored (the ciphertext never leaves the repo). */ + secretKeys: string[] } interface ClientModuleRow { @@ -186,9 +238,19 @@ interface ClientModuleRow { next_renewal: string | null; active: number provider: string | null; username: string | null; password_enc: string | null details: string; remark: string | null + field_values: string; secrets_enc: string | null } function toClientModule(r: ClientModuleRow): ClientModule { + // secretKeys is derived WITHOUT decrypting: the blob is a JSON map whose keys we + // never need the value of here, but the ciphertext is opaque — so we track set-ness + // via a plaintext key index kept alongside. To avoid a second column, the encrypted + // map is prefixed with its key list in clear (keys are not secret; values are). + let secretKeys: string[] = [] + if (r.secrets_enc !== null) { + const sep = r.secrets_enc.indexOf('|') + if (sep > 0) secretKeys = r.secrets_enc.slice(0, sep).split(',').filter((k) => k !== '') + } return { id: r.id, clientId: r.client_id, moduleId: r.module_id, status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition, @@ -197,6 +259,8 @@ function toClientModule(r: ClientModuleRow): ClientModule { provider: r.provider, username: r.username, details: JSON.parse(r.details) as ServiceDetail[], remark: r.remark, hasPassword: r.password_enc !== null, // the ciphertext itself never leaves the repo + fieldValues: JSON.parse(r.field_values ?? '{}') as Record, + secretKeys, } } @@ -411,3 +475,102 @@ export async function revealModulePassword(db: DB, userId: string, id: string, k await writeAudit(db, userId, 'reveal_module_password', 'client_module', id) return plain } + +// ---------- D21: module-defined field values + multi-secret store ---------- + +// secrets_enc is stored as `keylist|ciphertext`: the clear comma-joined key list (keys are +// not secret) followed by the AES-GCM blob over the JSON { key: plaintext } map. The prefix +// lets the UI show which secrets are set without ever decrypting. +function packSecrets(map: Record, keyHex: string): string | null { + const keys = Object.keys(map) + if (keys.length === 0) return null + return `${keys.join(',')}|${encrypt(JSON.stringify(map), keyHex)}` +} +function unpackSecrets(blob: string | null, keyHex: string): Record { + if (blob === null) return {} + const sep = blob.indexOf('|') + if (sep < 0) return {} + return JSON.parse(decrypt(blob.slice(sep + 1), keyHex)) as Record +} + +/** The module's field definition for a key (or undefined). */ +async function fieldDefFor(db: DB, moduleId: string, key: string): Promise { + const mod = await getModule(db, moduleId) + return mod?.fieldSpec.find((f) => f.key === key) +} + +/** Light per-type coercion/validation; returns the value to store (string). */ +function coerceFieldValue(def: FieldDef, value: string): string { + const v = value.trim() + if (v === '') return '' + if (def.type === 'number' && !Number.isFinite(Number(v))) throw new Error(`${def.label}: must be a number`) + if (def.type === 'boolean' && !['true', 'false'].includes(v)) throw new Error(`${def.label}: must be true or false`) + if (def.type === 'select' && !(def.options ?? []).includes(v)) throw new Error(`${def.label}: must be one of ${(def.options ?? []).join(', ')}`) + if (def.type === 'date' && !/^\d{4}-\d{2}-\d{2}$/.test(v)) throw new Error(`${def.label}: must be YYYY-MM-DD`) + return v +} + +/** + * Merge module-defined NON-secret field values (D21). Keys must be declared in the + * module's field_spec and not be secret fields; '' clears a value. Audited. + */ +export async function setModuleFieldValues( + db: DB, userId: string, id: string, values: Record, +): Promise { + return db.transaction(async () => { + const before = await getClientModule(db, id) + if (before === null) throw new Error('Client module not found') + const merged = { ...before.fieldValues } + for (const [key, raw] of Object.entries(values)) { + const def = await fieldDefFor(db, before.moduleId, key) + if (def === undefined) throw new Error(`Unknown field '${key}' for this module`) + if (def.type === 'secret') throw new Error(`Field '${key}' is a secret — use the reveal/set-secret path`) + const v = coerceFieldValue(def, raw) + if (v === '') delete merged[key] + else merged[key] = v + } + await db.run(`UPDATE client_module SET field_values=? WHERE id=?`, JSON.stringify(merged), id) + const after = (await getClientModule(db, id))! + await writeAudit(db, userId, 'update', 'client_module', id, before, after) + return after + }) +} + +/** + * Set (or clear, with '') one secret field's value — AES-256-GCM at rest (D21). + * Key must be a `secret` field in the module's field_spec. Owner/manager-gated at the + * route; the plaintext never appears in audit rows. Empty keyHex fails loudly. + */ +export async function setModuleSecret( + db: DB, userId: string, id: string, key: string, plain: string, keyHex: string, +): Promise { + return db.transaction(async () => { + const beforeRow = await db.get<{ module_id: string; secrets_enc: string | null }>( + `SELECT module_id, secrets_enc FROM client_module WHERE id=?`, id, + ) + if (beforeRow === undefined) throw new Error('Client module not found') + const def = await fieldDefFor(db, beforeRow.module_id, key) + if (def === undefined || def.type !== 'secret') throw new Error(`Field '${key}' is not a secret field of this module`) + if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a secret unencrypted`) + const before = (await getClientModule(db, id))! + const map = unpackSecrets(beforeRow.secrets_enc, keyHex) + if (plain.trim() === '') delete map[key] + else map[key] = plain + await db.run(`UPDATE client_module SET secrets_enc=? WHERE id=?`, packSecrets(map, keyHex), id) + await writeAudit(db, userId, 'set_module_secret', 'client_module', id, undefined, { key, set: plain.trim() !== '' }) + return (await getClientModule(db, id))! + }) +} + +/** Decrypt-and-return one secret field's value — every reveal writes an audit row. */ +export async function revealModuleSecret( + db: DB, userId: string, id: string, key: string, keyHex: string, +): Promise { + const row = await db.get<{ secrets_enc: string | null }>(`SELECT secrets_enc FROM client_module WHERE id=?`, id) + if (row === undefined) throw new Error('Client module not found') + if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`) + const map = unpackSecrets(row.secrets_enc, keyHex) + if (!(key in map)) throw new Error(`No value stored for secret '${key}'`) + await writeAudit(db, userId, 'reveal_module_secret', 'client_module', id, undefined, { key }) + return map[key]! +} diff --git a/apps/hq/test/module-fields.test.ts b/apps/hq/test/module-fields.test.ts new file mode 100644 index 0000000..ab97650 --- /dev/null +++ b/apps/hq/test/module-fields.test.ts @@ -0,0 +1,134 @@ +// apps/hq/test/module-fields.test.ts — D21: module-defined field schema + values + secrets. +import express from 'express' +import { describe, it, expect, afterAll } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { apiRouter } from '../src/api' +import { createStaff } from '../src/auth' +import { createClient } from '../src/repos-clients' +import { + createModule, assignModule, updateModule, getModule, + setModuleFieldValues, setModuleSecret, revealModuleSecret, getClientModule, + validateFieldSpec, +} from '../src/repos-modules' + +const KEY = '33'.repeat(32) + +async function world() { + const db = openDb(':memory:') + await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' }) + const rtgs = await createModule(db, 'u1', { + code: 'RTGS', name: 'RTGS', + fieldSpec: [ + { key: 'ip', label: 'IP address', type: 'text' }, + { key: 'ifsc', label: 'IFSC', type: 'text' }, + { key: 'partner_bank', label: 'Partner bank', type: 'select', options: ['SBI', 'ICICI', 'HDFC'] }, + { key: 'portal_password', label: 'Portal password', type: 'secret' }, + { key: 'go_live', label: 'Go live', type: 'date' }, + ], + }) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: rtgs.id, kind: 'yearly' }) + return { db, c, rtgs, cm } +} + +describe('module field_spec (D21)', () => { + it('validates keys, labels, types and select options', () => { + expect(() => validateFieldSpec([{ key: 'Bad Key', label: 'x', type: 'text' }])).toThrow(/snake_case/) + expect(() => validateFieldSpec([{ key: 'a', label: '', type: 'text' }])).toThrow(/label/) + expect(() => validateFieldSpec([{ key: 'a', label: 'A', type: 'bogus' }])).toThrow(/type/) + expect(() => validateFieldSpec([{ key: 'a', label: 'A', type: 'select' }])).toThrow(/options/) + expect(() => validateFieldSpec([ + { key: 'a', label: 'A', type: 'text' }, { key: 'a', label: 'B', type: 'text' }, + ])).toThrow(/duplicate/) + const ok = validateFieldSpec([{ key: 'ip', label: 'IP', type: 'text', required: true }]) + expect(ok[0]!.key).toBe('ip') + }) + + it('carries field_spec on the module and is editable', async () => { + const { db, rtgs } = await world() + expect((await getModule(db, rtgs.id))!.fieldSpec).toHaveLength(5) + const upd = await updateModule(db, 'u1', rtgs.id, { + fieldSpec: [{ key: 'ip', label: 'IP address', type: 'text' }], + }) + expect(upd.fieldSpec).toHaveLength(1) + }) +}) + +describe('client_module field values + secrets (D21)', () => { + it('stores non-secret values keyed by field, validating type/options; rejects unknown + secret keys', async () => { + const { db, cm } = await world() + const upd = await setModuleFieldValues(db, 'u1', cm.id, { + ip: '192.168.10.8', ifsc: 'SBIN0001234', partner_bank: 'SBI', go_live: '2025-10-13', + }) + expect(upd.fieldValues).toEqual({ ip: '192.168.10.8', ifsc: 'SBIN0001234', partner_bank: 'SBI', go_live: '2025-10-13' }) + await expect(setModuleFieldValues(db, 'u1', cm.id, { partner_bank: 'AXIS' })).rejects.toThrow(/one of/) + await expect(setModuleFieldValues(db, 'u1', cm.id, { go_live: 'nope' })).rejects.toThrow(/YYYY-MM-DD/) + await expect(setModuleFieldValues(db, 'u1', cm.id, { nope: 'x' })).rejects.toThrow(/Unknown field/) + await expect(setModuleFieldValues(db, 'u1', cm.id, { portal_password: 'x' })).rejects.toThrow(/secret/) + // '' clears a value. + const cleared = await setModuleFieldValues(db, 'u1', cm.id, { ifsc: '' }) + expect(cleared.fieldValues['ifsc']).toBeUndefined() + expect(cleared.fieldValues['ip']).toBe('192.168.10.8') // others untouched + }) + + it('encrypts secret fields, tracks which are set without decrypting, reveals audited, never leaks plaintext', async () => { + const { db, cm } = await world() + await expect(setModuleSecret(db, 'u1', cm.id, 'portal_password', 'pw', '')).rejects.toThrow(/HQ_SECRET_KEY/) + await expect(setModuleSecret(db, 'u1', cm.id, 'ip', 'x', KEY)).rejects.toThrow(/not a secret/) + const withSecret = await setModuleSecret(db, 'u1', cm.id, 'portal_password', 's3cr3t', KEY) + expect(withSecret.secretKeys).toEqual(['portal_password']) + expect(JSON.stringify(withSecret)).not.toContain('s3cr3t') + const stored = (await db.get<{ secrets_enc: string }>(`SELECT secrets_enc FROM client_module WHERE id=?`, cm.id))! + expect(stored.secrets_enc).not.toContain('s3cr3t') // encrypted at rest + expect(await revealModuleSecret(db, 'u1', cm.id, 'portal_password', KEY)).toBe('s3cr3t') + // Audit carried only the key + set flag, never the value. + const blob = (await db.all<{ after_json: string | null }>( + `SELECT after_json FROM audit_log WHERE action='set_module_secret'`, + )).map((r) => r.after_json ?? '').join('') + expect(blob).toContain('portal_password') + expect(blob).not.toContain('s3cr3t') + // Clearing removes it. + const cleared = await setModuleSecret(db, 'u1', cm.id, 'portal_password', '', KEY) + expect(cleared.secretKeys).toEqual([]) + await expect(revealModuleSecret(db, 'u1', cm.id, 'portal_password', KEY)).rejects.toThrow(/No value/) + }) +}) + +describe('D21 routes (gating)', () => { + const servers: { close: () => void }[] = [] + afterAll(() => { for (const s of servers) s.close() }) + + it('staff set field values but NOT secrets; owner sets + reveals', async () => { + const { db, cm } = await world() + await createStaff(db, { email: 'staff@t.in', displayName: 'S', role: 'staff', password: 'staff-pass-1' }) + await createStaff(db, { email: 'own@t.in', displayName: 'O', role: 'owner', password: 'owner-pass-1' }) + const app = express(); app.use(express.json()); app.locals['db'] = db + app.use('/api', apiRouter(db, { keyHex: KEY })) + const server = app.listen(0); servers.push(server) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + const tok = async (e: string, p: string) => ((await (await fetch(`${base}/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: e, password: p }), + })).json()) as { token: string }).token + const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` }) + const staff = await tok('staff@t.in', 'staff-pass-1') + const owner = await tok('own@t.in', 'owner-pass-1') + + const fields = await fetch(`${base}/client-modules/${cm.id}/fields`, { + method: 'PUT', headers: H(staff), body: JSON.stringify({ values: { ip: '10.0.0.1' } }), + }) + expect(fields.status).toBe(200) + const denySecret = await fetch(`${base}/client-modules/${cm.id}/secrets`, { + method: 'POST', headers: H(staff), body: JSON.stringify({ key: 'portal_password', value: 'x' }), + }) + expect(denySecret.status).toBe(403) + const setSecret = await fetch(`${base}/client-modules/${cm.id}/secrets`, { + method: 'POST', headers: H(owner), body: JSON.stringify({ key: 'portal_password', value: 'topsecret' }), + }) + expect(setSecret.status).toBe(200) + const reveal = await (await fetch(`${base}/client-modules/${cm.id}/reveal-secret`, { + method: 'POST', headers: H(owner), body: JSON.stringify({ key: 'portal_password' }), + })).json() as { value: string } + expect(reveal.value).toBe('topsecret') + }) +})