godcrm/backend/routes/v3/bdd/transitions.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

199 lines
7.2 KiB
JavaScript

/**
* ADR-0003 §C-4 — TOTP-signed criterion state transitions.
*
* POST /bdd/criteria/:id/verify (canonical)
* POST /bdd/criteria/:id/confirm (legacy alias of /verify)
* POST /bdd/criteria/:id/waive (TOTP-gated human waive, reason required)
*
* Shared internals: verifyCriterionTotp (lockout+backoff), finalizeCriterion
* (DB write + audit log + pg_notify + release gate recheck).
*/
import crypto from 'node:crypto';
import speakeasy from 'speakeasy';
import { apiLogger } from '../../../utils/logger.js';
import { success, error, notFound, badRequest } from '../../../utils/response.js';
import { maybeTransitionDocumentStatus } from '../../../services/bdd/releaseGate.js';
import {
totpLimiter,
criteriaTableId,
getCriterionRow,
patchLogicalRow,
pgNotify,
writeAuditLog,
hashTotpCode,
resolveActiveSecret,
} from './shared.js';
/**
* Internal: verify a TOTP/recovery attempt with lockout+backoff.
* Returns { ok, err, attempts_remaining }.
*/
async function verifyCriterionTotp(crit, { totp_code, recovery_code }) {
const totp = crit.data?.totp || {};
// ADR-156 iter-5 Task 1: prefer secret_enc, fall back to plaintext active_secret.
const activeSecret = resolveActiveSecret(totp);
if (!activeSecret && !totp.recovery_hash) {
return { ok: false, err: { status: 412, code: 'NOT_ENROLLED', message: 'Not enrolled — scan QR first' } };
}
// Lockout
if (totp.locked_until && new Date(totp.locked_until).getTime() > Date.now()) {
return { ok: false, err: { status: 429, code: 'TOTP_LOCKED', message: 'Locked for 1 hour' } };
}
let pass = false;
if (recovery_code) {
const h = crypto.createHash('sha256').update(String(recovery_code).trim()).digest('hex');
pass = !!totp.recovery_hash && h === totp.recovery_hash;
} else if (totp_code && activeSecret) {
pass = speakeasy.totp.verify({
secret: activeSecret,
encoding: 'base32',
token: String(totp_code),
window: 1,
});
}
if (pass) return { ok: true };
const attempts = (totp.failed_attempts || 0) + 1;
const MAX = 5;
const patch = { failed_attempts: attempts };
if (attempts >= MAX) {
patch.locked_until = new Date(Date.now() + 60 * 60 * 1000).toISOString();
patch.failed_attempts = 0;
}
const tid = await criteriaTableId();
await patchLogicalRow(tid, crit.id, { totp: { ...totp, ...patch } });
const remaining = Math.max(0, MAX - attempts);
return {
ok: false,
err: {
status: 401,
code: 'TOTP_INVALID',
message: 'Wrong code',
attempts_remaining: remaining,
},
};
}
async function finalizeCriterion(crit, { status, reason, userId, totpCode = null, ip = null, action = null }) {
const tid = await criteriaTableId();
const totp = crit.data?.totp || {};
const fromStatus = crit.data?.status || null;
const totpHash = totpCode ? hashTotpCode(totpCode) : null;
const patch = {
status,
[`${status}_at`]: new Date().toISOString(),
[`${status}_by_user_id`]: userId ?? null,
totp: {
...totp,
failed_attempts: 0,
locked_until: null,
// ADR-0003 §C-4: fresh-TOTP sentinel — last_verified_at becomes the row-lock
// timestamp. Subsequent state transitions must present a new TOTP code.
last_verified_at: new Date().toISOString(),
last_verified_hash: totpHash,
},
};
if (reason) patch[`${status}_reason`] = reason;
await patchLogicalRow(tid, crit.id, patch);
await writeAuditLog({
criterion_id: crit.id,
spec_id: crit.data?.spec_id ?? null,
doc_id: crit.data?.source_doc_id ?? null,
action: action || status,
from_status: fromStatus,
to_status: status,
user_id: userId ?? null,
actor_kind: userId ? 'user' : 'system',
totp_hash: totpHash,
reason: reason ?? null,
ip,
});
await pgNotify(`bdd.criterion.${status}`, {
event: `bdd.criterion.${status}`,
criterion_id: crit.id,
spec_id: crit.data?.spec_id ?? null,
doc_id: crit.data?.source_doc_id ?? null,
by_user_id: userId ?? null,
reason: reason ?? null,
});
// ADR-0003 §C-5: fire-and-forget release gate recheck on every criterion
// transition. Safe on any state — maybeTransitionDocumentStatus is a no-op
// unless the aggregated gate crosses a threshold (ready ↔ red).
const docId = Number(crit.data?.source_doc_id);
if (Number.isFinite(docId) && docId > 0) {
maybeTransitionDocumentStatus(docId, {
causedBy: `bdd.criterion.${status}:${crit.id}`,
userId: userId ?? null,
}).catch((err) => {
apiLogger.error({ err: err.message, docId, critId: crit.id }, 'release gate hook failed (non-fatal)');
});
}
}
// ADR-0003 §C-4: shared handler for TOTP-signed state transitions.
// Used by /verify (canonical), /confirm (legacy alias), and /waive.
async function handleTotpTransition(req, res, { status, reasonRequired = false, action }) {
try {
const critId = parseInt(req.params.id, 10);
if (!Number.isFinite(critId)) return badRequest(res, 'Invalid criterion id');
const { reason } = req.body || {};
if (reasonRequired && (!reason || String(reason).trim().length === 0)) {
return badRequest(res, 'reason is required');
}
const crit = await getCriterionRow(critId);
if (!crit) return notFound(res, 'bdd_criteria row');
const v = await verifyCriterionTotp(crit, req.body || {});
if (!v.ok) {
return res.status(v.err.status).json({
success: false,
error: { code: v.err.code, message: v.err.message },
attempts_remaining: v.err.attempts_remaining,
});
}
const totpCode = (req.body || {}).totp_code || (req.body || {}).recovery_code || null;
const ip = req.ip || req.headers['x-forwarded-for'] || null;
await finalizeCriterion(crit, {
status,
reason: reasonRequired ? String(reason).trim() : null,
userId: req.user?.id,
totpCode,
ip,
action: action || status,
});
return success(res, { criterion_id: critId, status });
} catch (err) {
apiLogger.error({ err, critId: req.params.id, status }, `POST /bdd/criteria/:id/${action || status} failed`);
return error(res, `BDD_${(action || status).toUpperCase()}_FAILED`, err.message, 500);
}
}
export default function registerTransitionRoutes(router) {
/* ------------------- POST /bdd/criteria/:id/verify ------------------- */
// ADR-0003 §C-4 canonical ownership act. TOTP-signed; sets status='verified',
// writes bdd_audit_log entry, emits pg_notify('bdd.criterion.verified').
router.post('/criteria/:id/verify', totpLimiter, async (req, res) => {
return handleTotpTransition(req, res, { status: 'verified', action: 'verify' });
});
/* ------------------- POST /bdd/criteria/:id/confirm (legacy alias) ------------------- */
// Retained for back-compat with older frontend. Equivalent to /verify.
router.post('/criteria/:id/confirm', totpLimiter, async (req, res) => {
return handleTotpTransition(req, res, { status: 'verified', action: 'verify' });
});
/* ------------------- POST /bdd/criteria/:id/waive ------------------- */
router.post('/criteria/:id/waive', totpLimiter, async (req, res) => {
return handleTotpTransition(req, res, { status: 'waived', reasonRequired: true, action: 'waive' });
});
}