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

OP OpenVibers committed 23 Sept 2026 · ed94b7ac53 · 4 files, +111 −6


Changes

ddiff --git a/README.md b/README.md
iindex 22688c6..1ab0b47 100644
---- a/README.md
++++ b/README.md
@@@ -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 from
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`.
- **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 this
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
--`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.
---
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex 631a26f..e2070c2 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -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())));
ddiff --git a/test/feeds.test.js b/test/feeds.test.js
nnew file mode 100644
iindex 0000000..fea9094
---- /dev/null
++++ b/test/feeds.test.js
@@@ -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); });
ddiff --git a/test/seed.test.js b/test/seed.test.js
iindex 3249135..68165b7 100644
---- a/test/seed.test.js
++++ b/test/seed.test.js
@@@ -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');