openvibers/openvibe.network Commits

The OpenVibe Frame: server/chrome is server/frame; /api/frame and /api/frame/hit (the /api/chrome names stay as aliases for openvibe-shared older than 1.11.0); chrome_cache/chrome_hits are renamed frame_cache/frame_hits once on boot, rows kept (tested); openvibe-shared 1.11.0 (openvibe-shared/frame)

OP OpenVibers committed 24 Sept 2026 · 7571ba73dc · 20 files, +425 −403


Changes

ddiff --git a/README.md b/README.md
iindex 563f1f0e..b8f61cce 100644
---- a/README.md
++++ b/README.md
@@@ -408,7 +408,7 @@ Google-style account management supporting up to 5 accounts:
domain still serves a placeholder page), `library` (released package), `repository` (code with CI,
nothing to run) or `placeholder` (planned). The registry adds `exposure` to each service, the
descriptor gives an `origin` only to `live` services (`planned_origin` otherwise), `/status` reads
-- "up (loopback only)" for an internal service, and the chrome nav lists only `live` sites. Change a
++ "up (loopback only)" for an internal service, and the Frame's nav lists only `live` sites. Change a
row when a domain stops serving its placeholder; `test/registry-exposure.test.js` pins the states.
- The registry also answers `GET /api/v1/registry/topics` (every event type from the manifests'
`eventsProduced`/`eventsConsumed` and the payload contracts, with producers, consumers, the matching
@@@ -421,7 +421,7 @@ Google-style account management supporting up to 5 accounts:
from its own Events consumer.
Payload schemas are served at their `$id` (`/contracts/events/payloads/<type>.v1.json`).
- `GET /api/v1/registry/categories[/:id]` groups every service by what it is, each with its rule:
-- `site` (in the network's site list, `server/chrome/sites.js`), `platform` (runs, but other services call
++ `site` (in the network's site list, `server/frame/sites.js`), `platform` (runs, but other services call
it), `library`, `repository`, `planned`. `GET /api/v1/registry/featured` lists the open sites whose last
check was up or degraded, in the navigation's usage order (7-day page views plus 14-day signed-in
history, recounted every 30 minutes), with `ranked_at` and `stale`; the hub is not listed and nothing
ddiff --git a/docs/shared-contracts.md b/docs/shared-contracts.md
iindex 15b4e258..fce10e90 100644
---- a/docs/shared-contracts.md
++++ b/docs/shared-contracts.md
@@@ -123,21 +123,23 @@ canonical URL per page; titles ≤ 60 chars, descriptions 120–160; JSON-LD on
sitemaps list canonical hosts only; `/llms.txt` on every site; never the words "free", "$0",
"no ads" as claims about the platform.
--## 7. Chrome data (`GET https://openvibe.network/api/chrome?host=<hostname>`)
++## 7. Frame data (`GET https://openvibe.network/api/frame?host=<hostname>`)
++
++The OpenVibe Frame is the navbar, footer and "shipped" views every OpenVibe site sits in. `/api/chrome` is the old name of this endpoint, kept for copies of openvibe-shared older than 1.11.0.
Public, CORS `*`, `max-age=600`, ETag. What the navbar and footer of every site show:
`nav` (open sites, most used first), `soon` (reserved domains), `footer.blurb`, `footer.discover`,
`footer.popular` (tools) and `footer.legal` (`terms`/`privacy`/`dmca` on the site's **own** domain).
- Ranking = each service's 7-day page views and visitors plus signed-in cross-site history,
-- refreshed every 30 minutes and stored in `chrome_cache`, so an unreachable service keeps its last
++ refreshed every 30 minutes and stored in `frame_cache`, so an unreachable service keeps its last
score. The Network itself counts page views only, at half weight.
- Copy = once a day OpenVibe.AI's `network.site_copy` workflow (self-signed `svc:network` service
token, `ns network.*`) may write a blurb per site and pick links **by id** from a list we supply.
Ids resolve to our URLs server-side; text is length-capped and screened (no markup, no URLs, no
cost claims). There is no fallback source: when AI fails, the last good copy (kept in
-- `chrome_cache`) stays, or the hand-written copy if there has never been one.
--- Clients: `navbar.js` and `footer.js` share `window.OpenVibeChrome` — a per-host localStorage cache
++ `frame_cache`) stays, or the hand-written copy if there has never been one.
++- Clients: `navbar.js` and `footer.js` share `window.OpenVibeFrame` (`OpenVibeChrome` is the old alias) — a per-host localStorage cache
(30 min, refreshed in the background). Navbar options: `networkLinks` (`false` | count, default 4).
## 8. Legal (`require('openvibe-shared/legal')`)
ddiff --git a/package-lock.json b/package-lock.json
iindex 93ba03fe..5258392f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
}
@@@ -1490,9 +1490,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.9.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0",
-- "integrity": "sha512-6OP9RKfTN8NGE/oa5mr+WFNx+Po8ORqwjhzY+Cfmo9KRkp5fNnX+OI/R+oBqcDEntNPMPQFtP81Mmj4hTgcu4g==",
++ "version": "1.11.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.0",
++ "integrity": "sha512-edMW0I25HNFReLcfZ9ak+4q5sy2t7qq7EPG52tOerTBV2fU994C5we/02AD/9i2Vuq0+ayNBBisW0cg60L6DLQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex abb3f471..6d6061ce 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.9.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
}
ddiff --git a/server/admin/analytics-routes.js b/server/admin/analytics-routes.js
iindex 14adf874..15a69093 100644
---- a/server/admin/analytics-routes.js
++++ b/server/admin/analytics-routes.js
@@@ -35,12 +35,12 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config)
router.use(requireAuth, requireAdmin);
// Games and Media keep no analytics of their own. The shared navbar's anonymous page-view count
-- // (chrome_hits: day + host, nothing else) is the honest number for them; the summary says so.
++ // (frame_hits: day + host, nothing else) is the honest number for them; the summary says so.
const BEACON_HOSTS = { games: 'openvibe.games', media: 'openvibe.media', tools: 'openvibe.tools' };
function beaconFallback(svc, days) {
try {
const host = BEACON_HOSTS[svc.name]; if (!host) return null;
-- const row = analytics.db.prepare("SELECT COALESCE(SUM(hits), 0) AS n FROM chrome_hits WHERE day >= date('now', ?) AND (host = ? OR host LIKE ?)").get(`-${Math.min(days || 30, 365)} days`, host, '%.' + host);
++ const row = analytics.db.prepare("SELECT COALESCE(SUM(hits), 0) AS n FROM frame_hits WHERE day >= date('now', ?) AND (host = ? OR host LIKE ?)").get(`-${Math.min(days || 30, 365)} days`, host, '%.' + host);
return { ok: true, analytics: { summary: { total_pageviews: row.n, source: 'navbar page-view count (no service analytics)' }, realtime: {} } };
} catch { return null; }
}
ddiff --git a/server/chrome/service.js b/server/chrome/service.js
ddeleted file mode 100644
iindex 6babdaff..00000000
---- a/server/chrome/service.js
++++ /dev/null
@@@ -1,218 +0,0 @@
--'use strict';
--// ═══════════════════════════════════════════════════════════════
--// Shared chrome data: what the navbar and footer of every site show.
--//
--// GET /api/chrome?host=<hostname> public, CORS *, max-age=600, ETag
--// → { updated, nav: [{ id, name, url, icon, tagline }], open sites, most used first
--// soon: [{ id, name, url, icon, state }], state: internal | placeholder
--// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }
--//
--// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in
--// cross-site history, refreshed every 30 minutes and kept in the database so a restart or an
--// unreachable service never reorders the network at random. Only totals cross the wire.
--// Copy: once a day OpenVibe.AI (network.site_copy) may write a blurb per site and pick "discover"
--// links BY ID from a list we supply; ids resolve to our own URLs here, text is stripped, length-capped
--// and screened. AI unreachable, failing or answering badly → the last good copy stays (kept in
--// chrome_cache across restarts), or the hand-written copy when there has never been one.
--// ═══════════════════════════════════════════════════════════════
--const express = require('express');
--const crypto = require('crypto');
--const { SITES, siteForHost } = require('./sites');
--const toolsCatalog = require('../domains/catalog');
--
--const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
--const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
--const BASE_WEIGHT = { live: 6, tools: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only
--
--/**
-- * 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.*).
-- * There is no other source: Live's /internal/ai/site-copy is no longer called.
-- */
--function aiSiteCopy({ privateKey, issuer, aiUrl }) {
-- const { serviceAuth } = require('openvibe-contracts');
-- let cached = null;
-- function token() {
-- const now = Math.floor(Date.now() / 1000);
-- if (cached && cached.exp - 60 > now) return cached.token;
-- const claims = { iss: issuer, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.ai'], cap: ['ai.run.create', 'ai.run.read'], ns: ['network.*'], iat: now, exp: now + 300, jti: `tok_${require('crypto').randomBytes(12).toString('hex')}` };
-- cached = { token: serviceAuth.signServiceToken(claims, privateKey), exp: claims.exp };
-- return cached.token;
-- }
-- return async function run(body) {
-- const r = await fetch(`${aiUrl}/api/v1/runs?wait=90000`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
-- body: JSON.stringify({ workflow: 'network.site_copy', input: body }),
-- signal: AbortSignal.timeout(100_000),
-- });
-- if (!r.ok) return null;
-- const j = await r.json().catch(() => null);
-- const run = j && j.run;
-- if (!run || run.status !== 'succeeded' && run.status !== 'cached' || !run.output || !Array.isArray(run.output.sites)) return null;
-- // ai.run@1: the model that answered is provenance.model (null for a cached or unresolved run);
-- // run.route is the provider route { key, version }. There is no top-level run.model.
-- const prov = run.provenance && typeof run.provenance === 'object' ? run.provenance : {};
-- const route = run.route && typeof run.route === 'object' ? run.route.key : prov.route;
-- return { sites: run.output.sites, model: prov.model || route || 'openvibe.ai' };
-- };
--}
--
--function createChromeService(db, config, analytics, { privateKey = null, issuer = null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {
-- const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
-- db.exec('CREATE TABLE IF NOT EXISTS chrome_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)');
-- // Anonymous page-view counts per host and day, sent by the shared navbar. No user, no IP, no path.
-- db.exec('CREATE TABLE IF NOT EXISTS chrome_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host))');
-- const bump = db.prepare("INSERT INTO chrome_hits (day, host, hits) VALUES (date('now'), ?, 1) ON CONFLICT(day, host) DO UPDATE SET hits = hits + 1");
-- const load = (k) => { try { const r = db.prepare('SELECT value, updated_at FROM chrome_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };
-- const save = (k, v) => db.prepare('INSERT INTO chrome_cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(k, JSON.stringify(v));
--
-- const savedRank = load('rank') || {};
-- let rank = savedRank.value || { scores: {}, tools: [] };
-- let rankedAt = savedRank.value ? savedRank.at || null : null;
-- let copy = (load('copy') || {}).value || { sites: {} };
-- let version = Date.now();
--
-- const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
-- async function getJson(url, headers) {
-- try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(6000) }); return r.ok ? await r.json() : null; } catch { return null; }
-- }
--
-- async function refreshRank() {
-- const scores = Object.assign({}, rank.scores);
-- // Live runs its own navbar and its own analytics: ask it for totals (internal key, totals only).
-- const live = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, { 'X-Internal-Key': config.internalKey });
-- if (live && live.summary) scores.live = Math.round(Number(live.summary.total_pageviews) || 0);
-- // Everything else: the navbar's page-view beacon, summed per site over 7 days.
-- let tools = rank.tools || [];
-- try {
-- const rows = db.prepare("SELECT host, SUM(hits) AS n FROM chrome_hits WHERE day >= date('now', '-7 days') GROUP BY host").all();
-- const perSite = {}; const perTool = new Map();
-- const { catalog } = toolsCatalog.peek();
-- const toolOfHost = new Map();
-- for (const t of catalog.tools) for (const h of [t.hosts && t.hosts.canonical, t.hosts && t.hosts.short, ...((t.hosts && t.hosts.aliases) || [])]) if (h) toolOfHost.set(h, t.id);
-- for (const r of rows) {
-- const site = siteForHost(r.host) || (toolOfHost.has(r.host) ? { id: 'tools' } : null);
-- if (!site) continue;
-- perSite[site.id] = (perSite[site.id] || 0) + r.n;
-- const tool = toolOfHost.get(r.host); if (tool) perTool.set(tool, (perTool.get(tool) || 0) + r.n);
-- }
-- for (const [id, n] of Object.entries(perSite)) if (id !== 'live') scores[id] = Math.round(id === 'network' ? n * 0.5 : n); // the account desk is not a destination
-- if (perTool.size) tools = [...perTool.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(x => x[0]);
-- } catch { /* */ }
-- // Signed-in history is a second opinion that also covers hosts without the shared navbar.
-- try {
-- const bySvc = db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();
-- for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }
-- } catch { /* table appears on first history write */ }
-- rank = { scores, tools };
-- save('rank', rank); version = Date.now(); rankedAt = Date.now();
-- }
--
-- const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
-- function orderedOpen() {
-- const open = SITES.filter(s => s.status === 'open');
-- const max = Math.max(1, ...open.map(s => scoreOf(s.id)));
-- // Real use decides; the base weight only breaks ties and orders a cold start.
-- return open.map(s => ({ s, v: scoreOf(s.id) / max + (BASE_WEIGHT[s.id] || 0) / 100 })).sort((a, b) => b.v - a.v).map(x => x.s);
-- }
--
-- function linkPool() {
-- const { catalog } = toolsCatalog.peek();
-- const pool = new Map();
-- for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: 'OpenVibe.' + s.name, url: `https://${s.host}/`, about: s.tagline });
-- for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });
-- for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
-- return pool;
-- }
-- function popularTools(limit) {
-- const { catalog } = toolsCatalog.peek();
-- const byId = new Map(catalog.tools.map(t => [t.id, t]));
-- const seed = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress'];
-- return [...new Set([...(rank.tools || []), ...seed])].map(id => byId.get(id)).filter(Boolean).slice(0, limit).map(t => ({ name: t.name, url: t.url }));
-- }
--
-- async function refreshCopy() {
-- if (!fromAi) return;
-- const pool = linkPool();
-- const popular = new Set(popularTools(12).map(t => t.url));
-- const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));
-- const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: 'OpenVibe.' + s.name, what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
-- let j = null;
-- try { j = await fromAi(body); } catch { j = null; }
-- if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
-- const next = {};
-- for (const row of j.sites) {
-- const site = SITES.find(s => s.id === row.id); if (!site) continue;
-- const blurb = String(row.blurb || '').replace(/\s+/g, ' ').trim();
-- const picks = (Array.isArray(row.picks) ? row.picks : []).filter(id => pool.has(id) && id !== 'site:' + site.id).slice(0, 4);
-- if (blurb.length < 20 || blurb.length > 170 || BANNED.test(blurb)) continue; // screened out: the hand-written copy stays
-- next[site.id] = { blurb, picks };
-- }
-- if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; save('copy', copy); version = Date.now(); }
-- }
--
-- function payloadFor(hostname) {
-- const site = siteForHost(hostname) || SITES.find(s => s.id === 'network');
-- const open = orderedOpen(); const pool = linkPool();
-- const ai = copy.sites[site.id];
-- const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
-- // Legal documents live on the site's own apex, so each domain answers for itself.
-- const legalBase = `https://${site.host}`;
-- return {
-- updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
-- nav: open.map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, tagline: s.tagline })),
-- // state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
-- soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
-- footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
-- };
-- }
--
-- const router = express.Router();
-- const cache = new Map();
-- router.get('/', (req, res) => {
-- const host = String(req.query.host || '').toLowerCase().slice(0, 255);
-- const site = siteForHost(host) || { id: 'network' };
-- let e = cache.get(site.id);
-- if (!e || e.version !== version) { const body = JSON.stringify(payloadFor(host)); e = { version, body, etag: '"' + crypto.createHash('sha1').update(body).digest('base64url').slice(0, 18) + '"' }; cache.set(site.id, e); }
-- res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=600, stale-while-revalidate=86400', ETag: e.etag, 'Content-Type': 'application/json; charset=utf-8' });
-- if (req.headers['if-none-match'] === e.etag) return res.status(304).end();
-- res.send(e.body);
-- });
--
-- // POST /api/chrome/hit — one anonymous count for the calling page's host. The host comes from the
-- // browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
-- const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
-- router.post('/hit', (req, res) => {
-- // sendBeacon is a no-cors request: without CORP cross-origin the browser logs every beacon's
-- // (empty) answer as blocked on every page of every site (the count itself was never affected).
-- res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'no-store' });
-- 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]++;
-- 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)) bump.run(host);
-- } catch { /* counting must never fail a page */ }
-- res.status(204).end();
-- });
--
-- function start() {
-- const rankAge = Date.now() - ((load('rank') || {}).at || 0), copyAge = Date.now() - ((load('copy') || {}).at || 0);
-- const t1 = setTimeout(() => refreshRank().catch(() => {}), rankAge > RANK_MS ? 8000 : RANK_MS - rankAge); t1.unref();
-- const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
-- const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
-- const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
-- }
--
-- /** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */
-- function ranking() {
-- return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
-- }
--
-- return { router, start, refreshRank, refreshCopy, payloadFor, ranking, _state: () => ({ rank, copy }) };
--}
--
--module.exports = { createChromeService, BANNED };
ddiff --git a/server/chrome/sites.js b/server/chrome/sites.js
ddeleted file mode 100644
iindex e9681fde..00000000
---- a/server/chrome/sites.js
++++ /dev/null
@@@ -1,57 +0,0 @@
--'use strict';
--const contracts = require('openvibe-contracts');
--const { exposureOf } = require('../registry/exposure');
--// Every OpenVibe property: what it is, whether it is open, and which legal documents apply.
--// legal.profile decides the wording of /terms, /privacy and /dmca on that site (openvibe-shared/legal):
--// streaming user video + chat + payments tools files processed and discarded
--// ugc user posts/pastes games accounts + game state
--// hosting stored user media account identity provider
--// info read-only placeholder/editorial site
--// A site is 'open' (in the nav) only when its service's public domain serves the service itself, per
--// Network's exposure overlay (server/registry/exposure.js); every other site is 'soon' and says why
--// (state: internal = runs on loopback only, placeholder = planned). The two lists cannot disagree.
--// host comes from the service's manifest (openvibe-contracts publicOrigin); an entry names its host only
--// while its manifest has no public origin (chat). TODO(plan §5.1/§5.3): name, icon, tagline, what and the
--// legal profile are presentation with no manifest field yet; they move when the manifest schema has them.
--const RAW_SITES = [
-- { id: 'live', name: 'Live', icon: 'live', service: 'live', profile: 'streaming', tagline: 'Streams, clips and chat', what: 'Live streaming from a browser, OBS or a phone, with restreaming, clips, VODs, chat games and channel points.' },
-- { id: 'tools', name: 'Tools', icon: 'tools', service: 'tools', profile: 'tools', tagline: 'Every online tool', what: 'More than 160 online tools: converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own address.' },
-- { id: 'community', name: 'Community', icon: 'community', service: 'community', profile: 'ugc', tagline: 'Pastes and posts', what: 'Share text and code with a link, comment, and follow what people on OpenVibe are making.' },
-- { id: 'games', name: 'Games', icon: 'games', service: 'games', profile: 'games', tagline: 'Browser games', what: 'Scraplandia, a shared pixel canvas and other games that run in a tab and know your OpenVibe account.' },
-- { id: 'media', name: 'Media', icon: 'media', service: 'media', profile: 'hosting', tagline: 'VODs, clips, files', what: 'The public media library behind every site: recorded streams, clips, images and uploads.' },
-- { id: 'network', name: 'Network', icon: 'network', service: 'network', profile: 'account', tagline: 'Account and themes', what: 'The account behind every site: one sign-in, themes, notifications, history and linked services.' },
-- { id: 'chat', name: 'Chat', host: 'openvibe.chat', icon: 'chat', service: 'chat', profile: 'ugc', tagline: 'One chat for the network', what: 'Rooms, stream chat, direct messages, calls and text-to-speech under one identity.' },
-- { id: 'codes', name: 'Codes', icon: 'codes', service: 'codes', profile: 'ugc', tagline: 'Build on OpenVibe', what: 'The developer portal: API and SDK docs, the service and capability registry, event schemas, credentials, playgrounds and mod publishing.' },
-- { id: 'blog', name: 'Blog', icon: 'blog', service: 'blog', profile: 'info', tagline: 'Long-form, yours to keep', what: 'The official OpenVibe blog and a blog for every member: drafts, scheduling, media, comments and feeds.' },
-- { id: 'wiki', name: 'Wiki', icon: 'wiki', service: 'wiki', profile: 'ugc', tagline: 'Knowledge, with sources', what: 'Wiki spaces with page trees, revision history, citations and discussion, editable together.' },
-- { id: 'news', name: 'News', icon: 'news', service: 'news', profile: 'info', tagline: 'Sourced, not spun', what: 'Source-backed stories: clustered coverage, cited summaries, multiple perspectives and timelines.' },
-- { id: 'reviews', name: 'Reviews', icon: 'reviews', service: 'reviews', profile: 'ugc', tagline: 'Reviews with receipts', what: 'Review signals gathered across sources with provenance, pros and cons, and community discussion. No invented ratings.' },
-- { id: 'tips', name: 'Tips', icon: 'tips', service: 'tips', profile: 'streaming', tagline: 'Support creators', what: 'Tips, goals, paid messages and alerts for creators, settled by the network\'s billing ledger.' },
-- { id: 'vip', name: 'VIP', icon: 'vip', service: 'vip', profile: 'account', tagline: 'Memberships and perks', what: 'Creator and network memberships: plans, perks and benefits recognised on every OpenVibe site.' },
-- { id: 'trade', name: 'Trade', icon: 'trade', service: 'trade', profile: 'ugc', tagline: 'Watchlists and alerts', what: 'Informational watchlists, sourced market context and alerts. No custody, no order execution.' },
-- { id: 'host', name: 'Host', icon: 'host', service: 'host', profile: 'hosting', tagline: 'Deploy and host', what: 'The network\'s deployment plane first, then simple hosting for community sites, bots and mods.' },
-- { id: 'deals', name: 'Deals', icon: 'deals', service: 'deals', profile: 'info', tagline: 'Deals worth sharing', what: 'Deals submitted and voted on by the community, with source, price and freshness always shown.' },
-- { id: 'coupons', name: 'Coupons', icon: 'coupons', service: 'coupons', profile: 'info', tagline: 'Codes that work', what: 'Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.' },
-- { id: 'stream', name: 'OpenRe.Stream', icon: 'stream', service: 'openre', profile: 'streaming', tagline: 'Restream anywhere', what: 'Ingest once and send the stream to every platform at once; keys, destinations and output health in one place.' },
--];
--function manifestHost(service) {
-- const m = contracts.services.get(service);
-- try { return m && m.publicOrigin ? new URL(m.publicOrigin).hostname : null; } catch { return null; }
--}
--const SITES = RAW_SITES.map((s) => {
-- const e = exposureOf(s.service);
-- const host = manifestHost(s.service) || s.host;
-- if (!host) throw new Error(`site ${s.id}: no host (its manifest has no publicOrigin and the entry names none)`);
-- return { ...s, host, status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
--});
--const byHost = new Map(SITES.map(s => [s.host, s]));
--
--/** The site a hostname belongs to (subdomains included), or null. */
--function siteForHost(hostname) {
-- const h = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
-- if (byHost.has(h)) return byHost.get(h);
-- for (const s of SITES) if (h.endsWith('.' + s.host)) return s;
-- return null;
--}
--
--module.exports = { SITES, siteForHost };
ddiff --git a/server/frame/service.js b/server/frame/service.js
nnew file mode 100644
iindex 00000000..a052bb75
---- /dev/null
++++ b/server/frame/service.js
@@@ -0,0 +1,223 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// OpenVibe Frame data: what the navbar and footer of every site show.
++//
++// GET /api/frame?host=<hostname> public, CORS *, max-age=600, ETag
++// → { updated, nav: [{ id, name, url, icon, tagline }], open sites, most used first
++// soon: [{ id, name, url, icon, state }], state: internal | placeholder
++// footer: { blurb, discover: [{ name, url }], popular: [{ name, url }], legal: { terms, privacy, dmca } } }
++//
++// Ranking: page views and visitors from each service's own analytics (last 7 days) plus signed-in
++// cross-site history, refreshed every 30 minutes and kept in the database so a restart or an
++// unreachable service never reorders the network at random. Only totals cross the wire.
++// Copy: once a day OpenVibe.AI (network.site_copy) may write a blurb per site and pick "discover"
++// links BY ID from a list we supply; ids resolve to our own URLs here, text is stripped, length-capped
++// and screened. AI unreachable, failing or answering badly → the last good copy stays (kept in
++// frame_cache across restarts), or the hand-written copy when there has never been one.
++// ═══════════════════════════════════════════════════════════════
++const express = require('express');
++const crypto = require('crypto');
++const { SITES, siteForHost } = require('./sites');
++const toolsCatalog = require('../domains/catalog');
++
++const RANK_MS = 30 * 60_000, COPY_MS = 24 * 60 * 60_000;
++const BANNED = /\b(free|\$0|no ads|ad[- ]free|no cost|gratis)\b|https?:|www\.|[<>{}]/i;
++const BASE_WEIGHT = { live: 6, tools: 5, community: 4, games: 3, media: 2, network: 1 }; // cold-start order only
++
++/**
++ * 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.*).
++ * There is no other source: Live's /internal/ai/site-copy is no longer called.
++ */
++function aiSiteCopy({ privateKey, issuer, aiUrl }) {
++ const { serviceAuth } = require('openvibe-contracts');
++ let cached = null;
++ function token() {
++ const now = Math.floor(Date.now() / 1000);
++ if (cached && cached.exp - 60 > now) return cached.token;
++ const claims = { iss: issuer, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.ai'], cap: ['ai.run.create', 'ai.run.read'], ns: ['network.*'], iat: now, exp: now + 300, jti: `tok_${require('crypto').randomBytes(12).toString('hex')}` };
++ cached = { token: serviceAuth.signServiceToken(claims, privateKey), exp: claims.exp };
++ return cached.token;
++ }
++ return async function run(body) {
++ const r = await fetch(`${aiUrl}/api/v1/runs?wait=90000`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
++ body: JSON.stringify({ workflow: 'network.site_copy', input: body }),
++ signal: AbortSignal.timeout(100_000),
++ });
++ if (!r.ok) return null;
++ const j = await r.json().catch(() => null);
++ const run = j && j.run;
++ if (!run || run.status !== 'succeeded' && run.status !== 'cached' || !run.output || !Array.isArray(run.output.sites)) return null;
++ // ai.run@1: the model that answered is provenance.model (null for a cached or unresolved run);
++ // run.route is the provider route { key, version }. There is no top-level run.model.
++ const prov = run.provenance && typeof run.provenance === 'object' ? run.provenance : {};
++ const route = run.route && typeof run.route === 'object' ? run.route.key : prov.route;
++ return { sites: run.output.sites, model: prov.model || route || 'openvibe.ai' };
++ };
++}
++
++function createFrameService(db, config, analytics, { privateKey = null, issuer = null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {
++ const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
++ // Renamed from chrome_cache/chrome_hits (2026-09-24): carry the old tables over once.
++ for (const [from, to] of [['chrome_cache', 'frame_cache'], ['chrome_hits', 'frame_hits']]) {
++ const has = (t) => Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(t));
++ if (has(from) && !has(to)) db.exec(`ALTER TABLE ${from} RENAME TO ${to}`);
++ }
++ db.exec('CREATE TABLE IF NOT EXISTS frame_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)');
++ // Anonymous page-view counts per host and day, sent by the shared navbar. No user, no IP, no path.
++ db.exec('CREATE TABLE IF NOT EXISTS frame_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host))');
++ const bump = db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (date('now'), ?, 1) ON CONFLICT(day, host) DO UPDATE SET hits = hits + 1");
++ const load = (k) => { try { const r = db.prepare('SELECT value, updated_at FROM frame_cache WHERE key = ?').get(k); return r ? { value: JSON.parse(r.value), at: Date.parse(r.updated_at + 'Z') || 0 } : null; } catch { return null; } };
++ const save = (k, v) => db.prepare('INSERT INTO frame_cache (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP').run(k, JSON.stringify(v));
++
++ const savedRank = load('rank') || {};
++ let rank = savedRank.value || { scores: {}, tools: [] };
++ let rankedAt = savedRank.value ? savedRank.at || null : null;
++ let copy = (load('copy') || {}).value || { sites: {} };
++ let version = Date.now();
++
++ const svcUrl = (name, fallback) => (config.services && config.services[name] && config.services[name].internalUrl) || fallback;
++ async function getJson(url, headers) {
++ try { const r = await fetch(url, { headers, signal: AbortSignal.timeout(6000) }); return r.ok ? await r.json() : null; } catch { return null; }
++ }
++
++ async function refreshRank() {
++ const scores = Object.assign({}, rank.scores);
++ // Live runs its own navbar and its own analytics: ask it for totals (internal key, totals only).
++ const live = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, { 'X-Internal-Key': config.internalKey });
++ if (live && live.summary) scores.live = Math.round(Number(live.summary.total_pageviews) || 0);
++ // Everything else: the navbar's page-view beacon, summed per site over 7 days.
++ let tools = rank.tools || [];
++ try {
++ const rows = db.prepare("SELECT host, SUM(hits) AS n FROM frame_hits WHERE day >= date('now', '-7 days') GROUP BY host").all();
++ const perSite = {}; const perTool = new Map();
++ const { catalog } = toolsCatalog.peek();
++ const toolOfHost = new Map();
++ for (const t of catalog.tools) for (const h of [t.hosts && t.hosts.canonical, t.hosts && t.hosts.short, ...((t.hosts && t.hosts.aliases) || [])]) if (h) toolOfHost.set(h, t.id);
++ for (const r of rows) {
++ const site = siteForHost(r.host) || (toolOfHost.has(r.host) ? { id: 'tools' } : null);
++ if (!site) continue;
++ perSite[site.id] = (perSite[site.id] || 0) + r.n;
++ const tool = toolOfHost.get(r.host); if (tool) perTool.set(tool, (perTool.get(tool) || 0) + r.n);
++ }
++ for (const [id, n] of Object.entries(perSite)) if (id !== 'live') scores[id] = Math.round(id === 'network' ? n * 0.5 : n); // the account desk is not a destination
++ if (perTool.size) tools = [...perTool.entries()].sort((x, y) => y[1] - x[1]).slice(0, 12).map(x => x[0]);
++ } catch { /* */ }
++ // Signed-in history is a second opinion that also covers hosts without the shared navbar.
++ try {
++ const bySvc = db.prepare("SELECT service, COUNT(*) AS n, COUNT(DISTINCT user_id) AS u FROM user_history WHERE created_at > datetime('now', '-14 days') GROUP BY service").all();
++ for (const r of bySvc) { const id = r.service === 'pastes' ? 'community' : r.service; if (!id) continue; scores[id + ':history'] = r.n + 5 * r.u; }
++ } catch { /* table appears on first history write */ }
++ rank = { scores, tools };
++ save('rank', rank); version = Date.now(); rankedAt = Date.now();
++ }
++
++ const scoreOf = (id) => (rank.scores[id] || 0) + 3 * (rank.scores[id + ':history'] || 0);
++ function orderedOpen() {
++ const open = SITES.filter(s => s.status === 'open');
++ const max = Math.max(1, ...open.map(s => scoreOf(s.id)));
++ // Real use decides; the base weight only breaks ties and orders a cold start.
++ return open.map(s => ({ s, v: scoreOf(s.id) / max + (BASE_WEIGHT[s.id] || 0) / 100 })).sort((a, b) => b.v - a.v).map(x => x.s);
++ }
++
++ function linkPool() {
++ const { catalog } = toolsCatalog.peek();
++ const pool = new Map();
++ for (const s of SITES.filter(x => x.status === 'open')) pool.set('site:' + s.id, { name: 'OpenVibe.' + s.name, url: `https://${s.host}/`, about: s.tagline });
++ for (const f of catalog.families) if (f.path) pool.set('family:' + f.id, { name: f.name, url: 'https://openvibe.tools' + f.path, about: f.tagline });
++ for (const t of catalog.tools) pool.set('tool:' + t.id, { name: t.name, url: t.url, about: t.tagline });
++ return pool;
++ }
++ function popularTools(limit) {
++ const { catalog } = toolsCatalog.peek();
++ const byId = new Map(catalog.tools.map(t => [t.id, t]));
++ const seed = ['yt', 'convert', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'compress'];
++ return [...new Set([...(rank.tools || []), ...seed])].map(id => byId.get(id)).filter(Boolean).slice(0, limit).map(t => ({ name: t.name, url: t.url }));
++ }
++
++ async function refreshCopy() {
++ if (!fromAi) return;
++ const pool = linkPool();
++ const popular = new Set(popularTools(12).map(t => t.url));
++ const links = [...pool.entries()].filter(([id, l]) => !id.startsWith('tool:') || popular.has(l.url)).map(([id, l]) => ({ id, name: l.name, about: l.about }));
++ const body = { sites: SITES.filter(s => s.status === 'open').map(s => ({ id: s.id, name: 'OpenVibe.' + s.name, what: s.what, popular: s.id === 'tools' ? popularTools(6).map(t => t.name) : [] })), links };
++ let j = null;
++ try { j = await fromAi(body); } catch { j = null; }
++ if (!j || !Array.isArray(j.sites)) return; // keep the last good copy
++ const next = {};
++ for (const row of j.sites) {
++ const site = SITES.find(s => s.id === row.id); if (!site) continue;
++ const blurb = String(row.blurb || '').replace(/\s+/g, ' ').trim();
++ const picks = (Array.isArray(row.picks) ? row.picks : []).filter(id => pool.has(id) && id !== 'site:' + site.id).slice(0, 4);
++ if (blurb.length < 20 || blurb.length > 170 || BANNED.test(blurb)) continue; // screened out: the hand-written copy stays
++ next[site.id] = { blurb, picks };
++ }
++ if (Object.keys(next).length) { copy = { sites: next, model: String(j.model || '').slice(0, 60) }; save('copy', copy); version = Date.now(); }
++ }
++
++ function payloadFor(hostname) {
++ const site = siteForHost(hostname) || SITES.find(s => s.id === 'network');
++ const open = orderedOpen(); const pool = linkPool();
++ const ai = copy.sites[site.id];
++ const discover = (ai ? ai.picks.map(id => pool.get(id)) : open.filter(s => s.id !== site.id).slice(0, 4).map(s => pool.get('site:' + s.id))).filter(Boolean).map(l => ({ name: l.name, url: l.url }));
++ // Legal documents live on the site's own apex, so each domain answers for itself.
++ const legalBase = `https://${site.host}`;
++ return {
++ updated: new Date(version).toISOString(), site: { id: site.id, name: site.name, host: site.host, status: site.status, state: site.state },
++ nav: open.map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, tagline: s.tagline })),
++ // state: 'internal' (runs on loopback, the domain still shows a placeholder) or 'placeholder' (planned).
++ soon: SITES.filter(s => s.status === 'soon').map(s => ({ id: s.id, name: s.name, url: `https://${s.host}/`, icon: s.icon, state: s.state })),
++ footer: { blurb: (ai && ai.blurb) || site.what, ai: !!ai, discover, popular: popularTools(8), legal: { terms: legalBase + '/terms', privacy: legalBase + '/privacy', dmca: legalBase + '/dmca' } },
++ };
++ }
++
++ const router = express.Router();
++ const cache = new Map();
++ router.get('/', (req, res) => {
++ const host = String(req.query.host || '').toLowerCase().slice(0, 255);
++ const site = siteForHost(host) || { id: 'network' };
++ let e = cache.get(site.id);
++ if (!e || e.version !== version) { const body = JSON.stringify(payloadFor(host)); e = { version, body, etag: '"' + crypto.createHash('sha1').update(body).digest('base64url').slice(0, 18) + '"' }; cache.set(site.id, e); }
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=600, stale-while-revalidate=86400', ETag: e.etag, 'Content-Type': 'application/json; charset=utf-8' });
++ if (req.headers['if-none-match'] === e.etag) return res.status(304).end();
++ res.send(e.body);
++ });
++
++ // POST /api/frame/hit — one anonymous count for the calling page's host. The host comes from the
++ // browser-set Origin header (never the body) and must be one of ours or a registered tool domain.
++ const recent = new Map(); // ip → [count, windowStart]: 40/min is plenty for a person, useless for stuffing
++ router.post('/hit', (req, res) => {
++ // sendBeacon is a no-cors request: without CORP cross-origin the browser logs every beacon's
++ // (empty) answer as blocked on every page of every site (the count itself was never affected).
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'Cache-Control': 'no-store' });
++ 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]++;
++ 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)) bump.run(host);
++ } catch { /* counting must never fail a page */ }
++ res.status(204).end();
++ });
++
++ function start() {
++ const rankAge = Date.now() - ((load('rank') || {}).at || 0), copyAge = Date.now() - ((load('copy') || {}).at || 0);
++ const t1 = setTimeout(() => refreshRank().catch(() => {}), rankAge > RANK_MS ? 8000 : RANK_MS - rankAge); t1.unref();
++ const i1 = setInterval(() => refreshRank().catch(() => {}), RANK_MS); i1.unref();
++ const t2 = setTimeout(() => refreshCopy().catch(() => {}), copyAge > COPY_MS ? 60_000 : COPY_MS - copyAge); t2.unref();
++ const i2 = setInterval(() => refreshCopy().catch(() => {}), COPY_MS); i2.unref();
++ }
++
++ /** The open sites in navbar order (most used first), and when that use was last counted (ms, or null: cold start). */
++ function ranking() {
++ return { at: rankedAt, order: orderedOpen().map(s => s.id), everyMs: RANK_MS };
++ }
++
++ return { router, start, refreshRank, refreshCopy, payloadFor, ranking, _state: () => ({ rank, copy }) };
++}
++
++module.exports = { createFrameService, BANNED };
ddiff --git a/server/frame/sites.js b/server/frame/sites.js
nnew file mode 100644
iindex 00000000..e9681fde
---- /dev/null
++++ b/server/frame/sites.js
@@@ -0,0 +1,57 @@
++'use strict';
++const contracts = require('openvibe-contracts');
++const { exposureOf } = require('../registry/exposure');
++// Every OpenVibe property: what it is, whether it is open, and which legal documents apply.
++// legal.profile decides the wording of /terms, /privacy and /dmca on that site (openvibe-shared/legal):
++// streaming user video + chat + payments tools files processed and discarded
++// ugc user posts/pastes games accounts + game state
++// hosting stored user media account identity provider
++// info read-only placeholder/editorial site
++// A site is 'open' (in the nav) only when its service's public domain serves the service itself, per
++// Network's exposure overlay (server/registry/exposure.js); every other site is 'soon' and says why
++// (state: internal = runs on loopback only, placeholder = planned). The two lists cannot disagree.
++// host comes from the service's manifest (openvibe-contracts publicOrigin); an entry names its host only
++// while its manifest has no public origin (chat). TODO(plan §5.1/§5.3): name, icon, tagline, what and the
++// legal profile are presentation with no manifest field yet; they move when the manifest schema has them.
++const RAW_SITES = [
++ { id: 'live', name: 'Live', icon: 'live', service: 'live', profile: 'streaming', tagline: 'Streams, clips and chat', what: 'Live streaming from a browser, OBS or a phone, with restreaming, clips, VODs, chat games and channel points.' },
++ { id: 'tools', name: 'Tools', icon: 'tools', service: 'tools', profile: 'tools', tagline: 'Every online tool', what: 'More than 160 online tools: converters, downloaders, PDF and image tools, developer utilities and network diagnostics, each on its own address.' },
++ { id: 'community', name: 'Community', icon: 'community', service: 'community', profile: 'ugc', tagline: 'Pastes and posts', what: 'Share text and code with a link, comment, and follow what people on OpenVibe are making.' },
++ { id: 'games', name: 'Games', icon: 'games', service: 'games', profile: 'games', tagline: 'Browser games', what: 'Scraplandia, a shared pixel canvas and other games that run in a tab and know your OpenVibe account.' },
++ { id: 'media', name: 'Media', icon: 'media', service: 'media', profile: 'hosting', tagline: 'VODs, clips, files', what: 'The public media library behind every site: recorded streams, clips, images and uploads.' },
++ { id: 'network', name: 'Network', icon: 'network', service: 'network', profile: 'account', tagline: 'Account and themes', what: 'The account behind every site: one sign-in, themes, notifications, history and linked services.' },
++ { id: 'chat', name: 'Chat', host: 'openvibe.chat', icon: 'chat', service: 'chat', profile: 'ugc', tagline: 'One chat for the network', what: 'Rooms, stream chat, direct messages, calls and text-to-speech under one identity.' },
++ { id: 'codes', name: 'Codes', icon: 'codes', service: 'codes', profile: 'ugc', tagline: 'Build on OpenVibe', what: 'The developer portal: API and SDK docs, the service and capability registry, event schemas, credentials, playgrounds and mod publishing.' },
++ { id: 'blog', name: 'Blog', icon: 'blog', service: 'blog', profile: 'info', tagline: 'Long-form, yours to keep', what: 'The official OpenVibe blog and a blog for every member: drafts, scheduling, media, comments and feeds.' },
++ { id: 'wiki', name: 'Wiki', icon: 'wiki', service: 'wiki', profile: 'ugc', tagline: 'Knowledge, with sources', what: 'Wiki spaces with page trees, revision history, citations and discussion, editable together.' },
++ { id: 'news', name: 'News', icon: 'news', service: 'news', profile: 'info', tagline: 'Sourced, not spun', what: 'Source-backed stories: clustered coverage, cited summaries, multiple perspectives and timelines.' },
++ { id: 'reviews', name: 'Reviews', icon: 'reviews', service: 'reviews', profile: 'ugc', tagline: 'Reviews with receipts', what: 'Review signals gathered across sources with provenance, pros and cons, and community discussion. No invented ratings.' },
++ { id: 'tips', name: 'Tips', icon: 'tips', service: 'tips', profile: 'streaming', tagline: 'Support creators', what: 'Tips, goals, paid messages and alerts for creators, settled by the network\'s billing ledger.' },
++ { id: 'vip', name: 'VIP', icon: 'vip', service: 'vip', profile: 'account', tagline: 'Memberships and perks', what: 'Creator and network memberships: plans, perks and benefits recognised on every OpenVibe site.' },
++ { id: 'trade', name: 'Trade', icon: 'trade', service: 'trade', profile: 'ugc', tagline: 'Watchlists and alerts', what: 'Informational watchlists, sourced market context and alerts. No custody, no order execution.' },
++ { id: 'host', name: 'Host', icon: 'host', service: 'host', profile: 'hosting', tagline: 'Deploy and host', what: 'The network\'s deployment plane first, then simple hosting for community sites, bots and mods.' },
++ { id: 'deals', name: 'Deals', icon: 'deals', service: 'deals', profile: 'info', tagline: 'Deals worth sharing', what: 'Deals submitted and voted on by the community, with source, price and freshness always shown.' },
++ { id: 'coupons', name: 'Coupons', icon: 'coupons', service: 'coupons', profile: 'info', tagline: 'Codes that work', what: 'Coupon codes with merchant matching, restrictions, expiry and real-people validity reports.' },
++ { id: 'stream', name: 'OpenRe.Stream', icon: 'stream', service: 'openre', profile: 'streaming', tagline: 'Restream anywhere', what: 'Ingest once and send the stream to every platform at once; keys, destinations and output health in one place.' },
++];
++function manifestHost(service) {
++ const m = contracts.services.get(service);
++ try { return m && m.publicOrigin ? new URL(m.publicOrigin).hostname : null; } catch { return null; }
++}
++const SITES = RAW_SITES.map((s) => {
++ const e = exposureOf(s.service);
++ const host = manifestHost(s.service) || s.host;
++ if (!host) throw new Error(`site ${s.id}: no host (its manifest has no publicOrigin and the entry names none)`);
++ return { ...s, host, status: e.state === 'live' && e.public_site === 'service' ? 'open' : 'soon', state: e.state };
++});
++const byHost = new Map(SITES.map(s => [s.host, s]));
++
++/** The site a hostname belongs to (subdomains included), or null. */
++function siteForHost(hostname) {
++ const h = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
++ if (byHost.has(h)) return byHost.get(h);
++ for (const s of SITES) if (h.endsWith('.' + s.host)) return s;
++ return null;
++}
++
++module.exports = { SITES, siteForHost };
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 019664a9..9e01dd2f 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -77,7 +77,7 @@ function render() {
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;
if (cache.key === key) return cache;
const ld = seo.jsonLdTag(seo.jsonLd.itemList('OpenVibe sites', SITES.map(([, n, u, , d]) => ({ name: n, url: u, description: d }))));
-- const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', String(catalog.tools.length)).replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);
++ const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', String(catalog.tools.length)).replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);
cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return cache;
}
ddiff --git a/server/index.js b/server/index.js
iindex aead6aa9..b6f7934c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -496,12 +496,15 @@ app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarS
app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub);
app.locals.avatarService = avatarService;
--// Shared chrome: analytics-ranked navigation + footer copy for every site (server/chrome).
--const chromeService = require('./chrome/service').createChromeService(db, config, analytics, { privateKey, issuer: config.jwt.issuer });
--app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);
--chromeService.start();
++// 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 = require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer });
++const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
++app.use('/api/frame', frameLimit, frameService.router);
++app.use('/api/chrome', frameLimit, frameService.router);
++frameService.start();
// /api/v1/registry/featured follows the navigation's usage ranking.
--ecosystem.setRanking(() => chromeService.ranking());
++ecosystem.setRanking(() => frameService.ranking());
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
ddiff --git a/server/not-found.js b/server/not-found.js
iindex 0e63c9ad..03d036af 100644
---- a/server/not-found.js
++++ b/server/not-found.js
@@@ -24,7 +24,7 @@ function page() {
let nav = '';
try { footer = require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' }); } catch { /* optional */ }
try { icons = require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' }); } catch { /* optional */ }
-- try { nav = require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }] }); } catch { /* optional */ }
++ try { nav = require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }] }); } catch { /* optional */ }
cached = `<!DOCTYPE html>
<html lang="en">
<head>
ddiff --git a/server/notifications/events-consumer.js b/server/notifications/events-consumer.js
iindex 372877fc..b924979e 100644
---- a/server/notifications/events-consumer.js
++++ b/server/notifications/events-consumer.js
@@@ -36,14 +36,14 @@
* operator sets it and creates the subscriptions.
*
* Links: a notification links to the event's URL only when that host is one of our sites whose
-- * public domain serves the service (server/chrome/sites.js, via the exposure overlay). Today Deals
++ * public domain serves the service (server/frame/sites.js, via the exposure overlay). Today Deals
* and Trade are loopback-only and their domains show placeholder pages, so the link is kept in
* rich_content.context.planned_url and the notification has no click-through yet.
*/
const express = require('express');
const { http, ids } = require('openvibe-contracts');
const { parseDelivery, createInbox } = require('openvibe-sdk/events');
--const { siteForHost } = require('../chrome/sites');
++const { siteForHost } = require('../frame/sites');
const streamLive = require('./stream-live');
const { LiveFollowersError } = require('./live-followers');
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex 95580d97..c2c898d6 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -36,7 +36,7 @@ const contractsPkg = require('openvibe-contracts/package.json');
const { exposureOf, publicOriginOf, libraries } = require('./exposure');
const { buildTopics, eventsOf } = require('./topics');
const { driftOf } = require('./versions');
--const { SITES } = require('../chrome/sites');
++const { SITES } = require('../frame/sites');
// Where each running service answers health checks from this host (manifests carry the public origin).
// TODO(plan §5.1 "contract manifests plus validated environment overrides"): registry.service-manifest@1 has
@@@ -110,9 +110,9 @@ function internalFromEnv(env = process.env) {
}
// What each category means; categoryOf() applies it. Derived from Network's exposure overlay and its site
--// list (server/chrome/sites.js), never set by hand per service.
++// list (server/frame/sites.js), never set by hand per service.
const CATEGORIES = [
-- { id: 'site', name: 'Sites', rule: 'a product people use in a browser: it has an entry in the network\'s site list (server/chrome/sites.js), open in the navigation once its public domain serves it' },
++ { id: 'site', name: 'Sites', rule: 'a product people use in a browser: it has an entry in the network\'s site list (server/frame/sites.js), open in the navigation once its public domain serves it' },
{ id: 'platform', name: 'Platform services', rule: 'runs (publicly or on loopback) but is not a site people visit: other services call it' },
{ id: 'library', name: 'Libraries', rule: 'exposure library: a released package other code installs' },
{ id: 'repository', name: 'Repositories', rule: 'exposure repository: code with CI, neither released nor run' },
@@@ -256,7 +256,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
/**
* Featured: the sites whose public domain serves them and whose last check (not stale) was up or degraded,
-- * in the navbar's usage order (server/chrome/service.js: page views over 7 days plus signed-in history
++ * in the navbar's usage order (server/frame/service.js: page views over 7 days plus signed-in history
* over 14, recounted every 30 minutes). The hub does not feature itself. Nothing is picked by hand.
*/
function featured() {
ddiff --git a/server/status/routes.js b/server/status/routes.js
iindex 7d4099ad..d7c45fe3 100644
---- a/server/status/routes.js
++++ b/server/status/routes.js
@@@ -128,7 +128,7 @@ ${CSS}
</head>
<body>
<div id="navbar-mount"></div>
--${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}
++${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Status', href: '/status' }] })}
<main class="st" id="main">
<h1>Service status</h1>
<p class="lede">What Network observed when it last checked each service: its readiness endpoint (named checks, required or optional), its deployed release, and when. A service that has not been checked, or whose last check is out of date, shows as <b>unknown</b>. Placeholders, libraries and repositories show as not running. <b>Where</b> says whether the public domain serves the service itself (live), or the service runs on this host's loopback only while its domain still serves a placeholder page (internal). Page generated <time datetime="${esc(generatedAt)}">${esc(generatedAt)}</time>; checks run about every ${esc(Math.round(slo.pollSeconds || 60))} seconds. JSON: <a href="/api/v1/status"><code>/api/v1/status</code></a>.</p>
ddiff --git a/server/updates/routes.js b/server/updates/routes.js
iindex cff9f48a..82eca1d5 100644
---- a/server/updates/routes.js
++++ b/server/updates/routes.js
@@@ -127,7 +127,7 @@ a{color:var(--accent)}
</head>
<body>
<div id="navbar-mount"></div>
--${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Updates', href: '/updates' }, { label: 'Status', href: '/status' }] })}
++${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Updates', href: '/updates' }, { label: 'Status', href: '/status' }] })}
<main class="up" id="main">
<h1>${esc(title)}</h1>
<p class="lede">Every change deployed to OpenVibe, newest first. Each line is a commit from the OpenVibers repositories, linked to the change itself. When enough have gathered, or a large feature lands, they are written up as <a href="https://openvibe.blog/@openvibe">Patch notes on openvibe.blog</a>. JSON: <a href="/api/v1/changelog"><code>/api/v1/changelog</code></a>.</p>
ddiff --git a/test/chrome.test.js b/test/chrome.test.js
ddeleted file mode 100644
iindex f0a73111..00000000
---- a/test/chrome.test.js
++++ /dev/null
@@@ -1,95 +0,0 @@
--'use strict';
--const assert = require('assert');
--const crypto = require('crypto');
--const http = require('http');
--const Database = require('better-sqlite3');
--const { createChromeService, BANNED } = require('../server/chrome/service');
--const { siteForHost } = require('../server/chrome/sites');
--
--const db = new Database(':memory:');
--db.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
--const svc = createChromeService(db, { internalKey: 'change-me-in-production', services: {} }, null);
--
--assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
--assert.equal(siteForHost('evil.example'), null);
--assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
--let p = svc.payloadFor('openvibe.media');
--assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki'], 'cold start follows the base order');
--assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
--assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');
--assert.ok(p.soon.length >= 10);
--
--// Real use reorders: community gets the history, so it rises above the cold-start order.
--const ins = db.prepare("INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, 'page', 't', 'u')");
--for (let i = 0; i < 40; i++) ins.run(i % 9, 'community', null);
--for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
--(async () => {
-- db.prepare("INSERT INTO chrome_hits (day, host, hits) VALUES (date('now'), 'openvibe.games', 900), (date('now'), 'dns.openvibe.tools', 50), (date('now'), 'openvibe.network', 1000)").run();
-- const realFetch = global.fetch; global.fetch = async () => ({ ok: false });
-- await svc.refreshRank(); global.fetch = realFetch;
-- p = svc.payloadFor('openvibe.network');
-- assert.equal(p.nav[0].id, 'games', 'most viewed site comes first');
-- assert.deepEqual(svc.ranking().order, p.nav.map(n => n.id), 'ranking() is the navigation order (the registry features by it)');
-- assert.ok(Date.now() - svc.ranking().at < 5000, 'and says when it was counted');
-- assert.ok(p.nav.findIndex(n => n.id === 'network') > 0, 'the Network is never first on shared-service traffic alone');
-- assert.ok(BANNED.test('Totally free tools') && BANNED.test('see https://x.y') && BANNED.test('<b>hi</b>') && !BANNED.test('Go live from a browser in seconds.'), 'AI copy screen');
-- assert.equal(p.footer.ai, false);
--
-- // Footer copy comes from OpenVibe.AI only. Live's /internal/ai/site-copy is never called, and an
-- // AI failure keeps the last good copy (or the hand-written one when there has never been one).
-- let liveHits = 0; let aiMode = 'ok'; let aiAuth = null;
-- const live = http.createServer((req, res) => { liveHits++; res.statusCode = 200; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ ok: true, sites: [{ id: 'games', blurb: 'Copy from Live that must never be used here.', picks: [] }] })); });
-- const ai = http.createServer((req, res) => {
-- aiAuth = req.headers.authorization;
-- if (aiMode === 'down') { res.statusCode = 503; return res.end('{}'); }
-- res.setHeader('content-type', 'application/json');
-- res.end(JSON.stringify({ run: { status: 'succeeded', route: { key: 'site-copy', version: 1 }, provenance: { origin: 'ai', workflow: 'network.site_copy', model: 'test-model', route: 'site-copy' }, output: { sites: [{ id: 'games', blurb: 'Play Scraplandia and the shared pixel canvas in a tab.', picks: ['site:live'] }] } } }));
-- });
-- await Promise.all([live, ai].map(s => new Promise(r => s.listen(0, '127.0.0.1', r))));
-- const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
-- const cfg = { internalKey: 'a-real-internal-key', services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } };
-- const aiUrl = `http://127.0.0.1:${ai.address().port}`;
-- const db2 = new Database(':memory:');
-- const copySvc = (d) => createChromeService(d, cfg, null, { privateKey, issuer: 'https://openvibe.network', aiUrl });
--
-- let s2 = copySvc(db2);
-- aiMode = 'down';
-- await s2.refreshCopy();
-- p = s2.payloadFor('openvibe.games');
-- assert.equal(p.footer.ai, false, 'AI down and no earlier copy: the hand-written blurb');
-- assert.equal(p.footer.blurb, siteForHost('openvibe.games').what);
-- assert.equal(liveHits, 0, 'no fallback to Live when AI fails');
--
-- aiMode = 'ok';
-- await s2.refreshCopy();
-- assert.match(aiAuth, /^Bearer ey/, 'AI is called with the self-signed service token');
-- p = s2.payloadFor('openvibe.games');
-- assert.equal(p.footer.ai, true);
-- assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.');
-- assert.equal(JSON.parse(db2.prepare("SELECT value FROM chrome_cache WHERE key = 'copy'").get().value).model, 'test-model', 'the model is read from run.provenance.model (ai.run@1)');
--
-- aiMode = 'down';
-- await s2.refreshCopy();
-- p = s2.payloadFor('openvibe.games');
-- assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'AI failure keeps the last good copy');
-- s2 = copySvc(db2);
-- assert.equal(s2.payloadFor('openvibe.games').footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'and it survives a restart');
--
-- // Without a signing key there is no AI client, and still no Live call.
-- await createChromeService(new Database(':memory:'), cfg, null).refreshCopy();
-- assert.equal(liveHits, 0, 'Live is never asked for copy');
-- live.close(); ai.close();
-- // The page-view beacon (navigator.sendBeacon, a no-cors request) is readable cross-origin, so no
-- // site's console reports it as blocked.
-- {
-- const express = require('express');
-- const app = express(); app.use('/api/chrome', svc.router);
-- const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
-- const r = await fetch(`http://127.0.0.1:${srv.address().port}/api/chrome/hit`, { method: 'POST', headers: { origin: 'https://case.openvibe.tools', 'content-type': 'text/plain' }, body: '' });
-- assert.equal(r.status, 204);
-- assert.equal(r.headers.get('cross-origin-resource-policy'), 'cross-origin');
-- assert.equal(r.headers.get('access-control-allow-origin'), '*');
-- srv.close();
-- }
-- console.log('chrome service: all checks passed');
--})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/frame.test.js b/test/frame.test.js
nnew file mode 100644
iindex 00000000..8f52f8a6
---- /dev/null
++++ b/test/frame.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const Database = require('better-sqlite3');
++const { createFrameService, BANNED } = require('../server/frame/service');
++const { siteForHost } = require('../server/frame/sites');
++
++const db = new Database(':memory:');
++db.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
++const svc = createFrameService(db, { internalKey: 'change-me-in-production', services: {} }, null);
++
++assert.equal(siteForHost('yt.openvibe.tools').id, 'tools');
++assert.equal(siteForHost('evil.example'), null);
++assert.strictEqual(svc.ranking().at, null, 'cold start: no use counted yet (the registry\'s featured list says so)');
++let p = svc.payloadFor('openvibe.media');
++assert.deepEqual(p.nav.map(n => n.id), ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki'], 'cold start follows the base order');
++assert.equal(p.footer.legal.dmca, 'https://openvibe.media/dmca', 'legal links stay on the site\'s own domain');
++assert.ok(!p.footer.discover.some(l => l.url === 'https://openvibe.media/'), 'a site never recommends itself');
++assert.ok(p.soon.length >= 10);
++
++// Real use reorders: community gets the history, so it rises above the cold-start order.
++const ins = db.prepare("INSERT INTO user_history (user_id, service, sub, type, title, url) VALUES (?, ?, ?, 'page', 't', 'u')");
++for (let i = 0; i < 40; i++) ins.run(i % 9, 'community', null);
++for (let i = 0; i < 6; i++) ins.run(1, 'tools', 'dns');
++(async () => {
++ db.prepare("INSERT INTO frame_hits (day, host, hits) VALUES (date('now'), 'openvibe.games', 900), (date('now'), 'dns.openvibe.tools', 50), (date('now'), 'openvibe.network', 1000)").run();
++ const realFetch = global.fetch; global.fetch = async () => ({ ok: false });
++ await svc.refreshRank(); global.fetch = realFetch;
++ p = svc.payloadFor('openvibe.network');
++ assert.equal(p.nav[0].id, 'games', 'most viewed site comes first');
++ assert.deepEqual(svc.ranking().order, p.nav.map(n => n.id), 'ranking() is the navigation order (the registry features by it)');
++ assert.ok(Date.now() - svc.ranking().at < 5000, 'and says when it was counted');
++ assert.ok(p.nav.findIndex(n => n.id === 'network') > 0, 'the Network is never first on shared-service traffic alone');
++ assert.ok(BANNED.test('Totally free tools') && BANNED.test('see https://x.y') && BANNED.test('<b>hi</b>') && !BANNED.test('Go live from a browser in seconds.'), 'AI copy screen');
++ assert.equal(p.footer.ai, false);
++
++ // Footer copy comes from OpenVibe.AI only. Live's /internal/ai/site-copy is never called, and an
++ // AI failure keeps the last good copy (or the hand-written one when there has never been one).
++ let liveHits = 0; let aiMode = 'ok'; let aiAuth = null;
++ const live = http.createServer((req, res) => { liveHits++; res.statusCode = 200; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ ok: true, sites: [{ id: 'games', blurb: 'Copy from Live that must never be used here.', picks: [] }] })); });
++ const ai = http.createServer((req, res) => {
++ aiAuth = req.headers.authorization;
++ if (aiMode === 'down') { res.statusCode = 503; return res.end('{}'); }
++ res.setHeader('content-type', 'application/json');
++ res.end(JSON.stringify({ run: { status: 'succeeded', route: { key: 'site-copy', version: 1 }, provenance: { origin: 'ai', workflow: 'network.site_copy', model: 'test-model', route: 'site-copy' }, output: { sites: [{ id: 'games', blurb: 'Play Scraplandia and the shared pixel canvas in a tab.', picks: ['site:live'] }] } } }));
++ });
++ await Promise.all([live, ai].map(s => new Promise(r => s.listen(0, '127.0.0.1', r))));
++ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const cfg = { internalKey: 'a-real-internal-key', services: { live: { internalUrl: `http://127.0.0.1:${live.address().port}` } } };
++ const aiUrl = `http://127.0.0.1:${ai.address().port}`;
++ const db2 = new Database(':memory:');
++ const copySvc = (d) => createFrameService(d, cfg, null, { privateKey, issuer: 'https://openvibe.network', aiUrl });
++
++ let s2 = copySvc(db2);
++ aiMode = 'down';
++ await s2.refreshCopy();
++ p = s2.payloadFor('openvibe.games');
++ assert.equal(p.footer.ai, false, 'AI down and no earlier copy: the hand-written blurb');
++ assert.equal(p.footer.blurb, siteForHost('openvibe.games').what);
++ assert.equal(liveHits, 0, 'no fallback to Live when AI fails');
++
++ aiMode = 'ok';
++ await s2.refreshCopy();
++ assert.match(aiAuth, /^Bearer ey/, 'AI is called with the self-signed service token');
++ p = s2.payloadFor('openvibe.games');
++ assert.equal(p.footer.ai, true);
++ assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.');
++ assert.equal(JSON.parse(db2.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get().value).model, 'test-model', 'the model is read from run.provenance.model (ai.run@1)');
++
++ aiMode = 'down';
++ await s2.refreshCopy();
++ p = s2.payloadFor('openvibe.games');
++ assert.equal(p.footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'AI failure keeps the last good copy');
++ s2 = copySvc(db2);
++ assert.equal(s2.payloadFor('openvibe.games').footer.blurb, 'Play Scraplandia and the shared pixel canvas in a tab.', 'and it survives a restart');
++
++ // Without a signing key there is no AI client, and still no Live call.
++ await createFrameService(new Database(':memory:'), cfg, null).refreshCopy();
++ assert.equal(liveHits, 0, 'Live is never asked for copy');
++ live.close(); ai.close();
++ // The page-view beacon (navigator.sendBeacon, a no-cors request) is readable cross-origin, so no
++ // site's console reports it as blocked.
++ {
++ const express = require('express');
++ const app = express(); app.use('/api/frame', svc.router);
++ const srv = await new Promise(r => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const r = await fetch(`http://127.0.0.1:${srv.address().port}/api/frame/hit`, { method: 'POST', headers: { origin: 'https://case.openvibe.tools', 'content-type': 'text/plain' }, body: '' });
++ assert.equal(r.status, 204);
++ assert.equal(r.headers.get('cross-origin-resource-policy'), 'cross-origin');
++ assert.equal(r.headers.get('access-control-allow-origin'), '*');
++ srv.close();
++ }
++ {
++ // Renamed from chrome_cache/chrome_hits: an existing database keeps its rows.
++ const old = new Database(':memory:');
++ old.exec("CREATE TABLE user_history (id INTEGER PRIMARY KEY, user_id INT, service TEXT, sub TEXT, type TEXT, title TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)");
++ old.exec("CREATE TABLE chrome_cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP); INSERT INTO chrome_cache (key, value) VALUES ('copy', '{\"x\":1}')");
++ old.exec("CREATE TABLE chrome_hits (day TEXT NOT NULL, host TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (day, host)); INSERT INTO chrome_hits VALUES ('2026-09-23', 'openvibe.live', 7)");
++ createFrameService(old, { internalKey: 'k', services: {} }, null);
++ assert.equal(old.prepare("SELECT hits FROM frame_hits WHERE host = 'openvibe.live'").get().hits, 7);
++ assert.equal(old.prepare("SELECT value FROM frame_cache WHERE key = 'copy'").get().value, '{"x":1}');
++ assert.equal(old.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name IN ('chrome_cache', 'chrome_hits')").get().n, 0);
++ createFrameService(old, { internalKey: 'k', services: {} }, null); // idempotent
++ }
++ console.log('frame service: all checks passed');
++})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex 1ab20b46..a2a42664 100644
---- a/test/registry-ecosystem.test.js
++++ b/test/registry-ecosystem.test.js
@@@ -106,7 +106,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
const env = internalFromEnv({ OV_EVENTS_INTERNAL_URL: 'http://127.0.0.1:4300/', OV_AI_INTERNAL_URL: 'http://localhost:4700', OV_LIVE_INTERNAL_URL: 'https://live.example.com', OV_NOPE_INTERNAL_URL: 'http://127.0.0.1:1' });
assert.deepStrictEqual(env.overrides, { events: 'http://127.0.0.1:4300', ai: 'http://localhost:4700' }, 'loopback URLs of known services override');
assert.deepStrictEqual(env.ignored, ['OV_LIVE_INTERNAL_URL'], 'a non-loopback address is ignored and reported');
-- const { SITES } = require('../server/chrome/sites');
++ const { SITES } = require('../server/frame/sites');
for (const site of SITES) {
const m = contracts.services.get(site.service);
if (m && m.publicOrigin) assert.strictEqual(site.host, new URL(m.publicOrigin).hostname, `${site.id}: host from the manifest`);
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex 32bdd099..2e25744a 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -1,6 +1,6 @@
'use strict';
// Exposure overlay (server/registry/exposure.js): every service has one honest state, and the
--// registry, the status page and the chrome nav all say the same thing. A service that runs on
++// registry, the status page and the Frame's nav all say the same thing. A service that runs on
// loopback while its public domain still serves a placeholder is never "up" at that domain.
// node test/registry-exposure.test.js
const assert = require('assert');
@@@ -10,7 +10,7 @@ const contracts = require('openvibe-contracts');
const exposure = require('../server/registry/exposure');
const { createEcosystemRegistry } = require('../server/registry/ecosystem');
const { createStatusRoutes } = require('../server/status/routes');
--const { SITES } = require('../server/chrome/sites');
++const { SITES } = require('../server/frame/sites');
// Observed 2026-09-23 with curl against every public domain. Change a row only after the domain
// stops answering "this page is a placeholder" (or a library ships a new release).
@@@ -114,7 +114,7 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
assert.ok(/<tr id="svc-news">[\s\S]*?openvibe\.news: not this service yet/.test(html));
assert.ok(/<tr id="svc-live">[\s\S]*?>Up<\/span>/.test(html), 'a public service still reads Up');
-- // 3. Chrome: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
++ // 3. The Frame: the nav lists only sites whose domain serves the service; the rest are soon with a reason.
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki']);
for (const s of SITES) {