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)
Changes
@@@ -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, thedescriptor 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 arow 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 callit), `library`, `repository`, `planned`. `GET /api/v1/registry/featured` lists the open sites whose lastcheck was up or degraded, in the navigation's usage order (7-day page views plus 14-day signed-inhistory, recounted every 30 minutes), with `ranked_at` and `stale`; the hub is not listed and nothing@@@ -123,21 +123,23 @@ canonical URL per page; titles ≤ 60 chars, descriptions 120–160; JSON-LD onsitemaps 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 lastscore. 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` servicetoken, `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, nocost 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')`)@@@ -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"@@@ -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"}@@@ -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; }}ddeleted file mode 100644@@@ -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 };ddeleted file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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;}@@@ -496,12 +496,15 @@ app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarSapp.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);@@@ -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>@@@ -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');@@@ -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() {@@@ -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>@@@ -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>ddeleted file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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`);@@@ -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.jsconst 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: [], chassert.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) {
@@ -408,7 +408,7 @@ Google-style account management supporting up to 5 accounts:
@@ -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,
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
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
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.
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'
- 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
`eventsProduced`/`eventsConsumed` and the payload contracts, with producers, consumers, the matching
@@ -421,7 +421,7 @@ Google-style account management supporting up to 5 accounts:
@@ -421,7 +421,7 @@ Google-style account management supporting up to 5 accounts:
from its own Events consumer.
from its own Events consumer.
Payload schemas are served at their `$id` (`/contracts/events/payloads/<type>.v1.json`).
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:
- `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
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
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
history, recounted every 30 minutes), with `ranked_at` and `stale`; the hub is not listed and nothing
@@ -123,21 +123,23 @@ canonical URL per page; titles ≤ 60 chars, descriptions 120–160; JSON-LD on
@@ -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",
sitemaps list canonical hosts only; `/llms.txt` on every site; never the words "free", "$0",
"no ads" as claims about the platform.
"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:
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`,
`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).
`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,
- 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.
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
- 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.
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
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
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).
(30 min, refreshed in the background). Navbar options: `networkLinks` (`false` | count, default 4).
## 8. Legal (`require('openvibe-shared/legal')`)
## 8. Legal (`require('openvibe-shared/legal')`)
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"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.35.0",
"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-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",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
}
}
@@ -1490,9 +1490,9 @@
@@ -1490,9 +1490,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"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.35.0",
"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-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",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
}
}
@@ -35,12 +35,12 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config)
@@ -35,12 +35,12 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config)
router.use(requireAuth, requireAdmin);
router.use(requireAuth, requireAdmin);
// Games and Media keep no analytics of their own. The shared navbar's anonymous page-view count
// 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' };
const BEACON_HOSTS = { games: 'openvibe.games', media: 'openvibe.media', tools: 'openvibe.tools' };
function beaconFallback(svc, days) {
function beaconFallback(svc, days) {
try {
try {
const host = BEACON_HOSTS[svc.name]; if (!host) return null;
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: {} } };
return { ok: true, analytics: { summary: { total_pageviews: row.n, source: 'navbar page-view count (no service analytics)' }, realtime: {} } };
} catch { return null; }
} catch { return null; }
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,218 +0,0 @@
@@ -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 };
deleted file mode 100644
deleted file mode 100644
@@ -1,57 +0,0 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,223 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
@@ -77,7 +77,7 @@ function render() {
@@ -77,7 +77,7 @@ function render() {
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;
if (cache.key === key) return cache;
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 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) + '"' };
cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return cache;
return cache;
}
}
@@ -496,12 +496,15 @@ app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarS
@@ -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.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub);
app.locals.avatarService = avatarService;
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.
// /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).
// Tool domains: public list for the Tools gateway, owner-only management (docs/shared-contracts.md §1).
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
const toolDomains = require('./domains/routes').createDomainRoutes(db, requireAuth);
@@ -24,7 +24,7 @@ function page() {
@@ -24,7 +24,7 @@ function page() {
let nav = '';
let nav = '';
try { footer = require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' }); } catch { /* optional */ }
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 { 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>
cached = `<!DOCTYPE html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -36,14 +36,14 @@
@@ -36,14 +36,14 @@
* operator sets it and creates the subscriptions.
* 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
* 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
* 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.
* rich_content.context.planned_url and the notification has no click-through yet.
*/
*/
const express = require('express');
const express = require('express');
const { http, ids } = require('openvibe-contracts');
const { http, ids } = require('openvibe-contracts');
const { parseDelivery, createInbox } = require('openvibe-sdk/events');
const { parseDelivery, createInbox } = require('openvibe-sdk/events');
-const { siteForHost } = require('../chrome/sites');
+const { siteForHost } = require('../frame/sites');
const streamLive = require('./stream-live');
const streamLive = require('./stream-live');
const { LiveFollowersError } = require('./live-followers');
const { LiveFollowersError } = require('./live-followers');
@@ -36,7 +36,7 @@ const contractsPkg = require('openvibe-contracts/package.json');
@@ -36,7 +36,7 @@ const contractsPkg = require('openvibe-contracts/package.json');
const { exposureOf, publicOriginOf, libraries } = require('./exposure');
const { exposureOf, publicOriginOf, libraries } = require('./exposure');
const { buildTopics, eventsOf } = require('./topics');
const { buildTopics, eventsOf } = require('./topics');
const { driftOf } = require('./versions');
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).
// 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
// TODO(plan §5.1 "contract manifests plus validated environment overrides"): registry.service-manifest@1 has
@@ -110,9 +110,9 @@ function internalFromEnv(env = process.env) {
@@ -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
// 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 = [
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: '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: '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' },
{ id: 'repository', name: 'Repositories', rule: 'exposure repository: code with CI, neither released nor run' },
@@ -256,7 +256,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -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,
* 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.
* over 14, recounted every 30 minutes). The hub does not feature itself. Nothing is picked by hand.
*/
*/
function featured() {
function featured() {
@@ -128,7 +128,7 @@ ${CSS}
@@ -128,7 +128,7 @@ ${CSS}
</head>
</head>
<body>
<body>
<div id="navbar-mount"></div>
<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">
<main class="st" id="main">
<h1>Service status</h1>
<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>
<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>
@@ -127,7 +127,7 @@ a{color:var(--accent)}
@@ -127,7 +127,7 @@ a{color:var(--accent)}
</head>
</head>
<body>
<body>
<div id="navbar-mount"></div>
<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">
<main class="up" id="main">
<h1>${esc(title)}</h1>
<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>
<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>
deleted file mode 100644
deleted file mode 100644
@@ -1,95 +0,0 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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); });
@@ -106,7 +106,7 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -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' });
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.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');
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) {
for (const site of SITES) {
const m = contracts.services.get(site.service);
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`);
if (m && m.publicOrigin) assert.strictEqual(site.host, new URL(m.publicOrigin).hostname, `${site.id}: host from the manifest`);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// Exposure overlay (server/registry/exposure.js): every service has one honest state, and the
// 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.
// loopback while its public domain still serves a placeholder is never "up" at that domain.
// node test/registry-exposure.test.js
// node test/registry-exposure.test.js
const assert = require('assert');
const assert = require('assert');
@@ -10,7 +10,7 @@ const contracts = require('openvibe-contracts');
@@ -10,7 +10,7 @@ const contracts = require('openvibe-contracts');
const exposure = require('../server/registry/exposure');
const exposure = require('../server/registry/exposure');
const { createEcosystemRegistry } = require('../server/registry/ecosystem');
const { createEcosystemRegistry } = require('../server/registry/ecosystem');
const { createStatusRoutes } = require('../server/status/routes');
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
// 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).
// 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
@@ -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-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');
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);
const open = SITES.filter(s => s.status === 'open').map(s => s.id);
assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki']);
assert.deepStrictEqual(open, ['live', 'tools', 'community', 'games', 'media', 'network', 'codes', 'blog', 'wiki']);
for (const s of SITES) {
for (const s of SITES) {