godcrm/backend/middleware/requestContext.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

99 lines
3.6 KiB
JavaScript

// backend/middleware/requestContext.js
//
// ADR-0066 P0 — Request-context middleware for `/api/v3/*`.
//
// Populates three properties on every Express request reaching `/api/v3`:
// - req.requestId → UUIDv4 correlating all writes inside one HTTP
// request (audit_log.request_id).
// - req.spaceId → best-effort numeric space id (audit_log.space_id).
// - req.actingAs → ADR-0065 placeholder. Always `null` until the
// ephemeral-permission-downgrade ADR ships. Wired
// here so writeAudit() does not need a code change
// when 0065 lands — only the resolver below.
//
// MUST be mounted BEFORE `authenticate` so the requestId is available
// even on auth failures (otherwise a 401 audit row has no correlation
// id). Auth-dependent fields (actingAs in the future) tolerate a
// missing req.user — the resolver short-circuits to `null`.
import { randomUUID } from 'node:crypto';
// Matches /spaces/<id> anywhere in the path (e.g. /api/v3/spaces/11,
// /api/v3/spaces/11/tables, /api/v3/spaces/11/projects/42/...).
// First numeric segment wins.
const SPACE_PATH_RE = /\/spaces\/(\d+)(?:\/|$|\?)/;
/**
* Best-effort extraction of a numeric space id from URL / body / query.
* Returns `null` if no plausible value found. Never throws.
*/
function resolveSpaceId(req) {
// 1) URL path — most reliable signal.
const pathMatch =
req.originalUrl && SPACE_PATH_RE.exec(req.originalUrl.split('?')[0]);
if (pathMatch) {
const n = Number(pathMatch[1]);
if (Number.isFinite(n) && n > 0) return n;
}
// 2) Body — `space_id`, `spaceId`, or nested `data.space_id`.
const body = req.body;
if (body && typeof body === 'object') {
const candidates = [
body.space_id,
body.spaceId,
body.data && body.data.space_id,
body.data && body.data.spaceId,
];
for (const v of candidates) {
const n = Number(v);
if (Number.isFinite(n) && n > 0) return n;
}
}
// 3) Query string.
if (req.query) {
const n = Number(req.query.space_id ?? req.query.spaceId);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
/**
* ADR-0065 hook — resolves the user this request is impersonating, if
* any. Returns `null` always until ADR-0065 lands. Implementing this as
* an async resolver (rather than a literal `null`) keeps the middleware
* signature stable when 0065 wires `_perm_test_sessions` lookups here.
*/
// eslint-disable-next-line no-unused-vars
async function resolveActingAs(req) {
// TODO(ADR-0065): SELECT acting_as FROM _perm_test_sessions
// WHERE actor_id = req.user.id AND (expires_at IS NULL OR expires_at > NOW())
// LIMIT 1;
// Dead code until then — must always return null without throwing.
return null;
}
/**
* Express middleware. Async because resolveActingAs is async (forward-
* looking), but the body never awaits anything that can throw — we just
* set the three properties and call next().
*/
export async function requestContext(req, _res, next) {
try {
req.requestId = req.headers['x-request-id'] || randomUUID();
req.spaceId = resolveSpaceId(req);
req.actingAs = await resolveActingAs(req);
} catch {
// Defensive: if anything in the resolvers throws, the request still
// proceeds — context fields just stay unset. writeAudit() tolerates
// missing fields.
req.requestId = req.requestId || randomUUID();
if (!('spaceId' in req)) req.spaceId = null;
if (!('actingAs' in req)) req.actingAs = null;
}
next();
}
export default requestContext;