feat(d21): P1+P2 — module-defined field schema (backend)

module.field_spec (FieldDef[]: key/label/type/options/required) declares a
module's own operational fields, validated on create/update. client_module
gains field_values (JSON map of non-secret values, type-checked against the
owning module's spec) + secrets_enc (one AES-GCM blob over a {key:plaintext}
map; the clear key-list prefix lets the UI show which secrets are set without
decrypting). Routes: PUT /client-modules/:id/fields (staff), POST
/client-modules/:id/secrets + /reveal-secret (managerial, audited, plaintext
never logged). Additive over D20 — provider/username/password stay. Both
engines (SCHEMA + migrate guards + pg 003). 11 new tests; module suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 2d4344e098
commit 427ac31c57

@ -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<string, string> }).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) => {

@ -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)
}

@ -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;
`,
},
]

@ -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<string>()
return spec.map((raw, i) => {
const f = raw as Partial<FieldDef>
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<Module | null> {
@ -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<Module> {
@ -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<string, string>
/** 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<string, string>,
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<string, string>, 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<string, string> {
if (blob === null) return {}
const sep = blob.indexOf('|')
if (sep < 0) return {}
return JSON.parse(decrypt(blob.slice(sep + 1), keyHex)) as Record<string, string>
}
/** The module's field definition for a key (or undefined). */
async function fieldDefFor(db: DB, moduleId: string, key: string): Promise<FieldDef | undefined> {
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<string, string>,
): Promise<ClientModule> {
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<ClientModule> {
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<string> {
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]!
}

@ -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')
})
})
Loading…
Cancel
Save