import { and, eq, isNull } from 'drizzle-orm'; import { z } from 'zod'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { decrypt, encrypt } from '@/lib/utils/encryption'; import { registryFor } from './registry'; import type { ResolvedSetting, SettingEntry, SettingSource } from './types'; /** * Stored shape for encrypted JSONB values. The encrypt() helper returns a * JSON string of this shape - we wrap it in the JSONB column verbatim. */ interface EncryptedEnvelope { iv: string; tag: string; data: string; } function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope { return ( typeof value === 'object' && value !== null && typeof (value as { iv?: unknown }).iv === 'string' && typeof (value as { tag?: unknown }).tag === 'string' && typeof (value as { data?: unknown }).data === 'string' ); } /** * Validator inferred from the entry type when no explicit `validator` is set. * Keeps the registry concise - only override when standard rules don't fit. */ function defaultValidator(entry: SettingEntry): z.ZodTypeAny { if (entry.validator) return entry.validator; switch (entry.type) { case 'string': case 'password': case 'textarea': case 'user-select': return z.string(); case 'url': return z.string().url(); case 'email': return z.string().email(); case 'number': return z.coerce.number(); case 'boolean': return z.coerce.boolean(); case 'select': if (entry.options) { return z.enum(entry.options.map((o) => o.value) as [string, ...string[]]); } return z.string(); default: return z.unknown(); } } function coerceForType(entry: SettingEntry, raw: unknown): unknown { if (raw == null) return null; if (entry.transform) return entry.transform(raw); if (entry.type === 'number') { const n = typeof raw === 'number' ? raw : Number(raw); return Number.isFinite(n) ? n : null; } if (entry.type === 'boolean') { if (typeof raw === 'boolean') return raw; if (raw === 'true' || raw === '1') return true; if (raw === 'false' || raw === '0') return false; return Boolean(raw); } return raw; } function readEnvValue(entry: SettingEntry): unknown | null { if (!entry.envFallback) return null; const v = process.env[entry.envFallback]; if (v == null || v === '') return null; return coerceForType(entry, v); } function unwrapStoredValue(entry: SettingEntry, stored: unknown): unknown { if (stored == null) return null; if (entry.encrypted && isEncryptedEnvelope(stored)) { return decrypt(JSON.stringify(stored)); } // Settings written via the legacy upsertSetting helper wrap the value in // `{ value: ... }`. Unwrap that shape transparently for backward compat. if ( typeof stored === 'object' && stored !== null && 'value' in stored && Object.keys(stored as object).length === 1 ) { return (stored as { value: unknown }).value; } return stored; } interface ResolvedRaw { source: SettingSource; rawValue: unknown; } /** * Lower-level lookup that returns both the resolved value AND the source it * came from (port row, global row, env, or registry default). The admin API * uses this directly to drive the "Using env fallback" badge; service code * usually calls `getSetting()` which discards the source. */ export async function resolveSettingWithSource( key: string, portId: string | null, ): Promise { const entry = registryFor(key); if (!entry) throw new Error(`Unknown setting key: ${key}`); // 1. Port-specific row (only meaningful for port-scoped entries). if (portId && entry.scope === 'port') { const row = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)), }); if (row?.value != null) { return { source: 'port', rawValue: unwrapStoredValue(entry, row.value) }; } } // 2. Global row (port_id IS NULL). const globalRow = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)), }); if (globalRow?.value != null) { return { source: 'global', rawValue: unwrapStoredValue(entry, globalRow.value) }; } // 3. Env fallback. const envValue = readEnvValue(entry); if (envValue != null) { return { source: 'env', rawValue: envValue }; } // 4. Registry default. return { source: 'default', rawValue: entry.defaultValue ?? null }; } /** * Resolves a setting value through the precedence chain: port → global → env * → registry default. Encrypted values are decrypted on the way out. * * Use this from service code that needs the concrete cleartext value * (e.g. building an outbound Documenso request). */ export async function getSetting( key: string, portId: string | null, ): Promise { const { rawValue } = await resolveSettingWithSource(key, portId); return rawValue as T | null; } /** * Batch resolver - efficient for the admin form which needs every field in a * section. Returns a map keyed by setting key. */ export async function resolveSettings( keys: string[], portId: string | null, ): Promise> { const out = new Map(); await Promise.all( keys.map(async (k) => { out.set(k, await resolveSettingWithSource(k, portId)); }), ); return out; } /** * Shape returned to the admin API. Sensitive fields surface `isSet` only. */ export async function resolveForAdminAPI( keys: string[], portId: string | null, ): Promise> { const resolved = await resolveSettings(keys, portId); const out = new Map(); for (const key of keys) { const entry = registryFor(key); if (!entry) continue; const r = resolved.get(key); if (!r) continue; const isSet = r.source !== 'default' && r.rawValue != null && r.rawValue !== ''; const surfaceSensitive = entry.sensitive || entry.encrypted; out.set(key, { key, source: r.source, isSet, value: surfaceSensitive ? undefined : (r.rawValue ?? undefined), }); } return out; } /** * Validate and persist a setting. Encrypts if registered as encrypted. Always * writes to the row scope appropriate to the entry: port-scoped entries with * a non-null portId write the port row; global-scoped entries (or when called * with portId=null) write the global row. */ export async function writeSetting( key: string, rawValue: unknown, portId: string | null, meta: AuditMeta, ): Promise { const entry = registryFor(key); if (!entry) throw new ValidationError(`Unknown setting: ${key}`); // Empty value on a settable field == delete the row (revert to fallback). // Sensitive/encrypted: empty input means "don't change" rather than // "revert" - UI shows ••• placeholder so an unchanged save shouldn't // wipe the stored ciphertext. The dedicated DELETE endpoint exists for // explicit reverts. if (rawValue === '' || rawValue == null) { if (entry.encrypted || entry.sensitive) { // No-op: leaving the existing row untouched. return; } await deleteSetting(key, portId, meta); return; } const validator = defaultValidator(entry); const parsed = validator.safeParse(rawValue); if (!parsed.success) { throw new ValidationError( `Invalid value for "${key}": ${parsed.error.issues .map((i) => `${i.path.join('.')}: ${i.message}`) .join('; ')}`, ); } const value = parsed.data; const writePortId = entry.scope === 'global' ? null : portId; const storedValue = entry.encrypted ? (JSON.parse(encrypt(String(value))) as EncryptedEnvelope) : value; // Read existing for audit diff. const existing = writePortId ? await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), eq(systemSettings.portId, writePortId)), }) : await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)), }); await db .insert(systemSettings) .values({ key, value: storedValue as Record, portId: writePortId, updatedBy: meta.userId, }) .onConflictDoUpdate({ target: [systemSettings.key, systemSettings.portId], set: { value: storedValue as Record, updatedBy: meta.userId, updatedAt: new Date(), }, }); // Audit-log with redaction for sensitive / encrypted fields - fixes AU-02 // (encrypted ciphertext stored in audit_logs.new_value). const isSecret = entry.encrypted || entry.sensitive; void createAuditLog({ userId: meta.userId, portId: meta.portId, action: existing ? 'update' : 'create', entityType: 'setting', entityId: key, oldValue: existing ? { value: isSecret ? '[redacted]' : existing.value } : undefined, newValue: { value: isSecret ? '[redacted]' : value }, metadata: { settingKey: key, scope: entry.scope }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } /** * Delete a setting row, reverting the resolver to global → env → default. * No-op (with NotFoundError) if no row exists at the target scope. */ export async function deleteSetting( key: string, portId: string | null, meta: AuditMeta, ): Promise { const entry = registryFor(key); if (!entry) throw new ValidationError(`Unknown setting: ${key}`); const writePortId = entry.scope === 'global' ? null : portId; const existing = writePortId ? await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), eq(systemSettings.portId, writePortId)), }) : await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)), }); if (!existing) throw new NotFoundError('Setting'); await db .delete(systemSettings) .where( writePortId ? and(eq(systemSettings.key, key), eq(systemSettings.portId, writePortId)) : and(eq(systemSettings.key, key), isNull(systemSettings.portId)), ); const isSecret = entry.encrypted || entry.sensitive; void createAuditLog({ userId: meta.userId, portId: meta.portId, action: 'delete', entityType: 'setting', entityId: key, oldValue: { value: isSecret ? '[redacted]' : existing.value }, metadata: { settingKey: key, scope: entry.scope }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); } /** * One-click migration: read the env var named in `entry.envFallback`, write * it as the current scope's row. Used by the admin UI "Copy from env" button. */ export async function copyFromEnv( key: string, portId: string | null, meta: AuditMeta, ): Promise<{ copied: boolean; envValue?: string }> { const entry = registryFor(key); if (!entry) throw new ValidationError(`Unknown setting: ${key}`); if (!entry.envFallback) { throw new ValidationError(`Setting "${key}" has no env fallback configured`); } const envValue = process.env[entry.envFallback]; if (envValue == null || envValue === '') { return { copied: false }; } await writeSetting(key, envValue, portId, meta); return { copied: true, envValue: entry.encrypted || entry.sensitive ? undefined : envValue }; }