You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/repos-modules.ts

693 lines
32 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { uuidv7 } from '@sims/domain'
import { writeAudit } from './audit'
import { decrypt, encrypt } from './crypto'
import { ensureProjectMilestones } from './repos-milestones'
import type { DB } from './db'
/** Module catalog + dated price book — plain functions over the handle (D12 portable-repo pattern). */
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
sortOrder: number // D25: fixed display order (lower first); owner-editable
}
interface ModuleRow {
id: string; code: string; name: string; sac: string
allowed_kinds: string; multi_subscription: number; active: number
quote_content: string; field_spec: string; sort_order: number
}
function toModule(r: ModuleRow): Module {
return {
id: r.id, code: r.code, name: r.name, sac: r.sac,
allowedKinds: JSON.parse(r.allowed_kinds) as Kind[],
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[],
sortOrder: r.sort_order ?? 100,
}
}
export interface ModuleInput {
code: string; name: string; sac?: string
allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
fieldSpec?: FieldDef[]; sortOrder?: number
}
export async function getModule(db: DB, id: string): Promise<Module | null> {
const row = await db.get<ModuleRow>(`SELECT * FROM module WHERE id=?`, id)
return row === undefined ? null : toModule(row)
}
export async function listModules(db: DB): Promise<Module[]> {
// D25: fixed business display order (sort_order), code as the stable tiebreak.
const rows = await db.all<ModuleRow>(`SELECT * FROM module ORDER BY sort_order, code`)
return rows.map(toModule)
}
export async function createModule(db: DB, userId: string, input: ModuleInput): Promise<Module> {
const kinds = input.allowedKinds ?? ALL_KINDS
for (const k of kinds) {
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, field_spec, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, input.code, input.name, input.sac ?? '998313',
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
JSON.stringify(input.quoteContent ?? []), JSON.stringify(fieldSpec),
input.sortOrder ?? 100,
)
const mod = (await getModule(db, id))!
await writeAudit(db, userId, 'create', 'module', id, undefined, mod)
return mod
}
export interface ModulePatch {
name?: string; sac?: string; allowedKinds?: Kind[]
multiSubscription?: boolean; active?: boolean; quoteContent?: string[]
fieldSpec?: FieldDef[]; sortOrder?: number
}
export async function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Promise<Module> {
const before = await getModule(db, id)
if (before === null) throw new Error('Module not found')
if (patch.allowedKinds !== undefined) {
for (const k of patch.allowedKinds) {
if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`)
}
}
const sets: string[] = []
const args: unknown[] = []
if (patch.name !== undefined) { sets.push('name=?'); args.push(patch.name) }
if (patch.sac !== undefined) { sets.push('sac=?'); args.push(patch.sac) }
if (patch.allowedKinds !== undefined) { sets.push('allowed_kinds=?'); args.push(JSON.stringify(patch.allowedKinds)) }
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 (patch.sortOrder !== undefined) { sets.push('sort_order=?'); args.push(patch.sortOrder) }
if (sets.length > 0) {
args.push(id)
await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args)
}
const after = (await getModule(db, id))!
await writeAudit(db, userId, 'update', 'module', id, before, after)
return after
}
export interface ModulePrice {
id: string; moduleId: string; edition: string; kind: Kind
pricePaise: number; effectiveFrom: string
}
interface PriceRow {
id: string; module_id: string; edition: string; kind: string
price_paise: number; effective_from: string
}
function toPrice(r: PriceRow): ModulePrice {
return {
id: r.id, moduleId: r.module_id, edition: r.edition,
kind: r.kind as Kind, pricePaise: r.price_paise, effectiveFrom: r.effective_from,
}
}
export interface PriceInput {
moduleId: string; edition?: string; kind: Kind; pricePaise: number; effectiveFrom: string
}
export async function setPrice(db: DB, userId: string, input: PriceInput): Promise<void> {
if (!ALL_KINDS.includes(input.kind)) throw new Error(`Unknown kind: ${input.kind}`)
if (!Number.isInteger(input.pricePaise) || input.pricePaise < 0) {
throw new Error('pricePaise must be a non-negative integer (paise)')
}
if ((await getModule(db, input.moduleId)) === null) throw new Error('Module not found')
const id = uuidv7()
const edition = input.edition ?? 'standard'
await db.run(
`INSERT INTO module_price_book (id, module_id, edition, kind, price_paise, effective_from)
VALUES (?, ?, ?, ?, ?, ?)`,
id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom,
)
await writeAudit(db, userId, 'create', 'module_price_book', id, undefined, {
moduleId: input.moduleId, edition, kind: input.kind,
pricePaise: input.pricePaise, effectiveFrom: input.effectiveFrom,
})
}
export async function listPrices(db: DB, moduleId: string): Promise<ModulePrice[]> {
const rows = await db.all<PriceRow>(
`SELECT * FROM module_price_book WHERE module_id=? ORDER BY edition, kind, effective_from`,
moduleId,
)
return rows.map(toPrice)
}
/** Latest effective_from <= onDate wins — the D5 dated-rows philosophy. */
export async function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, onDate: string): Promise<number | null> {
const row = await db.get<{ price_paise: number }>(
`SELECT price_paise FROM module_price_book
WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ?
ORDER BY effective_from DESC LIMIT 1`,
moduleId, kind, edition, onDate,
)
return row === undefined ? null : row.price_paise
}
// ---------- client-module tracking (lifecycle + multi-subscription rule) ----------
export type ClientModuleStatus =
| 'quoted' | 'ordered' | 'installing' | 'installed'
| 'trained' | 'live' | 'expired' | 'cancelled'
const ALL_STATUSES: ClientModuleStatus[] = [
'quoted', 'ordered', 'installing', 'installed', 'trained', 'live', 'expired', 'cancelled',
]
/** Free-form labeled value shown on the module's service card (D20 APEX parity). */
export interface ServiceDetail { label: string; value: string }
export interface ClientModule {
id: string; clientId: string; moduleId: string; status: ClientModuleStatus
kind: Kind; edition: string
installedOn: string | null; completedOn: string | null; trainedOn: string | null
nextRenewal: string | null; active: boolean
/** D20 per-service operational data. The portal password is NOT here — it is
* encrypted at rest and readable only via revealModulePassword (audited). */
provider: string | null; username: string | null
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 {
id: string; client_id: string; module_id: string; status: string
kind: string; edition: string
installed_on: string | null; completed_on: string | null; trained_on: string | null
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,
installedOn: r.installed_on, completedOn: r.completed_on, trainedOn: r.trained_on,
nextRenewal: r.next_renewal, active: r.active === 1,
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,
}
}
function assertDetails(details: ServiceDetail[]): void {
if (!Array.isArray(details)) throw new Error('details must be an array of { label, value }')
for (const d of details) {
if (typeof d?.label !== 'string' || typeof d?.value !== 'string' || d.label.trim() === '') {
throw new Error('Each detail needs a non-empty label and a string value')
}
}
}
export async function getClientModule(db: DB, id: string): Promise<ClientModule | null> {
const row = await db.get<ClientModuleRow>(`SELECT * FROM client_module WHERE id=?`, id)
return row === undefined ? null : toClientModule(row)
}
export async function listClientModules(db: DB, clientId: string): Promise<ClientModule[]> {
const rows = await db.all<ClientModuleRow>(
`SELECT * FROM client_module WHERE client_id=? ORDER BY id`, clientId,
)
return rows.map(toClientModule)
}
// ---------- module directory (D28): every client on a module × its service fields ----------
export interface ModuleDirectoryRow {
cmId: string; clientId: string; clientName: string; clientCode: string
status: string; provider: string | null; username: string | null; hasPassword: boolean
/** Lifecycle / install details (D20) so the roster shows when each client went live. */
kind: string; edition: string
installedOn: string | null; completedOn: string | null; trainedOn: string | null; nextRenewal: string | null
/** The module's declared field values for this client (non-secret; secrets stay server-side). */
fieldValues: Record<string, string>
}
export interface ModuleDirectory {
moduleId: string; code: string; name: string; fieldSpec: FieldDef[]
rows: ModuleDirectoryRow[]; total: number
}
/**
* The operational roster for ONE module: every active client on it with that module's
* field values, so the whole book (all RTGS IPs, all CloudBackup buckets, …) is reviewable
* at once instead of one client at a time. Secret field VALUES never leave the repo — the
* spec tells the UI which are secret; reveal stays on Client 360. Returns all rows (bounded
* by active subscriptions per module — no silent cap). Read-only, writes no audit.
*/
export async function moduleDirectory(db: DB, moduleId: string): Promise<ModuleDirectory | null> {
const mod = await getModule(db, moduleId)
if (mod === null) return null
const rows = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string
status: string; provider: string | null; username: string | null; password_enc: string | null
kind: string; edition: string
installed_on: string | null; completed_on: string | null; trained_on: string | null; next_renewal: string | null
field_values: string
}>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.provider, cm.username, cm.password_enc,
cm.kind, cm.edition, cm.installed_on, cm.completed_on, cm.trained_on, cm.next_renewal,
cm.field_values
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id`,
moduleId,
)
const out: ModuleDirectoryRow[] = rows.map((r) => {
let fv: Record<string, string> = {}
try { fv = JSON.parse(r.field_values) as Record<string, string> } catch { fv = {} }
return {
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
status: r.status, provider: r.provider, username: r.username, hasPassword: r.password_enc !== null,
kind: r.kind, edition: r.edition,
installedOn: r.installed_on, completedOn: r.completed_on, trainedOn: r.trained_on, nextRenewal: r.next_renewal,
fieldValues: fv,
}
})
return { moduleId, code: mod.code, name: mod.name, fieldSpec: mod.fieldSpec, rows: out, total: out.length }
}
// ---------- portals quick-list (D28): every stored login/portal across all modules ----------
export interface PortalRow {
cmId: string; clientId: string; clientName: string; clientCode: string
moduleCode: string; moduleName: string; provider: string | null
username: string | null; hasPassword: boolean
/** URL-valued fields of this subscription (portal/console shortcuts). */
links: { label: string; url: string }[]
}
/**
* Every subscription that has a way in — a stored username, a portal password, or a
* URL-valued field — across ALL modules, so support can reach any client's panel from
* one place. Passwords never leave the repo (hasPassword only); reveal stays on Client
* 360. Read-only, no audit. No silent cap (bounded by active subscriptions).
*/
export async function listPortals(db: DB): Promise<PortalRow[]> {
const rows = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string
mcode: string; mname: string; field_spec: string; provider: string | null
username: string | null; password_enc: string | null; field_values: string
}>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
m.code AS mcode, m.name AS mname, m.field_spec, cm.provider,
cm.username, cm.password_enc, cm.field_values
FROM client_module cm
JOIN client c ON c.id = cm.client_id
JOIN module m ON m.id = cm.module_id
WHERE cm.active = 1
ORDER BY c.name, m.code`,
)
const out: PortalRow[] = []
for (const r of rows) {
let fv: Record<string, string> = {}
let spec: FieldDef[] = []
try { fv = JSON.parse(r.field_values) as Record<string, string> } catch { fv = {} }
try { spec = JSON.parse(r.field_spec ?? '[]') as FieldDef[] } catch { spec = [] }
const links = spec
.filter((f) => f.type !== 'secret')
.map((f) => ({ label: f.label, url: (fv[f.key] ?? '').trim() }))
.filter((l) => /^https?:\/\//i.test(l.url))
const hasLogin = (r.username !== null && r.username !== '') || r.password_enc !== null || links.length > 0
if (!hasLogin) continue
out.push({
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
moduleCode: r.mcode, moduleName: r.mname, provider: r.provider,
username: r.username, hasPassword: r.password_enc !== null, links,
})
}
return out
}
// ---------- module → client roster (spec §10, D-ROSTER) ----------
export interface ModuleClientRow {
/** client_module id — the handle the roster's inline edit/unassign writes through. */
cmId: string
clientId: string; clientName: string; clientCode: string
status: ClientModuleStatus; kind: Kind; edition: string
nextRenewal: string | null
/** Price-book rate for this link's kind/edition on the roster date; null = no price row. */
pricePaise: number | null
}
export interface ModuleClientsPage {
clients: ModuleClientRow[]
total: number; page: number; pageSize: number
/** Sum of price-book rates across ALL active links (not just this page). */
totalPricePaise: number
}
/**
* The reverse lookup: every client on a module (support/impact + revenue lens).
* ACTIVE links only — `active=0` assignments are historical and deliberately
* excluded (stated here, not silently dropped). Paginated with a true total
* (rule 8); price per row via the dated price book on `onDate`.
*/
export async function listClientsByModule(
db: DB, moduleId: string,
opts: { page?: number; pageSize?: number; onDate?: string } = {},
): Promise<ModuleClientsPage> {
const page = Math.max(1, opts.page ?? 1)
const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50))
const onDate = opts.onDate ?? new Date().toISOString().slice(0, 10)
const total = ((await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`, moduleId,
))!).n
const rows = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string
status: string; kind: string; edition: string; next_renewal: string | null
}>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.kind, cm.edition, cm.next_renewal
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id
LIMIT ? OFFSET ?`,
moduleId, pageSize, (page - 1) * pageSize,
)
const clients: ModuleClientRow[] = []
for (const r of rows) {
clients.push({
cmId: r.cm_id,
clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
nextRenewal: r.next_renewal,
pricePaise: await priceOn(db, moduleId, r.kind as Kind, r.edition, onDate),
})
}
// Revenue total spans ALL active links, not the visible page (the footer figure).
const allLinks = await db.all<{ kind: string; edition: string }>(
`SELECT kind, edition FROM client_module WHERE module_id=? AND active=1`, moduleId,
)
let totalPricePaise = 0
for (const l of allLinks) {
totalPricePaise += (await priceOn(db, moduleId, l.kind as Kind, l.edition, onDate)) ?? 0
}
return { clients, total, page, pageSize, totalPricePaise }
}
export interface AssignModuleInput {
clientId: string; moduleId: string; kind: Kind
edition?: string; status?: ClientModuleStatus
}
export async function assignModule(db: DB, userId: string, input: AssignModuleInput): Promise<ClientModule> {
const mod = await getModule(db, input.moduleId)
if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(input.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${input.kind}' (allowed: ${mod.allowedKinds.join(', ')})`)
}
const status = input.status ?? 'quoted'
if (!ALL_STATUSES.includes(status)) throw new Error(`Unknown status: ${status}`)
if (!mod.multiSubscription) {
const existing = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM client_module WHERE client_id=? AND module_id=? AND active=1`,
input.clientId, input.moduleId,
))!
if (existing.n > 0) {
throw new Error(`Client already has an active subscription for module ${mod.code}`)
}
}
const id = uuidv7()
await db.run(
// created_at (FIX D — stalled-onboarding false positives): real assignment time, so a
// brand-new project with zero ticks is measured from here, not treated as ancient.
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard',
new Date().toISOString(),
)
await ensureProjectMilestones(db, id) // D22: seed the onboarding checklist for the new project
const cm = (await getClientModule(db, id))!
await writeAudit(db, userId, 'create', 'client_module', id, undefined, cm)
return cm
}
export interface ClientModulePatch {
status?: ClientModuleStatus; installedOn?: string | null; completedOn?: string | null
trainedOn?: string | null; nextRenewal?: string | null; active?: boolean
/** D18 WS-G (roster full-edit): kind must be one the module allows; edition free text. */
kind?: Kind; edition?: string
/** D20 service data ('' clears the text fields). The portal password is NOT
* patchable here — setModulePassword is its own gated, audited action. */
provider?: string; username?: string; remark?: string
details?: ServiceDetail[]
}
export async function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): Promise<ClientModule> {
const before = await getClientModule(db, id)
if (before === null) throw new Error('Client module not found')
if (patch.status !== undefined && !ALL_STATUSES.includes(patch.status)) {
throw new Error(`Unknown status: ${patch.status}`)
}
if (patch.kind !== undefined) {
const mod = await getModule(db, before.moduleId)
if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(patch.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${patch.kind}'`)
}
}
if (patch.edition !== undefined && patch.edition.trim() === '') {
throw new Error('Edition cannot be empty')
}
const sets: string[] = []
const args: unknown[] = []
if (patch.kind !== undefined) { sets.push('kind=?'); args.push(patch.kind) }
if (patch.edition !== undefined) { sets.push('edition=?'); args.push(patch.edition.trim()) }
if (patch.status !== undefined) { sets.push('status=?'); args.push(patch.status) }
if (patch.installedOn !== undefined) { sets.push('installed_on=?'); args.push(patch.installedOn) }
if (patch.completedOn !== undefined) { sets.push('completed_on=?'); args.push(patch.completedOn) }
if (patch.trainedOn !== undefined) { sets.push('trained_on=?'); args.push(patch.trainedOn) }
if (patch.nextRenewal !== undefined) { sets.push('next_renewal=?'); args.push(patch.nextRenewal) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
const optText = (col: string, v: string | undefined) => {
if (v !== undefined) { sets.push(`${col}=?`); args.push(v.trim() === '' ? null : v.trim()) }
}
optText('provider', patch.provider)
optText('username', patch.username)
optText('remark', patch.remark)
if (patch.details !== undefined) {
assertDetails(patch.details)
sets.push('details=?'); args.push(JSON.stringify(patch.details))
}
if (sets.length > 0) {
args.push(id)
await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
}
const after = (await getClientModule(db, id))!
await writeAudit(db, userId, 'update', 'client_module', id, before, after)
return after
}
/**
* Store (or clear, with '') the service portal password — AES-256-GCM at rest
* (D20, same discipline as the client DB password). Owner/manager-gated at the
* route; the plaintext never appears in audit rows. Empty keyHex fails loudly.
*/
export async function setModulePassword(
db: DB, userId: string, id: string, plain: string, keyHex: string,
): Promise<ClientModule> {
return db.transaction(async () => {
const before = await getClientModule(db, id)
if (before === null) throw new Error('Client module not found')
// D31: stored in the clear (encrypt() tags plaintext); no key required.
const enc = plain === '' ? null : encrypt(plain, keyHex)
await db.run(`UPDATE client_module SET password_enc=? WHERE id=?`, enc, id)
await writeAudit(db, userId, 'set_module_password', 'client_module', id, undefined, { set: enc !== null })
return (await getClientModule(db, id))!
})
}
/** Decrypt-and-return the stored portal password — every reveal writes an audit row. */
export async function revealModulePassword(db: DB, userId: string, id: string, keyHex: string): Promise<string> {
const row = await db.get<{ password_enc: string | null }>(
`SELECT password_enc FROM client_module WHERE id=?`, id,
)
if (row === undefined) throw new Error('Client module not found')
if (row.password_enc === null) throw new Error('No portal password stored for this module')
// D31: plaintext returns without a key; a legacy row still needs the key (decrypt throws).
const plain = decrypt(row.password_enc, keyHex)
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`)
// D31: stored in the clear; no key required.
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')
// D31: plaintext returns without a key; a legacy row still needs the key (unpack throws).
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]!
}