openvibers/openvibe.wiki Commits
/feed.atom answers 200 with a valid Atom feed of zero entries while nothing is listable (it is linked from every page and answered 404): the feed-level updated is the last change to a public space, or the epoch when there is none, never now and never the time of a hidden change; test/feeds.test.js checks status, content type, well-formedness, id/title/updated/self and that drafts, private pages and unreviewed AI-assisted imports never become entries
Changes
@@@ -95,7 +95,8 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.(approval publishes it) or rejects it. Published AI text carries a disclosure at the item.- **Discovery (§32)**: server-rendered pages with canonical URLs, robots from the gate, JSON-LD fromreal fields only (no invented author, image or date), breadcrumbs; `/sitemap.xml` (index) →-- `/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes);++ `/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes; with nothing listable++ yet they are valid feeds with zero entries, never a 404);`/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons(policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text and@@@ -204,6 +205,7 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit- 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`)++- `/feed.atom` is a valid Atom feed with zero entries while nothing is listable (`feeds.test.js`)- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)- permissions are enforced; visitors without SSO read public content only; AI proposals need a@@@ -229,8 +231,7 @@ held (plan §12.12); the launch release went out on 2026-09-23:The launch release removed `openvibe.wiki` from `OpenVibe.Sites/sites.json`, switched routing to thisservice and opened its Network hub entry in the same release. A placeholder is never counted as animplemented service. Still open: a person's review of the 10 seed pages (until then they stay--`noindex`), `/feed.atom` answers 404 while no page is indexable, and there are no event payload--schemas in OpenVibe.Contracts.++`noindex`) and there are no event payload schemas in OpenVibe.Contracts.---@@@ -70,10 +70,21 @@ function createMachine({ svc, config }) {}));}++ /**++ * Atom needs a feed-level <updated>. With entries it is the newest entry's. With none (nothing++ * listable yet) it is the last change to a public space — never "now", and never the time of a++ * change readers cannot see — or the Unix epoch when there is no public space at all. The feed is++ * linked from every page, so an empty one is a valid feed with zero entries, not a 404.++ */++ function emptyFeedUpdated() {++ const times = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => s.updated_at);++ return new Date(times.length ? Math.max(...times) : 0).toISOString();++ }++router.get('/feed.atom', (_req, res) => {const items = feedItems();-- if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('Nothing public has been published yet.');-- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));++ const updated = items.some((i) => i.decision.listable) ? null : emptyFeedUpdated();++ cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));});router.get('/feed.json', (_req, res) => {cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, feedItems())));nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * /feed.atom is linked from every page, so it always answers: with nothing listable it is a valid++ * Atom feed with zero entries (200, never 404). The feed-level <updated> is a real time — the last++ * change to a public space, or the epoch when there is none — never "now" and never the time of a++ * change readers cannot see. Unlisted content (drafts, private pages, unreviewed AI-assisted++ * imports) never appears as an entry.++ */++const assert = require('assert');++const H = require('./helpers');++const { SYSTEM, IMPORTED } = require('../server/wiki/seed');++++/** A small well-formedness check: balanced tags, one root, no stray markup characters in text. */++function assertWellFormed(xml) {++ const body = xml.replace(/^<\?xml[^?]*\?>\s*/, '');++ const stack = [];++ let roots = 0;++ let last = 0;++ for (const m of body.matchAll(/<(\/?)([A-Za-z][\w:.-]*)((?:\s+[\w:.-]+="[^"<]*")*)\s*(\/?)>/g)) {++ const text = body.slice(last, m.index);++ assert.ok(!/[<>]|&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/i.test(text), `stray markup in text: ${text.slice(0, 80)}`);++ last = m.index + m[0].length;++ const [, close, name, , selfClose] = m;++ if (!stack.length && !close) roots++;++ if (close) assert.strictEqual(stack.pop(), name, `</${name}> closes the wrong element`);++ else if (!selfClose) stack.push(name);++ }++ assert.strictEqual(body.slice(last).trim(), '', 'nothing after the root element');++ assert.strictEqual(stack.length, 0, `unclosed: ${stack.join(', ')}`);++ assert.strictEqual(roots, 1, 'exactly one root element');++}++++function atom(text) {++ assertWellFormed(text);++ const head = text.split('<entry>')[0];++ const one = (tag, src = head) => { const m = src.match(new RegExp(`<${tag}>([^<]*)</${tag}>`)); return m ? m[1] : null; };++ return {++ root: /<feed xmlns="http:\/\/www\.w3\.org\/2005\/Atom">/.test(text),++ id: one('id'), title: one('title'), updated: one('updated'),++ self: (head.match(/<link rel="self" type="application\/atom\+xml" href="([^"]+)"\/>/) || [])[1] || null,++ entries: [...text.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => ({ id: one('id', m[1]), updated: one('updated', m[1]), href: (m[1].match(/href="([^"]+)"/) || [])[1] })),++ };++}++++(async () => {++ const h = await H.boot();++ const owner = { kind: 'user', subject: H.subject(), staff: false };++ try {++ // A new wiki: nothing at all.++ let r = await H.req(h, 'GET', '/feed.atom');++ assert.strictEqual(r.status, 200, 'an empty feed is a feed, not a 404');++ assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');++ let f = atom(r.text);++ assert.ok(f.root, 'root <feed> in the Atom namespace');++ assert.strictEqual(f.id, 'http://wiki.test/feed.atom');++ assert.strictEqual(f.title, 'OpenVibe.Wiki: recent changes');++ assert.strictEqual(f.self, 'http://wiki.test/feed.atom');++ assert.strictEqual(f.updated, '1970-01-01T00:00:00.000Z', 'no public space: the epoch, never "now"');++ assert.deepStrictEqual(f.entries, []);++++ // Published, but nothing listable: an unreviewed AI-assisted import, a private page, a draft.++ const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);++ const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];++ const seeded = h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM).page;++ h.svc.publish(seeded.id, {}, SYSTEM);++ const hidden = h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner).page;++ h.svc.publish(hidden.id, {}, owner);++ h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);++ const secret = h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);++ const inSecret = h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner).page;++ h.svc.publish(inSecret.id, {}, owner);++ r = await H.req(h, 'GET', '/feed.atom');++ assert.strictEqual(r.status, 200);++ f = atom(r.text);++ assert.deepStrictEqual(f.entries, []);++ assert.strictEqual(f.updated, new Date(h.svc.findSpace('notes').updated_at).toISOString(), 'the last change to a public space');++ assert.ok(!/imported-page|private-page|draft-page|hidden-plans/.test(r.text), 'nothing unlisted leaks into the feed');++ assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);++++ // A listable page: one entry, and the feed's <updated> is that entry's.++ const open = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;++ h.svc.publish(open.id, {}, owner);++ f = atom((await H.req(h, 'GET', '/feed.atom')).text);++ assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);++ assert.strictEqual(f.updated, f.entries[0].updated);++ console.log('feeds ok');++ } finally {++ await h.stop();++ }++})().catch((err) => { console.error(err); process.exit(1); });@@@ -63,7 +63,9 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope}// Absent from sitemaps, feeds and Search until reviewed.assert.strictEqual(((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.match(/<loc>/g) || []).length, 0);-- assert.strictEqual((await H.req(h, 'GET', '/feed.atom')).status, 404);++ const atomFeed = await H.req(h, 'GET', '/feed.atom');++ assert.strictEqual(atomFeed.status, 200, 'an empty feed, not a 404');++ assert.ok(!atomFeed.text.includes('<entry>'));assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);const idx = H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.'));assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');
@@ -95,7 +95,8 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
@@ -95,7 +95,8 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
(approval publishes it) or rejects it. Published AI text carries a disclosure at the item.
(approval publishes it) or rejects it. Published AI text carries a disclosure at the item.
- **Discovery (§32)**: server-rendered pages with canonical URLs, robots from the gate, JSON-LD from
- **Discovery (§32)**: server-rendered pages with canonical URLs, robots from the gate, JSON-LD from
real fields only (no invented author, image or date), breadcrumbs; `/sitemap.xml` (index) →
real fields only (no invented author, image or date), breadcrumbs; `/sitemap.xml` (index) →
- `/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes);
+ `/sitemaps/spaces.xml`, `/sitemaps/pages-N.xml`; `/feed.atom` and `/feed.json` (recent changes; with nothing listable
+ yet they are valid feeds with zero entries, never a 404);
`/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.
`/robots.txt`; `/llms.txt`; a JSON representation of every page at `<page>.json`.
- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons
- **The gate**: `openvibe-publishing/seo` decides indexing per page with explicit reasons
(policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text and
(policy: at least `WIKI_GATE_MIN_WORDS` words and `WIKI_GATE_MIN_SOURCES` citations; AI text and
@@ -204,6 +205,7 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
@@ -204,6 +205,7 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
- scheduled publication is idempotent across worker restarts (`schedule.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)
- 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`)
and is served `Cache-Control: private`; renames 301, deletions 410 (`visibility.test.js`)
+- `/feed.atom` is a valid Atom feed with zero entries while nothing is listable (`feeds.test.js`)
- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)
- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)
- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)
- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)
- permissions are enforced; visitors without SSO read public content only; AI proposals need a
- permissions are enforced; visitors without SSO read public content only; AI proposals need a
@@ -229,8 +231,7 @@ held (plan §12.12); the launch release went out on 2026-09-23:
@@ -229,8 +231,7 @@ held (plan §12.12); the launch release went out on 2026-09-23:
The launch release removed `openvibe.wiki` from `OpenVibe.Sites/sites.json`, switched routing to this
The launch release removed `openvibe.wiki` from `OpenVibe.Sites/sites.json`, switched routing to this
service and opened its Network hub entry in the same release. A placeholder is never counted as an
service and opened its Network hub entry in the same release. A placeholder is never counted as an
implemented service. Still open: a person's review of the 10 seed pages (until then they stay
implemented service. Still open: a person's review of the 10 seed pages (until then they stay
-`noindex`), `/feed.atom` answers 404 while no page is indexable, and there are no event payload
-schemas in OpenVibe.Contracts.
+`noindex`) and there are no event payload schemas in OpenVibe.Contracts.
---
---
@@ -70,10 +70,21 @@ function createMachine({ svc, config }) {
@@ -70,10 +70,21 @@ function createMachine({ svc, config }) {
}));
}));
}
}
+ /**
+ * Atom needs a feed-level <updated>. With entries it is the newest entry's. With none (nothing
+ * listable yet) it is the last change to a public space — never "now", and never the time of a
+ * change readers cannot see — or the Unix epoch when there is no public space at all. The feed is
+ * linked from every page, so an empty one is a valid feed with zero entries, not a 404.
+ */
+ function emptyFeedUpdated() {
+ const times = svc.listSpaces({ kind: 'anonymous' }).filter((s) => s.visibility === 'public').map((s) => s.updated_at);
+ return new Date(times.length ? Math.max(...times) : 0).toISOString();
+ }
+
router.get('/feed.atom', (_req, res) => {
router.get('/feed.atom', (_req, res) => {
const items = feedItems();
const items = feedItems();
- if (!items.some((i) => i.decision.listable)) return res.status(404).set('Cache-Control', 'public, max-age=60').type('text/plain').send('Nothing public has been published yet.');
- cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom` }, items));
+ const updated = items.some((i) => i.decision.listable) ? null : emptyFeedUpdated();
+ cache(res).type('application/atom+xml').send(seo.atomFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.atom`, id: `${origin}/feed.atom`, ...(updated ? { updated } : {}) }, items));
});
});
router.get('/feed.json', (_req, res) => {
router.get('/feed.json', (_req, res) => {
cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, feedItems())));
cache(res).type('application/feed+json').send(JSON.stringify(seo.jsonFeed({ title: 'OpenVibe.Wiki: recent changes', link: `${origin}/recent`, feedUrl: `${origin}/feed.json`, description: 'Public wiki pages by the time their current revision was published.' }, feedItems())));
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * /feed.atom is linked from every page, so it always answers: with nothing listable it is a valid
+ * Atom feed with zero entries (200, never 404). The feed-level <updated> is a real time — the last
+ * change to a public space, or the epoch when there is none — never "now" and never the time of a
+ * change readers cannot see. Unlisted content (drafts, private pages, unreviewed AI-assisted
+ * imports) never appears as an entry.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+const { SYSTEM, IMPORTED } = require('../server/wiki/seed');
+
+/** A small well-formedness check: balanced tags, one root, no stray markup characters in text. */
+function assertWellFormed(xml) {
+ const body = xml.replace(/^<\?xml[^?]*\?>\s*/, '');
+ const stack = [];
+ let roots = 0;
+ let last = 0;
+ for (const m of body.matchAll(/<(\/?)([A-Za-z][\w:.-]*)((?:\s+[\w:.-]+="[^"<]*")*)\s*(\/?)>/g)) {
+ const text = body.slice(last, m.index);
+ assert.ok(!/[<>]|&(?!(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);)/i.test(text), `stray markup in text: ${text.slice(0, 80)}`);
+ last = m.index + m[0].length;
+ const [, close, name, , selfClose] = m;
+ if (!stack.length && !close) roots++;
+ if (close) assert.strictEqual(stack.pop(), name, `</${name}> closes the wrong element`);
+ else if (!selfClose) stack.push(name);
+ }
+ assert.strictEqual(body.slice(last).trim(), '', 'nothing after the root element');
+ assert.strictEqual(stack.length, 0, `unclosed: ${stack.join(', ')}`);
+ assert.strictEqual(roots, 1, 'exactly one root element');
+}
+
+function atom(text) {
+ assertWellFormed(text);
+ const head = text.split('<entry>')[0];
+ const one = (tag, src = head) => { const m = src.match(new RegExp(`<${tag}>([^<]*)</${tag}>`)); return m ? m[1] : null; };
+ return {
+ root: /<feed xmlns="http:\/\/www\.w3\.org\/2005\/Atom">/.test(text),
+ id: one('id'), title: one('title'), updated: one('updated'),
+ self: (head.match(/<link rel="self" type="application\/atom\+xml" href="([^"]+)"\/>/) || [])[1] || null,
+ entries: [...text.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((m) => ({ id: one('id', m[1]), updated: one('updated', m[1]), href: (m[1].match(/href="([^"]+)"/) || [])[1] })),
+ };
+}
+
+(async () => {
+ const h = await H.boot();
+ const owner = { kind: 'user', subject: H.subject(), staff: false };
+ try {
+ // A new wiki: nothing at all.
+ let r = await H.req(h, 'GET', '/feed.atom');
+ assert.strictEqual(r.status, 200, 'an empty feed is a feed, not a 404');
+ assert.match(r.headers.get('content-type'), /^application\/atom\+xml/);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
+ let f = atom(r.text);
+ assert.ok(f.root, 'root <feed> in the Atom namespace');
+ assert.strictEqual(f.id, 'http://wiki.test/feed.atom');
+ assert.strictEqual(f.title, 'OpenVibe.Wiki: recent changes');
+ assert.strictEqual(f.self, 'http://wiki.test/feed.atom');
+ assert.strictEqual(f.updated, '1970-01-01T00:00:00.000Z', 'no public space: the epoch, never "now"');
+ assert.deepStrictEqual(f.entries, []);
+
+ // Published, but nothing listable: an unreviewed AI-assisted import, a private page, a draft.
+ const space = h.svc.createSpace({ name: 'Notes', slug: 'notes' }, owner);
+ const cite = [{ url: 'https://example.org/a', retrievedAt: '2026-09-20T00:00:00Z' }];
+ const seeded = h.svc.createPage(space.id, { title: 'Imported page', body: H.LONG, citations: cite, authorship: IMPORTED }, SYSTEM).page;
+ h.svc.publish(seeded.id, {}, SYSTEM);
+ const hidden = h.svc.createPage(space.id, { title: 'Private page', body: H.LONG, citations: cite, visibility: 'private' }, owner).page;
+ h.svc.publish(hidden.id, {}, owner);
+ h.svc.createPage(space.id, { title: 'Draft page', body: H.LONG, citations: cite }, owner);
+ const secret = h.svc.createSpace({ name: 'Secret', slug: 'secret', visibility: 'private' }, owner);
+ const inSecret = h.svc.createPage(secret.id, { title: 'Hidden plans', body: H.LONG, citations: cite }, owner).page;
+ h.svc.publish(inSecret.id, {}, owner);
+ r = await H.req(h, 'GET', '/feed.atom');
+ assert.strictEqual(r.status, 200);
+ f = atom(r.text);
+ assert.deepStrictEqual(f.entries, []);
+ assert.strictEqual(f.updated, new Date(h.svc.findSpace('notes').updated_at).toISOString(), 'the last change to a public space');
+ assert.ok(!/imported-page|private-page|draft-page|hidden-plans/.test(r.text), 'nothing unlisted leaks into the feed');
+ assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
+
+ // A listable page: one entry, and the feed's <updated> is that entry's.
+ const open = h.svc.createPage(space.id, { title: 'Open page', body: H.LONG, citations: cite }, owner).page;
+ h.svc.publish(open.id, {}, owner);
+ f = atom((await H.req(h, 'GET', '/feed.atom')).text);
+ assert.deepStrictEqual(f.entries.map((e) => e.href), ['http://wiki.test/w/notes/open-page']);
+ assert.strictEqual(f.updated, f.entries[0].updated);
+ console.log('feeds ok');
+ } finally {
+ await h.stop();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -63,7 +63,9 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -63,7 +63,9 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
}
}
// Absent from sitemaps, feeds and Search until reviewed.
// Absent from sitemaps, feeds and Search until reviewed.
assert.strictEqual(((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.match(/<loc>/g) || []).length, 0);
assert.strictEqual(((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.match(/<loc>/g) || []).length, 0);
- assert.strictEqual((await H.req(h, 'GET', '/feed.atom')).status, 404);
+ const atomFeed = await H.req(h, 'GET', '/feed.atom');
+ assert.strictEqual(atomFeed.status, 200, 'an empty feed, not a 404');
+ assert.ok(!atomFeed.text.includes('<entry>'));
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
assert.deepStrictEqual((await H.req(h, 'GET', '/feed.json')).json.items, []);
const idx = H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.'));
const idx = H.outbox(h).filter((e) => e.event_type.startsWith('wiki.index_document.'));
assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');
assert.ok(idx.length && idx.every((e) => e.event_type === 'wiki.index_document.deleted'), 'Search only ever got tombstones');