openvibers/openvibe.network Commits
Fetch, validate and rank the menu's network cards in /api/frame (#623)
Changes
@@@ -96,8 +96,8 @@ person's username and picture (a service token, `identity.subject.resolve`).## Depends on--- `openvibe-contracts` v0.297.0 (registry manifests, capability checks), `openvibe-sdk` v0.94.0 (PostgreSQL-- and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.30.0, pinned by release tarball++- `openvibe-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.94.0 (PostgreSQL++ and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.126.0, pinned by release tarball- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`workflow), OpenVibe.Blog (the changelog feed it proxies)- email (Resend), Discord and GitHub when their secrets are set ([Provider secrets](#provider-secrets))@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4000,"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",-- "contracts": "openvibe-contracts v0.315.0",++ "contracts": "openvibe-contracts v0.317.0","sdk": "openvibe-sdk v0.94.0","shared": "openvibe-shared v4.126.0","capabilities": [@@@ -20,7 +20,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.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.126.0","pg": "^8.23.0",@@@ -2303,9 +2303,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.315.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.0",-- "integrity": "sha512-m1bWT4/sF6Hc7lia6FjAR+880r9SBRhO/v819Lr5ldxlSkFYiKMXTtt7MDbJ4FCgQ7Dkt+2OJaKanlvk2vNPlg==",++ "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",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.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.126.0","pg": "^8.23.0",nnew file mode 100644@@@ -0,0 +1,241 @@++'use strict';++/**++ * The menu's network cards: every open site publishes /.well-known/openvibe-menu.json (frame.menu-cards@1),++ * Network fetches them every five minutes and GET /api/frame carries the best cards as `cards` (at most four).++ *++ * A card is frame.menu-card@1: a rail, rows or chips of up to six items. The fetch is small and strict —++ * a five-second timeout, 64 KB at most, no redirects, JSON only — and every card is checked against the contract++ * after unknown fields are dropped and its text is stripped of markup and cut to the schema's limits. An item's++ * URL must be https on the publishing site's own host or an openvibe.* host, and the card's `site` must be the++ * site that served it. A site that fails keeps its last good cards for 30 minutes, then contributes none. The++ * result is kept in frame_cache, so a restart starts from the last good set rather than an empty menu.++ *++ * Ranking: cards with items only; Live's card first while anyone is live, then the site's place in the Frame's++ * own ranking. At most four cards.++ */++const contracts = require('openvibe-contracts');++++const REFRESH_MS = 5 * 60_000;++const KEEP_MS = 30 * 60_000;++const TIMEOUT_MS = 5_000;++const MAX_BYTES = 64 * 1024;++const MAX_CARDS = 4;++const PATH = '/.well-known/openvibe-menu.json';++const KINDS = new Set(['rail', 'rows', 'chips']);++const CARD_FIELDS = ['id', 'site', 'title', 'kind', 'items', 'more', 'updated_at', 'ttl_s'];++++/** Plain words for a card: no markup, no control characters, one line, cut to the schema's limit. */++function text(value, max) {++ return String(value == null ? '' : value)++ .replace(/<[^>]*>/g, ' ')++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')++ .replace(/\s+/g, ' ')++ .trim()++ .slice(0, max);++}++++/** An https URL on the site's own host or an openvibe.* host, or null. */++function cardUrl(value, site) {++ const raw = String(value == null ? '' : value).trim();++ if (!raw || raw.length > 400) return null;++ let u;++ try { u = new URL(raw); } catch { return null; }++ if (u.protocol !== 'https:' || u.username || u.password) return null;++ const host = u.hostname.toLowerCase().replace(/^www\./, '');++ const own = String(site.host || '').toLowerCase().replace(/^www\./, '');++ if (host !== own && !host.startsWith('openvibe.')) return null;++ return raw;++}++++/** One item ({ title, meta?, url }), or null. */++function itemOf(raw, site) {++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;++ const title = text(raw.title, 80);++ const url = cardUrl(raw.url, site);++ if (!title || !url) return null;++ const item = { title, url };++ const meta = text(raw.meta, 120);++ if (raw.meta !== undefined) item.meta = meta;++ return item;++}++++/** One card of the site, sanitized and schema-checked, or null. Unknown fields never reach the output. */++function cardOf(raw, site) {++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;++ if (raw.site !== site.id || !KINDS.has(raw.kind)) return null;++ const id = text(raw.id, 40);++ const title = text(raw.title, 60);++ const ttl = Math.round(Number(raw.ttl_s));++ const at = Date.parse(String(raw.updated_at || ''));++ if (!id || !title || !Number.isFinite(ttl) || !Number.isFinite(at)) return null;++ const items = (Array.isArray(raw.items) ? raw.items : []).slice(0, 6).map((it) => itemOf(it, site)).filter(Boolean);++ if (!Array.isArray(raw.items)) return null;++ const card = { id, site: site.id, title, kind: raw.kind, items, updated_at: new Date(at).toISOString(), ttl_s: ttl };++ if (raw.more && typeof raw.more === 'object' && !Array.isArray(raw.more)) {++ const label = text(raw.more.label, 24);++ const url = cardUrl(raw.more.url, site);++ if (label && url) card.more = { label, url };++ }++ // Keep the contract's key order; the check below refuses anything a sanitize step left out of bounds.++ const ordered = {};++ for (const k of CARD_FIELDS) if (card[k] !== undefined) ordered[k] = card[k];++ const check = contracts.validate('frame.menu-card@1', ordered);++ return check.valid ? ordered : null;++}++++/** The array a site served, sanitized, or null when the file is not a valid frame.menu-cards@1. */++function cardsOf(raw, site) {++ if (!Array.isArray(raw)) return null;++ const seen = new Set();++ const cards = [];++ for (const item of raw.slice(0, 3)) {++ const card = cardOf(item, site);++ if (card && !seen.has(card.id)) { seen.add(card.id); cards.push(card); }++ }++ if (!contracts.validate('frame.menu-cards@1', cards).valid) return null;++ return cards;++}++++/** Read at most max bytes of a response body; null when it is bigger. */++async function readLimited(res, max) {++ const declared = Number(res.headers && res.headers.get ? res.headers.get('content-length') : NaN);++ if (Number.isFinite(declared) && declared > max) {++ if (res.body && typeof res.body.cancel === 'function') res.body.cancel().catch(() => {});++ return null;++ }++ if (!res.body || typeof res.body.getReader !== 'function') {++ const body = await res.text();++ return Buffer.byteLength(body, 'utf8') > max ? null : body;++ }++ const reader = res.body.getReader();++ const chunks = [];++ let size = 0;++ try {++ for (;;) {++ const { done, value } = await reader.read();++ if (done) break;++ size += value.byteLength;++ if (size > max) { await reader.cancel().catch(() => {}); return null; }++ chunks.push(Buffer.from(value));++ }++ } finally {++ try { reader.releaseLock(); } catch { /* already released */ }++ }++ return Buffer.concat(chunks).toString('utf8');++}++++/**++ * `sites()` is the open sites, `rankOf()` their ids in Frame order, `load`/`save` the frame_cache accessors.++ * `onChange` bumps the payload version so the response's ETag and per-site cache notice new cards.++ */++function createMenuCards({ sites, rankOf, load, save, onChange = () => {}, fetchImpl = (...a) => fetch(...a), now = Date.now, log = console }) {++ let state = {}; // site id → { cards, goodAt }++ let loaded = false;++ let inflight = null;++ let timer = null;++++ async function init() {++ if (loaded) return;++ loaded = true;++ const saved = await load();++ const value = saved && saved.value;++ if (value && value.sites && typeof value.sites === 'object') {++ for (const [id, entry] of Object.entries(value.sites)) {++ if (entry && Array.isArray(entry.cards) && Number.isFinite(Number(entry.goodAt))) {++ state[id] = { cards: entry.cards, goodAt: Number(entry.goodAt) };++ }++ }++ }++ }++++ async function fetchSite(site) {++ const url = `https://${site.host}${PATH}`;++ const res = await fetchImpl(url, {++ redirect: 'error',++ cache: 'no-store',++ signal: AbortSignal.timeout(TIMEOUT_MS),++ headers: { accept: 'application/json' },++ });++ if (!res || res.status !== 200) {++ if (res && res.body && typeof res.body.cancel === 'function') res.body.cancel().catch(() => {});++ return { ok: false };++ }++ const body = await readLimited(res, MAX_BYTES);++ if (body == null) return { ok: false };++ let raw;++ try { raw = JSON.parse(body); } catch { return { ok: false }; }++ const cards = cardsOf(raw, site);++ return cards ? { ok: true, cards } : { ok: false };++ }++++ /** Fetch every open site once; failures keep the site's last good card, which expires after 30 minutes. */++ async function refresh() {++ if (inflight) return inflight;++ inflight = (async () => {++ const at = now();++ const list = sites();++ const results = await Promise.all(list.map(async (site) => [site, await fetchSite(site).catch(() => ({ ok: false }))]));++ let changed = false;++ for (const [site, result] of results) {++ if (result.ok) {++ state[site.id] = { cards: result.cards, goodAt: at };++ changed = true;++ } else if (state[site.id] && at - state[site.id].goodAt > KEEP_MS) {++ delete state[site.id];++ changed = true;++ } else if (!state[site.id]) {++ // Nothing to keep: remember that the site has no card, so the next pass does not save it again.++ changed = changed || false;++ }++ }++ // Drop sites that are no longer open, and last-good sets past their window even if they were not fetched.++ const open = new Set(list.map((s) => s.id));++ for (const [id, entry] of Object.entries(state)) {++ if (!open.has(id) || at - entry.goodAt > KEEP_MS) { delete state[id]; changed = true; }++ }++ if (changed) {++ try { await save({ sites: state, at }); } catch (err) { log.warn && log.warn('[Frame] menu cards not saved:', err && err.message); }++ onChange();++ }++ return cards();++ })().finally(() => { inflight = null; });++ return inflight;++ }++++ /** The cards GET /api/frame carries: cards with items, Live first while anyone is live, then the Frame rank. */++ function cards() {++ const at = now();++ const live = state.live;++ const liveNow = !!(live && at - live.goodAt <= KEEP_MS && live.cards.some((c) => c.items.length > 0));++ const ranked = rankOf();++ const order = liveNow ? ['live', ...ranked.filter((id) => id !== 'live')] : ranked;++ const out = [];++ for (const id of order) {++ const entry = state[id];++ if (!entry || at - entry.goodAt > KEEP_MS) continue;++ for (const card of entry.cards) {++ if (!card.items.length) continue;++ out.push(card);++ if (out.length >= MAX_CARDS) return out;++ }++ }++ return out;++ }++++ /** Start the five-minute fetch (the first after two seconds so boot is not held up). */++ async function start() {++ await init();++ const kick = setTimeout(() => refresh().catch(() => {}), 2_000);++ kick.unref();++ timer = setInterval(() => refresh().catch(() => {}), REFRESH_MS);++ timer.unref();++ }++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { init, refresh, cards, start, stop, _state: () => state };++}++++module.exports = { createMenuCards, cardOf, cardsOf, text, cardUrl, MAX_CARDS, KEEP_MS, REFRESH_MS, PATH };@@@ -10,6 +10,7 @@// next: { [siteId]: [siteId, …] }, up to 6 places people go next// discover_ids: [siteId, …], the open sites, ordered for discovery// menu: { beacon }, where the shared menu sends its clicks++// cards: [frame.menu-card@1, …], each open site's cards, at most 4, Live first// groups: [{ id, name }], the launcher's sections, in order// soon: [{ id, name, url, icon, state }], state: internal | placeholder// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }@@@ -25,6 +26,7 @@const express = require('express');const crypto = require('crypto');const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');++const { createMenuCards } = require('./menu-cards');const toolsCatalog = require('../domains/catalog');const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;@@@ -128,6 +130,17 @@ async function createFrameService(db, config, analytics, { privateKey = null, islet rankedAt = savedRank.value ? savedRank.at || null : null;let copy = (await load('copy') || {}).value || { sites: {} };let version = Date.now();++ // The menu's network cards (frame.menu-cards@1): fetched from every open site every five minutes, kept in++ // frame_cache across restarts. A failed site keeps its last good card for 30 minutes; GET /api/frame carries++ // at most four, Live first while anyone is live, then the Frame's own ranking.++ const menuCards = createMenuCards({++ sites: () => SITES.filter((s) => s.status === 'open'),++ rankOf: () => orderedOpen().map((s) => s.id),++ load: () => load('menu-cards'),++ save: (v) => save('menu-cards', v),++ onChange: () => { version = Date.now(); },++ });++ await menuCards.init();const openIds = () => new Set(SITES.filter((s) => s.status === 'open').map((s) => s.id));/** Only open sites, never the site itself, at most six; drops duplicates and anything from an older seed. */function cleanNext(map) {@@@ -353,6 +366,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is// order, and the click beacon (Shared sends it only when this object is present).next, discover_ids: discoverIds,menu: { beacon: MENU_BEACON },++ // The network's cards: each open site's published frame.menu-card@1, at most four, Live first while++ // anyone is live, then the site's place in this Frame's ranking. Empty cards are not sent.++ cards: menuCards.cards(),groups: GROUPS.map(g => ({ id: g.id, name: g.name })),// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),@@@ -591,17 +607,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, isconst i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();const t3 = setTimeout(() => probeLegal().catch(() => {}), 45_000); t3.unref();const i3 = setInterval(() => probeLegal().catch(() => {}), 24 * 3600 * 1000); i3.unref();++ await menuCards.start();timers = [t1, i1, t2, i2, t3, i3];}/** Graceful stop: no further rank or copy refreshes. */-- function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }++ function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; menuCards.stop(); }/** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */function ranking() {return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };}-- return { router, start, stop, refreshRank, refreshCopy, probeLegal, payloadFor, ranking, _state: () => ({ rank, copy }) };++ return { router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, _state: () => ({ rank, copy, cards: menuCards._state() }) };}module.exports = { createFrameService, BANNED };nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++// The menu's network cards (frame.menu-card@1): Network fetches every open site's /.well-known/openvibe-menu.json++// every five minutes (5 s, 64 KB, no redirects), drops unknown fields and markup, validates against the contract,++// keeps a failed site's last good cards for 30 minutes, sends at most four with Live first while anyone is live,++// then in Frame rank order, and carries them in GET /api/frame. One migrated test database, stubbed fetches.++// node test/frame-cards.test.js++const assert = require('assert');++const express = require('express');++const http = require('http');++const { getDb } = require('../server/db/database');++const { createFrameService } = require('../server/frame/service');++const { createMenuCards, cardOf, cardsOf } = require('../server/frame/menu-cards');++++const SITES = [++ { id: 'live', host: 'openvibe.live' },++ { id: 'games', host: 'openvibe.games' },++ { id: 'media-hub', host: 'openvibe.video' },++];++const NOW = Date.parse('2026-11-07T08:00:00Z');++const card = (site, over = {}) => ({++ id: `${site}-card`,++ site,++ title: 'The card',++ kind: 'rows',++ items: [{ title: 'One', meta: '1 watching', url: `https://${SITES.find((s) => s.id === site).host}/one` }],++ updated_at: '2026-11-07T08:00:00Z',++ ttl_s: 30,++ ...over,++});++++// ── The sanitizer: unknown fields dropped, markup stripped, URLs checked ──++const dirty = card('live', {++ extra: 'dropped',++ title: ' Live <b>now</b> ',++ items: [++ { title: '<i>Nova</i>', meta: '<b>12</b> watching', url: 'https://openvibe.live/@nova', extra: 1 },++ { title: 'Elsewhere', url: 'https://example.com/x' },++ { title: 'Video host', url: 'https://openvibe.video/v/2' },++ { title: 'Plain http', url: 'http://openvibe.live/x' },++ { url: 'https://openvibe.live/x' },++ ],++});++const clean = cardOf(dirty, SITES[0]);++assert.deepStrictEqual(Object.keys(clean).sort(), ['id', 'items', 'kind', 'site', 'title', 'ttl_s', 'updated_at']);++assert.strictEqual(clean.title, 'Live now');++assert.deepStrictEqual(clean.items, [++ { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova' },++ { title: 'Video host', url: 'https://openvibe.video/v/2' },++]);++assert.strictEqual(cardOf(card('games'), SITES[0]), null, 'a card is refused by a host other than its site');++assert.strictEqual(cardOf(card('live', { title: 'x'.repeat(200) }), SITES[0]).title.length, 60, 'text is cut to the schema');++assert.strictEqual(cardOf(card('live', { ttl_s: 5 }), SITES[0]), null, 'a ttl under 30 is refused');++assert.strictEqual(cardOf(card('live', { updated_at: 'yesterday' }), SITES[0]), null, 'an unreadable time is refused');++assert.strictEqual(cardsOf([card('live'), card('live', { id: 'other' }), card('live', { id: 'third' }), card('live', { id: 'fourth' })], SITES[0]).length, 3, 'at most three cards per site');++assert.deepStrictEqual(cardsOf([], SITES[0]), [], 'an empty file is a valid, empty set');++++// ── The ranking, the 64 KB cap and the 30-minute last-good window ──++function response(body, { status = 200, headers = {} } = {}) {++ return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });++}++++(async () => {++ let now = NOW;++ let saved = null;++ let failed = new Set();++ const fetchImpl = async (url) => {++ const site = SITES.find((s) => url.startsWith(`https://${s.host}/`));++ if (!site || failed.has(site.id)) return response('nope', { status: 503 });++ return response([card(site.id)]);++ };++ const load = async () => (saved ? { value: saved, at: now } : null);++ const save = async (v) => { saved = v; };++ let changed = 0;++ const menu = createMenuCards({ sites: () => SITES, rankOf: () => ['games', 'media-hub', 'live'], load, save, fetchImpl, now: () => now, onChange: () => { changed++; } });++ await menu.init();++ assert.deepStrictEqual(menu.cards(), []);++ await menu.refresh();++ assert.deepStrictEqual(menu.cards().map((c) => c.site), ['live', 'games', 'media-hub'], 'Live is first while it has anyone live; then Frame rank');++ assert.ok(changed > 0, 'a new set bumps the response version');++ assert.ok(saved && saved.sites.live, 'the set is saved in frame_cache');++++ // Nobody live: Live's empty rail makes no card, and the rank decides.++ const emptyLive = async (url) => {++ const site = SITES.find((s) => url.startsWith(`https://${s.host}/`));++ if (!site) return response('nope', { status: 503 });++ return response([card(site.id, site.id === 'games' ? {} : { items: [] })]);++ };++ const quiet = createMenuCards({ sites: () => SITES, rankOf: () => ['games', 'media-hub', 'live'], load: async () => null, save: async () => {}, fetchImpl: emptyLive, now: () => now });++ await quiet.refresh();++ assert.deepStrictEqual(quiet.cards().map((c) => c.site), ['games'], 'an empty Live rail is skipped');++++ // A site whose file fails keeps its last good card for 30 minutes, then contributes none.++ failed = new Set();++ const flaky = createMenuCards({ sites: () => SITES, rankOf: () => ['games', 'media-hub', 'live'], load: async () => null, save: async () => {}, fetchImpl, now: () => now });++ await flaky.refresh();++ failed = new Set(['live']);++ now += 29 * 60_000;++ await flaky.refresh();++ assert.ok(flaky.cards().some((c) => c.site === 'live'), 'the last good card is kept for 30 minutes');++ now += 2 * 60_000;++ await flaky.refresh();++ assert.ok(!flaky.cards().some((c) => c.site === 'live'), 'after 30 minutes without a good file the card is gone');++++ // A file over 64 KB is a failure, not a huge card.++ const big = createMenuCards({++ sites: () => [SITES[1]],++ rankOf: () => ['games'],++ load: async () => null,++ save: async () => {},++ fetchImpl: async () => response('x'.repeat(70 * 1024)),++ now: () => now,++ });++ await big.refresh();++ assert.deepStrictEqual(big.cards(), [], 'a file over 64 KB is refused');++++ // ── GET /api/frame?host=openvibe.chat carries the cards ──++ failed = new Set();++ now = NOW;++ const realFetch = global.fetch;++ global.fetch = fetchImpl;++ const db = getDb();++ const svc = await createFrameService(db, { services: {} }, null);++ await svc.refreshCards();++ global.fetch = realFetch;++ const payload = svc.payloadFor('openvibe.chat');++ assert.ok(Array.isArray(payload.cards), 'the Frame payload has cards');++ assert.deepStrictEqual(payload.cards.map((c) => c.site), ['live', 'games', 'media-hub'], 'Live first, then the rank');++ assert.ok(payload.cards.length <= 4);++++ const app = express(); app.use('/api/frame', svc.router);++ const srv = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });++ const r = await fetch(`http://127.0.0.1:${srv.address().port}/api/frame?host=openvibe.chat`);++ const body = await r.json();++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');++ assert.strictEqual(body.cards[0].site, 'live');++ srv.close();++++ console.log('frame menu cards: sanitize, 64 KB, Live first, rank, 30-minute last good, /api/frame carries them');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -124,6 +124,7 @@ const REVIEWED = {'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],++ 'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
@@ -96,8 +96,8 @@ person's username and picture (a service token, `identity.subject.resolve`).
@@ -96,8 +96,8 @@ person's username and picture (a service token, `identity.subject.resolve`).
## Depends on
## Depends on
-- `openvibe-contracts` v0.297.0 (registry manifests, capability checks), `openvibe-sdk` v0.94.0 (PostgreSQL
- and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.30.0, pinned by release tarball
+- `openvibe-contracts` v0.317.0 (registry manifests, capability checks), `openvibe-sdk` v0.94.0 (PostgreSQL
+ and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.126.0, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
workflow), OpenVibe.Blog (the changelog feed it proxies)
- email (Resend), Discord and GitHub when their secrets are set ([Provider secrets](#provider-secrets))
- email (Resend), Discord and GitHub when their secrets are set ([Provider secrets](#provider-secrets))
@@ -6,7 +6,7 @@
@@ -6,7 +6,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": 4000,
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
- "contracts": "openvibe-contracts v0.315.0",
+ "contracts": "openvibe-contracts v0.317.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.126.0",
"shared": "openvibe-shared v4.126.0",
"capabilities": [
"capabilities": [
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.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.126.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.126.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.315.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.0",
- "integrity": "sha512-m1bWT4/sF6Hc7lia6FjAR+880r9SBRhO/v819Lr5ldxlSkFYiKMXTtt7MDbJ4FCgQ7Dkt+2OJaKanlvk2vNPlg==",
+ "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",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.315.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.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.126.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.126.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,241 @@
@@ -0,0 +1,241 @@
+'use strict';
+/**
+ * The menu's network cards: every open site publishes /.well-known/openvibe-menu.json (frame.menu-cards@1),
+ * Network fetches them every five minutes and GET /api/frame carries the best cards as `cards` (at most four).
+ *
+ * A card is frame.menu-card@1: a rail, rows or chips of up to six items. The fetch is small and strict —
+ * a five-second timeout, 64 KB at most, no redirects, JSON only — and every card is checked against the contract
+ * after unknown fields are dropped and its text is stripped of markup and cut to the schema's limits. An item's
+ * URL must be https on the publishing site's own host or an openvibe.* host, and the card's `site` must be the
+ * site that served it. A site that fails keeps its last good cards for 30 minutes, then contributes none. The
+ * result is kept in frame_cache, so a restart starts from the last good set rather than an empty menu.
+ *
+ * Ranking: cards with items only; Live's card first while anyone is live, then the site's place in the Frame's
+ * own ranking. At most four cards.
+ */
+const contracts = require('openvibe-contracts');
+
+const REFRESH_MS = 5 * 60_000;
+const KEEP_MS = 30 * 60_000;
+const TIMEOUT_MS = 5_000;
+const MAX_BYTES = 64 * 1024;
+const MAX_CARDS = 4;
+const PATH = '/.well-known/openvibe-menu.json';
+const KINDS = new Set(['rail', 'rows', 'chips']);
+const CARD_FIELDS = ['id', 'site', 'title', 'kind', 'items', 'more', 'updated_at', 'ttl_s'];
+
+/** Plain words for a card: no markup, no control characters, one line, cut to the schema's limit. */
+function text(value, max) {
+ return String(value == null ? '' : value)
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, max);
+}
+
+/** An https URL on the site's own host or an openvibe.* host, or null. */
+function cardUrl(value, site) {
+ const raw = String(value == null ? '' : value).trim();
+ if (!raw || raw.length > 400) return null;
+ let u;
+ try { u = new URL(raw); } catch { return null; }
+ if (u.protocol !== 'https:' || u.username || u.password) return null;
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
+ const own = String(site.host || '').toLowerCase().replace(/^www\./, '');
+ if (host !== own && !host.startsWith('openvibe.')) return null;
+ return raw;
+}
+
+/** One item ({ title, meta?, url }), or null. */
+function itemOf(raw, site) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const title = text(raw.title, 80);
+ const url = cardUrl(raw.url, site);
+ if (!title || !url) return null;
+ const item = { title, url };
+ const meta = text(raw.meta, 120);
+ if (raw.meta !== undefined) item.meta = meta;
+ return item;
+}
+
+/** One card of the site, sanitized and schema-checked, or null. Unknown fields never reach the output. */
+function cardOf(raw, site) {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ if (raw.site !== site.id || !KINDS.has(raw.kind)) return null;
+ const id = text(raw.id, 40);
+ const title = text(raw.title, 60);
+ const ttl = Math.round(Number(raw.ttl_s));
+ const at = Date.parse(String(raw.updated_at || ''));
+ if (!id || !title || !Number.isFinite(ttl) || !Number.isFinite(at)) return null;
+ const items = (Array.isArray(raw.items) ? raw.items : []).slice(0, 6).map((it) => itemOf(it, site)).filter(Boolean);
+ if (!Array.isArray(raw.items)) return null;
+ const card = { id, site: site.id, title, kind: raw.kind, items, updated_at: new Date(at).toISOString(), ttl_s: ttl };
+ if (raw.more && typeof raw.more === 'object' && !Array.isArray(raw.more)) {
+ const label = text(raw.more.label, 24);
+ const url = cardUrl(raw.more.url, site);
+ if (label && url) card.more = { label, url };
+ }
+ // Keep the contract's key order; the check below refuses anything a sanitize step left out of bounds.
+ const ordered = {};
+ for (const k of CARD_FIELDS) if (card[k] !== undefined) ordered[k] = card[k];
+ const check = contracts.validate('frame.menu-card@1', ordered);
+ return check.valid ? ordered : null;
+}
+
+/** The array a site served, sanitized, or null when the file is not a valid frame.menu-cards@1. */
+function cardsOf(raw, site) {
+ if (!Array.isArray(raw)) return null;
+ const seen = new Set();
+ const cards = [];
+ for (const item of raw.slice(0, 3)) {
+ const card = cardOf(item, site);
+ if (card && !seen.has(card.id)) { seen.add(card.id); cards.push(card); }
+ }
+ if (!contracts.validate('frame.menu-cards@1', cards).valid) return null;
+ return cards;
+}
+
+/** Read at most max bytes of a response body; null when it is bigger. */
+async function readLimited(res, max) {
+ const declared = Number(res.headers && res.headers.get ? res.headers.get('content-length') : NaN);
+ if (Number.isFinite(declared) && declared > max) {
+ if (res.body && typeof res.body.cancel === 'function') res.body.cancel().catch(() => {});
+ return null;
+ }
+ if (!res.body || typeof res.body.getReader !== 'function') {
+ const body = await res.text();
+ return Buffer.byteLength(body, 'utf8') > max ? null : body;
+ }
+ const reader = res.body.getReader();
+ const chunks = [];
+ let size = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > max) { await reader.cancel().catch(() => {}); return null; }
+ chunks.push(Buffer.from(value));
+ }
+ } finally {
+ try { reader.releaseLock(); } catch { /* already released */ }
+ }
+ return Buffer.concat(chunks).toString('utf8');
+}
+
+/**
+ * `sites()` is the open sites, `rankOf()` their ids in Frame order, `load`/`save` the frame_cache accessors.
+ * `onChange` bumps the payload version so the response's ETag and per-site cache notice new cards.
+ */
+function createMenuCards({ sites, rankOf, load, save, onChange = () => {}, fetchImpl = (...a) => fetch(...a), now = Date.now, log = console }) {
+ let state = {}; // site id → { cards, goodAt }
+ let loaded = false;
+ let inflight = null;
+ let timer = null;
+
+ async function init() {
+ if (loaded) return;
+ loaded = true;
+ const saved = await load();
+ const value = saved && saved.value;
+ if (value && value.sites && typeof value.sites === 'object') {
+ for (const [id, entry] of Object.entries(value.sites)) {
+ if (entry && Array.isArray(entry.cards) && Number.isFinite(Number(entry.goodAt))) {
+ state[id] = { cards: entry.cards, goodAt: Number(entry.goodAt) };
+ }
+ }
+ }
+ }
+
+ async function fetchSite(site) {
+ const url = `https://${site.host}${PATH}`;
+ const res = await fetchImpl(url, {
+ redirect: 'error',
+ cache: 'no-store',
+ signal: AbortSignal.timeout(TIMEOUT_MS),
+ headers: { accept: 'application/json' },
+ });
+ if (!res || res.status !== 200) {
+ if (res && res.body && typeof res.body.cancel === 'function') res.body.cancel().catch(() => {});
+ return { ok: false };
+ }
+ const body = await readLimited(res, MAX_BYTES);
+ if (body == null) return { ok: false };
+ let raw;
+ try { raw = JSON.parse(body); } catch { return { ok: false }; }
+ const cards = cardsOf(raw, site);
+ return cards ? { ok: true, cards } : { ok: false };
+ }
+
+ /** Fetch every open site once; failures keep the site's last good card, which expires after 30 minutes. */
+ async function refresh() {
+ if (inflight) return inflight;
+ inflight = (async () => {
+ const at = now();
+ const list = sites();
+ const results = await Promise.all(list.map(async (site) => [site, await fetchSite(site).catch(() => ({ ok: false }))]));
+ let changed = false;
+ for (const [site, result] of results) {
+ if (result.ok) {
+ state[site.id] = { cards: result.cards, goodAt: at };
+ changed = true;
+ } else if (state[site.id] && at - state[site.id].goodAt > KEEP_MS) {
+ delete state[site.id];
+ changed = true;
+ } else if (!state[site.id]) {
+ // Nothing to keep: remember that the site has no card, so the next pass does not save it again.
+ changed = changed || false;
+ }
+ }
+ // Drop sites that are no longer open, and last-good sets past their window even if they were not fetched.
+ const open = new Set(list.map((s) => s.id));
+ for (const [id, entry] of Object.entries(state)) {
+ if (!open.has(id) || at - entry.goodAt > KEEP_MS) { delete state[id]; changed = true; }
+ }
+ if (changed) {
+ try { await save({ sites: state, at }); } catch (err) { log.warn && log.warn('[Frame] menu cards not saved:', err && err.message); }
+ onChange();
+ }
+ return cards();
+ })().finally(() => { inflight = null; });
+ return inflight;
+ }
+
+ /** The cards GET /api/frame carries: cards with items, Live first while anyone is live, then the Frame rank. */
+ function cards() {
+ const at = now();
+ const live = state.live;
+ const liveNow = !!(live && at - live.goodAt <= KEEP_MS && live.cards.some((c) => c.items.length > 0));
+ const ranked = rankOf();
+ const order = liveNow ? ['live', ...ranked.filter((id) => id !== 'live')] : ranked;
+ const out = [];
+ for (const id of order) {
+ const entry = state[id];
+ if (!entry || at - entry.goodAt > KEEP_MS) continue;
+ for (const card of entry.cards) {
+ if (!card.items.length) continue;
+ out.push(card);
+ if (out.length >= MAX_CARDS) return out;
+ }
+ }
+ return out;
+ }
+
+ /** Start the five-minute fetch (the first after two seconds so boot is not held up). */
+ async function start() {
+ await init();
+ const kick = setTimeout(() => refresh().catch(() => {}), 2_000);
+ kick.unref();
+ timer = setInterval(() => refresh().catch(() => {}), REFRESH_MS);
+ timer.unref();
+ }
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { init, refresh, cards, start, stop, _state: () => state };
+}
+
+module.exports = { createMenuCards, cardOf, cardsOf, text, cardUrl, MAX_CARDS, KEEP_MS, REFRESH_MS, PATH };
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
// next: { [siteId]: [siteId, …] }, up to 6 places people go next
// next: { [siteId]: [siteId, …] }, up to 6 places people go next
// discover_ids: [siteId, …], the open sites, ordered for discovery
// discover_ids: [siteId, …], the open sites, ordered for discovery
// menu: { beacon }, where the shared menu sends its clicks
// menu: { beacon }, where the shared menu sends its clicks
+// cards: [frame.menu-card@1, …], each open site's cards, at most 4, Live first
// groups: [{ id, name }], the launcher's sections, in order
// groups: [{ id, name }], the launcher's sections, in order
// soon: [{ id, name, url, icon, state }], state: internal | placeholder
// soon: [{ id, name, url, icon, state }], state: internal | placeholder
// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }
// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }
@@ -25,6 +26,7 @@
@@ -25,6 +26,7 @@
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
+const { createMenuCards } = require('./menu-cards');
const toolsCatalog = require('../domains/catalog');
const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
@@ -128,6 +130,17 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -128,6 +130,17 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let rankedAt = savedRank.value ? savedRank.at || null : null;
let rankedAt = savedRank.value ? savedRank.at || null : null;
let copy = (await load('copy') || {}).value || { sites: {} };
let copy = (await load('copy') || {}).value || { sites: {} };
let version = Date.now();
let version = Date.now();
+ // The menu's network cards (frame.menu-cards@1): fetched from every open site every five minutes, kept in
+ // frame_cache across restarts. A failed site keeps its last good card for 30 minutes; GET /api/frame carries
+ // at most four, Live first while anyone is live, then the Frame's own ranking.
+ const menuCards = createMenuCards({
+ sites: () => SITES.filter((s) => s.status === 'open'),
+ rankOf: () => orderedOpen().map((s) => s.id),
+ load: () => load('menu-cards'),
+ save: (v) => save('menu-cards', v),
+ onChange: () => { version = Date.now(); },
+ });
+ await menuCards.init();
const openIds = () => new Set(SITES.filter((s) => s.status === 'open').map((s) => s.id));
const openIds = () => new Set(SITES.filter((s) => s.status === 'open').map((s) => s.id));
/** Only open sites, never the site itself, at most six; drops duplicates and anything from an older seed. */
/** Only open sites, never the site itself, at most six; drops duplicates and anything from an older seed. */
function cleanNext(map) {
function cleanNext(map) {
@@ -353,6 +366,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -353,6 +366,9 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
// order, and the click beacon (Shared sends it only when this object is present).
// order, and the click beacon (Shared sends it only when this object is present).
next, discover_ids: discoverIds,
next, discover_ids: discoverIds,
menu: { beacon: MENU_BEACON },
menu: { beacon: MENU_BEACON },
+ // The network's cards: each open site's published frame.menu-card@1, at most four, Live first while
+ // anyone is live, then the site's place in this Frame's ranking. Empty cards are not sent.
+ cards: menuCards.cards(),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
@@ -591,17 +607,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -591,17 +607,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
const t3 = setTimeout(() => probeLegal().catch(() => {}), 45_000); t3.unref();
const t3 = setTimeout(() => probeLegal().catch(() => {}), 45_000); t3.unref();
const i3 = setInterval(() => probeLegal().catch(() => {}), 24 * 3600 * 1000); i3.unref();
const i3 = setInterval(() => probeLegal().catch(() => {}), 24 * 3600 * 1000); i3.unref();
+ await menuCards.start();
timers = [t1, i1, t2, i2, t3, i3];
timers = [t1, i1, t2, i2, t3, i3];
}
}
/** Graceful stop: no further rank or copy refreshes. */
/** Graceful stop: no further rank or copy refreshes. */
- function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
+ function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; menuCards.stop(); }
/** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */
/** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */
function ranking() {
function ranking() {
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
}
}
- return { router, start, stop, refreshRank, refreshCopy, probeLegal, payloadFor, ranking, _state: () => ({ rank, copy }) };
+ return { router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, _state: () => ({ rank, copy, cards: menuCards._state() }) };
}
}
module.exports = { createFrameService, BANNED };
module.exports = { createFrameService, BANNED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+// The menu's network cards (frame.menu-card@1): Network fetches every open site's /.well-known/openvibe-menu.json
+// every five minutes (5 s, 64 KB, no redirects), drops unknown fields and markup, validates against the contract,
+// keeps a failed site's last good cards for 30 minutes, sends at most four with Live first while anyone is live,
+// then in Frame rank order, and carries them in GET /api/frame. One migrated test database, stubbed fetches.
+// node test/frame-cards.test.js
+const assert = require('assert');
+const express = require('express');
+const http = require('http');
+const { getDb } = require('../server/db/database');
+const { createFrameService } = require('../server/frame/service');
+const { createMenuCards, cardOf, cardsOf } = require('../server/frame/menu-cards');
+
+const SITES = [
+ { id: 'live', host: 'openvibe.live' },
+ { id: 'games', host: 'openvibe.games' },
+ { id: 'media-hub', host: 'openvibe.video' },
+];
+const NOW = Date.parse('2026-11-07T08:00:00Z');
+const card = (site, over = {}) => ({
+ id: `${site}-card`,
+ site,
+ title: 'The card',
+ kind: 'rows',
+ items: [{ title: 'One', meta: '1 watching', url: `https://${SITES.find((s) => s.id === site).host}/one` }],
+ updated_at: '2026-11-07T08:00:00Z',
+ ttl_s: 30,
+ ...over,
+});
+
+// ── The sanitizer: unknown fields dropped, markup stripped, URLs checked ──
+const dirty = card('live', {
+ extra: 'dropped',
+ title: ' Live <b>now</b> ',
+ items: [
+ { title: '<i>Nova</i>', meta: '<b>12</b> watching', url: 'https://openvibe.live/@nova', extra: 1 },
+ { title: 'Elsewhere', url: 'https://example.com/x' },
+ { title: 'Video host', url: 'https://openvibe.video/v/2' },
+ { title: 'Plain http', url: 'http://openvibe.live/x' },
+ { url: 'https://openvibe.live/x' },
+ ],
+});
+const clean = cardOf(dirty, SITES[0]);
+assert.deepStrictEqual(Object.keys(clean).sort(), ['id', 'items', 'kind', 'site', 'title', 'ttl_s', 'updated_at']);
+assert.strictEqual(clean.title, 'Live now');
+assert.deepStrictEqual(clean.items, [
+ { title: 'Nova', meta: '12 watching', url: 'https://openvibe.live/@nova' },
+ { title: 'Video host', url: 'https://openvibe.video/v/2' },
+]);
+assert.strictEqual(cardOf(card('games'), SITES[0]), null, 'a card is refused by a host other than its site');
+assert.strictEqual(cardOf(card('live', { title: 'x'.repeat(200) }), SITES[0]).title.length, 60, 'text is cut to the schema');
+assert.strictEqual(cardOf(card('live', { ttl_s: 5 }), SITES[0]), null, 'a ttl under 30 is refused');
+assert.strictEqual(cardOf(card('live', { updated_at: 'yesterday' }), SITES[0]), null, 'an unreadable time is refused');
+assert.strictEqual(cardsOf([card('live'), card('live', { id: 'other' }), card('live', { id: 'third' }), card('live', { id: 'fourth' })], SITES[0]).length, 3, 'at most three cards per site');
+assert.deepStrictEqual(cardsOf([], SITES[0]), [], 'an empty file is a valid, empty set');
+
+// ── The ranking, the 64 KB cap and the 30-minute last-good window ──
+function response(body, { status = 200, headers = {} } = {}) {
+ return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });
+}
+
+(async () => {
+ let now = NOW;
+ let saved = null;
+ let failed = new Set();
+ const fetchImpl = async (url) => {
+ const site = SITES.find((s) => url.startsWith(`https://${s.host}/`));
+ if (!site || failed.has(site.id)) return response('nope', { status: 503 });
+ return response([card(site.id)]);
+ };
+ const load = async () => (saved ? { value: saved, at: now } : null);
+ const save = async (v) => { saved = v; };
+ let changed = 0;
+ const menu = createMenuCards({ sites: () => SITES, rankOf: () => ['games', 'media-hub', 'live'], load, save, fetchImpl, now: () => now, onChange: () => { changed++; } });
+ await menu.init();
+ assert.deepStrictEqual(menu.cards(), []);
+ await menu.refresh();
+ assert.deepStrictEqual(menu.cards().map((c) => c.site), ['live', 'games', 'media-hub'], 'Live is first while it has anyone live; then Frame rank');
+ assert.ok(changed > 0, 'a new set bumps the response version');
+ assert.ok(saved && saved.sites.live, 'the set is saved in frame_cache');
+
+ // Nobody live: Live's empty rail makes no card, and the rank decides.
+ const emptyLive = async (url) => {
+ const site = SITES.find((s) => url.startsWith(`https://${s.host}/`));
+ if (!site) return response('nope', { status: 503 });
+ return response([card(site.id, site.id === 'games' ? {} : { items: [] })]);
+ };
+ const quiet = createMenuCards({ sites: () => SITES, rankOf: () => ['games', 'media-hub', 'live'], load: async () => null, save: async () => {}, fetchImpl: emptyLive, now: () => now });
+ await quiet.refresh();
+ assert.deepStrictEqual(quiet.cards().map((c) => c.site), ['games'], 'an empty Live rail is skipped');
+
+ // A site whose file fails keeps its last good card for 30 minutes, then contributes none.
+ failed = new Set();
+ const flaky = createMenuCards({ sites: () => SITES, rankOf: () => ['games', 'media-hub', 'live'], load: async () => null, save: async () => {}, fetchImpl, now: () => now });
+ await flaky.refresh();
+ failed = new Set(['live']);
+ now += 29 * 60_000;
+ await flaky.refresh();
+ assert.ok(flaky.cards().some((c) => c.site === 'live'), 'the last good card is kept for 30 minutes');
+ now += 2 * 60_000;
+ await flaky.refresh();
+ assert.ok(!flaky.cards().some((c) => c.site === 'live'), 'after 30 minutes without a good file the card is gone');
+
+ // A file over 64 KB is a failure, not a huge card.
+ const big = createMenuCards({
+ sites: () => [SITES[1]],
+ rankOf: () => ['games'],
+ load: async () => null,
+ save: async () => {},
+ fetchImpl: async () => response('x'.repeat(70 * 1024)),
+ now: () => now,
+ });
+ await big.refresh();
+ assert.deepStrictEqual(big.cards(), [], 'a file over 64 KB is refused');
+
+ // ── GET /api/frame?host=openvibe.chat carries the cards ──
+ failed = new Set();
+ now = NOW;
+ const realFetch = global.fetch;
+ global.fetch = fetchImpl;
+ const db = getDb();
+ const svc = await createFrameService(db, { services: {} }, null);
+ await svc.refreshCards();
+ global.fetch = realFetch;
+ const payload = svc.payloadFor('openvibe.chat');
+ assert.ok(Array.isArray(payload.cards), 'the Frame payload has cards');
+ assert.deepStrictEqual(payload.cards.map((c) => c.site), ['live', 'games', 'media-hub'], 'Live first, then the rank');
+ assert.ok(payload.cards.length <= 4);
+
+ const app = express(); app.use('/api/frame', svc.router);
+ const srv = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
+ const r = await fetch(`http://127.0.0.1:${srv.address().port}/api/frame?host=openvibe.chat`);
+ const body = await r.json();
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
+ assert.strictEqual(body.cards[0].site, 'live');
+ srv.close();
+
+ console.log('frame menu cards: sanitize, 64 KB, Live first, rank, 30-minute last good, /api/frame carries them');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -124,6 +124,7 @@ const REVIEWED = {
@@ -124,6 +124,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
+ 'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],