openvibers/openvibe.news Commits
Publish the Top stories card as a frame.menu-cards array (#227)
Changes
@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.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.4.1","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",@@@ -1148,9 +1148,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",@@@ -29,7 +29,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.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.4.1","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",@@@ -37,6 +37,7 @@ const { createSharing, describeRef } = require('./sharing');const { createTopics } = require('./domain/topics');const { createReading } = require('./domain/reading');const { createPublicRoutes } = require('./http/public');++const { createMenuCard } = require('./menu-card');const { createEditorRoutes } = require('./http/editor');const { createApi } = require('./http/api');const { createDiscoveryRoutes } = require('./http/discovery');@@@ -208,6 +209,18 @@ async function createApp(opts = {}) {},}));++ // The menu's network card (frame.menu-cards@1): the newest published stories, read anonymously. It is the++ // same public listing the home page uses, so a draft or a retracted story never reaches the card.++ {++ const menuCard = createMenuCard({++ load: async () => {++ const { stories: rows } = await stories.listPublished({ limit: 6 });++ return await reading.listItems(rows);++ },++ });++ app.get('/.well-known/openvibe-menu.json', menuCard.handler);++ }++// ── API ─────────────────────────────────────────────────// ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────{nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json.++//++// News publishes "Top stories": the newest published stories readers can open, each linking to its story page.++// The rows come from the same public listing the home page uses (stories.listPublished + reading.listItems), so++// the card can only show what an anonymous reader can already read: drafts and retracted stories never appear.++// The file is public and CORS *, cached for five minutes (the card's ttl); Network fetches it every five minutes.++// With nothing published the card is still served, with no items, and Network skips it.++const BASE = 'https://openvibe.news';++const CARD_ID = 'top-stories';++const SITE = 'news';++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" for a story's first publication; empty when unknown. */++function ago(value, now) {++ // first_published_at is epoch milliseconds (a bigint from the database), but a date string works too.++ 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 card itself. `items` are reading.listItems() rows: { headline, url (path), outlets, published_at, retracted }. */++function cardFrom(items, at = Date.now()) {++ const out = [];++ for (const it of Array.isArray(items) ? items : []) {++ if (!it || it.retracted) continue;++ const title = plain(it.headline, 80);++ const path = String(it.url || '');++ if (!title || !path.startsWith('/')) continue;++ const meta = [plain((it.outlets || [])[0], 60), ago(it.published_at, at)].filter(Boolean).join(' · ');++ out.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${path}` });++ if (out.length >= LIMIT) break;++ }++ return {++ id: CARD_ID,++ site: SITE,++ title: 'Top stories',++ kind: 'rows',++ items: out,++ more: { label: 'All stories', url: `${BASE}/` },++ updated_at: new Date(at).toISOString(),++ ttl_s: CARD_TTL_S,++ };++}++++/** The route. `load` returns reading.listItems() 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, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };@@@ -1,12 +1,9 @@'use strict';/*** The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the-- * library's grant rule rather than deciding a proposed id locally. One id is still a proposal and-- * waits for a Contracts release — news.review.ai_editor (the AI editor's review kind) — so it is-- * decided by the same grant rule locally until then (server/auth/capabilities.js PROPOSED). These-- * pin that: every guarded id is defined by the installed contracts and the news service manifest,-- * except the proposal, which is tracked as one; and checkCapability agrees with capabilities.check-- * for an exact grant, a prefix grant, no grant, and an id nobody defines.++ * library's grant rule rather than deciding a proposed id locally. These pin that: every guarded id++ * is defined by the installed contracts and the news service manifest; and checkCapability agrees++ * with capabilities.check for an exact grant, a prefix grant, no grant, and an id nobody defines.*/const assert = require('assert');const { capabilities, services } = require('openvibe-contracts');@@@ -27,8 +24,6 @@ const { check, done } = require('./helpers/boot');}assert.strictEqual(cap.owner, 'news', `${id} is owned by news`);}-- // The AI editor's id is the one proposal; when Contracts releases it, this line goes.-- assert.strictEqual(capabilities.get('news.review.ai_editor'), undefined);});await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the++// "Top stories" array — the newest published stories, each linking to its page on openvibe.news. It validates++// against frame.menu-cards@1, never carries a retracted story, is CORS * and is cached for five minutes.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { createMenuCard, cardFrom, ago, plain, CARD_TTL_S } = require('../server/menu-card');++++const NOW = Date.parse('2026-11-07T12:00:00Z');++const item = (over = {}) => ({++ headline: 'A story readers can check', url: '/stories/a-story', outlets: ['Example News'],++ published_at: NOW - 2 * 60 * 60_000, retracted: false, ...over,++});++++// ── The card's shape and words ───────────────────────────────────────────++const card = cardFrom([++ item(),++ item({ headline: '<b>Second</b>\nstory', url: '/stories/second', outlets: ['Other', 'Third'], 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, 'top-stories');++assert.strictEqual(card.site, 'news');++assert.strictEqual(card.title, 'Top stories');++assert.strictEqual(card.kind, 'rows');++assert.strictEqual(card.ttl_s, CARD_TTL_S);++assert.strictEqual(card.updated_at, new Date(NOW).toISOString());++assert.deepStrictEqual(card.items, [++ { title: 'A story readers can check', meta: 'Example News · 2h ago', url: 'https://openvibe.news/stories/a-story' },++ { title: 'Second story', meta: 'Other · 1d ago', url: 'https://openvibe.news/stories/second' },++]);++assert.deepStrictEqual(card.more, { label: 'All stories', url: 'https://openvibe.news/' });++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.news/'));++++// Retracted and incomplete stories never enter the card, and at most six stories are sent.++assert.deepStrictEqual(cardFrom([item({ retracted: true })], NOW).items, []);++assert.deepStrictEqual(cardFrom([item({ headline: ' ' })], NOW).items, []);++assert.deepStrictEqual(cardFrom([item({ url: 'https://elsewhere.example/x' })], NOW).items, []);++assert.deepStrictEqual(cardFrom(null, NOW).items, []);++const many = Array.from({ length: 9 }, (_, i) => item({ headline: `Story ${i}`, url: `/stories/${i}` }));++assert.strictEqual(cardFrom(many, NOW).items.length, 6);++++// Text is plain words and the meta line names the outlet and the time ago.++assert.strictEqual(plain('<img src=x onerror=alert(1)>Nova\n\t live ', 80), 'Nova live');++assert.strictEqual(plain('x'.repeat(200), 80).length, 80);++assert.strictEqual(ago(NOW - 30_000, NOW), 'just now');++assert.strictEqual(ago(NOW - 3 * 60 * 60_000, NOW), '3h ago');++assert.strictEqual(ago(null, NOW), '');++++// ── The five-minute cache and the route's headers ───────────────────────++(async () => {++ let t = 1_000;++ let loads = 0;++ const menu = createMenuCard({ load: async () => { loads++; return [item()]; }, 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 [item()]; }, 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, retracted filter, plain text, 5 min cache, CORS and last-good card');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.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.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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",
@@ -1148,9 +1148,9 @@
@@ -1148,9 +1148,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",
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.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.4.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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",
@@ -37,6 +37,7 @@ const { createSharing, describeRef } = require('./sharing');
@@ -37,6 +37,7 @@ const { createSharing, describeRef } = require('./sharing');
const { createTopics } = require('./domain/topics');
const { createTopics } = require('./domain/topics');
const { createReading } = require('./domain/reading');
const { createReading } = require('./domain/reading');
const { createPublicRoutes } = require('./http/public');
const { createPublicRoutes } = require('./http/public');
+const { createMenuCard } = require('./menu-card');
const { createEditorRoutes } = require('./http/editor');
const { createEditorRoutes } = require('./http/editor');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
@@ -208,6 +209,18 @@ async function createApp(opts = {}) {
@@ -208,6 +209,18 @@ async function createApp(opts = {}) {
},
},
}));
}));
+ // The menu's network card (frame.menu-cards@1): the newest published stories, read anonymously. It is the
+ // same public listing the home page uses, so a draft or a retracted story never reaches the card.
+ {
+ const menuCard = createMenuCard({
+ load: async () => {
+ const { stories: rows } = await stories.listPublished({ limit: 6 });
+ return await reading.listItems(rows);
+ },
+ });
+ app.get('/.well-known/openvibe-menu.json', menuCard.handler);
+ }
+
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
// ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
// ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
{
{
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
+//
+// News publishes "Top stories": the newest published stories readers can open, each linking to its story page.
+// The rows come from the same public listing the home page uses (stories.listPublished + reading.listItems), so
+// the card can only show what an anonymous reader can already read: drafts and retracted stories never appear.
+// The file is public and CORS *, cached for five minutes (the card's ttl); Network fetches it every five minutes.
+// With nothing published the card is still served, with no items, and Network skips it.
+const BASE = 'https://openvibe.news';
+const CARD_ID = 'top-stories';
+const SITE = 'news';
+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" for a story's first publication; empty when unknown. */
+function ago(value, now) {
+ // first_published_at is epoch milliseconds (a bigint from the database), but a date string works too.
+ 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 card itself. `items` are reading.listItems() rows: { headline, url (path), outlets, published_at, retracted }. */
+function cardFrom(items, at = Date.now()) {
+ const out = [];
+ for (const it of Array.isArray(items) ? items : []) {
+ if (!it || it.retracted) continue;
+ const title = plain(it.headline, 80);
+ const path = String(it.url || '');
+ if (!title || !path.startsWith('/')) continue;
+ const meta = [plain((it.outlets || [])[0], 60), ago(it.published_at, at)].filter(Boolean).join(' · ');
+ out.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${path}` });
+ if (out.length >= LIMIT) break;
+ }
+ return {
+ id: CARD_ID,
+ site: SITE,
+ title: 'Top stories',
+ kind: 'rows',
+ items: out,
+ more: { label: 'All stories', url: `${BASE}/` },
+ updated_at: new Date(at).toISOString(),
+ ttl_s: CARD_TTL_S,
+ };
+}
+
+/** The route. `load` returns reading.listItems() 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, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };
@@ -1,12 +1,9 @@
@@ -1,12 +1,9 @@
'use strict';
'use strict';
/**
/**
* The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the
* The news.* capabilities are released by openvibe-contracts, so the service guards delegate to the
- * library's grant rule rather than deciding a proposed id locally. One id is still a proposal and
- * waits for a Contracts release — news.review.ai_editor (the AI editor's review kind) — so it is
- * decided by the same grant rule locally until then (server/auth/capabilities.js PROPOSED). These
- * pin that: every guarded id is defined by the installed contracts and the news service manifest,
- * except the proposal, which is tracked as one; and checkCapability agrees with capabilities.check
- * for an exact grant, a prefix grant, no grant, and an id nobody defines.
+ * library's grant rule rather than deciding a proposed id locally. These pin that: every guarded id
+ * is defined by the installed contracts and the news service manifest; and checkCapability agrees
+ * with capabilities.check for an exact grant, a prefix grant, no grant, and an id nobody defines.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { capabilities, services } = require('openvibe-contracts');
const { capabilities, services } = require('openvibe-contracts');
@@ -27,8 +24,6 @@ const { check, done } = require('./helpers/boot');
@@ -27,8 +24,6 @@ const { check, done } = require('./helpers/boot');
}
}
assert.strictEqual(cap.owner, 'news', `${id} is owned by news`);
assert.strictEqual(cap.owner, 'news', `${id} is owned by news`);
}
}
- // The AI editor's id is the one proposal; when Contracts releases it, this line goes.
- assert.strictEqual(capabilities.get('news.review.ai_editor'), undefined);
});
});
await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {
await check('checkCapability follows the contracts grant rule (exact, prefix, denied, unknown)', () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the
+// "Top stories" array — the newest published stories, each linking to its page on openvibe.news. It validates
+// against frame.menu-cards@1, never carries a retracted story, is CORS * and is cached for five minutes.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { createMenuCard, cardFrom, ago, plain, CARD_TTL_S } = require('../server/menu-card');
+
+const NOW = Date.parse('2026-11-07T12:00:00Z');
+const item = (over = {}) => ({
+ headline: 'A story readers can check', url: '/stories/a-story', outlets: ['Example News'],
+ published_at: NOW - 2 * 60 * 60_000, retracted: false, ...over,
+});
+
+// ── The card's shape and words ───────────────────────────────────────────
+const card = cardFrom([
+ item(),
+ item({ headline: '<b>Second</b>\nstory', url: '/stories/second', outlets: ['Other', 'Third'], 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, 'top-stories');
+assert.strictEqual(card.site, 'news');
+assert.strictEqual(card.title, 'Top stories');
+assert.strictEqual(card.kind, 'rows');
+assert.strictEqual(card.ttl_s, CARD_TTL_S);
+assert.strictEqual(card.updated_at, new Date(NOW).toISOString());
+assert.deepStrictEqual(card.items, [
+ { title: 'A story readers can check', meta: 'Example News · 2h ago', url: 'https://openvibe.news/stories/a-story' },
+ { title: 'Second story', meta: 'Other · 1d ago', url: 'https://openvibe.news/stories/second' },
+]);
+assert.deepStrictEqual(card.more, { label: 'All stories', url: 'https://openvibe.news/' });
+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.news/'));
+
+// Retracted and incomplete stories never enter the card, and at most six stories are sent.
+assert.deepStrictEqual(cardFrom([item({ retracted: true })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([item({ headline: ' ' })], NOW).items, []);
+assert.deepStrictEqual(cardFrom([item({ url: 'https://elsewhere.example/x' })], NOW).items, []);
+assert.deepStrictEqual(cardFrom(null, NOW).items, []);
+const many = Array.from({ length: 9 }, (_, i) => item({ headline: `Story ${i}`, url: `/stories/${i}` }));
+assert.strictEqual(cardFrom(many, NOW).items.length, 6);
+
+// Text is plain words and the meta line names the outlet and the time ago.
+assert.strictEqual(plain('<img src=x onerror=alert(1)>Nova\n\t live ', 80), 'Nova live');
+assert.strictEqual(plain('x'.repeat(200), 80).length, 80);
+assert.strictEqual(ago(NOW - 30_000, NOW), 'just now');
+assert.strictEqual(ago(NOW - 3 * 60 * 60_000, NOW), '3h ago');
+assert.strictEqual(ago(null, NOW), '');
+
+// ── The five-minute cache and the route's headers ───────────────────────
+(async () => {
+ let t = 1_000;
+ let loads = 0;
+ const menu = createMenuCard({ load: async () => { loads++; return [item()]; }, 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 [item()]; }, 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, retracted filter, plain text, 5 min cache, CORS and last-good card');
+})().catch((err) => { console.error(err); process.exit(1); });