openvibers/openvibe.games Commits

Unknown paths answer 404 instead of the landing page: a path that was neither a route nor a file of the client build used to get the portal (openvibe.games) or the game (play.openvibe.games) with a 200, a soft 404 that search engines index and monitors read as up. It now gets a real 404 with Cache-Control: no-store. Browsers get a small self-contained HTML page (inline CSS, no scripts) that shows the requested path escaped and links to the host's entry points (apex: /, /play, /editor; play host: /, /editor and the portal). Anything under /api, and a client that asks for JSON, gets {"error":"not_found"}, the shape /auth/* already answers. A missing asset (any extension but .html, e.g. /favicon.ico, /robots.txt, a stale /assets/*.js) gets plain text. HEAD and POST get the same status. Real routes keep their status: / (portal on the apex, the game on the play host), /play (301 to / on the play host), /editor, the build's files including the hashed /assets, /map.json, /map-assets/<id>, the API (/api/characters, /api/map, /api/map-assets, /api/texture, /api/ready, /api/v1/platform, /api/v1/mods), /auth/*, /healthz and /metrics; /play/ and /editor/ now redirect to their canonical URL, and /game and /canvas (the URLs from when the game lived on OpenVibe.Live, which still links and 301s to them) redirect to /play and / (both to / on the play host) instead of becoming 404s. The client has no router of its own, so no page prefix accepts sub-paths. apps/server/src/net/httpServer.test.ts drives the real HTTP layer over a stand-in build on both hosts

OP OpenVibers committed 23 Sept 2026 · 84ee13eba0 · 9 files, +496 −14


Changes

ddiff --git a/README.md b/README.md
iindex 6ea8e32..108e895 100644
---- a/README.md
++++ b/README.md
@@@ -83,7 +83,11 @@ STATIC_DIR=apps/client/dist PORT=8000 DB_PATH=data/world.db pnpm --filter @openv
```
The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game
--WebSocket on one port. `/api/ready` is 200 only while `world.db` answers a query and the simulation
++WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is
++the game and `/play` redirects there. `/game` and `/canvas` (the URLs from when the game lived on
++OpenVibe.Live) redirect to `/play` and `/`. Any other path that is neither a file of the build nor a route
++answers 404 (an HTML page with links to those pages; `{"error":"not_found"}` under `/api`; plain text
++for a missing asset), never the portal with a 200. `/api/ready` is 200 only while `world.db` answers a query and the simulation
has ticked within the last 5 s, otherwise 503 naming the failed check (`status`
`ready`/`not_ready`, `checks.db`, `checks.tick`); nginx keeps it off the public vhosts. Environment: `PORT`, `HOST`, `DB_PATH`, `STATIC_DIR`,
`MAX_PLAYERS`, `LOG_LEVEL` (platform variables below).
ddiff --git a/apps/server/dist-types/net/httpServer.d.ts.map b/apps/server/dist-types/net/httpServer.d.ts.map
iindex d472ff2..d94a414 100644
---- a/apps/server/dist-types/net/httpServer.d.ts.map
++++ b/apps/server/dist-types/net/httpServer.d.ts.map
@@@ -1 +1 @@
--{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAsBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AAyBlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAyhBR"}
\\ No newline at end of file
++{"version":3,"file":"httpServer.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,MAAM,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhG,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAA;AAuBhE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AA2BlD,MAAM,WAAW,UAAU;IACzB,8BAA8B;IAC9B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,gFAAgF;IAChF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED,qFAAqF;AACrF,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAA;CAChB;AAmCD,kFAAkF;AAClF,MAAM,WAAW,iBAAiB;IAChC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,KAAK,OAAO,CAAA;IAC/D,gEAAgE;IAChE,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAA;CAC5F;AAYD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,UAAU,EACvB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,EACxD,cAAc,CAAC,EAAE,CACf,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GAAG,SAAS,KACrB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,EAAE,CAAC,EACnE,KAAK,CAAC,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,CAAC,EAAE,iBAAiB,GAC3B,MAAM,CAyjBR"}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/httpServer.test.d.ts b/apps/server/dist-types/net/httpServer.test.d.ts
nnew file mode 100644
iindex 0000000..df9e18e
---- /dev/null
++++ b/apps/server/dist-types/net/httpServer.test.d.ts
@@@ -0,0 +1,2 @@
++export {};
++//# sourceMappingURL=httpServer.test.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/httpServer.test.d.ts.map b/apps/server/dist-types/net/httpServer.test.d.ts.map
nnew file mode 100644
iindex 0000000..bdac944
---- /dev/null
++++ b/apps/server/dist-types/net/httpServer.test.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"httpServer.test.d.ts","sourceRoot":"","sources":["../../src/net/httpServer.test.ts"],"names":[],"mappings":""}
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/notFound.d.ts b/apps/server/dist-types/net/notFound.d.ts
nnew file mode 100644
iindex 0000000..7b2885a
---- /dev/null
++++ b/apps/server/dist-types/net/notFound.d.ts
@@@ -0,0 +1,23 @@
++import type { IncomingMessage, ServerResponse } from 'node:http';
++/**
++ * The answer for a path no route matches: a real 404, never the landing page
++ * with a 200 (a "soft 404" that search engines index as a duplicate of the
++ * portal and monitors read as up).
++ *
++ * Page requests get a small self-contained HTML page that names the path and
++ * links to the host's real entry points. API paths and clients asking for
++ * JSON get `{"error":"not_found"}` (the shape /auth/* already answers). A
++ * missing asset (any extension but .html) gets plain text.
++ */
++export interface NotFoundLink {
++ href: string;
++ label: string;
++}
++export type NotFoundKind = 'html' | 'json' | 'text';
++export declare function escapeHtml(value: string): string;
++/** Which body a 404 for `path` gets. */
++export declare function notFoundKind(req: IncomingMessage, path: string): NotFoundKind;
++export declare function notFoundPage(path: string, links: readonly NotFoundLink[]): string;
++/** Answers 404 for `path` with the body its client can use; HEAD gets the headers only. */
++export declare function sendNotFound(req: IncomingMessage, res: ServerResponse, path: string, links: readonly NotFoundLink[]): void;
++//# sourceMappingURL=notFound.d.ts.map
\\ No newline at end of file
ddiff --git a/apps/server/dist-types/net/notFound.d.ts.map b/apps/server/dist-types/net/notFound.d.ts.map
nnew file mode 100644
iindex 0000000..3d17783
---- /dev/null
++++ b/apps/server/dist-types/net/notFound.d.ts.map
@@@ -0,0 +1 @@
++{"version":3,"file":"notFound.d.ts","sourceRoot":"","sources":["../../src/net/notFound.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAGhE;;;;;;;;;GASG;AAEH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;AAUnD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,wCAAwC;AACxC,wBAAgB,YAAY,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAO7E;AAKD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAuCjF;AAED,2FAA2F;AAC3F,wBAAgB,YAAY,CAC1B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,SAAS,YAAY,EAAE,GAC7B,IAAI,CAmBN"}
\\ No newline at end of file
ddiff --git a/apps/server/src/net/httpServer.test.ts b/apps/server/src/net/httpServer.test.ts
nnew file mode 100644
iindex 0000000..c281423
---- /dev/null
++++ b/apps/server/src/net/httpServer.test.ts
@@@ -0,0 +1,303 @@
++import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
++import { request, type IncomingHttpHeaders, type OutgoingHttpHeaders, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { createConsoleLogger } from '@openvibe/shared'
++import { afterAll, beforeAll, describe, expect, it } from 'vitest'
++import { ServerMetrics } from '../observability/metrics.js'
++import { createReadiness } from '../observability/readiness.js'
++import { createHttpServer } from './httpServer.js'
++import { escapeHtml, notFoundPage } from './notFound.js'
++
++/**
++ * Routing of the real HTTP layer over a stand-in client build: every real
++ * route keeps its status, the play host serves the game at /, and a path no
++ * route matches is a 404 (never the landing page with a 200).
++ */
++
++const log = createConsoleLogger({ app: 'test' }, 'error')
++const APEX = 'openvibe.games'
++const PLAY = 'play.openvibe.games'
++const ASSET = '/assets/main-Cj5pXm1a.js'
++
++interface Reply {
++ status: number
++ headers: IncomingHttpHeaders
++ body: string
++}
++
++let dir: string
++let server: Server
++let bare: Server
++
++function listen(s: Server): Promise<number> {
++ return new Promise((resolve) =>
++ s.listen(0, '127.0.0.1', () => resolve((s.address() as AddressInfo).port)),
++ )
++}
++
++let port = 0
++let barePort = 0
++
++/** A raw request (node:http, so the Host header and the path go out exactly as given). */
++function send(
++ path: string,
++ opts: { host?: string; method?: string; headers?: OutgoingHttpHeaders; to?: number } = {},
++): Promise<Reply> {
++ return new Promise((resolve, reject) => {
++ const req = request(
++ {
++ host: '127.0.0.1',
++ port: opts.to ?? port,
++ path,
++ method: opts.method ?? 'GET',
++ headers: { host: opts.host ?? APEX, ...opts.headers },
++ },
++ (res) => {
++ const chunks: Buffer[] = []
++ res.on('data', (c: Buffer) => chunks.push(c))
++ res.on('end', () =>
++ resolve({
++ status: res.statusCode ?? 0,
++ headers: res.headers,
++ body: Buffer.concat(chunks).toString('utf8'),
++ }),
++ )
++ },
++ )
++ req.on('error', reject)
++ req.end()
++ })
++}
++
++beforeAll(async () => {
++ dir = mkdtempSync(join(tmpdir(), 'ovg-http-'))
++ const dist = join(dir, 'dist')
++ mkdirSync(join(dist, 'assets', 'tex'), { recursive: true })
++ writeFileSync(join(dist, 'index.html'), '<!doctype html><title>portal page</title>')
++ writeFileSync(join(dist, 'play.html'), '<!doctype html><title>game page</title>')
++ writeFileSync(join(dist, 'editor.html'), '<!doctype html><title>editor page</title>')
++ writeFileSync(join(dist, 'chrome.js'), 'void 0')
++ writeFileSync(join(dist, ASSET.slice(1)), 'export {}')
++ writeFileSync(join(dist, 'assets', 'tex', 'wood_planks.jpg'), 'jpeg')
++ const mapPath = join(dir, 'data', 'map.json')
++ mkdirSync(join(dir, 'data', 'map-assets'), { recursive: true })
++ writeFileSync(join(dir, 'data', 'map-assets', 'tex-1700000000000.png'), 'png')
++
++ const metrics = new ServerMetrics()
++ metrics.tick = 42
++ metrics.lastTickAt = Date.now()
++ // /api/ready arrives through the platform hook, as main.ts wires it.
++ const readiness = createReadiness({ pingDb: () => true, metrics })
++ server = createHttpServer(
++ dist,
++ metrics,
++ log,
++ mapPath,
++ { key: 'editor-secret', networkAuthUrl: null },
++ undefined,
++ async () => [{ slot: 0, name: 'Ana', appearance: null }],
++ null,
++ { handle: (req, res) => readiness.handle(req, res) },
++ )
++ bare = createHttpServer(null, metrics, log)
++ port = await listen(server)
++ barePort = await listen(bare)
++})
++
++afterAll(() => {
++ server.close()
++ bare.close()
++ rmSync(dir, { recursive: true, force: true })
++})
++
++describe('real routes on the apex', () => {
++ it.each([
++ ['/', 'portal page'],
++ ['/play', 'game page'],
++ ['/play?sso=none', 'game page'],
++ ['/editor', 'editor page'],
++ ['/index.html', 'portal page'],
++ ])('%s answers 200 with its page', async (path, marker) => {
++ const r = await send(path)
++ expect(r.status).toBe(200)
++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
++ expect(r.headers['cache-control']).toBe('no-cache')
++ expect(r.body).toContain(marker)
++ })
++
++ it.each([
++ ['/game', '/play'],
++ ['/canvas', '/'],
++ ])('%s (the old OpenVibe.Live URL) redirects to %s', async (path, location) => {
++ const r = await send(path)
++ expect(r.status).toBe(301)
++ expect(r.headers.location).toBe(location)
++ })
++
++ it('serves hashed build assets as immutable, and public files', async () => {
++ const asset = await send(ASSET)
++ expect(asset.status).toBe(200)
++ expect(asset.headers['content-type']).toBe('text/javascript')
++ expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable')
++ expect((await send('/chrome.js')).status).toBe(200)
++ expect((await send('/assets/tex/wood_planks.jpg')).status).toBe(200)
++ })
++
++ it('answers health, readiness, metrics and the API', async () => {
++ const health = await send('/healthz')
++ expect(health.status).toBe(200)
++ expect(JSON.parse(health.body)).toEqual({ ok: true, tick: 42 })
++ expect((await send('/api/ready')).status).toBe(200)
++ expect((await send('/metrics')).status).toBe(200)
++ const chars = await send('/api/characters?token=guest-token-1234')
++ expect(chars.status).toBe(200)
++ expect(JSON.parse(chars.body)).toEqual([{ slot: 0, name: 'Ana', appearance: null }])
++ const map = await send('/map.json')
++ expect(map.status).toBe(200)
++ expect(map.headers.etag).toBeTruthy()
++ const tex = await send('/map-assets/tex-1700000000000.png')
++ expect(tex.status).toBe(200)
++ expect(tex.headers['content-type']).toBe('image/png')
++ })
++
++ it('answers the SSO routes', async () => {
++ const me = await send('/auth/me')
++ expect(me.status).toBe(200)
++ expect(JSON.parse(me.body)).toEqual({ user: null })
++ const out = await send('/auth/logout')
++ expect(out.status).toBe(200)
++ expect(out.body).toContain('location.replace("/play")')
++ })
++
++ it.each([
++ ['/play.html', '/play'],
++ ['/editor.html', '/editor'],
++ ['/play/', '/play'],
++ ['/editor/', '/editor'],
++ ])('%s redirects to %s', async (path, location) => {
++ const r = await send(path)
++ expect(r.status).toBe(301)
++ expect(r.headers.location).toBe(location)
++ })
++
++ it('answers HEAD like GET, without a body', async () => {
++ const r = await send('/', { method: 'HEAD' })
++ expect(r.status).toBe(200)
++ expect(r.body).toBe('')
++ })
++})
++
++describe('unknown paths answer 404', () => {
++ it.each(['/nope', '/play/extra', '/editor/some-map', '/assets', '/assets/', '/old-page.html'])(
++ '%s is a 404 page, not the portal',
++ async (path) => {
++ const r = await send(path)
++ expect(r.status).toBe(404)
++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
++ expect(r.headers['cache-control']).toBe('no-store')
++ expect(r.body).toContain('<title>Page not found · OpenVibe.Games</title>')
++ expect(r.body).not.toContain('portal page')
++ expect(r.body).toContain('<a href="/">OpenVibe.Games home</a>')
++ expect(r.body).toContain('<a href="/play">Play Scraplandia</a>')
++ expect(r.body).toContain('<a href="/editor">Map editor</a>')
++ expect(r.body).not.toMatch(/<script\b/)
++ },
++ )
++
++ it('echoes the requested path escaped', async () => {
++ const r = await send(`/x<script>alert("1")</script>&'y`)
++ expect(r.status).toBe(404)
++ expect(r.body).not.toContain('<script>alert')
++ expect(r.body).toContain(
++ '<code>/x&lt;script&gt;alert(&quot;1&quot;)&lt;/script&gt;&amp;&#39;y</code>',
++ )
++ const encoded = await send('/%3Cimg%20src%3Dx%3E')
++ expect(encoded.body).toContain('<code>/%3Cimg%20src%3Dx%3E</code>')
++ })
++
++ it('gives HEAD and POST the same status', async () => {
++ const head = await send('/nope', { method: 'HEAD' })
++ expect(head.status).toBe(404)
++ expect(head.headers['content-type']).toBe('text/html; charset=utf-8')
++ expect(head.body).toBe('')
++ expect((await send('/nope', { method: 'POST' })).status).toBe(404)
++ })
++
++ it('answers a missing asset with a plain-text 404', async () => {
++ for (const path of ['/assets/main-Missing1.js', '/favicon.ico', '/assets/tex/nope.jpg']) {
++ const r = await send(path)
++ expect(r.status).toBe(404)
++ expect(r.headers['content-type']).toBe('text/plain; charset=utf-8')
++ expect(r.body).toBe('not found\n')
++ }
++ expect((await send('/map-assets/tex-1.png')).status).toBe(404)
++ })
++
++ it('answers unknown API paths and JSON clients with the JSON 404', async () => {
++ for (const path of ['/api/nope', '/api', '/api/map', '/auth/nope']) {
++ const r = await send(path)
++ expect(r.status).toBe(404)
++ expect(r.headers['content-type']).toBe('application/json')
++ expect(JSON.parse(r.body)).toEqual({ error: 'not_found' })
++ }
++ const json = await send('/nope', { headers: { accept: 'application/json' } })
++ expect(json.status).toBe(404)
++ expect(JSON.parse(json.body)).toEqual({ error: 'not_found' })
++ })
++})
++
++describe('play.openvibe.games', () => {
++ it('serves the game at /', async () => {
++ const r = await send('/', { host: PLAY })
++ expect(r.status).toBe(200)
++ expect(r.body).toContain('game page')
++ })
++
++ it.each(['/play', '/play/', '/play.html', '/game', '/canvas'])(
++ '%s redirects to /',
++ async (path) => {
++ const r = await send(path, { host: PLAY })
++ expect(r.status).toBe(301)
++ expect(r.headers.location).toBe('/')
++ },
++ )
++
++ it('serves the editor, assets, map and health like the apex', async () => {
++ expect((await send('/editor', { host: PLAY })).body).toContain('editor page')
++ expect((await send(ASSET, { host: PLAY })).status).toBe(200)
++ expect((await send('/map.json', { host: PLAY })).status).toBe(200)
++ expect((await send('/healthz', { host: PLAY })).status).toBe(200)
++ })
++
++ it('answers an unknown path with 404, not the game', async () => {
++ const r = await send('/nope', { host: PLAY })
++ expect(r.status).toBe(404)
++ expect(r.body).not.toContain('game page')
++ expect(r.body).toContain('<a href="/">Play Scraplandia</a>')
++ expect(r.body).toContain('<a href="https://openvibe.games/">OpenVibe.Games home</a>')
++ expect((await send('/assets/nope.js', { host: PLAY })).status).toBe(404)
++ })
++})
++
++describe('without a client build (API/WS only)', () => {
++ it('still answers health, and 404 for pages', async () => {
++ expect((await send('/healthz', { to: barePort })).status).toBe(200)
++ const r = await send('/', { to: barePort })
++ expect(r.status).toBe(404)
++ expect(r.headers['content-type']).toBe('text/html; charset=utf-8')
++ })
++})
++
++describe('404 page', () => {
++ it('escapes every link and cuts a very long path', () => {
++ expect(escapeHtml(`<a href="x">'&'</a>`)).toBe(
++ '&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;',
++ )
++ const page = notFoundPage(`/${'a'.repeat(500)}`, [{ href: '/?a=1&b="2"', label: '<b>' }])
++ expect(page).toContain('<a href="/?a=1&amp;b=&quot;2&quot;">&lt;b&gt;</a>')
++ expect(page).toContain(`/${'a'.repeat(199)}…</code>`)
++ expect(page).not.toContain('a'.repeat(201))
++ })
++})
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 305dbc7..f2f8afa 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -25,14 +25,17 @@ import {
} from './sso.js'
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
++import { sendNotFound, type NotFoundLink } from './notFound.js'
import type { MapFileV2 } from '@openvibe/content'
/**
* Minimal HTTP layer: health/metrics endpoints and (in production) the
-- * built client bundle. Game traffic itself is WebSocket-only.
++ * built client bundle. Game traffic itself is WebSocket-only. A path no
++ * route matches is a 404 (see notFound.ts), never the landing page.
*/
const DEFAULT_NETWORK_URL = 'https://openvibe.network'
++const DEFAULT_PORTAL_URL = 'https://openvibe.games'
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
@@@ -138,12 +141,25 @@ export function createHttpServer(
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// browser answer without the Network round trip. See /auth/login.
const knownSessions = new SessionCheckCache()
++ // The real entry points, offered on the 404 page of each host.
++ const portalHome = `${(oauth?.selfUrl ?? DEFAULT_PORTAL_URL).replace(/\/+$/, '')}/`
++ const apexLinks: NotFoundLink[] = [
++ { href: '/', label: 'OpenVibe.Games home' },
++ { href: '/play', label: 'Play Scraplandia' },
++ { href: '/editor', label: 'Map editor' },
++ ]
++ const playLinks: NotFoundLink[] = [
++ { href: '/', label: 'Play Scraplandia' },
++ { href: '/editor', label: 'Map editor' },
++ { href: portalHome, label: 'OpenVibe.Games home' },
++ ]
return createServer((req: IncomingMessage, res: ServerResponse) => {
const url = (req.url ?? '/').split('?')[0] ?? '/'
// Host-based routing: play.openvibe.games serves the game at its root
// (the apex root is the games portal). Everything else — /assets,
// /map.json, /api, /auth, websockets — behaves identically on both.
const playHost = (req.headers.host ?? '').toLowerCase().startsWith('play.')
++ const notFound = () => sendNotFound(req, res, url, playHost ? playLinks : apexLinks)
if (platform?.handle?.(req, res)) return
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
@@@ -603,15 +619,19 @@ export function createHttpServer(
res.end(JSON.stringify(metrics.snapshot()))
return
}
++ // Every API route has answered above; what is left under /api is unknown.
++ if (url === '/api' || url.startsWith('/api/')) {
++ notFound()
++ return
++ }
if (!root) {
-- res.writeHead(404)
-- res.end('not found')
++ notFound()
return
}
// Pretty page routes: on the apex, / is the games portal, /play the
// game and /editor the map editor. On the play host the game IS the
// root page (with /play redirecting there so old links keep working).
-- // The old .html URLs redirect so bookmarks keep working.
++ // The old .html URLs and a trailing slash redirect to the canonical URL.
if (url === '/play.html' || url === '/editor.html') {
res.writeHead(301, {
location: url === '/play.html' ? (playHost ? '/' : '/play') : '/editor',
@@@ -619,14 +639,29 @@ export function createHttpServer(
res.end()
return
}
-- if (playHost && url === '/play') {
++ if (playHost && (url === '/play' || url === '/play/')) {
res.writeHead(301, { location: '/' })
res.end()
return
}
++ if (url === '/play/' || url === '/editor/') {
++ res.writeHead(301, { location: url.slice(0, -1) })
++ res.end()
++ return
++ }
++ // The game's URLs from when it lived on OpenVibe.Live, which still links
++ // and 301s to them: the game is /play now, and the pixel canvas is gone
++ // (its nearest page is the portal).
++ if (url === '/game' || url === '/canvas') {
++ res.writeHead(301, { location: url === '/game' && !playHost ? '/play' : '/' })
++ res.end()
++ return
++ }
const pageAlias =
-- playHost && url === '/'
-- ? 'play.html'
++ url === '/'
++ ? playHost
++ ? 'play.html'
++ : 'index.html'
: url === '/play'
? 'play.html'
: url === '/editor'
@@@ -635,21 +670,21 @@ export function createHttpServer(
// Static files with path traversal guard.
const safePath = normalize(pageAlias ?? url).replace(/^(\.\.[/\\])+/, '')
-- let filePath = join(root, safePath)
++ const filePath = join(root, safePath)
if (!filePath.startsWith(root)) {
res.writeHead(403)
res.end()
return
}
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
-- // Unknown paths fall back to the host's landing page (apex: portal
-- // home; play host: the game itself).
-- filePath = join(root, playHost ? 'play.html' : 'index.html')
-- if (!existsSync(filePath)) {
++ if (pageAlias) {
res.writeHead(404)
res.end('client build missing')
return
}
++ // No route and no such file: a real 404, not the landing page.
++ notFound()
++ return
}
const type = MIME[extname(filePath)] ?? 'application/octet-stream'
res.writeHead(200, {
ddiff --git a/apps/server/src/net/notFound.ts b/apps/server/src/net/notFound.ts
nnew file mode 100644
iindex 0000000..a9489f5
---- /dev/null
++++ b/apps/server/src/net/notFound.ts
@@@ -0,0 +1,113 @@
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { extname } from 'node:path'
++
++/**
++ * The answer for a path no route matches: a real 404, never the landing page
++ * with a 200 (a "soft 404" that search engines index as a duplicate of the
++ * portal and monitors read as up).
++ *
++ * Page requests get a small self-contained HTML page that names the path and
++ * links to the host's real entry points. API paths and clients asking for
++ * JSON get `{"error":"not_found"}` (the shape /auth/* already answers). A
++ * missing asset (any extension but .html) gets plain text.
++ */
++
++export interface NotFoundLink {
++ href: string
++ label: string
++}
++
++export type NotFoundKind = 'html' | 'json' | 'text'
++
++const ENTITIES: Record<string, string> = {
++ '&': '&amp;',
++ '<': '&lt;',
++ '>': '&gt;',
++ '"': '&quot;',
++ "'": '&#39;',
++}
++
++export function escapeHtml(value: string): string {
++ return value.replace(/[&<>"']/g, (c) => ENTITIES[c] ?? c)
++}
++
++/** Which body a 404 for `path` gets. */
++export function notFoundKind(req: IncomingMessage, path: string): NotFoundKind {
++ if (path === '/api' || path.startsWith('/api/')) return 'json'
++ const accept = (req.headers.accept ?? '').toLowerCase()
++ if (accept.includes('application/json') && !accept.includes('text/html')) return 'json'
++ const ext = extname(path).toLowerCase()
++ if (ext !== '' && ext !== '.html' && ext !== '.htm') return 'text'
++ return 'html'
++}
++
++/** Longest request path echoed back on the page; the rest is cut. */
++const MAX_SHOWN_PATH = 200
++
++export function notFoundPage(path: string, links: readonly NotFoundLink[]): string {
++ const shown = path.length > MAX_SHOWN_PATH ? `${path.slice(0, MAX_SHOWN_PATH)}…` : path
++ const items = links
++ .map((l) => `<li><a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a></li>`)
++ .join('')
++ return `<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<meta name="robots" content="noindex">
++<title>Page not found · OpenVibe.Games</title>
++<style>
++:root { color-scheme: dark; }
++* { box-sizing: border-box; margin: 0; }
++body { min-height: 100vh; display: grid; place-items: center; padding: 24px 16px;
++ font-family: system-ui, -apple-system, sans-serif; background: #0a0f1c; color: #e6edf7; }
++main { width: 100%; max-width: 32rem; padding: 32px 28px; border: 1px solid #1f2d47;
++ border-radius: 14px; background: #101828; }
++.code { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.12em; color: #60a5fa; }
++h1 { margin: 6px 0 12px; font-size: 1.6rem; }
++p { color: #96a7c2; line-height: 1.5; }
++code { color: #e6edf7; word-break: break-all; }
++ul { list-style: none; padding: 0; margin-top: 22px; display: flex; flex-wrap: wrap; gap: 10px; }
++a { display: inline-block; padding: 8px 14px; border-radius: 8px; background: #1d4ed8;
++ color: #fff; text-decoration: none; font-weight: 600; }
++a:hover, a:focus-visible { background: #3b82f6; }
++</style>
++</head>
++<body>
++<main>
++<p class="code">404</p>
++<h1>Page not found</h1>
++<p>There is no page at <code>${escapeHtml(shown)}</code>. It may have moved, or the link may be wrong.</p>
++<ul>${items}</ul>
++</main>
++</body>
++</html>
++`
++}
++
++/** Answers 404 for `path` with the body its client can use; HEAD gets the headers only. */
++export function sendNotFound(
++ req: IncomingMessage,
++ res: ServerResponse,
++ path: string,
++ links: readonly NotFoundLink[],
++): void {
++ const kind = notFoundKind(req, path)
++ const body =
++ kind === 'html'
++ ? notFoundPage(path, links)
++ : kind === 'json'
++ ? '{"error":"not_found"}'
++ : 'not found\n'
++ res.writeHead(404, {
++ 'content-type':
++ kind === 'html'
++ ? 'text/html; charset=utf-8'
++ : kind === 'json'
++ ? 'application/json'
++ : 'text/plain; charset=utf-8',
++ 'content-length': Buffer.byteLength(body),
++ 'cache-control': 'no-store',
++ })
++ res.end(req.method === 'HEAD' ? undefined : body)
++}