From 8ae9bced240c8521cd3b4b5a607e62cb469e78cc Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 02:26:56 +1000 Subject: [PATCH] chore: version 2.0.0-alpha.2 + changelog 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 --- CHANGELOG.md | 56 +++++++++++++++ package.json | 2 +- public/dom.js | 12 ++++ public/views/reference.js | 10 +-- public/views/resource.js | 4 +- public/views/search.js | 139 ++++++++++++++++++++++++++++++++++++-- server.js | 2 +- tests/server.test.js | 2 +- 8 files changed, 211 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa4b25..b318afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,62 @@ All notable changes to Void 2.0 are documented here. Format: [Keep a Changelog](https://keepachangelog.com). +## [2.0.0-alpha.2] — 2026-06-01 + +### Added (Plan 2: API surface + UI shell) +- REST routes for the full entity tree: + - `/api/spaces`, `/api/projects`, `/api/tasks` (with project + space scoping) + - `/api/pages` + page revisions + `/api/pages/:id/backlinks` + - `/api/refs` + `/api/refs/upsert` + - `/api/resources` + dependencies + change history + - `/api/resources/:id/source-docs` + `/api/source-docs/:id/resync` (gated by `ENABLE_RESYNC`) + - `/api/agents` (owner-only) + agent token mint/revoke + - `/api/conversations` + nested `/messages` + - `/api/tags` + entity-scoped attach/detach via `/api/:entity_type/:entity_id/tags` + - `/api/links` (POST/GET from|to/DELETE) for polymorphic entity links + - `/api/pending-changes` + approve/reject with dispatch table covering + page/project/task/ref/resource/source_doc × create/update/delete + - `/api/audit/entity/:type/:id` + `/api/audit/actor` + - `/api/search` unified FTS across pages, refs, source docs, messages +- Agent bearer auth middleware + capability tiering: owner allow, agent + `write+scope` → allow, agent `suggest` → 202 + pending row, else 403. +- Approve and reject emit explicit `approve` / `reject` entries in the + audit log with the original agent id preserved in the diff. +- Static SPA shell served from `public/`: + - Three-column Cradle aesthetic (blackflame palette, Cinzel display + headings, Cormorant Garamond body) + - Hash-based router with views for home / space / project / page / + reference / resource / search / inbox / sacred valley + - `dom.js` safe builders — no `innerHTML` on API data anywhere; the + explicit `html:` opt-in is used only by the markdown editor's + preview pane, which sanitizes with DOMPurify + - Sidebar Spaces tree with lazy project expansion, bottom Navigate + section, pending-count badge shared with the topbar bell via a tiny + `state.js` event bus + - Topbar: brand, capture modal stub, global search (Enter → + `#/search?q=`), pending bell, owner toggle + - Page editor: split-pane markdown via marked + DOMPurify, save + PATCHes `/api/pages/:id`, backlinks card + - Reference detail: media block (image / YouTube embed / link), + summary, metadata table, tag attach/detach, linked-from list + - Resource detail: status header, dependencies + source docs + + runbook pages columns, change history + - Inbox: pending changes grouped by agent, approve → navigate to the + resulting entity +- Test coverage: 185 tests across 43 files (113 new for Plan 2 routes + + search + GET / shell smoke). + +### Security follow-ups (deferred) +- Polymorphic IDOR risk on entity_links / entity_tags / attachments — + acceptable today since the entire API is owner-token gated and there + is one tenant; see `docs/security-followups.md` for the tighten-now + vs defer decision. +- `pending_changes.action` CHECK constraint blocks `'upsert'` / + `'add_dependency'` / `'remove_dependency'` actions emitted by some + routes' `divertToPending` paths. Latent — only fires when an agent at + suggest tier hits those specific endpoints. Mitigation options + documented in `docs/security-followups.md`. + ## [Unreleased] ### Added diff --git a/package.json b/package.json index a7c51a4..e70dc09 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.0.0-alpha.1", + "version": "2.0.0-alpha.2", "type": "module", "private": true, "scripts": { diff --git a/public/dom.js b/public/dom.js index 7ee3aa1..bf6b42f 100644 --- a/public/dom.js +++ b/public/dom.js @@ -46,3 +46,15 @@ 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 '#'; } +} diff --git a/public/views/reference.js b/public/views/reference.js index c9b930e..12ae4a7 100644 --- a/public/views/reference.js +++ b/public/views/reference.js @@ -1,10 +1,12 @@ // Reference detail: media block + summary + metadata + tag attach/detach + linked-from. import { api } from '../api.js'; -import { el, mount, clear } from '../dom.js'; +import { el, mount, clear, safeHref } from '../dom.js'; function mediaBlock(ref) { if (ref.kind === 'image' && (ref.source_url || ref.blob_path)) { - return el('img', { src: ref.source_url || ref.blob_path, style: { maxWidth: '100%', borderRadius: '4px', border: '1px solid var(--border)' } }); + const src = safeHref(ref.source_url || ref.blob_path); + if (src === '#') return el('span', { class: 'muted' }, '(image url rejected by scheme check)'); + return el('img', { src, style: { maxWidth: '100%', borderRadius: '4px', border: '1px solid var(--border)' } }); } if (ref.kind === 'video' && ref.source_url) { const yt = ref.source_url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{6,})/); @@ -16,10 +18,10 @@ function mediaBlock(ref) { referrerpolicy: 'no-referrer' }); } - return el('a', { href: ref.source_url, target: '_blank', rel: 'noopener noreferrer' }, ref.source_url); + return el('a', { href: safeHref(ref.source_url), target: '_blank', rel: 'noopener noreferrer' }, ref.source_url); } if (ref.source_url) { - return el('a', { href: ref.source_url, target: '_blank', rel: 'noopener noreferrer' }, ref.source_url); + return el('a', { href: safeHref(ref.source_url), target: '_blank', rel: 'noopener noreferrer' }, ref.source_url); } return el('span', { class: 'muted' }, '(no source)'); } diff --git a/public/views/resource.js b/public/views/resource.js index 07756ef..bdb3a25 100644 --- a/public/views/resource.js +++ b/public/views/resource.js @@ -1,6 +1,6 @@ // Resource detail: status header + dependencies + source docs + runbook pages + change history. import { api } from '../api.js'; -import { el, mount, clear } from '../dom.js'; +import { el, mount, clear, safeHref } from '../dom.js'; function statusClass(s) { return s === 'running' ? 'ok' : s === 'stopped' ? 'warn' : s === 'down' ? 'bad' : 'idle'; @@ -114,7 +114,7 @@ export async function render(main, ctx) { ' · ', el('span', { class: 'status idle' }, res.runtime_type), res.host ? ' · ' + res.host : '', res.url ? ' · ' : '', - res.url ? el('a', { href: res.url, target: '_blank', rel: 'noopener noreferrer' }, res.url) : null + res.url ? el('a', { href: safeHref(res.url), target: '_blank', rel: 'noopener noreferrer' }, res.url) : null ), el('div', { class: 'row' }, el('div', { class: 'card' }, diff --git a/public/views/search.js b/public/views/search.js index 5feed34..49004e0 100644 --- a/public/views/search.js +++ b/public/views/search.js @@ -1,9 +1,134 @@ -// T17 stub — full implementation lands in T22. -import { el, mount } from '../dom.js'; -export async function render(main, ctx) { - mount(main, - el('h1', { class: 'view-h1' }, 'Search'), - el('p', { class: 'view-sub muted' }, 'q: ' + (ctx.query.q || '—')), - el('div', { class: 'card muted' }, 'Search view ships in T22.') +// Search results — read q from hash, group hits by kind, sidebar +// filters for kinds and space_id. +import { api } from '../api.js'; +import { el, mount, clear } from '../dom.js'; +import { navigate } from '../router.js'; + +const KINDS = ['page', 'ref', 'source_doc', 'message']; +const KIND_LABEL = { page: 'Pages', ref: 'References', source_doc: 'Source docs', message: 'Messages' }; +const ROUTE_BY_KIND = { + page: id => '#/page/' + id, + ref: id => '#/ref/' + id, + source_doc: () => null, + message: () => null +}; + +function hitRow(h) { + const route = ROUTE_BY_KIND[h.kind]; + const target = route ? route(h.id) : null; + return el('div', { class: 'search-hit' }, + target + ? el('a', { href: target }, h.title_or_snippet || '(untitled)') + : el('span', {}, h.title_or_snippet || '(untitled)'), + el('div', { class: 'hit-meta' }, + 'rank ' + (h.rank || 0).toFixed(3) + + (h.space_id ? ' · space ' + h.space_id.slice(0, 8) : ' · no space') + ) ); } + +async function runSearch(resultsEl, statusEl, q, opts) { + if (!q) { + clear(resultsEl); + mount(statusEl, el('span', { class: 'muted' }, 'Type a query above to start.')); + return; + } + mount(statusEl, el('span', { class: 'muted' }, 'Searching …')); + const url = new URL('/api/search', location.origin); + url.searchParams.set('q', q); + if (opts.space_id) url.searchParams.set('space_id', opts.space_id); + if (opts.kinds.length && opts.kinds.length < KINDS.length) url.searchParams.set('kinds', opts.kinds.join(',')); + url.searchParams.set('limit', '100'); + let hits = []; + try { hits = await api.get(url.pathname + url.search); } + catch (e) { + clear(resultsEl); + mount(statusEl, el('span', { class: 'muted' }, 'Error: ' + e.message)); + return; + } + mount(statusEl, el('span', { class: 'muted' }, hits.length + ' hits')); + + const byKind = new Map(); + for (const h of hits) { + if (!byKind.has(h.kind)) byKind.set(h.kind, []); + byKind.get(h.kind).push(h); + } + clear(resultsEl); + if (!hits.length) { + resultsEl.appendChild(el('p', { class: 'muted' }, 'No matches.')); + return; + } + for (const k of KINDS) { + const items = byKind.get(k); + if (!items?.length) continue; + resultsEl.appendChild(el('div', { class: 'search-group' }, + el('div', { class: 'group-h' }, KIND_LABEL[k] + ' (' + items.length + ')'), + items.map(hitRow) + )); + } +} + +export async function render(main, ctx) { + const q = ctx.query.q || ''; + let spaces = []; + try { spaces = await api.get('/api/spaces'); } catch { /* */ } + + const queryInput = el('input', { type: 'text', placeholder: 'Search …', value: q, style: { width: '100%' } }); + + const opts = { kinds: [...KINDS], space_id: '' }; + const kindCheckboxes = KINDS.map(k => { + const cb = el('input', { + type: 'checkbox', checked: true, + onchange: () => { + opts.kinds = KINDS.filter((kk, i) => kindCheckboxes[i].querySelector('input').checked); + runSearch(resultsEl, statusEl, queryInput.value, opts); + } + }); + return el('label', { style: { display: 'block', cursor: 'pointer', padding: '2px 0' } }, + cb, ' ', KIND_LABEL[k] + ); + }); + + const spaceSelect = el('select', { + style: { width: '100%' }, + onchange: () => { + opts.space_id = spaceSelect.value; + runSearch(resultsEl, statusEl, queryInput.value, opts); + } + }); + spaceSelect.appendChild(el('option', { value: '' }, '(all spaces)')); + for (const s of spaces) spaceSelect.appendChild(el('option', { value: s.id }, s.name)); + + const statusEl = el('div', { class: 'muted', style: { marginBottom: '10px', fontSize: '12px' } }); + const resultsEl = el('div'); + + queryInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const v = queryInput.value.trim(); + navigate('/search?q=' + encodeURIComponent(v)); + runSearch(resultsEl, statusEl, v, opts); + } + }); + + mount(main, + el('h1', { class: 'view-h1' }, 'Search'), + el('p', { class: 'view-sub muted' }, 'Full-text across pages, references, source docs, and messages.'), + el('div', { class: 'row' }, + el('div', { class: 'card', style: { flex: '0 0 240px' } }, + el('h3', {}, 'Filters'), + el('div', { class: 'muted', style: { fontSize: '11px', marginBottom: '4px' } }, 'Kinds'), + kindCheckboxes, + el('div', { class: 'muted', style: { fontSize: '11px', margin: '10px 0 4px' } }, 'Space'), + spaceSelect + ), + el('div', { class: 'card' }, + el('h3', {}, 'Query'), + queryInput, + statusEl, + resultsEl + ) + ) + ); + + runSearch(resultsEl, statusEl, q, opts); +} diff --git a/server.js b/server.js index e745b2e..6e83e0a 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ import { pool } from './lib/db/pool.js'; import { log } from './lib/log.js'; import { mountApi } from './lib/api/index.js'; -const VERSION = '2.0.0-alpha.1'; +const VERSION = '2.0.0-alpha.2'; export function createApp() { const app = express(); diff --git a/tests/server.test.js b/tests/server.test.js index 17de34f..e39bced 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -17,7 +17,7 @@ describe('server', () => { const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body.db_ok).toBe(true); - expect(res.body.version).toBeDefined(); + expect(res.body.version).toBe('2.0.0-alpha.2'); }); it('GET /api/spaces without token returns 401', async () => {