Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
145 lines
5.2 KiB
JavaScript
145 lines
5.2 KiB
JavaScript
// ADR-0011 · Phase D · Step 6 — Verification TTL sweeper (C-9).
|
|
//
|
|
// Periodically scans every `verification` column whose config contains
|
|
// `ttl_seconds`, finds rows whose `verified_at` is older than that TTL, and
|
|
// regresses them:
|
|
// verified: false
|
|
// verified_at: null
|
|
// methods_used: []
|
|
// jti: null
|
|
// audit_log: [...prev, { at, actor: 'system', event: 'regressed', reason: 'ttl_expired' }]
|
|
//
|
|
// Safety:
|
|
// * Feature-flag gated — no-op when VERIFICATION_COLUMN_ENABLED !== 'true'.
|
|
// * Idempotent — already-regressed cells are skipped by the WHERE clause.
|
|
// * Column-name guard (isSafeIdentifier) — never interpolates user-controlled
|
|
// identifiers straight into SQL even though column names come from our own
|
|
// table_columns registry.
|
|
|
|
import { dbAll, dbRun } from '../../database/connection.js';
|
|
import { apiLogger } from '../../utils/logger.js';
|
|
import { validateVerificationConfig } from './validateConfig.js';
|
|
|
|
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // hourly sweep
|
|
const ROW_BATCH_LIMIT = 500;
|
|
|
|
let _interval = null;
|
|
let _running = false;
|
|
|
|
function isEnabled() {
|
|
return process.env.VERIFICATION_COLUMN_ENABLED === 'true';
|
|
}
|
|
|
|
function isSafeIdentifier(name) {
|
|
return typeof name === 'string' && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
|
}
|
|
|
|
export function startVerificationTTLSweeper() {
|
|
if (_interval) return;
|
|
const intervalMs = Number(process.env.VERIFICATION_TTL_SWEEP_INTERVAL_MS) || DEFAULT_INTERVAL_MS;
|
|
_interval = setInterval(() => { runVerificationTTLSweep().catch(() => {}); }, intervalMs);
|
|
apiLogger.info({ intervalMs }, 'ADR-0011: VerificationTTLSweeper started');
|
|
// kick off first sweep shortly after boot so TTL isn't a full interval late
|
|
setTimeout(() => { runVerificationTTLSweep().catch(() => {}); }, 10_000);
|
|
}
|
|
|
|
export function stopVerificationTTLSweeper() {
|
|
if (_interval) {
|
|
clearInterval(_interval);
|
|
_interval = null;
|
|
apiLogger.info('ADR-0011: VerificationTTLSweeper stopped');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single sweep pass. Returns a summary object for logging/tests.
|
|
*/
|
|
export async function runVerificationTTLSweep() {
|
|
if (!isEnabled()) return { skipped: 'flag_off', columns: 0, regressed: 0 };
|
|
if (_running) return { skipped: 'already_running', columns: 0, regressed: 0 };
|
|
_running = true;
|
|
|
|
const summary = { columns: 0, regressed: 0, errors: 0 };
|
|
|
|
try {
|
|
const columns = await dbAll(
|
|
`SELECT id, table_id, column_name AS name, config
|
|
FROM table_columns
|
|
WHERE type = 'verification'`
|
|
);
|
|
|
|
for (const col of columns) {
|
|
let cfg = col.config;
|
|
if (typeof cfg === 'string') {
|
|
try { cfg = JSON.parse(cfg); } catch { cfg = null; }
|
|
}
|
|
const cfgCheck = validateVerificationConfig(cfg || {});
|
|
if (!cfgCheck.ok) continue;
|
|
const { ttl_seconds } = cfgCheck.normalized;
|
|
if (!ttl_seconds) continue;
|
|
if (!isSafeIdentifier(col.name)) {
|
|
apiLogger.warn({ columnId: col.id, name: col.name }, 'TTL sweeper: unsafe column identifier, skipping');
|
|
continue;
|
|
}
|
|
|
|
summary.columns += 1;
|
|
|
|
try {
|
|
// Find rows whose verified_at is past TTL and still flagged verified.
|
|
// JSONB pathing: `data->column_name->>'verified_at'` parsed as timestamptz.
|
|
const expired = await dbAll(
|
|
`SELECT id, data
|
|
FROM table_rows
|
|
WHERE table_id = ?
|
|
AND (data->?->>'verified') = 'true'
|
|
AND (data->?->>'verified_at') IS NOT NULL
|
|
AND ((data->?->>'verified_at')::timestamptz) < (NOW() - (? || ' seconds')::interval)
|
|
LIMIT ${ROW_BATCH_LIMIT}`,
|
|
[col.table_id, col.name, col.name, col.name, String(ttl_seconds)]
|
|
);
|
|
|
|
for (const row of expired) {
|
|
try {
|
|
const data = typeof row.data === 'string' ? JSON.parse(row.data || '{}') : (row.data || {});
|
|
const prevCell = data?.[col.name] || {};
|
|
const prevAudit = Array.isArray(prevCell.audit_log) ? prevCell.audit_log : [];
|
|
const at = new Date().toISOString();
|
|
const cell = {
|
|
verified: false,
|
|
verified_at: null,
|
|
verified_by_user_id: null,
|
|
methods_used: [],
|
|
jti: null,
|
|
audit_log: [
|
|
...prevAudit,
|
|
{ at, actor: 'system', event: 'regressed', reason: 'ttl_expired' },
|
|
],
|
|
};
|
|
await dbRun(
|
|
`UPDATE table_rows
|
|
SET data = jsonb_set(data, '{${col.name}}', $1::jsonb),
|
|
updated_at = NOW()
|
|
WHERE id = $2 AND table_id = $3`,
|
|
[JSON.stringify(cell), row.id, col.table_id]
|
|
);
|
|
summary.regressed += 1;
|
|
} catch (rowErr) {
|
|
summary.errors += 1;
|
|
apiLogger.error({ err: rowErr, columnId: col.id, rowId: row.id }, 'TTL sweeper: row regression failed');
|
|
}
|
|
}
|
|
} catch (colErr) {
|
|
summary.errors += 1;
|
|
apiLogger.error({ err: colErr, columnId: col.id }, 'TTL sweeper: column scan failed');
|
|
}
|
|
}
|
|
|
|
if (summary.regressed > 0 || summary.errors > 0) {
|
|
apiLogger.info(summary, 'ADR-0011: TTL sweep completed');
|
|
}
|
|
} finally {
|
|
_running = false;
|
|
}
|
|
|
|
return summary;
|
|
}
|