openvibers/openvibe.network Commits

Fetch, validate and rank the menu's network cards in /api/frame (#623)

OP OpenVibers committed 6 days ago · 858838a8a9 · 8 files, +409 −10

* Fetch, validate and rank the menu's network cards in /api/frame

* Review the menu-card fetch in the SSRF ratchet

Changes

ddiff --git a/README.md b/README.md
iindex e16a5c37..97b22325 100644
---- a/README.md
++++ b/README.md
@@@ -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))
ddiff --git a/STATUS.json b/STATUS.json
iindex a7a17147..0ab0eab5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 9e7665ad..6440963e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 82e05a29..98278721 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/frame/menu-cards.js b/server/frame/menu-cards.js
nnew file mode 100644
iindex 00000000..b8579f09
---- /dev/null
++++ b/server/frame/menu-cards.js
@@@ -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 };
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 87c7ba0f..5e63f688 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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, is
let 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, is
const 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 };
ddiff --git a/test/frame-cards.test.js b/test/frame-cards.test.js
nnew file mode 100644
iindex 00000000..91f67ab4
---- /dev/null
++++ b/test/frame-cards.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 0516006d..b2f9d087 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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'],