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 <noreply@anthropic.com>
This commit is contained in:
56
CHANGELOG.md
56
CHANGELOG.md
@@ -3,6 +3,62 @@
|
|||||||
All notable changes to Void 2.0 are documented here.
|
All notable changes to Void 2.0 are documented here.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.0.0-alpha.1",
|
"version": "2.0.0-alpha.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -46,3 +46,15 @@ export function mount(node, ...children) {
|
|||||||
clear(node);
|
clear(node);
|
||||||
appendAll(node, children);
|
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 '#'; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// Reference detail: media block + summary + metadata + tag attach/detach + linked-from.
|
// Reference detail: media block + summary + metadata + tag attach/detach + linked-from.
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { el, mount, clear } from '../dom.js';
|
import { el, mount, clear, safeHref } from '../dom.js';
|
||||||
|
|
||||||
function mediaBlock(ref) {
|
function mediaBlock(ref) {
|
||||||
if (ref.kind === 'image' && (ref.source_url || ref.blob_path)) {
|
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) {
|
if (ref.kind === 'video' && ref.source_url) {
|
||||||
const yt = ref.source_url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{6,})/);
|
const yt = ref.source_url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]{6,})/);
|
||||||
@@ -16,10 +18,10 @@ function mediaBlock(ref) {
|
|||||||
referrerpolicy: 'no-referrer'
|
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) {
|
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)');
|
return el('span', { class: 'muted' }, '(no source)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Resource detail: status header + dependencies + source docs + runbook pages + change history.
|
// Resource detail: status header + dependencies + source docs + runbook pages + change history.
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { el, mount, clear } from '../dom.js';
|
import { el, mount, clear, safeHref } from '../dom.js';
|
||||||
|
|
||||||
function statusClass(s) {
|
function statusClass(s) {
|
||||||
return s === 'running' ? 'ok' : s === 'stopped' ? 'warn' : s === 'down' ? 'bad' : 'idle';
|
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),
|
' · ', el('span', { class: 'status idle' }, res.runtime_type),
|
||||||
res.host ? ' · ' + res.host : '',
|
res.host ? ' · ' + res.host : '',
|
||||||
res.url ? ' · ' : '',
|
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: 'row' },
|
||||||
el('div', { class: 'card' },
|
el('div', { class: 'card' },
|
||||||
|
|||||||
@@ -1,9 +1,134 @@
|
|||||||
// T17 stub — full implementation lands in T22.
|
// Search results — read q from hash, group hits by kind, sidebar
|
||||||
import { el, mount } from '../dom.js';
|
// filters for kinds and space_id.
|
||||||
export async function render(main, ctx) {
|
import { api } from '../api.js';
|
||||||
mount(main,
|
import { el, mount, clear } from '../dom.js';
|
||||||
el('h1', { class: 'view-h1' }, 'Search'),
|
import { navigate } from '../router.js';
|
||||||
el('p', { class: 'view-sub muted' }, 'q: ' + (ctx.query.q || '—')),
|
|
||||||
el('div', { class: 'card muted' }, 'Search view ships in T22.')
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { pool } from './lib/db/pool.js';
|
|||||||
import { log } from './lib/log.js';
|
import { log } from './lib/log.js';
|
||||||
import { mountApi } from './lib/api/index.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() {
|
export function createApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('server', () => {
|
|||||||
const res = await request(app).get('/health');
|
const res = await request(app).get('/health');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.db_ok).toBe(true);
|
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 () => {
|
it('GET /api/spaces without token returns 401', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user