godcrm/backend/services/verification/guards.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

225 lines
8.6 KiB
JavaScript

// ADR-0011 · Phase B · row-update guards for verification columns.
//
// Call from any row-update path (PUT /rows/:id, batch updates) BEFORE writing.
// Loads all verification-type columns for the table and enforces:
//
// C-4 — guard-violation auto-invalidation: if an UPDATE touches any column
// listed in `config.guards` AND the cell currently has verified=true,
// the returned cellOverride clears the stamp and appends an audit entry
// `{ event: 'regressed', reason: 'guard_violation:<col>',
// transition: { column, from, to } }`. The `transition` snapshot
// mirrors the shape written on `verified` events by /verify so the UI
// timeline can render both sides symmetrically.
//
// C-6 — status lock: if any incoming column value resolves to a string in
// `config.locks_on_statuses` AND the cell is NOT verified, we reject
// with { ok: false, status: 409, code: 'VERIFICATION_REQUIRED' }.
//
// The guarded column may be either:
// - text/select → incoming value is the slug itself (string)
// - relation → incoming value is a row_id (int/string); we
// resolve it to the target row's `slug` column
// (cached per call). Slug column name is hard-
// coded as `slug` — matches `_doc_statuses` and
// ADR-0013 status-table convention.
//
// Direct-write rejection: verification cells CANNOT be written via row PUT
// (only via /verify | /unverify endpoints). Any key in incomingData
// matching a verification column name → 403 VERIFICATION_IMMUTABLE.
//
// The helper does NOT touch the DB beyond reads — it returns structured intent;
// the caller merges `cellOverrides` into outgoing row data or short-circuits on
// reject.
import { dbAll, dbGet, safeJsonParse } from '../../database/connection.js';
function parseConfig(raw) {
if (!raw) return {};
if (typeof raw === 'string') {
const parsed = safeJsonParse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
}
return typeof raw === 'object' ? raw : {};
}
function isStampedVerified(cell) {
return !!(cell && typeof cell === 'object' && cell.verified === true && cell.verified_at);
}
/**
* Resolve a row reference (int or string row_id) on a relation-typed column
* to the target row's `slug` value. Returns the resolved slug or null if the
* target row / slug is missing.
*
* For non-relation columns, returns the input cast to string (so callers can
* compare against locks_on_statuses uniformly).
*
* @param {object} args
* @param {Map} args.cache — per-call cache: key = `${tableId}:${columnName}`
* @param {Map<number,object>} args.colMetaByName — table_columns rows by name
* @param {string} args.columnName
* @param {*} args.value
* @returns {Promise<string|null>}
*/
async function resolveSlugForGuardValue({ cache, colMetaByName, columnName, value }) {
if (value === null || value === undefined) return null;
const meta = colMetaByName.get(columnName);
// Unknown column or non-relation → value is already its own slug.
if (!meta || meta.type !== 'relation') {
return typeof value === 'string' ? value : String(value);
}
const cfg = parseConfig(meta.config);
const targetTableId = cfg.target_table_id || cfg.target_table || cfg.tableId;
if (!targetTableId) return null;
const cacheKey = `${targetTableId}:${value}`;
if (cache.has(cacheKey)) return cache.get(cacheKey);
const row = await dbGet(
`SELECT data FROM table_rows WHERE id = ? AND table_id = ?`,
[value, targetTableId]
);
if (!row) {
cache.set(cacheKey, null);
return null;
}
const data = safeJsonParse(row.data) || (typeof row.data === 'object' ? row.data : {});
const slug = data && typeof data === 'object' ? (data.slug ?? null) : null;
const result = slug != null ? String(slug) : null;
cache.set(cacheKey, result);
return result;
}
/**
* @param {object} args
* @param {number|string} args.tableId
* @param {object} args.existingData — current row.data (pre-merge)
* @param {object} args.incomingData — user-submitted delta, keys are COLUMN NAMES
* @param {number|null} args.userId
* @returns {Promise<
* | { ok: true, cellOverrides: Record<string, object> }
* | { ok: false, status: number, code: string, message: string, meta?: object }
* >}
*/
export async function enforceVerificationGuards({ tableId, existingData, incomingData, userId = null }) {
// Load metadata for ALL columns once (cheap; needed for both verification
// discovery and relation-type detection of guard keys).
const allCols = await dbAll(
`SELECT id, column_name, type, config
FROM table_columns
WHERE table_id = ?`,
[tableId]
);
const verCols = allCols.filter((c) => c.type === 'verification');
if (!verCols.length) return { ok: true, cellOverrides: {} };
const incomingKeys = new Set(Object.keys(incomingData || {}));
const colMetaByName = new Map();
for (const c of allCols) colMetaByName.set(c.column_name, c);
// Per-call cache for relation→slug resolution (key = `${targetTableId}:${rowId}`).
const slugCache = new Map();
const cellOverrides = {};
for (const col of verCols) {
// Direct-write rejection: /verify or /unverify endpoints only.
if (incomingKeys.has(col.column_name)) {
return {
ok: false,
status: 403,
code: 'VERIFICATION_IMMUTABLE',
message: `Column '${col.column_name}' is a verification cell and cannot be written via row update — use POST /columns/${col.id}/verify or /unverify`,
meta: {
verification_column_id: col.id,
verification_column_name: col.column_name,
},
};
}
const cfg = parseConfig(col.config);
const locks = Array.isArray(cfg.locks_on_statuses) ? cfg.locks_on_statuses : [];
const guards = Array.isArray(cfg.guards) ? cfg.guards : [];
const cell = existingData?.[col.column_name] ?? null;
const verified = isStampedVerified(cell);
// C-6: block status transition to a locked value while unverified.
// For each incoming key:
// - if the column is type=relation, resolve row_id → slug (cached) and
// compare against locks_on_statuses.
// - otherwise (legacy text/select), compare the raw string value.
if (locks.length > 0 && !verified) {
for (const [k, v] of Object.entries(incomingData || {})) {
const meta = colMetaByName.get(k);
let candidate = null;
if (meta && meta.type === 'relation') {
candidate = await resolveSlugForGuardValue({
cache: slugCache,
colMetaByName,
columnName: k,
value: v,
});
} else if (typeof v === 'string') {
candidate = v;
}
if (candidate != null && locks.includes(candidate)) {
return {
ok: false,
status: 409,
code: 'VERIFICATION_REQUIRED',
message: `Cannot transition to status '${candidate}': verification column '${col.column_name}' is not verified`,
meta: {
verification_column_id: col.id,
verification_column_name: col.column_name,
offending_column: k,
offending_value: v,
resolved_slug: candidate,
},
};
}
}
}
// C-4: guard-violation auto-invalidation.
// Comparison is on the raw incoming vs existing value (row_id vs row_id
// is a valid identity check for relation columns — no slug resolution
// needed for "did the field change?").
if (guards.length > 0 && verified) {
const violated = guards.find(
(g) => incomingKeys.has(g) && incomingData[g] !== existingData?.[g]
);
if (violated) {
const prevAudit = Array.isArray(cell.audit_log)
? cell.audit_log
: Array.isArray(cell.audit) ? cell.audit : []; // legacy audit[] fallback
cellOverrides[col.column_name] = {
verified: false,
verified_at: null,
verified_by_user_id: null,
methods_used: [],
jti: null,
audit_log: [
...prevAudit,
{
at: new Date().toISOString(),
actor: userId,
event: 'regressed',
reason: `guard_violation:${violated}`,
transition: {
column: violated,
from: existingData?.[violated] ?? null,
to: incomingData[violated] ?? null,
},
},
],
};
}
}
}
return { ok: true, cellOverrides };
}