openvibers/openvibe.network Commits

Serve up to twelve menu cards in /api/frame (#633)

OP OpenVibers committed 6 days ago · e4b941c996 · 5 files, +32 −10

Changes

ddiff --git a/server/frame/menu-cards.js b/server/frame/menu-cards.js
iindex 88445dca..2ce1905c 100644
---- a/server/frame/menu-cards.js
++++ b/server/frame/menu-cards.js
@@@ -1,7 +1,7 @@
'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).
++ * Network fetches them every five minutes and GET /api/frame carries the best cards as `cards` (at most twelve).
*
* 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
@@@ -11,7 +11,7 @@
* 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.
++ * own ranking. At most twelve cards.
*/
const contracts = require('openvibe-contracts');
@@@ -19,7 +19,7 @@ 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 MAX_CARDS = 12;
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'];
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 5e63f688..25bf9891 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -10,7 +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
++// cards: [frame.menu-card@1, …], each open site's cards, at most 12, 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 } } }
@@@ -132,7 +132,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
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.
++ // at most twelve, 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),
@@@ -366,7 +366,7 @@ 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
++ // The network's cards: each open site's published frame.menu-card@1, at most twelve, 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 })),
ddiff --git a/test/frame-cards.test.js b/test/frame-cards.test.js
iindex c6d85ddb..cec680b6 100644
---- a/test/frame-cards.test.js
++++ b/test/frame-cards.test.js
@@@ -1,7 +1,7 @@
'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,
++// keeps a failed site's last good cards for 30 minutes, sends at most twelve with Live first while anyone is live,
// then in Frame rank order, and carries them in GET /api/frame, images included. One migrated test database, stubbed fetches.
// node test/frame-cards.test.js
const assert = require('assert');
@@@ -109,6 +109,24 @@ function response(body, { status = 200, headers = {} } = {}) {
await flaky.refresh();
assert.ok(!flaky.cards().some((c) => c.site === 'live'), 'after 30 minutes without a good file the card is gone');
++ // At most twelve cards: thirteen open sites publishing one valid card each give the first twelve in rank order.
++ const manySites = Array.from({ length: 13 }, (_, i) => ({ id: `openvibe-site-${i}`, host: `openvibe.site${i}` }));
++ const manyCard = (site) => ({ id: 'the-card', site: site.id, title: 'The card', kind: 'rows', items: [{ title: 'One', url: `https://${site.host}/one` }], updated_at: '2026-11-07T08:00:00Z', ttl_s: 30 });
++ const twelve = createMenuCards({
++ sites: () => manySites,
++ rankOf: () => manySites.map((s) => s.id),
++ load: async () => null,
++ save: async () => {},
++ fetchImpl: async (url) => {
++ const site = manySites.find((s) => url.startsWith(`https://${s.host}/`));
++ return response(site ? [manyCard(site)] : 'nope', { status: site ? 200 : 503 });
++ },
++ now: () => now,
++ });
++ await twelve.refresh();
++ assert.strictEqual(twelve.cards().length, 12, 'at most twelve cards are sent');
++ assert.deepStrictEqual(twelve.cards().map((c) => c.site), manySites.slice(0, 12).map((s) => s.id), 'the first twelve in rank order');
++
// A file over 64 KB is a failure, not a huge card.
const big = createMenuCards({
sites: () => [SITES[1]],
@@@ -133,7 +151,7 @@ function response(body, { status = 200, headers = {} } = {}) {
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);
++ assert.ok(payload.cards.length <= 12);
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)); });
ddiff --git a/test/identity-trust.test.js b/test/identity-trust.test.js
iindex dc77db2e..c6a82357 100644
---- a/test/identity-trust.test.js
++++ b/test/identity-trust.test.js
@@@ -20,7 +20,9 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
(async () => {
const db = getDb();
const quiet = console.log; console.log = () => {};
-- const now = Date.UTC(2026, 10, 6, 12);
++ // A moving clock: the HTTP read below uses the real time, so a fixed date made the 24-hour
++ // assertion fail the day after the fixture was written.
++ const now = Date.now();
const at = (ms) => new Date(ms).toISOString();
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen, email_verified) VALUES (1, 'newbie', 'x', ?, ?, ?, 0)").run(USER_NEW, at(now), at(now));
await db.prepare("INSERT INTO users (id, username, password_hash, subject_id, created_at, last_seen, email_verified) VALUES (2, 'old', 'x', ?, ?, ?, 0)").run(USER_OLD, at(now - 40 * DAY), at(now));
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex ce31276e..c6fffa82 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -281,7 +281,9 @@ function inventory(root) {
assert.strictEqual(target.count(), 0, 'nothing connected to the address a person named');
assert.deepStrictEqual(made.filter((c) => c.endsWith(` ${T}`)), [], 'no connection to the chosen port at all');
const hosts = new Set(made.map((c) => c.split(' ')[0]));
-- for (const h of hosts) assert.ok(['127.0.0.1', 'discord.com', 'gateway.discord.gg', 'api.github.com'].includes(h), `unexpected destination ${h}`);
++ // openvibe.* is the menu-cards fetcher: every open site's fixed https://<site host>/.well-known/openvibe-menu.json
++ // (server/frame/menu-cards.js, reviewed above). It reads first-party hosts only, never a value a person named.
++ for (const h of hosts) assert.ok(['127.0.0.1', 'discord.com', 'gateway.discord.gg', 'api.github.com'].includes(h) || h.startsWith('openvibe.'), `unexpected destination ${h}`);
assert.match(gh.text, /no egress to api\.github\.com|could not be reached/, 'the GitHub integration test goes to api.github.com only (the egress guard refuses the socket; undici surfaces it as a fetch failure)');
assert.ok(ingested.length >= 10 && ingested.every((x) => x.path === '/internal/avatar-ingest'), `avatar imports went to Media's ingest instead (${ingested.length})`);
assert.ok(ingested.some((x) => x.url === `https://127.0.0.1:${T}/a.png`), 'Media was asked to import the URL; Network did not fetch it');