openvibers/openvibe.wiki Commits
Publish the New on the wiki card as a frame.menu-cards array (#233)
Changes
@@@ -444,7 +444,7 @@ implemented service. Still open: a person's review of the 42 seed pages (until tPart of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.312.0++- openvibe-contracts: v0.317.0- openvibe-sdk: v0.94.0- openvibe-shared: v4.129.0- openvibe-publishing: v1.3.0@@@ -7,7 +7,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4800,"domain": "openvibe.wiki",-- "contracts": "openvibe-contracts v0.312.0",++ "contracts": "openvibe-contracts v0.317.0","sdk": "openvibe-sdk v0.94.0","shared": "openvibe-shared v4.129.0","packages": [@@@ -14,7 +14,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",@@@ -1134,9 +1134,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.312.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",++ "version": "0.317.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",++ "integrity": "sha512-qy9hGj4cl5MSHHcYmOKcrL1BmMrbQ9uJCGYH5zxhp3gu6RwZqu7kHLVWBCcy5y7SY8odkxNuVU5Ts/RksiivxQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",@@@ -147,6 +147,12 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo}));// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);++ // The menu's network card (frame.menu-cards@1): the same public recent changes the home page reads.++ {++ const { createMenuCard } = require('./menu-card');++ const menuCard = createMenuCard({ load: async () => await svc.recentChanges(6) });++ app.get('/.well-known/openvibe-menu.json', menuCard.handler);++ }app.use(createMachine({ svc, config }));app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++// The menu's network card for OpenVibe.Wiki (frame.menu-card@1): GET /.well-known/openvibe-menu.json.++//++// Wiki publishes "New on the wiki": the six pages revised most recently, each linking to its article. The rows++// are the same public recent changes the home page and /recent show (svc.recentChanges), which already keeps++// drafts, members-only wikis and private pages out. The file is CORS *, cached for five minutes; with nothing++// published it is served with no items and Network skips it.++const BASE = 'https://openvibe.wiki';++const CARD_ID = 'new-pages';++const SITE = 'wiki';++const CARD_TTL_S = 300;++const CACHE_MS = CARD_TTL_S * 1000;++const LIMIT = 6;++++/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */++function plain(value, max) {++ return String(value == null ? '' : value)++ .replace(/<[^>]*>/g, ' ')++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')++ .replace(/\s+/g, ' ')++ .trim()++ .slice(0, max);++}++++/** "just now", "12m ago", "3h ago" or "2d ago"; empty when unknown. */++function ago(value, now) {++ const at = typeof value === 'number' ? value : Date.parse(String(value || ''));++ if (!Number.isFinite(at)) return '';++ const mins = Math.max(0, Math.floor((now - at) / 60_000));++ if (mins < 1) return 'just now';++ if (mins < 60) return `${mins}m ago`;++ const hours = Math.floor(mins / 60);++ if (hours < 24) return `${hours}h ago`;++ return `${Math.floor(hours / 24)}d ago`;++}++++/** The article's path, the same /w/<space>/<slug> the site renders. */++const pagePath = (space, page) => `/w/${encodeURIComponent(String(space.slug))}/${encodeURIComponent(String(page.slug))}`;++++/** The card itself. `changes` are svc.recentChanges() rows: { space, page, rev, ... }. */++function cardFrom(changes, at = Date.now()) {++ const out = [];++ for (const change of Array.isArray(changes) ? changes : []) {++ const space = change && change.space;++ const page = change && change.page;++ if (!space || !page || page.state === 'deleted') continue;++ if (page.visibility && page.visibility !== 'public') continue;++ const title = plain(page.title, 80);++ if (!title || !space.slug || !page.slug) continue;++ const meta = [plain(space.name, 60), ago(page.revision_published_at || page.published_at, at)].filter(Boolean).join(' · ');++ out.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${pagePath(space, page)}` });++ if (out.length >= LIMIT) break;++ }++ return {++ id: CARD_ID,++ site: SITE,++ title: 'New on the wiki',++ kind: 'rows',++ items: out,++ more: { label: 'All wikis', url: `${BASE}/` },++ updated_at: new Date(at).toISOString(),++ ttl_s: CARD_TTL_S,++ };++}++++/** The route. `load` returns svc.recentChanges() rows; `now` is injectable for the test. */++function createMenuCard({ load, now = Date.now } = {}) {++ let cached = null;++ let cachedAt = 0;++ let pending = null;++++ async function card() {++ const at = now();++ if (cached && at - cachedAt < CACHE_MS) return cached;++ if (pending) return pending;++ pending = (async () => {++ try {++ cached = cardFrom(await load(), at);++ cachedAt = at;++ } catch {++ // Keep the last good card rather than publish an empty one on a database hiccup.++ if (!cached) { cached = cardFrom([], at); cachedAt = at; }++ }++ return cached;++ })().finally(() => { pending = null; });++ return pending;++ }++++ async function handler(_req, res) {++ const body = await card();++ res.set('Cache-Control', 'public, max-age=60')++ .set('Access-Control-Allow-Origin', '*')++ .set('Cross-Origin-Resource-Policy', 'cross-origin')++ .json([body]);++ }++++ return { card, handler, cardFrom, cacheMs: CACHE_MS };++}++++module.exports = { createMenuCard, cardFrom, ago, pagePath, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };@@@ -12,7 +12,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');(async () => {// The capabilities the routes enforce are the released ones, so capabilities.check() decides them.-- // One id waits for a Contracts release — wiki.review.ai_editor — and is decided locally until then.for (const id of Object.values(CAPS)) {const cap = contracts.capabilities.get(id);if (!cap) {@@@ -22,8 +21,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');assert.strictEqual(cap.owner, 'wiki', `${id} is owned by wiki`);assert.notStrictEqual(cap.status, 'retired', `${id} is not retired`);}-- // The AI editor's id is the one proposal; when Contracts releases it, this line goes.-- assert.strictEqual(contracts.capabilities.get('wiki.review.ai_editor'), undefined);assert.deepStrictEqual(contracts.capabilities.check({ cap: ['wiki.*'] }, CAPS.PAGE_READ),{ allowed: true, code: null, reason: null },nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++// The menu's network card for OpenVibe.Wiki (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves++// "New on the wiki" — the most recently revised public pages, each linking to its article. The array validates++// against frame.menu-cards@1, carries no private or deleted page, is CORS * and cached for five minutes.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { createMenuCard, cardFrom, pagePath, CARD_TTL_S } = require('../server/menu-card');++++const NOW = Date.parse('2026-11-07T12:00:00Z');++const change = (over = {}) => ({++ space: { slug: 'openvibe', name: 'OpenVibe', ...(over.space || {}) },++ page: {++ slug: 'how-it-works', title: 'How OpenVibe works', state: 'published', visibility: 'public',++ revision_published_at: NOW - 2 * 60 * 60_000, ...(over.page || {}),++ },++});++++// ── The card's shape and words ───────────────────────────────────────────++const card = cardFrom([++ change(),++ change({ space: { slug: 'cooking', name: 'Cooking' }, page: { slug: 'rye-bread', title: '<b>Rye</b>\nbread', revision_published_at: NOW - 26 * 60 * 60_000 } }),++], NOW);++const checked = contracts.validate('frame.menu-cards@1', [card]);++assert.ok(checked.valid, JSON.stringify(checked.errors));++assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);++assert.strictEqual(card.id, 'new-pages');++assert.strictEqual(card.site, 'wiki');++assert.strictEqual(card.title, 'New on the wiki');++assert.strictEqual(card.kind, 'rows');++assert.strictEqual(card.ttl_s, CARD_TTL_S);++assert.deepStrictEqual(card.items, [++ { title: 'How OpenVibe works', meta: 'OpenVibe · 2h ago', url: 'https://openvibe.wiki/w/openvibe/how-it-works' },++ { title: 'Rye bread', meta: 'Cooking · 1d ago', url: 'https://openvibe.wiki/w/cooking/rye-bread' },++]);++assert.deepStrictEqual(card.more, { label: 'All wikis', url: 'https://openvibe.wiki/' });++assert.ok(card.title.length <= 60);++for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.wiki/w/'));++++// Drafts, private wikis, deleted pages and broken rows never enter the card, and at most six pages are sent.++assert.deepStrictEqual(cardFrom([change({ page: { visibility: 'private' } })], NOW).items, []);++assert.deepStrictEqual(cardFrom([change({ page: { state: 'deleted' } })], NOW).items, []);++assert.deepStrictEqual(cardFrom([change({ page: { title: ' ' } })], NOW).items, []);++assert.deepStrictEqual(cardFrom([null, {}], NOW).items, []);++assert.deepStrictEqual(cardFrom(null, NOW).items, []);++const many = Array.from({ length: 9 }, (_, i) => change({ page: { slug: `page-${i}`, title: `Page ${i}` } }));++assert.strictEqual(cardFrom(many, NOW).items.length, 6);++++// The path is the site's own, with both segments encoded.++assert.strictEqual(pagePath({ slug: 'a b' }, { slug: 'c/d' }), '/w/a%20b/c%2Fd');++++// ── The five-minute cache and the route's headers ───────────────────────++(async () => {++ let t = NOW;++ let loads = 0;++ const menu = createMenuCard({ load: async () => { loads++; return [change()]; }, now: () => t });++ const first = await menu.card();++ assert.strictEqual(loads, 1);++ assert.strictEqual(await menu.card(), first);++ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');++ t += CARD_TTL_S * 1000 + 1;++ await menu.card();++ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');++++ const headers = {};++ let body = null;++ const res = {++ set(k, v) { headers[k] = v; return this; },++ json(v) { body = v; return this; },++ };++ await menu.handler({}, res);++ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');++ assert.strictEqual(headers['Cross-Origin-Resource-Policy'], 'cross-origin');++ assert.strictEqual(headers['Cache-Control'], 'public, max-age=60');++ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');++ assert.ok(contracts.validate('frame.menu-cards@1', body).valid);++++ // A failing database keeps the last good card rather than publishing an empty list.++ let fail = false;++ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [change()]; }, now: () => t });++ const good = await flaky.card();++ fail = true;++ t += CARD_TTL_S * 1000 + 1;++ assert.strictEqual(await flaky.card(), good);++++ console.log('menu card: shape, contract, public-only pages, plain text, 5 min cache, CORS and last-good card');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -444,7 +444,7 @@ implemented service. Still open: a person's review of the 42 seed pages (until t
@@ -444,7 +444,7 @@ implemented service. Still open: a person's review of the 42 seed pages (until t
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.312.0
+- openvibe-contracts: v0.317.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.129.0
- openvibe-shared: v4.129.0
- openvibe-publishing: v1.3.0
- openvibe-publishing: v1.3.0
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4800,
"port": 4800,
"domain": "openvibe.wiki",
"domain": "openvibe.wiki",
- "contracts": "openvibe-contracts v0.312.0",
+ "contracts": "openvibe-contracts v0.317.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.129.0",
"shared": "openvibe-shared v4.129.0",
"packages": [
"packages": [
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
@@ -1134,9 +1134,9 @@
@@ -1134,9 +1134,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.312.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
+ "version": "0.317.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
+ "integrity": "sha512-qy9hGj4cl5MSHHcYmOKcrL1BmMrbQ9uJCGYH5zxhp3gu6RwZqu7kHLVWBCcy5y7SY8odkxNuVU5Ts/RksiivxQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
@@ -147,6 +147,12 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
@@ -147,6 +147,12 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
}));
}));
// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).
// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).
if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);
if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);
+ // The menu's network card (frame.menu-cards@1): the same public recent changes the home page reads.
+ {
+ const { createMenuCard } = require('./menu-card');
+ const menuCard = createMenuCard({ load: async () => await svc.recentChanges(6) });
+ app.get('/.well-known/openvibe-menu.json', menuCard.handler);
+ }
app.use(createMachine({ svc, config }));
app.use(createMachine({ svc, config }));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+// The menu's network card for OpenVibe.Wiki (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
+//
+// Wiki publishes "New on the wiki": the six pages revised most recently, each linking to its article. The rows
+// are the same public recent changes the home page and /recent show (svc.recentChanges), which already keeps
+// drafts, members-only wikis and private pages out. The file is CORS *, cached for five minutes; with nothing
+// published it is served with no items and Network skips it.
+const BASE = 'https://openvibe.wiki';
+const CARD_ID = 'new-pages';
+const SITE = 'wiki';
+const CARD_TTL_S = 300;
+const CACHE_MS = CARD_TTL_S * 1000;
+const LIMIT = 6;
+
+/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
+function plain(value, max) {
+ return String(value == null ? '' : value)
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, max);
+}
+
+/** "just now", "12m ago", "3h ago" or "2d ago"; empty when unknown. */
+function ago(value, now) {
+ const at = typeof value === 'number' ? value : Date.parse(String(value || ''));
+ if (!Number.isFinite(at)) return '';
+ const mins = Math.max(0, Math.floor((now - at) / 60_000));
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+/** The article's path, the same /w/<space>/<slug> the site renders. */
+const pagePath = (space, page) => `/w/${encodeURIComponent(String(space.slug))}/${encodeURIComponent(String(page.slug))}`;
+
+/** The card itself. `changes` are svc.recentChanges() rows: { space, page, rev, ... }. */
+function cardFrom(changes, at = Date.now()) {
+ const out = [];
+ for (const change of Array.isArray(changes) ? changes : []) {
+ const space = change && change.space;
+ const page = change && change.page;
+ if (!space || !page || page.state === 'deleted') continue;
+ if (page.visibility && page.visibility !== 'public') continue;
+ const title = plain(page.title, 80);
+ if (!title || !space.slug || !page.slug) continue;
+ const meta = [plain(space.name, 60), ago(page.revision_published_at || page.published_at, at)].filter(Boolean).join(' · ');
+ out.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${pagePath(space, page)}` });
+ if (out.length >= LIMIT) break;
+ }
+ return {
+ id: CARD_ID,
+ site: SITE,
+ title: 'New on the wiki',
+ kind: 'rows',
+ items: out,
+ more: { label: 'All wikis', url: `${BASE}/` },
+ updated_at: new Date(at).toISOString(),
+ ttl_s: CARD_TTL_S,
+ };
+}
+
+/** The route. `load` returns svc.recentChanges() rows; `now` is injectable for the test. */
+function createMenuCard({ load, now = Date.now } = {}) {
+ let cached = null;
+ let cachedAt = 0;
+ let pending = null;
+
+ async function card() {
+ const at = now();
+ if (cached && at - cachedAt < CACHE_MS) return cached;
+ if (pending) return pending;
+ pending = (async () => {
+ try {
+ cached = cardFrom(await load(), at);
+ cachedAt = at;
+ } catch {
+ // Keep the last good card rather than publish an empty one on a database hiccup.
+ if (!cached) { cached = cardFrom([], at); cachedAt = at; }
+ }
+ return cached;
+ })().finally(() => { pending = null; });
+ return pending;
+ }
+
+ async function handler(_req, res) {
+ const body = await card();
+ res.set('Cache-Control', 'public, max-age=60')
+ .set('Access-Control-Allow-Origin', '*')
+ .set('Cross-Origin-Resource-Policy', 'cross-origin')
+ .json([body]);
+ }
+
+ return { card, handler, cardFrom, cacheMs: CACHE_MS };
+}
+
+module.exports = { createMenuCard, cardFrom, ago, pagePath, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };
@@ -12,7 +12,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');
@@ -12,7 +12,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');
(async () => {
(async () => {
// The capabilities the routes enforce are the released ones, so capabilities.check() decides them.
// The capabilities the routes enforce are the released ones, so capabilities.check() decides them.
- // One id waits for a Contracts release — wiki.review.ai_editor — and is decided locally until then.
for (const id of Object.values(CAPS)) {
for (const id of Object.values(CAPS)) {
const cap = contracts.capabilities.get(id);
const cap = contracts.capabilities.get(id);
if (!cap) {
if (!cap) {
@@ -22,8 +21,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');
@@ -22,8 +21,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');
assert.strictEqual(cap.owner, 'wiki', `${id} is owned by wiki`);
assert.strictEqual(cap.owner, 'wiki', `${id} is owned by wiki`);
assert.notStrictEqual(cap.status, 'retired', `${id} is not retired`);
assert.notStrictEqual(cap.status, 'retired', `${id} is not retired`);
}
}
- // The AI editor's id is the one proposal; when Contracts releases it, this line goes.
- assert.strictEqual(contracts.capabilities.get('wiki.review.ai_editor'), undefined);
assert.deepStrictEqual(
assert.deepStrictEqual(
contracts.capabilities.check({ cap: ['wiki.*'] }, CAPS.PAGE_READ),
contracts.capabilities.check({ cap: ['wiki.*'] }, CAPS.PAGE_READ),
{ allowed: true, code: null, reason: null },
{ allowed: true, code: null, reason: null },
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+// The menu's network card for OpenVibe.Wiki (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves
+// "New on the wiki" — the most recently revised public pages, each linking to its article. The array validates
+// against frame.menu-cards@1, carries no private or deleted page, is CORS * and cached for five minutes.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { createMenuCard, cardFrom, pagePath, CARD_TTL_S } = require('../server/menu-card');
+
+const NOW = Date.parse('2026-11-07T12:00:00Z');
+const change = (over = {}) => ({
+ space: { slug: 'openvibe', name: 'OpenVibe', ...(over.space || {}) },
+ page: {
+ slug: 'how-it-works', title: 'How OpenVibe works', state: 'published', visibility: 'public',
+ revision_published_at: NOW - 2 * 60 * 60_000, ...(over.page || {}),
+ },
+});
+
+// ── The card's shape and words ───────────────────────────────────────────
+const card = cardFrom([
+ change(),
+ change({ space: { slug: 'cooking', name: 'Cooking' }, page: { slug: 'rye-bread', title: '<b>Rye</b>\nbread', revision_published_at: NOW - 26 * 60 * 60_000 } }),
+], NOW);
+const checked = contracts.validate('frame.menu-cards@1', [card]);
+assert.ok(checked.valid, JSON.stringify(checked.errors));
+assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);
+assert.strictEqual(card.id, 'new-pages');
+assert.strictEqual(card.site, 'wiki');
+assert.strictEqual(card.title, 'New on the wiki');
+assert.strictEqual(card.kind, 'rows');
+assert.strictEqual(card.ttl_s, CARD_TTL_S);
+assert.deepStrictEqual(card.items, [
+ { title: 'How OpenVibe works', meta: 'OpenVibe · 2h ago', url: 'https://openvibe.wiki/w/openvibe/how-it-works' },
+ { title: 'Rye bread', meta: 'Cooking · 1d ago', url: 'https://openvibe.wiki/w/cooking/rye-bread' },
+]);
+assert.deepStrictEqual(card.more, { label: 'All wikis', url: 'https://openvibe.wiki/' });
+assert.ok(card.title.length <= 60);
+for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.wiki/w/'));
+
+// Drafts, private wikis, deleted pages and broken rows never enter the card, and at most six pages are sent.
+assert.deepStrictEqual(cardFrom([change({ page: { visibility: 'private' } })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([change({ page: { state: 'deleted' } })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([change({ page: { title: ' ' } })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([null, {}], NOW).items, []);
+assert.deepStrictEqual(cardFrom(null, NOW).items, []);
+const many = Array.from({ length: 9 }, (_, i) => change({ page: { slug: `page-${i}`, title: `Page ${i}` } }));
+assert.strictEqual(cardFrom(many, NOW).items.length, 6);
+
+// The path is the site's own, with both segments encoded.
+assert.strictEqual(pagePath({ slug: 'a b' }, { slug: 'c/d' }), '/w/a%20b/c%2Fd');
+
+// ── The five-minute cache and the route's headers ───────────────────────
+(async () => {
+ let t = NOW;
+ let loads = 0;
+ const menu = createMenuCard({ load: async () => { loads++; return [change()]; }, now: () => t });
+ const first = await menu.card();
+ assert.strictEqual(loads, 1);
+ assert.strictEqual(await menu.card(), first);
+ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
+ t += CARD_TTL_S * 1000 + 1;
+ await menu.card();
+ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
+
+ const headers = {};
+ let body = null;
+ const res = {
+ set(k, v) { headers[k] = v; return this; },
+ json(v) { body = v; return this; },
+ };
+ await menu.handler({}, res);
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');
+ assert.strictEqual(headers['Cross-Origin-Resource-Policy'], 'cross-origin');
+ assert.strictEqual(headers['Cache-Control'], 'public, max-age=60');
+ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');
+ assert.ok(contracts.validate('frame.menu-cards@1', body).valid);
+
+ // A failing database keeps the last good card rather than publishing an empty list.
+ let fail = false;
+ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [change()]; }, now: () => t });
+ const good = await flaky.card();
+ fail = true;
+ t += CARD_TTL_S * 1000 + 1;
+ assert.strictEqual(await flaky.card(), good);
+
+ console.log('menu card: shape, contract, public-only pages, plain text, 5 min cache, CORS and last-good card');
+})().catch((err) => { console.error(err); process.exit(1); });