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
Changes
@@@ -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"},@@@ -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, () => {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -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",
+ "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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -603,7 +603,7 @@ app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control',
@@ -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
// Account hub (my.html) — the apex hosts the account hub under /my
// plus its client-routed sections.
// 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');
if (req.path === '/my.html') return redirectWithoutHtml(req, res, '/my');
return sendMyAccountApp(res);
return sendMyAccountApp(res);
});
});
@@ -695,13 +695,9 @@ app.get(['/admin', '/admin/*'], (req, res) => {
@@ -695,13 +695,9 @@ app.get(['/admin', '/admin/*'], (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'admin.html'));
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 ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
app.listen(config.port, config.host, () => {
app.listen(config.port, config.host, () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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); });