Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
244 lines
7.3 KiB
JavaScript
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,
|
|
};
|
|
}
|