openvibers/openvibe.network Commits
Serve up to twelve menu cards in /api/frame (#633)
Changes
@@@ -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'];@@@ -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, islet 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 })),@@@ -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.jsconst 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)); });@@@ -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));@@@ -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');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* The menu's network cards: every open site publishes /.well-known/openvibe-menu.json (frame.menu-cards@1),
* 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 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
* a five-second timeout, 64 KB at most, no redirects, JSON only — and every card is checked against the contract
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* result is kept in frame_cache, so a restart starts from the last good set rather than an empty menu.
* 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
* 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');
const contracts = require('openvibe-contracts');
@@ -19,7 +19,7 @@ const REFRESH_MS = 5 * 60_000;
@@ -19,7 +19,7 @@ const REFRESH_MS = 5 * 60_000;
const KEEP_MS = 30 * 60_000;
const KEEP_MS = 30 * 60_000;
const TIMEOUT_MS = 5_000;
const TIMEOUT_MS = 5_000;
const MAX_BYTES = 64 * 1024;
const MAX_BYTES = 64 * 1024;
-const MAX_CARDS = 4;
+const MAX_CARDS = 12;
const PATH = '/.well-known/openvibe-menu.json';
const PATH = '/.well-known/openvibe-menu.json';
const KINDS = new Set(['rail', 'rows', 'chips']);
const KINDS = new Set(['rail', 'rows', 'chips']);
const CARD_FIELDS = ['id', 'site', 'title', 'kind', 'items', 'more', 'updated_at', 'ttl_s'];
const CARD_FIELDS = ['id', 'site', 'title', 'kind', 'items', 'more', 'updated_at', 'ttl_s'];
@@ -10,7 +10,7 @@
@@ -10,7 +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
+// cards: [frame.menu-card@1, …], each open site's cards, at most 12, 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 } } }
@@ -132,7 +132,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -132,7 +132,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
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
// 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
// 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({
const menuCards = createMenuCards({
sites: () => SITES.filter((s) => s.status === 'open'),
sites: () => SITES.filter((s) => s.status === 'open'),
rankOf: () => orderedOpen().map((s) => s.id),
rankOf: () => orderedOpen().map((s) => s.id),
@@ -366,7 +366,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -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).
// 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
+ // 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.
// anyone is live, then the site's place in this Frame's ranking. Empty cards are not sent.
cards: menuCards.cards(),
cards: menuCards.cards(),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The menu's network cards (frame.menu-card@1): Network fetches every open site's /.well-known/openvibe-menu.json
// 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,
// 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.
// 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
// node test/frame-cards.test.js
const assert = require('assert');
const assert = require('assert');
@@ -109,6 +109,24 @@ function response(body, { status = 200, headers = {} } = {}) {
@@ -109,6 +109,24 @@ function response(body, { status = 200, headers = {} } = {}) {
await flaky.refresh();
await flaky.refresh();
assert.ok(!flaky.cards().some((c) => c.site === 'live'), 'after 30 minutes without a good file the card is gone');
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.
// A file over 64 KB is a failure, not a huge card.
const big = createMenuCards({
const big = createMenuCards({
sites: () => [SITES[1]],
sites: () => [SITES[1]],
@@ -133,7 +151,7 @@ function response(body, { status = 200, headers = {} } = {}) {
@@ -133,7 +151,7 @@ function response(body, { status = 200, headers = {} } = {}) {
const payload = svc.payloadFor('openvibe.chat');
const payload = svc.payloadFor('openvibe.chat');
assert.ok(Array.isArray(payload.cards), 'the Frame payload has cards');
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.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 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 srv = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
@@ -20,7 +20,9 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
@@ -20,7 +20,9 @@ const USER_STALE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNP5';
(async () => {
(async () => {
const db = getDb();
const db = getDb();
const quiet = console.log; console.log = () => {};
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();
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 (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));
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));
@@ -281,7 +281,9 @@ function inventory(root) {
@@ -281,7 +281,9 @@ function inventory(root) {
assert.strictEqual(target.count(), 0, 'nothing connected to the address a person named');
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');
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]));
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.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.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');
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');