openvibers/openvibe.games Commits

M1 identity: the WebSocket authenticates at the upgrade, the editor key is gone, and the contracts pin is current (ADR-0007 decisions 8 and 12).

OP OpenVibers committed 29 Sept 2026 · 3ea36b36af · 48 files, +2172 −570

A client first calls POST /api/ws-ticket (signed in: Authorization: Bearer with the Network access token; a guest: its token in the X-Guest-Token header, never the URL) and gets a single-use 32-byte ticket valid for 30 s, bound to the subject or to the hashed guest key. /ws?ticket=… consumes it before the socket is accepted (401 on a missing, bad, expired or reused ticket; 429 past the per-address limit; 503 when the store fails, which fails closed), so a session starts knowing who it is and hello carries no identity. With VALKEY_URL the tickets live in Valkey (SET NX PX, GETDEL) and are single-use across instances; without it an in-process map, swept and capped. Signing in with a guest token adopts that guest's character through the authenticated ticket request, never through hello.

Client addresses for limits and logs come from the TCP peer unless it is a trusted proxy (TRUST_PROXY; production trusts the local nginx, which overwrites X-Real-IP, X-Forwarded-For and CF-Connecting-IP from its Cloudflare-verified address). /api/ws-ticket and the upgrade each have a per-address limit.

EDITOR_KEY, the x-editor-key header, the editor's key prompt and hello.key are deleted: /api/map, /api/map-assets and /editor-ws take a Network session with the staff rank that grants staff.games.manage, and the mods API also a service principal with games.mod.manage. openvibe-contracts goes to v0.77.0; mod manifests validate against the installed package's mods.mod-manifest 1.1.0, and the local schema copy and its proposal docs (adopted by Contracts) are deleted.

Built by DeepSeek from Claude's spec. Reviewed by Claude Sonnet twice: the first review found six problems (a guest token in the URL, a hello.token takeover path, a spoofable upgrade limit, unbounded ticket minting, a mirror-only Valkey store, a tautological manifest test), all fixed; the second found TRUST_PROXY unset in the unit, fixed by Opus with a test that ties the unit to nginx's header overwrites. pnpm typecheck, lint, test 924/924, prettier, the slice test and the server bundle pass; no EDITOR_KEY reference outside the ADR.

Changes

ddiff --git a/.env.example b/.env.example
iindex c94d72c..7f5a1df 100644
---- a/.env.example
++++ b/.env.example
@@@ -19,4 +19,8 @@ VALKEY_PREFIX=ov:games:
# ── Server ──
PORT=8000
HOST=0.0.0.0
++# Proxies whose client-address headers (X-Real-IP, X-Forwarded-For, CF-Connecting-IP) are trusted for per-address
++# limits and logs: empty = none (the TCP peer is the client), true = all (reachable only through the proxy), else a
++# comma-separated IP/CIDR list. Production: 127.0.0.1 (the local nginx, which overwrites those headers).
++TRUST_PROXY=
MAP_PATH=data/map.json
ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex 4df7bf6..4521cc2 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -11,7 +11,7 @@ paths = ['''^packages/(persistence/src/pg/pgStore|protocol/src/codec)\.test\.ts$
regexes = ['''^(tok_12345678|abcdefgh12345678)$''']
[[allowlists]]
--description = "A made-up canonical subject id (usr_ + a ULID-shaped alphabet run) as a test default"
++description = "A made-up canonical subject id (usr_ + a ULID-shaped alphabet run) as a test default and as the identity field hello must ignore"
condition = "AND"
--paths = ['''^apps/server/src/game/flusher\.test\.ts$''']
++paths = ['''^(apps/server/src/game/flusher|packages/protocol/src/codec)\.test\.ts$''']
regexes = ['''^usr_01JABCDEFGHJKMNPQRSTVWXYZ0$''']
ddiff --git a/README.md b/README.md
iindex c0c5b29..b9bd8a9 100644
---- a/README.md
++++ b/README.md
@@@ -31,7 +31,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- OpenVibe.Network (SSO, JWKS, client-credentials tokens, identity resolve, the `games.progress.summary`
user module, mod grants), OpenVibe.Events (outbox relay, subscriptions), OpenVibe.Media (asset mirror)
--- `openvibe-contracts` v0.71.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
++- `openvibe-contracts` v0.77.0, `openvibe-sdk` v0.22.0 and `openvibe-shared` v1.30.1 (pinned by release
tarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite in
development), Valkey (`iovalkey`, optional)
@@@ -113,7 +113,13 @@ DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/c
(the owner role, a direct session). With neither set and `NODE_ENV` not `production`, the server uses
an embedded PGlite database under `DATABASE_DIR` (default `data/`) — development only, one process.
`GAMES_PLACE_ID` (default `scraplandia`) is the place every world row belongs to; `VALKEY_URL`
--(optional) makes the per-actor limits shared across instances. See [.env.example](.env.example).
++(optional) makes the per-actor limits and the WebSocket upgrade tickets shared across instances
++(issue is `SET … PX … NX`, consume is `GETDEL`, so a ticket is single-use cluster-wide).
++`TRUST_PROXY` (optional) says which proxies may set the client-address headers: unset means the TCP
++peer is the client and `X-Forwarded-For`/`X-Real-IP`/`CF-Connecting-IP` are ignored (they are
++client-supplied and would otherwise let a caller mint fresh rate-limit buckets); `true` means the
++server is only reachable through the proxy; otherwise a comma-separated list of trusted proxy
++IPs/CIDRs (behind nginx, which sets `X-Real-IP` from `$remote_addr`). See [.env.example](.env.example).
The server serves the built client, `/healthz` (liveness), `/api/ready`, `/metrics`, and the game
WebSocket on one port. Pages are `/` (portal), `/play` and `/editor`; on play.openvibe.games `/` is
@@@ -175,9 +181,9 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
a restart-safe queue (`media_mirrors`). There are no screenshots or blueprint
files to upload: blueprints are per-player recipe unlocks.
- **Mods** (ADR-013 in OpenVibe.Contracts). Manifests follow
-- `mods/mod-manifest.v1` (published in OpenVibe.Contracts v0.14.0; Games
-- validates with its own copy in `apps/server/src/mods/manifestSchema.ts`;
-- it pins openvibe-contracts v0.71.0). The only runtime today is `games-content@1`:
++ `[email protected]` (Games validates against the schema in the
++ installed `openvibe-contracts` package — pin drift is a defect, ADR-0007
++ decision 12 — currently v0.77.0). The only runtime today is `games-content@1`:
declarative data packs checked against `@openvibe/content` (announcements;
inert, mod-owned props). Each install stores the approved subset of its
requested capabilities; every runtime binding checks it at call time, a
@@@ -243,5 +249,5 @@ capability boundary (editor saves and uploads, the mods API) have per-actor limi
Players are keyed by Network subjects; guest tokens that look like account keys are refused. Mods are
declarative only, each install keeps only its approved capabilities, and executable mods are refused.
nginx keeps `/api/ready` off the public vhosts and `/metrics` answers direct loopback callers only. `OV_OAUTH_CLIENT_SECRET`, `GAMES_EVENTS_SECRET` (signed
--Events deliveries) and `EDITOR_KEY` live in `/etc/openvibe/games.env`, by name only; there are no shared keys
++Events deliveries) and `OV_NETWORK_AUTH_URL` live in `/etc/openvibe/games.env`, by name only; there are no shared keys
between services.
ddiff --git a/apps/client/e2e/editorCheck.ts b/apps/client/e2e/editorCheck.ts
iindex 727e3e8..ca35e1d 100644
---- a/apps/client/e2e/editorCheck.ts
++++ b/apps/client/e2e/editorCheck.ts
@@@ -11,6 +11,8 @@
* pnpm test:editor
*/
import { spawn } from 'node:child_process'
++import { createServer } from 'node:http'
++import type { AddressInfo } from 'node:net'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@@ -79,6 +81,41 @@ writeFileSync(
}),
)
++const TEST_TOKEN = 'test-admin-token'
++/**
++ * The editor authenticates against openvibe.network's /api/auth/me. The legacy editor key
++ * is gone. Spin up a tiny mock that says "yes, staff.games.manage" for the
++ * test bearer and rejects every other token, and point the spawned server
++ * at it.
++ */
++const mockNet = createServer((req, res) => {
++ if (req.url === '/api/auth/me') {
++ const auth = req.headers.authorization ?? ''
++ const bearer = /^Bearer\s+(.+)$/i.exec(auth)?.[1]
++ if (bearer !== TEST_TOKEN) {
++ res.writeHead(401, { 'content-type': 'application/json' })
++ res.end('{"error":"forbidden"}')
++ return
++ }
++ res.writeHead(200, { 'content-type': 'application/json' })
++ res.end(
++ JSON.stringify({
++ user: {
++ id: 1,
++ username: 'admin',
++ role: 'admin',
++ subject_id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ },
++ }),
++ )
++ return
++ }
++ res.writeHead(404)
++ res.end()
++})
++await new Promise<void>((r) => mockNet.listen(0, '127.0.0.1', r))
++const mockNetUrl = `http://127.0.0.1:${(mockNet.address() as AddressInfo).port}/api/auth/me`
++
const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {
env: {
...process.env,
@@@ -89,7 +126,7 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mapPath,
-- EDITOR_KEY: 'test-admin-key',
++ OV_NETWORK_AUTH_URL: mockNetUrl,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
@@@ -705,7 +742,7 @@ async function withMap(
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
-- EDITOR_KEY: 'test-admin-key',
++ OV_NETWORK_AUTH_URL: mockNetUrl,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
@@@ -726,7 +763,12 @@ async function withMap(
try {
const pg = await br.newPage({ viewport: { width: 1400, height: 900 } })
// Asset upload and collaboration both need the editor credential.
-- await pg.addInitScript(() => localStorage.setItem('openvibe.editorkey', 'test-admin-key'))
++ // the legacy editor key is gone: the page reads the SSO access token from the
++ // `ovg_sso` cookie or `ovg_sso` localStorage entry.
++ await pg.addInitScript((t) => {
++ localStorage.setItem('ovg_sso', t as string)
++ document.cookie = `ovg_sso=${encodeURIComponent(t as string)}; Path=/; SameSite=Lax`
++ }, TEST_TOKEN)
pg.on('pageerror', (e) =>
console.log('[pageerror]', String((e as Error).stack ?? e).slice(0, 500)),
)
@@@ -780,7 +822,7 @@ section('O. Live static reconciliation reaches the running server')
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
-- EDITOR_KEY: 'test-admin-key',
++ OV_NETWORK_AUTH_URL: mockNetUrl,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
@@@ -812,7 +854,7 @@ section('O. Live static reconciliation reaches the running server')
method: 'POST',
headers: {
'content-type': 'application/json',
-- 'x-editor-key': 'test-admin-key',
++ authorization: `Bearer ${TEST_TOKEN}`,
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
body: JSON.stringify(map),
@@@ -828,11 +870,11 @@ section('O. Live static reconciliation reaches the running server')
])
const upload = async (
body: Buffer,
-- key = 'test-admin-key',
++ token: string = TEST_TOKEN,
): Promise<{ status: number; json: Record<string, unknown> }> => {
const resp = await fetch(`${root}/api/map-assets`, {
method: 'POST',
-- headers: { 'x-editor-key': key },
++ headers: { authorization: `Bearer ${token}` },
body: new Uint8Array(body),
})
return { status: resp.status, json: (await resp.json()) as Record<string, unknown> }
@@@ -866,8 +908,12 @@ section('O. Live static reconciliation reaches the running server')
const bogus = await upload(Buffer.from('<?php system($_GET[0]); ?>'))
ok('unsupported content is refused, not stored', bogus.status === 400, bogus)
-- const unauth = await upload(png, 'wrong-key')
-- ok('an upload without the editor key is forbidden', unauth.status === 403, unauth)
++ const unauth = await upload(png, 'not-a-real-token')
++ ok(
++ 'an upload with a bearer the mock Network rejects is forbidden',
++ unauth.status === 403,
++ unauth,
++ )
const fetched = await fetch(`${root}${String(first.json['url'])}`)
ok('the stored asset is served back', fetched.status === 200)
@@@ -1037,7 +1083,7 @@ section('U. Live reconciliation touches only what changed')
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
-- EDITOR_KEY: 'test-admin-key',
++ OV_NETWORK_AUTH_URL: mockNetUrl,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
@@@ -1066,7 +1112,7 @@ section('U. Live reconciliation touches only what changed')
method: 'POST',
headers: {
'content-type': 'application/json',
-- 'x-editor-key': 'test-admin-key',
++ authorization: `Bearer ${TEST_TOKEN}`,
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
body: JSON.stringify(map),
ddiff --git a/apps/client/e2e/editorCollabCheck.ts b/apps/client/e2e/editorCollabCheck.ts
iindex a5a6459..61c436a 100644
---- a/apps/client/e2e/editorCollabCheck.ts
++++ b/apps/client/e2e/editorCollabCheck.ts
@@@ -15,13 +15,16 @@
* pnpm test:collab
*/
import { spawn } from 'node:child_process'
++import { createServer } from 'node:http'
++import type { AddressInfo } from 'node:net'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { chromium, type Browser, type JSHandle, type Page } from 'playwright'
const PORT = 18271
--const KEY = 'test-admin-key'
++const TEST_TOKEN = 'test-admin-token'
++const KEY = TEST_TOKEN // back-compat alias used below
const dir = mkdtempSync(join(tmpdir(), 'openvibe-collab-'))
const mapPath = join(dir, 'map.json')
@@@ -60,6 +63,34 @@ writeFileSync(
}),
)
++/** Mock openvibe.network: accepts the test token, returns staff.games.manage. */
++const mockNet = createServer((req, res) => {
++ if (req.url === '/api/auth/me') {
++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
++ if (bearer !== TEST_TOKEN) {
++ res.writeHead(401, { 'content-type': 'application/json' })
++ res.end('{"error":"forbidden"}')
++ return
++ }
++ res.writeHead(200, { 'content-type': 'application/json' })
++ res.end(
++ JSON.stringify({
++ user: {
++ id: 1,
++ username: 'admin',
++ role: 'admin',
++ subject_id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ },
++ }),
++ )
++ return
++ }
++ res.writeHead(404)
++ res.end()
++})
++await new Promise<void>((r) => mockNet.listen(0, '127.0.0.1', r))
++const mockNetUrl = `http://127.0.0.1:${(mockNet.address() as AddressInfo).port}/api/auth/me`
++
const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {
env: {
...process.env,
@@@ -70,7 +101,7 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mapPath,
-- EDITOR_KEY: KEY,
++ OV_NETWORK_AUTH_URL: mockNetUrl,
},
stdio: ['ignore', 'pipe', 'pipe'],
})
@@@ -135,10 +166,12 @@ async function openEditor(browser: Browser, name: string): Promise<Page> {
const context = await browser.newContext({ viewport: { width: 1280, height: 820 } })
const page = await context.newPage()
page.on('pageerror', (e) => console.log(`[${name} pageerror]`, String(e).slice(0, 300)))
-- // Seed the credential and name before boot, so `hello` goes out on connect.
++ // Seed the SSO credential (the legacy editor key is gone) and name before boot, so
++ // the editor's WebSocket hello authorizes as a Network staff session.
await page.addInitScript(
([k, n]) => {
-- localStorage.setItem('openvibe.editorkey', k as string)
++ localStorage.setItem('ovg_sso', k as string)
++ document.cookie = `ovg_sso=${encodeURIComponent(k as string)}; Path=/; SameSite=Lax`
localStorage.setItem('openvibe.editor.name', n as string)
},
[KEY, name],
ddiff --git a/apps/client/src/editor/assets/assetController.test.ts b/apps/client/src/editor/assets/assetController.test.ts
iindex e72bb4c..96abdbd 100644
---- a/apps/client/src/editor/assets/assetController.test.ts
++++ b/apps/client/src/editor/assets/assetController.test.ts
@@@ -62,7 +62,7 @@ const controller = (doc: EditorDocument) => {
doc,
history,
modelCache: { forget: () => undefined, instantiate: () => Promise.resolve(null) } as never,
-- editorKey: () => 'k',
++ editorToken: () => 'k',
onAssetsChanged: () => undefined,
setMessage: (m) => messages.push(m),
})
ddiff --git a/apps/client/src/editor/assets/assetController.ts b/apps/client/src/editor/assets/assetController.ts
iindex aebfa8c..adc4ec7 100644
---- a/apps/client/src/editor/assets/assetController.ts
++++ b/apps/client/src/editor/assets/assetController.ts
@@@ -29,7 +29,7 @@ export interface AssetControllerOptions {
doc: EditorDocument
history: CommandHistory<EditorDocument>
modelCache: ModelCache
-- editorKey: () => string
++ editorToken: () => string
/** Re-register custom textures with the renderer after a change. */
onAssetsChanged: () => void
setMessage: (m: string) => void
@@@ -122,7 +122,7 @@ export class AssetController {
try {
const resp = await fetch('/api/map-assets', {
method: 'POST',
-- headers: { 'x-editor-key': this.opts.editorKey() },
++ headers: { authorization: `Bearer ${this.opts.editorToken()}` },
body: blob,
})
if (resp.status === 403) return 'forbidden'
ddiff --git a/apps/client/src/editor/collaboration/editorConnection.ts b/apps/client/src/editor/collaboration/editorConnection.ts
iindex d141282..42e9274 100644
---- a/apps/client/src/editor/collaboration/editorConnection.ts
++++ b/apps/client/src/editor/collaboration/editorConnection.ts
@@@ -156,7 +156,7 @@ export function createEditorConnection(opts: EditorConnectionOptions): EditorCon
socket = ws
ws.addEventListener('open', () => {
open = true
-- send({ t: 'hello', key, name: opts.nameOf() })
++ send({ t: 'hello', auth: key, name: opts.nameOf() })
startTimers()
})
ws.addEventListener('message', (e) => onMessage(String(e.data)))
ddiff --git a/apps/client/src/editor/editorApp.ts b/apps/client/src/editor/editorApp.ts
iindex d2c7181..e21ef84 100644
---- a/apps/client/src/editor/editorApp.ts
++++ b/apps/client/src/editor/editorApp.ts
@@@ -764,7 +764,7 @@ export async function bootEditor(): Promise<void> {
doc,
history,
modelCache,
-- editorKey: editorToken,
++ editorToken,
onAssetsChanged: () => {
registerCustomTextures(doc.textures() as MapTextureEntry[])
ui.refreshAll()
@@@ -990,18 +990,13 @@ export async function bootEditor(): Promise<void> {
ui.refreshAll()
-- // Seed the credential from the last session so collaboration connects on
-- // boot rather than only after someone retypes the token. Changing it
-- // reconnects deliberately — the socket must not stay authenticated as
-- // whoever was there before. With a OpenVibe SSO session present the
-- // token box is only an override, and says so.
-- const keyInput = document.getElementById('key') as HTMLInputElement
-- keyInput.value = localStorage.getItem('openvibe.editorkey') ?? ''
-- if (ssoToken()) keyInput.placeholder = 'OpenVibe SSO ✓'
-- keyInput.addEventListener('change', () => {
-- localStorage.setItem('openvibe.editorkey', keyInput.value.trim())
-- connection.connect()
-- })
++ // The editor authenticates with the signed-in Network session (ovg_sso).
++ // There is no key prompt to read: the page boots, picks the bearer
++ // cookie, and connects. A sign-out / sign-in flow elsewhere in the app
++ // reloads the editor with the new token.
++ if (!ssoToken()) {
++ ui.setMessage('Sign in with OpenVibe (top right) before editing')
++ }
connection.connect()
// Offer any unsaved work from a previous session. Never automatic: a
// crash recovering itself into everyone else's world would be worse than
ddiff --git a/apps/client/src/editor/net/editorToken.ts b/apps/client/src/editor/net/editorToken.ts
iindex b70cf52..5a2ec4e 100644
---- a/apps/client/src/editor/net/editorToken.ts
++++ b/apps/client/src/editor/net/editorToken.ts
@@@ -1,20 +1,23 @@
/**
-- * Editor credential resolution. An explicitly typed admin token always wins
-- * (the EDITOR_KEY shared-secret path still works without SSO); otherwise the
-- * OpenVibe SSO session from the game (`ovg_sso`, same origin as /play) is
-- * used, so admins and owners never have to paste anything — the server
-- * validates the token against OpenVibe and checks admin/owner rank.
++ * Editor credential resolution (ADR-0007 "Deleted": the shared editor key is gone).
++ *
++ * The editor is a Network staff session with `staff.games.manage`. The same
++ * `ovg_sso` cookie the rest of the site sets is what the editor uses; the
++ * page sends it as a Bearer token on every request. No key prompt, no
++ * manual override.
*/
export function ssoToken(): string {
++ // The shared navbar / OAuth callback stores the Network access token as
++ // `ovg_sso` (see main.ts and frame.js); it also arrives as a cookie. The
++ // editor is same-origin as /play, so it reads the same value back here.
const stored = localStorage.getItem('ovg_sso')
if (stored) return stored
const cookie = document.cookie.split('; ').find((c) => c.startsWith('ovg_sso='))
return cookie ? decodeURIComponent(cookie.slice('ovg_sso='.length)) : ''
}
++/** What every editor call carries as Authorization: Bearer …. */
export function editorToken(): string {
-- const manual =
-- (document.getElementById('key') as HTMLInputElement | null)?.value.trim() ?? ''
-- return manual || ssoToken()
++ return ssoToken()
}
ddiff --git a/apps/client/src/editor/net/saveController.ts b/apps/client/src/editor/net/saveController.ts
iindex b0c8ed4..ab2b92c 100644
---- a/apps/client/src/editor/net/saveController.ts
++++ b/apps/client/src/editor/net/saveController.ts
@@@ -71,7 +71,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
// the last place two authorities described the same map.
const serialize = (): MapFileV2 => doc.serialize()
-- const editorKey = editorToken
++ const tokenOf = editorToken
/**
* Masks stay canvases while editing and become content-addressed assets on
@@@ -91,7 +91,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
try {
const resp = await fetch('/api/map-assets', {
method: 'POST',
-- headers: { 'x-editor-key': editorKey() },
++ headers: { authorization: `Bearer ${tokenOf()}` },
body: blob,
})
if (!resp.ok) continue
@@@ -110,11 +110,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
}
const save = async (): Promise<void> => {
-- const key = editorKey()
-- // Persist only a hand-typed override token — never the SSO session,
-- // which lives in ovg_sso and is resolved fresh on every request.
-- const manual = (document.getElementById('key') as HTMLInputElement | null)?.value.trim() ?? ''
-- localStorage.setItem('openvibe.editorkey', manual)
++ const token = tokenOf()
setMessage('saving…')
await uploadDirtyMasks()
const file = serialize()
@@@ -124,7 +120,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
method: 'POST',
headers: {
'content-type': 'application/json',
-- 'x-editor-key': key,
++ authorization: `Bearer ${token}`,
// The revision we last loaded: the server 409s a stale save rather
// than letting it overwrite another admin's work.
...(baseRevision ? { 'if-match': baseRevision } : {}),
ddiff --git a/apps/client/src/editor/ui/editorShell.ts b/apps/client/src/editor/ui/editorShell.ts
iindex 6705cc1..a65d3e1 100644
---- a/apps/client/src/editor/ui/editorShell.ts
++++ b/apps/client/src/editor/ui/editorShell.ts
@@@ -132,13 +132,6 @@ export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): Shell
spaceBtn.title = 'Transform space (World / Local)'
const right = el('div', 'toolbar-right')
-- const key = el('input', undefined, 'key')
-- key.type = 'password'
-- key.placeholder = 'admin token'
-- key.title =
-- 'Optional override. Signed in with OpenVibe on /play as admin/owner? ' +
-- 'The editor uses that session automatically.'
-- key.size = 14
const save = el('button', 'primary', 'save')
save.type = 'button'
save.textContent = '💾 Save map (applies live)'
@@@ -156,7 +149,7 @@ export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): Shell
settings.type = 'button'
settings.textContent = '⚙'
settings.title = 'Keyboard shortcuts'
-- right.append(key, save, exportBtn, importLabel, settings)
++ right.append(save, exportBtn, importLabel, settings)
toolbar.append(tools, gizmoModes, spaceBtn, right)
// ── Columns ─────────────────────────────────────────────────────────
ddiff --git a/apps/client/src/net/connection.ts b/apps/client/src/net/connection.ts
iindex 8983c65..9c17192 100644
---- a/apps/client/src/net/connection.ts
++++ b/apps/client/src/net/connection.ts
@@@ -10,9 +10,54 @@ import {
/** How long the first WebSocket connect may take before the page falls back to waiting for the server. */
export const CONNECT_TIMEOUT_MS = 10_000
++/** A single-use, 30 s WebSocket ticket. */
++export interface WsTicket {
++ ticket: string
++ expiresAt: number
++}
++
++/**
++ * One call to the games server's POST /api/ws-ticket. The server resolves
++ * identity from the bearer token (a Network access token, the same ovg_sso
++ * cookie the rest of the page sends) OR from the raw guest token and returns
++ * a ticket bound to that identity. The browser then opens /ws?ticket=… and
++ * the upgrade handler validates-and-consumes before accepting the socket.
++ *
++ * The raw guest token travels in the X-Guest-Token HEADER, never the query
++ * string: it is a long-lived credential and must not land in proxy or access
++ * logs, the Referer header or browser history. (The ticket itself is fine in
++ * /ws?ticket= — it is single-use and lasts 30 s.)
++ *
++ * Identity is NEVER taken from `hello`; the upgrade is the only authn gate.
++ */
++export async function fetchWsTicket(args: {
++ guestToken?: string
++ networkToken?: string
++ signal?: AbortSignal
++}): Promise<WsTicket> {
++ // A signed-in caller presents the bearer; the guest token header is the
++ // identity for a guest and the character-adoption hint for a signed-in one.
++ const headers: Record<string, string> = {}
++ if (args.networkToken) headers.authorization = `Bearer ${args.networkToken}`
++ if (args.guestToken) headers['x-guest-token'] = args.guestToken
++ const resp = await fetch('/api/ws-ticket', {
++ method: 'POST',
++ headers,
++ signal: args.signal ?? null,
++ })
++ if (!resp.ok) {
++ throw new Error(`ws-ticket failed: ${resp.status}`)
++ }
++ return (await resp.json()) as WsTicket
++}
++
/**
* WebSocket connection to the dedicated server. Message handling is a
* callback so the network layer stays independent of game/state code.
++ *
++ * The socket is preceded by a successful /api/ws-ticket call: the server
++ * has already resolved and bound the identity, so `hello` only carries
++ * non-identity fields (slot, client version, appearance).
*/
export class Connection {
private ws: WebSocket | null = null
@@@ -25,9 +70,15 @@ export class Connection {
name: string,
appearance: Appearance,
slot = 0,
-- auth?: string,
++ /** Network access token; ignored if absent (the identity is the ticket). */
++ networkToken?: string,
): Promise<void> {
-- const ws = new WebSocket(url)
++ const ticket = await fetchWsTicket({
++ ...(networkToken ? { networkToken } : {}),
++ guestToken: token,
++ })
++ const sep = url.includes('?') ? '&' : '?'
++ const ws = new WebSocket(`${url}${sep}ticket=${encodeURIComponent(ticket.ticket)}`)
this.ws = ws
await new Promise<void>((resolve, reject) => {
// An upgrade sent while the server restarts can wait with no answer (the proxy holds it):
@@@ -57,11 +108,10 @@ export class Connection {
this.send({
t: 'hello',
v: PROTOCOL_VERSION,
-- token,
slot,
name,
appearance,
-- ...(auth ? { auth } : {}),
++ // Identity was resolved at the upgrade; hello carries no identity field.
})
}
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 30ee45f..35394d0 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -20,7 +20,7 @@
"@electric-sql/pglite": "^0.5.8",
"ajv": "^8.17.1",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.77.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1",
"pg": "^8.23.0",
ddiff --git a/apps/server/scripts/sliceTest.ts b/apps/server/scripts/sliceTest.ts
iindex e9f0f71..1671a49 100644
---- a/apps/server/scripts/sliceTest.ts
++++ b/apps/server/scripts/sliceTest.ts
@@@ -177,7 +177,15 @@ class TestClient {
[]
async connect(name: string, token: string, appearance?: Appearance): Promise<void> {
-- this.ws = new WebSocket(URL)
++ // Authenticate at the upgrade: POST /api/ws-ticket first (ADR-0007 netcode).
++ const httpBase = URL.replace(/^ws/, 'http').replace(/\/ws$/, '')
++ const ticketResp = await fetch(`${httpBase}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { 'x-guest-token': token },
++ })
++ if (!ticketResp.ok) throw new Error(`ws-ticket failed: ${ticketResp.status}`)
++ const { ticket } = (await ticketResp.json()) as { ticket: string }
++ this.ws = new WebSocket(`${URL}?ticket=${encodeURIComponent(ticket)}`)
await new Promise<void>((res, rej) => {
this.ws.on('open', () => res())
this.ws.on('error', rej)
@@@ -186,7 +194,6 @@ class TestClient {
this.send({
t: 'hello',
v: PROTOCOL_VERSION,
-- token,
slot: 0,
name,
appearance: appearance ?? defaultAppearance(),
ddiff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts
iindex 618fcd2..77081bb 100644
---- a/apps/server/src/config.test.ts
++++ b/apps/server/src/config.test.ts
@@@ -1,4 +1,4 @@
--import { existsSync } from 'node:fs'
++import { existsSync, readFileSync } from 'node:fs'
import { dirname, isAbsolute, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
@@@ -14,6 +14,28 @@ import { loadConfig } from './config.js'
/** apps/server (the dev cwd), from this test file's location. */
const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
++const REPO_DIR = join(SERVER_DIR, '..', '..')
++
++describe('production client address', () => {
++ // Behind nginx every TCP peer is 127.0.0.1: without TRUST_PROXY the per-address limits collapse into one
++ // global bucket, and trusting nginx is only safe while nginx overwrites every client-address header.
++ it('trusts the local nginx in the unit', () => {
++ const unit = readFileSync(join(REPO_DIR, 'deploy/openvibe-games.service'), 'utf8')
++ expect(unit).toMatch(/^Environment=HOST=127\.0\.0\.1$/m)
++ expect(unit).toMatch(/^Environment=TRUST_PROXY=127\.0\.0\.1$/m)
++ expect(loadConfig({ TRUST_PROXY: '127.0.0.1' }).trustProxy).toEqual(['127.0.0.1'])
++ })
++ it('has nginx overwrite every client-address header it proxies', () => {
++ const conf = readFileSync(join(REPO_DIR, 'deploy/nginx-openvibe-games.conf'), 'utf8')
++ const proxies = conf.match(/proxy_pass /g) ?? []
++ expect(proxies.length).toBeGreaterThan(0)
++ for (const header of ['X-Real-IP', 'X-Forwarded-For', 'CF-Connecting-IP']) {
++ const set = conf.match(new RegExp(`proxy_set_header ${header} \\$remote_addr;`, 'g')) ?? []
++ expect(set.length, header).toBe(proxies.length)
++ }
++ })
++})
++
const originalCwd = process.cwd()
afterEach(() => process.chdir(originalCwd))
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex eab7307..241e532 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -1,4 +1,5 @@
import { MIGRATIONS_DIR } from '@openvibe/persistence'
++import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'
export interface DbConfig {
/** Pooled PostgreSQL URL (through PgBouncer); null in development, where an embedded PGlite is used. */
@@@ -27,7 +28,15 @@ export interface ServerConfig {
/** Directory of built client assets to serve, or null for API/WS only. */
staticDir: string | null
mapPath: string
-- editorKey: string | null
++ /**
++ * TRUST_PROXY: which proxies may set the client-address headers. null = none
++ * (the TCP peer is the client). 'all' = the server is only reachable through
++ * the proxy. Otherwise a list of trusted proxy IPs/CIDRs. Forwarded headers
++ * are ignored from any other peer, so they cannot be spoofed into fresh
++ * rate-limit buckets.
++ */
++ trustProxy: ProxyTrust | null
++ /** openvibe.network /api/auth/me (the editor authorizes through it; the legacy key is gone). */
networkAuthUrl: string | null
/** openvibe.network OAuth client (SSO); null until the secret is configured. */
oauth: {
@@@ -122,8 +131,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
},
staticDir: env.STATIC_DIR ?? null,
mapPath: env.MAP_PATH ?? 'data/map.json',
-- /** Fallback admin secret for the map editor (until openvibe.network SSO). */
-- editorKey: env.EDITOR_KEY ?? null,
++ trustProxy: parseProxyTrust(env.TRUST_PROXY),
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
networkAuthUrl:
env.OV_NETWORK_AUTH_URL ??
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex 513d723..541c27d 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -4,7 +4,7 @@
* protocol-level connections), across a restart:
*
* - a signed-in player is keyed by the canonical subject;
-- * - a guest presenting an account key as its token is refused;
++ * - a session that bypasses the upgrade gate (no stamped identity) is refused;
* - a mod's props enter the world through the runtime seam, a denied
* capability never takes effect, and a revoked mod's props are gone on
* the next tick;
@@@ -33,6 +33,7 @@ import { createMockPlatform } from 'openvibe-sdk/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { loadConfig } from '../config.js'
import { loadHavok } from '../havokLoader.js'
++import { hashGuestKey } from '../net/guestIdentity.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
import { ModRuntime } from '../mods/runtime.js'
@@@ -129,18 +130,22 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
function connect(game: GameServer) {
const inbox: ServerMessage[] = []
let closed: { code: number; reason: string } | null = null
++ // The session system now requires an identity stamped on the connection
++ // (the WS upgrade handler is the only path that sets it). The test
++ // harness calls `game.onMessage` directly, so we mirror what the
++ // upgrade would have done: peek at the next hello to decide subject vs
++ // guest, then stamp the conn just before invoking the message handler.
const conn: GameConnection = {
send: (text) => inbox.push(JSON.parse(text) as ServerMessage),
close: (code, reason) => {
closed = { code, reason }
},
}
-- const hello = async (token: string, auth?: string) => {
++ const sendHello = async (): Promise<void> => {
++ // hello carries no identity field (ADR-0007 decision 8).
const msg = {
t: 'hello',
v: PROTOCOL_VERSION,
-- token,
-- ...(auth ? { auth } : {}),
slot: 0,
name: 'Ana',
appearance: defaultAppearance(),
@@@ -149,9 +154,24 @@ function connect(game: GameServer) {
for (let i = 0; i < 100 && !closed && !inbox.some((m) => m.t === 'welcome'); i++) {
await new Promise((r) => setTimeout(r, 5))
}
++ }
++ const hello = async (token: string, auth?: string) => {
++ // Mirror what the upgrade gate stamps: a subject ticket (carrying the
++ // hashed guest key as the adoption hint, as POST /api/ws-ticket does) or
++ // a guest ticket bound to sha256hex(raw token) — never hello's fields.
++ conn.identity = auth
++ ? { subject: SUBJECT, rank: 'admin', authIat: null, adoptGuestKeyHash: hashGuestKey(token) }
++ : { guestKeyHash: hashGuestKey(token) }
++ await sendHello()
++ delete conn.identity
++ return { welcome: inbox.find((m) => m.t === 'welcome'), closed: closed as typeof closed }
++ }
++ /** A raw hello on a connection the transport never authenticated. */
++ const helloWithoutIdentity = async () => {
++ await sendHello()
return { welcome: inbox.find((m) => m.t === 'welcome'), closed: closed as typeof closed }
}
-- return { conn, inbox, hello }
++ return { conn, inbox, hello, helloWithoutIdentity }
}
const propsOwnedBy = (world: GameWorld, owner: string) =>
@@@ -172,11 +192,14 @@ describe('Games on platform identity, events and mods (real server, restart)', (
it('runs identity, mods and events against the live world', async () => {
const a = await boot(platform)
-- // A guest cannot claim an account key as its token.
-- const intruder = connect(a.game)
-- const refused = await intruder.hello(SUBJECT)
++ // The upgrade gate is the only identity source (ADR-0007 decision 8):
++ // a hello that arrives on a connection the transport never stamped is
++ // refused before any world state is touched. (Account-shaped guest
++ // tokens are refused even earlier, at /api/ws-ticket.)
++ const unauthenticated = connect(a.game)
++ const refused = await unauthenticated.helloWithoutIdentity()
expect(refused.welcome).toBeUndefined()
-- expect(refused.closed).toEqual({ code: 4007, reason: 'invalid_guest_token' })
++ expect(refused.closed).toEqual({ code: 4012, reason: 'no_identity' })
// The signed-in player is keyed by the canonical subject.
const ana = connect(a.game)
ddiff --git a/apps/server/src/game/systems/context.ts b/apps/server/src/game/systems/context.ts
iindex 2ce0157..6a95d21 100644
---- a/apps/server/src/game/systems/context.ts
++++ b/apps/server/src/game/systems/context.ts
@@@ -4,6 +4,7 @@ import type { BodyId } from '@openvibe/physics'
import type { ServerMessage, ServerStats } from '@openvibe/protocol'
import type { EntityId, Logger, PlayerId } from '@openvibe/shared'
import type { ServerConfig } from '../../config.js'
++import type { TicketIdentity } from '../../net/wsTicket.js'
import type { ModRuntime } from '../../mods/runtime.js'
import type { ServerMetrics } from '../../observability/metrics.js'
import type { GameEventRecorder } from '../../platform/gameEvents.js'
@@@ -29,12 +30,31 @@ import type { SocialSystem } from './social.js'
import type { VehicleSystem } from './vehicles.js'
import type { WorldEventSystem } from './worldEvents.js'
--/** A network connection as the game sees it — transport-agnostic. */
++/**
++ * A network connection as the game sees it — transport-agnostic.
++ *
++ * `identity` is the resolved identity stamped on the socket by the WS
++ * upgrade handler after it consumed the ticket. The session system reads
++ * this and ignores `msg.auth` (ADR-0007 netcode + decision 8).
++ */
export interface GameConnection {
send(text: string): void
close(code: number, reason: string): void
++ /**
++ * Either a Network subject (signed-in caller) or a hashed guest key
++ * (local guest). Exactly one per connection; absent only when the
++ * upgrade handler bypassed ticket validation, which the session system
++ * treats as a connection error.
++ */
++ identity?: ResolvedConnectionIdentity
}
++/**
++ * The identity bound to a WS connection at the upgrade: exactly the ticket
++ * identity, so the two shapes cannot drift.
++ */
++export type ResolvedConnectionIdentity = TicketIdentity
++
/**
* Platform adapters the server drives (roadmap Wave 12). Both optional: the
* game runs exactly as before without them.
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 7ca78e8..35f74a9 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -3,26 +3,12 @@ import { Inventory, SkillSet, type GameEntity } from '@openvibe/gameplay'
import { CollisionLayer } from '@openvibe/physics'
import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'
--import { resolveNetworkUser } from '../../net/networkAuth.js'
--import { isGuestToken } from '../../platform/accounts.js'
import type { GameConnection, ServerContext } from './context.js'
import { MOVE } from './movement.js'
import { eventPlayer, progressOf } from './platform.js'
import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'
import type { HandlerMap, System } from './system.js'
--/** A JWT's iat (seconds), read from a token the Network has already accepted; null otherwise. */
--function tokenIat(jwt: string): number | null {
-- const part = jwt.split('.')[1]
-- if (!part) return null
-- try {
-- const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { iat?: unknown }
-- return typeof claims.iat === 'number' ? claims.iat : null
-- } catch {
-- return null
-- }
--}
--
/**
* Connect, hello, disconnect, supersede and revocation close: everything about a session's
* lifetime in the world. It owns no gameplay state — the session record itself is the state, and
@@@ -41,12 +27,18 @@ export class SessionsSystem implements System {
hello: (session, msg, conn) => {
// A session already exists: a duplicate hello is silently ignored.
if (session) return
-- // A second hello while the first is still resolving (it awaits Network) is refused, so two
++ // A second hello while the first is still resolving is refused, so two
// frames cannot both create a session for one connection.
if (this.hellosInFlight.has(conn)) {
conn.close(4008, 'hello_in_flight')
return
}
++ // No identity on the upgrade? The transport must have validated a
++ // ticket before accepting the socket — refuse anyone who bypasses it.
++ if (!conn.identity) {
++ conn.close(4012, 'no_identity')
++ return
++ }
void this.handleHello(conn, msg as ClientHello)
},
}
@@@ -106,49 +98,51 @@ export class SessionsSystem implements System {
}
const slot = msg.slot ?? 0
-- // Account resolution. openvibe.network sign-in keys the account on the
-- // canonical subject (3 slots, follows you across devices). Guests get ONE
-- // character bound to their connection's browser token.
-- let token = msg.token
++ // Identity was resolved at the WS upgrade (ADR-0007 netcode). hello
++ // carries slot, client version and appearance only; any auth field is
++ // ignored — there is no old hello-identity fallback (owner rule).
++ const id = conn.identity
++ if (!id) {
++ conn.close(4012, 'no_identity')
++ return
++ }
++ let token: string
let subjectId: string | null = null
let authIat: number | null = null
let rank: 'owner' | 'admin' | 'moderator' | null = null
-- if (msg.auth) {
-- const user = await resolveNetworkUser(ctx.config.networkAuthUrl, msg.auth)
-- // A signed-in account is keyed by its canonical subject (ADR-0006); a
-- // token that carries none cannot name an account.
-- if (!user || !user.subjectId) {
-- conn.send(encodeServerMessage({ t: 'reject', reason: 'auth_failed' }))
-- conn.close(4009, 'auth_failed')
-- return
-- }
-- token = user.subjectId
-- subjectId = user.subjectId
-- authIat = tokenIat(msg.auth)
-- rank = user.rank
-- // Guest conversion (WS-B task 8): the character this browser played as a guest joins the
-- // account in its first free slot, once.
-- if (isGuestToken(msg.token)) {
-- const g = await ctx.store.identity.adoptGuestCharacter(msg.token, subjectId, Date.now())
++ if ('subject' in id) {
++ // A signed-in account is keyed by its canonical subject (3 slots,
++ // follows you across devices).
++ subjectId = id.subject
++ token = id.subject
++ authIat = id.authIat
++ rank = id.rank
++ // Guest conversion (WS-B task 8): the ticket request carried the guest
++ // token (hashed at POST /api/ws-ticket); adoptGuestCharacter's
++ // accountKey() is idempotent on `guest:<sha256>`, so the key is passed
++ // as-is. `hello.token` is never an input.
++ if (id.adoptGuestKeyHash) {
++ const g = await ctx.store.identity.adoptGuestCharacter(
++ `guest:${id.adoptGuestKeyHash}`,
++ subjectId,
++ Date.now(),
++ )
if (g.moved > 0)
ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
else if (g.full)
ctx.log.info('guest character kept: account slots full', { subject: subjectId })
}
} else {
-- // A guest token may never look like an account key (`usr_…`): that
-- // would open someone else's characters. The client mints plain
-- // alphanumerics; anything key-shaped is refused.
-- if (!isGuestToken(msg.token)) {
-- conn.send(encodeServerMessage({ t: 'reject', reason: 'invalid_hello' }))
-- conn.close(4007, 'invalid_guest_token')
-- return
-- }
++ // Guests get ONE character bound to the hashed guest key the ticket
++ // carried. The raw token never reaches the server here: `guest:` +
++ // sha256hex is exactly the account_key the repository stores, so
++ // hello's `token` is ignored entirely (ADR-0007 decision 8).
if (slot > 0) {
conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))
conn.close(4010, 'guest_one_character')
return
}
++ token = `guest:${id.guestKeyHash}`
}
// A disconnect save of this character may still be queued: read after it lands, not before.
await ctx.systems.persistence.drain(5000)
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 2f943a2..368d333 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'
++import type { IncomingMessage } from 'node:http'
import { dirname, join } from 'node:path'
import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'
import { createValkey } from 'openvibe-sdk/valkey'
@@@ -22,7 +23,8 @@ import {
} from './net/gracefulStop.js'
import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.js'
--import { attachWebSocket } from './net/wsTransport.js'
++import { acceptUpgrade, attachWebSocket } from './net/wsTransport.js'
++import { resolveClientAddress } from './net/clientAddress.js'
import { ServerMetrics } from './observability/metrics.js'
import { createReadiness } from './observability/readiness.js'
import { buildRelease, releaseHandler } from './observability/release.js'
@@@ -49,6 +51,7 @@ import {
ensureRevocationSubscription,
} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
++import { createWsTicketStore } from './net/wsTicket.js'
/**
* Dedicated authoritative server entry point.
@@@ -184,7 +187,6 @@ async function main(): Promise<void> {
const authorizeStaff = createStaffAuthorizer({
networkAuthUrl: config.networkAuthUrl,
networkUrl: config.platform.networkUrl,
-- editorKey: config.editorKey,
})
const pruneTimer = setInterval(() => void outbox?.prune(), 6 * 3600 * 1000)
pruneTimer.unref()
@@@ -280,13 +282,17 @@ async function main(): Promise<void> {
onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),
valkey,
})
++ const ticketStore = createWsTicketStore({ valkey })
++ // One trusted-proxy-aware address resolution for the upgrade limit, the
++ // ticket limit and logs (config.TRUST_PROXY; the socket address otherwise).
++ const clientAddress = (req: IncomingMessage): string =>
++ resolveClientAddress(req, config.trustProxy)
const http = createHttpServer(
config.staticDir,
metrics,
log.child({ system: 'http' }),
config.mapPath,
{
-- key: config.editorKey,
networkAuthUrl: config.networkAuthUrl,
},
// LIVE map apply. Receives the validated v2 DOCUMENT, not a JSON string
@@@ -393,15 +399,22 @@ async function main(): Promise<void> {
},
onAssetStored: (asset) => mirror?.enqueue(asset),
limits: actorLimits,
++ clientAddress,
},
++ ticketStore,
)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapProps()
-- const gameWss = attachWebSocket(http, game, log.child({ system: 'ws' }))
++ const gameWss = attachWebSocket(http, game, {
++ tickets: ticketStore,
++ upgradeLimits: actorLimits,
++ clientAddress,
++ log: log.child({ system: 'ws' }),
++ })
const editors = attachEditorWs(
http,
-- { key: config.editorKey, networkAuthUrl: config.networkAuthUrl },
++ { networkAuthUrl: config.networkAuthUrl },
log.child({ system: 'editor-ws' }),
)
http.on('upgrade', (req, socket, head) => {
@@@ -410,7 +423,26 @@ async function main(): Promise<void> {
// Stopping: no new sessions (the client retries after the restart).
socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nRetry-After: 5\r\n\r\n')
} else if (path === '/ws') {
-- gameWss.handleUpgrade(req, socket, head, (ws) => gameWss.emit('connection', ws, req))
++ // Authenticate at the upgrade: validate-and-consume the ticket before
++ // ws.handleUpgrade runs (ADR-0007 netcode + decision 8).
++ void acceptUpgrade(
++ ticketStore,
++ actorLimits,
++ log.child({ system: 'ws-upgrade' }),
++ req,
++ socket,
++ clientAddress,
++ )
++ .then((ok) => {
++ if (!ok) return
++ gameWss.handleUpgrade(req, socket, head, (ws) => gameWss.emit('connection', ws, req))
++ })
++ .catch((err: unknown) => {
++ // A failure here must never become an unhandled rejection: refuse
++ // the upgrade and let the client retry.
++ log.warn('ws upgrade failed', { error: err instanceof Error ? err.message : String(err) })
++ socket.destroy()
++ })
} else if (path === '/editor-ws') {
editors.wss.handleUpgrade(req, socket, head, (ws) => editors.wss.emit('connection', ws, req))
} else {
ddiff --git a/apps/server/src/mods/manifest.test.ts b/apps/server/src/mods/manifest.test.ts
iindex 9089d9e..16c7816 100644
---- a/apps/server/src/mods/manifest.test.ts
++++ b/apps/server/src/mods/manifest.test.ts
@@@ -1,20 +1,113 @@
--import { readFileSync } from 'node:fs'
++import { createRequire } from 'node:module'
++import { Ajv2020 } from 'ajv/dist/2020.js'
import { createContent } from '@openvibe/content'
import { describe, expect, it } from 'vitest'
import { validateContentPack } from './contentPack.js'
--import { checkForGames, validateManifest } from './manifest.js'
--import { MOD_MANIFEST_SCHEMA } from './manifestSchema.js'
--import { sampleManifest } from './testFixtures.js'
++import { checkForGames, MOD_MANIFEST_REF, validateManifest } from './manifest.js'
++import { MOD_ID, sampleManifest } from './testFixtures.js'
++
++interface ContractsModule {
++ schema(ref: string): Record<string, unknown>
++}
++const contracts = createRequire(import.meta.url)('openvibe-contracts') as ContractsModule
describe('mod manifest schema', () => {
-- it('is the same document as the Contracts proposal', () => {
-- const proposal: unknown = JSON.parse(
-- readFileSync(
-- new URL('../../../../docs/contracts-proposal/mods/mod-manifest.v1.json', import.meta.url),
-- 'utf8',
-- ),
-- )
-- expect(MOD_MANIFEST_SCHEMA).toEqual(proposal)
++ // 1.1.0 (openvibe-contracts v0.34.0) added permissions.readGrants and
++ // writeGrants, billingHooks and dependencies; a 1.0 structure rejects them
++ // (additionalProperties: false). Exercising them is what makes the
++ // agreement test below a real drift detector: a local 1.0 copy that happens
++ // to match the minimal fixtures would fail here.
++ const minimalV11 = {
++ ...sampleManifest(),
++ permissions: {
++ capabilities: ['games.world.announce'],
++ readGrants: { modules: ['other.read'] },
++ writeGrants: { mediaNamespaces: ['games'] },
++ },
++ billingHooks: [{ kind: 'entitlement', key: 'vip.plan:pln_01jab' }],
++ dependencies: [{ id: MOD_ID, version: '>=1.0.0' }],
++ } as unknown as Record<string, unknown>
++
++ it('validates against the installed contracts schema for mods.mod-manifest@1, version 1.1.0', () => {
++ // The installed package advertises the version the pin claims. Neither the
++ // schema nor contracts exposes a machine-readable version field, so the
++ // canonical $id and the description's version marker are asserted here.
++ const schema = contracts.schema(MOD_MANIFEST_REF) as { $id?: string; description?: string }
++ expect(schema.$id).toBe('https://openvibe.network/contracts/mods/mod-manifest.v1.json')
++ expect(schema.description).toContain('1.1.0')
++ // The 1.1.0-only fields are accepted: proof validateManifest is using the
++ // package's 1.1.0 schema, not a stale local 1.0 copy.
++ expect(validateManifest(minimalV11).ok).toBe(true)
++ })
++
++ it('agrees with the installed contracts package on every sample manifest', () => {
++ // Pin drift is a defect (ADR-0007 decision 12): whenever Games accepts a
++ // manifest, the installed contracts package must accept it too, and the
++ // other way round. Both validators must return the SAME verdict for
++ // every case, valid or invalid — if a future change reintroduces a local
++ // schema copy, this fails the moment the two disagree.
++ const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false })
++ ajv.addSchema(contracts.schema('identity.subject-ref@1'))
++ ajv.addSchema(contracts.schema('media.media-ref@1'))
++ const validate = ajv.compile(contracts.schema(MOD_MANIFEST_REF))
++
++ const minimal = sampleManifest()
++ const missingRequired = { ...minimal } as Record<string, unknown>
++ delete missingRequired['version']
++ const cases: [string, unknown][] = [
++ [
++ 'the full fixture (assets, modules, mediaNamespaces, events, homepage)',
++ sampleManifest({
++ assets: [{ media_id: 'med_01JABCDEFGHJKMNPQRSTVWXYZ0', role: 'icon' }],
++ homepage: 'https://openvibe.games/mods/town-square',
++ permissions: {
++ capabilities: ['games.world.announce'],
++ events: ['games.player.joined'],
++ modules: ['games.progress.summary'],
++ mediaNamespaces: ['games'],
++ },
++ }),
++ ],
++ ['the minimal fixture', minimal],
++ ['the 1.1.0-only fields (readGrants, writeGrants, billingHooks, dependencies)', minimalV11],
++ ['a 1.0 structure that omits the 1.1.0 fields', minimal],
++ ['a non-conforming id', { ...minimal, id: 'my-mod' }],
++ ['an unknown top-level property', { ...minimal, extra: true }],
++ ['a missing required field', missingRequired],
++ [
++ 'a bad 1.1.0 billingHooks kind',
++ { ...minimalV11, billingHooks: [{ kind: 'nope', key: 'vip.plan:x' }] },
++ ],
++ [
++ 'a 1.1.0 dependency id that is not a mod ULID',
++ { ...minimalV11, dependencies: [{ id: 'other', version: '>=1.0.0' }] },
++ ],
++ ]
++ for (const [label, manifest] of cases) {
++ expect(validateManifest(manifest).ok, `Games vs contracts: ${label}`).toBe(
++ Boolean(validate(manifest)),
++ )
++ }
++ })
++
++ it('has an independent expected verdict for each 1.1.0 delta case', () => {
++ // A second oracle, not agreement: the explicit verdicts these fields must
++ // get, so a future schema bump that Games silently ignores is caught even
++ // if both validators are (wrongly) reading the same stale source.
++ expect(validateManifest(minimalV11).ok).toBe(true)
++ expect(
++ validateManifest({
++ ...minimalV11,
++ permissions: { capabilities: ['games.world.announce'], readGrants: {} },
++ }).ok,
++ ).toBe(false)
++ expect(
++ validateManifest({
++ ...minimalV11,
++ billingHooks: [{ kind: 'refund', key: 'vip.plan:x' }],
++ }).ok,
++ ).toBe(false)
++ expect(validateManifest({ ...minimalV11, dependencies: [{ id: MOD_ID }] }).ok).toBe(false)
})
it('accepts a complete manifest, including Media asset refs', () => {
ddiff --git a/apps/server/src/mods/manifest.ts b/apps/server/src/mods/manifest.ts
iindex 03419f8..738da37 100644
---- a/apps/server/src/mods/manifest.ts
++++ b/apps/server/src/mods/manifest.ts
@@@ -1,21 +1,22 @@
/**
-- * Mod manifest validation (mods/mod-manifest.v1, ADR-013).
++ * Mod manifest validation ([email protected], ADR-013).
*
-- * Structure is checked against the JSON Schema itself (Ajv, draft 2020-12,
-- * with the SubjectRef and MediaRef contracts from openvibe-contracts), so the
-- * schema proposed to Contracts and the one Games enforces are the same
-- * document. `checkForGames` adds what only this runtime can judge.
++ * Structure is checked against the JSON Schema in the INSTALLED
++ * openvibe-contracts package, so the schema Games enforces and the one
++ * released on Contracts can never drift (ADR decision 12). `checkForGames`
++ * adds what only this runtime can judge.
*/
import { createRequire } from 'node:module'
import { Ajv2020 } from 'ajv/dist/2020.js'
import { satisfiesRange } from 'openvibe-sdk/core'
--import { MOD_MANIFEST_SCHEMA } from './manifestSchema.js'
interface ContractsModule {
schema(ref: string): Record<string, unknown>
}
const contracts = createRequire(import.meta.url)('openvibe-contracts') as ContractsModule
++export const MOD_MANIFEST_REF = 'mods.mod-manifest@1'
++
export interface ModManifest {
id: string
name: string
@@@ -39,11 +40,11 @@ export interface ModManifest {
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false })
ajv.addSchema(contracts.schema('identity.subject-ref@1'))
ajv.addSchema(contracts.schema('media.media-ref@1'))
--const validateSchema = ajv.compile(MOD_MANIFEST_SCHEMA)
++const validateSchema = ajv.compile(contracts.schema(MOD_MANIFEST_REF))
export type Validation<T> = { ok: true; value: T } | { ok: false; errors: string[] }
--/** Structural validation against mods/mod-manifest.v1. */
++/** Structural validation against [email protected]. */
export function validateManifest(value: unknown): Validation<ModManifest> {
if (validateSchema(value)) return { ok: true, value: value as unknown as ModManifest }
return {
ddiff --git a/apps/server/src/mods/manifestSchema.ts b/apps/server/src/mods/manifestSchema.ts
ddeleted file mode 100644
iindex 3c8f8ad..0000000
---- a/apps/server/src/mods/manifestSchema.ts
++++ /dev/null
@@@ -1,179 +0,0 @@
--/**
-- * The mod manifest JSON Schema (mods/mod-manifest.v1) as Games validates it.
-- * The proposal for OpenVibe.Contracts is docs/contracts-proposal/mods/mod-manifest.v1.json;
-- * manifest.test.ts fails if the two ever differ. Once Contracts releases the
-- * schema, import it from openvibe-contracts instead of keeping this copy.
-- */
--export const MOD_MANIFEST_SCHEMA = {
-- $schema: 'https://json-schema.org/draft/2020-12/schema',
-- $id: 'https://openvibe.network/contracts/mods/mod-manifest.v1.json',
-- title: 'ModManifest',
-- description:
-- "A mod as the platform knows it (ADR-013): who publishes it, which runtime runs it, which capabilities it asks for and the resources it may use. Requested capabilities are only a request: the install's approved subset is the grant, and trust tiers are install metadata that never change a grant check. The runtime-specific payload (a data pack, a script bundle) is not part of the manifest.",
-- type: 'object',
-- required: [
-- 'id',
-- 'name',
-- 'version',
-- 'publisher',
-- 'target',
-- 'runtime',
-- 'permissions',
-- 'resources',
-- 'compatibility',
-- ],
-- additionalProperties: false,
-- properties: {
-- id: {
-- type: 'string',
-- pattern: '^mod_[0-9A-HJKMNP-TV-Z]{26}$',
-- description: 'Stable mod id; its principal subject is mod:<id>.',
-- },
-- name: {
-- type: 'string',
-- minLength: 1,
-- maxLength: 80,
-- },
-- version: {
-- type: 'string',
-- pattern: '^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$',
-- description: 'Semantic version of this release.',
-- },
-- description: {
-- type: 'string',
-- maxLength: 1000,
-- },
-- publisher: {
-- $ref: '../identity/subject-ref.v1.json',
-- description: 'Who publishes the mod (a user or an app subject).',
-- },
-- target: {
-- type: 'string',
-- pattern: '^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$',
-- description:
-- 'Where the mod runs: <service>.<surface>, e.g. games.browser, games.source, live.overlay.',
-- },
-- runtime: {
-- type: 'string',
-- pattern: '^[a-z][a-z0-9-]{1,39}@[1-9][0-9]{0,3}$',
-- description:
-- 'Runtime adapter and its major version, e.g. games-content@1 (declarative data pack) or source-quickjs@1.',
-- },
-- permissions: {
-- type: 'object',
-- required: ['capabilities'],
-- additionalProperties: false,
-- properties: {
-- capabilities: {
-- description:
-- 'Capability ids the mod asks for (3+ segments). The target runtime binds only those it implements, and only once granted.',
-- type: 'array',
-- maxItems: 64,
-- uniqueItems: true,
-- items: {
-- type: 'string',
-- pattern: '^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$',
-- },
-- },
-- events: {
-- description: 'Event types the mod wants delivered to it.',
-- type: 'array',
-- maxItems: 64,
-- uniqueItems: true,
-- items: {
-- type: 'string',
-- pattern: '^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$',
-- },
-- },
-- modules: {
-- description:
-- 'User-module namespaces the mod wants to read or write (a trailing .* names a family).',
-- type: 'array',
-- maxItems: 32,
-- uniqueItems: true,
-- items: {
-- type: 'string',
-- pattern: '^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*(\\.\\*)?$',
-- },
-- },
-- mediaNamespaces: {
-- description: 'Media namespaces the mod wants to read or write.',
-- type: 'array',
-- maxItems: 32,
-- uniqueItems: true,
-- items: {
-- type: 'string',
-- pattern: '^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$',
-- },
-- },
-- },
-- },
-- resources: {
-- type: 'object',
-- required: ['cpuMs', 'memoryMb', 'storageMb'],
-- additionalProperties: false,
-- description:
-- 'The budget the mod asks for. Runtimes meter it; the sandbox that enforces it lives in OpenVibe.Host (Stage C).',
-- properties: {
-- cpuMs: {
-- type: 'number',
-- minimum: 0,
-- maximum: 1000,
-- description: 'CPU milliseconds per tick (game runtimes) or per request.',
-- },
-- memoryMb: {
-- type: 'integer',
-- minimum: 0,
-- maximum: 4096,
-- },
-- storageMb: {
-- type: 'integer',
-- minimum: 0,
-- maximum: 102400,
-- },
-- outboundHosts: {
-- description: 'Hosts the mod may reach over the network. Empty or absent = none.',
-- type: 'array',
-- maxItems: 32,
-- uniqueItems: true,
-- items: {
-- type: 'string',
-- pattern: '^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$',
-- },
-- },
-- },
-- },
-- assets: {
-- description: 'Assets the mod ships, as Media object references.',
-- type: 'array',
-- maxItems: 256,
-- items: {
-- $ref: '../media/media-ref.v1.json',
-- },
-- },
-- compatibility: {
-- type: 'object',
-- required: ['runtime'],
-- additionalProperties: false,
-- properties: {
-- runtime: {
-- type: 'string',
-- minLength: 1,
-- maxLength: 100,
-- description:
-- 'Semver range of the runtime this release works with, e.g. ">=1.0.0 <2.0.0".',
-- },
-- contracts: {
-- type: 'string',
-- minLength: 1,
-- maxLength: 100,
-- description: 'Semver range of openvibe-contracts releases it was built against.',
-- },
-- },
-- },
-- homepage: {
-- type: 'string',
-- pattern: '^https://[^\\s]{1,2000}$',
-- },
-- },
--} as const
ddiff --git a/apps/server/src/mods/routes.test.ts b/apps/server/src/mods/routes.test.ts
iindex 7e32d57..3e543bb 100644
---- a/apps/server/src/mods/routes.test.ts
++++ b/apps/server/src/mods/routes.test.ts
@@@ -116,7 +116,6 @@ describe('staff authorization', () => {
const authorize = createStaffAuthorizer({
networkAuthUrl: null,
networkUrl: platform.origins.network,
-- editorKey: null,
verifyPrincipal: (t) =>
verifyUserToken(t, {
jwks: platform.keys.jwks,
@@@ -149,19 +148,18 @@ describe('staff authorization', () => {
expect(await authorize(req('not-a-token'))).toBeNull()
})
-- it('falls back to the editor key only without a Network', async () => {
++ it('refuses every credential without Network configured', async () => {
++ // The legacy shared secret is gone: with no Network endpoint and no
++ // Authorization bearer, every request is anonymous and refused.
const authorize = createStaffAuthorizer({
networkAuthUrl: null,
networkUrl: 'http://x',
-- editorKey: 'k3y',
})
-- const withKey = {
-- headers: { 'x-editor-key': 'k3y' },
-- } as unknown as IncomingMessage
-- expect(await authorize(withKey)).toMatchObject({ audit: 'editor-key' })
-- const wrong = {
-- headers: { 'x-editor-key': 'nope' },
-- } as unknown as IncomingMessage
-- expect(await authorize(wrong)).toBeNull()
++ expect(
++ await authorize({
++ headers: { authorization: 'Bearer anything' },
++ } as unknown as IncomingMessage),
++ ).toBeNull()
++ expect(await authorize({ headers: {} } as unknown as IncomingMessage)).toBeNull()
})
})
ddiff --git a/apps/server/src/net/actorLimits.ts b/apps/server/src/net/actorLimits.ts
iindex fd48b65..bdf674b 100644
---- a/apps/server/src/net/actorLimits.ts
++++ b/apps/server/src/net/actorLimits.ts
@@@ -41,6 +41,13 @@ export function createGamesActorLimits(
const asset = limiter('games.map.asset', { minute: 60, hour: 1200 })
// Mod installs, grants and revokes each ask OpenVibe.Network first.
const modWrite = limiter('games.mod.write', { minute: 30, hour: 300 })
++ // Ticket minting, counted per address: without this an unauthenticated
++ // caller could mint tickets faster than the store sweeps them (ADR-0007
++ // decision 8; the store is also capped and swept on issue).
++ const ticket = limiter('games.ws.ticket', { minute: 60, hour: 1200 })
++ // WebSocket upgrades, counted per address BEFORE the ticket is consumed: a
++ // flood must not get to spend tickets or hold sockets (ADR-0007 decision 8).
++ const upgrade = limiter('games.ws.upgrade', { minute: 90, hour: 1800 })
const run =
(mw: ReturnType<typeof limiter>) =>
(req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
@@@ -57,6 +64,8 @@ export function createGamesActorLimits(
mapSave: run(mapSave),
asset: run(asset),
modWrite: run(modWrite),
++ ticket: run(ticket),
++ upgrade: run(upgrade),
}
}
ddiff --git a/apps/server/src/net/clientAddress.test.ts b/apps/server/src/net/clientAddress.test.ts
nnew file mode 100644
iindex 0000000..93d64b3
---- /dev/null
++++ b/apps/server/src/net/clientAddress.test.ts
@@@ -0,0 +1,72 @@
++import type { IncomingMessage } from 'node:http'
++import { describe, expect, it } from 'vitest'
++import { parseProxyTrust, resolveClientAddress } from './clientAddress.js'
++
++/**
++ * Client-address resolution behind a proxy (finding 3). Forwarded headers are
++ * client-supplied: they must be honoured only when the TCP peer is a trusted
++ * proxy, otherwise a caller mints unlimited rate-limit buckets by varying
++ * X-Forwarded-For. nginx sets X-Real-IP from $remote_addr; Cloudflare sets
++ * CF-Connecting-IP.
++ */
++function req(remoteAddress: string, headers: Record<string, string> = {}): IncomingMessage {
++ return { headers, socket: { remoteAddress } } as unknown as IncomingMessage
++}
++
++describe('resolveClientAddress', () => {
++ it('uses the socket address and ignores forwarded headers when nothing is trusted', () => {
++ const r = req('203.0.113.5', {
++ 'x-forwarded-for': '1.2.3.4',
++ 'x-real-ip': '1.2.3.4',
++ 'cf-connecting-ip': '1.2.3.4',
++ })
++ expect(resolveClientAddress(r, null)).toBe('203.0.113.5')
++ })
++
++ it('honours CF-Connecting-IP from a trusted peer, then X-Real-IP, then X-Forwarded-For', () => {
++ const trust = ['10.0.0.0/8']
++ const all = {
++ 'cf-connecting-ip': '198.51.100.1',
++ 'x-real-ip': '198.51.100.2',
++ 'x-forwarded-for': '198.51.100.3',
++ }
++ expect(resolveClientAddress(req('10.0.0.5', all), trust)).toBe('198.51.100.1')
++ const noCf = { 'x-real-ip': '198.51.100.2', 'x-forwarded-for': '198.51.100.3' }
++ expect(resolveClientAddress(req('10.0.0.5', noCf), trust)).toBe('198.51.100.2')
++ expect(
++ resolveClientAddress(req('10.0.0.5', { 'x-forwarded-for': '198.51.100.3' }), trust),
++ ).toBe('198.51.100.3')
++ })
++
++ it('takes the right-most hop that is not itself a trusted proxy', () => {
++ const r = req('10.0.0.5', { 'x-forwarded-for': '9.9.9.9, 10.0.0.9, 10.0.0.8' })
++ expect(resolveClientAddress(r, ['10.0.0.0/8'])).toBe('9.9.9.9')
++ })
++
++ it('ignores forwarded headers from a peer outside the trusted set', () => {
++ const r = req('203.0.113.5', { 'x-forwarded-for': '1.2.3.4', 'cf-connecting-ip': '1.2.3.4' })
++ expect(resolveClientAddress(r, ['10.0.0.0/8'])).toBe('203.0.113.5')
++ })
++
++ it("honours headers from any peer when trust is 'all' (proxy-only deployment)", () => {
++ const r = req('127.0.0.1', { 'x-real-ip': '198.51.100.9' })
++ expect(resolveClientAddress(r, 'all')).toBe('198.51.100.9')
++ })
++
++ it('normalises IPv4-mapped IPv6 socket addresses', () => {
++ const r = req('::ffff:127.0.0.1')
++ expect(resolveClientAddress(r, null)).toBe('127.0.0.1')
++ // The trusted-set match works on the normalised form too.
++ expect(
++ resolveClientAddress(req('::ffff:10.0.0.5', { 'x-real-ip': '1.2.3.4' }), ['10.0.0.5']),
++ ).toBe('1.2.3.4')
++ })
++
++ it('parses TRUST_PROXY', () => {
++ expect(parseProxyTrust(undefined)).toBeNull()
++ expect(parseProxyTrust('')).toBeNull()
++ expect(parseProxyTrust('true')).toBe('all')
++ expect(parseProxyTrust('*')).toBe('all')
++ expect(parseProxyTrust('10.0.0.0/8, 192.168.1.1')).toEqual(['10.0.0.0/8', '192.168.1.1'])
++ })
++})
ddiff --git a/apps/server/src/net/clientAddress.ts b/apps/server/src/net/clientAddress.ts
nnew file mode 100644
iindex 0000000..6ece5b4
---- /dev/null
++++ b/apps/server/src/net/clientAddress.ts
@@@ -0,0 +1,83 @@
++/**
++ * The client address used for per-address rate limits and logs.
++ *
++ * Forwarded headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP) are
++ * client-supplied and must not be trusted by default: a direct caller could
++ * otherwise mint an unlimited number of rate-limit buckets by varying them.
++ * They are honoured only when the TCP peer is a configured trusted proxy
++ * (TRUST_PROXY) — behind nginx, which sets X-Real-IP from $remote_addr and
++ * overwrites X-Forwarded-For, and behind Cloudflare, whose CF-Connecting-IP
++ * is authoritative. When trusted, X-Forwarded-For is walked right-to-left so
++ * the right-most hop that is not itself a trusted proxy wins.
++ */
++import { isIP } from 'node:net'
++import type { IncomingMessage } from 'node:http'
++
++/** null = trust nothing; 'all' = the server is only reachable through the proxy; else IPs/CIDRs. */
++export type ProxyTrust = 'all' | string[]
++
++/** Parse TRUST_PROXY: empty = none, `true`/`1`/`*` = all, else a comma-separated IP/CIDR list. */
++export function parseProxyTrust(raw: string | undefined): ProxyTrust | null {
++ const value = (raw ?? '').trim()
++ if (!value) return null
++ if (value === 'true' || value === '1' || value === '*') return 'all'
++ const rules = value
++ .split(',')
++ .map((s) => s.trim())
++ .filter(Boolean)
++ return rules.length > 0 ? rules : null
++}
++
++function normalize(addr: string | null | undefined): string | null {
++ if (!addr) return null
++ const trimmed = addr.trim()
++ if (!trimmed) return null
++ // Node reports IPv4-mapped IPv6 (::ffff:127.0.0.1) for IPv4 peers.
++ return trimmed.startsWith('::ffff:') ? trimmed.slice(7) : trimmed
++}
++
++function ipToInt(ip: string): number {
++ return ip.split('.').reduce((a, o) => ((a << 8) >>> 0) + Number.parseInt(o, 10), 0) >>> 0
++}
++
++function matchesRule(addr: string, rule: string): boolean {
++ const [net, bitsRaw] = rule.split('/')
++ if (net === undefined) return false
++ if (bitsRaw === undefined) return normalize(net) === addr
++ const bits = Number.parseInt(bitsRaw, 10)
++ if (isIP(net) !== 4 || isIP(addr) !== 4) return false
++ if (!Number.isInteger(bits) || bits < 0 || bits > 32) return false
++ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
++ return (ipToInt(addr) & mask) === (ipToInt(net) & mask)
++}
++
++function isTrusted(addr: string, trust: ProxyTrust): boolean {
++ if (trust === 'all') return true
++ return trust.some((rule) => matchesRule(addr, rule))
++}
++
++/** The address to key a rate limit or log line on. */
++export function resolveClientAddress(req: IncomingMessage, trust: ProxyTrust | null): string {
++ const socketAddr = normalize(req.socket?.remoteAddress) ?? 'unknown'
++ // Nothing is trusted: the TCP peer is the caller (the direct-exposure case).
++ if (!trust || !isTrusted(socketAddr, trust)) return socketAddr
++ const headers = req.headers
++ // Cloudflare's single header wins when present (a trusted proxy stripped
++ // any client-supplied copy).
++ const cf = normalize(
++ typeof headers['cf-connecting-ip'] === 'string' ? headers['cf-connecting-ip'] : null,
++ )
++ if (cf) return cf
++ const real = normalize(typeof headers['x-real-ip'] === 'string' ? headers['x-real-ip'] : null)
++ if (real) return real
++ const xff = typeof headers['x-forwarded-for'] === 'string' ? headers['x-forwarded-for'] : ''
++ const hops = xff
++ .split(',')
++ .map((s) => normalize(s))
++ .filter((s): s is string => s !== null)
++ for (let i = hops.length - 1; i >= 0; i--) {
++ if (!isTrusted(hops[i] as string, trust)) return hops[i] as string
++ }
++ // Every hop is a trusted proxy (or 'all'): the left-most is the origin.
++ return hops[0] ?? socketAddr
++}
ddiff --git a/apps/server/src/net/editorWs.ts b/apps/server/src/net/editorWs.ts
iindex 1af860d..263b224 100644
---- a/apps/server/src/net/editorWs.ts
++++ b/apps/server/src/net/editorWs.ts
@@@ -12,6 +12,12 @@ import { canEditMap, resolveNetworkUser } from './networkAuth.js'
import { LockManager } from './editorLocks.js'
import type { EditorAuth } from './httpServer.js'
++/**
++ * The shared-secret editor-key path is gone (ADR-0007 "Deleted"). The editor
++ * uses a Network session with `staff.games.manage`; the WS handshake reads
++ * the same bearer token the editor's HTTP saves use.
++ */
++
/**
* Live collaboration for the map editor (`/editor-ws`).
*
@@@ -127,7 +133,7 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
ws.close(4001, 'hello_first')
return
}
-- void authorizeEditor(auth, msg.key).then((ok) => {
++ void authorizeEditor(auth, msg.auth).then((ok) => {
if (!ok) {
ws.close(4003, 'not_authorized')
return
@@@ -250,10 +256,12 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
}
}
--async function authorizeEditor(auth: EditorAuth, key: string): Promise<boolean> {
-- if (auth.networkAuthUrl) {
-- const user = await resolveNetworkUser(auth.networkAuthUrl, key)
-- return user !== null && canEditMap(user.rank)
-- }
-- return auth.key !== null && key === auth.key
++/**
++ * The editor WS authorizes exactly like the HTTP save: a Network bearer
++ * token whose session is staff.games.manage. No key, no fallback.
++ */
++async function authorizeEditor(auth: EditorAuth, token: string): Promise<boolean> {
++ if (!auth.networkAuthUrl) return false
++ const user = await resolveNetworkUser(auth.networkAuthUrl, token)
++ return user !== null && canEditMap(user.rank)
}
ddiff --git a/apps/server/src/net/guestIdentity.ts b/apps/server/src/net/guestIdentity.ts
nnew file mode 100644
iindex 0000000..beb9cb9
---- /dev/null
++++ b/apps/server/src/net/guestIdentity.ts
@@@ -0,0 +1,12 @@
++/**
++ * Guest identity helpers (ADR-0007 netcode + "Deleted"): a raw guest token
++ * is never stored on the server, only the SHA-256 of one. This module is
++ * the single place that hashing lives so the value stored in a WS ticket
++ * matches the value stored in a guest character's persistence row.
++ */
++import { createHash } from 'node:crypto'
++
++/** SHA-256 hex of a raw guest token. */
++export function hashGuestKey(raw: string): string {
++ return createHash('sha256').update(raw).digest('hex')
++}
ddiff --git a/apps/server/src/net/httpServer.test.ts b/apps/server/src/net/httpServer.test.ts
iindex 44280a8..b09fbd6 100644
---- a/apps/server/src/net/httpServer.test.ts
++++ b/apps/server/src/net/httpServer.test.ts
@@@ -9,6 +9,8 @@ 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 { createWsTicketStore } from './wsTicket.js'
++import { createGamesActorLimits } from './actorLimits.js'
import { escapeHtml, notFoundPage } from './notFound.js'
/**
@@@ -96,7 +98,10 @@ beforeAll(async () => {
metrics,
log,
mapPath,
-- { key: 'editor-secret', networkAuthUrl: null },
++ // the legacy editor key is gone — the editor requires a Network session. The
++ // test stays a "no Network" server, so the editor routes return 403
++ // for everyone, which is exactly what we want to lock in.
++ { networkAuthUrl: null },
undefined,
async () => [{ slot: 0, name: 'Ana', appearance: null }],
null,
@@@ -299,3 +304,45 @@ describe('404 page', () => {
expect(existsSync(icon)).toBe(true)
})
})
++
++describe('/api/ws-ticket is rate limited per address', () => {
++ it('refuses a flood from one address with 429, and spoofed X-Forwarded-For does not help', async () => {
++ const metrics = new ServerMetrics()
++ const limits = createGamesActorLimits({})
++ const tickets = createWsTicketStore()
++ const server = createHttpServer(
++ null,
++ metrics,
++ log,
++ undefined,
++ undefined,
++ undefined,
++ undefined,
++ null,
++ {
++ handle: () => false,
++ limits,
++ // Trust nothing: forwarded headers must not create fresh buckets.
++ clientAddress: (req) => req.socket.remoteAddress ?? 'unknown',
++ },
++ tickets,
++ )
++ const ticketPort = await listen(server)
++ try {
++ // The per-address cap is 60/minute: the first 60 succeed, the next is 429.
++ let status = 0
++ for (let i = 0; i < 61; i++) {
++ const r = await send('/api/ws-ticket', {
++ to: ticketPort,
++ method: 'POST',
++ headers: { 'x-guest-token': 'guesttoken1234', 'x-forwarded-for': `203.0.113.${i}` },
++ })
++ status = r.status
++ if (status === 429) break
++ }
++ expect(status).toBe(429)
++ } finally {
++ server.close()
++ }
++ })
++})
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 333813f..7f45752 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -27,7 +27,9 @@ import {
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
import type { GamesActorLimits } from './actorLimits.js'
++import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
++import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import type { MapFileV2 } from '@openvibe/content'
/**
@@@ -55,9 +57,12 @@ const MIME: Record<string, string> = {
'.map': 'application/json',
}
++/**
++ * Authorization for the editor HTTP routes. Owner rule (ADR-0007 deleted
++ * the shared secret): a Network staff session with `staff.games.manage` is
++ * the ONLY way to edit the map. No legacy fallback remains.
++ */
export interface EditorAuth {
-- /** Shared-secret fallback. */
-- key: string | null
/** openvibe.network session endpoint; token validated there when configured. */
networkAuthUrl: string | null
}
@@@ -75,17 +80,17 @@ export interface OAuthConfig {
}
/**
-- * Validates a map-editor token: against openvibe.network when configured (the
-- * endpoint must answer a JSON body with an admin-ish rank for the given
-- * bearer token), else against the EDITOR_KEY shared secret.
++ * Validates an editor request: the bearer token MUST be a Network session
++ * whose rank resolves to owner or admin (`staff.games.manage`, ADR-022).
++ * The shared-secret editor key is gone (ADR-0007 "Deleted"), and no
++ * local-only fallback remains — a developer without Network configured
++ * must run openvibe.network to use the editor.
*/
async function editorAuthorized(auth: EditorAuth, token: string | undefined): Promise<boolean> {
if (!token) return false
-- if (auth.networkAuthUrl) {
-- const user = await resolveNetworkUser(auth.networkAuthUrl, token)
-- return user !== null && canEditMap(user.rank)
-- }
-- return auth.key !== null && token === auth.key
++ if (!auth.networkAuthUrl) return false
++ const user = await resolveNetworkUser(auth.networkAuthUrl, token)
++ return user !== null && canEditMap(user.rank)
}
/** Reads a small request body as utf8, or null when it exceeds `limit`. */
@@@ -113,8 +118,10 @@ export interface HttpPlatformHooks {
handle?: (req: IncomingMessage, res: ServerResponse) => boolean
/** A map asset was stored (or found already stored) locally. */
onAssetStored?: (asset: { hash: string; url: string; bytes: number; mime: string }) => void
-- /** Per-actor limits on editor saves and uploads (./actorLimits.ts); absent in tests that do not need them. */
++ /** Per-actor limits on editor saves, uploads, the mods API and ticket minting (./actorLimits.ts); absent in tests that do not need them. */
limits?: GamesActorLimits
++ /** Trusted-proxy-aware client address (./clientAddress.ts); the socket address when absent. */
++ clientAddress?: (req: IncomingMessage) => string
}
/** The token endpoint's answer, for both the code and the jwt-bearer grant. */
@@@ -140,6 +147,7 @@ export function createHttpServer(
) => Promise<{ slot: number; name: string; appearance: unknown }[]>,
oauth?: OAuthConfig | null,
platform?: HttpPlatformHooks,
++ tickets?: WsTicketStore,
): Server {
const root = staticDir ? resolve(staticDir) : null
// Sessions the Network confirmed recently: lets a silent login on a signed-in
@@@ -164,6 +172,30 @@ export function createHttpServer(
// /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 (tickets && url === '/api/ws-ticket') {
++ const ticketLimits = platform?.limits
++ const address =
++ platform?.clientAddress ?? ((r: IncomingMessage) => resolveClientAddress(r, null))
++ void (async () => {
++ // Per-address limit BEFORE any identity is resolved or ticket minted:
++ // an unauthenticated caller cannot flood the store.
++ if (ticketLimits && !(await ticketLimits.ticket(req, res, `ip:${address(req)}`))) return
++ await handleWsTicketRequest(req, res, {
++ tickets,
++ networkAuthUrl: editorAuth?.networkAuthUrl ?? null,
++ log,
++ })
++ })().catch((err: unknown) => {
++ log.warn('ws-ticket request failed', {
++ error: err instanceof Error ? err.message : String(err),
++ })
++ if (!res.headersSent) {
++ res.writeHead(503, { 'content-type': 'application/json' })
++ res.end('{"error":"ticket_unavailable"}')
++ }
++ })
++ return
++ }
if (platform?.handle?.(req, res)) return
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
@@@ -179,7 +211,7 @@ export function createHttpServer(
return
}
if (url === '/api/map' && req.method === 'POST' && mapPath && editorAuth) {
-- const token = (req.headers['x-editor-key'] as string | undefined) ?? undefined
++ const token = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
void editorAuthorized(editorAuth, token).then(async (ok) => {
if (!ok) {
log.warn('editor save rejected', {})
@@@ -251,7 +283,7 @@ export function createHttpServer(
// artifact instead of being base64-embedded into map.json.
// Generic content-addressed upload (textures, paint masks, models).
if (url === '/api/map-assets' && req.method === 'POST' && mapPath && editorAuth) {
-- const token = (req.headers['x-editor-key'] as string | undefined) ?? undefined
++ const token = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
void editorAuthorized(editorAuth, token).then(async (authed) => {
if (!authed) {
res.writeHead(403, { 'content-type': 'application/json' })
ddiff --git a/apps/server/src/net/staffAuthorization.test.ts b/apps/server/src/net/staffAuthorization.test.ts
nnew file mode 100644
iindex 0000000..db8dace
---- /dev/null
++++ b/apps/server/src/net/staffAuthorization.test.ts
@@@ -0,0 +1,196 @@
++/**
++ * Editor and mods HTTP routes authorize on the Network staff capability
++ * `staff.games.manage` (and, for the mods API, the service capability
++ * `games.mod.manage`). The legacy shared secret is gone: a request without a bearer, or
++ * with a bearer that the mock Network rejects, is refused.
++ *
++ * Boots a tiny mock openvibe.network + the real createHttpServer, then
++ * exercises:
++ * - /api/map-assets: POST refused without a staff bearer (403)
++ * - /api/map-assets: POST refused when the bearer resolves to a non-staff
++ * user (403)
++ * - /api/map-assets: POST accepted when the bearer resolves to admin
++ * - /api/v1/mods: install POST refused without a bearer (403)
++ * - /api/v1/mods: install POST accepted when the bearer is a Network
++ * user with staff.games.manage (201)
++ */
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
++import { tmpdir } from 'node:os'
++import { join } from 'node:path'
++import { createContent } from '@openvibe/content'
++import { openTestStore } from '@openvibe/persistence/testing'
++import { createConsoleLogger } from '@openvibe/shared'
++import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
++import { ServerMetrics } from '../observability/metrics.js'
++import { createHttpServer } from './httpServer.js'
++import { createStaffAuthorizer } from '../platform/staffAuth.js'
++import { ModRegistry } from '../mods/registry.js'
++import { handleModsRequest } from '../mods/routes.js'
++import { sampleManifest } from '../mods/testFixtures.js'
++import { CAP_PLACE_PROP } from '../mods/contentPack.js'
++
++const log = createConsoleLogger({ app: 'test' }, 'error')
++
++let mockNet: Server | null = null
++let mockNetUrl = ''
++let dir = ''
++let server: Server | null = null
++let port = 0
++
++beforeAll(async () => {
++ // Tiny mock openvibe.network: tokens map to users (admin / user) and the
++ // staff role is derived from the user record exactly as the real
++ // Network does (canEditMap uses staff.games.manage).
++ const users = new Map<string, { subject_id: string; role: 'admin' | 'user' }>([
++ ['staff-token', { subject_id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', role: 'admin' }],
++ ['user-token', { subject_id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1', role: 'user' }],
++ ])
++ mockNet = createServer((req, res) => {
++ if (req.url === '/api/auth/me') {
++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
++ const u = bearer ? users.get(bearer) : undefined
++ if (!u) {
++ res.writeHead(401, { 'content-type': 'application/json' })
++ res.end('{"error":"forbidden"}')
++ return
++ }
++ res.writeHead(200, { 'content-type': 'application/json' })
++ res.end(JSON.stringify({ user: { id: 1, username: bearer, ...u } }))
++ return
++ }
++ res.writeHead(404)
++ res.end()
++ })
++ await new Promise<void>((r) => mockNet!.listen(0, '127.0.0.1', r))
++ mockNetUrl = `http://127.0.0.1:${(mockNet.address() as AddressInfo).port}/api/auth/me`
++
++ dir = mkdtempSync(join(tmpdir(), 'ovg-staff-'))
++ mkdirSync(join(dir, 'data'), { recursive: true })
++ writeFileSync(join(dir, 'data', 'map.json'), JSON.stringify({ v: 2, terrains: [], statics: [] }))
++ const _d = mkdtempSync(join(tmpdir(), 'ovg-mod-store-'))
++ void _d
++ const registry = new ModRegistry(await openTestStore(), createContent())
++ await registry.load()
++ const authorize = createStaffAuthorizer({
++ networkAuthUrl: mockNetUrl,
++ networkUrl: mockNetUrl.replace(/\/api\/auth\/me$/, ''),
++ })
++
++ const metrics = new ServerMetrics()
++ server = createHttpServer(
++ null,
++ metrics,
++ log,
++ join(dir, 'data', 'map.json'),
++ { networkAuthUrl: mockNetUrl },
++ undefined,
++ undefined,
++ null,
++ {
++ handle: (req, res) => handleModsRequest(req, res, { registry, authorize, log }),
++ },
++ )
++ await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r))
++ port = (server.address() as AddressInfo).port
++})
++
++afterEach(() => {
++ // Registry state is per-test; we keep the server running for speed.
++})
++
++afterAll(() => {
++ server?.close()
++ mockNet?.close()
++ if (dir) rmSync(dir, { recursive: true, force: true })
++})
++
++const baseUrl = () => `http://127.0.0.1:${port}`
++
++describe('editor HTTP routes', () => {
++ it('refuses POST /api/map-assets without a bearer (403)', async () => {
++ const resp = await fetch(`${baseUrl()}/api/map-assets`, { method: 'POST', body: 'x' })
++ expect(resp.status).toBe(403)
++ })
++
++ it('refuses POST /api/map-assets when the bearer is not staff (403)', async () => {
++ const resp = await fetch(`${baseUrl()}/api/map-assets`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer user-token' },
++ body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
++ })
++ expect(resp.status).toBe(403)
++ })
++
++ it('refuses POST /api/map-assets when the mock Network rejects the bearer (403)', async () => {
++ const resp = await fetch(`${baseUrl()}/api/map-assets`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer not-in-mock' },
++ body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
++ })
++ expect(resp.status).toBe(403)
++ })
++
++ it('accepts POST /api/map-assets from a staff.games.manage session (200)', async () => {
++ // The mock Network resolves `staff-token` to an admin whose role grants
++ // staff.games.manage; canEditMap admits exactly that (owner/admin).
++ const resp = await fetch(`${baseUrl()}/api/map-assets`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer staff-token' },
++ body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
++ })
++ expect(resp.status).toBe(200)
++ const body = (await resp.json()) as { ok: boolean; hash: string }
++ expect(body.ok).toBe(true)
++ expect(body.hash).toMatch(/^sha256-[a-f0-9]+$/)
++ })
++})
++
++describe('mods HTTP API', () => {
++ it('refuses an install POST without a bearer (capability.denied)', async () => {
++ const resp = await fetch(`${baseUrl()}/api/v1/mods`, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({
++ manifest: sampleManifest(),
++ pack: { props: [{ key: 'bench', item: 'workbench', pos: [0, 0, 0] }] },
++ approve: [CAP_PLACE_PROP],
++ enable: true,
++ }),
++ })
++ expect(resp.status).toBe(403)
++ })
++
++ it('refuses a non-staff bearer (capability.denied)', async () => {
++ const resp = await fetch(`${baseUrl()}/api/v1/mods`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer user-token', 'content-type': 'application/json' },
++ body: JSON.stringify({
++ manifest: sampleManifest(),
++ pack: { props: [{ key: 'bench', item: 'workbench', pos: [0, 0, 0] }] },
++ approve: [CAP_PLACE_PROP],
++ enable: true,
++ }),
++ })
++ expect(resp.status).toBe(403)
++ })
++
++ it('accepts a staff bearer (201) and the install is reachable by id', async () => {
++ const resp = await fetch(`${baseUrl()}/api/v1/mods`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer staff-token', 'content-type': 'application/json' },
++ body: JSON.stringify({
++ manifest: sampleManifest(),
++ pack: { props: [{ key: 'bench', item: 'workbench', pos: [0, 0, 0] }] },
++ approve: [CAP_PLACE_PROP],
++ enable: true,
++ }),
++ })
++ expect(resp.status).toBe(201)
++ const view = (await resp.json()) as { id: string }
++ expect(typeof view.id).toBe('string')
++ const get = await fetch(`${baseUrl()}/api/v1/mods/${view.id}`)
++ expect(get.status).toBe(200)
++ })
++})
ddiff --git a/apps/server/src/net/wsTicket.test.ts b/apps/server/src/net/wsTicket.test.ts
nnew file mode 100644
iindex 0000000..4e3462f
---- /dev/null
++++ b/apps/server/src/net/wsTicket.test.ts
@@@ -0,0 +1,182 @@
++import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
++import {
++ MAX_MEMORY_TICKETS,
++ TICKET_TTL_MS,
++ createWsTicketStore,
++ isGuestTicket,
++ isSubjectTicket,
++} from './wsTicket.js'
++import { hashGuestKey } from './guestIdentity.js'
++import type { Valkey } from 'openvibe-sdk/valkey'
++
++/**
++ * WebSocket upgrade tickets (ADR-0007 netcode + decision 8):
++ * - 32 random bytes base64url;
++ * - single-use, 30 s TTL;
++ * - bound to either a subject (usr_…/gst_…) or a hashed guest key;
++ * - a guest ticket never carries a subject, and an account ticket never
++ * carries a guest key.
++ * With Valkey, Valkey is the source of truth: SET … PX … NX on issue and
++ * GETDEL on consume, so exactly one of two racing consumers wins.
++ */
++describe('wsTicket store', () => {
++ let now = 1_000_000
++ beforeEach(() => {
++ now = 1_000_000
++ })
++ afterEach(() => {
++ vi.restoreAllMocks()
++ })
++
++ const subject = { subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', rank: 'admin' as const, authIat: 1 }
++ const guest = { guestKeyHash: hashGuestKey('openvibe-guest-1') }
++
++ it('issues a base64url ticket with 32 bytes of randomness', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ const { token, expiresAt } = await store.issue(subject)
++ // 32 bytes → 43 base64url chars (no padding).
++ expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/)
++ expect(expiresAt).toBe(now + TICKET_TTL_MS)
++ })
++
++ it('returns the bound identity and consumes the ticket atomically', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ const { token } = await store.issue(subject)
++ const got = await store.consume(token)
++ expect(got).not.toBeNull()
++ expect(isSubjectTicket(got!.identity)).toBe(true)
++ if (isSubjectTicket(got!.identity)) {
++ expect(got!.identity.subject).toBe(subject.subject)
++ expect(got!.identity.rank).toBe('admin')
++ }
++ // Second consume: nothing left, returns null.
++ expect(await store.consume(token)).toBeNull()
++ })
++
++ it('refuses expired tickets', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ const { token } = await store.issue(subject)
++ now += TICKET_TTL_MS + 1
++ expect(await store.consume(token)).toBeNull()
++ })
++
++ it('refuses unknown tickets', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ expect(await store.consume('not-a-real-ticket')).toBeNull()
++ })
++
++ it('binds guest tickets to a hashed key, never to a subject', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ const { token } = await store.issue(guest)
++ const got = await store.consume(token)
++ expect(got).not.toBeNull()
++ expect(isGuestTicket(got!.identity)).toBe(true)
++ expect(isSubjectTicket(got!.identity)).toBe(false)
++ if (isGuestTicket(got!.identity)) {
++ // The store carries the HASH; the raw guest token never lands.
++ expect(got!.identity.guestKeyHash).toBe(guest.guestKeyHash)
++ expect(got!.identity.guestKeyHash).not.toContain('openvibe-guest-1')
++ expect(got!.identity.guestKeyHash).toMatch(/^[a-f0-9]{64}$/)
++ }
++ })
++
++ it('sweeps expired tickets on issue, not only on consume', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ const a = (await store.issue(subject)).token
++ now += TICKET_TTL_MS + 1
++ // Issuing a fresh ticket sweeps the expired one; no consume happened.
++ const b = (await store.issue(subject)).token
++ expect(await store.consume(a)).toBeNull()
++ expect(await store.consume(b)).not.toBeNull()
++ })
++
++ it('caps the in-memory store, evicting the oldest ticket', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ const first = (await store.issue(subject)).token
++ for (let i = 0; i < MAX_MEMORY_TICKETS - 1; i++) await store.issue(subject)
++ // One more than the cap: the oldest is gone, the newest survives.
++ const last = (await store.issue(subject)).token
++ expect(await store.consume(first)).toBeNull()
++ expect(await store.consume(last)).not.toBeNull()
++ })
++
++ it('reset() drops every in-memory ticket (test seam)', async () => {
++ const store = createWsTicketStore({ now: () => now })
++ const { token } = await store.issue(subject)
++ store.reset()
++ expect(await store.consume(token)).toBeNull()
++ })
++
++ it('with Valkey, two racing consumes of one ticket have exactly one winner', async () => {
++ const valkey = fakeValkey()
++ const store = createWsTicketStore({ valkey, now: () => now })
++ const { token } = await store.issue(subject)
++ const results = await Promise.all([store.consume(token), store.consume(token)])
++ expect(results.filter((r) => r !== null)).toHaveLength(1)
++ expect(results.filter((r) => r === null)).toHaveLength(1)
++ })
++
++ it('with Valkey, issue writes SET … PX … NX and consume removes the key', async () => {
++ const valkey = fakeValkey()
++ const store = createWsTicketStore({ valkey, now: () => now })
++ const { token } = await store.issue(subject)
++ const entry = valkey._store.get(valkey.key('ws-ticket', token))
++ expect(entry?.value).toContain(subject.subject)
++ expect(entry?.ttlMs).toBe(TICKET_TTL_MS)
++ expect(await store.consume(token)).not.toBeNull()
++ expect(valkey._store.has(valkey.key('ws-ticket', token))).toBe(false)
++ })
++
++ it('with Valkey, a store error propagates (the caller fails closed)', async () => {
++ const valkey = fakeValkey()
++ valkey._fail = true
++ const store = createWsTicketStore({ valkey, now: () => now })
++ await expect(store.issue(subject)).rejects.toThrow()
++ await expect(store.consume('anything')).rejects.toThrow()
++ })
++})
++
++interface FakeEntry {
++ value: string
++ ttlMs: number
++}
++
++/**
++ * A minimal iovalkey stand-in: SET with PX/NX and an atomic GETDEL. The
++ * check-and-delete in getdel is synchronous, so two racing consumers cannot
++ * both win (the same guarantee a real GETDEL gives).
++ */
++function fakeValkey(): Valkey & { _store: Map<string, FakeEntry>; _fail: boolean } {
++ const store = new Map<string, FakeEntry>()
++ const fake = {
++ _store: store,
++ _fail: false,
++ client: {
++ async set(key: string, value: string, ...args: unknown[]) {
++ if (fake._fail) throw new Error('valkey down')
++ const px = args.indexOf('PX')
++ const ttlMs = px >= 0 ? Number(args[px + 1]) : 0
++ const nx = args.includes('NX')
++ if (nx && store.has(key)) return null
++ // Yield AFTER the NX check so a racing SET sees the entry.
++ await Promise.resolve()
++ store.set(key, { value, ttlMs })
++ return 'OK'
++ },
++ async getdel(key: string) {
++ if (fake._fail) throw new Error('valkey down')
++ const entry = store.get(key)
++ if (!entry) return null
++ // Atomic consume: delete before any await.
++ store.delete(key)
++ return entry.value
++ },
++ },
++ prefix: '',
++ key: (...parts: string[]) => parts.join(':'),
++ duplicate: () => fake,
++ ready: async () => ({ ok: true as const, detail: { store: 'valkey' as const } }),
++ close: async () => undefined,
++ }
++ return fake as unknown as Valkey & { _store: Map<string, FakeEntry>; _fail: boolean }
++}
ddiff --git a/apps/server/src/net/wsTicket.ts b/apps/server/src/net/wsTicket.ts
nnew file mode 100644
iindex 0000000..1a9c624
---- /dev/null
++++ b/apps/server/src/net/wsTicket.ts
@@@ -0,0 +1,359 @@
++/**
++ * Single-use WebSocket upgrade tickets (ADR-0007 netcode + decision 8: the
++ * WebSocket authenticates at the upgrade).
++ *
++ * A client first calls POST /api/ws-ticket (HTTP; signed-in callers present
++ * their access token as today's HTTP auth, local guests present their guest
++ * token in the X-Guest-Token header). The server resolves the identity, binds
++ * it to a fresh 32-byte base64url ticket and returns it. The browser then
++ * opens /ws?ticket=… and the upgrade handler validates-and-consumes BEFORE
++ * accepting the socket.
++ *
++ * With VALKEY_URL the tickets live in Valkey and THAT is the source of truth:
++ * issue is SET … PX … NX, consume is GETDEL, both async and awaited, so several
++ * instances share one single-use ticket. Without it, an in-process Map (also
++ * single-use, swept on issue and consume, capped). An infrastructure error
++ * fails closed (503 at the HTTP endpoint, 401 at the upgrade): a ticket that
++ * cannot be verified is never accepted.
++ *
++ * A ticket is bound to either:
++ * - subject (usr_…, gst_…) — a Network account or service principal, plus an
++ * optional hashed guest key to adopt on first sign-in; or
++ * - the SHA-256 of a raw guest token (a guest key, never the token itself).
++ * A guest ticket never carries a subject; an account ticket never carries a
++ * guest key as its identity. The raw guest token is read only from the
++ * X-Guest-Token header, never from the query string (finding: it must not land
++ * in proxy logs, Referer or browser history).
++ */
++import { randomBytes } from 'node:crypto'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import type { Valkey } from 'openvibe-sdk/valkey'
++import { resolveNetworkUser } from './networkAuth.js'
++import { isGuestToken } from '../platform/accounts.js'
++import { hashGuestKey } from './guestIdentity.js'
++
++export const TICKET_TTL_MS = 30_000
++const TICKET_BYTES = 32
++/** Hard cap on live in-memory tickets: a flood cannot grow the Map without end. */
++export const MAX_MEMORY_TICKETS = 10_000
++/** The only header a raw guest token is ever read from. */
++export const GUEST_TOKEN_HEADER = 'x-guest-token'
++
++/**
++ * The identity bound to a ticket. `subject` is a canonical openvibe.network
++ * subject (usr_…/gst_…) and `guestKeyHash` is the SHA-256 hex of a raw guest
++ * token. Exactly one of subject/guestKeyHash is set per ticket.
++ *
++ * `adoptGuestKeyHash` rides along on a subject ticket only: it is the hashed
++ * guest key whose character joins this account on first sign-in. It comes from
++ * the authenticated ticket request, never from `hello`.
++ */
++export type TicketIdentity =
++ | {
++ subject: string
++ rank: 'owner' | 'admin' | 'moderator' | null
++ authIat: number | null
++ adoptGuestKeyHash?: string
++ }
++ | { guestKeyHash: string }
++
++/** The auth header / cookie a ticket was issued against, replayed to /ws upgrade. */
++export interface ResolvedTicket {
++ identity: TicketIdentity
++ consumed: boolean
++ expiresAt: number
++}
++
++export interface WsTicketStoreOptions {
++ /** Shared Valkey (ADR-035); null = in-memory only. */
++ valkey?: Valkey | null
++ /** Test seam. */
++ now?: () => number
++}
++
++export interface WsTicketStore {
++ /** Issue a fresh ticket bound to `identity`. The token is what the client uses. */
++ issue(identity: TicketIdentity): Promise<{ token: string; expiresAt: number }>
++ /**
++ * Validate AND consume a ticket atomically. Returns the bound identity
++ * or null when the ticket is unknown, expired, or already used. Throws on
++ * an infrastructure error so the caller can fail closed.
++ */
++ consume(token: string): Promise<ResolvedTicket | null>
++ /** Test seam: drop every in-memory ticket (Valkey entries expire on their own). */
++ reset(): void
++}
++
++function base64url(bytes: Buffer): string {
++ return bytes.toString('base64url')
++}
++
++function isSubject(value: string): boolean {
++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$|^gst_[0-9A-HJKMNP-TV-Z]{26}$/.test(value)
++}
++
++/**
++ * In-memory ticket store: one Map, single-use. Lookups and consumes are O(1);
++ * expired entries are swept when a ticket is issued and when one is consumed,
++ * and the Map is capped so an unauthenticated flood cannot exhaust memory.
++ */
++function createInMemoryBase(
++ now: () => number,
++): Omit<WsTicketStore, 'reset'> & { reset: () => void } {
++ const live = new Map<string, ResolvedTicket>()
++ const sweep = (): void => {
++ const t = now()
++ for (const [k, v] of live) if (v.expiresAt <= t) live.delete(k)
++ }
++ /** Oldest-first eviction (a Map preserves insertion order). */
++ const cap = (): void => {
++ while (live.size >= MAX_MEMORY_TICKETS) {
++ const oldest = live.keys().next().value
++ if (oldest === undefined) break
++ live.delete(oldest)
++ }
++ }
++ return {
++ async issue(identity) {
++ sweep()
++ cap()
++ const expiresAt = now() + TICKET_TTL_MS
++ const token = base64url(randomBytes(TICKET_BYTES))
++ live.set(token, { identity, consumed: false, expiresAt })
++ return { token, expiresAt }
++ },
++ async consume(token) {
++ // O(1): no full sweep here — expiry is checked on the one entry, and
++ // expired entries are swept (and the map capped) whenever a ticket is
++ // issued, so a consume flood cannot make every lookup scan the map.
++ const entry = live.get(token)
++ if (!entry) return null
++ // Single-use: consume atomically by deleting the entry before returning.
++ // A second attempt with the same token sees nothing and gets null.
++ live.delete(token)
++ if (entry.expiresAt <= now()) return null
++ return entry
++ },
++ reset() {
++ live.clear()
++ },
++ }
++}
++
++/**
++ * Valkey-backed ticket store: Valkey IS the source of truth (not a mirror of
++ * an in-process Map). Issue is an atomic SET … PX … NX; consume is an atomic
++ * GETDEL, so across instances exactly one consumer wins. Errors propagate to
++ * the caller, which fails closed.
++ */
++function createValkeyTicketStore(valkey: Valkey, now: () => number): WsTicketStore {
++ const c = valkey.client
++ const key = (token: string): string => valkey.key('ws-ticket', token)
++ return {
++ async issue(identity) {
++ const expiresAt = now() + TICKET_TTL_MS
++ const token = base64url(randomBytes(TICKET_BYTES))
++ const payload = JSON.stringify({ identity, expiresAt })
++ const reply = await c.set(key(token), payload, 'PX', TICKET_TTL_MS, 'NX')
++ if (reply === null) throw new Error('ws ticket collision')
++ return { token, expiresAt }
++ },
++ async consume(token) {
++ const raw = await c.getdel(key(token))
++ if (typeof raw !== 'string') return null
++ let parsed: { identity?: unknown; expiresAt?: unknown }
++ try {
++ parsed = JSON.parse(raw) as { identity?: unknown; expiresAt?: unknown }
++ } catch {
++ return null
++ }
++ if (typeof parsed.expiresAt !== 'number' || parsed.expiresAt <= now()) return null
++ if (!parsed.identity || typeof parsed.identity !== 'object') return null
++ return {
++ identity: parsed.identity as TicketIdentity,
++ consumed: false,
++ expiresAt: parsed.expiresAt,
++ }
++ },
++ reset() {
++ /* Valkey entries expire on their own; nothing local to clear. */
++ },
++ }
++}
++
++export function createWsTicketStore(opts: WsTicketStoreOptions = {}): WsTicketStore {
++ const now = opts.now ?? (() => Date.now())
++ if (opts.valkey) return createValkeyTicketStore(opts.valkey, now)
++ return createInMemoryBase(now)
++}
++
++/** Type guard: a resolved identity is a Network account (subject) ticket. */
++export function isSubjectTicket(id: TicketIdentity): id is {
++ subject: string
++ rank: 'owner' | 'admin' | 'moderator' | null
++ authIat: number | null
++ adoptGuestKeyHash?: string
++} {
++ return 'subject' in id && typeof id.subject === 'string' && isSubject(id.subject)
++}
++
++/** Type guard: a resolved identity is a guest ticket. */
++export function isGuestTicket(id: TicketIdentity): id is { guestKeyHash: string } {
++ return 'guestKeyHash' in id && typeof id.guestKeyHash === 'string'
++}
++
++/**
++ * What POST /api/ws-ticket resolves to when the call succeeds. The body is
++ * sent as JSON with the TTL so a misbehaving client can refresh itself
++ * before reconnecting instead of starting a handshake that will fail.
++ */
++export interface IssueTicketResult {
++ ticket: string
++ /** Unix ms when the ticket expires. */
++ expiresAt: number
++}
++
++/**
++ * POST /api/ws-ticket handler: resolves identity from the request and
++ * issues a single-use ticket. Returns null and writes an error response
++ * itself when the request is unauthenticated or malformed.
++ *
++ * - Signed-in: `Authorization: Bearer <Network access token>` (the same
++ * token `/auth/me` validates). An optional `X-Guest-Token: <raw>` asks
++ * for that guest's character to be adopted into the account; it is
++ * hashed here and never stored raw.
++ * - Guest: `X-Guest-Token: <raw guest token>` (the same token the
++ * client's `openvibe.token` cookie carries). Never a query parameter.
++ *
++ * A ticket store failure fails closed with 503.
++ */
++export interface WsTicketEndpointOptions {
++ tickets: WsTicketStore
++ /** openvibe.network /api/auth/me endpoint, or null in local development. */
++ networkAuthUrl: string | null
++ log?: {
++ info?(msg: string, meta?: Record<string, unknown>): void
++ warn(msg: string, meta?: Record<string, unknown>): void
++ }
++}
++
++const TICKET_BODY_LIMIT = 1024
++
++function headerValue(req: IncomingMessage, name: string): string | null {
++ const v = req.headers[name]
++ return typeof v === 'string' && v.length > 0 ? v : null
++}
++
++export async function handleWsTicketRequest(
++ req: IncomingMessage,
++ res: ServerResponse,
++ opts: WsTicketEndpointOptions,
++): Promise<boolean> {
++ // Exact path match: /api/ws-ticketX is not this endpoint.
++ if ((req.url ?? '').split('?')[0] !== '/api/ws-ticket') return false
++ if (req.method !== 'POST') {
++ res.writeHead(405, { allow: 'POST', 'content-type': 'application/json' })
++ res.end('{"error":"method_not_allowed"}')
++ return true
++ }
++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]?.trim()
++ const guest = headerValue(req, GUEST_TOKEN_HEADER)
++ // Drain the request body — clients should never send a payload, but if
++ // they do (a misbehaving retry layer) we cap it so an upload probe
++ // can't hang the handler. The 413 is written BEFORE destroying the
++ // request so a fetch client sees the status, not a half-closed socket.
++ const chunks: Buffer[] = []
++ let size = 0
++ let tooBig = false
++ req.on('data', (c: Buffer) => {
++ size += c.length
++ if (size > TICKET_BODY_LIMIT) {
++ tooBig = true
++ res.writeHead(413, { 'content-type': 'application/json' })
++ res.end('{"error":"body_too_large"}')
++ req.destroy()
++ return
++ }
++ chunks.push(c)
++ })
++ await new Promise<void>((r) => {
++ req.on('end', r)
++ req.on('close', r)
++ req.on('error', r)
++ })
++ if (tooBig) return true
++ void chunks
++ let identity: TicketIdentity | null = null
++ if (bearer) {
++ const user = await resolveNetworkUser(opts.networkAuthUrl, bearer)
++ if (!user || !user.subjectId) {
++ res.writeHead(401, { 'content-type': 'application/json' })
++ res.end('{"error":"auth_failed"}')
++ return true
++ }
++ // Guest conversion (WS-B task 8): the guest token comes from the
++ // authenticated HTTP request, not from `hello`. An account-shaped value
++ // is never accepted — that would open someone else's characters.
++ let adoptGuestKeyHash: string | undefined
++ if (guest !== null) {
++ if (!isGuestToken(guest)) {
++ res.writeHead(400, { 'content-type': 'application/json' })
++ res.end('{"error":"invalid_guest_token"}')
++ return true
++ }
++ adoptGuestKeyHash = hashGuestKey(guest)
++ }
++ identity = {
++ subject: user.subjectId,
++ rank: user.rank,
++ authIat: tokenIat(bearer),
++ ...(adoptGuestKeyHash ? { adoptGuestKeyHash } : {}),
++ }
++ } else if (guest !== null) {
++ if (!isGuestToken(guest)) {
++ res.writeHead(400, { 'content-type': 'application/json' })
++ res.end('{"error":"invalid_guest_token"}')
++ return true
++ }
++ identity = { guestKeyHash: hashGuestKey(guest) }
++ } else {
++ res.writeHead(401, { 'content-type': 'application/json' })
++ res.end('{"error":"unauthenticated"}')
++ return true
++ }
++ let issued: { token: string; expiresAt: number }
++ try {
++ issued = await opts.tickets.issue(identity)
++ } catch (err: unknown) {
++ // A ticket that cannot be issued is never handed out: fail closed.
++ res.writeHead(503, { 'content-type': 'application/json' })
++ res.end('{"error":"ticket_unavailable"}')
++ opts.log?.warn('ws ticket issue failed', {
++ error: err instanceof Error ? err.message : String(err),
++ })
++ return true
++ }
++ res.writeHead(200, {
++ 'content-type': 'application/json',
++ 'cache-control': 'no-store',
++ })
++ const body: IssueTicketResult = { ticket: issued.token, expiresAt: issued.expiresAt }
++ res.end(JSON.stringify(body))
++ opts.log?.info?.('ws ticket issued', {
++ subject: 'subject' in identity ? identity.subject : undefined,
++ guest: 'guestKeyHash' in identity,
++ })
++ return true
++}
++
++/** A JWT's iat (seconds), read from a token the Network has already accepted. */
++function tokenIat(jwt: string): number | null {
++ const part = jwt.split('.')[1]
++ if (!part) return null
++ try {
++ const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as { iat?: unknown }
++ return typeof claims.iat === 'number' ? claims.iat : null
++ } catch {
++ return null
++ }
++}
ddiff --git a/apps/server/src/net/wsTicketEndpoint.test.ts b/apps/server/src/net/wsTicketEndpoint.test.ts
nnew file mode 100644
iindex 0000000..c0e8b91
---- /dev/null
++++ b/apps/server/src/net/wsTicketEndpoint.test.ts
@@@ -0,0 +1,175 @@
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
++import {
++ createWsTicketStore,
++ handleWsTicketRequest,
++ isSubjectTicket,
++ type TicketIdentity,
++} from './wsTicket.js'
++import { hashGuestKey } from './guestIdentity.js'
++
++/**
++ * POST /api/ws-ticket — the entry point every browser and the slice test
++ * hit before opening /ws. Identity comes from the Authorization header
++ * (Network bearer) or the X-Guest-Token HEADER — never a query parameter,
++ * which would leak the long-lived guest credential into logs and history.
++ * A bearer plus X-Guest-Token binds the subject AND an adoption hint.
++ */
++describe('/api/ws-ticket endpoint', () => {
++ let server: Server
++ let mockNet: Server
++ let url: string
++ const tickets = createWsTicketStore()
++ const SUBJECT = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0'
++ const GUEST = 'guesttoken1234'
++
++ beforeAll(async () => {
++ // A tiny openvibe.network: one accepted bearer, a canonical subject.
++ mockNet = createServer((req, res) => {
++ if (req.url === '/api/auth/me') {
++ const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]
++ if (bearer !== 'staff-token') {
++ res.writeHead(401, { 'content-type': 'application/json' })
++ res.end('{"error":"forbidden"}')
++ return
++ }
++ res.writeHead(200, { 'content-type': 'application/json' })
++ res.end(
++ JSON.stringify({
++ user: { id: 1, username: 'admin', role: 'admin', subject_id: SUBJECT },
++ }),
++ )
++ return
++ }
++ res.writeHead(404)
++ res.end()
++ })
++ await new Promise<void>((r) => mockNet.listen(0, '127.0.0.1', r))
++ const networkAuthUrl = `http://127.0.0.1:${(mockNet.address() as AddressInfo).port}/api/auth/me`
++ server = createServer((req, res) => {
++ void handleWsTicketRequest(req, res, { tickets, networkAuthUrl })
++ })
++ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r))
++ url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
++ })
++ afterEach(() => tickets.reset())
++ afterAll(() => {
++ server?.close()
++ mockNet?.close()
++ })
++
++ it('issues a subject ticket for an accepted Network bearer', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer staff-token' },
++ })
++ expect(resp.status).toBe(200)
++ const body = (await resp.json()) as { ticket: string }
++ const resolved = await tickets.consume(body.ticket)
++ expect(resolved).not.toBeNull()
++ expect(isSubjectTicket(resolved!.identity)).toBe(true)
++ if (isSubjectTicket(resolved!.identity)) expect(resolved!.identity.subject).toBe(SUBJECT)
++ })
++
++ it('rejects a bearer the Network refuses (401)', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer nope' },
++ })
++ expect(resp.status).toBe(401)
++ })
++
++ it('binds an adoption hint from X-Guest-Token on a signed-in ticket', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer staff-token', 'x-guest-token': GUEST },
++ })
++ expect(resp.status).toBe(200)
++ const body = (await resp.json()) as { ticket: string }
++ const resolved = await tickets.consume(body.ticket)
++ expect(resolved?.identity).toEqual<TicketIdentity>({
++ subject: SUBJECT,
++ rank: 'admin',
++ authIat: null,
++ adoptGuestKeyHash: hashGuestKey(GUEST),
++ })
++ })
++
++ it('rejects an account-shaped x-guest-token on a signed-in request (400)', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { authorization: 'Bearer staff-token', 'x-guest-token': SUBJECT },
++ })
++ expect(resp.status).toBe(400)
++ })
++
++ it('issues a guest ticket for the X-Guest-Token header', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { 'x-guest-token': GUEST },
++ })
++ expect(resp.status).toBe(200)
++ const body = (await resp.json()) as { ticket: string; expiresAt: number }
++ expect(body.ticket).toMatch(/^[A-Za-z0-9_-]{43}$/)
++ expect(body.expiresAt).toBeGreaterThan(Date.now())
++ const resolved = await tickets.consume(body.ticket)
++ expect(resolved?.identity).toEqual<TicketIdentity>({ guestKeyHash: hashGuestKey(GUEST) })
++ })
++
++ it('ignores a guest token in the query string: the header is the only source', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket?guest=${GUEST}`, { method: 'POST' })
++ expect(resp.status).toBe(401)
++ })
++
++ it('rejects an empty request (no Authorization, no X-Guest-Token)', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, { method: 'POST' })
++ expect(resp.status).toBe(401)
++ })
++
++ it('rejects a non-POST request', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'GET',
++ headers: { 'x-guest-token': GUEST },
++ })
++ expect(resp.status).toBe(405)
++ })
++
++ it('rejects a guest token that looks like an account key', async () => {
++ // An account-shaped raw token is never accepted — it would let one
++ // browser open someone else's characters. The server refuses first.
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { 'x-guest-token': SUBJECT },
++ })
++ expect(resp.status).toBe(400)
++ })
++
++ it('rejects a body larger than the cap', async () => {
++ const resp = await fetch(`${url}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { 'x-guest-token': GUEST },
++ body: 'x'.repeat(2048),
++ })
++ expect(resp.status).toBe(413)
++ })
++
++ it('is an exact path: /api/ws-ticketX is not this endpoint', async () => {
++ let wrote = false
++ const req = {
++ url: '/api/ws-ticketX',
++ method: 'POST',
++ headers: {},
++ on: () => undefined,
++ } as never
++ const res = {
++ writeHead: () => {
++ wrote = true
++ },
++ end: () => undefined,
++ } as never
++ const handled = await handleWsTicketRequest(req, res, { tickets, networkAuthUrl: null })
++ expect(handled).toBe(false)
++ expect(wrote).toBe(false)
++ })
++})
ddiff --git a/apps/server/src/net/wsTransport.test.ts b/apps/server/src/net/wsTransport.test.ts
nnew file mode 100644
iindex 0000000..a2b9f30
---- /dev/null
++++ b/apps/server/src/net/wsTransport.test.ts
@@@ -0,0 +1,287 @@
++import type { IncomingMessage } from 'node:http'
++import type { Socket } from 'node:net'
++import { PassThrough } from 'node:stream'
++import { describe, expect, it, vi } from 'vitest'
++import { acceptUpgrade } from './wsTransport.js'
++import { createWsTicketStore } from './wsTicket.js'
++import { createActorLimiter } from 'openvibe-sdk/limits'
++
++/**
++ * WebSocket upgrade gate (ADR-0007 netcode + decision 8). The transport
++ * refuses an upgrade before the WS handshake whenever the ticket is missing,
++ * bad, expired, already used, or the address has crossed its per-window
++ * limit.
++ */
++
++/** A fake socket that records what was written/destroyed. */
++function fakeSocket(remoteAddress = '127.0.0.1'): Socket & {
++ _writes: Buffer[]
++ _destroyed: boolean
++} {
++ const pt = new PassThrough() as unknown as Socket
++ const writes: Buffer[] = []
++ const fake = Object.assign(pt, {
++ remoteAddress,
++ destroyed: false,
++ _writes: writes,
++ _destroyed: false,
++ write(chunk: string | Buffer) {
++ writes.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer))
++ return true
++ },
++ destroy() {
++ ;(this as unknown as { _destroyed: boolean })._destroyed = true
++ },
++ end(chunk?: string | Buffer) {
++ if (chunk) writes.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer))
++ ;(this as unknown as { _destroyed: boolean })._destroyed = true
++ return this
++ },
++ }) as Socket & { _writes: Buffer[]; _destroyed: boolean }
++ return fake
++}
++
++/** A fake request with a URL, headers, and a socket object so pendingIdentities can stamp it. */
++function fakeReq(url: string, headers: Record<string, string> = {}): IncomingMessage {
++ return {
++ url,
++ headers,
++ socket: { remoteAddress: '127.0.0.1' } as never,
++ } as unknown as IncomingMessage
++}
++
++/** A limits helper with effectively no per-minute cap (tests that don't care). */
++function openLimits() {
++ const limiter = createActorLimiter({
++ limits: { minute: 10_000 },
++ actor: (req) => (req as { ovActor?: string }).ovActor ?? null,
++ })
++ return {
++ editorActor: (token: string) => `editor:${token}`,
++ // Unused by acceptUpgrade; present so the object shape matches the real
++ // GamesActorLimits.
++ mapSave: () => Promise.resolve(true),
++ asset: () => Promise.resolve(true),
++ modWrite: () => Promise.resolve(true),
++ ticket: () => Promise.resolve(true),
++ upgrade: (req: unknown, _res: unknown, actor: string) =>
++ new Promise<boolean>((resolve) => {
++ ;(req as { ovActor?: string }).ovActor = actor
++ limiter('games.ws.upgrade', { minute: 10_000 })(req as never, _res as never, () =>
++ resolve(true),
++ )
++ }),
++ }
++}
++
++describe('ws upgrade gate', () => {
++ it('refuses an upgrade with no ticket (401 + destroy)', async () => {
++ const tickets = createWsTicketStore()
++ const limits = openLimits()
++ const sock = fakeSocket()
++ const ok = await acceptUpgrade(
++ tickets,
++ limits,
++ { warn: () => undefined } as never,
++ fakeReq('/ws'),
++ sock,
++ )
++ expect(ok).toBe(false)
++ expect(sock._destroyed).toBe(true)
++ expect(Buffer.concat(sock._writes).toString('utf8')).toContain('401')
++ })
++
++ it('refuses an upgrade with a ticket the store has never issued', async () => {
++ const tickets = createWsTicketStore()
++ const sock = fakeSocket()
++ const ok = await acceptUpgrade(
++ tickets,
++ openLimits(),
++ { warn: () => undefined } as never,
++ fakeReq('/ws?ticket=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'),
++ sock,
++ )
++ expect(ok).toBe(false)
++ expect(sock._destroyed).toBe(true)
++ expect(Buffer.concat(sock._writes).toString('utf8')).toContain('401')
++ })
++
++ it('accepts a fresh ticket, then refuses a second upgrade with the same token (single-use)', async () => {
++ const tickets = createWsTicketStore()
++ const limits = openLimits()
++ const { token } = await tickets.issue({
++ subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ rank: null,
++ authIat: null,
++ })
++ const a = fakeSocket('10.0.0.1')
++ expect(
++ await acceptUpgrade(
++ tickets,
++ limits,
++ { warn: () => undefined } as never,
++ fakeReq(`/ws?ticket=${token}`),
++ a,
++ ),
++ ).toBe(true)
++ expect(a._destroyed).toBe(false)
++ // A second attempt with the same token: refused (the first consume
++ // deleted it from the store).
++ const b = fakeSocket('10.0.0.2')
++ expect(
++ await acceptUpgrade(
++ tickets,
++ limits,
++ { warn: () => undefined } as never,
++ fakeReq(`/ws?ticket=${token}`),
++ b,
++ ),
++ ).toBe(false)
++ expect(b._destroyed).toBe(true)
++ expect(Buffer.concat(b._writes).toString('utf8')).toContain('401')
++ })
++
++ it('refuses an expired ticket', async () => {
++ let now = 1_000_000
++ const tickets = createWsTicketStore({ now: () => now })
++ const { token } = await tickets.issue({
++ guestKeyHash: 'a'.repeat(64),
++ })
++ now += 31_000
++ const sock = fakeSocket()
++ const ok = await acceptUpgrade(
++ tickets,
++ openLimits(),
++ { warn: () => undefined } as never,
++ fakeReq(`/ws?ticket=${token}`),
++ sock,
++ )
++ expect(ok).toBe(false)
++ expect(sock._destroyed).toBe(true)
++ })
++
++ it('refuses upgrades past the per-address rate limit (429)', async () => {
++ const tickets = createWsTicketStore()
++ // The SDK limiter lets a single name carry its own per-minute cap, so
++ // the test is independent of createGamesActorLimits' production routing.
++ const limiter = createActorLimiter({
++ limits: { minute: 120 },
++ actor: (req) => (req as { ovActor?: string }).ovActor ?? null,
++ })
++ const wrapped = (req: unknown, res: unknown, actor: string) =>
++ new Promise<boolean>((resolve) => {
++ ;(req as { ovActor?: string }).ovActor = actor
++ limiter('games.ws.upgrade', { minute: 2 })(req as never, res as never, () => resolve(true))
++ const r = res as { writableEnded?: boolean; headersSent?: boolean }
++ if (r.writableEnded || r.headersSent) resolve(false)
++ })
++ const warn = vi.fn()
++ for (let i = 0; i < 2; i++) {
++ const sock = fakeSocket('10.0.0.99')
++ const { token } = await tickets.issue({
++ subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ rank: null,
++ authIat: null,
++ })
++ expect(
++ await acceptUpgrade(
++ tickets,
++ { upgrade: wrapped } as never,
++ { warn } as never,
++ fakeReq(`/ws?ticket=${token}`),
++ sock,
++ ),
++ ).toBe(true)
++ }
++ const sock = fakeSocket('10.0.0.99')
++ const { token } = await tickets.issue({
++ subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ rank: null,
++ authIat: null,
++ })
++ expect(
++ await acceptUpgrade(
++ tickets,
++ { upgrade: wrapped } as never,
++ { warn } as never,
++ fakeReq(`/ws?ticket=${token}`),
++ sock,
++ ),
++ ).toBe(false)
++ expect(sock._destroyed).toBe(true)
++ expect(Buffer.concat(sock._writes).toString('utf8')).toContain('429')
++ })
++
++ it('does not let a spoofed X-Forwarded-For escape the per-address limit', async () => {
++ const tickets = createWsTicketStore()
++ const limiter = createActorLimiter({
++ limits: { minute: 120 },
++ actor: (req) => (req as { ovActor?: string }).ovActor ?? null,
++ })
++ const wrapped = (req: unknown, res: unknown, actor: string) =>
++ new Promise<boolean>((resolve) => {
++ ;(req as { ovActor?: string }).ovActor = actor
++ limiter('games.ws.upgrade', { minute: 2 })(req as never, res as never, () => resolve(true))
++ const r = res as { writableEnded?: boolean; headersSent?: boolean }
++ if (r.writableEnded || r.headersSent) resolve(false)
++ })
++ // Same TCP peer every time, a different spoofed X-Forwarded-For each time.
++ // Without a trusted proxy the limit must key on the socket address, so the
++ // third upgrade is refused even though the header claims a new client.
++ for (let i = 0; i < 2; i++) {
++ const { token } = await tickets.issue({
++ subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ rank: null,
++ authIat: null,
++ })
++ expect(
++ await acceptUpgrade(
++ tickets,
++ { upgrade: wrapped } as never,
++ { warn: vi.fn() } as never,
++ fakeReq(`/ws?ticket=${token}`, { 'x-forwarded-for': `203.0.113.${i}` }),
++ fakeSocket('10.0.0.99'),
++ ),
++ ).toBe(true)
++ }
++ const { token } = await tickets.issue({
++ subject: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ rank: null,
++ authIat: null,
++ })
++ const sock = fakeSocket('10.0.0.99')
++ expect(
++ await acceptUpgrade(
++ tickets,
++ { upgrade: wrapped } as never,
++ { warn: vi.fn() } as never,
++ fakeReq(`/ws?ticket=${token}`, { 'x-forwarded-for': '198.51.100.7' }),
++ sock,
++ ),
++ ).toBe(false)
++ expect(Buffer.concat(sock._writes).toString('utf8')).toContain('429')
++ })
++
++ it('fails closed with 503 when the ticket store errors', async () => {
++ const tickets = {
++ issue: async () => {
++ throw new Error('x')
++ },
++ consume: async () => {
++ throw new Error('valkey down')
++ },
++ reset: () => undefined,
++ }
++ const sock = fakeSocket()
++ const ok = await acceptUpgrade(
++ tickets,
++ openLimits(),
++ { warn: () => undefined } as never,
++ fakeReq('/ws?ticket=whatever'),
++ sock,
++ )
++ expect(ok).toBe(false)
++ expect(sock._destroyed).toBe(true)
++ expect(Buffer.concat(sock._writes).toString('utf8')).toContain('503')
++ })
++})
ddiff --git a/apps/server/src/net/wsTransport.ts b/apps/server/src/net/wsTransport.ts
iindex 979a00a..9aa7024 100644
---- a/apps/server/src/net/wsTransport.ts
++++ b/apps/server/src/net/wsTransport.ts
@@@ -1,43 +1,164 @@
import type { Server } from 'node:http'
++import type { IncomingMessage } from 'node:http'
++import type { Duplex } from 'node:stream'
import { WebSocketServer, type WebSocket } from 'ws'
import { decodeClientMessage } from '@openvibe/protocol'
import type { Logger } from '@openvibe/shared'
import type { GameConnection, GameServer } from '../game/gameServer.js'
++import { type ResolvedTicket, type WsTicketStore } from './wsTicket.js'
++import { type GamesActorLimits } from './actorLimits.js'
++import { resolveClientAddress } from './clientAddress.js'
/**
* WebSocket transport binding. Enforces wire-level limits (message size,
* rate) before anything reaches game code; malformed or abusive traffic
* drops the connection.
++ *
++ * The upgrade itself authenticates (ADR-0007 decision 8 / "Deleted"):
++ * /ws?ticket=… is validated-and-consumed BEFORE accept, so the session
++ * that opens already knows its identity. `hello` carries slot, client
++ * version and appearance only.
*/
const MAX_MESSAGE_BYTES = 4096
const MAX_MESSAGES_PER_SECOND = 120
--export function attachWebSocket(http: Server, game: GameServer, log: Logger): WebSocketServer {
++/**
++ * Per-upgrade identity note: the WebSocketServer itself does not pass us the
++ * `req` we stamped on the underlying socket, so we keep a one-shot map from
++ * the socket to its resolved ticket. The entry is removed the moment the
++ * connection handler reads it.
++ *
++ * The "socket" we key on is whatever opaque object the upgrade handler
++ * gave us (a Duplex in node's typings). On `connection`, req.socket is
++ * the same underlying TCP socket; passing that object back in hits the
++ * same WeakMap entry.
++ */
++const pendingIdentities = new WeakMap<object, ResolvedTicket>()
++
++/** The stream the upgrade handler hands us: a Duplex with write + destroy. */
++type UpgradeStream = Duplex
++
++export interface WsTransportOptions {
++ /** Resolves-and-consumes the upgrade ticket. */
++ tickets: WsTicketStore
++ /** Per-address upgrade rate limit; an address past its limit is 429. */
++ upgradeLimits: GamesActorLimits
++ /** The address a rate limit is keyed on (trusted-proxy aware); socket when absent. */
++ clientAddress?: (req: IncomingMessage) => string
++ log: Logger
++}
++
++/**
++ * Validates the ticket for an incoming upgrade, consumes it, and stores the
++ * resolved identity against the underlying socket. Returns true when the
++ * upgrade may proceed; false writes an HTTP error response itself.
++ *
++ * Must run BEFORE wss.handleUpgrade, and main.ts calls it from the http
++ * upgrade handler.
++ */
++export async function acceptUpgrade(
++ tickets: WsTicketStore,
++ upgradeLimits: GamesActorLimits,
++ log: Logger,
++ req: IncomingMessage,
++ socket: UpgradeStream,
++ /** Trusted-proxy-aware address resolver; the socket address by default. */
++ clientAddress?: (req: IncomingMessage) => string,
++): Promise<boolean> {
++ const url = new URL(req.url ?? '/', 'http://x')
++ const ticket = url.searchParams.get('ticket')
++ // Forwarded headers are honoured only through a configured trusted proxy;
++ // without one this is the TCP peer, so X-Forwarded-For cannot be spoofed
++ // to get a fresh rate-limit bucket.
++ const remote = (clientAddress ?? ((r: IncomingMessage) => resolveClientAddress(r, null)))(req)
++ // Per-address upgrade flood control FIRST, before a ticket is spent. The
++ // limits helper writes its own 429 into the (throwaway) response object
++ // when it refuses; we translate that into the raw HTTP 429 the socket
++ // needs.
++ const fakeRes = {
++ writeHead: () => undefined,
++ setHeader: () => undefined,
++ end() {
++ ;(fakeRes as { writableEnded: boolean }).writableEnded = true
++ },
++ writableEnded: false,
++ headersSent: false,
++ } as never
++ const allowed = await upgradeLimits.upgrade(req, fakeRes, `ip:${remote}`)
++ if (!allowed) {
++ socket.write('HTTP/1.1 429 Too Many Requests\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
++ socket.destroy()
++ log.warn('ws upgrade refused: rate limit', { remote })
++ return false
++ }
++ if (!ticket) {
++ socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
++ socket.destroy()
++ log.warn('ws upgrade refused: no ticket', { remote })
++ return false
++ }
++ let resolved: ResolvedTicket | null
++ try {
++ resolved = await tickets.consume(ticket)
++ } catch (err: unknown) {
++ // The ticket store (Valkey) is unavailable: fail closed, never accept an
++ // unverified upgrade. 503 so the client retries instead of re-authing.
++ socket.write(
++ 'HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
++ )
++ socket.destroy()
++ log.warn('ws upgrade refused: ticket store error', {
++ remote,
++ error: err instanceof Error ? err.message : String(err),
++ })
++ return false
++ }
++ if (!resolved) {
++ socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
++ socket.destroy()
++ log.warn('ws upgrade refused: ticket invalid or consumed', { remote })
++ return false
++ }
++ // Stamp the identity on the underlying TCP socket so the connection
++ // handler can read it via req.socket.
++ pendingIdentities.set(req.socket as object, resolved)
++ return true
++}
++
++export function attachWebSocket(
++ http: Server,
++ game: GameServer,
++ opts: WsTransportOptions,
++): WebSocketServer {
++ const { log } = opts
// noServer: upgrades are routed by path in main (two WSS instances bound
// to one http server both complete the handshake and corrupt frames).
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES })
void http
wss.on('connection', (ws: WebSocket, req) => {
-- // Real client IP for logs: Cloudflare/nginx headers first (we sit behind
-- // both in prod), socket address in dev.
-- const fwd = req.headers['x-forwarded-for']
-- const remote =
-- (typeof req.headers['cf-connecting-ip'] === 'string'
-- ? req.headers['cf-connecting-ip']
-- : undefined) ??
-- (typeof fwd === 'string' ? (fwd.split(',')[0] ?? '').trim() : undefined) ??
-- req.socket.remoteAddress ??
-- 'unknown'
++ // Real client IP for logs, resolved the same trusted-proxy-aware way as
++ // the upgrade rate limit (socket address unless TRUST_PROXY says we sit
++ // behind a configured proxy).
++ const remote = (opts.clientAddress ?? ((r: IncomingMessage) => resolveClientAddress(r, null)))(
++ req,
++ )
let msgCount = 0
let windowStart = Date.now()
++ // Recover the identity the upgrade handler stamped on the socket. This
++ // is the one place the WS connection knows who opened it; nothing
++ // beyond uses msg.auth.
++ const identity = pendingIdentities.get(req.socket as object) ?? null
++ pendingIdentities.delete(req.socket as object)
++
const conn: GameConnection = {
send: (text) => {
if (ws.readyState === ws.OPEN) ws.send(text)
},
close: (code, reason) => ws.close(code, reason),
++ ...(identity ? { identity: identity.identity } : {}),
}
ws.on('message', (data, isBinary) => {
ddiff --git a/apps/server/src/platform/staffAuth.ts b/apps/server/src/platform/staffAuth.ts
iindex 9c1875e..021d4fa 100644
---- a/apps/server/src/platform/staffAuth.ts
++++ b/apps/server/src/platform/staffAuth.ts
@@@ -3,10 +3,11 @@
* rank that may edit the map), or a platform principal whose client-
* credentials token for audience `openvibe.games` carries the
* `games.mod.manage` capability (verified offline against the Network JWKS).
-- * Without a Network configured (local development) the EDITOR_KEY is
-- * accepted, exactly as for the map editor.
++ *
++ * The editor (HTTP map save, asset upload, WS presence) authorizes through
++ * the same Network staff check; there is no longer a shared-secret
++ * legacy shared-secret fallback (ADR-0007 "Deleted").
*/
--import { timingSafeEqual } from 'node:crypto'
import type { IncomingMessage } from 'node:http'
import { createRequire } from 'node:module'
import { verifyUserToken, type UserTokenClaims } from 'openvibe-sdk/auth'
@@@ -26,7 +27,6 @@ export interface StaffAuthOptions {
networkAuthUrl: string | null
/** Network base for the JWKS (`/api/.well-known/jwks`). */
networkUrl: string
-- editorKey: string | null
/** Test seam: verifies a principal token. */
verifyPrincipal?: (token: string) => Promise<UserTokenClaims>
}
@@@ -49,12 +49,6 @@ function looksLikePrincipal(token: string): boolean {
}
}
--function sameSecret(a: string, b: string): boolean {
-- const x = Buffer.from(a)
-- const y = Buffer.from(b)
-- return x.length === y.length && timingSafeEqual(x, y)
--}
--
export function createStaffAuthorizer(
opts: StaffAuthOptions,
): (req: IncomingMessage) => Promise<ModActor | null> {
@@@ -69,11 +63,9 @@ export function createStaffAuthorizer(
return async (req) => {
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]?.trim()
-- const editorKey = req.headers['x-editor-key']
-- const token = bearer ?? (typeof editorKey === 'string' ? editorKey : undefined)
-- if (!token) return null
++ if (!bearer) return null
-- if (bearer && looksLikePrincipal(bearer)) {
++ if (looksLikePrincipal(bearer)) {
try {
const claims = await verifyPrincipal(bearer)
if (!contracts.capabilities.grants(claims.cap, CAP_MOD_MANAGE)) return null
@@@ -89,7 +81,7 @@ export function createStaffAuthorizer(
}
if (opts.networkAuthUrl) {
-- const user = await resolveNetworkUser(opts.networkAuthUrl, token)
++ const user = await resolveNetworkUser(opts.networkAuthUrl, bearer)
if (!user || !canEditMap(user.rank)) return null
if (user.subjectId?.startsWith('usr_')) {
return { audit: user.subjectId, subject: { type: 'user', id: user.subjectId } }
@@@ -101,9 +93,9 @@ export function createStaffAuthorizer(
subject: { type: 'service', id: 'games' },
}
}
-- if (opts.editorKey && sameSecret(token, opts.editorKey)) {
-- return { audit: 'editor-key', subject: { type: 'service', id: 'games' } }
-- }
++ // Without Network configured (local development) no Authorization
++ // header gets a result. the legacy editor key is gone — there is no shared-secret
++ // fallback for the mods API either.
return null
}
}
ddiff --git a/apps/server/src/shutdown.test.ts b/apps/server/src/shutdown.test.ts
iindex af9778a..51f2a48 100644
---- a/apps/server/src/shutdown.test.ts
++++ b/apps/server/src/shutdown.test.ts
@@@ -41,6 +41,16 @@ async function refused(port: number): Promise<boolean> {
}
}
++async function fetchTicket(httpBase: string, guest = 'shutdown-guest-token'): Promise<string> {
++ const resp = await fetch(`${httpBase}/api/ws-ticket`, {
++ method: 'POST',
++ headers: { 'x-guest-token': guest },
++ })
++ if (!resp.ok) throw new Error(`ws-ticket failed: ${resp.status}`)
++ const { ticket } = (await resp.json()) as { ticket: string }
++ return ticket
++}
++
function openSocket(
url: string,
): Promise<{ ws: WebSocket; closed: Promise<{ code: number; reason: string }> }> {
@@@ -99,7 +109,10 @@ describe('SIGTERM', () => {
}
expect(out).toContain('"events on"')
-- const player = await openSocket(`ws://127.0.0.1:${port}/ws`)
++ const playerTicket = await fetchTicket(`http://127.0.0.1:${port}`)
++ const player = await openSocket(
++ `ws://127.0.0.1:${port}/ws?ticket=${encodeURIComponent(playerTicket)}`,
++ )
const editor = await openSocket(`ws://127.0.0.1:${port}/editor-ws`)
// A request in flight: POST /internal/events reads its whole body before it answers.
ddiff --git a/deploy/openvibe-games.service b/deploy/openvibe-games.service
iindex 1b3ff95..52b9179 100644
---- a/deploy/openvibe-games.service
++++ b/deploy/openvibe-games.service
@@@ -1,6 +1,6 @@
# OpenVibe.Games — authoritative game server (Scraplandia) + portal static host.
# Install: cp deploy/openvibe-games.service /etc/systemd/system/ && systemctl daemon-reload
--# Env secrets (OV_OAUTH_CLIENT_SECRET, EDITOR_KEY, ...) live in /etc/openvibe/games.env (0600).
++# Env secrets (OV_OAUTH_CLIENT_SECRET, ...) live in /etc/openvibe/games.env (0600).
# Platform (optional, see README "Platform integration"): OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000,
# EVENTS_URL=http://127.0.0.1:4300, MEDIA_URL=http://127.0.0.1:4100 (MEDIA_NAMESPACE=games) in the same file.
[Unit]
@@@ -17,6 +17,10 @@ ExecStart=/usr/bin/env node --import tsx src/main.ts
Environment=NODE_ENV=production
Environment=PORT=8000
Environment=HOST=127.0.0.1
++# nginx on this host is the only way in (HOST=127.0.0.1) and overwrites X-Real-IP, X-Forwarded-For and
++# CF-Connecting-IP with its Cloudflare-verified $remote_addr, so the per-address limits may trust it.
++# Without this every request resolves to 127.0.0.1 and the limits become one global bucket.
++Environment=TRUST_PROXY=127.0.0.1
Environment=GAMES_PLACE_ID=scraplandia
Environment=GAMES_MIGRATIONS_DIR=/opt/openvibe.games/packages/persistence/migrations
# DATABASE_URL (pooled), DATABASE_DIRECT_URL (owner role, for migrations) and VALKEY_URL live in the
ddiff --git a/docs/contracts-proposal/catalog-entry.json b/docs/contracts-proposal/catalog-entry.json
ddeleted file mode 100644
iindex 20df671..0000000
---- a/docs/contracts-proposal/catalog-entry.json
++++ /dev/null
@@@ -1,11 +0,0 @@
--{
-- "_about": "Proposed catalog entry for contracts/catalog.json (the schema is mods/mod-manifest.v1.json next to this file; it references ../identity/subject-ref.v1.json and ../media/media-ref.v1.json).",
-- "id": "mods.mod-manifest",
-- "version": "1.0.0",
-- "owner": "contracts",
-- "schema": "mods/mod-manifest.v1.json",
-- "visibility": "public",
-- "compatibility": "backward",
-- "status": "active",
-- "adr": "ADR-013"
--}
ddiff --git a/docs/contracts-proposal/mods/mod-manifest.v1.json b/docs/contracts-proposal/mods/mod-manifest.v1.json
ddeleted file mode 100644
iindex 2f5ae9b..0000000
---- a/docs/contracts-proposal/mods/mod-manifest.v1.json
++++ /dev/null
@@@ -1,134 +0,0 @@
--{
-- "$schema": "https://json-schema.org/draft/2020-12/schema",
-- "$id": "https://openvibe.network/contracts/mods/mod-manifest.v1.json",
-- "title": "ModManifest",
-- "description": "A mod as the platform knows it (ADR-013): who publishes it, which runtime runs it, which capabilities it asks for and the resources it may use. Requested capabilities are only a request: the install's approved subset is the grant, and trust tiers are install metadata that never change a grant check. The runtime-specific payload (a data pack, a script bundle) is not part of the manifest.",
-- "type": "object",
-- "required": [
-- "id",
-- "name",
-- "version",
-- "publisher",
-- "target",
-- "runtime",
-- "permissions",
-- "resources",
-- "compatibility"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "id": {
-- "type": "string",
-- "pattern": "^mod_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "Stable mod id; its principal subject is mod:<id>."
-- },
-- "name": { "type": "string", "minLength": 1, "maxLength": 80 },
-- "version": {
-- "type": "string",
-- "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
-- "description": "Semantic version of this release."
-- },
-- "description": { "type": "string", "maxLength": 1000 },
-- "publisher": {
-- "$ref": "../identity/subject-ref.v1.json",
-- "description": "Who publishes the mod (a user or an app subject)."
-- },
-- "target": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{1,39}\\.[a-z][a-z0-9-]{1,39}$",
-- "description": "Where the mod runs: <service>.<surface>, e.g. games.browser, games.source, live.overlay."
-- },
-- "runtime": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{1,39}@[1-9][0-9]{0,3}$",
-- "description": "Runtime adapter and its major version, e.g. games-content@1 (declarative data pack) or source-quickjs@1."
-- },
-- "permissions": {
-- "type": "object",
-- "required": ["capabilities"],
-- "additionalProperties": false,
-- "properties": {
-- "capabilities": {
-- "description": "Capability ids the mod asks for (3+ segments). The target runtime binds only those it implements, and only once granted.",
-- "type": "array",
-- "maxItems": 64,
-- "uniqueItems": true,
-- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" }
-- },
-- "events": {
-- "description": "Event types the mod wants delivered to it.",
-- "type": "array",
-- "maxItems": 64,
-- "uniqueItems": true,
-- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+){2,}$" }
-- },
-- "modules": {
-- "description": "User-module namespaces the mod wants to read or write (a trailing .* names a family).",
-- "type": "array",
-- "maxItems": 32,
-- "uniqueItems": true,
-- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*(\\.\\*)?$" }
-- },
-- "mediaNamespaces": {
-- "description": "Media namespaces the mod wants to read or write.",
-- "type": "array",
-- "maxItems": 32,
-- "uniqueItems": true,
-- "items": { "type": "string", "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z0-9_-]+)*$" }
-- }
-- }
-- },
-- "resources": {
-- "type": "object",
-- "required": ["cpuMs", "memoryMb", "storageMb"],
-- "additionalProperties": false,
-- "description": "The budget the mod asks for. Runtimes meter it; the sandbox that enforces it lives in OpenVibe.Host (Stage C).",
-- "properties": {
-- "cpuMs": {
-- "type": "number",
-- "minimum": 0,
-- "maximum": 1000,
-- "description": "CPU milliseconds per tick (game runtimes) or per request."
-- },
-- "memoryMb": { "type": "integer", "minimum": 0, "maximum": 4096 },
-- "storageMb": { "type": "integer", "minimum": 0, "maximum": 102400 },
-- "outboundHosts": {
-- "description": "Hosts the mod may reach over the network. Empty or absent = none.",
-- "type": "array",
-- "maxItems": 32,
-- "uniqueItems": true,
-- "items": {
-- "type": "string",
-- "pattern": "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$"
-- }
-- }
-- }
-- },
-- "assets": {
-- "description": "Assets the mod ships, as Media object references.",
-- "type": "array",
-- "maxItems": 256,
-- "items": { "$ref": "../media/media-ref.v1.json" }
-- },
-- "compatibility": {
-- "type": "object",
-- "required": ["runtime"],
-- "additionalProperties": false,
-- "properties": {
-- "runtime": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 100,
-- "description": "Semver range of the runtime this release works with, e.g. \">=1.0.0 <2.0.0\"."
-- },
-- "contracts": {
-- "type": "string",
-- "minLength": 1,
-- "maxLength": 100,
-- "description": "Semver range of openvibe-contracts releases it was built against."
-- }
-- }
-- },
-- "homepage": { "type": "string", "pattern": "^https://[^\\s]{1,2000}$" }
-- }
--}
ddiff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts
iindex bf4327b..eb3ef81 100644
---- a/packages/protocol/src/codec.test.ts
++++ b/packages/protocol/src/codec.test.ts
@@@ -6,14 +6,9 @@ import { defaultAppearance } from './appearance.js'
describe('protocol codec', () => {
it('roundtrips every client message kind', () => {
const messages: ClientMessage[] = [
-- {
-- t: 'hello',
-- v: 1,
-- token: 'abcdefgh12345678',
-- slot: 0,
-- name: 'Tester',
-- appearance: defaultAppearance(),
-- },
++ // hello carries no identity field: authentication happens at the
++ // WebSocket upgrade (ADR-0007 decision 8).
++ { t: 'hello', v: 1, slot: 0, name: 'Tester', appearance: defaultAppearance() },
{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yaw: 1.2, pitch: -0.3, buttons: 5 },
{ t: 'use', target: 'abc123' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
@@@ -47,6 +42,22 @@ describe('protocol codec', () => {
).toBeNull()
})
++ it('ignores an identity field in hello (the upgrade is the only authn gate)', () => {
++ const decoded = decodeClientMessage(
++ JSON.stringify({
++ t: 'hello',
++ v: 1,
++ token: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ slot: 0,
++ name: 'Tester',
++ appearance: defaultAppearance(),
++ }),
++ )
++ expect(decoded).not.toBeNull()
++ // The smuggled account key never becomes part of the message.
++ expect(decoded).not.toHaveProperty('token')
++ })
++
it('rejects oversized strings', () => {
expect(decodeClientMessage(JSON.stringify({ t: 'use', target: 'x'.repeat(100) }))).toBeNull()
})
ddiff --git a/packages/protocol/src/editor.test.ts b/packages/protocol/src/editor.test.ts
iindex 88f41af..f6b5aa6 100644
---- a/packages/protocol/src/editor.test.ts
++++ b/packages/protocol/src/editor.test.ts
@@@ -18,7 +18,7 @@ const server = (v: unknown): EditorServerMessage | null =>
describe('client messages', () => {
it('round-trips every kind', () => {
const messages: EditorClientMessage[] = [
-- { t: 'hello', key: 'secret', name: 'Ada' },
++ { t: 'hello', auth: 'secret', name: 'Ada' },
{ t: 'camera', pos: [1, 2, 3], yaw: 0.5, pitch: -0.2 },
{ t: 'selection', ids: ['a', 'b'] },
{ t: 'lockRequest', ids: ['a'] },
@@@ -38,15 +38,15 @@ describe('client messages', () => {
expect(decodeEditorClientMessage('not json')).toBeNull()
})
-- it('requires a key on hello and defaults the name', () => {
++ it('requires an auth token on hello and defaults the name', () => {
expect(client({ t: 'hello' })).toBeNull()
-- expect(client({ t: 'hello', key: '' })).toBeNull()
-- expect(client({ t: 'hello', key: 'k' })).toEqual({ t: 'hello', key: 'k', name: 'editor' })
++ expect(client({ t: 'hello', auth: '' })).toBeNull()
++ expect(client({ t: 'hello', auth: 'k' })).toEqual({ t: 'hello', auth: 'k', name: 'editor' })
})
it('bounds the display name rather than trusting it', () => {
-- const long = client({ t: 'hello', key: 'k', name: 'x'.repeat(500) })
-- expect(long).toEqual({ t: 'hello', key: 'k', name: 'editor' })
++ const long = client({ t: 'hello', auth: 'k', name: 'x'.repeat(500) })
++ expect(long).toEqual({ t: 'hello', auth: 'k', name: 'editor' })
})
it('refuses a non-finite camera instead of poisoning presence', () => {
ddiff --git a/packages/protocol/src/editor.ts b/packages/protocol/src/editor.ts
iindex 2ff2c95..4a4d527 100644
---- a/packages/protocol/src/editor.ts
++++ b/packages/protocol/src/editor.ts
@@@ -45,8 +45,8 @@ export const EDITOR_HEARTBEAT_MS = 12_000
export interface EditorHello {
t: 'hello'
-- /** Editor credential. Sent in the body, never in the URL. */
-- key: string
++ /** Network access token; the server validates `staff.games.manage`. */
++ auth: string
name: string
}
@@@ -202,9 +202,12 @@ export function decodeEditorClientMessage(raw: string): EditorClientMessage | nu
if (!isRecord(value)) return null
switch (value['t']) {
case 'hello': {
-- const key = str(value['key'], 512)
++ // Bearer token (Network session) — was `key` when a shared secret authorized the
++ // shared secret. The field is now a Network access token validated
++ // against `staff.games.manage`; the name survives.
++ const auth = str(value['auth'], 2048)
const name = str(value['name'], EDITOR_MAX_NAME_LENGTH)
-- return key === null ? null : { t: 'hello', key, name: name ?? 'editor' }
++ return auth === null ? null : { t: 'hello', auth, name: name ?? 'editor' }
}
case 'camera': {
const pos = vec3(value['pos'])
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex bce49d8..1f77b31 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -10,10 +10,6 @@ import { AppearanceSchema } from '../appearance.js'
export const ClientHelloSchema = z.object({
t: z.literal('hello'),
v: z.number().int(),
-- /** Persistent identity token (localStorage). Replaced by real auth later. */
-- token: z.string().min(8).max(64),
-- /** openvibe.network session token (a JWT — they run long); absent for guests. */
-- auth: z.string().min(1).max(2048).optional(),
/** Character slot under this account (MMO-style, 3 max). */
slot: z.number().int().min(0).max(2).default(0),
name: z.string().min(1).max(24),
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 99f5765..63f12cc 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -114,8 +114,8 @@ importers:
specifier: ^0.4.0
version: 0.4.0
openvibe-contracts:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.77.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.77.0
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0(@electric-sql/[email protected])([email protected])([email protected])
@@@ -2035,12 +2035,12 @@ packages:
integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==,
}
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.77.0:
resolution:
{
-- tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0,
++ tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.77.0,
}
-- version: 0.71.0
++ version: 0.77.0
engines: { node: '>=20' }
hasBin: true
@@@ -3751,7 +3751,7 @@ snapshots:
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.77.0:
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])