godcrm/backend/services/agent-run-dispatcher/approval-gate.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

391 lines
13 KiB
JavaScript

/**
* 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: "<sha256 hex>", // never the plaintext
* expires_at: "<ISO+10min>",
* generated_at: "<ISO>",
* attempts: 0, // max 5 → auto-deny
* state: "pending"|"approved"|"denied"|"expired",
* resolved_at: "<ISO>"|null,
* resolved_by: <user_id>|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: '<redacted>' },
'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,
};