godcrm/backend/routes/v3/tickets/seal.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

402 lines
13 KiB
JavaScript

/**
* ADR-0002 §8 Phase 4 — Ticket seal (TOTP-act).
*
* POST /api/v3/tickets/:id/seal body: { totp_code, notes? }
* POST /api/v3/tickets/:id/unseal body: { totp_code, reason }
*
* The seal is a separate TOTP-signed act, distinct from criterion-level
* verification (ADR-0011 / ADR-0003). Per §8.5 A4.x:
*
* A4.1 — Tickets gains sealed_at + sealed_by + seal_proof (migration 051).
* A4.2 — `ticket_seal_verification` table mirrors criterion_verification:
* one append-only audit row per seal/un-seal event.
* A4.3 — sealing requires (i) all Must criteria verified (Phase 3 gate),
* (ii) human user click + TOTP, (iii) atomic insert + update in one
* DB transaction. Partial seal is impossible.
* A4.4 — un-seal clears Tickets.sealed_* and appends an audit row with
* action='broken'. Reason is required.
*
* TOTP source: per-user `users.totp_secret` (same pool used by ADR-0011
* verification column) — speakeasy with window=1. Hashing follows the
* BDD audit convention (sha256(code + salt)) so the proof is comparable
* across seal acts but is not a secret-recovery vector.
*
* Coordination with thread A (Phase 3): completionGate.checkCompletionGate
* is used as the canonical gate-check helper. No inline must-counter here.
*/
import crypto from 'node:crypto';
import speakeasy from 'speakeasy';
import {
dbGet,
isPostgres,
safeJsonParse,
withTransactionAsync,
} from '../../../database/connection.js';
import { generateBaseId } from '../../../utils/baseId.js';
import { apiLogger } from '../../../utils/logger.js';
import {
success,
error,
badRequest,
notFound,
forbidden,
} from '../../../utils/response.js';
import {
checkCompletionGate,
formatGateError,
} from '../../../services/bdd/completionGate.js';
import { totpLimiter } from '../bdd/shared.js';
const TICKETS_TABLE_ID = 1708;
// `ticket_seal_verification` — created by migration 051. We resolve its id
// once at startup via a name lookup (mirrors getBddTableId pattern). Cached
// for subsequent calls.
const SEAL_TABLE_CACHE = { id: null };
async function getSealVerificationTableId() {
if (SEAL_TABLE_CACHE.id) return SEAL_TABLE_CACHE.id;
const row = await dbGet(
isPostgres()
? `SELECT ut.id FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = $1 AND ut.name = $2 LIMIT 1`
: `SELECT ut.id FROM universal_tables ut
JOIN projects p ON ut.project_id = p.id
WHERE p.space_id = ? AND ut.name = ? LIMIT 1`,
[11, 'ticket_seal_verification'],
);
if (row?.id) {
SEAL_TABLE_CACHE.id = row.id;
return row.id;
}
return null;
}
/**
* Hash a TOTP code → sha256(code + salt). Same shape as bdd/shared.js
* `hashTotpCode` — the proof is a presence indicator, not a secret recovery.
*/
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');
}
/**
* Verify a TOTP code against the user's enrolled secret (users.totp_secret).
* Returns { ok, code }. Codes:
* - totp_code_missing : body.totp_code empty
* - totp_not_enrolled : user has no TOTP secret on file
* - totp_invalid : speakeasy rejected the code
* - user_not_found : userId resolves to no user
*/
async function verifyUserTotp(userId, code) {
if (!code || typeof code !== 'string') {
return { ok: false, code: 'totp_code_missing' };
}
const user = await dbGet(
isPostgres()
? `SELECT totp_secret, totp_enabled, user_type FROM users WHERE id = $1`
: `SELECT totp_secret, totp_enabled, user_type FROM users WHERE id = ?`,
[userId],
);
if (!user) return { ok: false, code: 'user_not_found' };
// ADR-0011 C-5 parity: agents/bots/services cannot self-sign.
if (user.user_type === 'agent' || user.user_type === 'bot' || user.user_type === 'service') {
return { ok: false, code: 'agent_forbidden' };
}
if (!user.totp_enabled || !user.totp_secret) {
return { ok: false, code: 'totp_not_enrolled' };
}
const ok = speakeasy.totp.verify({
secret: user.totp_secret,
encoding: 'base32',
token: String(code).trim(),
window: 1,
});
if (!ok) return { ok: false, code: 'totp_invalid' };
return { ok: true };
}
/**
* Load a Tickets row, parsed.
*/
async function loadTicket(ticketId) {
const row = await dbGet(
isPostgres()
? `SELECT id, data FROM table_rows WHERE id = $1 AND table_id = $2`
: `SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?`,
[ticketId, TICKETS_TABLE_ID],
);
if (!row) return null;
return { id: row.id, data: safeJsonParse(row.data, {}) || {} };
}
/**
* Insert one audit row in `ticket_seal_verification` and update Tickets in a
* single transaction. Builds the Tickets patch from the caller-supplied
* `patchBuilder({ verifiedAt, totpHash })` so seal/unseal can express their
* own column-level deltas (sealed_*=values vs sealed_*=null) while sharing
* the audit-row insert.
*/
async function performSealAct({
ticketId,
userId,
totpCode,
action, // 'sealed' | 'broken'
reason,
ip,
patchBuilder,
}) {
const sealTableId = await getSealVerificationTableId();
if (!sealTableId) {
throw new Error(
'ticket_seal_verification table not found — run migration 051',
);
}
const totpHash = hashTotpCode(totpCode);
const verifiedAt = new Date().toISOString();
const ticketDataPatch = patchBuilder({ verifiedAt, totpHash });
return withTransactionAsync(async (trx) => {
// 1. Append audit row.
const auditData = {
ticket_id: ticketId,
user_id: userId,
totp_proof: totpHash,
verified_at: verifiedAt,
action,
reason: reason || null,
ip: ip || null,
};
await trx.run(
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
VALUES (?, ?, ?::jsonb, NOW(), NOW())`,
[sealTableId, generateBaseId(), JSON.stringify(auditData)],
);
// 2. Re-read ticket inside the txn (avoid lost-update on concurrent seal).
const ticketRow = await trx.get(
`SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?`,
[ticketId, TICKETS_TABLE_ID],
);
if (!ticketRow) {
throw new Error(`ticket ${ticketId} disappeared mid-transaction`);
}
const ticketData =
typeof ticketRow.data === 'string'
? JSON.parse(ticketRow.data || '{}')
: ticketRow.data || {};
Object.assign(ticketData, ticketDataPatch);
await trx.run(
`UPDATE table_rows
SET data = ?::jsonb, updated_at = NOW()
WHERE id = ? AND table_id = ?`,
[JSON.stringify(ticketData), ticketId, TICKETS_TABLE_ID],
);
return { verifiedAt, totpHash, sealTableId };
});
}
/* =========================================================================
* Route registration
* ========================================================================= */
export default function registerSealRoutes(router) {
/* ------------------- POST /tickets/:id/seal ------------------- */
router.post('/tickets/:id/seal', totpLimiter, async (req, res) => {
try {
const ticketId = parseInt(req.params.id, 10);
if (!Number.isFinite(ticketId)) return badRequest(res, 'Invalid ticket id');
const { totp_code, notes } = req.body || {};
const userId = req.user?.id;
if (!userId) return forbidden(res, 'Unauthenticated');
// 1. Load ticket
const ticket = await loadTicket(ticketId);
if (!ticket) return notFound(res, `Ticket ${ticketId}`);
// 2. Reject double-seal
if (ticket.data.sealed_at) {
return error(
res,
'TICKET_ALREADY_SEALED',
`Ticket ${ticketId} is already sealed at ${ticket.data.sealed_at} — unseal first to re-seal`,
409,
{ sealed_at: ticket.data.sealed_at, sealed_by: ticket.data.sealed_by ?? null },
);
}
// 3. Completion gate (Phase 3 — thread A helper)
const gate = await checkCompletionGate(ticketId);
if (!gate.ok) {
const body = formatGateError(gate);
return error(
res,
body.code,
`Cannot seal ticket — ${gate.blockers.length} of ${gate.must_total} must-criteria not verified`,
409,
{ must_total: body.must_total, must_verified: body.must_verified, failed: body.failed },
);
}
// 4. TOTP verification
const totpResult = await verifyUserTotp(userId, totp_code);
if (!totpResult.ok) {
const status =
totpResult.code === 'totp_not_enrolled'
? 412
: totpResult.code === 'agent_forbidden'
? 403
: totpResult.code === 'user_not_found'
? 404
: 401;
return error(
res,
`TICKET_SEAL_${totpResult.code.toUpperCase()}`,
totpResult.code === 'totp_not_enrolled'
? 'TOTP not enrolled — set up 2FA first'
: totpResult.code === 'agent_forbidden'
? 'Agent accounts cannot seal tickets'
: totpResult.code === 'totp_code_missing'
? 'totp_code is required'
: totpResult.code === 'user_not_found'
? 'User not found'
: 'Invalid TOTP code',
status,
);
}
// 5. Atomic seal-act — audit row insert + Tickets.sealed_* update in
// one DB transaction (A4.3). Patch carries the real verifiedAt /
// hash, so we never write a placeholder.
const ip = req.ip || req.headers['x-forwarded-for'] || null;
const { verifiedAt } = await performSealAct({
ticketId,
userId,
totpCode: totp_code,
action: 'sealed',
reason: notes || null,
ip,
patchBuilder: ({ verifiedAt: t, totpHash }) => ({
sealed_at: t,
sealed_by: String(userId),
seal_proof: totpHash,
}),
});
apiLogger.info(
{ ticket_id: ticketId, user_id: userId },
'ADR-0002 §8 Phase 4: ticket sealed',
);
return success(res, {
ticket_id: ticketId,
sealed_at: verifiedAt,
sealed_by: userId,
});
} catch (err) {
apiLogger.error(
{ err: err.message, ticket_id: req.params.id },
'POST /tickets/:id/seal failed',
);
return error(res, 'TICKET_SEAL_FAILED', err.message, 500);
}
});
/* ------------------- POST /tickets/:id/unseal ------------------- */
router.post('/tickets/:id/unseal', totpLimiter, async (req, res) => {
try {
const ticketId = parseInt(req.params.id, 10);
if (!Number.isFinite(ticketId)) return badRequest(res, 'Invalid ticket id');
const { totp_code, reason } = req.body || {};
const userId = req.user?.id;
if (!userId) return forbidden(res, 'Unauthenticated');
if (!reason || String(reason).trim().length === 0) {
return badRequest(res, 'reason is required for unseal');
}
// 1. Load ticket
const ticket = await loadTicket(ticketId);
if (!ticket) return notFound(res, `Ticket ${ticketId}`);
// 2. Must currently be sealed
if (!ticket.data.sealed_at) {
return error(
res,
'TICKET_NOT_SEALED',
`Ticket ${ticketId} is not sealed — nothing to unseal`,
409,
);
}
// 3. TOTP verification (same gate as seal)
const totpResult = await verifyUserTotp(userId, totp_code);
if (!totpResult.ok) {
const status =
totpResult.code === 'totp_not_enrolled'
? 412
: totpResult.code === 'agent_forbidden'
? 403
: totpResult.code === 'user_not_found'
? 404
: 401;
return error(
res,
`TICKET_UNSEAL_${totpResult.code.toUpperCase()}`,
totpResult.code === 'totp_not_enrolled'
? 'TOTP not enrolled — set up 2FA first'
: totpResult.code === 'agent_forbidden'
? 'Agent accounts cannot unseal tickets'
: totpResult.code === 'totp_code_missing'
? 'totp_code is required'
: totpResult.code === 'user_not_found'
? 'User not found'
: 'Invalid TOTP code',
status,
);
}
// 4. Atomic break-act: audit + clear Tickets.sealed_* (A4.4).
const ip = req.ip || req.headers['x-forwarded-for'] || null;
const { verifiedAt } = await performSealAct({
ticketId,
userId,
totpCode: totp_code,
action: 'broken',
reason: String(reason).trim(),
ip,
patchBuilder: () => ({
sealed_at: null,
sealed_by: null,
seal_proof: null,
}),
});
apiLogger.info(
{ ticket_id: ticketId, user_id: userId, reason },
'ADR-0002 §8 Phase 4: ticket unsealed',
);
return success(res, {
ticket_id: ticketId,
unsealed_at: verifiedAt,
unsealed_by: userId,
});
} catch (err) {
apiLogger.error(
{ err: err.message, ticket_id: req.params.id },
'POST /tickets/:id/unseal failed',
);
return error(res, 'TICKET_UNSEAL_FAILED', err.message, 500);
}
});
}