openvibers/openvibe.wiki Commits

Threat review fixes: outbound links in community spaces are rel="nofollow ugc noopener" (Markdown links, bare URLs, citations, infobox URLs; official spaces unchanged); a page summary is text of at most 300 characters on every write path (any JSON value of any size was stored and put in the meta description); AI proposals come only from first-party svc: principals (a developer app or module holding wiki.revision.propose could file draft pages into any space, private ones included); GET /search and revision diffs (SSR and API) are rate-limited per address (60/min, 30/min). test/threat-review.test.js; test helpers can boot with rate limits on

OP OpenVibers committed 23 Sept 2026 · ea468b48c5 · 6 files, +146 −16


Changes

ddiff --git a/server/app.js b/server/app.js
iindex 005db9a..a123de5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -76,6 +76,9 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
// Imports are the heaviest writes (up to 200 pages in one transaction): a few per hour.
app.post(['/api/v1/spaces/:space/import', '/s/:space/import'], limiter(60 * 60000, 20));
++ // A word diff of two very different 200,000-character revisions costs a few hundred ms of CPU
++ // and tens of MB (openvibe-publishing/diff caps the search at 4000 edits): per address, not per crawl.
++ app.get(['/w/:space/:slug/diff/:a/:b', '/api/v1/pages/:id/diff'], limiter(60000, 30));
app.use('/api/', limiter(60000, 300));
app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
@@@ -87,6 +90,8 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
app.use(createMachine({ svc, config }));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
++ // Search scans the text of every published page (LIKE): a person's pace, not a crawler's.
++ app.get('/search', limiter(60000, 60));
const pages = createPages({ svc, viewers, platform, config, log });
app.use(pages.router);
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 35e7d23..bd9cfe2 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -14,6 +14,9 @@ const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibe
function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
++/** rel for outbound links (citations, infobox URLs): community spaces add ugc (see service.linkRel). */
++function outRel(space) { return space && space.kind === 'official' ? 'noopener nofollow' : 'nofollow ugc noopener'; }
++
function notice(kind, text) { return html`<p class="wk-notice wk-${kind}" role="status">${text}</p>`; }
function crumbs(items) { return raw(ssr.breadcrumbsHtml(items)); }
@@@ -51,11 +54,11 @@ ${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length}
</section>`;
}
--function infoboxHtml(entries, resolve) {
++function infoboxHtml(entries, resolve, rel = 'nofollow ugc noopener') {
if (!entries.length) return '';
const cell = (x) => {
switch (x.type) {
-- case 'url': return html`<a href="${x.value}" rel="noopener nofollow">${x.value.replace(/^https?:\/\//, '')}</a>`;
++ case 'url': return html`<a href="${x.value}" rel="${rel}">${x.value.replace(/^https?:\/\//, '')}</a>`;
case 'date': return raw(ssr.timeTag(x.value, { label: String(x.value).slice(0, 10) }) || ssr.escapeHtml(x.value));
case 'boolean': return x.value ? 'Yes' : 'No';
case 'number': return String(x.value);
@@@ -67,10 +70,10 @@ 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, inspectHref) {
++function citationsHtml(cites, inspectHref, rel = 'nofollow ugc noopener') {
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>`;
++ 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="${rel}">${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' };
@@@ -80,8 +83,9 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, dropped
const base = wpath(space, page);
const n = v.revision.number;
const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
++ const rel = outRel(space);
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.url ? html`<a href="${c.url}" rel="${rel}">${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>
@@@ -92,7 +96,7 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, dropped
<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>`}` : ''}
++${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="${rel}">${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>`;
@@@ -142,10 +146,10 @@ function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolv
<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}">
<header><h1>${rev.fields.title || page.title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav></header>
${banners}
--${raw(infoboxHtml(v.infobox, resolve))}
++${raw(infoboxHtml(v.infobox, resolve, outRel(space)))}
<div class="wk-content">${raw(contentHtml)}</div>
${raw(mediaHtml(v.attachments, mediaUrl))}
--${raw(citationsHtml(v.citations, `${base}/sources${v.isPublishedRevision ? '' : `?rev=${rev.number}`}`))}
++${raw(citationsHtml(v.citations, `${base}/sources${v.isPublishedRevision ? '' : `?rev=${rev.number}`}`, outRel(space)))}
${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>
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex e3392ee..b1dbddf 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -29,6 +29,7 @@ class WikiError extends Error {
const SPACE_KINDS = ['official', 'user'];
const MAX_BODY = 200000;
const MAX_TITLE = 200;
++const MAX_SUMMARY = 300;
const GATE_VIS = { public: 'public', members: 'gated', private: 'private' };
const PAGE_ID_RE = /^pg_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -205,6 +206,15 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return t;
}
++ /** A summary is one short sentence of text (it becomes the meta description, feeds and Search). */
++ function checkSummary(summary) {
++ if (summary == null || summary === '') return null;
++ if (typeof summary !== 'string') fail(422, 'page.invalid_summary', 'summary must be text');
++ const t = summary.replace(/\s+/g, ' ').trim();
++ if (t.length > MAX_SUMMARY) fail(422, 'page.invalid_summary', `A summary is at most ${MAX_SUMMARY} characters`);
++ return t || null;
++ }
++
function checkBody(body) {
if (typeof body !== 'string') fail(422, 'page.invalid_body', 'body must be Markdown text');
if (body.length > MAX_BODY) fail(413, 'page.body_too_large', `A page is at most ${MAX_BODY} characters`);
@@@ -401,6 +411,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
++ const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const vis = checkVisibility(visibility, 'public');
@@@ -413,7 +424,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
redirects.release(`/w/${space.slug}/${slug}`);
const { revision } = wrapContentError(() => revisions.create({
-- entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },
++ entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: cleanSummary, infobox: box },
meta: { authorship: rec }, author: actorId(actor), message,
}));
const page = q.pageById.get(id);
@@@ -675,7 +686,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
if (!head) fail(404, 'revision.not_found', 'This page has no revision');
const text = body == null ? head.content : checkBody(body);
const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));
-- const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : (summary || null), infobox: box };
++ const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : checkSummary(summary), infobox: box };
const { revision, created } = wrapContentError(() => revisions.create({
entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,
}));
@@@ -904,10 +915,17 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return { href: null, exists: false };
},
++ /**
++ * Markdown → safe HTML. Outbound links in community (user) spaces carry rel="nofollow ugc":
++ * nobody vouches for them, so they pass no ranking (link spam gains nothing). Official spaces
++ * are edited by staff, who vouch for their links.
++ */
renderRevision(space, rev, actor) {
-- return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor) });
++ return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor), rel: svc.linkRel(space) });
},
++ linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },
++
history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));
const space = q.spaceById.get(page.space_id);
@@@ -1171,9 +1189,13 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
* pageId null + title = propose a new page (created as a draft).
*/
propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
-- if (!actor || (actor.kind !== 'service' && actor.kind !== 'system')) fail(403, 'proposal.service_only', 'AI proposals come from a service principal');
++ // A proposal may target any space, private ones included, and never needs a person: only
++ // first-party services (svc:…, i.e. OpenVibe.AI) file them, never a developer app or module.
++ const firstParty = actor && actor.kind === 'service' && /^svc:/.test(String(actor.service)) && (!actor.claims || actor.claims.actor_type === 'service');
++ if (!actor || (actor.kind !== 'system' && !firstParty)) fail(403, 'proposal.service_only', 'AI proposals come from a first-party service principal');
if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');
const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));
++ const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
return tx(() => {
@@@ -1194,7 +1216,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });
}
const head = base ? revisions.head(page.id) : null;
-- const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary, infobox: box };
++ const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary: cleanSummary, infobox: box };
const { revision } = wrapContentError(() => revisions.create({
entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),
message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
ddiff --git a/test/citation-inspector.test.js b/test/citation-inspector.test.js
iindex 4200da6..08ec1a9 100644
---- a/test/citation-inspector.test.js
++++ b/test/citation-inspector.test.js
@@@ -51,7 +51,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
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('href="https://flour.example/rye" rel="nofollow ugc noopener">Rye flour</a>'), 'a community space\'s links pass no ranking');
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.'));
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 0fe29a8..8a82965 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -34,7 +34,7 @@ const quiet = { log() {}, warn() {}, error() {} };
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* (Community, Sources, Media, Events, Network token endpoint).
*/
--async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens } = {}) {
++async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false } = {}) {
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
@@@ -43,7 +43,7 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
...env,
});
const h = await start({
-- config, publicKey, log: quiet, listen: true, workers, rateLimits: false, now,
++ config, publicKey, log: quiet, listen: true, workers, rateLimits, now,
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
ddiff --git a/test/threat-review.test.js b/test/threat-review.test.js
nnew file mode 100644
iindex 0000000..fc0c308
---- /dev/null
++++ b/test/threat-review.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * Regressions for the gaps fixed in the threat review (docs/threat-review.md):
++ * - outbound links in community (user) spaces carry rel="nofollow ugc" — Markdown links, bare
++ * URLs, citations and infobox URLs — so link spam passes no ranking; official spaces keep theirs;
++ * - a page summary is short text (it becomes the meta description, feeds and the Search document):
++ * anything else is refused instead of stored;
++ * - AI proposals (which reach any space without a person) come only from first-party services;
++ * - the HTML search (a LIKE scan over every published page) and revision diffs (CPU and memory
++ * heavy for large, very different revisions) are rate-limited per address.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++const { SYSTEM } = require('../server/wiki/seed');
++
++(async () => {
++ const h = await H.boot();
++ const me = H.subject();
++ const tok = H.userToken({ subject: me });
++ const cite = [{ url: 'https://cited.example/a', title: 'Cited', retrievedAt: '2026-09-01' }];
++ const body = `[a link](https://spam.example/one) and https://spam.example/two ${H.LONG}`;
++ const infobox = [{ label: 'Site', type: 'url', value: 'https://infobox.example/' }];
++ const anchors = (html, host) => html.match(new RegExp(`<a href="https://${host.replace('.', '\\.')}[^"]*"[^>]*>`, 'g')) || [];
++ try {
++ // Community space: every outbound link is nofollow ugc.
++ const user = h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });
++ const up = h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me }).page;
++ h.svc.publish(up.id, {}, { kind: 'user', subject: me });
++ let html = (await H.req(h, 'GET', '/w/community/links')).text;
++ const userLinks = [...anchors(html, 'spam.example'), ...anchors(html, 'cited.example'), ...anchors(html, 'infobox.example')];
++ assert.strictEqual(userLinks.length, 4, userLinks.join('\n'));
++ for (const a of userLinks) assert.ok(/rel="nofollow ugc noopener"/.test(a), a);
++ html = (await H.req(h, 'GET', '/w/community/links/sources')).text;
++ assert.ok(anchors(html, 'cited.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));
++ // The editor's preview renders the same way.
++ html = (await H.req(h, 'POST', '/s/community/new', { cookie: H.cookieFor(tok), form: { op: 'preview', title: 'X', body } })).text;
++ assert.ok(anchors(html, 'spam.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));
++
++ // Official space: staff vouch for their links.
++ const off = h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);
++ const op = h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true }).page;
++ h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });
++ html = (await H.req(h, 'GET', '/w/official/links')).text;
++ assert.ok(anchors(html, 'spam.example').every((a) => /rel="noopener"/.test(a)) && anchors(html, 'spam.example').length === 2);
++ assert.ok(anchors(html, 'cited.example').every((a) => /rel="noopener nofollow"/.test(a)));
++
++ // Summaries: short text only, through every write path.
++ const bad = [{ a: 1 }, ['x'], 42, 's'.repeat(301)];
++ for (const summary of bad) {
++ const r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: `S ${String(summary).length}`, body: H.LONG, summary } });
++ assert.strictEqual(r.status, 422, JSON.stringify(summary).slice(0, 40));
++ assert.strictEqual(r.json.code, 'page.invalid_summary');
++ }
++ let r = await H.req(h, 'POST', `/api/v1/pages/${up.id}/revisions`, { token: tok, body: { expected_revision: 1, summary: 'x'.repeat(5000) } });
++ assert.strictEqual(r.status, 422);
++ assert.throws(() => h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');
++ r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: 'Summarised', body: H.LONG, summary: ' One short sentence. ' } });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.json.revision.summary, 'One short sentence.');
++
++ // AI proposals reach any space without a person, so only first-party services file them:
++ // a developer app or module holding the capability is refused.
++ const proposal = { space: 'community', title: 'Planted', body: H.LONG, workflow: { id: 'wiki.generate_page', run_id: 'run_2' } };
++ for (const [sub, actorType] of [['app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app'], ['mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'mod']]) {
++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ sub, actorType, cap: ['wiki.revision.propose'], extra: { on_behalf_of: me } }), body: proposal });
++ assert.strictEqual(r.status, 403, `${actorType}: ${r.text}`);
++ assert.strictEqual(r.json.code, 'proposal.service_only');
++ }
++ assert.strictEqual(h.svc.findPage(user.id, 'planted'), null);
++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose'] }), body: proposal });
++ assert.strictEqual(r.status, 201, r.text);
++ await h.stop();
++
++ // The HTML search and diffs are rate-limited per address (other pages are not).
++ const limited = await H.boot({ rateLimits: true });
++ try {
++ for (let i = 0; i < 60; i++) assert.strictEqual((await H.req(limited, 'GET', `/search?q=w${i}`)).status, 200);
++ assert.strictEqual((await H.req(limited, 'GET', '/search?q=again')).status, 429);
++ assert.strictEqual((await H.req(limited, 'GET', '/')).status, 200);
++ const owner = { kind: 'user', subject: H.subject() };
++ const sp = limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);
++ const pg = limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner).page;
++ limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);
++ limited.svc.publish(pg.id, {}, owner);
++ for (let i = 0; i < 15; i++) {
++ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2')).status, 200);
++ assert.strictEqual((await H.req(limited, 'GET', `/api/v1/pages/${pg.id}/diff?from=1&to=2`)).status, 200);
++ }
++ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2?mode=line')).status, 429, 'SSR and API diffs share one budget');
++ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long')).status, 200);
++ } finally {
++ await limited.stop();
++ }
++ console.log('threat review regressions ok');
++ } catch (err) {
++ await h.stop().catch(() => {});
++ throw err;
++ }
++})().catch((err) => { console.error(err); process.exit(1); });