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

134 lines
4.8 KiB
JavaScript

/**
* ADR-156 Phase 5D — TOTP enrollment routes for bdd_criteria.
*
* POST /bdd/criteria/:id/enroll-start (generate TOTP secret + QR URI)
* POST /bdd/criteria/:id/enroll-confirm (verify first code, issue recovery)
*
* TOTP secrets are stored on the criterion row itself under `data.totp`. During
* the iter-5 transition window, enroll-confirm writes BOTH secret_enc (new,
* AES-256-GCM) and active_secret (legacy plaintext). See shared.js.
*/
import crypto from 'node:crypto';
import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import { apiLogger } from '../../../utils/logger.js';
import { success, error, notFound, badRequest } from '../../../utils/response.js';
import {
criteriaTableId,
getCriterionRow,
patchLogicalRow,
encryptSecret,
} from './shared.js';
export default function registerEnrollmentRoutes(router) {
/* ------------------- POST /bdd/criteria/:id/enroll-start ------------------- */
router.post('/criteria/:id/enroll-start', async (req, res) => {
try {
const critId = parseInt(req.params.id, 10);
if (!Number.isFinite(critId)) return badRequest(res, 'Invalid criterion id');
const crit = await getCriterionRow(critId);
if (!crit) return notFound(res, 'bdd_criteria row');
const label = crit.data?.code || `criterion-${critId}`;
const secret = speakeasy.generateSecret({
length: 20,
name: `GodCRM:${label}`,
issuer: 'GodCRM-BDD',
});
const tid = await criteriaTableId();
const totpPatch = {
...(crit.data?.totp || {}),
pending_secret: secret.base32,
pending_at: new Date().toISOString(),
};
await patchLogicalRow(tid, critId, { totp: totpPatch });
let qrDataUrl = null;
try {
qrDataUrl = await QRCode.toDataURL(secret.otpauth_url, { margin: 1, width: 240 });
} catch (e) {
apiLogger.warn({ err: e.message }, 'QR data URL generation failed (non-fatal)');
}
return success(res, {
criterion_id: critId,
provisioning_uri: secret.otpauth_url,
qr_data_url: qrDataUrl,
});
} catch (err) {
apiLogger.error({ err, critId: req.params.id }, 'POST /bdd/criteria/:id/enroll-start failed');
return error(res, 'BDD_ENROLL_START_FAILED', err.message, 500);
}
});
/* ------------------- POST /bdd/criteria/:id/enroll-confirm ------------------- */
router.post('/criteria/:id/enroll-confirm', async (req, res) => {
try {
const critId = parseInt(req.params.id, 10);
if (!Number.isFinite(critId)) return badRequest(res, 'Invalid criterion id');
const { totp_code } = req.body || {};
if (!totp_code || !/^\d{6}$/.test(String(totp_code))) {
return badRequest(res, 'totp_code (6 digits) is required');
}
const crit = await getCriterionRow(critId);
if (!crit) return notFound(res, 'bdd_criteria row');
const totp = crit.data?.totp || {};
if (!totp.pending_secret) {
return res.status(412).json({
success: false,
error: { code: 'NO_PENDING_ENROLLMENT', message: 'Call enroll-start first' },
});
}
const ok = speakeasy.totp.verify({
secret: totp.pending_secret,
encoding: 'base32',
token: String(totp_code),
window: 1,
});
if (!ok) {
return res.status(401).json({
success: false,
error: { code: 'TOTP_INVALID', message: 'Wrong code' },
});
}
// Issue recovery code (shown once, stored as SHA-256 hash)
const recoveryCode = crypto.randomBytes(10).toString('hex').toUpperCase().slice(0, 16);
const recoveryHash = crypto.createHash('sha256').update(recoveryCode).digest('hex');
const tid = await criteriaTableId();
// ADR-156 iter-5 Task 1: write encrypted secret_enc AND plaintext
// active_secret during transition. Migration script will drop plaintext
// once all rows have secret_enc.
let secretEnc = null;
try {
secretEnc = encryptSecret(totp.pending_secret);
} catch (e) {
apiLogger.error({ err: e.message }, 'BDD TOTP: enroll-confirm encrypt failed (falling back to plaintext-only)');
}
await patchLogicalRow(tid, critId, {
totp: {
secret_enc: secretEnc,
active_secret: totp.pending_secret,
recovery_hash: recoveryHash,
failed_attempts: 0,
locked_until: null,
enrolled_at: new Date().toISOString(),
},
});
return success(res, {
criterion_id: critId,
success: true,
recovery_code: recoveryCode,
});
} catch (err) {
apiLogger.error({ err, critId: req.params.id }, 'POST /bdd/criteria/:id/enroll-confirm failed');
return error(res, 'BDD_ENROLL_CONFIRM_FAILED', err.message, 500);
}
});
}