godcrm/backend/lib/publicScrubber.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

244 lines
7.3 KiB
JavaScript

// ADR-0060 §Fat-P5 / AC11 + AC14 — Public widget scrubber.
//
// Two responsibilities, kept in this single module so the test fixtures cover
// both as a unit:
// 1. Preset whitelist for `/api/v3/public/s/:slug/widgets/...` — anything not
// in PUBLIC_PRESET_WHITELIST is unreachable on the public surface (route
// returns 404). New widget types are default-deny: an explicit allowlist
// addition is required before they show up publicly.
// 2. scrubWidgetConfig(widget) — pure sync projection that drops every
// sensitive key (deep) and exposes only the public-safe fields the
// preset renderers need.
//
// FK-gating (refuse to expose a widget whose primary table_id is not public)
// is handled by the route — it is async and requires DB lookups, so it lives
// next to the other publicSpaceAccess checks in `routes/v3/public.js`. The
// scrubber stays pure & test-friendly.
/**
* Presets allowed on the public read-only surface.
*
* These are the actual `widgets.preset_name` values used by the codebase.
* Adding a new value here is a security review item — every preset's data
* pull and render path must be audited for PII / FK leak before inclusion.
*/
export const PUBLIC_PRESET_WHITELIST = Object.freeze(new Set([
'kanban_board',
'table_view',
'task_list',
// ADR-0060 P6/B — documents preset (read-only mirror via dedicated
// /widgets/:id/documents[/...] endpoints in routes/v3/public.js).
'documents',
]));
/**
* Keys to drop anywhere they appear inside widget.config (deep walk).
*
* `created_by` / `email_to` / `webhook_secret` are explicit in AC14; the
* remainder are the same blacklist applied across the public surface —
* surfacing them would defeat the purpose of having a public read API
* separate from the auth-gated one.
*/
const BLACKLIST_KEYS = Object.freeze(new Set([
'created_by',
'updated_by',
'owner_id',
'email_to',
'email_from',
'webhook_secret',
'webhook_url',
'webhook_token',
'api_key',
'apiKey',
'api_token',
'secret',
'password',
'auth',
'token',
'access_token',
'refresh_token',
'bearer',
'private_key',
'privateKey',
]));
/**
* Predicate: is this preset reachable on the public surface?
*
* Default-deny: unknown / null / typo'd presets all return false.
*/
export function isPresetAllowed(presetName) {
return typeof presetName === 'string' && PUBLIC_PRESET_WHITELIST.has(presetName);
}
function safeParseConfig(raw) {
if (raw == null) return {};
if (typeof raw === 'object') return raw;
if (typeof raw !== 'string') return {};
try {
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
} catch {
return {};
}
}
function deepScrub(node) {
if (Array.isArray(node)) {
return node.map(deepScrub);
}
if (node && typeof node === 'object') {
const out = {};
for (const [k, v] of Object.entries(node)) {
if (BLACKLIST_KEYS.has(k)) continue;
out[k] = deepScrub(v);
}
return out;
}
return node;
}
/**
* Documents preset: find the registry table id inside a documents widget
* config. The DocumentsWidget config carries the registry under one of two
* historical keys (`registry_table_id` or `documents_table_id`); both are
* used in production today. Returns the first finite positive integer.
*/
export function extractDocumentsRegistryTableId(cfgRaw) {
const cfg = safeParseConfig(cfgRaw);
const candidates = [
cfg.registry_table_id,
cfg.documents_table_id,
];
for (const c of candidates) {
const n = Number(c);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
/**
* Documents preset: registry-row allow-list. Only these keys ever reach the
* public reader. Authoring-only metadata (created_by, agent_*, plan_verification,
* status_id raw FK, etc.) is dropped here.
*
* NOTE: `table_id` is the per-document atoms table reference — the F-track
* adapter needs it to fetch atoms via /atoms. It is NOT sensitive (just an
* internal id) and the route layer gates its reachability anyway.
*/
const REGISTRY_ROW_PUBLIC_KEYS = Object.freeze([
'name',
'description',
'slug',
'icon',
'category',
'status',
'order_index',
'cover_url',
'pinned',
'parent_id',
'tags',
'lang',
'table_id',
]);
/**
* Project a registry row's `data` blob through the allow-list.
* Input : raw parsed `data` object from table_rows.
* Output : new object containing only public-safe keys (other keys silently
* dropped). Missing keys are NOT defaulted — caller decides.
*/
export function scrubRegistryRowData(dataRaw) {
if (!dataRaw || typeof dataRaw !== 'object' || Array.isArray(dataRaw)) return {};
const out = {};
for (const key of REGISTRY_ROW_PUBLIC_KEYS) {
if (Object.prototype.hasOwnProperty.call(dataRaw, key)) {
out[key] = dataRaw[key];
}
}
return out;
}
/**
* Documents preset: atom-row deny-list. Atoms hold rendered content
* (type/level/order/content_en/content_ru/atom_ref/widget_ref/etc.) — almost
* everything is safe to read publicly. We strip authoring metadata only.
*/
const ATOM_ROW_DENY_KEYS = Object.freeze(new Set([
'created_by',
'updated_by',
'last_edited_at',
'last_edited_by',
'agent_id',
'agent_run_id',
]));
/**
* Project an atom row's `data` blob through the deny-list (drop authoring
* metadata, keep everything else).
*/
export function scrubAtomRowData(dataRaw) {
if (!dataRaw || typeof dataRaw !== 'object' || Array.isArray(dataRaw)) return {};
const out = {};
for (const [k, v] of Object.entries(dataRaw)) {
if (ATOM_ROW_DENY_KEYS.has(k)) continue;
out[k] = v;
}
return out;
}
/**
* Find the table-id reference inside a widget config.
*
* Different presets store it under different keys (`table_id`,
* `tasks_table_id`, `data_table_id`, sometimes nested `kanban.tableId`).
* Return the first finite number we find, else null.
*/
export function extractWidgetTableRef(cfgRaw) {
const cfg = safeParseConfig(cfgRaw);
const candidates = [
cfg.table_id,
cfg.tasks_table_id,
cfg.data_table_id,
cfg.kanban?.tableId,
cfg.tableId,
];
for (const c of candidates) {
const n = Number(c);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
/**
* Project a widget row to its public-safe shape.
*
* Input : a row from the `widgets` table (or any object with the same shape
* — `id`, `preset_name`, `title`, `config` (json string or object)).
* Output : `{ id, type, name, table_id, view_config, filter, sort }`
* OR `null` when the preset is not whitelisted (callers must 404).
*
* The function does NOT verify that `table_id` points at a public table —
* that gate lives in the route (async DB lookup). Here we only strip the
* deterministic blacklist and the un-whitelisted presets.
*/
export function scrubWidgetConfig(widget) {
if (!widget || typeof widget !== 'object') return null;
const preset = widget.preset_name ?? widget.preset ?? widget.type ?? null;
if (!isPresetAllowed(preset)) return null;
const cfg = safeParseConfig(widget.config);
const scrubbed = deepScrub(cfg);
const tableRef = extractWidgetTableRef(cfg);
return {
id: widget.id ?? null,
type: preset,
name: widget.title ?? widget.name ?? '',
table_id: tableRef,
view_config: scrubbed,
filter: scrubbed.filter ?? null,
sort: scrubbed.sort ?? null,
};
}