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

217 lines
7.6 KiB
JavaScript

/**
* Shared helpers for BDD routes (ADR-156, ADR-0003 §C-4).
*
* Extracted from the original monolithic routes/v3/bdd.js. All helpers here are
* used by two or more submodules (tests, specs, enrollments, transitions,
* escalation, events). Per-submodule helpers live alongside their handlers.
*/
import crypto from 'node:crypto';
import rateLimit from 'express-rate-limit';
import { dbGet, dbRun, sqlNow } from '../../../database/connection.js';
import { generateBaseId } from '../../../utils/baseId.js';
import { apiLogger } from '../../../utils/logger.js';
// ADR-0003 §C-4: TOTP attempt limiter at HTTP layer (3 per minute per IP).
// Complements the per-criterion `failed_attempts` lockout (5 attempts → 1h).
export const totpLimiter = rateLimit({
windowMs: 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
message: {
success: false,
error: { code: 'TOTP_RATE_LIMITED', message: 'Too many TOTP attempts. Wait 60s.' },
timestamp: new Date().toISOString(),
},
});
// Space where BDD tables live (see scripts/bootstrap-bdd-tables.js)
export const BDD_SPACE_ID = 11;
// In-memory cache of logical-table name -> table_id (populated on first use)
const tableIdCache = new Map();
/**
* Resolve logical BDD table id by name, within space 11.
* Returns null if the table has not been bootstrapped yet.
*/
export async function getBddTableId(name) {
if (tableIdCache.has(name)) return tableIdCache.get(name);
const row = await dbGet(`
SELECT ut.id
FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = ? AND ut.name = ?
ORDER BY ut.id ASC
LIMIT 1
`, [BDD_SPACE_ID, name]);
if (row?.id) {
tableIdCache.set(name, row.id);
return row.id;
}
return null;
}
const BDD_CRITERIA_TABLE_CACHE = { id: null };
export async function criteriaTableId() {
if (BDD_CRITERIA_TABLE_CACHE.id) return BDD_CRITERIA_TABLE_CACHE.id;
const id = await getBddTableId('bdd_criteria');
BDD_CRITERIA_TABLE_CACHE.id = id;
return id;
}
// ADR-0003 §C-4 — append-only audit log for TOTP-signed ownership acts
// and state transitions. Table is created by scripts/bootstrap-bdd-tables.js.
const BDD_AUDIT_TABLE_CACHE = { id: null };
export async function auditLogTableId() {
if (BDD_AUDIT_TABLE_CACHE.id) return BDD_AUDIT_TABLE_CACHE.id;
const id = await getBddTableId('bdd_audit_log');
BDD_AUDIT_TABLE_CACHE.id = id;
return id;
}
export async function writeAuditLog(entry) {
try {
const tid = await auditLogTableId();
if (!tid) {
apiLogger.warn({ entry }, 'bdd_audit_log missing — run scripts/bootstrap-bdd-tables.js');
return;
}
const row = {
criterion_id: entry.criterion_id ?? null,
spec_id: entry.spec_id ?? null,
doc_id: entry.doc_id ?? null,
action: entry.action,
from_status: entry.from_status ?? null,
to_status: entry.to_status ?? null,
user_id: entry.user_id ?? null,
actor_kind: entry.actor_kind || 'user',
totp_hash: entry.totp_hash ?? null,
reason: entry.reason ?? null,
caused_by: entry.caused_by ?? null,
ip: entry.ip ?? null,
ts: new Date().toISOString(),
};
await dbRun(
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
VALUES (?, ?, ?::jsonb, ${sqlNow()}, ${sqlNow()})`,
[tid, generateBaseId(), JSON.stringify(row)]
);
} catch (e) {
apiLogger.error({ err: e.message, entry }, 'writeAuditLog failed (non-fatal)');
}
}
// ADR-0003 §C-4: totp_hash = sha256(code + server_salt). Server_salt comes from
// env BDD_AUDIT_SALT (fallback derives from SESSION_SECRET so the value is
// stable across restarts but never equal to the raw TOTP).
export function hashTotpCode(code) {
if (!code) return null;
const salt = process.env.BDD_AUDIT_SALT
|| process.env.SESSION_SECRET
|| 'godcrm-bdd-audit-default-salt';
return crypto.createHash('sha256').update(`${code}|${salt}`).digest('hex');
}
/* =========================================================================
* ADR-156 iter-5 Task 1 — TOTP encrypt-at-rest (AES-256-GCM)
*
* Key comes from BDD_TOTP_KEY (32 raw bytes, base64-encoded). Storage format
* on bdd_criteria.data.totp.secret_enc is: "<iv_b64>:<ct_b64>:<tag_b64>".
*
* During the transition window, enroll-confirm writes BOTH secret_enc (new)
* and active_secret (legacy plaintext). The one-shot migration script
* scripts/encrypt-totp-secrets.js back-fills secret_enc on rows that only
* have active_secret, and drops the plaintext. verifyCriterionTotp() reads
* secret_enc first and falls back to active_secret only when secret_enc is
* absent (transition safety).
* ========================================================================= */
function getTotpKey() {
const b64 = process.env.BDD_TOTP_KEY;
if (!b64) throw new Error('BDD_TOTP_KEY env var is required (32-byte base64)');
const key = Buffer.from(b64, 'base64');
if (key.length !== 32) throw new Error('BDD_TOTP_KEY must decode to exactly 32 bytes');
return key;
}
export function encryptSecret(plaintext) {
const key = getTotpKey();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64')}:${ct.toString('base64')}:${tag.toString('base64')}`;
}
export function decryptSecret(enc) {
if (typeof enc !== 'string') throw new Error('secret_enc must be string');
const parts = enc.split(':');
if (parts.length !== 3) throw new Error('secret_enc malformed (expected iv:ct:tag)');
const [ivB64, ctB64, tagB64] = parts;
const key = getTotpKey();
const iv = Buffer.from(ivB64, 'base64');
const ct = Buffer.from(ctB64, 'base64');
const tag = Buffer.from(tagB64, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
return pt.toString('utf8');
}
/**
* Return the base32 TOTP secret for verification — prefers encrypted
* secret_enc, falls back to plaintext active_secret during migration window.
* Returns null if neither is present (not enrolled).
*/
export function resolveActiveSecret(totp) {
if (!totp || typeof totp !== 'object') return null;
if (totp.secret_enc) {
try { return decryptSecret(totp.secret_enc); }
catch (e) {
apiLogger.warn({ err: e.message }, 'BDD TOTP: secret_enc decrypt failed');
// fall through to plaintext fallback
}
}
if (totp.active_secret) return totp.active_secret;
return null;
}
/**
* Update a single logical row's JSONB data with a shallow merge.
*/
export async function patchLogicalRow(tableId, rowId, patch) {
await dbRun(`
UPDATE table_rows
SET data = COALESCE(data,'{}'::jsonb) || ?::jsonb,
updated_at = ${sqlNow()}
WHERE table_id = ? AND id = ?
`, [JSON.stringify(patch), tableId, rowId]);
}
/**
* Emit a pg_notify with JSON payload.
*/
export async function pgNotify(channel, payloadObj) {
await dbRun(
`SELECT pg_notify(?, ?)`,
[channel, JSON.stringify(payloadObj)]
);
}
/**
* Fetch the logical row for `bdd_criteria.id` by the CRM row id.
* Returns { id, base_id, data } with data parsed to object, or null.
*/
export async function getCriterionRow(rowId) {
const tid = await criteriaTableId();
if (!tid) return null;
const row = await dbGet(
`SELECT id, base_id, data FROM table_rows WHERE table_id = ? AND id = ?`,
[tid, rowId]
);
if (!row) return null;
row.data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
return row;
}