openvibers/openvibe.network Commits

Unknown paths answer 404 (roadmap §2.5, D44): the apex sent the account hub (my.html) with 200 for any path, so openvibe.network/<anything> looked like a page. Every real page keeps its own route; the account hub answers only at its own sections (server/not-found.js ACCOUNT_HUB_PATHS) and /admin/* stays the admin SPA. Anything else, any method, gets a 404: JSON under /api, /internal and /oauth, otherwise a small noindex page with links home, to the account, sign-in and status. test/unknown-paths.test.js boots the real server (test/helpers/boot-server.js: scratch database, loopback, ephemeral keys, no egress) and checks both sides

OP OpenVibers committed 24 Sept 2026 · f87b514927 · 5 files, +225 −9


Changes

ddiff --git a/package.json b/package.json
iindex 6061c715..6e855f89 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js",
++ "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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/index.js b/server/index.js
iindex 12668420..6704dc57 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -603,7 +603,7 @@ app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control',
// Account hub (my.html) — the apex hosts the account hub under /my
// plus its client-routed sections.
--app.get(['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences', '/history'], (req, res) => {
++app.get(require('./not-found').ACCOUNT_HUB_PATHS, (req, res) => {
if (req.path === '/my.html') return redirectWithoutHtml(req, res, '/my');
return sendMyAccountApp(res);
});
@@@ -695,13 +695,9 @@ app.get(['/admin', '/admin/*'], (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'admin.html'));
});
--app.get('*', (req, res) => {
-- if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) {
-- return res.status(404).json({ error: 'Not found' });
-- }
-- // Unknown paths fall through to the account hub SPA (apex is the account-hub host)
-- return sendMyAccountApp(res);
--});
++// Anything no route above answered is a 404: JSON under /api, /internal and /oauth, otherwise a small
++// noindex page (server/not-found.js). The account hub is served only at its own paths.
++app.use(require('./not-found').notFound);
// ── Start ────────────────────────────────────────────────────
app.listen(config.port, config.host, () => {
ddiff --git a/server/not-found.js b/server/not-found.js
nnew file mode 100644
iindex 00000000..0e63c9ad
---- /dev/null
++++ b/server/not-found.js
@@@ -0,0 +1,81 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Unknown paths answer 404 (roadmap §2.5 crawlability, D44).
++//
++// The apex used to send the account hub (my.html) with 200 for ANY path, so
++// openvibe.network/<anything> looked like a real page to crawlers and people. Every real page
++// is now a route of its own in server/index.js (the account hub's sections are listed in
++// ACCOUNT_HUB_PATHS below, /admin/* stays the admin SPA); everything else ends here:
++// /api/*, /internal/*, /oauth/* 404 JSON { error: 'Not found' }
++// anything else 404, a small noindex page with links back in
++// ═══════════════════════════════════════════════════════════════
++
++// The account hub (public/my.html) and its client-routed sections. my.html maps each path to a
++// section (pathSection in its script); a path it does not know is not a page.
++const ACCOUNT_HUB_PATHS = ['/my', '/my.html', '/themes', '/notifications', '/linked', '/security', '/profile', '/billing', '/preferences', '/history'];
++
++const JSON_PREFIXES = ['/api/', '/internal/', '/oauth/'];
++
++let cached = null;
++function page() {
++ if (cached) return cached;
++ let footer = '';
++ let icons = '';
++ 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 */ }
++ cached = `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>Page not found · OpenVibe.Network</title>
++<meta name="robots" content="noindex">
++${icons}
++<meta name="color-scheme" content="dark light">
++<script src="/shared/theme-loader.js" defer></script>
++<style>
++*,*::before,*::after{box-sizing:border-box}
++:root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--border:#1f2d47;--accent:#3b82f6;--text-primary:#e6edf7;--text-secondary:#96a7c2}
++html,body{margin:0;background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
++a{color:var(--accent)}
++.nf{max-width:640px;margin:0 auto;padding:72px 16px 56px}
++.nf h1{font-size:clamp(26px,4vw,36px);letter-spacing:-.02em;margin:0 0 10px}
++.nf p{color:var(--text-secondary);line-height:1.55;margin:0 0 22px}
++.nf ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:10px}
++.nf li a{display:inline-block;padding:9px 16px;border-radius:999px;border:1px solid var(--border);background:var(--bg-secondary);color:inherit;text-decoration:none;font-weight:600}
++.nf li a:hover,.nf li a:focus-visible{border-color:var(--accent);outline:0}
++</style>
++</head>
++<body>
++<div id="navbar-mount"></div>
++${nav}
++<main class="nf" id="main">
++<h1>Page not found</h1>
++<p>There is no page at this address on openvibe.network. The link may be old or mistyped.</p>
++<ul>
++<li><a href="/">OpenVibe.Network home</a></li>
++<li><a href="/my">Your account</a></li>
++<li><a href="/login">Sign in</a></li>
++<li><a href="/status">Service status</a></li>
++</ul>
++</main>
++${footer}
++<script src="/shared/navbar.js" defer></script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:location.origin});}catch(e){}});</script>
++</body>
++</html>`;
++ return cached;
++}
++
++/** Last handler of the app: every method, every path nothing else answered. */
++function notFound(req, res) {
++ res.status(404);
++ res.set('Cache-Control', 'no-cache');
++ if (JSON_PREFIXES.some(p => req.path.startsWith(p))) return res.json({ error: 'Not found' });
++ res.set('X-Robots-Tag', 'noindex');
++ return res.type('html').send(page());
++}
++
++module.exports = { notFound, page, ACCOUNT_HUB_PATHS };
ddiff --git a/test/helpers/boot-server.js b/test/helpers/boot-server.js
nnew file mode 100644
iindex 00000000..9f0a9a89
---- /dev/null
++++ b/test/helpers/boot-server.js
@@@ -0,0 +1,74 @@
++'use strict';
++// Boots the real server (server/index.js) in a child process for route-level tests: a scratch
++// database and data directory, loopback only, a free port, no .env (the child runs in the temp dir),
++// ephemeral signing keys, and no traffic off the machine (fetch to anything but loopback is refused
++// by a preload). stop() kills it; logs() returns what it printed.
++const fs = require('fs');
++const os = require('os');
++const net = require('net');
++const path = require('path');
++const { spawn } = require('child_process');
++
++const ROOT = path.join(__dirname, '..', '..');
++
++const NO_EGRESS = `'use strict';
++const real = globalThis.fetch;
++globalThis.fetch = (input, init) => {
++ let host = '';
++ try { host = new URL(typeof input === 'string' ? input : input.url).hostname; } catch { /* */ }
++ if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]') return real(input, init);
++ return Promise.reject(new Error('test: no egress to ' + host));
++};
++`;
++
++function freePort() {
++ return new Promise((resolve, reject) => {
++ const s = net.createServer();
++ s.once('error', reject);
++ s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
++ });
++}
++
++async function bootServer({ env = {}, timeoutMs = 20000 } = {}) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-boot-'));
++ const guard = path.join(dir, 'no-egress.js');
++ fs.writeFileSync(guard, NO_EGRESS);
++ const port = await freePort();
++ const childEnv = {
++ PATH: process.env.PATH, HOME: dir, NODE_ENV: 'test', PORT: String(port), HOST: '127.0.0.1',
++ DB_PATH: path.join(dir, 'network.db'), AVATAR_PATH: path.join(dir, 'avatars'),
++ JWT_PRIVATE_KEY: path.join(dir, 'none.pem'), JWT_PUBLIC_KEY: path.join(dir, 'none.pub.pem'),
++ BOOTSTRAP_PROFILE: 'local-dev', INTERNAL_API_KEY: 'k'.repeat(40),
++ OV_NETWORK_INTERNAL_URL: `http://127.0.0.1:${port}`,
++ OV_LIVE_INTERNAL_URL: 'http://127.0.0.1:9', OV_TOOLS_INTERNAL_URL: 'http://127.0.0.1:9',
++ OV_GAMES_INTERNAL_URL: 'http://127.0.0.1:9', OV_MEDIA_INTERNAL_URL: 'http://127.0.0.1:9',
++ OV_AI_INTERNAL_URL: 'http://127.0.0.1:9',
++ ...env,
++ };
++ const child = spawn(process.execPath, ['-r', guard, path.join(ROOT, 'server', 'index.js')], { cwd: dir, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
++ let out = '';
++ child.stdout.on('data', (d) => { out += d; });
++ child.stderr.on('data', (d) => { out += d; });
++ let exited = null;
++ child.on('exit', (code, sig) => { exited = { code, sig }; });
++ const base = `http://127.0.0.1:${port}`;
++ const t0 = Date.now();
++ for (;;) {
++ if (exited) throw new Error(`server exited early (${JSON.stringify(exited)}):\n${out}`);
++ try { const r = await fetch(`${base}/api/health`); if (r.ok) break; } catch { /* not listening yet */ }
++ if (Date.now() - t0 > timeoutMs) { child.kill('SIGKILL'); throw new Error(`server did not answer within ${timeoutMs} ms:\n${out}`); }
++ await new Promise(r => setTimeout(r, 100));
++ }
++ return {
++ base, port, dir,
++ logs: () => out,
++ stop: () => new Promise((resolve) => {
++ if (exited) return resolve();
++ child.once('exit', () => resolve());
++ child.kill('SIGTERM');
++ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, 3000).unref();
++ }),
++ };
++}
++
++module.exports = { bootServer, freePort, ROOT };
ddiff --git a/test/unknown-paths.test.js b/test/unknown-paths.test.js
nnew file mode 100644
iindex 00000000..c65b84eb
---- /dev/null
++++ b/test/unknown-paths.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++// Unknown paths answer 404 (roadmap §2.5, D44): the apex no longer sends the account hub with 200 for
++// any path. Boots the real server (test/helpers/boot-server.js) and checks that every real page keeps
++// its 200 and that anything else is a 404: a small noindex page, or JSON under /api, /internal, /oauth.
++// node test/unknown-paths.test.js
++const assert = require('assert');
++const { bootServer } = require('./helpers/boot-server');
++const { ACCOUNT_HUB_PATHS } = require('../server/not-found');
++
++(async () => {
++ const srv = await bootServer();
++ try {
++ const get = (p, opts = {}) => fetch(srv.base + p, { redirect: 'manual', ...opts }).then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', robots: r.headers.get('x-robots-tag') || '', location: r.headers.get('location') || '', text: await r.text() }));
++
++ // ── Real pages keep 200 ──
++ const real = [
++ '/', '/login', '/forgot-password', '/reset-password', '/admin', '/admin/settings', '/admin/analytics/overview',
++ '/verify-email', '/status', '/sso/fanout', '/terms', '/privacy', '/dmca', '/llms.txt',
++ '/robots.txt', '/sitemap.xml', '/manifest.webmanifest', '/assets/logo.svg', '/shared/navbar.js', '/shared/v1/navbar.js',
++ '/openvibe-sw.js', '/release.json', '/.well-known/openvibe', '/.well-known/web-identity', '/api/health', '/api/brand',
++ '/api/v1/registry/services', '/api/.well-known/jwks',
++ ...ACCOUNT_HUB_PATHS.filter(p => p !== '/my.html'),
++ ];
++ for (const p of real) {
++ const r = await get(p);
++ assert.strictEqual(r.status, 200, `${p} is a real page: 200 (got ${r.status})`);
++ }
++ assert.strictEqual((await get('/sso/check')).status, 400, '/sso/check is a route (400 without its parameters), not a 404');
++ const hub = await get('/themes');
++ assert.ok(hub.text.includes('<html') && !hub.text.includes('Page not found'), 'an account hub section is the account hub');
++ // Redirects stay redirects.
++ for (const [p, to] of [['/my.html', '/my'], ['/login.html', '/login'], ['/admin.html', '/admin'], ['/index.html', '/'], ['/tos', '/terms']]) {
++ const r = await get(p);
++ assert.ok((r.status === 301 || r.status === 302) && r.location === to, `${p} redirects to ${to} (got ${r.status} ${r.location})`);
++ }
++
++ // ── Everything else is a 404 ──
++ for (const p of ['/nope', '/some/deep/path', '/developers', '/user/someone', '/my/extra', '/themes/x', '/problems/capability.denied', '/wp-login.php', '/@someone', '/assets/missing.png']) {
++ const r = await get(p);
++ assert.strictEqual(r.status, 404, `${p} answers 404 (got ${r.status})`);
++ assert.ok(r.type.startsWith('text/html'), `${p}: the 404 is an HTML page`);
++ assert.ok(/<meta name="robots" content="noindex">/.test(r.text) && /noindex/.test(r.robots), `${p}: the 404 page is noindex`);
++ assert.ok(r.text.includes('Page not found') && r.text.includes('href="/"'), `${p}: the 404 page says so and links home`);
++ }
++ for (const p of ['/api/nope', '/api/v2/whatever', '/oauth/nope']) {
++ const r = await get(p);
++ assert.strictEqual(r.status, 404, `${p} answers 404`);
++ assert.ok(r.type.includes('application/json') && JSON.parse(r.text).error === 'Not found', `${p}: a JSON 404`);
++ }
++ // Every method, not only GET.
++ let r = await get('/nope', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
++ assert.strictEqual(r.status, 404, 'POST to an unknown path: 404');
++ r = await get('/nope', { method: 'HEAD' });
++ assert.strictEqual(r.status, 404, 'HEAD of an unknown path: 404');
++ r = await get('/api/nope', { method: 'DELETE' });
++ assert.strictEqual(r.status, 404, 'DELETE /api/nope: 404');
++
++ console.log('unknown paths: all checks passed');
++ } catch (err) {
++ console.error(srv.logs());
++ throw err;
++ } finally {
++ await srv.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });