openvibers/openvibe.network Commits

Frame menu backend: next moves, Discover order, menu clicks and the ranking fix (#621)

OP OpenVibers committed 6 days ago · a928b86060 · 10 files, +573 −14

Changes

ddiff --git a/migrations/0046_menu_clicks.sql b/migrations/0046_menu_clicks.sql
nnew file mode 100644
iindex 00000000..b9e8d95a
---- /dev/null
++++ b/migrations/0046_menu_clicks.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- The shared menu's anonymous click counts (plan 2026-10-22, track W; Shared 4.128.0 sends the beacon).
++-- One row per day, section, destination site and position bucket; no person, page or cookie is stored.
++CREATE TABLE IF NOT EXISTS menu_clicks (
++ day text COLLATE "C" NOT NULL,
++ section text COLLATE "C" NOT NULL,
++ to_site text COLLATE "C" NOT NULL,
++ pos_bucket text COLLATE "C" NOT NULL,
++ n bigint NOT NULL DEFAULT 0,
++ PRIMARY KEY (day, section, to_site, pos_bucket)
++);
ddiff --git a/public/admin.html b/public/admin.html
iindex 301a3222..01ed6b0d 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -382,6 +382,7 @@
<!-- Overview sub-tab -->
<div id="analytics-overview">
<div id="analytics-insights"></div>
++ <div id="analytics-menu"></div>
<div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="card" id="analytics-realtime-card" style="display:none">
<h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3><p class="admin-intro">These counts cover requests in the last five minutes. Check them during an incident; higher request counts mean more activity and higher error counts mean more failures.</p>
ddiff --git a/public/js/admin/analytics.js b/public/js/admin/analytics.js
iindex 7a41ffa0..f49b8a8a 100644
---- a/public/js/admin/analytics.js
++++ b/public/js/admin/analytics.js
@@@ -149,8 +149,32 @@ async function askVibeToImprove() {
} catch (e) { toast(e.message, 'error'); }
}
++// ── The menu: the shared menu's anonymous clicks, per section and per destination site, this week over last week ──
++const MENU_SECTION_LABEL = { places: 'Your places', here: 'On this site', next: 'Next from here', discover: 'New to you', you: 'You', tools: 'Tools', recent: 'Recent searches', all: 'Every OpenVibe site', back: 'Jump back in', live: 'Live now' };
++async function loadMenu() {
++ const el = document.getElementById('analytics-menu'); if (!el) return;
++ el.innerHTML = '<div class="loading">Loading the menu…</div>';
++ let menu;
++ try { menu = (await api('/api/admin/analytics/menu')).menu; } catch (e) { el.innerHTML = `<p class="muted">The menu counts could not be loaded: ${esc(e.message)}</p>`; return; }
++ const change = (n) => n > 0 ? `+${fmtNum(n)}` : n < 0 ? `−${fmtNum(-n)}` : '0';
++ const table = (heading, rows, labelOf) => rows.length
++ ? `<table class="data-table" style="width:100%;font-size:13px"><tr><th style="text-align:left">${esc(heading)}</th><th style="text-align:right">Last 7 days</th><th style="text-align:right">Previous 7 days</th><th style="text-align:right">Change</th></tr>${rows.map((r) => `<tr><td>${esc(labelOf(r.name))}</td><td style="text-align:right">${fmtNum(r.this_week)}</td><td style="text-align:right">${fmtNum(r.last_week)}</td><td style="text-align:right">${change(r.this_week - r.last_week)}</td></tr>`).join('')}</table>`
++ : '<p class="muted">No clicks recorded in these two weeks.</p>';
++ const totals = menu.totals && menu.totals.sections ? menu.totals.sections : { this_week: 0, last_week: 0 };
++ el.innerHTML = `<div class="card" style="margin-bottom:16px">
++ <h3><i class="fa-solid fa-bars"></i> The menu <small class="muted">${esc(menu.week.current)} to today · previous seven days</small></h3>
++ <p class="admin-intro">These are the menu's anonymous clicks: which part of the menu people used and which site they chose. Counts only, never a person; week over week shows whether the menu is spreading traffic.</p>
++ <p class="muted" style="margin:4px 0 10px">This week: ${fmtNum(totals.this_week)} sends · previous week: ${fmtNum(totals.last_week)}.</p>
++ <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));gap:16px">
++ <div>${table('Section', menu.sections || [], (id) => MENU_SECTION_LABEL[id] || id)}</div>
++ <div>${table('Destination site', menu.sites || [], (id) => id)}</div>
++ </div>
++ </div>`;
++}
++
async function loadAnalyticsOverview() {
loadInsights();
++ loadMenu();
const period = getAnalyticsPeriod();
const statsEl = document.getElementById('analytics-stats');
statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
ddiff --git a/server/admin/analytics-routes.js b/server/admin/analytics-routes.js
iindex e744caaf..a5bb044d 100644
---- a/server/admin/analytics-routes.js
++++ b/server/admin/analytics-routes.js
@@@ -118,6 +118,31 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
res.set('Cache-Control', 'private, no-store').json({ ok: true, insights: out });
});
++ // ═══════════════════════════════════════════════════════════
++ // The menu — the shared menu's anonymous clicks (menu_clicks), this week over last week
++ // ═══════════════════════════════════════════════════════════
++ // One small table section on the Analytics page: sends per section and per destination site, week over week.
++ // Days are UTC date strings written by POST /api/frame/menu-click; this week is the last seven days (today-6 … today)
++ // and last week is the seven before it (today-13 … today-7). No person, page or cookie is involved.
++ router.get('/menu', async (req, res) => {
++ const day = (n) => { const d = new Date(); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - n)).toISOString().slice(0, 10); };
++ const current = day(6), previous = day(13); // [previous, current) is last week
++ try {
++ const db = analytics.db;
++ const rows = async (column) => (await db.prepare(`SELECT ${column} AS name,
++ COALESCE(SUM(CASE WHEN day >= ? THEN n ELSE 0 END), 0)::bigint AS this_week,
++ COALESCE(SUM(CASE WHEN day >= ? AND day < ? THEN n ELSE 0 END), 0)::bigint AS last_week
++ FROM menu_clicks WHERE day >= ? GROUP BY ${column} ORDER BY this_week DESC, last_week DESC, ${column}`).all(current, previous, current, previous))
++ .map((r) => ({ name: String(r.name), this_week: Number(r.this_week) || 0, last_week: Number(r.last_week) || 0 }));
++ const [sections, sites] = await Promise.all([rows('section'), rows('to_site')]);
++ const total = (list) => ({ this_week: list.reduce((a, r) => a + r.this_week, 0), last_week: list.reduce((a, r) => a + r.last_week, 0) });
++ res.set('Cache-Control', 'private, no-store').json({ ok: true, menu: { week: { current, previous }, sections, sites: sites.slice(0, 40), totals: { sections: total(sections), sites: total(sites) } } });
++ } catch (err) {
++ console.error('[Analytics] Menu error:', err.message);
++ res.status(500).json({ ok: false, error: 'The menu counts could not be read' });
++ }
++ });
++
// ═══════════════════════════════════════════════════════════
// Overview — Cross-Service Summary
// ═══════════════════════════════════════════════════════════
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 6a90bff8..87c7ba0f 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -3,9 +3,13 @@
// OpenVibe Frame data: what the navbar and footer of every site show.
//
// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag
--// GET /api/frame/pulse public, CORS *, max-age=30: { live_now, watching, sites, views_7d, updated }
++// GET /api/frame/pulse public, CORS *, max-age=30: { live_now, watching, sites, views_7d, live, updated }
++// POST /api/frame/menu-click public, CORS *, text/plain, max 512 bytes, 60/min per IP: one menu click counted
// → { updated, nav: [{ id, name, url, icon, tagline, group, of? }], open sites, most used first (of: a product
// on its own domain, served by that site)
++// 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
// 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 } } }
@@@ -26,6 +30,56 @@ const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
const BASE_WEIGHT = { live: 7, tools: 6, space: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only
++const MENU_BEACON = 'https://openvibe.network/api/frame/menu-click';
++const MENU_SECTIONS = new Set(['places', 'here', 'next', 'discover', 'you', 'tools', 'recent', 'all', 'back', 'live']);
++// The shared menu's item kinds (openvibe-shared/search.js siteItem, toolRow, …) plus the 'open' impression beacon it
++// may send later. The count itself is stored by section and destination, not by kind.
++const MENU_KINDS = new Set(['site', 'page', 'action', 'results', 'tool', 'family', 'you', 'dock', 'live', 'recent', 'history', 'result', 'open']);
++
++// Shared 4.128.0's hand-picked "Next from <site>" relations (search.js NEXT_OF), the seed for a site without moves
++// yet. Product ids (pics, download) are dropped: `next` carries site ids only.
++const NEXT_SEED = {
++ live: ['media-hub', 'community', 'chat', 'tools', 'bot', 'vip'],
++ 'media-hub': ['live', 'pics', 'community', 'download'],
++ community: ['live', 'chat', 'tools', 'space'],
++ chat: ['live', 'community', 'media-hub', 'games'],
++ tools: ['live', 'community', 'download', 'chat'],
++ network: ['live', 'media-hub', 'community', 'design'],
++ games: ['live', 'casino', 'quest', 'chat'],
++ bot: ['live', 'actor', 'codes', 'run'],
++ pics: ['media-hub', 'download', 'live', 'design'],
++ download: ['pics', 'media-hub', 'tools', 'zone'],
++ design: ['space', 'website', 'pics', 'network'],
++ space: ['website', 'design', 'community', 'domains'],
++};
++
++// History service names that are not Frame site ids. 'media' is what Video's pages wrote (the brief's mapping);
++// 'pastes' is Community's. The URL host is the fallback, so a service whose id differs (openre → stream) still maps.
++const HISTORY_SITE = { pastes: 'community', media: 'media-hub', video: 'media-hub', 'media-hub': 'media-hub', openre: 'stream', download: 'media-hub', pics: 'media-hub', homes: 'rent' };
++
++const hostOfUrl = (url) => { try { return new URL(String(url)).hostname.toLowerCase(); } catch { return ''; } };
++const hostOfValue = (value) => {
++ const s = String(value || '').trim();
++ if (!s) return '';
++ try { return new URL(s.includes('://') ? s : `https://${s}`).hostname.toLowerCase().replace(/^www\./, ''); } catch { return ''; }
++};
++/** The open site a hostname belongs to, including products served on their own domains (openvibe.pics → media-hub). */
++function siteForDestination(hostname) {
++ const h = hostOfValue(hostname);
++ if (!h) return null;
++ const site = siteForHost(h);
++ if (site) return site;
++ const product = PRODUCTS.find((p) => h === p.host || h.endsWith('.' + p.host));
++ return product ? SITES.find((s) => s.id === product.of) || null : null;
++}
++/** A user_history row's Frame site id: service alias first, the site id itself, then the row's URL host. */
++function historySiteId(service, url) {
++ const key = String(service || '').toLowerCase();
++ if (HISTORY_SITE[key]) return HISTORY_SITE[key];
++ if (SITES.some((s) => s.id === key && s.status === 'open')) return key;
++ const site = siteForDestination(hostOfUrl(url));
++ return site ? site.id : null;
++}
/**
* Footer copy comes from OpenVibe.AI's network.site_copy workflow (roadmap Wave 13, ADR-015), called with a
@@@ -74,6 +128,39 @@ 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();
++ 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) {
++ const ids = openIds(); const out = {};
++ for (const [from, list] of Object.entries(map && typeof map === 'object' ? map : {})) {
++ if (!ids.has(from)) continue;
++ const seen = new Set([from]); const keep = [];
++ for (const id of Array.isArray(list) ? list : []) {
++ if (!ids.has(id) || seen.has(id)) continue;
++ seen.add(id); keep.push(id); if (keep.length >= 6) break;
++ }
++ if (keep.length) out[from] = keep;
++ }
++ return out;
++ }
++ /** The hand-picked graph, plus this site's group when the seed leaves room: every open site gets a list before
++ * the first move is counted, so the menu's "Next from here" is never empty on a cold network. */
++ function seedNext() {
++ const ids = openIds(); const map = {};
++ for (const site of SITES.filter((s) => s.status === 'open')) {
++ const out = []; const seen = new Set([site.id]);
++ const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
++ for (const id of NEXT_SEED[site.id] || []) add(id);
++ for (const s of SITES) if (s.status === 'open' && groupOf(s.id) === groupOf(site.id)) add(s.id);
++ map[site.id] = out;
++ }
++ return map;
++ }
++ let nextMap = cleanNext((await load('next') || {}).value) || seedNext();
++ if (!Object.keys(nextMap).length) nextMap = seedNext();
++ let discover = (await load('discover') || {}).value || null;
++ if (!discover || !Array.isArray(discover.ids)) discover = { ids: null, at: null, seed: 0 }; // filled from the frame order below
++ let menuOpensSeen = 0; // 'open' beacons, until the client sends them; an impression estimate, never stored by person
const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
async function getJson(url, headers, timeoutMs = 6000) {
@@@ -103,21 +190,114 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
for (const [id, n] of Object.entries(perSite)) if (id !== 'live') scores[id] = Math.round(id === 'network' ? n * 0.5 : n); // the account desk is not a destination
if (perTool.size) tools = [...perTool.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(x => x[0]);
} catch { /* */ }
-- // Signed-in history is a second opinion that also covers hosts without the shared navbar.
++ // Signed-in history is a second opinion that also covers hosts without the shared navbar. Service names that
++ // differ from site ids are mapped (Video's 'media' → media-hub, Community's 'pastes' → community), and two
++ // services on one site add up instead of overwriting each other.
try {
const bySvc = await db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();
-- for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }
++ for (const r of bySvc) {
++ const id = historySiteId(r.service, null) || (SITES.some((s) => s.id === r.service) ? r.service : null);
++ if (!id) continue;
++ scores[id + ':history'] = (scores[id + ':history'] || 0) + Number(r.n) + 5 * Number(r.u);
++ }
} catch { /* table appears on first history write */ }
rank = { scores, tools };
-- await save('rank', rank); version = Date.now(); rankedAt = Date.now();
++ await save('rank', rank);
++ // The menu's "next" graph and the discovery order are refreshed with the ranking, kept in frame_cache too.
++ try { nextMap = await refreshNext(); await save('next', nextMap); } catch { /* keep the last graph */ }
++ try { discover = await refreshDiscover(); await save('discover', discover); } catch { /* keep the last order */ }
++ version = Date.now(); rankedAt = Date.now();
++ }
++
++ /** Signed-in cross-site moves, last 30 days: consecutive history rows of one person on different services, mapped
++ * to Frame sites. Up to six destinations per site, by distinct people first and move count second. Observed moves
++ * lead; Shared's hand-picked relations fill a cold site and then this site's group fills any remaining room. */
++ async function refreshNext() {
++ const rows = await db.prepare("SELECT user_id, service, url FROM user_history WHERE created_at > datetime('now', '-30 days') ORDER BY user_id, updated_at, id").all();
++ const ids = openIds(); const pairs = new Map();
++ let lastUser = null, previous = null;
++ for (const r of rows) {
++ const user = String(r.user_id);
++ if (user !== lastUser) { lastUser = user; previous = null; }
++ const site = historySiteId(r.service, r.url);
++ const current = site && ids.has(site) ? site : null;
++ if (previous && current && previous !== current) {
++ const key = previous + '>' + current;
++ let pair = pairs.get(key);
++ if (!pair) { pair = { from: previous, to: current, people: new Set(), moves: 0 }; pairs.set(key, pair); }
++ pair.people.add(user); pair.moves++;
++ }
++ // An unmapped row breaks the chain: live → an unknown service → community is not a live → community move.
++ previous = current;
++ }
++ const byFrom = new Map();
++ for (const pair of pairs.values()) {
++ if (!byFrom.has(pair.from)) byFrom.set(pair.from, []);
++ byFrom.get(pair.from).push(pair);
++ }
++ const seed = seedNext(); const map = {};
++ for (const site of SITES.filter((s) => s.status === 'open')) {
++ const out = []; const seen = new Set([site.id]);
++ const add = (id) => { if (out.length < 6 && ids.has(id) && !seen.has(id)) { seen.add(id); out.push(id); } };
++ const moved = (byFrom.get(site.id) || []).sort((a, b) => b.people.size - a.people.size || b.moves - a.moves || a.to.localeCompare(b.to));
++ for (const pair of moved) add(pair.to);
++ for (const id of seed[site.id] || []) add(id);
++ map[site.id] = out;
++ }
++ return cleanNext(map);
++ }
++
++ // ── Discovery: Thompson sampling on menu clicks ────────────────────────────────────────────────────────────────
++ // The client does not send impressions yet. They are estimated from the menu's opens: every navbar page view is a
++ // chance to open the menu (frame_hits, 30 days), the menu shows two Discover places, and the estimate is spread
++ // evenly over the open sites. A click on a place raises its Beta(1 + clicks, 1 + misses) posterior, so places that
++ // are clicked when shown come up more often; every site still has a positive chance and everyone stays in the list.
++ // A later `kind: 'open'` beacon can report the real opens; until then menuOpensSeen is 0 and the estimate decides.
++ function mulberry32(a) {
++ return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
++ }
++ function sampleGamma(k, rnd) {
++ if (k < 1) return sampleGamma(k + 1, rnd) * Math.pow(rnd() || 1e-12, 1 / k);
++ const d = k - 1 / 3, c = 1 / Math.sqrt(9 * d);
++ for (;;) {
++ let x, v;
++ do { let u = 0, w = 0; while (u === 0) u = rnd(); while (w === 0) w = rnd(); x = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * w); v = 1 + c * x; } while (v <= 0);
++ v = v * v * v;
++ const u = rnd();
++ if (u < 1 - 0.0331 * x * x * x * x || Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
++ }
++ }
++ const sampleBeta = (a, b, rnd) => { const x = sampleGamma(a, rnd), y = sampleGamma(b, rnd); return x + y > 0 ? x / (x + y) : 0.5; };
++ async function refreshDiscover() {
++ let clicks = new Map(), totalClicks = 0;
++ try {
++ const rows = await db.prepare("SELECT to_site, SUM(n)::bigint AS n FROM menu_clicks WHERE section = 'discover' AND day >= substring(datetime('now', '-30 days'), 1, 10) GROUP BY to_site").all();
++ for (const r of rows) { const n = Number(r.n) || 0; if (n > 0) { clicks.set(r.to_site, n); totalClicks += n; } }
++ } catch { /* migration not applied yet, or no clicks */ }
++ const open = orderedOpen();
++ if (!totalClicks) return { ids: open.map((s) => s.id), at: Date.now(), seed: 0, impressions: 0 };
++ let opens = menuOpensSeen;
++ try { const r = await db.prepare("SELECT COALESCE(SUM(hits), 0)::bigint AS n FROM frame_hits WHERE day >= substring(datetime('now', '-30 days'), 1, 10)").get(); opens = Math.max(opens, Number(r && r.n) || 0); } catch { /* */ }
++ opens = Math.max(opens, totalClicks);
++ const impressions = Math.max(1, Math.round(opens * 2 / Math.max(1, open.length)));
++ const seed = (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
++ const rnd = mulberry32(seed);
++ const scored = open.map((s, i) => {
++ const n = clicks.get(s.id) || 0;
++ return { id: s.id, i, score: sampleBeta(1 + n, 1 + Math.max(0, impressions - n), rnd) };
++ }).sort((a, b) => b.score - a.score || a.i - b.i);
++ return { ids: scored.map((x) => x.id), at: Date.now(), seed, impressions };
}
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
function orderedOpen() {
const open = SITES.filter(s => s.status === 'open');
-- const max = Math.max(1, ...open.map(s => scoreOf(s.id)));
-- // Real use decides; the base weight only breaks ties and orders a cold start.
-- return open.map(s => ({ s, v: scoreOf(s.id) / max + (BASE_WEIGHT[s.id] || 0) / 100 })).sort((a, b) => b.v - a.v).map(x => x.s);
++ // Real use decides, compared raw: normalising by one service's own analytics scale made Live's tens of
++ // thousands of page views flatten every other site to nearly zero, so the cold-start BASE_WEIGHT decided the
++ // order and Video (openvibe.video) sat under Tools, Community and Space despite 2,687 hits in 7 days (11-07).
++ // The base weight now only breaks ties between sites with equal use and orders a network with no use yet.
++ return open.map(s => ({ s, score: scoreOf(s.id), base: BASE_WEIGHT[s.id] || 0 }))
++ .sort((a, b) => b.score - a.score || b.base - a.base).map(x => x.s);
}
function linkPool() {
@@@ -159,18 +339,24 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const site = siteForHost(hostname) || SITES.find(s => s.id === 'network');
const open = orderedOpen(); const pool = linkPool();
const ai = copy.sites[site.id];
-- const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
++ const footerDiscover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
// Legal documents live on the site's own apex, so each domain answers for itself, once it serves them: a site
// whose /privacy does not answer gets the network-wide documents here instead of three links that 404.
const legalBase = legalOwn.get(site.host) === false ? 'https://openvibe.network' : `https://${site.host}`;
++ const next = cleanNext(nextMap);
++ const discoverIds = discover && Array.isArray(discover.ids) && discover.ids.length ? discover.ids.filter(id => open.some(s => s.id === id)) : open.map(s => s.id);
return {
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
// Every open site, most used first, each followed by the products it serves on their own domains (`of`).
nav: open.flatMap(s => [s, ...PRODUCTS.filter(p => p.of === s.id)]).map(s => ({ id: s.id, name: MENU_NAME[s.id] || s.name, url: `https://${s.host}/`, icon: ICON_ALIAS[s.icon] || s.icon, tagline: MENU_LINE[s.id] || s.tagline, group: groupOf(s.id), ...(s.of ? { of: s.of } : {}) })),
++ // The menu's data: where people go next from each open site (totals only, no person), the fair discovery
++ // order, and the click beacon (Shared sends it only when this object is present).
++ next, discover_ids: discoverIds,
++ menu: { beacon: MENU_BEACON },
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 })),
-- footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
++ footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover: footerDiscover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
};
}
@@@ -193,15 +379,33 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let pulse = null;
let pulseAt = 0;
let pulseInflight = null;
++ /** Public live streams only: Live's list is public, but a row marked unlisted/private never reaches a navbar. */
++ const publicLive = (st) => {
++ if (!st || typeof st !== 'object') return false;
++ if (st.visibility && String(st.visibility).toLowerCase() !== 'public') return false;
++ if (st.is_public === false || st.unlisted === true || st.is_unlisted === true || st.listed === false) return false;
++ return true;
++ };
++ const viewersOf = (st) => Math.max(0, Number(st.total_viewer_count != null ? st.total_viewer_count : st.viewer_count) || 0);
async function readPulse() {
const d = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/api/streams`);
-- const streams = d && Array.isArray(d.streams) ? d.streams : null;
++ const streams = d && Array.isArray(d.streams) ? d.streams.filter(publicLive) : null;
++ const live = streams ? streams.filter((st) => /^[A-Za-z0-9_-]{1,40}$/.test(String(st.username || ''))).slice(0, 8).map((st) => {
++ const avatar = /^https:\/\//i.test(String(st.avatar_url || '')) ? String(st.avatar_url).slice(0, 500) : null;
++ return {
++ name: String(st.display_name || st.username || st.title || '').replace(/\s+/g, ' ').trim().slice(0, 100) || st.username,
++ url: `https://openvibe.live/@${encodeURIComponent(st.username)}`,
++ viewers: viewersOf(st),
++ ...(avatar ? { avatar } : {}),
++ };
++ }) : null;
const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);
return {
live_now: streams ? streams.length : null,
-- watching: streams ? streams.reduce((a, st) => a + Math.max(0, Number(st.total_viewer_count != null ? st.total_viewer_count : st.viewer_count) || 0), 0) : null,
++ watching: streams ? streams.reduce((a, st) => a + viewersOf(st), 0) : null,
sites: orderedOpen().length,
views_7d: views > 0 ? Math.round(views) : null,
++ live,
updated: new Date().toISOString(),
};
}
@@@ -211,7 +415,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
await pulseInflight;
}
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'public, max-age=30, stale-while-revalidate=120', 'Content-Type': 'application/json; charset=utf-8' });
-- res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, updated: null }));
++ res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, live: null, updated: null }));
});
// GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's
@@@ -301,6 +505,62 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.status(204).end();
});
++ // POST /api/frame/menu-click — the shared menu's anonymous click count (openvibe-shared/search.js beacon):
++ // { host, section, pos, to, kind }, text/plain from navigator.sendBeacon, at most 512 bytes, 60/minute per IP.
++ // Only an OpenVibe host the Frame knows is counted; there is no cookie, token or person in the request or the
++ // table. `kind: 'open'` is accepted for a future impression beacon and only raises the in-memory estimate.
++ const MENU_MAX = 512, MENU_PER_MIN = 60;
++ const menuRecent = new Map(); // ip → [count, windowStart]
++ const menuParser = express.text({ type: () => true, limit: '8kb' });
++ const menuHeaders = (res) => res.set({
++ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type',
++ 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'no-store',
++ });
++ router.options('/menu-click', (req, res) => { menuHeaders(res); res.status(204).end(); });
++ router.post('/menu-click', menuParser, async (req, res) => {
++ menuHeaders(res);
++ const now = Date.now(), ip = req.ip || ''; const win = menuRecent.get(ip);
++ if (!win || now - win[1] > 60_000) menuRecent.set(ip, [1, now]); else win[0]++;
++ if (menuRecent.size > 5000) menuRecent.clear();
++ if ((menuRecent.get(ip) || [0])[0] > MENU_PER_MIN) { res.set('Retry-After', '60'); return res.status(429).json({ ok: false, error: 'Too many menu clicks' }); }
++ let body;
++ try {
++ if (req.body && typeof req.body === 'object') {
++ if (Buffer.byteLength(JSON.stringify(req.body)) > MENU_MAX) return res.status(413).json({ ok: false, error: 'Body too large' });
++ body = req.body;
++ } else {
++ const text = typeof req.body === 'string' ? req.body : '';
++ if (Buffer.byteLength(text, 'utf8') > MENU_MAX) return res.status(413).json({ ok: false, error: 'Body too large' });
++ body = JSON.parse(text);
++ }
++ } catch { return res.status(400).json({ ok: false, error: 'Invalid JSON' }); }
++ if (!body || typeof body !== 'object' || Array.isArray(body)) return res.status(400).json({ ok: false, error: 'Invalid body' });
++ const section = String(body.section || '').toLowerCase();
++ if (!MENU_SECTIONS.has(section)) return res.status(400).json({ ok: false, error: 'Unknown section' });
++ const source = siteForDestination(body.host);
++ if (!source) return res.status(400).json({ ok: false, error: 'Unknown host' });
++ const kind = String(body.kind || 'click').toLowerCase();
++ if (!MENU_KINDS.has(kind)) return res.status(400).json({ ok: false, error: 'Unknown kind' });
++ if (kind === 'open') { menuOpensSeen++; return res.status(204).end(); }
++ const destination = siteForDestination(body.to);
++ if (!destination) return res.status(400).json({ ok: false, error: 'Unknown destination' });
++ const pos = Number(body.pos);
++ if (!Number.isInteger(pos) || pos < -1 || pos > 500) return res.status(400).json({ ok: false, error: 'Invalid position' });
++ const bucket = pos < 0 ? 'unknown' : pos <= 1 ? '1-2' : pos <= 5 ? '3-6' : '7+';
++ try {
++ await db.prepare("INSERT INTO menu_clicks (day, section, to_site, pos_bucket, n) VALUES (substring(ov_now(), 1, 10), ?, ?, ?, 1) ON CONFLICT(day, section, to_site, pos_bucket) DO UPDATE SET n = menu_clicks.n + 1").run(section, destination.id, bucket);
++ } catch (err) {
++ console.warn('[Frame] menu-click: could not count:', err.message);
++ return res.status(500).json({ ok: false, error: 'Could not count' });
++ }
++ res.status(204).end();
++ });
++ // body-parser's 8 kb ceiling, for a body larger than the 512-byte rule.
++ router.use((err, req, res, next) => {
++ if (!err || !/entity\.too\.large/.test(String(err.type || ''))) return next(err);
++ menuHeaders(res); res.status(413).json({ ok: false, error: 'Body too large' });
++ });
++
// ── Which sites serve their own legal documents ──────────────────────────────────────────────────────────────
// A site's footer links to its own /terms, /privacy and /dmca (openvibe-shared/legal, wording by its manifest's
// legalProfile). On 11-02 ten open sites did not serve them, so their footers' legal links answered 404. Every open
ddiff --git a/test/admin-menu.test.js b/test/admin-menu.test.js
nnew file mode 100644
iindex 00000000..3b2d5e13
---- /dev/null
++++ b/test/admin-menu.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// Admin Analytics — "The menu" (GET /api/admin/analytics/menu): anonymous menu sends per section and destination
++// site, this week over last week, admins only; the panel draws it on the overview.
++// node test/admin-menu.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const createAnalyticsRoutes = require('../server/admin/analytics-routes');
++
++(async () => {
++ const db = getDb();
++ const day = (n) => { const d = new Date(); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - n)).toISOString().slice(0, 10); };
++ const put = db.prepare('INSERT INTO menu_clicks (day, section, to_site, pos_bucket, n) VALUES (?, ?, ?, ?, ?)');
++ await put.run(day(0), 'next', 'community', '1-2', 4);
++ await put.run(day(1), 'next', 'community', '1-2', 3);
++ await put.run(day(8), 'next', 'community', '1-2', 2);
++ await put.run(day(0), 'discover', 'games', '1-2', 5);
++ await put.run(day(9), 'discover', 'games', '1-2', 1);
++ await put.run(day(20), 'all', 'network', '7+', 100); // outside both weeks: not counted
++
++ const requireAuth = (req, res, next) => { const u = req.headers['x-test-user']; if (!u) return res.status(401).json({ error: 'Authentication required' }); req.user = JSON.parse(u); next(); };
++ const app = express();
++ app.use('/api/admin/analytics', createAnalyticsRoutes({ db, getStats: async () => ({}) }, requireAuth, { services: {} }));
++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const base = `http://127.0.0.1:${srv.address().port}/api/admin/analytics/menu`;
++ const get = async (user) => { const r = await fetch(base, { headers: user ? { 'x-test-user': JSON.stringify(user) } : {} }); return { status: r.status, body: await r.json().catch(() => null), cache: r.headers.get('cache-control') }; };
++ try {
++ assert.strictEqual((await get(null)).status, 401);
++ assert.strictEqual((await get({ id: 2, role: 'global_mod' })).status, 403, 'admins only');
++ const r = await get({ id: 1, role: 'admin' });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.cache, /no-store/);
++ const m = r.body.menu;
++ assert.deepStrictEqual(m.week, { current: day(6), previous: day(13) }, 'the last seven days and the seven before');
++ assert.deepStrictEqual(m.sections, [
++ { name: 'next', this_week: 7, last_week: 2 },
++ { name: 'discover', this_week: 5, last_week: 1 },
++ ], 'per section, busiest this week first; the 20-day-old row is outside both weeks');
++ assert.deepStrictEqual(m.sites, [
++ { name: 'community', this_week: 7, last_week: 2 },
++ { name: 'games', this_week: 5, last_week: 1 },
++ ], 'per destination site');
++ assert.deepStrictEqual(m.totals.sections, { this_week: 12, last_week: 3 });
++
++ // The panel: a section on the overview and the route it asks.
++ const js = fs.readFileSync(path.join(__dirname, '../public/js/admin/analytics.js'), 'utf8');
++ const html = fs.readFileSync(path.join(__dirname, '../public/admin.html'), 'utf8');
++ assert.match(js, /\/api\/admin\/analytics\/menu/);
++ assert.match(js, /function loadMenu\(/);
++ assert.match(js, /loadMenu\(\)/);
++ assert.match(html, /id="analytics-menu"/);
++ console.log('admin menu: all checks passed');
++ } finally {
++ srv.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex f78111c5..43a7cf93 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/frame-menu.test.js b/test/frame-menu.test.js
nnew file mode 100644
iindex 00000000..37cf4888
---- /dev/null
++++ b/test/frame-menu.test.js
@@@ -0,0 +1,134 @@
++'use strict';
++// The Frame's menu data (plan 2026-10-22 track W): next from signed-in cross-site moves, the Discover order from menu
++// clicks, the anonymous menu-click beacon (validation, 512-byte limit, per-IP rate limit, daily counters) and the
++// pulse's public live channels. One migrated test database, no service calls except the stubs below.
++// node test/frame-menu.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 { SITES, siteForHost } = require('../server/frame/sites');
++
++const listen = (app) => new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++const url = (srv, path) => `http://127.0.0.1:${srv.address().port}${path}`;
++
++(async () => {
++ const db = getDb();
++ const openIds = SITES.filter((s) => s.status === 'open').map((s) => s.id);
++ for (let i = 1; i <= 5; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `menu${i}`, 'x');
++ const ins = db.prepare("INSERT INTO user_history (user_id, service, type, title, url) VALUES (?, ?, 'page', 't', ?)");
++ const insOld = db.prepare("INSERT INTO user_history (user_id, service, type, title, url, created_at) VALUES (?, ?, 'page', 't', ?, datetime('now', '-31 days'))");
++
++ // A few real moves: two people came to Live from Video, one stayed into Community, one went to Tools and one to
++ // Games. A month-old move (Live → Deals) must not count: the window is the last 30 days.
++ await ins.run(1, 'media', 'https://openvibe.media/a');
++ await ins.run(1, 'live', 'https://openvibe.live/');
++ await ins.run(1, 'community', 'https://openvibe.community/');
++ await ins.run(2, 'media', 'https://openvibe.media/b');
++ await ins.run(2, 'live', 'https://openvibe.live/');
++ await ins.run(3, 'live', 'https://openvibe.live/');
++ await ins.run(3, 'tools', 'https://dns.openvibe.tools/');
++ await ins.run(4, 'live', 'https://openvibe.live/');
++ await ins.run(4, 'games', 'https://openvibe.games/');
++ await insOld.run(5, 'live', 'https://openvibe.live/');
++ await insOld.run(5, 'deals', 'https://openvibe.deals/');
++
++ const svc = await createFrameService(db, { services: {} }, null);
++ const realFetch = global.fetch;
++ global.fetch = async () => ({ ok: false });
++ await svc.refreshRank();
++ global.fetch = realFetch;
++
++ let p = svc.payloadFor('openvibe.live');
++ assert.ok(p.next && typeof p.next === 'object', 'the Frame sends next');
++ assert.ok(Array.isArray(p.next.live), 'next.live exists for the live site');
++ assert.ok(p.next.live.length <= 6, 'at most six places');
++ assert.ok(['community', 'games', 'tools'].includes(p.next.live[0]), 'the people who moved on from Live lead its list');
++ assert.ok(p.next.live.includes('community') && p.next.live.includes('tools') && p.next.live.includes('games'), 'every one-person move from Live is there');
++ assert.strictEqual(p.next['media-hub'][0], 'live', 'Video → Live, the move two people made, leads Video\'s list');
++ assert.ok(!p.next.live.includes('deals'), 'a move older than 30 days is not counted');
++ assert.ok(!p.next.live.includes('live'), 'a site never recommends itself');
++ assert.ok(p.next.live.every((id) => openIds.includes(id)), 'only open site ids');
++ for (const [from, list] of Object.entries(p.next)) {
++ assert.ok(openIds.includes(from), `${from} is open`);
++ assert.ok(list.length <= 6 && !list.includes(from) && list.every((id) => openIds.includes(id)), `${from}'s list is open ids, at most six, never itself`);
++ }
++ assert.ok(p.next.deals && p.next.deals.length > 0, 'a cold site still has its hand-picked/neighbourhood places');
++ assert.strictEqual(p.menu && p.menu.beacon, 'https://openvibe.network/api/frame/menu-click', 'the click beacon is advertised');
++ assert.deepStrictEqual(p.discover_ids, svc.ranking().order, 'with no click data the Discover order is the frame order');
++
++ // A click on Games (50 of them) puts it first; every open site stays in the list exactly once.
++ await db.prepare("INSERT INTO menu_clicks (day, section, to_site, pos_bucket, n) VALUES (substring(ov_now(), 1, 10), 'discover', 'games', '1-2', 50), (substring(ov_now(), 1, 10), 'discover', 'live', '1-2', 1)").run();
++ global.fetch = async () => ({ ok: false });
++ await svc.refreshRank();
++ global.fetch = realFetch;
++ p = svc.payloadFor('openvibe.live');
++ assert.strictEqual(p.discover_ids[0], 'games', 'a place clicked when shown comes up first');
++ assert.strictEqual(p.discover_ids.length, openIds.length, 'every site keeps its chance: the list still has all of them');
++ assert.strictEqual(new Set(p.discover_ids).size, openIds.length, 'no duplicates');
++
++ // ── The click beacon ─────────────────────────────────────────────────────────────────────────────────────────
++ const app = express(); app.use('/api/frame', svc.router);
++ const srv = await listen(app);
++ const post = async (body, headers = {}) => {
++ const r = await fetch(url(srv, '/api/frame/menu-click'), { method: 'POST', headers: { 'content-type': 'text/plain', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) });
++ return { status: r.status, cors: r.headers.get('access-control-allow-origin'), body: await r.text() };
++ };
++ const good = { host: 'openvibe.live', section: 'next', pos: 1, to: 'openvibe.community', kind: 'site' };
++ let r = await post(good);
++ assert.strictEqual(r.status, 204, 'a valid beacon answers 204');
++ assert.strictEqual(r.cors, '*', 'any origin may send it');
++ let row = await db.prepare("SELECT section, to_site, pos_bucket, n FROM menu_clicks WHERE day = substring(ov_now(), 1, 10) AND section = 'next' AND to_site = 'community'").get();
++ assert.deepStrictEqual([row.section, row.to_site, row.pos_bucket, Number(row.n)], ['next', 'community', '1-2', 1]);
++ r = await post({ ...good, section: 'not-a-section' }); assert.strictEqual(r.status, 400, 'unknown sections are refused');
++ r = await post({ ...good, to: 'evil.example' }); assert.strictEqual(r.status, 400, 'only an OpenVibe host the Frame knows');
++ r = await post({ ...good, kind: 'wat' }); assert.strictEqual(r.status, 400, 'unknown kinds are refused');
++ r = await post({ ...good, pos: 'second' }); assert.strictEqual(r.status, 400, 'position is an integer');
++ r = await post(JSON.stringify({ ...good, pad: 'x'.repeat(600) })); assert.strictEqual(r.status, 413, 'a body over 512 bytes is refused');
++ r = await post({ host: 'openvibe.live', section: 'places', pos: 0, kind: 'open' }); assert.strictEqual(r.status, 204, 'the future open beacon is accepted');
++ assert.strictEqual(Number((await db.prepare("SELECT n FROM menu_clicks WHERE section = 'places'").get() || {}).n || 0), 0, 'an open beacon is not a click');
++ // Product domains count as the site that serves them.
++ r = await post({ host: 'openvibe.pics', section: 'all', pos: 4, to: 'openvibe.homes', kind: 'site' });
++ assert.strictEqual(r.status, 204);
++ row = await db.prepare("SELECT to_site FROM menu_clicks WHERE section = 'all' AND to_site = 'rent'").get();
++ assert.ok(row, 'openvibe.homes counts for Rent');
++ srv.close();
++
++ // Rate limit: a fresh service gets its own in-memory window. 60 valid beacons are counted, the 61st in a minute
++ // is refused.
++ const limited = await createFrameService(db, { services: {} }, null);
++ const app2 = express(); app2.use('/api/frame', limited.router);
++ const srv2 = await listen(app2);
++ let ok = 0, throttled = 0;
++ for (let i = 0; i < 61; i++) {
++ const rr = await fetch(url(srv2, '/api/frame/menu-click'), { method: 'POST', headers: { 'content-type': 'text/plain' }, body: JSON.stringify({ ...good, pos: i }) });
++ if (rr.status === 204) ok++; else if (rr.status === 429) throttled++;
++ }
++ assert.deepStrictEqual([ok, throttled], [60, 1], '60 a minute per IP, then 429');
++ srv2.close();
++
++ // ── Pulse's live channels ────────────────────────────────────────────────────────────────────────────────────
++ const streams = [];
++ for (let i = 0; i < 9; i++) streams.push({ id: i + 1, username: `star${i}`, display_name: `Star ${i}`, viewer_count: i, total_viewer_count: i + 1, avatar_url: i === 0 ? 'https://openvibe.media/a.png' : null });
++ streams.push({ id: 20, username: 'hidden', total_viewer_count: 99, visibility: 'unlisted' });
++ streams.push({ id: 21, username: 'private', total_viewer_count: 99, is_unlisted: true });
++ const liveList = http.createServer((req, res) => {
++ res.setHeader('content-type', 'application/json');
++ res.end(JSON.stringify(req.url === '/api/streams' ? { streams } : {}));
++ });
++ await new Promise((resolve) => liveList.listen(0, '127.0.0.1', resolve));
++ const pulseSvc = await createFrameService(db, { services: { live: { internalUrl: `http://127.0.0.1:${liveList.address().port}` } } }, null);
++ const app3 = express(); app3.use('/api/frame', pulseSvc.router);
++ const srv3 = await listen(app3);
++ const pulse = await (await fetch(url(srv3, '/api/frame/pulse'))).json();
++ assert.strictEqual(pulse.live_now, 9, 'public streams only (unlisted and private are left out)');
++ assert.strictEqual(pulse.live.length, 8, 'up to eight channels');
++ assert.deepStrictEqual(pulse.live[0], { name: 'Star 0', url: 'https://openvibe.live/@star0', viewers: 1, avatar: 'https://openvibe.media/a.png' });
++ assert.ok(!pulse.live.some((c) => c.url.endsWith('/@hidden') || c.url.endsWith('/@private')), 'never unlisted or private');
++ assert.ok(pulse.live.every((c) => /^https:\/\/openvibe\.live\/@/.test(c.url) && Number.isInteger(c.viewers)), 'every channel has its Live URL and viewer count');
++ srv3.close(); liveList.close();
++
++ assert.equal(siteForHost('openvibe.video').id, 'media-hub');
++ console.log('frame menu: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/frame-ranking.test.js b/test/frame-ranking.test.js
nnew file mode 100644
iindex 00000000..2a744167
---- /dev/null
++++ b/test/frame-ranking.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++// The Frame's navigation order follows real use (11-07): openvibe.video had the most page views in frame_hits, yet
++// Tools, Community and Space sat above Video because BASE_WEIGHT was added after normalising every score by Live's
++// own analytics (69,516 page views), which flattened everyone else. This fixture is the production shape.
++// node test/frame-ranking.test.js
++const assert = require('assert');
++const { getDb } = require('../server/db/database');
++const { createFrameService } = require('../server/frame/service');
++
++(async () => {
++ const db = getDb();
++ const svc = await createFrameService(db, { services: {} }, null, { selfToken: () => 'test-token' });
++ assert.deepStrictEqual(svc.ranking().order.slice(0, 7), ['live', 'tools', 'space', 'community', 'games', 'media', 'network'], 'cold start: the hand-set base order');
++
++ for (let i = 1; i <= 4; i++) await db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)').run(i, `rank${i}`, 'x');
++ const day = "substring(ov_now(), 1, 10)";
++ await db.prepare(`INSERT INTO frame_hits (day, host, hits) VALUES
++ (${day}, 'openvibe.video', 2687), (${day}, 'openvibe.community', 1190), (${day}, 'openvibe.tools', 181),
++ (${day}, 'openvibe.space', 16), (${day}, 'openvibe.games', 58), (${day}, 'openvibe.network', 143)`).run();
++ const ins = db.prepare("INSERT INTO user_history (user_id, service, type, title, url) VALUES (?, ?, 'page', 't', ?)");
++ for (let i = 0; i < 10; i++) await ins.run(i % 3 + 1, 'live', 'https://openvibe.live/');
++ for (let i = 0; i < 5; i++) await ins.run(1, 'community', 'https://openvibe.community/');
++ for (let i = 0; i < 3; i++) await ins.run(2, 'tools', 'https://openvibe.tools/');
++ for (let i = 0; i < 2; i++) await ins.run(3, 'space', 'https://openvibe.space/');
++ // Video's history service is 'media' (the brief's mapping): it must raise media-hub, not the Media API site.
++ for (let i = 0; i < 4; i++) await ins.run(i % 2 + 1, 'media', 'https://openvibe.media/');
++
++ const realFetch = global.fetch;
++ global.fetch = async () => ({ ok: true, json: async () => ({ summary: { total_pageviews: 69516 } }) });
++ await svc.refreshRank();
++ global.fetch = realFetch;
++
++ const order = svc.ranking().order;
++ const at = (id) => order.indexOf(id);
++ assert.strictEqual(order[0], 'live', 'Live, the network\'s heart, is first');
++ assert.ok(at('media-hub') >= 0, 'Video is in the order');
++ assert.ok(at('media-hub') < at('community'), 'Video (2,687 hits) is above Community (1,190)');
++ assert.ok(at('media-hub') < at('tools'), 'and above Tools');
++ assert.ok(at('media-hub') < at('space'), 'and above Space');
++ assert.ok(at('community') < at('tools') && at('community') < at('space'), 'real page views decide the rest, not the base weight');
++ const state = svc._state();
++ assert.strictEqual(Number(state.rank.scores['media-hub']), 2687, 'openvibe.video maps to the media-hub site id');
++ assert.ok(Number(state.rank.scores['media-hub:history']) > 0, "history service 'media' counts for media-hub");
++ assert.strictEqual(Number(state.rank.scores['media:history'] || 0), 0, 'not for the Media API site');
++ console.log('frame ranking: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 44a4c814..27b9b70d 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');