openvibers/openvibe.network Commits

Network: one read says who is on the network (footer strip) (#761)

OP OpenVibers committed 3 days ago · 6d36829617 · 9 files, +545 −27

* Network: one read says who is on the network

GET /api/frame/online (public, CORS *, cached 30 s at the edge and in the
service) answers the footer strip on every site: how many people were on
the network in the last thirty minutes, members signed in and seen on any
site, guests counted from the page-view beacon, the live channels, a few
counts that open what they count, and the items a site declares in its
manifest.

A member is named with their name, username, picture and the site's name
only when their presence setting lets it; one who hides is counted and
never listed. A guest is a keyed hash of address and user agent with a key
that changes at UTC midnight, held in memory for thirty minutes, and only
ever returned as a number: no address, no hash, no database row. A beacon
that says the browser is signed in (Shared now sends ?in=1) is a member,
not a guest. Bots and monitors fall to the page-view rule.

Strip items come from a site's manifest entry, only for ?site= (its site
or service id), at most three, text only, an https link on a host the
manifests own, a whole number or a fixed line.

* Presence test: the heartbeat fills the online read

The site comes from the request's Origin, a bot heartbeat is left out,
and a heartbeat with no Origin is counted with no site named.

* Pin Contracts 0.390.0: the site manifests declare their strip items

Live, Community and Games now ship their own footer-strip items in the
released manifest, so the read returns them in production too.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 5aadf335..523b4608 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.390.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
@@@ -2303,9 +2303,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.384.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
-- "integrity": "sha512-tPQTkz3os5b2olND5LUICvMOKaH1EOz3usDxRMZCplDlMuvixgZpFbofXtGj1mt2acW3+H/5CBfLCObA1CWe6Q==",
++ "version": "0.390.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.390.0",
++ "integrity": "sha512-Do5oU1PQiuYI6bTDifs3IdRn/JJ43P3FXyXKPTKbTvErVj0ZuliXxzXmab5zQHx3694Welal2UMN2TSVhMLBwQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 116975c3..17f00537 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.390.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
ddiff --git a/server/frame/seen.js b/server/frame/seen.js
nnew file mode 100644
iindex 00000000..312eb45e
---- /dev/null
++++ b/server/frame/seen.js
@@@ -0,0 +1,122 @@
++'use strict';
++/**
++ * Who was on the network in the last thirty minutes, held in memory and nowhere else.
++ *
++ * Two signals fill it, both of them calls that already exist:
++ *
++ * - a signed-in person's presence heartbeat (server/presence/index.js, POST /api/v1/me/presence/heartbeat):
++ * the subject and the site the tab runs on (the request's Origin). The row already says whether the
++ * person lets others see them online; this store keeps only "seen", never a status.
++ * - a page view's anonymous beacon (POST /api/frame/hit, openvibe-shared navbar.js): a guest is one
++ * distinct visitor, counted by a keyed hash of the address and the browser's user agent. The key is
++ * random and changes at UTC midnight, the hash lives only in this process's memory and for thirty
++ * minutes, and neither the address nor the hash is ever written to a database, a log or a response:
++ * the read returns a count. A beacon that says the browser is signed in (`?in=1`) is not a guest, and
++ * it forgets any guest entry the same browser left earlier in the window.
++ *
++ * Bots and monitors are left out by the same user-agent rule the page-view count already uses. Sizes are
++ * capped so a flood cannot grow the process: the oldest entries go first.
++ */
++const crypto = require('crypto');
++
++const WINDOW_MS = 30 * 60 * 1000;
++const MAX_MEMBERS = 20_000;
++const MAX_GUESTS = 50_000;
++const BOT = /bot|crawl|spider|slurp|headless|preview|monitor|curl|wget|python|node|go-http/i;
++
++let day = '';
++let dayKey = null;
++const members = new Map(); // subject id → { at: ms, site: site id | null }
++const guests = new Map(); // keyed hash → last seen ms
++
++/** A new key at UTC midnight: yesterday's hashes cannot be recomputed from today's, so the guest entries
++ * they named go with it (the count starts again rather than mixing two days under one read). */
++function rotate(now) {
++ const d = new Date(now).toISOString().slice(0, 10);
++ if (d !== day) {
++ day = d;
++ dayKey = crypto.randomBytes(32);
++ guests.clear();
++ }
++}
++
++function isBot(ua) {
++ return BOT.test(String(ua || ''));
++}
++
++/** The keyed hash of an address and a user agent. Never stored outside this module's maps. */
++function guestKey(ip, ua, now = Date.now()) {
++ rotate(now);
++ return crypto.createHmac('sha256', dayKey).update(`${String(ip || '')}\n${String(ua || '')}`).digest('base64url');
++}
++
++/** Drop everything older than the window, then the oldest entries while over a cap. */
++function prune(now = Date.now()) {
++ const cut = now - WINDOW_MS;
++ for (const [k, e] of members) if (e.at < cut) members.delete(k);
++ for (const [k, at] of guests) if (at < cut) guests.delete(k);
++ if (members.size > MAX_MEMBERS) {
++ const keep = [...members.entries()].sort((a, b) => b[1].at - a[1].at).slice(0, MAX_MEMBERS);
++ members.clear();
++ for (const [k, e] of keep) members.set(k, e);
++ }
++ if (guests.size > MAX_GUESTS) {
++ const keep = [...guests.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_GUESTS);
++ guests.clear();
++ for (const [k, at] of keep) guests.set(k, at);
++ }
++}
++
++/** A signed-in person was seen on `site` (a Frame site id, or null when the Origin is not one of ours). */
++function seenMember(subject, site = null, now = Date.now()) {
++ const id = String(subject || '');
++ if (!id || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) return false;
++ members.set(id, { at: now, site: site ? String(site) : null });
++ if (members.size > MAX_MEMBERS) prune(now);
++ return true;
++}
++
++/** A guest page view: one entry per distinct address + user agent in the window. */
++function seenGuest(ip, ua, now = Date.now()) {
++ if (isBot(ua)) return false;
++ guests.set(guestKey(ip, ua, now), now);
++ if (guests.size > MAX_GUESTS) prune(now);
++ return true;
++}
++
++/** The Frame site a request's Origin belongs to (the site's tab), or null. */
++function siteOfOrigin(origin) {
++ try {
++ const host = new URL(String(origin || '')).hostname.toLowerCase();
++ const site = require('./sites').siteForHost(host);
++ return site ? site.id : null;
++ } catch { return null; }
++}
++
++/** A signed-in beacon: this browser is not a guest, and any guest entry it left is forgotten. */
++function forgetGuest(ip, ua, now = Date.now()) {
++ guests.delete(guestKey(ip, ua, now));
++}
++
++/** What the read sees: the members seen (with their last site) and how many distinct guests. */
++function snapshot(now = Date.now()) {
++ prune(now);
++ const out = new Map();
++ for (const [subject, e] of members) if (e.at >= now - WINDOW_MS) out.set(subject, { at: e.at, site: e.site });
++ return { members: out, guests: guests.size, at: now };
++}
++
++/** Tests and diagnostics: the sizes only, never a hash or an address. */
++function state() {
++ return { members: members.size, guests: guests.size, day };
++}
++
++/** Tests: start from an empty store. */
++function reset() {
++ members.clear();
++ guests.clear();
++ day = '';
++ dayKey = null;
++}
++
++module.exports = { WINDOW_MS, MAX_MEMBERS, MAX_GUESTS, BOT, isBot, guestKey, seenMember, seenGuest, forgetGuest, siteOfOrigin, snapshot, state, reset };
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 8837fe4d..0779412e 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -30,6 +30,7 @@ const express = require('express');
const crypto = require('crypto');
const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { createMenuCards } = require('./menu-cards');
++const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
@@@ -86,6 +87,45 @@ function historySiteId(service, url) {
return site ? site.id : null;
}
++const ONLINE_TTL_MS = 30_000;
++const SITE_TEXT = (v, max) => String(v == null ? '' : v)
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
++ .replace(/\s+/g, ' ')
++ .trim()
++ .slice(0, max);
++/** An https link on an OpenVibe domain (apex or any subdomain), or null. */
++const openvibeLink = (v) => {
++ const raw = String(v == null ? '' : v).trim();
++ if (!raw || raw.length > 400) return null;
++ let u;
++ try { u = new URL(raw); } catch { return null; }
++ if (u.protocol !== 'https:' || u.username || u.password) return null;
++ const host = u.hostname.toLowerCase();
++ // The host must be one the manifests own (each site's domain, from the same list the Frame serves), or a
++ // page on openvibe.network. A lookalike (openvibe.live.example.com) is not an OpenVibe domain.
++ return siteForHost(host) || host === 'openvibe.network' || host.endsWith('.openvibe.network') ? raw : null;
++};
++const STRIP_ICON = /^[a-z0-9][a-z0-9-]{0,39}$/;
++/** A site's declared strip items: at most three, text only, an https link on an OpenVibe domain, a number or a line. */
++function stripOf(list) {
++ const out = [];
++ for (const raw of Array.isArray(list) ? list.slice(0, 3) : []) {
++ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
++ const label = SITE_TEXT(raw.label, 24);
++ const link = openvibeLink(raw.link);
++ if (!label || !link || !STRIP_ICON.test(String(raw.icon || ''))) continue;
++ const item = { label, icon: String(raw.icon), link };
++ const n = Number(raw.count);
++ const line = SITE_TEXT(raw.line, 40);
++ if (raw.count !== undefined && Number.isInteger(n) && n >= 0) item.value = n;
++ else if (line) item.line = line;
++ else continue; // neither a number nor a line: not a strip item
++ out.push(item);
++ }
++ return out;
++}
++
/**
* Footer copy comes from OpenVibe.AI's network.site_copy workflow (roadmap Wave 13, ADR-015), called with a
* self-signed Network service token (sub svc:network, aud openvibe.ai, cap ai.run.create, ns network.*).
@@@ -120,7 +160,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
};
}
--async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {
++async function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700', today = null, now = Date.now } = {}) {
const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
// frame_cache and frame_hits are migrations/NNNN_*.sql (plan T2). The old chrome_cache/chrome_hits
// rename was a SQLite-only step and is not repeated here.
@@@ -406,25 +446,38 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
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() {
++ // Live's public list, held 30 s and shared by /pulse and /online. Null when Live does not answer.
++ let liveAt = 0;
++ let liveValue = null;
++ async function readLive(nowMs = now()) {
++ if (liveAt && nowMs - liveAt < 30_000) return liveValue;
const d = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/api/streams`);
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;
++ liveValue = streams ? {
++ channels: streams.length,
++ watching: streams.reduce((a, st) => a + viewersOf(st), 0),
++ list: 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;
++ liveAt = nowMs;
++ return liveValue;
++ }
++ async function readPulse() {
++ const live = await readLive();
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 + viewersOf(st), 0) : null,
++ live_now: live ? live.channels : null,
++ watching: live ? live.watching : null,
sites: orderedOpen().length,
views_7d: views > 0 ? Math.round(views) : null,
-- live,
++ live: live ? live.list : null,
updated: new Date().toISOString(),
};
}
@@@ -437,6 +490,123 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
res.send(JSON.stringify(pulse || { live_now: null, watching: null, sites: orderedOpen().length, views_7d: null, live: null, updated: null }));
});
++ // ── GET /api/frame/online — who is on the network, and a few counts (owner, 2026-11-10) ────────────────────────
++ // The footer strip on every site mounts from this one read. Members are signed-in people whose presence
++ // heartbeat arrived on any site in the last thirty minutes; one who hides their status is counted but never
++ // named. Guests are distinct anonymous visitors, counted from the page-view beacon as a keyed hash of the
++ // address and the browser's user agent — held in memory for thirty minutes, never written anywhere, and only
++ // ever returned as a number (server/frame/seen.js). Live is the first three public channels, empty when none.
++ // Counts open the thing they count; a service that does not publish a count contributes no item. A site's own
++ // strip items come from its manifest entry and are returned only for that site (?site=), validated hard.
++ // Public, CORS *, cached 30 s here and at the edge. Nothing personal: a named person carries their name,
++ // their @username, their picture and the site's name, and a guest carries nothing.
++ /** The joiners count and whatever a service already publishes for today; an item with no source is left out. */
++ async function countsOf(nowMs) {
++ const out = [];
++ try {
++ const since = new Date(nowMs - 7 * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
++ const r = await db.prepare("SELECT COUNT(*) AS n FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(deleted_at, '') = '' AND created_at >= ?").get(since);
++ const n = r ? Number(r.n) : NaN;
++ if (Number.isFinite(n) && n >= 0) out.push({ id: 'joined', label: 'Joined this week', value: n, icon: 'members', url: 'https://openvibe.network/stats' });
++ } catch { /* the count is optional */ }
++ // Report's daily activity (the same read /api/v1/frame/today makes): today's videos and posts, when it has them.
++ const t = today ? await Promise.resolve().then(() => today()).catch(() => null) : null;
++ if (t && Number.isSafeInteger(t.new_videos_today) && t.new_videos_today >= 0) out.push({ id: 'videos', label: 'Videos today', value: t.new_videos_today, icon: 'video', url: 'https://openvibe.video/' });
++ if (t && Number.isSafeInteger(t.posts_today) && t.posts_today >= 0) out.push({ id: 'posts', label: 'Threads and pastes today', value: t.posts_today, icon: 'clipboard', url: 'https://openvibe.community/' });
++ return out;
++ }
++ let onlineBase = null;
++ let onlineBaseAt = 0;
++ let onlineInflight = null;
++ async function readOnlineBase(nowMs) {
++ if (onlineBase && nowMs - onlineBaseAt < ONLINE_TTL_MS) return onlineBase;
++ onlineInflight = onlineInflight || (async () => {
++ const snap = seen.snapshot(nowMs);
++ const subjects = [...snap.members.keys()];
++ let rows = [];
++ if (subjects.length) {
++ try {
++ rows = await db.prepare(`SELECT u.username, u.display_name, u.subject_id, p.show_status, p.state
++ FROM person_presence p JOIN users u ON u.subject_id = p.subject_id
++ WHERE p.subject_id = ANY(?) AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.deleted_at, '') = ''`).all(subjects);
++ } catch { rows = []; }
++ }
++ const bySubject = new Map(rows.map((r) => [r.subject_id, r]));
++ const visible = [];
++ for (const [subject, entry] of snap.members) {
++ const r = bySubject.get(subject);
++ if (!r) continue;
++ // The person's own presence choice decides whether they may be named: the "Show my status"
++ // switch off, or Invisible, means counted among members and never in the list.
++ if (!Number(r.show_status) || r.state === 'invisible') continue;
++ const username = String(r.username || '').slice(0, 40);
++ if (!username) continue;
++ const name = SITE_TEXT(r.display_name || username, 60) || username;
++ const at = entry.site ? SITES.find((s) => s.id === entry.site) : null;
++ visible.push({ name, username, avatar: `https://openvibe.network/avatar/${encodeURIComponent(username)}?s=64`, site: at ? at.name : null, at: entry.at });
++ }
++ visible.sort((a, b) => b.at - a.at);
++ const live = await readLive(nowMs);
++ const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);
++ onlineBase = {
++ members: snap.members.size,
++ guests: snap.guests,
++ total: snap.members.size + snap.guests,
++ visible,
++ counts: await countsOf(nowMs),
++ live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },
++ sites: orderedOpen().length,
++ views_7d: views > 0 ? Math.round(views) : null,
++ updated: new Date(nowMs).toISOString(),
++ };
++ onlineBaseAt = nowMs;
++ return onlineBase;
++ })().finally(() => { onlineInflight = null; });
++ return await onlineInflight;
++ }
++ /** The site an asking footer means: its site id (the icon name) or its service id (openre is the site `stream`). */
++ const siteAsked = (asked) => {
++ const id = /^[a-z0-9][a-z0-9-]{0,39}$/.test(String(asked || '').toLowerCase()) ? String(asked).toLowerCase() : '';
++ return id ? SITES.find((s) => s.id === id || s.service === id) || null : null;
++ };
++ async function onlineFor(asked, nowMs = now()) {
++ const base = await readOnlineBase(nowMs);
++ const site = siteAsked(asked);
++ return {
++ updated: base.updated,
++ members: base.members,
++ guests: base.guests,
++ total: base.total,
++ people: base.visible.slice(0, 60).map(({ name, username, avatar, site: at }) => ({ name, username, avatar, site: at })),
++ more: Math.max(0, base.visible.length - 60),
++ live: base.live,
++ counts: base.counts,
++ items: site ? stripOf(site.strip) : [],
++ sites: base.sites,
++ views_7d: base.views_7d,
++ };
++ }
++ const onlineCache = new Map(); // site id ('' for none) → { at, body }
++ router.get('/online', async (req, res) => {
++ // One answer per site, held 30 s: the key is the site's own id, so ?site=openre and ?site=stream share one.
++ const site = (siteAsked(req.query.site) || {}).id || '';
++ const nowMs = now();
++ let e = onlineCache.get(site);
++ if (!e || nowMs - e.at >= ONLINE_TTL_MS) {
++ try {
++ e = { at: nowMs, body: JSON.stringify(await onlineFor(site, nowMs)) };
++ } catch {
++ e = null;
++ }
++ if (e) {
++ if (onlineCache.size >= 200) onlineCache.clear();
++ onlineCache.set(site, e);
++ }
++ }
++ 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(e ? e.body : JSON.stringify({ updated: null, members: null, guests: null, total: null, people: [], more: 0, live: null, counts: [], items: [], sites: orderedOpen().length, views_7d: null }));
++ });
++
// GET /api/frame/feed?kind=&site=&q=&following=1&cursor=&limit= and GET /api/frame/wall?limit=&after=&for= — the menu's Feed and wall
// (openvibe-shared ov-home.js) on every site, asked through Network because every site's connect-src lets the navbar
// reach openvibe.network, and the sites' policies refused openvibe.community (11-09: the Feed fell back to its old
@@@ -739,11 +909,19 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
try {
const ua = String(req.headers['user-agent'] || '');
let host = ''; try { host = new URL(String(req.headers.origin || '')).hostname.toLowerCase(); } catch { /* */ }
-- const now = Date.now(); const k = req.ip || ''; const w = recent.get(k);
-- if (!w || now - w[1] > 60_000) recent.set(k, [1, now]); else w[0]++;
++ const at = now(); const k = req.ip || ''; const w = recent.get(k);
++ if (!w || at - w[1] > 60_000) recent.set(k, [1, at]); else w[0]++;
if (recent.size > 5000) recent.clear();
const known = host && (siteForHost(host) || toolsCatalog.peek().catalog.tools.some(t => t.hosts && (t.hosts.canonical === host || t.hosts.short === host)));
-- if (known && (recent.get(k) || [0])[0] <= 40 && !/bot|crawl|spider|slurp|headless|preview|monitor|curl|wget|python|node|go-http/i.test(ua)) await bump.run(host);
++ if (known && (recent.get(k) || [0])[0] <= 40 && !seen.isBot(ua)) {
++ // The strip's guest count comes from this same beacon, never from a new call: a distinct address +
++ // user agent in the last thirty minutes is one guest (in memory only). `?in=1` marks a signed-in
++ // browser, which is counted as a member by its heartbeat instead, and it forgets any guest entry it
++ // left earlier in the window.
++ if (/^(1|true)$/.test(String(req.query.in || ''))) seen.forgetGuest(req.ip, ua, now());
++ else seen.seenGuest(req.ip, ua, now());
++ await bump.run(host);
++ }
} catch { /* counting must never fail a page */ }
res.status(204).end();
});
@@@ -845,7 +1023,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
}
-- return { router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, _state: () => ({ rank, copy, cards: menuCards._state() }) };
++ return { router, start, stop, refreshRank, refreshCopy, probeLegal, refreshCards: menuCards.refresh, cards: menuCards.cards, payloadFor, ranking, online: onlineFor, _state: () => ({ rank, copy, cards: menuCards._state() }) };
}
--module.exports = { createFrameService, BANNED };
++module.exports = { createFrameService, BANNED, stripOf, openvibeLink };
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
iindex 2f6f70d5..2446d8f1 100644
---- a/server/frame/sites.js
++++ b/server/frame/sites.js
@@@ -19,6 +19,9 @@ const HIDDEN_SITES = new Set(['bench']);
const COMPUTER_FALLBACK = { id: 'computer', name: 'Computer', icon: 'computer', service: 'computer', profile: 'hosting', tagline: 'A computer for you, your AI, or both', what: 'Start and manage computer sessions over OpenVibe.Run, with your own Link machines and cloud computers in one place.', host: 'openvibe.computer' };
const RAW_SITES = contracts.services.manifests.filter((m) => m.site && !HIDDEN_SITES.has(m.id)).sort((a, b) => (a.site.position ?? 999) - (b.site.position ?? 999)).map((m) => ({
id: m.site.icon, name: m.site.name, icon: m.site.icon, service: m.id, profile: m.site.legalProfile, tagline: m.site.tagline, what: m.site.what,
++ // A site's own footer-strip items (label, icon, link, and a number or a line), at most three. They are
++ // validated again at the read (server/frame/service.js stripOf): text only, an https link on an OpenVibe domain.
++ ...(Array.isArray(m.site.strip) ? { strip: m.site.strip } : {}),
...(m.site.host ? { host: m.site.host } : {}),
})).concat(contracts.services.get('computer') ? [] : [COMPUTER_FALLBACK]);
function manifestHost(service) {
ddiff --git a/server/index.js b/server/index.js
iindex d45c25a7..cb84c154 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -972,9 +972,10 @@ app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
--const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });
--app.use('/api/v1/frame/today', rateLimit({ windowMs: 60_000, max: 240 }),
-- require('./frame/today').createToday({ reportUrl: config.services.report.internalUrl }).router);
++// The menu's day counts (Report's daily activity): the strip's "today" counts reuse the same cached read.
++const frameToday = require('./frame/today').createToday({ reportUrl: config.services.report.internalUrl });
++const frameService = await require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken, today: () => frameToday.get() });
++app.use('/api/v1/frame/today', rateLimit({ windowMs: 60_000, max: 240 }), frameToday.router);
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
ddiff --git a/server/presence/index.js b/server/presence/index.js
iindex 9e67cb53..85494d5e 100644
---- a/server/presence/index.js
++++ b/server/presence/index.js
@@@ -34,6 +34,7 @@
*/
const { ids, validate } = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
++const seen = require('../frame/seen');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const HEARTBEAT_ONLINE_MS = 2 * 60 * 1000;
@@@ -355,6 +356,10 @@ function createPresence({ db, requireAuth, guard, selfToken, config = {}, fetchI
try {
res.set('Cache-Control', 'no-store');
const streaming = (await streamingSubjects()).has(req.user.subject_id);
++ // The network's "who is online" strip sees the same heartbeat: the person and the site their tab runs on
++ // (the request's Origin). This record lives in memory for thirty minutes; nothing about it is stored.
++ // Bots and monitors are left out by the page-view rule.
++ if (!seen.isBot(req.headers['user-agent'])) seen.seenMember(req.user.subject_id, seen.siteOfOrigin(req.headers.origin), now());
return res.json(await heartbeat(db, req.user.subject_id, { idle, nowMs: now(), streaming }));
} catch (err) {
if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });
ddiff --git a/test/frame-online.test.js b/test/frame-online.test.js
nnew file mode 100644
iindex 00000000..c0849975
---- /dev/null
++++ b/test/frame-online.test.js
@@@ -0,0 +1,190 @@
++'use strict';
++// The footer strip's one read (owner, 2026-11-10): GET /api/frame/online answers who was on the network in the
++// last thirty minutes and a few counts, and nothing that could identify a guest.
++// - members are signed-in people whose presence heartbeat arrived on any site; a member who hides their
++// status (the switch off, or Invisible) is counted among members and never named;
++// - guests are distinct address + user agent pairs from the page-view beacon, counted in memory only;
++// - a member and a guest are numbers only when the service holds no identifying field, and the answer
++// contains no address, no hash, and no way to tell one guest from another;
++// - Live is the first three public channels, empty when none; a site's own items come only for ?site=.
++// node test/frame-online.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const seen = require('../server/frame/seen');
++const { createFrameService, stripOf } = require('../server/frame/service');
++const { SITES } = require('../server/frame/sites');
++
++const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
++const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
++const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
++const MIN = 60_000;
++
++(async () => {
++const log = console.log; console.log = () => {};
++const db = getDb();
++console.log = log;
++await require('../server/identity/principals').ensureSchema(db);
++const ago = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
++let NOW = Date.parse('2026-11-10T10:00:00Z');
++await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role, created_at) VALUES
++ (1, 'ann', 'Ann', 'x', ?, 'user', ?), (2, 'bob', 'Bob', 'x', ?, 'streamer', ?),
++ (3, 'cat', 'Cat', 'x', ?, 'user', ?), (4, 'dan', 'Dan', 'x', ?, 'user', ?)`)
++ .run(ANN, ago(NOW - 2 * 86_400_000), BOB, ago(NOW - 3 * 86_400_000), CAT, ago(NOW - 4 * 86_400_000), DAN, ago(NOW - 40 * 86_400_000));
++// Ann shows her status; Bob turned it off; Cat is invisible. Dan has never beaten (no presence row).
++const ins = db.prepare(`INSERT INTO person_presence (subject_id, state, show_status, last_active_at, last_heartbeat_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`);
++await ins.run(ANN, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));
++await ins.run(BOB, 'auto', 0, ago(NOW), ago(NOW), ago(NOW));
++await ins.run(CAT, 'invisible', 1, ago(NOW), ago(NOW), ago(NOW));
++
++// A Live edge whose list the test controls; Live is the only service /online reads over the network.
++let liveRows = [{ username: 'eve', display_name: 'Eve', is_live: 1, total_viewer_count: 7 },
++ { username: 'fay', display_name: 'Fay', is_live: 1, viewer_count: 3 },
++ { username: 'gus', display_name: 'Gus', is_live: 1, visibility: 'unlisted' },
++ { username: 'hal', display_name: 'Hal', is_live: 1, total_viewer_count: 12 }];
++const edge = express();
++edge.get('/api/streams', (_req, res) => res.json({ streams: liveRows }));
++const edgeServer = http.createServer(edge);
++await new Promise((r) => edgeServer.listen(0, '127.0.0.1', r));
++const liveUrl = `http://127.0.0.1:${edgeServer.address().port}`;
++
++const svc = await createFrameService(db, { services: { live: { internalUrl: liveUrl }, report: { internalUrl: 'http://127.0.0.1:1' } } }, null, {
++ today: () => ({ new_videos_today: 4, posts_today: 3 }), now: () => NOW,
++});
++const app = express();
++app.set('trust proxy', true);
++app.use('/api/frame', svc.router);
++const server = http.createServer(app);
++await new Promise((r) => server.listen(0, '127.0.0.1', r));
++const base = `http://127.0.0.1:${server.address().port}`;
++const get = (path, headers = {}) => new Promise((resolve, reject) => {
++ const req = http.request(`${base}${path}`, { headers }, (res) => {
++ let body = '';
++ res.on('data', (c) => { body += c; });
++ res.on('end', () => { let json = null; try { json = JSON.parse(body); } catch { /* */ } resolve({ status: res.statusCode, headers: res.headers, body, json }); });
++ });
++ req.on('error', reject); req.end();
++});
++const beacon = (ip, ua, query = '') => new Promise((resolve, reject) => {
++ const req = http.request(`${base}/api/frame/hit${query}`, {
++ method: 'POST', headers: { Origin: 'https://openvibe.live', 'User-Agent': ua, 'X-Forwarded-For': ip },
++ }, (res) => { res.resume(); res.on('end', resolve); });
++ req.on('error', reject); req.end();
++});
++
++seen.reset();
++seen.seenMember(ANN, 'live', NOW - MIN);
++seen.seenMember(BOB, 'games', NOW - 2 * MIN);
++seen.seenMember(CAT, 'live', NOW - 3 * MIN);
++seen.seenMember(DAN, 'community', NOW - 4 * MIN); // seen, but no presence row: counted, not named
++
++let o = await svc.online('live');
++assert.strictEqual(o.members, 4, 'four signed-in people were seen, including the two who hide');
++assert.strictEqual(o.guests, 0);
++assert.deepStrictEqual(o.people.map((p) => p.username), ['ann'], 'only Ann may be named');
++assert.deepStrictEqual(o.people[0], { name: 'Ann', username: 'ann', avatar: 'https://openvibe.network/avatar/ann?s=64', site: 'Live' });
++assert.ok(!JSON.stringify(o).includes('bob') && !JSON.stringify(o).includes('cat'), 'a hidden member is never named');
++assert.strictEqual(o.more, 0);
++assert.deepStrictEqual(o.live, { channels: 3, watching: 22, first: [{ name: 'Eve', url: 'https://openvibe.live/@eve' }, { name: 'Fay', url: 'https://openvibe.live/@fay' }, { name: 'Hal', url: 'https://openvibe.live/@hal' }] },
++ 'an unlisted stream is left out; the first three public channels, by Live\'s own order');
++const joined = o.counts.find((c) => c.id === 'joined');
++assert.ok(joined && joined.value === 3 && joined.icon === 'members' && joined.url === 'https://openvibe.network/stats', 'people who joined this week, from Network\'s own accounts');
++assert.deepStrictEqual(o.counts.filter((c) => c.id !== 'joined').map((c) => [c.id, c.value]), [['videos', 4], ['posts', 3]], 'today\'s counts come from the service that publishes them');
++
++// Guests: the same address and user agent twice is one visitor; another browser is another; bots are not counted.
++NOW += 31_000;
++await beacon('203.0.113.9', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130');
++await beacon('203.0.113.9', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130');
++await beacon('203.0.113.10', 'Mozilla/5.0 (Macintosh) Safari/605');
++await beacon('203.0.113.11', 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)');
++await beacon('203.0.113.12', 'curl/8.5.0');
++o = await svc.online('live');
++assert.strictEqual(o.guests, 2, 'two distinct visitors; the bot and the monitor are left out');
++assert.strictEqual(o.total, 6, 'members plus guests');
++assert.ok(!JSON.stringify(o).includes('203.0.113') && !JSON.stringify(o).includes('Chrome') && !/"(ip|hash|guest_id|address)"/.test(JSON.stringify(o)),
++ 'no address, no hash and no field that could identify a guest');
++assert.deepStrictEqual(Object.keys(o).sort(), ['counts', 'guests', 'items', 'live', 'members', 'more', 'people', 'sites', 'total', 'updated', 'views_7d'], 'the read\'s whole shape');
++
++// A signed-in beacon is a member, not a guest, and it clears the guest entry the same browser left.
++NOW += 31_000;
++await beacon('203.0.113.10', 'Mozilla/5.0 (Macintosh) Safari/605', '?in=1');
++o = await svc.online('live');
++assert.strictEqual(o.guests, 1, 'the signed-in browser is not a guest any more');
++
++// The window: after thirty minutes a guest is gone, and so is a member.
++NOW += 31 * MIN;
++o = await svc.online('live');
++assert.strictEqual(o.guests, 0, 'guests last thirty minutes');
++assert.strictEqual(o.members, 0, 'members last thirty minutes');
++assert.deepStrictEqual(o.people, []);
++assert.strictEqual(o.total, 0);
++
++// A new UTC day is a new key: yesterday's guests are not carried into it.
++NOW = Date.parse('2026-11-11T00:01:00Z');
++await beacon('203.0.113.20', 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130');
++o = await svc.online('live');
++assert.strictEqual(o.guests, 1, 'each UTC day starts its own guest count');
++
++// Live empty: no channels, no names, and the strip shows nothing for it.
++liveRows = [];
++NOW += 31_000;
++o = await svc.online('live');
++assert.deepStrictEqual(o.live, { channels: 0, watching: 0, first: [] }, 'empty when nobody is live');
++
++// Site items: declared on a site's manifest entry, returned only for that site, and validated hard.
++// The released Contracts manifest carries Live's own item first.
++const shipped = await svc.online('live');
++assert.deepStrictEqual(shipped.items, [{ label: 'The Rundown', icon: 'news', link: 'https://openvibe.live/rundown', line: 'What streamers talked about' }], 'the released manifest declares it');
++const live = SITES.find((s) => s.id === 'live');
++live.strip = [
++ { label: 'The Rundown', icon: 'news', link: 'https://openvibe.live/rundown', line: 'Today\'s topics' },
++ { label: 'Clips', icon: 'clapper', link: 'https://openvibe.live/clips?sort=new', count: 1284 },
++ { label: 'Network page', icon: 'chart', link: 'https://openvibe.network/stats', count: 9 },
++ { label: 'Bad', icon: 'news', link: 'http://openvibe.live/unsafe', count: 1 },
++ { label: 'Off network', icon: 'news', link: 'https://evil.example/', count: 1 },
++ { label: 'Lookalike', icon: 'news', link: 'https://openvibe.live.example.com/x', count: 1 },
++ { label: 'Spoof', icon: 'news', link: 'https://[email protected]/x', count: 1 },
++ { label: 'No number', icon: 'news', link: 'https://openvibe.live/x' },
++];
++NOW += 31_000;
++o = await svc.online('live');
++assert.deepStrictEqual(o.items.map((i) => i.label), ['The Rundown', 'Clips', 'Network page'], 'at most the valid ones, and never a fourth');
++assert.deepStrictEqual(o.items[0], { label: 'The Rundown', icon: 'news', link: 'https://openvibe.live/rundown', line: 'Today\'s topics' });
++assert.deepStrictEqual(o.items[1], { label: 'Clips', icon: 'clapper', link: 'https://openvibe.live/clips?sort=new', value: 1284 });
++assert.deepStrictEqual(o.items[2], { label: 'Network page', icon: 'chart', link: 'https://openvibe.network/stats', value: 9 }, 'openvibe.network itself is an OpenVibe domain');
++assert.deepStrictEqual((await svc.online('games')).items.map((i) => i.label), ['Play now'], 'another site gets its own items, never these');
++assert.deepStrictEqual(stripOf([{ label: 'a', icon: 'news', link: 'https://openvibe.live.evil.example/x', count: 1 }]), [], 'a lookalike host is not an OpenVibe domain');
++assert.deepStrictEqual((await svc.online('')).items, [], 'no site asked, no items');
++const stream = SITES.find((s) => s.id === 'stream');
++assert.ok(stream, 'OpenRestream is a site whose service id is openre');
++stream.strip = [{ label: 'Restream', icon: 'live', link: 'https://openre.stream/', line: 'One broadcast, every platform' }];
++assert.deepStrictEqual((await svc.online('openre')).items.map((i) => i.label), ['Restream'], 'a footer may ask by its service id');
++delete stream.strip;
++delete live.strip;
++assert.deepStrictEqual(stripOf([{ label: '<b>Hi</b>', icon: 'news', link: 'https://openvibe.live/', line: 'x'.repeat(200) }]),
++ [{ label: 'Hi', icon: 'news', link: 'https://openvibe.live/', line: 'x'.repeat(40) }], 'markup gone, text capped');
++assert.deepStrictEqual(stripOf([{ label: 'a', icon: 'news', link: 'https://openvibe.live/', count: -1, line: '' }]), [], 'a negative number is not a count');
++assert.deepStrictEqual(stripOf([{ label: 'a', icon: 'news', link: 'https://openvibe.live/', count: 1.5 }]), [], 'a count is a whole number');
++
++// The route itself: public, CORS *, cached 30 s here and at the edge.
++NOW += 31_000;
++const r = await get('/api/frame/online?site=live');
++assert.strictEqual(r.status, 200);
++assert.strictEqual(r.headers['access-control-allow-origin'], '*');
++assert.strictEqual(r.headers['cache-control'], 'public, max-age=30, stale-while-revalidate=120');
++assert.strictEqual(r.json.members, 0);
++assert.strictEqual(r.json.sites > 0, true);
++assert.strictEqual(r.status === 200 && typeof r.json.updated === 'string', true);
++const r2 = await get('/api/frame/online?site=live');
++assert.strictEqual(r2.json.updated, r.json.updated, 'held 30 s in the service');
++NOW += 31_000;
++const r3 = await get('/api/frame/online?site=%3Cscript%3E');
++assert.strictEqual(r3.status, 200);
++assert.deepStrictEqual(r3.json.items, [], 'a junk site id is no site');
++
++await new Promise((r) => server.close(r));
++await new Promise((r) => edgeServer.close(r));
++console.log('frame-online: members and guests in the last thirty minutes, hidden members counted and never named, no address or hash in the answer, live and counts and a site\'s own items');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/presence.test.js b/test/presence.test.js
iindex d71a8b91..15f4e4ff 100644
---- a/test/presence.test.js
++++ b/test/presence.test.js
@@@ -106,6 +106,25 @@ const serviceToken = (cap = 'network.presence.read') => {
return true;
};
++ // ── The network's "who is online" strip is filled from this same heartbeat ──
++ // The person and the site their tab runs on (the request's Origin); a bot is left out. Kept in memory only.
++ {
++ const seenStore = require('../server/frame/seen');
++ seenStore.reset();
++ const beatWith = (headers) => call('POST', '/api/v1/me/presence/heartbeat', { body: {}, headers });
++ const UA = 'Mozilla/5.0 (X11; Linux x86_64) Chrome/130';
++ const beatAnn = await beatWith({ ...(await as(1)), origin: 'https://openvibe.live', 'user-agent': UA });
++ assert.strictEqual(beatAnn.status, 200);
++ const first = seenStore.snapshot(NOW);
++ assert.strictEqual(first.members.get(ANN).site, 'live', 'the site comes from the Origin');
++ await beatWith({ ...(await as(2)), origin: 'https://openvibe.games', 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' });
++ assert.ok(!seenStore.snapshot(NOW).members.has(BOB), 'a bot heartbeat is not a person online');
++ const noOrigin = await beatWith({ ...(await as(3)), 'user-agent': UA });
++ assert.strictEqual(noOrigin.status, 200);
++ assert.strictEqual(seenStore.snapshot(NOW).members.get(CAT).site, null, 'no Origin: counted, with no site named');
++ seenStore.reset();
++ }
++
// ── The people who may see: self, a follow, a DM peer; a stranger never ──
for (const id of [1, 2, 3, 4]) { const hb = await heartbeat(id); assert.strictEqual(hb.status, 200, `heartbeat ${id}: ${JSON.stringify(hb.body)}`); }
let r = await get(1, [ANN, BOB, CAT, DAN]);