/** * ADR-0030 Phase 5 — TOTP Approval Gate. * * Pure module. Generates a 6-digit one-time approval code, stores its sha256 * hash + expiry on the ticket row at `data.run_approval`, and exposes a * polling helper that blocks until the approval state resolves. * * Single source of truth = the ticket row. No new table. * * Security: * - Codes are generated with `crypto.randomInt(100000, 1000000)` — * cryptographically strong; ~20 bits of entropy. Combined with 5-attempt * auto-deny + 10-min TTL this is acceptable for a chat-confirmation flow. * - We NEVER store or log the plaintext. Only sha256 hash (hex). * - Hash comparison is timing-safe via `crypto.timingSafeEqual` on * identically-sized Buffers. * * Stored shape on `data.run_approval` (JSONB object): * { * code_hash: "", // never the plaintext * expires_at: "", * generated_at: "", * attempts: 0, // max 5 → auto-deny * state: "pending"|"approved"|"denied"|"expired", * resolved_at: ""|null, * resolved_by: |null * } * * Companion field on ticket: `data.run_state` flips to 'awaiting_approval' * when persistApprovalRequest runs, and is restored to 'preparing' on * approve / 'failed' on deny|expired by resolveApproval. * * @see ADR-0030 §6 Phase 5 deliverables. */ import { randomInt, createHash, timingSafeEqual } from 'node:crypto'; import { dbGet, dbRun } from '../../database/connection.js'; import { apiLogger } from '../../utils/logger.js'; const log = apiLogger.child({ module: 'approval_gate' }); const TICKETS_TABLE_ID = 1708; const APPROVAL_TTL_MS = 10 * 60 * 1000; // 10 minutes const MAX_ATTEMPTS = 5; const DEFAULT_POLL_MS = 2_000; const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — matches TTL // Public terminal outcomes for awaitApproval. Anything else is a bug. export const APPROVAL_OUTCOMES = Object.freeze({ APPROVED: 'approved', DENIED: 'denied', EXPIRED: 'expired', }); /** * Generate a fresh 6-digit code + its sha256 hash + expiry timestamp. * Returned `code` is the ONLY plaintext that ever exists — it must be * forwarded immediately to the chat post and then dropped from memory. * * @returns {{ code: string, code_hash: string, expires_at: string, generated_at: string }} */ export function generateApprovalCode() { // randomInt is half-open: [min, max). Range 100000..999999 inclusive. const num = randomInt(100000, 1_000_000); // Defensive padStart in case future ranges include leading zeros. const code = String(num).padStart(6, '0'); const generated_at = new Date().toISOString(); const expires_at = new Date(Date.now() + APPROVAL_TTL_MS).toISOString(); return { code, code_hash: hashCode(code), expires_at, generated_at, }; } /** * sha256 hex of input string. Used both at generate-time and at * verification-time; equality is checked via timingSafeEqual on Buffers. */ export function hashCode(code) { return createHash('sha256').update(String(code), 'utf8').digest('hex'); } /** * Constant-time equality of two sha256 hex strings. * Caller must ensure both are 64-char hex; otherwise we return false * without comparing (timingSafeEqual throws on length mismatch). */ export function timingSafeHashEqual(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false; if (a.length !== 64 || b.length !== 64) return false; let bufA; let bufB; try { bufA = Buffer.from(a, 'hex'); bufB = Buffer.from(b, 'hex'); } catch { return false; } if (bufA.length !== bufB.length) return false; return timingSafeEqual(bufA, bufB); } /** * Persist the approval request on the ticket row and flip run_state to * 'awaiting_approval'. Atomic: single jsonb-merge UPDATE. * * Caller already holds the ticket via the dispatcher claim — no extra lock * required. On other invocations we'd want SELECT … FOR UPDATE first. * * NOTE: never accepts the plaintext code — only the hash + metadata. */ export async function persistApprovalRequest(ticketId, { code_hash, expires_at, generated_at }) { if (!ticketId) throw new Error('persistApprovalRequest: ticketId required'); if (!code_hash || code_hash.length !== 64) { throw new Error('persistApprovalRequest: code_hash must be 64-char sha256 hex'); } if (!expires_at) throw new Error('persistApprovalRequest: expires_at required'); if (!generated_at) throw new Error('persistApprovalRequest: generated_at required'); const approval = { code_hash, expires_at, generated_at, attempts: 0, state: 'pending', resolved_at: null, resolved_by: null, }; await dbRun( `UPDATE table_rows SET data = data || jsonb_build_object( 'run_approval', $2::jsonb, 'run_state', 'awaiting_approval', 'run_last_event_at', $3::text ), updated_at = NOW() WHERE table_id = $1 AND id = $4`, [TICKETS_TABLE_ID, JSON.stringify(approval), new Date().toISOString(), ticketId] ); log.info( { ticket_id: ticketId, expires_at, code_hash: '' }, 'approval request persisted' ); } /** * Read current approval state for the ticket. Returns a normalised view: * * { * present: boolean, // false if data.run_approval is missing * state: string, // 'pending'|'approved'|'denied'|'expired' * attempts: number, * expires_at: string|null, * code_hash: string|null, // present so route handler can compare * run_state: string|null, * expired: boolean // true if expires_at < now and state==='pending' * } */ export async function readApprovalState(ticketId) { const row = await dbGet( `SELECT data->'run_approval' AS approval, data->>'run_state' AS run_state FROM table_rows WHERE table_id = $1 AND id = $2`, [TICKETS_TABLE_ID, ticketId] ); const raw = row?.approval; const approval = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!approval) { return { present: false, state: null, attempts: 0, expires_at: null, code_hash: null, run_state: row?.run_state || null, expired: false, }; } const now = Date.now(); const expMs = approval.expires_at ? Date.parse(approval.expires_at) : NaN; const expired = approval.state === 'pending' && Number.isFinite(expMs) && expMs <= now; return { present: true, state: approval.state || 'pending', attempts: Number(approval.attempts) || 0, expires_at: approval.expires_at || null, code_hash: approval.code_hash || null, run_state: row?.run_state || null, expired, }; } /** * Increment the attempts counter. If `success` is false AND attempts reach * MAX_ATTEMPTS, also flip state to 'denied' (and run_state to 'failed') so * the dispatcher's awaitApproval poll resolves with 'denied'. * * Returns the post-update view (attempts, state, attempts_remaining). */ export async function recordAttempt(ticketId, success) { // Increment then check via single round-trip. const incRow = await dbGet( `UPDATE table_rows SET data = jsonb_set( data, '{run_approval,attempts}', to_jsonb(COALESCE((data->'run_approval'->>'attempts')::int, 0) + 1), true ), updated_at = NOW() WHERE table_id = $1 AND id = $2 RETURNING (data->'run_approval'->>'attempts')::int AS attempts, data->'run_approval'->>'state' AS state`, [TICKETS_TABLE_ID, ticketId] ); const attempts = Number(incRow?.attempts) || 0; let state = incRow?.state || 'pending'; if (!success && attempts >= MAX_ATTEMPTS && state === 'pending') { // Auto-deny after too many wrong attempts. await resolveApproval(ticketId, { state: 'denied', resolved_by: null, reason: 'max_attempts' }); state = 'denied'; log.warn( { ticket_id: ticketId, attempts }, 'approval auto-denied after max attempts' ); } return { attempts, state, attempts_remaining: Math.max(0, MAX_ATTEMPTS - attempts), }; } /** * Mark approval as approved/denied/expired and flip ticket run_state * accordingly: * - approved → run_state = 'preparing' (dispatcher will continue to running) * - denied → run_state = 'failed', run_terminal_reason = 'approval_denied' * - expired → run_state = 'failed', run_terminal_reason = 'approval_timeout' * * Idempotent: calling twice with the same state is a no-op (the second * resolved_at simply overwrites — that's fine for this flow). */ export async function resolveApproval(ticketId, { state, resolved_by = null, reason = null } = {}) { if (!['approved', 'denied', 'expired'].includes(state)) { throw new Error(`resolveApproval: invalid state ${state}`); } const now = new Date().toISOString(); // Update run_approval sub-object first. await dbRun( `UPDATE table_rows SET data = jsonb_set( jsonb_set( jsonb_set( data, '{run_approval,state}', to_jsonb($2::text), true ), '{run_approval,resolved_at}', to_jsonb($3::text), true ), '{run_approval,resolved_by}', COALESCE(to_jsonb($4::int), 'null'::jsonb), true ), updated_at = NOW() WHERE table_id = $1 AND id = $5`, [TICKETS_TABLE_ID, state, now, resolved_by, ticketId] ); // Flip ticket run_state in a second write — keeps the SQL readable, and // run_state lives at the top level so we can't merge it inline cleanly. if (state === 'approved') { await dbRun( `UPDATE table_rows SET data = data || jsonb_build_object( 'run_state', 'preparing', 'run_last_event_at', $2::text ), updated_at = NOW() WHERE table_id = $1 AND id = $3`, [TICKETS_TABLE_ID, now, ticketId] ); } else { const terminalReason = state === 'expired' ? 'approval_timeout' : 'approval_denied'; await dbRun( `UPDATE table_rows SET data = data || jsonb_build_object( 'run_state', 'failed', 'run_terminal_reason', $2::text, 'run_finished_at', $3::text, 'run_last_event_at', $3::text ), updated_at = NOW() WHERE table_id = $1 AND id = $4`, [TICKETS_TABLE_ID, terminalReason, now, ticketId] ); } log.info( { ticket_id: ticketId, state, reason, resolved_by }, 'approval resolved' ); } /** * Block until the approval state resolves to approved / denied / expired. * Polls every `pollMs` ms; returns immediately if state is already terminal * or the stored expires_at is in the past (in which case we self-resolve to * 'expired' so the ticket reflects reality). * * Hard ceiling: `timeoutMs`. If we hit it before any state resolution, we * resolve as 'expired' and return. * * Returns: { outcome: 'approved'|'denied'|'expired', attempts: number } */ export async function awaitApproval(ticketId, { pollMs = DEFAULT_POLL_MS, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { const startedAt = Date.now(); let lastAttempts = 0; while (true) { const view = await readApprovalState(ticketId); lastAttempts = view.attempts; if (!view.present) { // Approval row missing — treat as expired (someone wiped it externally). log.warn({ ticket_id: ticketId }, 'awaitApproval: run_approval missing — resolving as expired'); await resolveApproval(ticketId, { state: 'expired', reason: 'missing_state' }).catch(() => {}); return { outcome: APPROVAL_OUTCOMES.EXPIRED, attempts: lastAttempts }; } if (view.state === 'approved') { return { outcome: APPROVAL_OUTCOMES.APPROVED, attempts: lastAttempts }; } if (view.state === 'denied') { return { outcome: APPROVAL_OUTCOMES.DENIED, attempts: lastAttempts }; } if (view.state === 'expired' || view.expired) { // Persist the 'expired' state if it's still nominally 'pending'. if (view.state === 'pending') { await resolveApproval(ticketId, { state: 'expired', reason: 'ttl_elapsed' }).catch(() => {}); } return { outcome: APPROVAL_OUTCOMES.EXPIRED, attempts: lastAttempts }; } // Hard timeout safety net (should normally fire via expires_at first). if (Date.now() - startedAt >= timeoutMs) { log.warn( { ticket_id: ticketId, timeoutMs }, 'awaitApproval: hard timeoutMs reached — resolving expired' ); await resolveApproval(ticketId, { state: 'expired', reason: 'hard_timeout' }).catch(() => {}); return { outcome: APPROVAL_OUTCOMES.EXPIRED, attempts: lastAttempts }; } await new Promise((r) => setTimeout(r, pollMs).unref?.()); } } export const APPROVAL_CONSTANTS = Object.freeze({ TTL_MS: APPROVAL_TTL_MS, MAX_ATTEMPTS, }); export default { generateApprovalCode, hashCode, timingSafeHashEqual, persistApprovalRequest, readApprovalState, recordAttempt, resolveApproval, awaitApproval, APPROVAL_OUTCOMES, APPROVAL_CONSTANTS, };