openvibers/openvibe.network Commits

The Node's own front door at node.openvibe.network, and the navbar's one accent (#593)

OP OpenVibers committed 7 days ago · ff9cacbb64 · 12 files, +542 −11

* The Node's own front door at node.openvibe.network, and the navbar's one accent

* Network: the Node's release page joins the size budget and the SSRF ratchet

* Network: the Node's own origin may ask the network's API

* Merge remote-tracking branch 'origin/main' into feature/node-home

# Conflicts:
#	test/perf-budget.test.js

* Node page review: the computer glyph, the site font, a readable Install button, and S1 is live

The navbar's Node item used the house glyph (home); it is the computer now, on the home page, the wallet pages and the
Node page. The Node page loaded only kit.css and fell back to a serif font: it sets Network's own font, background and
text colours. '.np a' coloured the kit's primary Install button blue on blue: links only, never .ovk-btn. The agent
desk card no longer says 'Coming with S1': S1 is live, and it links to the agent streams doc.

* The Node item's icon is fa-desktop, which the OpenVibe glyphs draw (fa-computer has no glyph)

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 4c997941..bafde80f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.117.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
"uuid": "^11.1.0",
@@@ -1766,9 +1766,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.115.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
-- "integrity": "sha512-xmdNHGlZSJdGoeruXoz6ZtDicY5472mhwo7uuv0Ct1HwyZdyhv9Qn1GLPgz0heues+0w6Q759dXMwHZ52zuDbg==",
++ "version": "4.117.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.117.0",
++ "integrity": "sha512-Rdphz8Wyb5bCbGtaULsPD5dODFBCNYYzIgeZMTPM43gF7IR7/j/CD6HddZf3jzieT4dndwcaqdWcpLCStKJNVQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 812e6ff0..17a0b428 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.298.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.117.0",
"pg": "^8.23.0",
"qrcode": "1.5.4",
"uuid": "^11.1.0",
ddiff --git a/public/index.html b/public/index.html
iindex 0b3ae238..536d007b 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -293,7 +293,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
function initNavbar(user) {
if (typeof OpenVibeNavbar !== 'undefined') {
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: token || null, user: user || null, menu: { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] }, links: [{ label: 'My Account', href: '/my' }, { label: 'Wallet', href: '/wallet' }, { label: 'Themes', href: '/themes' }], networkLinks: 5 });
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: token || null, user: user || null, menu: { before: [{ id: 'ov-wallet', label: 'Wallet', short: 'Wallet', icon: 'fa-wallet', href: 'https://openvibe.network/wallet' }] }, links: [{ label: 'Node', href: 'https://node.openvibe.network', icon: 'fa-desktop', featured: true }, { label: 'My Account', href: '/my' }, { label: 'Wallet', href: '/wallet' }, { label: 'Themes', href: '/themes' }], networkLinks: 5 });
}
if (user) {
document.getElementById('hero-guest-ctas').style.display = 'none';
ddiff --git a/public/js/wallet/common.js b/public/js/wallet/common.js
iindex 63e64cd1..b6fa4dd3 100644
---- a/public/js/wallet/common.js
++++ b/public/js/wallet/common.js
@@@ -96,7 +96,7 @@
root.OpenVibeNavbar.init({
service: 'network', notificationsRealtime: true, token: token(), user,
menu: WALLET_MENU,
-- links: [{ label: 'Wallet', href: '/wallet' }, { label: 'My Account', href: '/my' }],
++ links: [{ label: 'Node', href: 'https://node.openvibe.network', icon: 'fa-desktop', featured: true }, { label: 'Wallet', href: '/wallet' }, { label: 'My Account', href: '/my' }],
});
} catch { /* the page works without it */ }
}
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 08c9eb78..702055e3 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -487,6 +487,13 @@ function llmsTxt() {
{ title: 'Sites', links: OPEN.map((site) => ({ title: siteName(site), url: `https://${site.host}/`, note: site.what })) },
// A person's account ID: the endpoint an agent needs when it works for someone.
{ title: 'Your account ID', links: [ACCOUNT_ID_LINK] },
++ // The Node, on its own subdomain: the piece the network is built around, with its own map.
++ { title: 'The Node', links: [
++ { title: 'OpenVibe Node: your computer on the network', url: 'https://node.openvibe.network/',
++ note: 'one program on your computer, server, phone or robot: run your agents and code on it, offer your models or GPU to the network and earn credits, drive robots, and keep your memory read-only' },
++ { title: 'Install the Node', url: 'https://node.openvibe.network/install',
++ note: 'the installer of the latest release, as a script to run: curl -fsSL https://node.openvibe.network/install | sh' },
++ ] },
// The JSON an agent came for is a main section, not something to skip (AGT-5 review).
{ title: 'Machine-readable', links: [
{ title: 'Open stats (JSON): every site\'s numbers', url: 'https://openvibe.network/api/v1/stats' },
ddiff --git a/server/index.js b/server/index.js
iindex 8c3e0b17..15f679d5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -224,6 +224,9 @@ function buildAllowedOriginsSet() {
// Hard-coded OpenVibe defaults as baseline (survive registry reset/failure)
for (const o of [
'https://openvibe.network',
++ // The Node's own front door (the owner, 2026-11-06): node.openvibe.network is served by Network, and its
++ // page asks the network's own API (the account's look, the navbar's frame, the shared font) from that origin.
++ 'https://node.openvibe.network',
'https://openvibe.live', 'https://www.openvibe.live',
'https://openvibe.tools',
'https://openvibe.games', 'https://www.openvibe.games', 'https://play.openvibe.games',
@@@ -638,6 +641,9 @@ const openStats = require('./stats/open').createOpenStats({
db, selfToken, release, statusRows: () => require('./status/routes').rows(ecosystem),
prometheusUrl: config.prometheusUrl || process.env.PROMETHEUS_URL, liveUrl: process.env.OV_LIVE_INTERNAL_URL,
});
++// The Node's own front door on its own subdomain (node.openvibe.network): the Host header decides. It mounts
++// before the agent layer, so the Node's page is not answered by Network's home renderer.
++app.use(require('./node/routes').createNodeRoutes({ release: release.release }));
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the public pages as Markdown twins. It
// answers their HTML as well, with the discovery tags in the head, so it mounts before the routes for those pages.
require('openvibe-shared/agent-layer').mount(app, {
ddiff --git a/server/node/page.js b/server/node/page.js
nnew file mode 100644
iindex 00000000..a3047ec4
---- /dev/null
++++ b/server/node/page.js
@@@ -0,0 +1,176 @@
++'use strict';
++// node.openvibe.network: the Node's own front door. What the Node is (your computer on the network), what it does
++// today, how to install it on each OS, and what it never does. The chrome is Network's own; the page is the Node's.
++// The version, the release date and the checksums come from the Node's latest GitHub release (server/node/release.js).
++
++const kit = require('openvibe-shared/kit');
++
++const HOST = 'node.openvibe.network';
++const SITE = `https://${HOST}`;
++const DOCS = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs';
++const INSTALL_LINE = 'curl -fsSL https://node.openvibe.network/install | sh';
++
++// What the Node does today, each with where it happens. A page that promises tomorrow is a page that lies today.
++const DOES = [
++ { icon: 'code', title: 'Run your agents and code on your machine', body: 'Codes starts a coding task from any browser and this machine runs it, as an unprivileged user in a sandbox.', href: 'https://openvibe.codes/run', cta: 'OpenVibe.Codes' },
++ { icon: 'spark', title: 'Offer your models or your GPU and earn credits', body: 'Exchange lists what your machine serves; you choose the models, the hours, the price and who may use it.', href: 'https://openvibe.exchange/sell', cta: 'OpenVibe.Exchange' },
++ { icon: 'game', title: 'Drive robots', body: 'Bot drives what you approve, at the level you set, with a kill switch on the machine and a deadline on every command.', href: 'https://openvibe.bot', cta: 'OpenVibe.Bot' },
++ { icon: 'live', title: 'Stream an agent\u2019s desk', body: 'Live carries your machine\u2019s screen to a channel, private until you choose: under a second behind, with a curtain that hides secret screens.', href: 'https://openvibe.live/docs/agent-streams', cta: 'Agent streams on OpenVibe.Live' },
++ { icon: 'lock', title: 'Keep your memory, read-only', body: 'The collectors read what your coding tools remember: local, redacted, and never uploaded unless you say so.', href: `${DOCS}/memory.md`, cta: 'The memory docs' },
++];
++
++// What the Node never does. The two sentences people ask for most.
++const NEVER = [
++ 'It cannot reach your computer without your pairing: nobody drives it, reads it or runs a command on it until you approved the machine.',
++ 'Nothing runs that you did not turn on: no task, no camera, no driver and no upload starts on its own.',
++ 'It opens no port: it connects out, so it works behind any home router.',
++ 'It keeps a local kill switch: one command stops everything it drives, and it stays stopped.',
++];
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++/** The install block per OS. The one-liner is for Linux and macOS; Windows gets the release asset and the pair step. */
++function installBlock(release) {
++ const version = esc(release.version || '');
++ const exe = `https://github.com/OpenVibers/OpenVibe.Node/releases/download/${release.version || 'latest'}/openvibe-node-windows-amd64.exe`;
++ return `<div class="np-installs">
++<div class="np-install">
++<h3>Linux and macOS</h3>
++<p class="np-fine">One command. It finds your system, downloads the release, checks it against SHA256SUMS, sets up the service and shows a code to approve.</p>
++${kit.snippet({ code: INSTALL_LINE, id: 'np-install-sh', label: 'Copy the install command' })}
++<p class="np-fine">On a Raspberry Pi or another robot, the same command finds the kit and its driver.</p>
++</div>
++<div class="np-install">
++<h3>Windows</h3>
++<p class="np-fine">Download the binary, then in an Administrator prompt:</p>
++${kit.snippet({ code: 'openvibe-node pair <CODE>', id: 'np-install-win', label: 'Copy the pair command' })}
++<p class="np-fine"><a href="${esc(exe)}">openvibe-node-windows-amd64.exe</a> from the ${version ? esc(version) : 'latest'} release.</p>
++</div>
++</div>`;
++}
++
++/** The release facts: the version, when it came out, the checksums and the release page. */
++function releaseBlock(release) {
++ const { versionLabel, dateLabel } = require('./release');
++ const version = versionLabel(release);
++ const date = dateLabel(release);
++ const rows = [
++ kit.row({ title: 'Latest version', sub: release.version || '', meta: date || '', metaIso: date || undefined }),
++ kit.row({ href: release.checksumsUrl || '#', title: 'SHA256SUMS', sub: 'the checksums of every file in the release, to check the download against' }),
++ kit.row({ href: release.url || '#', title: 'The release on GitHub', sub: 'every asset, and the notes of that release' }),
++ ];
++ return `<section class="np-sec" aria-labelledby="h-release"><h2 id="h-release">The latest release</h2>
++<p class="np-fine">${version ? `Version <strong>${esc(version)}</strong>` : 'The latest version'}${date ? `, published ${esc(date)}` : ''}. The install command below always installs that release.</p>
++${kit.list(rows, { flush: true })}
++${release.stale ? '<p class="np-fine np-warn">GitHub could not be reached just now, so this is the last release we read.</p>' : ''}
++</section>`;
++}
++
++/** The page. `release` is what server/node/release.js answered. */
++function render({ release = {}, releaseId } = {}) {
++ const title = 'OpenVibe Node: your computer on the network';
++ const desc = 'OpenVibe Node runs on your computer, server, phone or robot: it runs your agents and code, offers your models or GPU to the network, drives robots, and keeps your memory read-only.';
++ const ld = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'OpenVibe Node', applicationCategory: 'DeveloperApplication',
++ operatingSystem: 'Linux, macOS, Windows', description: desc, url: SITE,
++ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
++ publisher: { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe' } };
++ const does = DOES.map((d) => `<article class="np-does">
++${kit.icon(d.icon)}
++<h3>${esc(d.title)}${d.soon ? ' <span class="np-soon">coming with S1</span>' : ''}</h3>
++<p>${esc(d.body)}</p>
++<p class="np-fine"><a href="${esc(d.href)}">${esc(d.cta)}</a></p>
++</article>`).join('');
++
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>${esc(title)}</title>
++<meta name="description" content="${esc(desc)}">
++<link rel="canonical" href="${SITE}/">
++${require('../boost').meta(releaseId)}
++${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
++<meta name="color-scheme" content="dark light">
++<script src="/shared/theme-loader.js" defer></script>
++<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, '\\u003c')}</script>
++<link rel="stylesheet" href="/shared/kit.css">
++<style>
++html,body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0}
++${CSS}
++</style>
++</head>
++<body>
++<div id="navbar-mount"></div>
++<main class="np" id="main">
++<header class="np-head">
++<p class="np-kicker">OpenVibe Node</p>
++<h1>Your computer on the network</h1>
++<p class="np-lede">One program, <code>openvibe-node</code>, on your computer, your server, your phone or your robot. It connects out to OpenVibe, needs no open ports, and keeps a local kill switch. You decide what it may do; nothing runs that you did not turn on.</p>
++<p class="np-cta">${kit.button({ href: '/install', label: 'Install the Node', kind: 'primary' })} ${kit.button({ href: `${DOCS}/install.md`, label: 'The install guide', kind: 'quiet' })}</p>
++</header>
++
++<section class="np-sec" aria-labelledby="h-does"><h2 id="h-does">What it does today</h2>
++<div class="np-grid">${does}</div>
++</section>
++
++<section class="np-sec" aria-labelledby="h-install"><h2 id="h-install">Install it</h2>
++${installBlock(release)}
++</section>
++
++${releaseBlock(release)}
++
++<section class="np-sec" aria-labelledby="h-never"><h2 id="h-never">What it never does</h2>
++<ul class="np-never">${NEVER.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>
++</section>
++
++<section class="np-sec" aria-labelledby="h-docs"><h2 id="h-docs">The docs</h2>
++<ul class="np-docs">
++<li><a href="${DOCS}/install.md">Install</a> &mdash; every OS, every option, and what the installer does.</li>
++<li><a href="${DOCS}/computer.md">Your computer for your agents</a> &mdash; the computer plugin, its levels and the live screen.</li>
++<li><a href="${DOCS}/worker.md">A machine that runs your jobs</a> &mdash; the sandbox the job runner uses.</li>
++<li><a href="${DOCS}/protocol.md">The protocol</a> &mdash; the wire format between the Node and OpenVibe.Bot.</li>
++<li><a href="${DOCS}/memory.md">Your memory</a> &mdash; what the collectors read and how it is uploaded.</li>
++</ul>
++<p class="np-fine">The source is on <a href="https://github.com/OpenVibers/OpenVibe.Node">GitHub</a>. The installer also lives at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working.</p>
++</section>
++</main>
++${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
++<script src="/shared/navbar.js" defer></script>
++${require('../boost').SCRIPT}
++<script src="/shared/kit-web.js" defer></script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',links:[{label:'Node',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
++</body>
++</html>`;
++}
++
++const CSS = `
++.np{max-width:1000px;margin:0 auto;padding:32px 20px 72px}
++.np-kicker{margin:0 0 6px;font-size:.78rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent,#3b82f6)}
++.np h1{margin:0 0 12px;font-size:clamp(1.7rem,5vw,2.6rem);letter-spacing:-.02em;line-height:1.12}
++.np-lede{margin:0 0 18px;max-width:760px;color:var(--text-secondary,#96a7c2);line-height:1.65;font-size:1.03rem}
++.np-lede code,.np code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
++.np-cta{display:flex;flex-wrap:wrap;gap:10px;margin:0}
++.np-sec{margin-top:44px}
++.np-sec h2{margin:0 0 14px;font-size:1.3rem;letter-spacing:-.01em}
++.np-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
++.np-does{padding:18px;border-radius:16px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);display:flex;flex-direction:column;gap:6px}
++.np-does svg{width:22px;height:22px;color:var(--accent,#3b82f6)}
++.np-does h3{margin:2px 0 0;font-size:1rem;line-height:1.35}
++.np-does p{margin:0;color:var(--text-secondary,#96a7c2);font-size:.92rem;line-height:1.55}
++.np-soon{display:inline-block;margin-left:6px;padding:2px 8px;border-radius:999px;font-size:.68rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#7386a3);border:1px solid var(--border,#1f2d47);vertical-align:middle}
++.np-installs{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}
++.np-install{padding:18px;border-radius:16px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47)}
++.np-install h3{margin:0 0 8px;font-size:1rem}
++.np-install .ovk-snip{margin:12px 0}
++.np-fine{margin:10px 0 0;font-size:.88rem;color:var(--text-muted,#7386a3);line-height:1.55}
++.np-warn{color:#f0b429}
++.np-never,.np-docs{list-style:none;margin:0;padding:0;display:grid;gap:8px;max-width:820px}
++.np-never li{position:relative;padding:11px 14px 11px 42px;border-radius:12px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);color:var(--text-secondary,#96a7c2);font-size:.93rem;line-height:1.55}
++.np-never li::before{content:"";position:absolute;left:15px;top:16px;width:13px;height:13px;border-radius:50%;background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);box-shadow:inset 0 0 0 2px var(--accent,#3b82f6)}
++.np-docs li{padding:11px 14px;border-radius:12px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);font-size:.93rem;line-height:1.55}
++.np a:not(.ovk-btn){color:var(--accent,#3b82f6)}
++@media (max-width:640px){.np{padding:22px 16px 52px}}
++`;
++
++module.exports = { render, HOST, SITE, DOCS, INSTALL_LINE, DOES, NEVER };
ddiff --git a/server/node/release.js b/server/node/release.js
nnew file mode 100644
iindex 00000000..03d79ce6
---- /dev/null
++++ b/server/node/release.js
@@@ -0,0 +1,88 @@
++'use strict';
++// The Node's latest release, read from GitHub (OpenVibers/OpenVibe.Node). The page shows the version and the
++// checksums; /install answers with the same release's installer. The answer is cached for ten minutes, so a
++// visitor never waits on GitHub, and when GitHub cannot be reached the last good answer is used; a cold start
++// without GitHub falls back to the release links that always resolve (the "latest" ones).
++
++const REPO = 'OpenVibers/OpenVibe.Node';
++const API = `https://api.github.com/repos/${REPO}/releases/latest`;
++const TTL_MS = 10 * 60 * 1000; // ten minutes
++const TIMEOUT_MS = 8000;
++const LATEST = `https://github.com/${REPO}/releases/latest`;
++
++// What the page says when GitHub cannot be reached and nothing was cached: no version (the page says "the latest
++// release" instead of naming one that may be old), and the links that always resolve to the newest release.
++const FALLBACK = {
++ version: '',
++ publishedAt: '',
++ url: LATEST,
++ installUrl: `https://github.com/${REPO}/releases/latest/download/install.sh`,
++ checksumsUrl: `https://github.com/${REPO}/releases/latest/download/SHA256SUMS`,
++};
++
++let cached = null; // { at, data }
++let inflight = null; // one fetch at a time
++
++/** The release fields the page and /install use. */
++function shape(json) {
++ const tag = String(json.tag_name || '').trim();
++ const assets = Array.isArray(json.assets) ? json.assets : [];
++ const asset = (name) => assets.find((a) => a && a.name === name);
++ const install = asset('install.sh');
++ const sums = asset('SHA256SUMS');
++ return {
++ version: tag,
++ publishedAt: String(json.published_at || ''),
++ url: String(json.html_url || (tag ? `https://github.com/${REPO}/releases/tag/${tag}` : LATEST)),
++ installUrl: String((install && install.browser_download_url) || (tag ? `https://github.com/${REPO}/releases/download/${tag}/install.sh` : FALLBACK.installUrl)),
++ checksumsUrl: String((sums && sums.browser_download_url) || (tag ? `https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS` : FALLBACK.checksumsUrl)),
++ };
++}
++
++/** Fetch the latest release. Returns the cached answer while it is fresh, and never throws. */
++async function latest({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
++ if (cached && now() - cached.at < TTL_MS) return { ...cached.data, cached: true };
++ if (inflight) return inflight;
++ inflight = (async () => {
++ try {
++ const controller = typeof AbortController === 'function' ? new AbortController() : null;
++ const timer = controller ? setTimeout(() => controller.abort(), TIMEOUT_MS) : null;
++ let res;
++ try {
++ res = await fetchImpl(API, {
++ signal: controller ? controller.signal : undefined,
++ headers: { accept: 'application/vnd.github+json', 'user-agent': 'openvibe-network' },
++ });
++ } finally {
++ if (timer) clearTimeout(timer);
++ }
++ if (!res || !res.ok) throw new Error(`GitHub releases API answered ${res && res.status}`);
++ const data = shape(await res.json());
++ if (!data.version) throw new Error('GitHub releases API answered without a tag');
++ cached = { at: now(), data };
++ return { ...data, cached: false };
++ } catch {
++ // GitHub is down or slow: the last good answer, or the written-down one. The page still works.
++ return { ...(cached ? cached.data : FALLBACK), cached: true, stale: true };
++ } finally {
++ inflight = null;
++ }
++ })();
++ return inflight;
++}
++
++/** The version without the leading v, for a sentence. */
++function versionLabel(release) {
++ return String((release && release.version) || '').replace(/^v/, '');
++}
++
++/** The release date, the day it was published. */
++function dateLabel(release) {
++ const iso = String((release && release.publishedAt) || '');
++ if (!iso) return '';
++ const d = new Date(iso);
++ if (Number.isNaN(d.getTime())) return '';
++ return d.toISOString().slice(0, 10);
++}
++
++module.exports = { latest, versionLabel, dateLabel, REPO, API, TTL_MS, FALLBACK, LATEST };
ddiff --git a/server/node/routes.js b/server/node/routes.js
nnew file mode 100644
iindex 00000000..4d8be098
---- /dev/null
++++ b/server/node/routes.js
@@@ -0,0 +1,110 @@
++'use strict';
++// node.openvibe.network, served by Network (the owner, 2026-11-06: the Node gets its own subdomain, "because it
++// looks nicer"). The Host header decides: on this host '/' is the Node's page, '/install' answers with the
++// installer of the latest release, and the page has its Markdown twin and its llms.txt, like Network's other pages.
++//
++// The route is mounted before the agent layer, so the Node's page is not answered by Network's home renderer.
++
++const { markdownTwin, linkHeaderTags } = require('openvibe-shared/agent-layer');
++const page = require('./page');
++const release = require('./release');
++
++const HOST = page.HOST;
++const SITE = page.SITE;
++
++/** The request's host, lowercased, without the port. */
++function hostOf(req) {
++ return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
++}
++
++/** The Node's llms.txt: what the page is, how to install it, and where the docs are. */
++function llmsTxt() {
++ return [
++ '# OpenVibe Node',
++ '',
++ '> OpenVibe Node is one program (openvibe-node) that runs on your computer, server, phone or robot. It connects out to OpenVibe, needs no open ports, keeps a local kill switch, and does nothing you did not turn on.',
++ '',
++ '## The page',
++ '',
++ `- [OpenVibe Node: your computer on the network](${SITE}/): what it is, what it does today (run your agents and code, offer your models or GPU and earn credits, drive robots, stream an agent's desk, keep your memory read-only), how to install it on each OS, and what it never does`,
++ '',
++ '## Install',
++ '',
++ `- [Install the Node](${SITE}/install): the latest release's installer, as a script to run`,
++ '',
++ '```sh',
++ page.INSTALL_LINE,
++ '```',
++ '',
++ '## The docs',
++ '',
++ `- [Install](${page.DOCS}/install.md): every OS, every option, and what the installer does`,
++ `- [Your computer for your agents](${page.DOCS}/computer.md): the computer plugin, its levels and the live screen`,
++ `- [A machine that runs your jobs](${page.DOCS}/worker.md): the sandbox the job runner uses`,
++ `- [The protocol](${page.DOCS}/protocol.md): the wire format between the Node and OpenVibe.Bot`,
++ `- [Your memory](${page.DOCS}/memory.md): what the collectors read and how it is uploaded`,
++ '- [The source on GitHub](https://github.com/OpenVibers/OpenVibe.Node)',
++ '',
++ ].join('\n');
++}
++
++/**
++ * The Node's routes. Returns null for a request that is not for this host, so the router can be mounted early
++ * and every other host falls through to Network's own pages.
++ */
++function createNodeRoutes({ releaseId } = {}) {
++ const router = require('express').Router();
++
++ router.use(async (req, res, next) => {
++ if (hostOf(req) !== HOST) return next();
++ const url = new URL(req.url, SITE);
++ const path = url.pathname;
++ const isTwin = path === '/index.md' || path.endsWith('.md') || url.searchParams.get('format') === 'markdown'
++ || (req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');
++
++ // The installer: the latest release's own asset, so `curl -fsSL https://node.openvibe.network/install | sh`
++ // installs that release. The same cached answer the page shows.
++ if (path === '/install') {
++ let rel;
++ try { rel = await release.latest(); } catch { rel = release.FALLBACK; }
++ const target = rel && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;
++ return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
++ }
++
++ if (path === '/llms.txt') {
++ return res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(llmsTxt());
++ }
++
++ // The crawl files of this host, so a crawler on node.openvibe.network is told about this page and not
++ // about Network's (the apex serves its own).
++ if (path === '/robots.txt') {
++ const robots = require('openvibe-shared/seo').robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`] });
++ return res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(robots);
++ }
++ if (path === '/sitemap.xml') {
++ const xml = require('openvibe-shared/seo').sitemapXml([{ loc: `${SITE}/`, changefreq: 'weekly', priority: 1.0 }]);
++ return res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(xml);
++ }
++
++ if (path !== '/' && path !== '/index.md' && path !== '/index.html') return next();
++
++ let rel;
++ try { rel = await release.latest(); } catch { rel = release.FALLBACK; }
++ const html = page.render({ release: rel, releaseId });
++ const canonical = `${SITE}/`;
++ const links = linkHeaderTags(canonical, `${SITE}/llms.txt`);
++ res.setHeader('Link', [res.getHeader('Link'), links.header].filter(Boolean).join(', '));
++ res.setHeader('Vary', [res.getHeader('Vary'), 'Accept'].filter(Boolean).join(', '));
++ res.setHeader('Cache-Control', 'public, max-age=600');
++ if (isTwin) {
++ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
++ return res.end(req.method === 'HEAD' ? undefined : markdownTwin({ html, title: 'OpenVibe Node: your computer on the network', canonical }));
++ }
++ res.setHeader('Content-Type', 'text/html; charset=utf-8');
++ return res.end(req.method === 'HEAD' ? undefined : html);
++ });
++
++ return router;
++}
++
++module.exports = { createNodeRoutes, llmsTxt, hostOf, HOST, SITE };
ddiff --git a/test/node-home.test.js b/test/node-home.test.js
nnew file mode 100644
iindex 00000000..0b97d6c6
---- /dev/null
++++ b/test/node-home.test.js
@@@ -0,0 +1,143 @@
++'use strict';
++// node.openvibe.network: the Node's own front door, served by Network. The Host header decides: on that host '/'
++// is the Node's page, '/install' answers with the installer of the latest release, and the page has its Markdown
++// twin and its llms.txt, like Network's other pages.
++// node test/node-home.test.js
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++
++const page = require('../server/node/page');
++const release = require('../server/node/release');
++const { createNodeRoutes, llmsTxt } = require('../server/node/routes');
++
++// ── The release answer: cached for ten minutes, with a fallback when GitHub cannot be reached ──
++const FIXTURE = {
++ tag_name: 'v9.9.9',
++ published_at: '2026-11-06T12:00:00Z',
++ html_url: 'https://github.com/OpenVibers/OpenVibe.Node/releases/tag/v9.9.9',
++ assets: [
++ { name: 'install.sh', browser_download_url: 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/install.sh' },
++ { name: 'SHA256SUMS', browser_download_url: 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/SHA256SUMS' },
++ ],
++};
++
++(async () => {
++ // The cache: one call to GitHub, then the same answer for ten minutes.
++ let calls = 0;
++ const fetchImpl = async () => { calls += 1; return { ok: true, status: 200, json: async () => FIXTURE }; };
++ let now = 1000;
++ const clock = () => now;
++ const first = await release.latest({ fetchImpl, now: clock });
++ assert.strictEqual(first.version, 'v9.9.9', 'the release is read from GitHub');
++ assert.strictEqual(first.installUrl, 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/install.sh');
++ assert.strictEqual(first.checksumsUrl, 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/SHA256SUMS');
++ const second = await release.latest({ fetchImpl, now: clock });
++ assert.strictEqual(calls, 1, 'the second call is answered from the cache');
++ assert.strictEqual(second.version, 'v9.9.9');
++
++ // Past ten minutes, GitHub is asked again.
++ now += release.TTL_MS + 1;
++ await release.latest({ fetchImpl, now: clock });
++ assert.strictEqual(calls, 2, 'after ten minutes GitHub is asked again');
++
++ // GitHub unreachable: the page still works, and says so. (Past the ten minutes, so the cache cannot answer.)
++ now += release.TTL_MS + 1;
++ const broken = await release.latest({ fetchImpl: async () => { throw new Error('no GitHub'); }, now: clock });
++ assert.ok(broken.version, 'a release is still named');
++ assert.strictEqual(broken.stale, true, 'and it is marked as the last one read');
++ assert.ok(broken.installUrl.startsWith('https://github.com/OpenVibers/OpenVibe.Node/releases/download/'), 'the installer link still works');
++
++ // ── The page ──
++ const html = page.render({ release: first, releaseId: 'rel_test' });
++ assert.ok(html.includes('Your computer on the network'), 'the page says what the Node is');
++ assert.ok(html.includes('curl -fsSL https://node.openvibe.network/install | sh'), 'the one-line installer is on the page');
++ assert.ok(html.includes('v9.9.9'), 'the version is on the page');
++ assert.ok(html.includes('2026-11-06'), 'the release date is on the page');
++ assert.ok(html.includes('releases/download/v9.9.9/SHA256SUMS'), 'the checksums are on the page');
++ assert.ok(html.includes('releases/tag/v9.9.9'), 'the release page is on the page');
++ assert.ok(html.includes('openvibe-node-windows-amd64.exe'), 'Windows has its own way in');
++ assert.ok(html.includes('openvibe.bot/install'), 'the installer also lives at openvibe.bot/install');
++ for (const [what, needle] of [
++ ['run your agents and code', 'openvibe.codes/run'],
++ ['offer your models and earn credits', 'openvibe.exchange/sell'],
++ ['drive robots', 'openvibe.bot'],
++ ['stream an agent\u2019s desk', 'openvibe.live'],
++ ['your memory, read-only', 'memory.md'],
++ ]) assert.ok(html.includes(needle), `the page says it ${what}`);
++ // What it never does.
++ assert.ok(html.includes('without your pairing'), 'the page says the Node needs your pairing');
++ assert.ok(html.includes('Nothing runs that you did not turn on'), 'and that nothing runs on its own');
++ // The docs.
++ assert.ok(html.includes('docs/install.md'), 'the install guide is linked');
++ // Network's chrome.
++ assert.ok(html.includes('/shared/navbar.js'), 'the shared navbar is on the page');
++ assert.ok(html.includes('https://node.openvibe.network'), 'the page names its own address');
++ assert.ok(html.includes('<link rel="canonical" href="https://node.openvibe.network/">'), 'and claims it as its own');
++
++ // The llms.txt says the same thing in plain lines.
++ const txt = llmsTxt();
++ assert.ok(txt.startsWith('# OpenVibe Node'), 'the llms.txt names the site');
++ assert.ok(txt.includes('https://node.openvibe.network/'), 'and links the page');
++ assert.ok(txt.includes('curl -fsSL https://node.openvibe.network/install | sh'), 'and the install command');
++
++ // ── The routes: the Host header decides ──
++ const app = express();
++ app.use(createNodeRoutes({ releaseId: 'rel_test' }));
++ app.get('/', (_req, res) => res.type('html').send('<p>network home</p>'));
++ const server = http.createServer(app).listen(0);
++ await new Promise((r) => server.once('listening', r));
++ const port = server.address().port;
++ const get = (path, host, headers = {}) => new Promise((resolve) => {
++ const req = http.request({ host: '127.0.0.1', port, path, method: 'GET', headers: { host, ...headers } }, (res) => {
++ let body = '';
++ res.on('data', (c) => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ });
++ req.end();
++ });
++
++ try {
++ const home = await get('/', 'openvibe.network');
++ assert.ok(home.body.includes('network home'), 'on the apex, Network\u2019s own home answers');
++
++ const node = await get('/', 'node.openvibe.network');
++ assert.strictEqual(node.status, 200);
++ assert.ok(node.body.includes('Your computer on the network'), 'on the Node\u2019s host, the Node\u2019s page answers');
++ assert.ok(!node.body.includes('network home'), 'and not Network\u2019s home');
++ assert.ok(String(node.headers.link || '').includes('text/markdown'), 'the page offers its Markdown twin');
++
++ const twin = await get('/index.md', 'node.openvibe.network');
++ assert.strictEqual(twin.status, 200);
++ assert.ok(String(twin.headers['content-type'] || '').includes('text/markdown'), 'the twin is Markdown');
++ assert.ok(twin.body.includes('# OpenVibe Node'), 'and it says what the Node is');
++ assert.ok(twin.body.includes('openvibe.network/install'), 'with the install command');
++
++ const install = await get('/install', 'node.openvibe.network');
++ assert.strictEqual(install.status, 302, '/install redirects');
++ assert.ok(String(install.headers.location || '').includes('releases/download/'), 'to the release\u2019s own installer');
++ assert.ok(String(install.headers.location || '').endsWith('/install.sh'), 'the install script');
++
++ const llms = await get('/llms.txt', 'node.openvibe.network');
++ assert.strictEqual(llms.status, 200);
++ assert.ok(llms.body.startsWith('# OpenVibe Node'), 'the Node\u2019s own llms.txt answers');
++
++ // Network\u2019s own paths are untouched on the apex.
++ const apex = await get('/llms.txt', 'openvibe.network');
++ assert.notStrictEqual(apex.status, 200, 'the apex keeps its own llms.txt');
++ } finally {
++ server.close();
++ }
++
++ // ── The navbar: the Node is the piece the Network is built around ──
++ const index = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'index.html'), 'utf8');
++ assert.ok(/links: \[\{ label: 'Node', href: 'https:\/\/node\.openvibe\.network', icon: 'fa-desktop', featured: true \}/.test(index), 'the home page\u2019s navbar leads with the Node, as the accent');
++ assert.ok(index.indexOf("label: 'Node'") < index.indexOf("label: 'My Account'"), 'and the Node comes first');
++
++ // The Node's own origin is allowed to ask the network's API (the account's look, the navbar's frame, the)
++ // shared font): without it, the Node\u2019s page loses its own chrome in the browser.
++ const serverSrc = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'index.js'), 'utf8');
++ assert.ok(serverSrc.includes("'https://node.openvibe.network',"), 'the CORS allow-list knows the Node\u2019s own origin');
++
++ console.log('node-home: the Node\u2019s page, its installer, its twin and the navbar item ok');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex f4b6ccfc..423424d5 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,11 +12,11 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 196, // 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
-- htmlBrotliKB: 36.5, // 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
++ htmlRawKB: 197, // 196 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06: the Node link and the llms.txt section); 194.9 with the Organization naming every open site in sameAs (SEO-27, 2026-11-03); 192.4 with Tours and Shoes open (contracts 0.265.0, 2026-10-31); 189.9 with Casino open (contracts 0.264.0, 2026-10-31); 189 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +1 KB); 188 with the agent layer's rel=alternate/describedby link tags on every public page (SEO-1/SEO-2, 2026-10-29: +0.2 KB); 187 with Bench open (contracts 0.238.0, 2026-10-28); 186 with Cam open (contracts 0.237.0, 2026-10-28); 184.4 with Fun open (contracts 0.236.0, 2026-10-28); 182.7 with Vision and Exchange open (contracts 0.235.0, 2026-10-28); 179 with Meme open (contracts 0.234.0, 2026-10-28); 174.1 with Domains, Report and Design open (contracts 0.226-0.228, 2026-10-28); 168.1 the hero's orbit and rich cards for live streams, recordings and screenshots (2026-10-28: the owner asked for animated visuals and YouTube-style cards); 152.4 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 147.9 with the home's FAQ (six questions and their FAQPage JSON-LD), the numbers panel and the service map's group headings (2026-10-22); 141.2 with Tips and VIP open (contracts 0.200.0, 37 sites; the Opening next section is gone); 134 with Play (contracts 0.172.1); 128.3 with Events, AI and Search added (31 sites, contracts 0.135.0); 122.3 (shared 2.21.0: a real glyph for every product card, where ten drew the 'V'); was 110.6 with 33 live sites (contracts 0.119.0). The home page lists every site, so it grows with the network
++ htmlBrotliKB: 36.8, // 36.1 with the Node's own item in the navbar (openvibe-shared 4.117.0, 2026-11-06); 35.5 with Tours and Shoes open (2026-10-31); 35.1 with the look-boot script inline above the stylesheets on every page (L42, 2026-10-31: +0.6 KB); 34.2 with the lower half as one block of chips on phones (2026-10-30); 33.1 with Games granted its Bench match capabilities (BENCH-7, 2026-10-29: the home lists what each service may do); 32.0 with Fun, Vision and Exchange open (contracts 0.236.0, 2026-10-28); 31.2 with Domains, Report and Design open (2026-10-28); 30.1 with the Organization saying who OpenVibe is (description, other names, what it is not, 15 sites; 2026-10-28); 28.5 with the FAQ, numbers and groups (2026-10-22); 26.1 with Play; 25.1 (31 sites, contracts 0.135.0); 24.1 (shared 2.21.0, the same glyphs); 20.5 (contracts 0.116.0)
jsFiles: 8, // 7 with boost.js, was 6 (2026-10-13, plan T11 step 3: one more script on every boosted page; it loads route-transition.js on the first move only)
-- jsRawKB: 354, // 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
-- jsBrotliKB: 86.5, // 86.2 with the account-safety helper on every page (2026-11-06: +2.2 KB brotli, the same feature); 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9
++ jsRawKB: 365, // about 363.8 with both (2026-11-06: openvibe-shared 4.117.0's featured navbar item, +1.7 KB, and the account-safety helper, +8.5 KB; merged, to be confirmed by CI's measure); 353.6 with the account-safety helper on every page (2026-11-06: the one re-authentication path and the scheduled-deletion banner, +8.5 KB); 345.1 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 343.9 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 342.3 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 339.6 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 337.8 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 334.1 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 333.2 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 330.1 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 323.2 with openvibe-shared 4.47.2 (the comfort settings in theme-loader 4.45, touch targets 4.46, the grouped updates log 4.47.2); 313.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 306.4 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 298.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 293.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 279.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 269.3 with shared 4.11.0's seasonal navbar (Pumpkin Night and the season badge, +2.6); 249.8 with boost.js (13.1); 228.3
++ jsBrotliKB: 89.5, // about 88.8 with both (2026-11-06: 4.117.0's featured item and the account-safety helper); 86.6 with openvibe-shared 4.117.0 (2026-11-06: the featured navbar item's CSS and the phone-menu order); 83.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.6 with openvibe-shared 4.52.0 (from 4.47.2): the seasonal fall, rich embeds, the Dock and its Chats tab (2026-10-30); 81.4 with openvibe-shared 4.47.2 (comfort settings, touch targets, the grouped updates log); 78.9 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 77 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 73.3 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 70.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 68.0 with shared 4.12.0 (light/dark switch, seasonal mark, theme pairs); 64.5 with shared 4.11.0's navbar; 60.3 with boost.js (3.8); 53.9
cssFiles: 2, // 1 (the site's own; Font Awesome's stylesheet is gone since 2026-11-01)
externalFiles: 3, // none locally (Font Awesome was the one); production adds Cloudflare's beacon
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 09565643..273d8ca2 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -136,6 +136,7 @@ const REVIEWED = {
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
++ 'server/node/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Node/releases/latest), 8 s timeout, held 10 minutes; no request value ever names a host and it only ever reads the public release list'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
};