openvibers/openvibe.network Commits
Network: one read says who is on the network (footer strip) (#761)
Changes
@@@ -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",@@@ -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",nnew file mode 100644@@@ -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 };@@@ -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, isreturn 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, isres.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, istry {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, isreturn { 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 };@@@ -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) {@@@ -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);@@@ -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 = {}, fetchItry {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 });nnew file mode 100644@@@ -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); });@@@ -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]);
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2303,9 +2303,9 @@
@@ -2303,9 +2303,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.187.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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 };
@@ -30,6 +30,7 @@ const express = require('express');
@@ -30,6 +30,7 @@ const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { SITES, siteForHost, GROUPS, groupOf, PRODUCTS, ICON_ALIAS, MENU_LINE, MENU_NAME, fullName } = require('./sites');
const { createMenuCards } = require('./menu-cards');
const { createMenuCards } = require('./menu-cards');
+const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
const toolsCatalog = require('../domains/catalog');
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
@@ -86,6 +87,45 @@ function historySiteId(service, url) {
@@ -86,6 +87,45 @@ function historySiteId(service, url) {
return site ? site.id : null;
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
* 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.*).
* 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 }) {
@@ -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;
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
// 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.
// rename was a SQLite-only step and is not repeated here.
@@ -406,25 +446,38 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -406,25 +446,38 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
return true;
return true;
};
};
const viewersOf = (st) => Math.max(0, Number(st.total_viewer_count != null ? st.total_viewer_count : st.viewer_count) || 0);
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 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 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);
const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);
return {
return {
- live_now: streams ? streams.length : null,
- 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,
sites: orderedOpen().length,
views_7d: views > 0 ? Math.round(views) : null,
views_7d: views > 0 ? Math.round(views) : null,
- live,
+ live: live ? live.list : null,
updated: new Date().toISOString(),
updated: new Date().toISOString(),
};
};
}
}
@@ -437,6 +490,123 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -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 }));
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
// 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
// (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
// 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
@@ -739,11 +909,19 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
try {
try {
const ua = String(req.headers['user-agent'] || '');
const ua = String(req.headers['user-agent'] || '');
let host = ''; try { host = new URL(String(req.headers.origin || '')).hostname.toLowerCase(); } catch { /* */ }
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();
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)));
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 */ }
} catch { /* counting must never fail a page */ }
res.status(204).end();
res.status(204).end();
});
});
@@ -845,7 +1023,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -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 { 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 };
@@ -19,6 +19,9 @@ const HIDDEN_SITES = new Set(['bench']);
@@ -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 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) => ({
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,
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 } : {}),
...(m.site.host ? { host: m.site.host } : {}),
})).concat(contracts.services.get('computer') ? [] : [COMPUTER_FALLBACK]);
})).concat(contracts.services.get('computer') ? [] : [COMPUTER_FALLBACK]);
function manifestHost(service) {
function manifestHost(service) {
@@ -972,9 +972,10 @@ app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles
@@ -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).
// 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.
// /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 });
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
@@ -34,6 +34,7 @@
@@ -34,6 +34,7 @@
*/
*/
const { ids, validate } = require('openvibe-contracts');
const { ids, validate } = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
const eventRelay = require('../developer/event-relay');
+const seen = require('../frame/seen');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const HEARTBEAT_ONLINE_MS = 2 * 60 * 1000;
const HEARTBEAT_ONLINE_MS = 2 * 60 * 1000;
@@ -355,6 +356,10 @@ function createPresence({ db, requireAuth, guard, selfToken, config = {}, fetchI
@@ -355,6 +356,10 @@ function createPresence({ db, requireAuth, guard, selfToken, config = {}, fetchI
try {
try {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
const streaming = (await streamingSubjects()).has(req.user.subject_id);
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 }));
return res.json(await heartbeat(db, req.user.subject_id, { idle, nowMs: now(), streaming }));
} catch (err) {
} catch (err) {
if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });
if (err instanceof PresenceError) return res.status(err.status).json({ error: err.code, detail: err.detail });
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -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); });
@@ -106,6 +106,25 @@ const serviceToken = (cap = 'network.presence.read') => {
@@ -106,6 +106,25 @@ const serviceToken = (cap = 'network.presence.read') => {
return true;
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 ──
// ── 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)}`); }
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]);
let r = await get(1, [ANN, BOB, CAT, DAN]);