openvibers/openvibe.network Commits

Network: read the home's popular five and Try next from Report (L43) (#739)

OP OpenVibers committed 4 days ago · d2ccae7bf8 · 12 files, +704 −16

* Network: read the home's popular five and Try next from Report (L43)

Adds GET /api/v1/home/popular (public, five-minute cache, fixed starter list only when Report does not answer, source in the data) and GET /api/v1/me/try-next (private, no-store, the person's own history plus Report's opt-in co-usage, excluding what they use). Open stats now publishes per-tool opens and distinct signed-in uses; the home renders a plain popular list and /my feeds Try next from the endpoint. Raises the home HTML budget by 1.2 KB for the list.

* Network: review the Report popularity fetch in the SSRF inventory

The new server/home/popular.js reads the operator-configured first-party Report origin (OV_REPORT_INTERNAL_URL, a fixed loopback URL) for the public popularity list and its aggregate co-usage. No request value reaches it.

Changes

ddiff --git a/public/js/my/today.js b/public/js/my/today.js
iindex 923c8ee9..ec5d126d 100644
---- a/public/js/my/today.js
++++ b/public/js/my/today.js
@@@ -203,8 +203,8 @@ function renderMoney(coins, last) {
box.hidden = false;
}
--/** Your sites: the ones you use most (history counts, then the switcher's pins and recents), and two to try. */
--function renderSites(hist, sites) {
++/** Your sites: the ones you use most (history counts, then the switcher's pins and recents), and what to try next. */
++function renderSites(hist, sites, tryNext) {
const box = document.getElementById('td-sites');
const grid = document.getElementById('td-sites-grid');
const counts = new Map();
@@@ -218,13 +218,26 @@ function renderSites(hist, sites) {
return tdEl('a', 'td-site', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', '', [label])], { href: url });
}));
grid.hidden = mine.length === 0;
-- const tries = TD_TRY.filter(([id]) => !mine.includes(id) && !counts.has(id)).slice(0, 2);
++ // Try next from Report's similar-use answer (server/home/try-next.js): two sites the person does not already use,
++ // each with the reason it is there. A failed read keeps the old fixed list, and a true empty answer shows nothing.
++ const fromApi = tryNext && Array.isArray(tryNext.items) ? tryNext.items.filter((item) => item && tdSafeUrl(item.url) && !mine.includes(item.id) && !counts.has(item.id)).slice(0, 2) : [];
++ const tries = fromApi.length
++ ? fromApi.map((item) => ({ id: item.id, name: item.name, url: item.url, reason: item.reason }))
++ : TD_TRY.filter(([id]) => !mine.includes(id) && !counts.has(id)).slice(0, 2).map(([id, what, why]) => {
++ const [, , url] = TD_SITES[id];
++ return { id, name: what, url, reason: why };
++ });
const tryBox = document.getElementById('td-try');
-- tryBox.replaceChildren(...tries.map(([id, what, why]) => {
-- const [, icon, url] = TD_SITES[id];
-- return tdEl('a', 'td-try', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', 'td-try-t', [tdEl('b', '', [what]), tdEl('small', '', [why])])], { href: url });
-- }));
-- tryBox.hidden = tries.length === 0;
++ const tryRows = tries.map((item) => {
++ const known = TD_SITES[item.id];
++ const icon = known ? known[1] : 'fa-globe';
++ const url = tdSafeUrl(item.url) || (known ? known[2] : null);
++ if (!url) return null;
++ const name = String(item.name || (known && known[0]) || item.id || '');
++ return tdEl('a', 'td-try', [tdEl('span', 'td-site-ic', [tdIcon(icon)]), tdEl('span', 'td-try-t', [tdEl('b', '', [name]), tdEl('small', '', [String(item.reason || '')])])], { href: url });
++ }).filter(Boolean);
++ tryBox.replaceChildren(...tryRows);
++ tryBox.hidden = tryRows.length === 0;
document.getElementById('td-sites-h').textContent = mine.length ? 'Your sites' : 'Start here';
box.hidden = mine.length === 0 && tries.length === 0;
}
@@@ -360,7 +373,7 @@ async function loadToday() {
const quiet = (p) => p.catch(() => null);
renderActions(null);
renderStatus();
-- const [acts, live, hist, coins, last, unread, sites, nodes, frame] = await Promise.all([
++ const [acts, live, hist, coins, last, unread, sites, nodes, frame, tryNext] = await Promise.all([
quiet(apiFetch('/api/v1/me/actions')),
quiet(apiFetch('/api/v1/me/live-follows')),
quiet(apiFetch('/api/history?limit=30')),
@@@ -370,6 +383,7 @@ async function loadToday() {
quiet(apiFetch('/api/themes/me/sites')),
quiet(apiFetch('/api/v1/me/nodes')),
quiet(apiFetch('/api/frame?host=openvibe.network')),
++ quiet(apiFetch('/api/v1/me/try-next?limit=3')),
]);
globalThis.__tdUnread = unread;
renderNeedsYou(acts);
@@@ -378,7 +392,7 @@ async function loadToday() {
renderNow(live, unread);
renderContinue(hist);
renderMoney(coins, last);
-- renderSites(hist, sites);
++ renderSites(hist, sites, tryNext);
renderStuff(live, hist, nodes);
document.getElementById('today').classList.add('td-ready');
}
ddiff --git a/server/home/popular.js b/server/home/popular.js
nnew file mode 100644
iindex 00000000..c9a122f0
---- /dev/null
++++ b/server/home/popular.js
@@@ -0,0 +1,177 @@
++'use strict';
++
++/**
++ * The five things people do now, for the signed-out front door (plan L43, 2026-11-09): OpenVibe.Report ranks the
++ * destinations from its own numbers (opens today and over seven days, growth, and the distinct signed-in people
++ * Network counts). Network never derives the order; it reads Report's public answer, holds it five minutes, and
++ * replaces it with a fixed starter list only when Report does not answer. The answer says which one it is:
++ *
++ * { source: 'report' | 'fallback', reason, generated_at, fetched_at, stale, items: [...] }
++ *
++ * Every item is a destination with the shape both lists use: id, kind (site | tool), name, url, icon id, reason,
++ * and the numbers behind it. The fixed list is markdown-free and hand-written on purpose: it is the floor, not a
++ * second ranking.
++ */
++const FALLBACK = Object.freeze([
++ { id: 'live', kind: 'site', name: 'OpenVibe.Live', url: 'https://openvibe.live/', icon: 'live', reason: 'Go live or watch, from a browser tab' },
++ { id: 'tools', kind: 'site', name: 'OpenVibe.Tools', url: 'https://openvibe.tools/', icon: 'tools', reason: 'Free tools that just open' },
++ { id: 'chat', kind: 'site', name: 'OpenVibe.Chat', url: 'https://openvibe.chat/', icon: 'chat', reason: 'Group chats, servers and DMs' },
++ { id: 'community', kind: 'site', name: 'OpenVibe.Community', url: 'https://openvibe.community/', icon: 'community', reason: 'Pastes and posts behind one link' },
++ { id: 'games', kind: 'site', name: 'OpenVibe.Games', url: 'https://openvibe.games/', icon: 'games', reason: 'Browser games that already know you' },
++]);
++
++const TTL_MS = 5 * 60 * 1000;
++const TIMEOUT_MS = 2000;
++const LIMIT = 5;
++
++const text = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
++const integer = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Math.round(Number(v)));
++const idOf = (v) => (/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(v || '')) ? String(v) : null);
++function httpsUrl(v) {
++ try { const u = new URL(String(v || '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
++}
++
++/** Only the fields both lists promise; an item without a real https address or an icon id is dropped, not guessed. */
++function validItems(input, limit = LIMIT) {
++ const out = [];
++ const seen = new Set();
++ for (const raw of Array.isArray(input) ? input : []) {
++ if (!raw || typeof raw !== 'object') continue;
++ const kind = raw.kind === 'site' || raw.kind === 'tool' ? raw.kind : null;
++ const id = idOf(raw.id);
++ const name = text(raw.name, 80);
++ const reason = text(raw.reason, 80);
++ const url = httpsUrl(raw.url);
++ const icon = idOf(raw.icon) || id;
++ if (!kind || !id || !name || !reason || !url || !icon) continue;
++ const key = `${kind}:${id}`;
++ if (seen.has(key)) continue;
++ seen.add(key);
++ const item = { id, kind, name, url, icon, reason };
++ for (const k of ['score', 'opens_24h', 'opens_7d', 'opens_prev_7d', 'growth_7d_pct', 'uses_24h', 'uses_7d']) {
++ const n = integer(raw[k]);
++ if (n != null) item[k] = n;
++ }
++ out.push(item);
++ if (out.length >= limit) break;
++ }
++ return out;
++}
++
++function validCoUsage(body, minPeople = 3) {
++ if (!body || typeof body !== 'object') return null;
++ const pairs = [];
++ const seen = new Set();
++ for (const raw of Array.isArray(body.pairs) ? body.pairs : []) {
++ if (!raw || typeof raw !== 'object') continue;
++ const a = idOf(raw.a);
++ const b = idOf(raw.b);
++ const people = integer(raw.people);
++ if (!a || !b || a === b || people == null || people < minPeople) continue;
++ const key = a < b ? `${a}|${b}` : `${b}|${a}`;
++ if (seen.has(key)) continue;
++ seen.add(key);
++ pairs.push({ a, b, people });
++ }
++ return {
++ source: 'report',
++ generated_at: body.generated_at || null,
++ sample_people: integer(body.sample_people) || 0,
++ min_people: integer(body.min_people) || minPeople,
++ pairs,
++ };
++}
++
++function createHomePopularity({ reportUrl = '', fetchImpl = globalThis.fetch, now = () => Date.now(), ttlMs = TTL_MS, timeoutMs = TIMEOUT_MS, limit = LIMIT, fallback = FALLBACK } = {}) {
++ const base = String(reportUrl || '').replace(/\/+$/, '');
++ const clock = typeof now === 'function' ? now : () => Date.now();
++ const starter = fallback.slice(0, limit);
++ let state = { at: 0, version: 0, source: 'fallback', reason: 'cold', generated_at: null, items: starter, stale: false, error: null };
++ let inflight = null;
++ let coState = { at: 0, value: null, error: null };
++ let coInflight = null;
++
++ function view() {
++ return {
++ source: state.source,
++ reason: state.reason,
++ generated_at: state.generated_at || (state.at ? new Date(state.at).toISOString() : null),
++ fetched_at: state.at ? new Date(state.at).toISOString() : null,
++ stale: !!state.stale,
++ items: state.items.map((item) => ({ ...item })),
++ ...(state.error ? { error: state.error } : {}),
++ };
++ }
++
++ function fallbackState(reason, error = null) {
++ return {
++ at: clock(), version: state.version + 1, source: 'fallback', reason,
++ generated_at: null, items: starter.map((item) => ({ ...item })), stale: false, error,
++ };
++ }
++
++ async function refresh() {
++ if (inflight) return inflight;
++ inflight = (async () => {
++ if (!base) { state = fallbackState('no_report'); return view(); }
++ try {
++ const r = await fetchImpl(`${base}/api/v1/popular?limit=${limit}`, {
++ headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs),
++ });
++ if (!r.ok) throw new Error(`Report answered ${r.status}`);
++ const body = await r.json();
++ const items = validItems(body && body.items, limit);
++ if (!items.length) { state = fallbackState('report_empty'); return view(); }
++ state = {
++ at: clock(), version: state.version + 1, source: 'report', reason: 'report',
++ generated_at: (body && body.generated_at) || new Date(clock()).toISOString(), items, stale: false, error: null,
++ };
++ } catch (err) {
++ const message = String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 160);
++ // A last good Report answer is still Report's answer: keep it, marked stale, until a new one arrives.
++ if (state.source === 'report' && state.items.length) state = { ...state, at: clock(), stale: true, error: message };
++ else state = fallbackState('report_error', message);
++ }
++ return view();
++ })().finally(() => { inflight = null; });
++ return inflight;
++ }
++
++ function peek() {
++ if (clock() - state.at > ttlMs && !inflight) refresh().catch(() => {}); // floating-ok: refresh catches its own read
++ return view();
++ }
++
++ async function coUsage() {
++ if (coState.value && clock() - coState.at < ttlMs) return coState.value;
++ if (coInflight) return coInflight;
++ if (!base) return null;
++ coInflight = (async () => {
++ try {
++ const r = await fetchImpl(`${base}/api/v1/popular/co-usage`, {
++ headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs),
++ });
++ if (!r.ok) throw new Error(`Report answered ${r.status}`);
++ const value = validCoUsage(await r.json());
++ if (value) coState = { at: clock(), value, error: null };
++ return coState.value;
++ } catch (err) {
++ coState = { ...coState, at: clock(), error: String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 160) };
++ return coState.value; // null when there has never been a good answer: Try next has its own fallback
++ } finally { coInflight = null; }
++ })();
++ return coInflight;
++ }
++
++ return { peek, refresh, coUsage, view, ttlMs, limit, fallback: starter };
++}
++
++// The one instance the home renderer uses; server/index.js configures it once at boot, tests may leave it unset.
++let instance = null;
++function configure(popularity) { instance = popularity || null; }
++function peek() {
++ if (instance) return instance.peek();
++ return { source: 'fallback', reason: 'not_configured', generated_at: null, fetched_at: null, stale: false, items: FALLBACK.slice(0, LIMIT).map((item) => ({ ...item })) };
++}
++
++module.exports = { createHomePopularity, configure, peek, validItems, validCoUsage, FALLBACK, TTL_MS };
ddiff --git a/server/home/render.js b/server/home/render.js
iindex ab5926f2..bb0f07ec 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -13,6 +13,7 @@ const icons = require('openvibe-shared/icons');
const showcase = require('openvibe-shared/showcase');
const toolsCatalog = require('../domains/catalog');
const activity = require('./activity');
++const homePopular = require('./popular');
const boost = require('../boost');
// The look before first paint (openvibe-shared look-boot, plan L42): the shell carries the marker and this
// puts the script there, above the stylesheets, in the page the server renders.
@@@ -197,6 +198,19 @@ function moreParts(parts) {
return `<div class="more"><h2 class="more-pick-h">${sectionIcon('grid')}More about OpenVibe</h2><nav class="more-pick" data-ovk-scroll aria-label="More about OpenVibe">${chips}</nav>\n${shown.map(([, , html]) => html.replace(/^<section class="([^"]*)" id="([^"]*)"/, '<section class="$1" id="$2" data-more-part')).join('\n')}</div>`;
}
++// The five things people do now (plan L43): a plain list, no new styles. The order and the numbers are Report's;
++// when Report does not answer the fixed starter list is used and the section says so in data-source. Opus places and
++// styles the block; this only renders it.
++function popularNow() {
++ const p = homePopular.peek();
++ const items = Array.isArray(p.items) ? p.items.slice(0, 5) : [];
++ if (!items.length) return '';
++ const lede = p.source === 'report'
++ ? "Ranked from OpenVibe.Report's numbers: what people opened today and this week."
++ : 'A fixed starter list while OpenVibe.Report is away.';
++ return `<section class="sc-sec" id="popular" data-source="${esc(p.source)}" aria-labelledby="h-popular"><h2 id="h-popular" class="ovk-titled">${sectionIcon('grid')}Five things people do now</h2><p class="sc-lede">${esc(lede)}</p><ul class="now-list">${items.map((it) => `<li><a href="${esc(it.url)}"><b>${esc(it.name)}</b><small>${esc(it.reason)}</small></a></li>`).join('')}</ul></section>`;
++}
++
// The hero's numbers: only what Network read itself within the last minute (server/home/activity.js); a number it
// could not read is left out rather than guessed.
function numbers(catalog, a) {
@@@ -412,6 +426,7 @@ ${chips(SOON.map((site) => [`https://${site.host}/`, site.tagline, site.icon, si
<div class="faq-grid">${faqPairs().map((p, i) => `<details class="faq-item"${i === 0 ? ' open' : ''}><summary><h3 class="ovk-titled">${sectionIcon('help')}${esc(p.q)}</h3></summary><p>${esc(p.a)}</p></details>`).join('')}</div><p class="home-more"><a href="https://openvibe.help/sites/openvibe.network">More answers about your account on OpenVibe.Help →</a></p></section>`;
return `<style>${CSS}</style>
${rightNow(act)}
++${popularNow()}
${foldSites(sites)}
${moreParts([['tools', 'Tools', tools], ['account', 'Your account', account], ['soon', 'Opening next', soon], ['faq', 'Questions', faq], ['developers', 'Developers', dev]])}`;
}
@@@ -442,8 +457,9 @@ function withSameAs(html) {
function render({ releaseId = '' } = {}) {
const { catalog } = toolsCatalog.peek();
const act = activity.peek();
++ const popular = homePopular.peek();
const stat = fs.statSync(SHELL);
-- const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}:${releaseId}`;
++ const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}:${act.version}:${popular.source}:${popular.generated_at || ''}:${(popular.items || []).map((i) => i.id).join(',')}:${releaseId}`;
if (pageCache.key === key) return pageCache;
// The network as one entity (SEO-31): a WebSite whose sitelinks search is the network's own search service, so a
// search box in a Google result searches OpenVibe rather than the wider web. The description is true of today.
ddiff --git a/server/home/try-next.js b/server/home/try-next.js
nnew file mode 100644
iindex 00000000..1d339539
---- /dev/null
++++ b/server/home/try-next.js
@@@ -0,0 +1,131 @@
++'use strict';
++
++/**
++ * "Try next" for a signed-in person on /my (plan L43, 2026-11-09): the sites people with similar use open, not a
++ * hand-picked list. The person's own top sites come from their own Network history, in their own request; the
++ * similar-use part is Report's aggregate co-usage answer, which only reads people who chose to publish their site
++ * list and never carries a person, a username or a subject. A site the person already uses is never suggested.
++ *
++ * When Report has no co-usage (or too small a sample to be honest), the answer falls back to Report's public popular
++ * sites, still excluding what the person uses; when Report does not answer at all, it is the fixed starter list.
++ * The answer says which it is: { source, basis, generated_at, items: [...] }.
++ */
++const { SITES, fullName } = require('../frame/sites');
++
++const DAY = 86_400_000;
++const OPEN = SITES.filter((site) => site.status === 'open' && site.id !== 'network');
++const BY_SERVICE = new Map();
++for (const site of OPEN) { BY_SERVICE.set(site.service, site); BY_SERVICE.set(site.id, site); }
++// History's host-derived service name for a product whose site has another id.
++for (const alias of [['video', 'media-hub'], ['pastes', 'community'], ['openre', 'stream'], ['my', 'network']]) {
++ const site = OPEN.find((s) => s.id === alias[1]);
++ if (site) BY_SERVICE.set(alias[0], site);
++}
++const siteOf = (service) => BY_SERVICE.get(String(service || '').toLowerCase()) || null;
++
++function itemOf(site, reason, extra = {}) {
++ return {
++ id: site.id, kind: 'site', name: fullName(site), url: `https://${site.host}/`, icon: site.icon,
++ reason, ...extra,
++ };
++}
++
++function createTryNext({ db = null, popularity = null, now = () => Date.now(), limit = 3 } = {}) {
++ const clock = typeof now === 'function' ? now : () => Date.now();
++
++ async function forPerson({ userId, limit: asked = limit } = {}) {
++ const max = Math.min(10, Math.max(1, parseInt(asked, 10) || limit));
++ const at = clock();
++ const generatedAt = new Date(at).toISOString();
++ const since = (days) => new Date(at - days * DAY).toISOString().slice(0, 19).replace('T', ' ');
++
++ let usedSiteIds = new Set(['network']);
++ let topSites = [];
++ if (db && userId != null) {
++ try {
++ const [topRows, usedRows] = await Promise.all([
++ db.prepare(`SELECT service, COUNT(*) AS n FROM user_history
++ WHERE user_id = ? AND service IS NOT NULL AND created_at >= ?
++ GROUP BY service ORDER BY n DESC, service LIMIT 12`).all(userId, since(30)),
++ db.prepare('SELECT DISTINCT service FROM user_history WHERE user_id = ? AND service IS NOT NULL').all(userId),
++ ]);
++ for (const row of Array.isArray(usedRows) ? usedRows : []) {
++ const site = siteOf(row.service);
++ if (site) usedSiteIds.add(site.id);
++ }
++ const seen = new Set();
++ topSites = [];
++ for (const row of Array.isArray(topRows) ? topRows : []) {
++ const site = siteOf(row.service);
++ if (!site || site.id === 'network' || seen.has(site.id)) continue;
++ seen.add(site.id);
++ topSites.push(site);
++ if (topSites.length === 3) break;
++ }
++ } catch { usedSiteIds = new Set(['network']); topSites = []; }
++ }
++
++ // The aggregate co-usage answer: for each pair, the other site is a candidate when one side is one of the
++ // person's top sites and the other is not already theirs. `via` remembers which top site it came through.
++ const via = new Map();
++ if (popularity && topSites.length) {
++ let co = null;
++ try { co = await popularity.coUsage(); } catch { co = null; }
++ const topIds = new Set(topSites.map((site) => site.id));
++ for (const pair of (co && Array.isArray(co.pairs) ? co.pairs : [])) {
++ const aSite = siteOf(pair.a);
++ const bSite = siteOf(pair.b);
++ if (!aSite || !bSite) continue;
++ for (const [from, to] of [[aSite, bSite], [bSite, aSite]]) {
++ if (!topIds.has(from.id) || topIds.has(to.id) || usedSiteIds.has(to.id) || to.id === 'network') continue;
++ const prev = via.get(to.id);
++ if (!prev || pair.people > prev.people) via.set(to.id, { site: to, from, people: pair.people });
++ }
++ }
++ }
++
++ let items = [];
++ let source = 'fallback';
++ let basis = 'starter';
++ if (via.size) {
++ items = [...via.values()]
++ .sort((x, y) => y.people - x.people || x.site.id.localeCompare(y.site.id))
++ .slice(0, max)
++ .map(({ site, from, people }) => itemOf(site, `People who use ${fullName(from)} also use this`, { score: people }));
++ source = 'report';
++ basis = 'co-usage';
++ }
++
++ // No co-usage sample: Report's public popular sites, still without anything the person already uses.
++ if (!items.length && popularity) {
++ let publicView = null;
++ try { publicView = popularity.peek(); } catch { publicView = null; }
++ const pool = (publicView && Array.isArray(publicView.items) ? publicView.items : [])
++ .filter((item) => item && item.kind === 'site' && !usedSiteIds.has(item.id));
++ if (pool.length) {
++ items = pool.slice(0, max).map((item) => ({
++ id: item.id, kind: 'site', name: item.name, url: item.url, icon: item.icon,
++ reason: 'Popular on OpenVibe', ...(item.score != null ? { score: item.score } : {}),
++ }));
++ source = publicView.source === 'report' ? 'report' : 'fallback';
++ basis = publicView.source === 'report' ? 'popular' : 'starter';
++ }
++ }
++
++ // Report never answered: the same fixed floor the signed-out list uses, without what the person already uses.
++ if (!items.length && popularity && Array.isArray(popularity.fallback)) {
++ items = popularity.fallback
++ .filter((item) => item && item.kind === 'site' && !usedSiteIds.has(item.id))
++ .slice(0, max)
++ .map((item) => ({ id: item.id, kind: 'site', name: item.name, url: item.url, icon: item.icon, reason: 'A place to start' }));
++ source = 'fallback';
++ basis = 'starter';
++ }
++
++ return { source, basis, generated_at: generatedAt, items: items.slice(0, max) };
++ }
++
++ return { forPerson };
++}
++
++module.exports = { createTryNext, siteOf, OPEN };
ddiff --git a/server/index.js b/server/index.js
iindex a4a56681..d45c25a7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -730,6 +730,16 @@ const openStats = require('./stats/open').createOpenStats({
db, selfToken, release, statusRows: () => require('./status/routes').rows(ecosystem),
prometheusUrl: config.prometheusUrl || process.env.PROMETHEUS_URL, liveUrl: process.env.OV_LIVE_INTERNAL_URL,
});
++// The front door's five things people do now (plan L43): Report's public popularity answer, held five minutes, and
++// the person's own "Try next" from Report's aggregate co-usage. The fixed starter list is the floor only when Report
++// does not answer, and every answer says which it is.
++const homePopularity = require('./home/popular').createHomePopularity({
++ reportUrl: process.env.OV_REPORT_INTERNAL_URL || (config.nodeEnv === 'test' ? '' : (config.services.report && config.services.report.internalUrl) || 'http://127.0.0.1:5080'),
++ limit: 5,
++});
++require('./home/popular').configure(homePopularity);
++homePopularity.refresh().catch(() => {}); // floating-ok: the first reader gets the floor, the next the numbers
++const tryNext = require('./home/try-next').createTryNext({ db, popularity: homePopularity });
// The Node's own front door on its own subdomain (openvibe.link): the Host header decides. It mounts
// before the agent layer, so the Node's page is not answered by Network's home renderer.
app.use(require('./link/routes').createLinkRoutes({ release: release.release }));
@@@ -740,6 +750,20 @@ require('openvibe-shared/agent-layer').mount(app, {
render: require('./agent-pages').createAgentPages({ openStats, release }).renderPublicPage,
});
app.use(openStats.router);
++// The signed-out front door's list (public, aggregate, five-minute cache) and the signed-in person's own Try next
++// (private, no-store, computed from their own history plus Report's aggregate pairs). Neither names another person.
++app.get('/api/v1/home/popular', (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=300').set('Access-Control-Allow-Origin', '*').json(homePopularity.view());
++});
++app.get('/api/v1/me/try-next', rateLimit({ windowMs: 60_000, max: 60 }), requireAuth, async (req, res) => {
++ if (!req.user || req.user.is_anon) return res.status(403).json({ error: 'Sign in to get Try next' });
++ const limit = Math.min(10, Math.max(1, parseInt(req.query.limit, 10) || 3));
++ try {
++ res.set('Cache-Control', 'private, no-store').json(await tryNext.forPerson({ userId: req.user.id, limit }));
++ } catch (err) {
++ res.status(503).json({ error: 'Try next is unavailable right now' });
++ }
++});
// OpenVibeBot (Sources' crawler, ADR 0007): the page its user agent links to, and the same facts as /bot.json.
app.use(require('./bot/page').createBotPage({ release }));
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
ddiff --git a/server/stats/open.js b/server/stats/open.js
iindex b8cfaeb6..51973030 100644
---- a/server/stats/open.js
++++ b/server/stats/open.js
@@@ -23,6 +23,8 @@ const express = require('express');
const seo = require('openvibe-shared/seo');
const icons = require('openvibe-shared/ov-icons');
const { SITES, siteForHost, ICON_ALIAS } = require('../frame/sites');
++// The Tools catalog gives a tool's own hosts, name and icon: a tool is a destination beside a site.
++const toolsCatalog = () => require('../domains/catalog').peek().catalog;
const esc = seo.esc;
const DAY = 86400000;
@@@ -50,7 +52,7 @@ const exact = (n) => (n == null ? '—' : Math.round(Number(n)).toLocaleString('
const change = (now, before) => (before > 0 && now != null ? Math.round(((now - before) / before) * 100) : null);
function windowOf(q) { const d = parseInt(q, 10); return WINDOWS.includes(d) ? d : 30; }
--function createOpenStats({ db, selfToken = () => null, statusRows = () => [], prometheusUrl, liveUrl, release = null, fetchImpl = globalThis.fetch, now = () => Date.now() }) {
++function createOpenStats({ db, selfToken = () => null, statusRows = () => [], prometheusUrl, liveUrl, release = null, fetchImpl = globalThis.fetch, now = () => Date.now(), toolsOf = toolsCatalog }) {
const prom = String(prometheusUrl || 'http://127.0.0.1:9090').replace(/\/+$/, '');
const live = String(liveUrl || 'http://127.0.0.1:3000').replace(/\/+$/, '');
const cache = new Map(); // days → { at, promise }
@@@ -78,13 +80,41 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
const site = siteForHost(r.host);
if (!site) continue;
let e = bySite.get(site.id);
-- if (!e) { e = { daily: new Map(), total: 0, prev: 0 }; bySite.set(site.id, e); }
++ if (!e) { e = { daily: new Map(), total: 0, prev: 0, prevDaily: new Map() }; bySite.set(site.id, e); }
const n = Number(r.n) || 0;
-- if (window.has(r.day)) { e.total += n; e.daily.set(r.day, (e.daily.get(r.day) || 0) + n); } else e.prev += n;
++ if (window.has(r.day)) { e.total += n; e.daily.set(r.day, (e.daily.get(r.day) || 0) + n); }
++ else { e.prev += n; e.prevDaily.set(r.day, (e.prevDaily.get(r.day) || 0) + n); }
}
return bySite;
}
++ // ── Tool page views: the same anonymous beacon, kept by the tool's own host (yt.openvibe.tools, …) ──
++ async function toolPageviews() {
++ const since = dayOf(now() - 13 * DAY);
++ const rows = await all('SELECT day, host, SUM(hits)::bigint AS n FROM frame_hits WHERE day >= ? GROUP BY day, host', since);
++ if (!rows) return null;
++ const catalog = toolsOf() || { tools: [] };
++ const byHost = new Map();
++ for (const t of Array.isArray(catalog.tools) ? catalog.tools : []) {
++ const hosts = [t.hosts && t.hosts.canonical, t.hosts && t.hosts.short,
++ ...((t.hosts && t.hosts.aliases) || []), ...((t.hosts && t.hosts.mirrors) || [])];
++ for (const h of hosts) if (h) byHost.set(String(h).toLowerCase(), t);
++ }
++ const sevenFrom = dayOf(now() - 6 * DAY);
++ const prevFrom = dayOf(now() - 13 * DAY);
++ const byTool = new Map();
++ for (const r of rows) {
++ const tool = byHost.get(String(r.host).toLowerCase());
++ if (!tool) continue;
++ let e = byTool.get(tool.id);
++ if (!e) { e = { tool, daily: new Map(), prevDaily: new Map() }; byTool.set(tool.id, e); }
++ const n = Number(r.n) || 0;
++ if (r.day >= sevenFrom) e.daily.set(r.day, (e.daily.get(r.day) || 0) + n);
++ else if (r.day >= prevFrom) e.prevDaily.set(r.day, (e.prevDaily.get(r.day) || 0) + n);
++ }
++ return byTool;
++ }
++
// ── OpenVibe.Live counts its own page views (its own navbar and analytics): totals for the windows ──
async function livePageviews(days) {
const token = selfToken('openvibe.live', ['live.analytics.read']);
@@@ -117,6 +147,34 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
daily,
};
}
++ // ── Distinct signed-in people: Network's cross-site history, counts only. Guests are never in it; a person
++ // with history paused is not counted while paused. The same rows give a site's and a tool's "uses".
++ async function useCounts() {
++ const since = (d) => new Date(now() - d * DAY).toISOString().slice(0, 19).replace('T', ' ');
++ const [s24, s7, sPrev, t24, t7, tPrev] = await Promise.all([
++ all('SELECT service, COUNT(DISTINCT user_id) AS u FROM user_history WHERE service IS NOT NULL AND created_at >= ? GROUP BY service', since(1)),
++ all('SELECT service, COUNT(DISTINCT user_id) AS u FROM user_history WHERE service IS NOT NULL AND created_at >= ? GROUP BY service', since(7)),
++ all('SELECT service, COUNT(DISTINCT user_id) AS u FROM user_history WHERE service IS NOT NULL AND created_at >= ? AND created_at < ? GROUP BY service', since(14), since(7)),
++ all("SELECT sub, COUNT(DISTINCT user_id) AS u FROM user_history WHERE service = 'tools' AND sub IS NOT NULL AND created_at >= ? GROUP BY sub", since(1)),
++ all("SELECT sub, COUNT(DISTINCT user_id) AS u FROM user_history WHERE service = 'tools' AND sub IS NOT NULL AND created_at >= ? GROUP BY sub", since(7)),
++ all("SELECT sub, COUNT(DISTINCT user_id) AS u FROM user_history WHERE service = 'tools' AND sub IS NOT NULL AND created_at >= ? AND created_at < ? GROUP BY sub", since(14), since(7)),
++ ]);
++ const sites = new Map();
++ const tools = new Map();
++ const add = (map, key, field, rows) => {
++ for (const r of Array.isArray(rows) ? rows : []) {
++ const id = String(r[key] || '').toLowerCase();
++ if (!id) continue;
++ const e = map.get(id) || {};
++ e[field] = Math.max(0, Number(r.u) || 0);
++ map.set(id, e);
++ }
++ };
++ add(sites, 'service', 'u24', s24); add(sites, 'service', 'u7', s7); add(sites, 'service', 'uPrev', sPrev);
++ add(tools, 'sub', 'u24', t24); add(tools, 'sub', 'u7', t7); add(tools, 'sub', 'uPrev', tPrev);
++ return { sites, tools };
++ }
++
async function coins(days) {
const since = (d) => new Date(now() - d * DAY).toISOString().slice(0, 19).replace('T', ' ');
const earned = await one('SELECT COALESCE(SUM(delta), 0) AS n FROM coin_transactions WHERE delta > 0');
@@@ -174,7 +232,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
}
async function compute(days) {
-- const [pv, livePv, acc, coin, tr, liveN] = await Promise.all([pageviews(days), livePageviews(days), accounts(days), coins(days), traffic(), liveNumbers()]);
++ const [pv, livePv, acc, coin, tr, liveN, tpv, uses] = await Promise.all([pageviews(days), livePageviews(days), accounts(days), coins(days), traffic(), liveNumbers(), toolPageviews(), useCounts()]);
let services = null;
try {
const rows = statusRows().filter((r) => r.exposure && r.exposure.state === 'live');
@@@ -182,21 +240,70 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
if (known.length) services = { up: known.filter((r) => r.status === 'up').length, checked: known.length };
} catch { services = null; }
const window = daysOf(days);
++ const today = dayOf(now());
++ const sevenFrom = dayOf(now() - 6 * DAY);
++ const prevFrom = dayOf(now() - 13 * DAY);
++ const prevSevenTo = dayOf(now() - 7 * DAY);
++ const sumDays = (map, from, to) => {
++ if (!map) return null;
++ let total = 0;
++ let seen = false;
++ for (const [day, n] of map) if (day >= from && day <= to) { total += n; seen = true; }
++ return seen ? total : null;
++ };
++ const useOfSite = (s) => (uses && uses.sites.get(s.service)) || (uses && uses.sites.get(s.id))
++ || (s.id === 'media-hub' && uses && uses.sites.get('video')) || (s.id === 'stream' && uses && uses.sites.get('openre')) || null;
const sites = SITES.filter((s) => s.status === 'open').map((s) => {
const e = pv && pv.get(s.id);
const isLive = s.id === 'live';
const total = isLive ? (livePv ? livePv.total : null) : (e ? e.total : (pv ? 0 : null));
const prev = isLive ? (livePv ? livePv.prev : null) : (e ? e.prev : (pv ? 0 : null));
++ const opens24 = isLive ? null : (e ? (e.daily.get(today) || 0) : (pv ? 0 : null));
++ const opens7 = isLive ? (days === 7 && livePv ? livePv.total : null) : (e ? sumDays(e.daily, sevenFrom, today) : (pv ? 0 : null));
++ const opensPrev7 = isLive ? (days === 7 && livePv ? livePv.prev : null) : (e ? sumDays(e.prevDaily, prevFrom, prevSevenTo) : (pv ? 0 : null));
++ const use = useOfSite(s);
const t = tr && tr.byJob.get(s.service);
return {
id: s.id, name: `OpenVibe.${s.name}`, short: s.name, host: s.host, url: `https://${s.host}/`, tagline: s.tagline || '',
icon: ICON_ALIAS[s.icon] || s.icon,
pageviews: total, pageviews_prev: prev, change_pct: change(total, prev),
++ ...(opens24 != null ? { opens_24h: opens24 } : {}),
++ ...(opens7 != null ? { opens_7d: opens7 } : {}),
++ ...(opensPrev7 != null ? { opens_prev_7d: opensPrev7 } : {}),
++ ...(opens7 != null || opensPrev7 != null ? { growth_7d_pct: change(opens7, opensPrev7) } : {}),
++ ...(use ? { uses_24h: use.u24, uses_7d: use.u7, uses_prev_7d: use.uPrev } : {}),
daily: isLive ? null : window.map((d) => ({ day: d, n: e ? e.daily.get(d) || 0 : 0 })),
...(isLive && livePv ? { unique_visitors: livePv.visitors, signed_in_people: livePv.people } : {}),
traffic_24h: t || null,
};
}).sort((a, b) => (b.pageviews || 0) - (a.pageviews || 0) || a.name.localeCompare(b.name));
++
++ // Tools, one per tool host, in the same shape as a site: opens today and over seven days, the seven before
++ // and the distinct signed-in people. A tool with no beacon row is left out, never shown with a zero.
++ const sevenDays = daysOf(7);
++ const catalog = toolsOf() || { tools: [] };
++ const subOf = (t) => {
++ const host = String((t.hosts && (t.hosts.canonical || t.hosts.short)) || '').split('.')[0].toLowerCase();
++ return host || String(t.id || '').toLowerCase();
++ };
++ const tools = [];
++ for (const [id, e] of tpv || new Map()) {
++ const t = e.tool;
++ const opens24 = e.daily.get(today) || 0;
++ const opens7 = sumDays(e.daily, sevenFrom, today);
++ const opensPrev7 = sumDays(e.prevDaily, prevFrom, prevSevenTo);
++ const use = (uses && uses.tools.get(id)) || (uses && uses.tools.get(subOf(t))) || null;
++ tools.push({
++ id: t.id, name: t.name || t.id, short: t.id, host: (t.hosts && t.hosts.canonical) || null,
++ url: t.url || (t.hosts && t.hosts.canonical ? `https://${t.hosts.canonical}/` : null),
++ icon: t.icon || t.id, tagline: t.tagline || '',
++ opens_24h: opens24, opens_7d: opens7, opens_prev_7d: opensPrev7,
++ change_pct: change(opens7, opensPrev7), growth_7d_pct: change(opens7, opensPrev7),
++ ...(use ? { uses_24h: use.u24, uses_7d: use.u7, uses_prev_7d: use.uPrev } : {}),
++ daily: sevenDays.map((d) => ({ day: d, n: e.daily.get(d) || 0 })),
++ });
++ }
++ tools.sort((a, b) => (b.opens_7d || 0) - (a.opens_7d || 0) || String(a.name).localeCompare(String(b.name)));
// Network-wide page views per day: every navbar site's beacon (Live counts its own; its days are not split out).
const daily = window.map((d) => ({ day: d, n: [...(pv ? pv.values() : [])].reduce((a, e) => a + (e.daily.get(d) || 0), 0) }));
const pvTotal = sites.reduce((a, s) => a + (s.pageviews || 0), 0);
@@@ -213,6 +320,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
accounts: acc, coins: coin, live: liveN,
busiest_pages_24h: tr ? tr.top : null,
sites,
++ ...(tools.length ? { tools } : {}),
};
}
/** The numbers for a window, held TTL_MS; one computation at a time. `refresh` (the boot warm-up, just before the
ddiff --git a/test/home-front-door.test.js b/test/home-front-door.test.js
iindex f93317cd..86163724 100644
---- a/test/home-front-door.test.js
++++ b/test/home-front-door.test.js
@@@ -85,6 +85,9 @@ for (const [id, , verb, , href] of home.INTENTS) {
assert.ok(html.includes(`<b>${verb}</b>`), `intent ${id} says "${verb}"`);
}
assert.ok(/Go live<\/b><small>From a browser tab\. No OBS, no follower minimum\./.test(html), 'browser go-live stays the headline promise');
++// The five most popular list (plan L43) is a plain list; with no Report wired in this process it says it is the floor.
++assert.ok(/<section class="sc-sec" id="popular" data-source="fallback"/.test(html), 'the signed-out popularity list is on the page and names its source');
++assert.ok(html.includes('Five things people do now') && /<ul class="now-list">/.test(html), 'the list is plain, with the existing classes');
assert.ok(/Nobody is live right now/.test(html) && /Go live from your browser/.test(html), 'an honest empty state');
const open = sites.filter((s) => s.status === 'open' && s.id !== 'network');
// The hero's orbit (2026-10-28): the most used sites around the mark, the count of every site in its middle.
ddiff --git a/test/home-popular-route.test.js b/test/home-popular-route.test.js
nnew file mode 100644
iindex 00000000..c40e0562
---- /dev/null
++++ b/test/home-popular-route.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++// The front door's popularity endpoint and the person's Try next, mounted in the real app. Report is a stub on a
++// loopback port: its public answer is passed through, the person's own history says what they already use, and the
++// co-usage pair suggests a site they do not. Signed out, Try next is a 401; the public list is aggregate only.
++// node --import ./test/helpers/pg-preload.mjs test/home-popular-route.test.js
++const assert = require('assert');
++const http = require('http');
++const { bootServer } = require('./helpers/boot-server');
++const { signToken } = require('../server/auth/routes');
++
++(async () => {
++ const popular = {
++ source: 'report', generated_at: '2026-11-09T12:00:00.000Z',
++ items: [
++ { id: 'live', kind: 'site', name: 'OpenVibe.Live', url: 'https://openvibe.live/', icon: 'live', reason: 'Most used today', opens_24h: 120, opens_7d: 900, growth_7d_pct: 12, uses_7d: 30 },
++ { id: 'yt', kind: 'tool', name: 'YouTube downloader', url: 'https://yt.openvibe.tools/', icon: 'yt', reason: 'Rising this week', opens_24h: 30, opens_7d: 200, growth_7d_pct: 40 },
++ ],
++ };
++ const co = { source: 'report', generated_at: '2026-11-09T12:00:00.000Z', sample_people: 8, min_people: 3, pairs: [{ a: 'live', b: 'chat', people: 7 }, { a: 'live', b: 'tools', people: 5 }] };
++ let asked = 0;
++ const report = http.createServer((req, res) => {
++ asked++;
++ res.setHeader('content-type', 'application/json');
++ if (req.url.startsWith('/api/v1/popular/co-usage')) return res.end(JSON.stringify(co));
++ if (req.url.startsWith('/api/v1/popular')) return res.end(JSON.stringify(popular));
++ res.statusCode = 404; return res.end('{}');
++ });
++ await new Promise((r) => report.listen(0, '127.0.0.1', r));
++ const srv = await bootServer({ env: { OV_REPORT_INTERNAL_URL: `http://127.0.0.1:${report.address().port}` } });
++ try {
++ const db = srv.app.locals.db;
++ const config = srv.app.locals.config;
++ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_verified) VALUES (94, 'tries', '[email protected]', 'x', 1)").run();
++ const user = await db.prepare('SELECT * FROM users WHERE id = 94').get();
++ const token = signToken(user, srv.app.locals.privateKey, config);
++ const history = db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, ?, ?, ?)');
++ await history.run(94, 'live', null, 'stream', 'A stream', 'https://openvibe.live/@alice');
++ await history.run(94, 'live', null, 'stream', 'Another stream', 'https://openvibe.live/@bob');
++ await history.run(94, 'tools', 'yt', 'tool', 'YouTube downloader', 'https://yt.openvibe.tools/');
++ await history.run(94, 'games', null, 'game', 'A game', 'https://openvibe.games/');
++
++ // The boot warm-up reads Report before the first reader; give it a moment if this process was slow to listen.
++ let pub;
++ for (let i = 0; i < 30; i++) {
++ pub = await fetch(`${srv.base}/api/v1/home/popular`);
++ const body = await pub.clone().json();
++ if (body.source === 'report') break;
++ await new Promise((r) => setTimeout(r, 50));
++ }
++ const pubBody = await pub.json();
++ assert.strictEqual(pub.status, 200);
++ assert.strictEqual(pub.headers.get('cache-control'), 'public, max-age=300');
++ assert.strictEqual(pub.headers.get('access-control-allow-origin'), '*');
++ assert.strictEqual(pubBody.source, 'report', 'Report answered, so the list is Report\'s');
++ assert.strictEqual(pubBody.items[0].id, 'live');
++ assert.strictEqual(pubBody.items[0].reason, 'Most used today');
++ assert.ok(pubBody.items.every((i) => i.url.startsWith('https://') && i.icon && i.kind), 'every item is a destination');
++
++ const anon = await fetch(`${srv.base}/api/v1/me/try-next`);
++ assert.strictEqual(anon.status, 401, 'Try next is the person\'s own');
++
++ const mine = await fetch(`${srv.base}/api/v1/me/try-next?limit=3`, { headers: { authorization: `Bearer ${token}` } });
++ const body = await mine.json();
++ assert.strictEqual(mine.status, 200);
++ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual(body.source, 'report');
++ assert.strictEqual(body.basis, 'co-usage');
++ assert.deepStrictEqual(body.items.map((i) => i.id), ['chat'], 'a site they do not use, through the sites they do');
++ assert.strictEqual(body.items[0].reason, 'People who use OpenVibe.Live also use this');
++ assert.ok(!JSON.stringify(body).includes('tries'), 'nothing about the person leaves the answer');
++ assert.ok(!body.items.some((i) => ['live', 'tools', 'games'].includes(i.id)), 'nothing they already use');
++ assert.ok(asked >= 2, 'the app read Report (popular and co-usage)');
++ } finally {
++ await srv.stop();
++ report.close();
++ console.log('home-popular-route: the public list and the person\'s Try next answer from Report ok');
++ }
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/home-popular.test.js b/test/home-popular.test.js
nnew file mode 100644
iindex 00000000..2a22671b
---- /dev/null
++++ b/test/home-popular.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++// The front door's popularity read and Try next without the app: the five-minute hold, the fixed floor when Report
++// does not answer, a stale answer kept rather than replaced by the floor, and the Try next fallbacks.
++// node test/home-popular.test.js
++const assert = require('assert');
++const { createHomePopularity, validItems, FALLBACK, TTL_MS } = require('../server/home/popular');
++const { createTryNext } = require('../server/home/try-next');
++
++const REPORT = {
++ generated_at: '2026-11-09T12:00:00.000Z',
++ items: [
++ { id: 'live', kind: 'site', name: 'OpenVibe.Live', url: 'https://openvibe.live/', icon: 'live', reason: 'Most used today', opens_24h: 9, opens_7d: 90, uses_7d: 4 },
++ { id: 'yt', kind: 'tool', name: 'YouTube downloader', url: 'https://yt.openvibe.tools/', icon: 'yt', reason: 'Rising this week', opens_7d: 40 },
++ ],
++};
++
++(async () => {
++ // The Report answer is used as it came; the five-minute hold means the second read does not call Report.
++ let at = Date.parse('2026-11-09T12:00:00Z');
++ let calls = 0;
++ let fail = false;
++ const popular = createHomePopularity({
++ reportUrl: 'http://report.test', now: () => at,
++ fetchImpl: async (url) => {
++ calls++;
++ if (fail) throw new Error('down');
++ if (String(url).endsWith('/api/v1/popular/co-usage')) return { ok: true, json: async () => ({ pairs: [{ a: 'live', b: 'chat', people: 4 }] }) };
++ return { ok: true, json: async () => REPORT };
++ },
++ });
++ await popular.refresh();
++ assert.strictEqual(popular.peek().source, 'report');
++ assert.deepStrictEqual(popular.peek().items.map((i) => i.id), ['live', 'yt']);
++ const before = calls;
++ popular.peek();
++ assert.strictEqual(calls, before, 'a read inside five minutes is the hold');
++ at += TTL_MS;
++ fail = true;
++ await popular.refresh();
++ const stale = popular.peek();
++ assert.strictEqual(stale.source, 'report', 'the last good Report answer is still Report\'s');
++ assert.strictEqual(stale.stale, true);
++ assert.deepStrictEqual(stale.items.map((i) => i.id), ['live', 'yt']);
++
++ // A cold failure is the fixed floor, marked as such.
++ const cold = createHomePopularity({ reportUrl: 'http://report.test', now: () => at, fetchImpl: async () => { throw new Error('down'); } });
++ await cold.refresh();
++ const floor = cold.peek();
++ assert.strictEqual(floor.source, 'fallback');
++ assert.strictEqual(floor.reason, 'report_error');
++ assert.deepStrictEqual(floor.items.map((i) => i.id), FALLBACK.slice(0, 5).map((i) => i.id));
++
++ // An item without a real https URL or any confidence is left out, never guessed.
++ assert.deepStrictEqual(validItems([{ id: 'x', kind: 'site', name: 'X', url: 'javascript:alert(1)', icon: 'x', reason: 'r' }, ...REPORT.items]).map((i) => i.id), ['live', 'yt']);
++
++ const co = { coUsage: async () => ({ pairs: [{ a: 'live', b: 'chat', people: 7 }, { a: 'live', b: 'tools', people: 5 }] }), peek: () => ({ source: 'report', items: [] }), fallback: FALLBACK };
++ const dbWith = (top, used) => ({ prepare: (sql) => ({ all: async () => (sql.includes('GROUP BY service') ? top : used) }) });
++ const tries = createTryNext({ db: dbWith([{ service: 'live', n: 3 }], [{ service: 'live' }, { service: 'tools' }]), popularity: co, now: () => at });
++ let body = await tries.forPerson({ userId: 1, limit: 3 });
++ assert.deepStrictEqual(body.items.map((i) => i.id), ['chat'], 'a co-used site, not one the person already uses');
++ assert.strictEqual(body.basis, 'co-usage');
++ assert.strictEqual(body.source, 'report');
++ assert.match(body.items[0].reason, /OpenVibe\.Live/);
++
++ // Co-usage too thin: Report's public popular sites, still excluding the person's own.
++ const publicPopular = { coUsage: async () => ({ pairs: [] }), peek: () => ({ source: 'report', items: [{ id: 'live', kind: 'site', name: 'Live', url: 'https://openvibe.live/', icon: 'live', reason: 'x' }, { id: 'chat', kind: 'site', name: 'Chat', url: 'https://openvibe.chat/', icon: 'chat', reason: 'x' }] }), fallback: FALLBACK };
++ body = await createTryNext({ db: dbWith([{ service: 'live', n: 3 }], [{ service: 'live' }]), popularity: publicPopular, now: () => at }).forPerson({ userId: 1 });
++ assert.deepStrictEqual(body.items.map((i) => i.id), ['chat']);
++ assert.strictEqual(body.basis, 'popular');
++
++ // Report never answered: the fixed floor, still without what the person uses.
++ body = await createTryNext({ db: dbWith([], [{ service: 'games' }]), popularity: { coUsage: async () => null, peek: () => ({ source: 'fallback', items: FALLBACK }), fallback: FALLBACK }, now: () => at }).forPerson({ userId: 1 });
++ assert.strictEqual(body.source, 'fallback');
++ assert.strictEqual(body.basis, 'starter');
++ assert.ok(!body.items.some((i) => i.id === 'games'), 'a used site is never suggested');
++
++ console.log('home-popular: the hold, the floor, the stale answer and Try next fallbacks pass');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/open-stats-tools.test.js b/test/open-stats-tools.test.js
nnew file mode 100644
iindex 00000000..11e2dae5
---- /dev/null
++++ b/test/open-stats-tools.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++// Open stats, the tool and people parts (plan L43): a tool's own host is a destination with opens today and over
++// seven days, and a site's "uses" is the distinct signed-in people Network's own history counted. The tools catalog
++// is injected, so the test names exactly the hosts it seeds.
++// node --import ./test/helpers/pg-preload.mjs test/open-stats-tools.test.js
++const assert = require('assert');
++const { getDb, initDb } = require('../server/db/database');
++const { createOpenStats } = require('../server/stats/open');
++
++(async () => {
++ const db = await getDb();
++ await initDb();
++ const NOW = Date.parse('2026-11-09T12:00:00Z');
++ const DAY = 86400000;
++ const day = (back) => new Date(NOW - back * DAY).toISOString().slice(0, 10);
++ const at = (back) => new Date(NOW - back * DAY).toISOString().slice(0, 19).replace('T', ' ');
++ const hit = db.prepare('INSERT INTO frame_hits (day, host, hits) VALUES (?, ?, ?)');
++ await hit.run(day(0), 'yt.openvibe.tools', 30);
++ await hit.run(day(3), 'yt.openvibe.tools', 20);
++ await hit.run(day(10), 'yt.openvibe.tools', 10);
++ await hit.run(day(0), 'dns.openvibe.tools', 5);
++ await hit.run(day(0), 'openvibe.tools', 7);
++ await hit.run(day(0), 'openvibe.live', 99); // Live counts its own; the beacon's row is not its number
++ const user = db.prepare('INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)');
++ await user.run(9201, 'ost1', 'x', at(1));
++ await user.run(9202, 'ost2', 'x', at(2));
++ const hist = db.prepare('INSERT INTO user_history (user_id, service, sub, type, title, url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
++ await hist.run(9201, 'tools', 'yt', 'tool', 'YouTube', 'https://yt.openvibe.tools/', at(0));
++ await hist.run(9202, 'tools', 'yt', 'tool', 'YouTube', 'https://yt.openvibe.tools/', at(2));
++ await hist.run(9201, 'live', null, 'stream', 'Live', 'https://openvibe.live/@a', at(1));
++ await hist.run(9202, 'live', null, 'stream', 'Live', 'https://openvibe.live/@b', at(2));
++
++ const catalog = { tools: [
++ { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools/', icon: 'yt', hosts: { canonical: 'yt.openvibe.tools', short: 'yt', aliases: [] } },
++ { id: 'dns', name: 'DNS lookup', url: 'https://dns.openvibe.tools/', icon: 'dns', hosts: { canonical: 'dns.openvibe.tools', short: 'dns', aliases: [] } },
++ ] };
++ const stats = createOpenStats({
++ db, now: () => NOW, toolsOf: () => catalog, selfToken: () => null,
++ fetchImpl: async () => ({ ok: false }), statusRows: () => [],
++ });
++ const d = await stats.collect(7);
++ const tool = (id) => d.tools.find((t) => t.id === id);
++ assert.ok(tool('yt') && tool('dns'), 'the tools with a beacon row are destinations');
++ assert.deepStrictEqual([tool('yt').opens_24h, tool('yt').opens_7d, tool('yt').opens_prev_7d, tool('yt').change_pct], [30, 50, 10, 400]);
++ assert.deepStrictEqual([tool('yt').uses_24h, tool('yt').uses_7d], [1, 2], 'distinct signed-in people, not page views');
++ assert.deepStrictEqual(tool('yt').daily.map((p) => p.n), [0, 0, 0, 20, 0, 0, 30], 'seven days, oldest first');
++ assert.deepStrictEqual([tool('dns').opens_7d, tool('dns').uses_7d], [5, undefined], 'a tool nobody signed in for has no made-up people count');
++ assert.strictEqual(d.tools[0].id, 'yt', 'most opened tool first');
++ const toolsSite = d.sites.find((s) => s.id === 'tools');
++ assert.strictEqual(toolsSite.pageviews, 62, 'the Tools site still counts every tool host');
++ assert.strictEqual(toolsSite.opens_7d, 62, 'its seven-day opens are the same window');
++ const live = d.sites.find((s) => s.id === 'live');
++ assert.deepStrictEqual([live.uses_24h, live.uses_7d], [1, 2], 'a site counts the same distinct people');
++ console.log('open stats tools: tool hosts, opens and distinct people pass');
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex ab4e8466..f31ef6db 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,7 +12,7 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 204.2, // 204.2: measured 204.2 KB with openvibe-contracts 0.373.0 (the Git service in the network list) on openvibe-shared 4.173.0, 2026-11-09; 204: measured 203.5 KB with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
++ htmlRawKB: 205.4, // 205.4: measured 205.4 KB with the front door's five most popular destinations (plan L43, 2026-11-09: Report's ranked list adds about 1.2 KB of plain list markup); 204.2: measured 204.2 KB with openvibe-contracts 0.373.0 (the Git service in the network list) on openvibe-shared 4.173.0, 2026-11-09; 204: measured 203.5 KB with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 198.8 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 197.4 with Email open (contracts 0.330.0, 2026-11-07: the mail site in the network list); 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
htmlBrotliKB: 39, // 39: measured 38.6 KB brotli with the shared page head's kit CSS on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 38.1 with openvibe-contracts 0.348.0 (2026-11-08: Studio joins the menu; Studio and Locker get menu lines); 37.9 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 37.8 with openvibe-shared 4.129.0 (2026-11-07: the one menu (the burger opens the OpenVibe menu)); 36.1 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06); 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
jsRawKB: 410, // 410: measured 409.2 KB raw / 99.4 KB brotli with openvibe-shared 4.173.0 (the wallet's cookie mode), 2026-11-09; 409: measured 408.1 KB raw / 99.0 KB brotli after merging main's Frame feed work (openvibe-shared 4.167.0), 2026-11-09; 406: measured 405.7 KB raw / 98.4 KB brotli with the shared page head's kit script on the home (openvibe-shared 4.163.0 + page head 1369721), 2026-11-09; 381.8: measured 375.8 KB raw / 91.1 KB brotli with openvibe-shared 4.159.0, plus room for 4.160.0–4.161.0 (the navbar's slots loader, the keyboard watcher, the pop motion), 2026-11-09; 373 with openvibe-shared 4.153.0 (2026-11-08: signed in to the network, signed in here, on phones too; a page can turn away a carried card); 372.3 with openvibe-shared 4.152.0 (2026-11-08: the menu marks the page you are on and opens without the keyboard on a phone, floating cards follow you across OpenVibe sites (overlay.js), and the navbar preconnects to the next OpenVibe site on intent); 369.6 with openvibe-shared 4.150.0 (2026-11-08: the bell opens the Dock on Activity, the Dock's phone header is in two rows, and the menu's orb is the close button with an open/close animation); 368.6 with openvibe-shared 4.149.0 (2026-11-08: the menu has tabs at the top; the phone's menu button is the orb of 4.148.0); 366.5 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets; main's 4.117.0 navbar item and the account-safety helper included); about 363.8 with both (2026-11-06: openvibe-shared 4.117.0's featured navbar item, +1.7 KB, and the account-safety helper, +8.5 KB; merged, to be confirmed by CI's measure); 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 71da23e4..2d24de6a 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -113,6 +113,7 @@ const REVIEWED = {
'server/link/egress-settings.js fetch': [2, 'browser-only GET and PUT to the fixed same-origin /api/v1/me/nodes/egress settings route; no caller-chosen host or URL'],
'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
++ 'server/home/popular.js fetch': [2, 'Report public popularity and co-usage: the operator-configured first-party Report URL (OV_REPORT_INTERNAL_URL), a fixed loopback origin, never a request value'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],