openvibers/openvibe.wiki Commits

Citation inspector: /w/:space/:slug/sources (?rev=N, the published revision by default) shows a revision's sources in full, server-rendered and useful without JavaScript: kind (URL or OpenVibe.Sources item), retrieval time, license, quote, the revision that first cited each source, and what was kept, added, restored or dropped since the previous readable revision. Linked from the article's sources and every history row; same read rules as the article (drafts, newer revisions, members/private and deleted pages answer 404/410, renames 301). test/citation-inspector.test.js

OP OpenVibers committed 23 Sept 2026 · 34bffa4e36 · 6 files, +193 −11


Changes

ddiff --git a/README.md b/README.md
iindex 0ee400d..493ac8a 100644
---- a/README.md
++++ b/README.md
@@@ -80,7 +80,11 @@ records who attached each Media object and what Media said about it at that mome
for everyone else. "What links here" per page.
- **Citations**: OpenVibe.Sources items (url, title, `retrieved_at`, license from the item's
provenance) or URLs with a required retrieval time; attached to one revision, carried forward
-- explicitly, never edited; a published revision's sources are fixed.
++ explicitly, never edited; a published revision's sources are fixed. The citation inspector
++ (`/w/:space/:slug/sources`, linked from the article's sources and every history row) shows one
++ revision's sources in full — kind, retrieval time, license, quote, the revision that first cited
++ each one, and what was kept, added, restored or dropped since the previous revision — under the
++ article's read rules.
- **Media**: attachments by Media object id; a check against Media marks missing or deleted objects
`broken` and the page shows an explicit "no longer available" placeholder instead of an image; an
outage changes nothing (`check_failed`). A periodic check runs when Media is configured.
@@@ -138,7 +142,7 @@ match; network search is OpenVibe.Search).
Pages (SSR, useful without JavaScript): `/`, `/recent`, `/search?q=`, `/new-space`, `/s/:space`,
`/s/:space/new`, `/s/:space/settings`, `/s/:space/proposals`, `/w/:space/:slug` (`?rev=N`),
--`/w/:space/:slug.json`, `/w/:space/:slug/history`, `/w/:space/:slug/diff/:a/:b` (`?mode=line`),
++`/w/:space/:slug.json`, `/w/:space/:slug/history`, `/w/:space/:slug/sources` (`?rev=N`), `/w/:space/:slug/diff/:a/:b` (`?mode=line`),
`/w/:space/:slug/edit`, `/w/:space/:slug/revert?to=N`, `/w/:space/:slug/settings`,
`POST /w/:space/:slug/watch|discuss`. Sign-in: `/auth/login|callback|logout|me|refresh|fedcm`
(Network SSO, same session layer as OpenVibe.Community). Legal: `/terms`, `/privacy`, `/dmca`.
@@@ -213,7 +217,9 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
## Acceptance (tested in `test/`)
- create → edit → publish → revise → diff → revert keeps an immutable lineage (`revisions.test.js`)
--- citations and infobox values stay attached to the exact revision that used them (`revisions.test.js`)
++- citations and infobox values stay attached to the exact revision that used them (`revisions.test.js`);
++ the citation inspector shows them per revision without JavaScript and leaks nothing the article
++ would not show (`citation-inspector.test.js`)
- scheduled publication is idempotent across worker restarts (`schedule.test.js`)
- a private, members-only or deleted page leaves sitemaps, feeds and the Search index (tombstone)
and is served `Cache-Control: private`; renames 301, deletions 410 (`visibility.test.js`)
ddiff --git a/public/css/wiki.css b/public/css/wiki.css
iindex fc890e9..8b53a09 100644
---- a/public/css/wiki.css
++++ b/public/css/wiki.css
@@@ -60,8 +60,9 @@ fieldset { border: 1px solid var(--wk-line); border-radius: 6px; margin: 12px 0;
.wk-sources blockquote { margin: 4px 0 0 12px; color: var(--wk-muted); font-style: italic; }
.wk-meta { clear: both; font-size: 14px; color: var(--wk-muted); border-top: 1px solid var(--wk-line); margin-top: 24px; }
table { border-collapse: collapse; }
--.wk-history, .wk-fields { width: 100%; font-size: 14px; display: block; overflow-x: auto; }
--.wk-history th, .wk-history td, .wk-fields th, .wk-fields td { padding: 4px 8px; border-bottom: 1px solid var(--wk-line); text-align: left; vertical-align: top; }
++.wk-history, .wk-fields, .wk-citations { width: 100%; font-size: 14px; display: block; overflow-x: auto; }
++.wk-history th, .wk-history td, .wk-fields th, .wk-fields td, .wk-citations th, .wk-citations td { padding: 4px 8px; border-bottom: 1px solid var(--wk-line); text-align: left; vertical-align: top; }
++.wk-citations blockquote { margin: 4px 0 0 12px; color: var(--wk-muted); font-style: italic; }
.wk-history tr.wk-live { background: var(--wk-panel); }
.wk-tree { padding-left: 1.2em; }
.wk-comments { list-style: none; padding: 0; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 1efc6c9..279ef98 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -13,6 +13,7 @@
* GET /w/:space/:slug the article (?rev=N for an old revision)
* GET /w/:space/:slug.json the same data as JSON (same visibility rules)
* GET /w/:space/:slug/history every revision; /compare?a&b → /diff/:a/:b
++ * GET /w/:space/:slug/sources citation inspector (?rev=N; default the published revision)
* GET /w/:space/:slug/diff/:a/:b word (default) or ?mode=line diff
* GET|POST /w/:space/:slug/edit edit form (preview / save / save and publish)
* GET|POST /w/:space/:slug/revert revert as a new revision
@@@ -289,6 +290,22 @@ function createPages({ svc, viewers, platform, config, log = console }) {
});
});
++ // The citation inspector: one revision's sources (?rev=N; the published one by default), with the
++ // same read rules as the article.
++ router.get('/w/:space/:slug/sources', (req, res) => {
++ const found = locate(req, res, '/sources');
++ if (!found) return;
++ const { space, page } = found;
++ const rev = req.query.rev != null ? Number(req.query.rev) : undefined;
++ if (rev !== undefined && !Number.isInteger(rev)) return notFound(req, res);
++ let inspected;
++ try { inspected = svc.citationInspector(space, page, req.actor, { revision: rev }); } catch (err) { if (err.status === 404) return notFound(req, res); throw err; }
++ send(req, res, 200, views.sourcesPage({ space, page, ...inspected }), {
++ title: `Sources of ${inspected.view.revision.fields.title || page.title}`, robots: 'noindex, follow',
++ cache: page.state === 'published' && svc.access.effectiveVisibility(space, page) === 'public' ? 'public' : null,
++ });
++ });
++
router.get('/w/:space/:slug/compare', (req, res) => {
const found = locate(req, res);
if (!found) return;
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 3f44f26..c1555fd 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -67,9 +67,35 @@ function infoboxHtml(entries, resolve) {
return html`<aside class="wk-infobox" aria-label="Infobox"><table><tbody>${entries.map((x) => html`<tr data-key="${x.key}" data-type="${x.type}"><th scope="row">${x.label}</th><td>${cell(x)}</td></tr>`)}</tbody></table></aside>`;
}
--function citationsHtml(cites) {
-- if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p></section>`;
-- return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol></section>`;
++function citationsHtml(cites, inspectHref) {
++ const inspect = inspectHref ? html`<p class="wk-muted"><a href="${inspectHref}">Inspect these sources</a> (retrieval times, where each was first cited, what changed between revisions)</p>` : '';
++ if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p>${inspect}</section>`;
++ return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol>${inspect}</section>`;
++}
++
++const CHANGE_LABEL = { kept: 'kept from revision', new: 'new in this revision', restored: 'restored from an older revision' };
++
++/** The citation inspector: one revision's sources in full, and how they changed from the previous one. */
++function sourcesPage({ space, page, view: v, citations: cites, previous, dropped, revisions }) {
++ const base = wpath(space, page);
++ const n = v.revision.number;
++ const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
++ const row = (c) => html`<tr id="cite-${String(c.id)}">
++<td>${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a><br><span class="wk-muted">${hostOf(c.url) || ''}</span>` : html`${c.title || 'Sources item'}`}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</td>
++<td>${c.sourceItemId ? html`OpenVibe.Sources item <code>${c.sourceItemId}</code><br><span class="wk-muted">URL, title, retrieval time and license from the item's provenance</span>` : 'URL given by the editor'}</td>
++<td>${c.retrievedAt ? t(c.retrievedAt) : html`<span class="wk-muted">unknown</span>`}</td>
++<td>${c.licenseNote || html`<span class="wk-muted">none recorded</span>`}</td>
++<td>${c.firstRevision ? html`<a href="${base}/sources?rev=${String(c.firstRevision)}">revision ${String(c.firstRevision)}</a>, ${t(c.firstAttachedAt)}` : html`<span class="wk-muted">an earlier revision</span>`}</td>
++<td>${c.change === 'kept' && previous ? html`${CHANGE_LABEL.kept} ${String(previous)}` : CHANGE_LABEL[c.change]}</td></tr>`;
++ return html`${crumbs([{ name: 'Wiki', url: '/' }, { name: space.name, url: `/s/${e(space.slug)}` }, { name: page.title, url: page.state === 'published' ? base : null }, { name: 'Sources' }])}
++<section class="wk-inspector"><h1>Sources of ${v.revision.fields.title || page.title}, revision ${String(n)}</h1>
++<p>${v.isPublishedRevision ? 'This is the published revision.' : html`This is not the published revision${page.published_revision && page.state === 'published' ? html` (<a href="${base}/sources">see the published one</a>)` : ''}.`} <a href="${base}?rev=${String(n)}">Read revision ${String(n)}</a> · <a href="${base}/history">history</a></p>
++<p class="wk-muted">Every citation belongs to the revision that used it and is never edited. A later revision keeps a source by carrying it forward; a dropped source stays on the older revision.</p>
++${cites.length ? html`<table class="wk-citations"><thead><tr><th scope="col">Source</th><th scope="col">Kind</th><th scope="col">Retrieved</th><th scope="col">License</th><th scope="col">First cited in</th><th scope="col">In this revision</th></tr></thead><tbody>${cites.map(row)}</tbody></table>` : html`<p>Revision ${String(n)} cites no sources.</p>`}
++${previous ? html`<h2>Dropped since revision ${String(previous)}</h2>${dropped.length ? html`<ul>${dropped.map((c) => html`<li>${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <code>${c.sourceItemId}</code>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : ''}</li>`)}</ul>` : html`<p class="wk-muted">None.</p>`}` : ''}
++<h2>Other revisions</h2>
++<ul class="wk-revisions">${revisions.map((r) => html`<li>${r.number === n ? html`<strong>Revision ${String(r.number)}</strong>` : html`<a href="${base}/sources?rev=${String(r.number)}">Revision ${String(r.number)}</a>`}${r.published ? ' (published)' : ''} · ${t(r.createdAt)} · ${String(r.citationCount)} source(s)</li>`)}</ul>
++</section>`;
}
const UNAVAILABLE = {
@@@ -119,7 +145,7 @@ ${banners}
${raw(infoboxHtml(v.infobox, resolve))}
<div class="wk-content">${raw(contentHtml)}</div>
${raw(mediaHtml(v.attachments, mediaUrl))}
--${raw(citationsHtml(v.citations))}
++${raw(citationsHtml(v.citations, `${base}/sources${v.isPublishedRevision ? '' : `?rev=${rev.number}`}`))}
${v.children.length ? html`<section><h2>Subpages</h2><ul>${v.children.map((c) => html`<li><a href="${wpath(space, c)}">${c.title}</a></li>`)}</ul></section>` : ''}
${v.backlinks.length ? html`<section><h2>What links here</h2><ul>${v.backlinks.map((b) => html`<li><a href="${wpath(b.space, b.page)}">${b.page.title}</a>${b.space.id !== space.id ? html` <span class="wk-muted">(${b.space.name})</span>` : ''}</li>`)}</ul></section>` : ''}
<footer class="wk-meta"><p>Revision ${String(rev.number)}${rev.kind === 'revert' ? html` (a revert to revision ${String(rev.revertedTo)})` : ''}, saved ${t(rev.createdAt)}${page.published_at ? html` · first published ${t(page.published_at)}` : ''} · <a href="${base}/history">history</a> · <a href="${base}.json">JSON</a></p>
@@@ -139,7 +165,7 @@ ${list.map((r, i) => html`<tr${r.published ? raw(' class="wk-live"') : ''}><td><
<td><a href="${base}?rev=${String(r.number)}">${String(r.number)}</a>${r.published ? html` <span class="wk-tag">published</span>` : ''}</td>
<td>${t(r.createdAt)}</td><td>${r.author || ''}</td>
<td>${r.kind}${r.meta && r.meta.authorship && r.meta.authorship.mode !== 'human' ? html` · ${r.meta.authorship.mode === 'ai' ? 'AI-generated' : r.meta.authorship.mode}` : ''}${r.proposal ? html` · proposal ${r.proposal.status}` : ''}</td>
--<td>${r.message || ''} <span class="wk-muted">${String(r.citationCount)} source(s)</span>
++<td>${r.message || ''} <a class="wk-muted" href="${base}/sources?rev=${String(r.number)}">${String(r.citationCount)} source(s)</a>
${r.aiAssisted ? html` <span class="wk-tag">${r.needsReview ? 'AI-assisted, not yet reviewed' : (r.review && r.review.decision === 'approved' ? 'reviewed by a person' : 'AI-assisted')}</span>` : ''}
${canEdit && !r.published ? html` <a href="${base}/revert?to=${String(r.number)}">revert to this</a>` : ''}
${canEdit && !r.published && (!r.proposal || r.proposal.status === 'approved') ? html` <a href="${base}/publish?rev=${String(r.number)}">publish this</a>` : ''}</td></tr>`)}
@@@ -322,7 +348,7 @@ function signInPage({ next, message }) {
}
module.exports = {
-- home, spacePage, articlePage, historyPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
++ home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
spaceSettingsPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
signInPage, errorBody, wpath, VIS_LABEL,
};
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex d1fabfc..38021cf 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -908,6 +908,37 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
},
citationsOf(page, revisionNumber) { return citations.forRevision(page.id, Number(revisionNumber)); },
++
++ /**
++ * The citation inspector: one revision's sources (the published one by default) with their
++ * retrieval times, where each was first cited, and how the list changed from the previous
++ * revision the actor may read (kept, new, restored from an older revision, dropped). Same
++ * read rules as the article (view() refuses what the actor may not see); an origin revision
++ * the actor may not read is not named.
++ */
++ citationInspector(space, page, actor, { revision } = {}) {
++ const v = svc.view(space, page, actor, { revision });
++ const n = v.revision.number;
++ const readable = svc.history(page, { limit: 500, actor });
++ const visible = new Set(readable.map((r) => r.number));
++ const lineage = (c) => c.carriedFrom || c.id;
++ const prev = readable.find((r) => r.number < n) || null;
++ const prevCites = prev ? citations.forRevision(page.id, prev.number) : [];
++ const prevLines = new Set(prevCites.map(lineage));
++ const items = v.citations.map((c) => {
++ const first = c.carriedFrom ? citations.get(c.carriedFrom) : c;
++ const shown = first && visible.has(first.revision) ? first : null;
++ const change = prevLines.has(lineage(c)) ? 'kept' : (c.carriedFrom ? 'restored' : 'new');
++ return { ...c, change, firstRevision: shown ? shown.revision : null, firstAttachedAt: shown ? shown.attachedAt : null };
++ });
++ const here = new Set(v.citations.map(lineage));
++ return {
++ view: v, citations: items,
++ previous: prev ? prev.number : null,
++ dropped: prevCites.filter((c) => !here.has(lineage(c))),
++ revisions: readable.map((r) => ({ number: r.number, createdAt: r.createdAt, citationCount: r.citationCount, published: r.published })),
++ };
++ },
citationHistory(page) { return citations.history(page.id); },
/**
ddiff --git a/test/citation-inspector.test.js b/test/citation-inspector.test.js
nnew file mode 100644
iindex 0000000..4200da6
---- /dev/null
++++ b/test/citation-inspector.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * The citation inspector (/w/:space/:slug/sources) works without JavaScript: a revision's sources
++ * with their retrieval times, licenses, where each was first cited and what changed from the
++ * previous revision (kept, new, dropped), linked from the article and the history page. It follows
++ * the article's read rules exactly: drafts, newer unpublished revisions, members/private pages and
++ * deleted pages do not leak through it; renamed pages redirect to it.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
++
++(async () => {
++ const h = await H.boot();
++ const owner = { kind: 'user', subject: H.subject(), staff: false };
++ const cookie = H.cookieFor(H.userToken({ subject: owner.subject }));
++ try {
++ const space = h.svc.createSpace({ name: 'Bread', slug: 'bread' }, owner);
++ const { page } = h.svc.createPage(space.id, {
++ title: 'Rye', body: H.LONG,
++ citations: [
++ { url: 'https://flour.example/rye', title: 'Rye flour', retrievedAt: '2026-09-01T00:00:00Z', licenseNote: 'CC BY 4.0', quote: { text: 'Rye has less gluten.' } },
++ { url: 'https://old.example/bread', title: 'Old bread book', retrievedAt: '2026-08-15T00:00:00Z' },
++ ],
++ }, owner);
++ h.svc.publish(page.id, {}, owner);
++ const r1 = h.svc.citationsOf(page, 1);
++ // Revision 2 keeps the first source, drops the second and adds a third.
++ h.svc.editPage(page.id, {
++ expectedRevision: 1, body: `${H.LONG} Sourdough helps.`, keepCitations: [r1[0].id],
++ citations: [{ url: 'https://sour.example/dough', title: 'Sourdough notes', retrievedAt: '2026-09-10T12:00:00Z' }],
++ }, owner);
++ h.svc.publish(page.id, {}, owner);
++ // Revision 3 is an unpublished draft citing something not public yet.
++ h.svc.editPage(page.id, { expectedRevision: 2, body: `${H.LONG} Draft.`, citations: [{ url: 'https://secret.example/draft', title: 'Unpublished source', retrievedAt: '2026-09-20T00:00:00Z' }] }, owner);
++
++ // Linked from the article and the history page.
++ let r = await H.req(h, 'GET', '/w/bread/rye');
++ assert.ok(r.text.includes('href="/w/bread/rye/sources"'), 'the article links the inspector');
++ r = await H.req(h, 'GET', '/w/bread/rye/history');
++ assert.ok(r.text.includes('href="/w/bread/rye/sources?rev=1"'), 'each history row links its sources');
++ r = await H.req(h, 'GET', '/w/bread/rye?rev=1');
++ assert.ok(r.text.includes('href="/w/bread/rye/sources?rev=1"'), 'an older revision links its own sources');
++
++ // The published revision by default, readable without scripts.
++ r = await H.req(h, 'GET', '/w/bread/rye/sources');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
++ assert.ok(r.text.includes('<meta name="robots" content="noindex, follow">'));
++ let page2 = stripScripts(r.text);
++ assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));
++ assert.ok(page2.includes('This is the published revision.'));
++ assert.ok(page2.includes('href="https://flour.example/rye" rel="noopener nofollow">Rye flour</a>'));
++ assert.ok(page2.includes('<time datetime="2026-09-01T00:00:00.000Z">'), 'retrieval time of the kept source');
++ assert.ok(page2.includes('<time datetime="2026-09-10T12:00:00.000Z">'), 'retrieval time of the new source');
++ assert.ok(page2.includes('CC BY 4.0') && page2.includes('Rye has less gluten.'));
++ assert.ok(page2.includes('kept from revision 1') && page2.includes('new in this revision'));
++ assert.ok(page2.includes('href="/w/bread/rye/sources?rev=1">revision 1</a>'), 'the kept source was first cited in revision 1');
++ assert.ok(/Dropped since revision 1<\/h2><ul><li><a href="https:\/\/old\.example\/bread"/.test(page2), 'the dropped source is listed');
++ assert.ok(!page2.includes('secret.example') && !page2.includes('Revision 3'), 'a newer draft does not leak');
++ // Any older revision a reader may see.
++ r = await H.req(h, 'GET', '/w/bread/rye/sources?rev=1');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Sources of Rye, revision 1') && r.text.includes('old.example/bread') && r.text.includes('This is not the published revision'));
++ for (const bad of ['?rev=3', '?rev=99', '?rev=x']) assert.strictEqual((await H.req(h, 'GET', `/w/bread/rye/sources${bad}`)).status, 404, bad);
++ // Editors see the draft's sources, privately.
++ r = await H.req(h, 'GET', '/w/bread/rye/sources?rev=3', { cookie });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.ok(r.text.includes('secret.example/draft'));
++
++ // A renamed page redirects to its inspector.
++ h.svc.movePage(page.id, { slug: 'rye-bread' }, owner);
++ r = await H.req(h, 'GET', '/w/bread/rye/sources?rev=1');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), '/w/bread/rye-bread/sources?rev=1');
++
++ // Same read rules as the article: private pages, members spaces, drafts and deleted pages.
++ h.svc.setPageVisibility(page.id, { visibility: 'private' }, owner);
++ r = await H.req(h, 'GET', '/w/bread/rye-bread/sources');
++ assert.strictEqual(r.status, 404);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.ok(!r.text.includes('flour.example'));
++ assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie })).status, 200, 'the owner still inspects it');
++ h.svc.setPageVisibility(page.id, { visibility: 'public' }, owner);
++ h.svc.updateSpace(space.id, { visibility: 'members' }, owner);
++ assert.strictEqual((await H.req(h, 'GET', '/w/bread/rye-bread/sources')).status, 404, 'anonymous: members space');
++ r = await H.req(h, 'GET', '/w/bread/rye-bread/sources', { cookie: H.cookieFor(H.userToken()) });
++ assert.strictEqual(r.status, 200, 'any signed-in account reads a members space');
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ h.svc.updateSpace(space.id, { visibility: 'public' }, owner);
++ const draft = h.svc.createPage(space.id, { title: 'Spelt', body: H.LONG, citations: [{ url: 'https://spelt.example/', retrievedAt: '2026-09-02' }] }, owner).page;
++ assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 404, 'a draft page');
++ h.svc.deletePage(draft.id, owner);
++ assert.strictEqual((await H.req(h, 'GET', '/w/bread/spelt/sources')).status, 410);
++ console.log('citation inspector ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });