openvibers/openvibe.news Commits

Publish the Top stories card as a frame.menu-cards array (#227)

OP OpenVibers committed 6 days ago · 1c70c0f203 · 6 files, +205 −13

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 9720254..97c5eb2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex c219815..ddf510d 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/app.js b/server/app.js
iindex 39ac4f7..8547560 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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) ────
{
ddiff --git a/server/menu-card.js b/server/menu-card.js
nnew file mode 100644
iindex 0000000..e939c0f
---- /dev/null
++++ b/server/menu-card.js
@@@ -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 };
ddiff --git a/test/capabilities.test.js b/test/capabilities.test.js
iindex 79b7d48..a3455e7 100644
---- a/test/capabilities.test.js
++++ b/test/capabilities.test.js
@@@ -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)', () => {
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
nnew file mode 100644
iindex 0000000..9b382d2
---- /dev/null
++++ b/test/menu-card.test.js
@@@ -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); });