openvibers/openvibe.network Commits
Frame menu backend: next moves, Discover order, menu clicks and the ranking fix (#621)
Changes
nnew file mode 100644@@@ -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)++);@@@ -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>@@@ -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>';@@@ -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// ═══════════════════════════════════════════════════════════@@@ -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, islet 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 personconst 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, isfor (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 destinationif (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, isconst 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, islet 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, isawait 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, isres.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 opennnew file mode 100644@@@ -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); });@@@ -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.nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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)
+);
@@ -382,6 +382,7 @@
@@ -382,6 +382,7 @@
<!-- Overview sub-tab -->
<!-- Overview sub-tab -->
<div id="analytics-overview">
<div id="analytics-overview">
<div id="analytics-insights"></div>
<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="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="card" id="analytics-realtime-card" style="display:none">
<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>
<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>
@@ -149,8 +149,32 @@ async function askVibeToImprove() {
@@ -149,8 +149,32 @@ async function askVibeToImprove() {
} catch (e) { toast(e.message, 'error'); }
} 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() {
async function loadAnalyticsOverview() {
loadInsights();
loadInsights();
+ loadMenu();
const period = getAnalyticsPeriod();
const period = getAnalyticsPeriod();
const statsEl = document.getElementById('analytics-stats');
const statsEl = document.getElementById('analytics-stats');
statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
@@ -118,6 +118,31 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
@@ -118,6 +118,31 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config,
res.set('Cache-Control', 'private, no-store').json({ ok: true, insights: out });
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
// Overview — Cross-Service Summary
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
@@ -3,9 +3,13 @@
@@ -3,9 +3,13 @@
// OpenVibe Frame data: what the navbar and footer of every site show.
// 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?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
// → { 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)
// 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
// 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 } } }
@@ -26,6 +30,56 @@ const toolsCatalog = require('../domains/catalog');
@@ -26,6 +30,56 @@ const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
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 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
* 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
@@ -74,6 +128,39 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let rankedAt = savedRank.value ? savedRank.at || null : null;
let rankedAt = savedRank.value ? savedRank.at || null : null;
let copy = (await load('copy') || {}).value || { sites: {} };
let copy = (await load('copy') || {}).value || { sites: {} };
let version = Date.now();
let version = Date.now();
+ 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;
const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
async function getJson(url, headers, timeoutMs = 6000) {
async function getJson(url, headers, timeoutMs = 6000) {
@@ -103,21 +190,114 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -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
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]);
if (perTool.size) tools = [...perTool.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(x => x[0]);
} catch { /* */ }
} 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 {
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();
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 */ }
} catch { /* table appears on first history write */ }
rank = { scores, tools };
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);
const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
function orderedOpen() {
function orderedOpen() {
const open = SITES.filter(s => s.status === 'open');
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() {
function linkPool() {
@@ -159,18 +339,24 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -159,18 +339,24 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const site = siteForHost(hostname) || SITES.find(s => s.id === 'network');
const site = siteForHost(hostname) || SITES.find(s => s.id === 'network');
const open = orderedOpen(); const pool = linkPool();
const open = orderedOpen(); const pool = linkPool();
const ai = copy.sites[site.id];
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
// 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.
// 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 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 {
return {
updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
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`).
// 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 } : {}) })),
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 })),
groups: GROUPS.map(g => ({ id: g.id, name: g.name })),
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
// state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
- 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
@@ -193,15 +379,33 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let pulse = null;
let pulse = null;
let pulseAt = 0;
let pulseAt = 0;
let pulseInflight = null;
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() {
async function readPulse() {
const d = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/api/streams`);
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);
const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);
return {
return {
live_now: streams ? streams.length : null,
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,
sites: orderedOpen().length,
views_7d: views > 0 ? Math.round(views) : null,
views_7d: views > 0 ? Math.round(views) : null,
+ live,
updated: new Date().toISOString(),
updated: new Date().toISOString(),
};
};
}
}
@@ -211,7 +415,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -211,7 +415,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
await pulseInflight;
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.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
// 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
@@ -301,6 +505,62 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.status(204).end();
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 ──────────────────────────────────────────────────────────────
// ── 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
// 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
// legalProfile). On 11-02 ten open sites did not serve them, so their footers' legal links answered 404. Every open
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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); });
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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); });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');