Search view: read ?q from hash, call /api/search, group hits by kind with rank + space_id; sidebar filters for kinds and space_id; updates on Enter or filter change. Bumps package.json + server.js VERSION to 2.0.0-alpha.2 and pins the /health version assertion to match. CHANGELOG: full Plan 2 entry covering API surface, capability tiering, audit chain extension (approve/reject events), and the SPA shell. Security: adds safeHref() to dom.js and applies it everywhere an API-supplied URL becomes href / src (reference media block + reference source_url anchor + resource url anchor). javascript: and other non-http(s)/mailto schemes from agent-suggested content can no longer execute in the owner's browser. Plan 2 surface is feature-complete: 22/22 tasks landed, 185 tests across 43 files, SPA renders end-to-end including the suggest -> approve agent flow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
61 lines
2.3 KiB
JavaScript
61 lines
2.3 KiB
JavaScript
// Safe DOM builder used by all components / views. Never assigns
|
|
// innerHTML from API data — all text is set via textContent (auto-escaped
|
|
// by the browser), all structure via createElement.
|
|
//
|
|
// Usage:
|
|
// el('div', { class: 'card', onclick: fn }, 'title', el('span', {}, 'sub'))
|
|
// Children may be strings (becomes text node), Nodes, arrays of either,
|
|
// or null/undefined (ignored).
|
|
|
|
export function el(tag, attrs = {}, ...children) {
|
|
const node = document.createElement(tag);
|
|
for (const [k, v] of Object.entries(attrs || {})) {
|
|
if (v === null || v === undefined || v === false) continue;
|
|
if (k === 'class') node.className = v;
|
|
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
|
|
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
else if (k === 'dataset' && typeof v === 'object') Object.assign(node.dataset, v);
|
|
else if (k === 'html') {
|
|
// Explicit, scary-named opt-in. ONLY use with strings produced by
|
|
// marked() or other vetted sanitizers — never with raw API data.
|
|
node.innerHTML = v;
|
|
} else if (k in node && typeof node[k] !== 'function') {
|
|
try { node[k] = v; } catch { node.setAttribute(k, v); }
|
|
} else {
|
|
node.setAttribute(k, v);
|
|
}
|
|
}
|
|
appendAll(node, children);
|
|
return node;
|
|
}
|
|
|
|
function appendAll(parent, children) {
|
|
for (const c of children) {
|
|
if (c === null || c === undefined || c === false) continue;
|
|
if (Array.isArray(c)) appendAll(parent, c);
|
|
else if (c instanceof Node) parent.appendChild(c);
|
|
else parent.appendChild(document.createTextNode(String(c)));
|
|
}
|
|
}
|
|
|
|
export function clear(node) {
|
|
while (node.firstChild) node.removeChild(node.firstChild);
|
|
}
|
|
|
|
export function mount(node, ...children) {
|
|
clear(node);
|
|
appendAll(node, children);
|
|
}
|
|
|
|
// Validate a URL before using it as href/src. Agent-suggested content
|
|
// could carry a `javascript:` scheme that would execute in the owner's
|
|
// browser context when clicked. Only http(s)/mailto/relative pass.
|
|
export function safeHref(u) {
|
|
if (!u) return '#';
|
|
try {
|
|
const url = new URL(u, location.origin);
|
|
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:') return url.href;
|
|
return '#';
|
|
} catch { return '#'; }
|
|
}
|