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).
Changes
@@@ -19,4 +19,8 @@ VALKEY_PREFIX=ov:games:# ── Server ──PORT=8000HOST=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@@@ -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$''']@@@ -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 releasetarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite indevelopment), 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 usesan 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 gameWebSocket 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-subjectsa restart-safe queue (`media_mirrors`). There are no screenshots or blueprintfiles 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 itsrequested 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 limiPlayers are keyed by Network subjects; guest tokens that look like account keys are refused. Mods aredeclarative 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 keysbetween services.@@@ -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/mainDATABASE_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),@@@ -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 belowconst 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/mainDATABASE_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],@@@ -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),})@@@ -29,7 +29,7 @@ export interface AssetControllerOptions {doc: EditorDocumenthistory: CommandHistory<EditorDocument>modelCache: ModelCache-- editorKey: () => string++ editorToken: () => string/** Re-register custom textures with the renderer after a change. */onAssetsChanged: () => voidsetMessage: (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'@@@ -156,7 +156,7 @@ export function createEditorConnection(opts: EditorConnectionOptions): EditorConsocket = wsws.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)))@@@ -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@@@ -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 storedconst 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()}@@@ -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): SaveControlletry {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): SaveControllemethod: '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 } : {}),@@@ -132,13 +132,6 @@ export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): ShellspaceBtn.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 = 14const 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): Shellsettings.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 ─────────────────────────────────────────────────────────@@@ -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 = wsawait 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.})}@@@ -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",@@@ -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(),@@@ -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))@@@ -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 | nullmapPath: 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 ??@@@ -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<Bofunction 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)@@@ -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): voidclose(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.@@@ -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: stringlet subjectId: string | null = nulllet authIat: number | null = nulllet 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)@@@ -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 {@@@ -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 ContractsModuledescribe('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', () => {@@@ -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: stringname: 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 {ddeleted file mode 100644@@@ -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@@@ -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()})})@@@ -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),}}nnew file mode 100644@@@ -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'])++ })++})nnew file mode 100644@@@ -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++}@@@ -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): Ediws.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)}nnew file mode 100644@@@ -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')++}@@@ -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()++ }++ })++})@@@ -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)) returnif (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' })nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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 }++}nnew file mode 100644@@@ -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++ }++}nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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')++ })++})@@@ -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 = 4096const 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 httpwss.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 = 0let 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) => {@@@ -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 nullif (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}}@@@ -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.@@@ -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.tsEnvironment=NODE_ENV=productionEnvironment=PORT=8000Environment=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.1Environment=GAMES_PLACE_ID=scraplandiaEnvironment=GAMES_MIGRATIONS_DIR=/opt/openvibe.games/packages/persistence/migrations# DATABASE_URL (pooled), DATABASE_DIRECT_URL (owner role, for migrations) and VALKEY_URL live in theddeleted file mode 100644@@@ -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"--}ddeleted file mode 100644@@@ -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}$" }-- }--}@@@ -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()})@@@ -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', () => {@@@ -45,8 +45,8 @@ export const EDITOR_HEARTBEAT_MS = 12_000export 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: stringname: string}@@@ -202,9 +202,12 @@ export function decodeEditorClientMessage(raw: string): EditorClientMessage | nuif (!isRecord(value)) return nullswitch (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'])@@@ -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),@@@ -114,8 +114,8 @@ importers:specifier: ^0.4.0version: 0.4.0openvibe-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.0openvibe-sdk:specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0version: 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.0engines: { 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.0ajv-formats: 3.0.1([email protected])
@@ -19,4 +19,8 @@ VALKEY_PREFIX=ov:games:
@@ -19,4 +19,8 @@ VALKEY_PREFIX=ov:games:
# ── Server ──
# ── Server ──
PORT=8000
PORT=8000
HOST=0.0.0.0
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
MAP_PATH=data/map.json
@@ -11,7 +11,7 @@ paths = ['''^packages/(persistence/src/pg/pgStore|protocol/src/codec)\.test\.ts$
@@ -11,7 +11,7 @@ paths = ['''^packages/(persistence/src/pg/pgStore|protocol/src/codec)\.test\.ts$
regexes = ['''^(tok_12345678|abcdefgh12345678)$''']
regexes = ['''^(tok_12345678|abcdefgh12345678)$''']
[[allowlists]]
[[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"
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$''']
regexes = ['''^usr_01JABCDEFGHJKMNPQRSTVWXYZ0$''']
@@ -31,7 +31,7 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
@@ -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`
- 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)
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
tarball in `apps/server/package.json`), Babylon.js, Havok, PostgreSQL (`pg`, or embedded PGlite in
development), Valkey (`iovalkey`, optional)
development), Valkey (`iovalkey`, optional)
@@ -113,7 +113,13 @@ DATABASE_URL=postgres://… DATABASE_DIRECT_URL=postgres://… STATIC_DIR=apps/c
@@ -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
(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.
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`
`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
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
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
@@ -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
a restart-safe queue (`media_mirrors`). There are no screenshots or blueprint
files to upload: blueprints are per-player recipe unlocks.
files to upload: blueprints are per-player recipe unlocks.
- **Mods** (ADR-013 in OpenVibe.Contracts). Manifests follow
- **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;
declarative data packs checked against `@openvibe/content` (announcements;
inert, mod-owned props). Each install stores the approved subset of its
inert, mod-owned props). Each install stores the approved subset of its
requested capabilities; every runtime binding checks it at call time, a
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
@@ -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
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.
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
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.
between services.
@@ -11,6 +11,8 @@
@@ -11,6 +11,8 @@
* pnpm test:editor
* pnpm test:editor
*/
*/
import { spawn } from 'node:child_process'
import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import type { AddressInfo } from 'node:net'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join } from 'node:path'
@@ -79,6 +81,41 @@ writeFileSync(
@@ -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'], {
const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {
env: {
env: {
...process.env,
...process.env,
@@ -89,7 +126,7 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
@@ -89,7 +126,7 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
DATABASE_DIRECT_URL: '',
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mapPath,
MAP_PATH: mapPath,
- EDITOR_KEY: 'test-admin-key',
+ OV_NETWORK_AUTH_URL: mockNetUrl,
},
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
})
})
@@ -705,7 +742,7 @@ async function withMap(
@@ -705,7 +742,7 @@ async function withMap(
DATABASE_DIRECT_URL: '',
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
MAP_PATH: mp,
- EDITOR_KEY: 'test-admin-key',
+ OV_NETWORK_AUTH_URL: mockNetUrl,
},
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
})
})
@@ -726,7 +763,12 @@ async function withMap(
@@ -726,7 +763,12 @@ async function withMap(
try {
try {
const pg = await br.newPage({ viewport: { width: 1400, height: 900 } })
const pg = await br.newPage({ viewport: { width: 1400, height: 900 } })
// Asset upload and collaboration both need the editor credential.
// 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) =>
pg.on('pageerror', (e) =>
console.log('[pageerror]', String((e as Error).stack ?? e).slice(0, 500)),
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')
@@ -780,7 +822,7 @@ section('O. Live static reconciliation reaches the running server')
DATABASE_DIRECT_URL: '',
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
MAP_PATH: mp,
- EDITOR_KEY: 'test-admin-key',
+ OV_NETWORK_AUTH_URL: mockNetUrl,
},
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
})
})
@@ -812,7 +854,7 @@ section('O. Live static reconciliation reaches the running server')
@@ -812,7 +854,7 @@ section('O. Live static reconciliation reaches the running server')
method: 'POST',
method: 'POST',
headers: {
headers: {
'content-type': 'application/json',
'content-type': 'application/json',
- 'x-editor-key': 'test-admin-key',
+ authorization: `Bearer ${TEST_TOKEN}`,
...(ifMatch ? { 'if-match': ifMatch } : {}),
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
},
body: JSON.stringify(map),
body: JSON.stringify(map),
@@ -828,11 +870,11 @@ section('O. Live static reconciliation reaches the running server')
@@ -828,11 +870,11 @@ section('O. Live static reconciliation reaches the running server')
])
])
const upload = async (
const upload = async (
body: Buffer,
body: Buffer,
- key = 'test-admin-key',
+ token: string = TEST_TOKEN,
): Promise<{ status: number; json: Record<string, unknown> }> => {
): Promise<{ status: number; json: Record<string, unknown> }> => {
const resp = await fetch(`${root}/api/map-assets`, {
const resp = await fetch(`${root}/api/map-assets`, {
method: 'POST',
method: 'POST',
- headers: { 'x-editor-key': key },
+ headers: { authorization: `Bearer ${token}` },
body: new Uint8Array(body),
body: new Uint8Array(body),
})
})
return { status: resp.status, json: (await resp.json()) as Record<string, unknown> }
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')
@@ -866,8 +908,12 @@ section('O. Live static reconciliation reaches the running server')
const bogus = await upload(Buffer.from('<?php system($_GET[0]); ?>'))
const bogus = await upload(Buffer.from('<?php system($_GET[0]); ?>'))
ok('unsupported content is refused, not stored', bogus.status === 400, bogus)
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'])}`)
const fetched = await fetch(`${root}${String(first.json['url'])}`)
ok('the stored asset is served back', fetched.status === 200)
ok('the stored asset is served back', fetched.status === 200)
@@ -1037,7 +1083,7 @@ section('U. Live reconciliation touches only what changed')
@@ -1037,7 +1083,7 @@ section('U. Live reconciliation touches only what changed')
DATABASE_DIRECT_URL: '',
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mp,
MAP_PATH: mp,
- EDITOR_KEY: 'test-admin-key',
+ OV_NETWORK_AUTH_URL: mockNetUrl,
},
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
})
})
@@ -1066,7 +1112,7 @@ section('U. Live reconciliation touches only what changed')
@@ -1066,7 +1112,7 @@ section('U. Live reconciliation touches only what changed')
method: 'POST',
method: 'POST',
headers: {
headers: {
'content-type': 'application/json',
'content-type': 'application/json',
- 'x-editor-key': 'test-admin-key',
+ authorization: `Bearer ${TEST_TOKEN}`,
...(ifMatch ? { 'if-match': ifMatch } : {}),
...(ifMatch ? { 'if-match': ifMatch } : {}),
},
},
body: JSON.stringify(map),
body: JSON.stringify(map),
@@ -15,13 +15,16 @@
@@ -15,13 +15,16 @@
* pnpm test:collab
* pnpm test:collab
*/
*/
import { spawn } from 'node:child_process'
import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import type { AddressInfo } from 'node:net'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join } from 'node:path'
import { chromium, type Browser, type JSHandle, type Page } from 'playwright'
import { chromium, type Browser, type JSHandle, type Page } from 'playwright'
const PORT = 18271
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 dir = mkdtempSync(join(tmpdir(), 'openvibe-collab-'))
const mapPath = join(dir, 'map.json')
const mapPath = join(dir, 'map.json')
@@ -60,6 +63,34 @@ writeFileSync(
@@ -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'], {
const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main.ts'], {
env: {
env: {
...process.env,
...process.env,
@@ -70,7 +101,7 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
@@ -70,7 +101,7 @@ const server = spawn(process.execPath, ['--import', 'tsx', 'apps/server/src/main
DATABASE_DIRECT_URL: '',
DATABASE_DIRECT_URL: '',
STATIC_DIR: 'apps/client/dist',
STATIC_DIR: 'apps/client/dist',
MAP_PATH: mapPath,
MAP_PATH: mapPath,
- EDITOR_KEY: KEY,
+ OV_NETWORK_AUTH_URL: mockNetUrl,
},
},
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
})
})
@@ -135,10 +166,12 @@ async function openEditor(browser: Browser, name: string): Promise<Page> {
@@ -135,10 +166,12 @@ async function openEditor(browser: Browser, name: string): Promise<Page> {
const context = await browser.newContext({ viewport: { width: 1280, height: 820 } })
const context = await browser.newContext({ viewport: { width: 1280, height: 820 } })
const page = await context.newPage()
const page = await context.newPage()
page.on('pageerror', (e) => console.log(`[${name} pageerror]`, String(e).slice(0, 300)))
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(
await page.addInitScript(
([k, n]) => {
([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)
localStorage.setItem('openvibe.editor.name', n as string)
},
},
[KEY, name],
[KEY, name],
@@ -62,7 +62,7 @@ const controller = (doc: EditorDocument) => {
@@ -62,7 +62,7 @@ const controller = (doc: EditorDocument) => {
doc,
doc,
history,
history,
modelCache: { forget: () => undefined, instantiate: () => Promise.resolve(null) } as never,
modelCache: { forget: () => undefined, instantiate: () => Promise.resolve(null) } as never,
- editorKey: () => 'k',
+ editorToken: () => 'k',
onAssetsChanged: () => undefined,
onAssetsChanged: () => undefined,
setMessage: (m) => messages.push(m),
setMessage: (m) => messages.push(m),
})
})
@@ -29,7 +29,7 @@ export interface AssetControllerOptions {
@@ -29,7 +29,7 @@ export interface AssetControllerOptions {
doc: EditorDocument
doc: EditorDocument
history: CommandHistory<EditorDocument>
history: CommandHistory<EditorDocument>
modelCache: ModelCache
modelCache: ModelCache
- editorKey: () => string
+ editorToken: () => string
/** Re-register custom textures with the renderer after a change. */
/** Re-register custom textures with the renderer after a change. */
onAssetsChanged: () => void
onAssetsChanged: () => void
setMessage: (m: string) => void
setMessage: (m: string) => void
@@ -122,7 +122,7 @@ export class AssetController {
@@ -122,7 +122,7 @@ export class AssetController {
try {
try {
const resp = await fetch('/api/map-assets', {
const resp = await fetch('/api/map-assets', {
method: 'POST',
method: 'POST',
- headers: { 'x-editor-key': this.opts.editorKey() },
+ headers: { authorization: `Bearer ${this.opts.editorToken()}` },
body: blob,
body: blob,
})
})
if (resp.status === 403) return 'forbidden'
if (resp.status === 403) return 'forbidden'
@@ -156,7 +156,7 @@ export function createEditorConnection(opts: EditorConnectionOptions): EditorCon
@@ -156,7 +156,7 @@ export function createEditorConnection(opts: EditorConnectionOptions): EditorCon
socket = ws
socket = ws
ws.addEventListener('open', () => {
ws.addEventListener('open', () => {
open = true
open = true
- send({ t: 'hello', key, name: opts.nameOf() })
+ send({ t: 'hello', auth: key, name: opts.nameOf() })
startTimers()
startTimers()
})
})
ws.addEventListener('message', (e) => onMessage(String(e.data)))
ws.addEventListener('message', (e) => onMessage(String(e.data)))
@@ -764,7 +764,7 @@ export async function bootEditor(): Promise<void> {
@@ -764,7 +764,7 @@ export async function bootEditor(): Promise<void> {
doc,
doc,
history,
history,
modelCache,
modelCache,
- editorKey: editorToken,
+ editorToken,
onAssetsChanged: () => {
onAssetsChanged: () => {
registerCustomTextures(doc.textures() as MapTextureEntry[])
registerCustomTextures(doc.textures() as MapTextureEntry[])
ui.refreshAll()
ui.refreshAll()
@@ -990,18 +990,13 @@ export async function bootEditor(): Promise<void> {
@@ -990,18 +990,13 @@ export async function bootEditor(): Promise<void> {
ui.refreshAll()
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()
connection.connect()
// Offer any unsaved work from a previous session. Never automatic: a
// Offer any unsaved work from a previous session. Never automatic: a
// crash recovering itself into everyone else's world would be worse than
// crash recovering itself into everyone else's world would be worse than
@@ -1,20 +1,23 @@
@@ -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 {
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')
const stored = localStorage.getItem('ovg_sso')
if (stored) return stored
if (stored) return stored
const cookie = document.cookie.split('; ').find((c) => c.startsWith('ovg_sso='))
const cookie = document.cookie.split('; ').find((c) => c.startsWith('ovg_sso='))
return cookie ? decodeURIComponent(cookie.slice('ovg_sso='.length)) : ''
return cookie ? decodeURIComponent(cookie.slice('ovg_sso='.length)) : ''
}
}
+/** What every editor call carries as Authorization: Bearer …. */
export function editorToken(): string {
export function editorToken(): string {
- const manual =
- (document.getElementById('key') as HTMLInputElement | null)?.value.trim() ?? ''
- return manual || ssoToken()
+ return ssoToken()
}
}
@@ -71,7 +71,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -71,7 +71,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
// the last place two authorities described the same map.
// the last place two authorities described the same map.
const serialize = (): MapFileV2 => doc.serialize()
const serialize = (): MapFileV2 => doc.serialize()
- const editorKey = editorToken
+ const tokenOf = editorToken
/**
/**
* Masks stay canvases while editing and become content-addressed assets on
* Masks stay canvases while editing and become content-addressed assets on
@@ -91,7 +91,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -91,7 +91,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
try {
try {
const resp = await fetch('/api/map-assets', {
const resp = await fetch('/api/map-assets', {
method: 'POST',
method: 'POST',
- headers: { 'x-editor-key': editorKey() },
+ headers: { authorization: `Bearer ${tokenOf()}` },
body: blob,
body: blob,
})
})
if (!resp.ok) continue
if (!resp.ok) continue
@@ -110,11 +110,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -110,11 +110,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
}
}
const save = async (): Promise<void> => {
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…')
setMessage('saving…')
await uploadDirtyMasks()
await uploadDirtyMasks()
const file = serialize()
const file = serialize()
@@ -124,7 +120,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
@@ -124,7 +120,7 @@ export function createSaveController(opts: SaveControllerOptions): SaveControlle
method: 'POST',
method: 'POST',
headers: {
headers: {
'content-type': 'application/json',
'content-type': 'application/json',
- 'x-editor-key': key,
+ authorization: `Bearer ${token}`,
// The revision we last loaded: the server 409s a stale save rather
// The revision we last loaded: the server 409s a stale save rather
// than letting it overwrite another admin's work.
// than letting it overwrite another admin's work.
...(baseRevision ? { 'if-match': baseRevision } : {}),
...(baseRevision ? { 'if-match': baseRevision } : {}),
@@ -132,13 +132,6 @@ export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): Shell
@@ -132,13 +132,6 @@ export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): Shell
spaceBtn.title = 'Transform space (World / Local)'
spaceBtn.title = 'Transform space (World / Local)'
const right = el('div', 'toolbar-right')
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')
const save = el('button', 'primary', 'save')
save.type = 'button'
save.type = 'button'
save.textContent = '💾 Save map (applies live)'
save.textContent = '💾 Save map (applies live)'
@@ -156,7 +149,7 @@ export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): Shell
@@ -156,7 +149,7 @@ export function buildShell(mount: HTMLElement, canvas: HTMLCanvasElement): Shell
settings.type = 'button'
settings.type = 'button'
settings.textContent = '⚙'
settings.textContent = '⚙'
settings.title = 'Keyboard shortcuts'
settings.title = 'Keyboard shortcuts'
- right.append(key, save, exportBtn, importLabel, settings)
+ right.append(save, exportBtn, importLabel, settings)
toolbar.append(tools, gizmoModes, spaceBtn, right)
toolbar.append(tools, gizmoModes, spaceBtn, right)
// ── Columns ─────────────────────────────────────────────────────────
// ── Columns ─────────────────────────────────────────────────────────
@@ -10,9 +10,54 @@ import {
@@ -10,9 +10,54 @@ import {
/** How long the first WebSocket connect may take before the page falls back to waiting for the server. */
/** 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
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
* WebSocket connection to the dedicated server. Message handling is a
* callback so the network layer stays independent of game/state code.
* 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 {
export class Connection {
private ws: WebSocket | null = null
private ws: WebSocket | null = null
@@ -25,9 +70,15 @@ export class Connection {
@@ -25,9 +70,15 @@ export class Connection {
name: string,
name: string,
appearance: Appearance,
appearance: Appearance,
slot = 0,
slot = 0,
- auth?: string,
+ /** Network access token; ignored if absent (the identity is the ticket). */
+ networkToken?: string,
): Promise<void> {
): 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
this.ws = ws
await new Promise<void>((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
// An upgrade sent while the server restarts can wait with no answer (the proxy holds it):
// An upgrade sent while the server restarts can wait with no answer (the proxy holds it):
@@ -57,11 +108,10 @@ export class Connection {
@@ -57,11 +108,10 @@ export class Connection {
this.send({
this.send({
t: 'hello',
t: 'hello',
v: PROTOCOL_VERSION,
v: PROTOCOL_VERSION,
- token,
slot,
slot,
name,
name,
appearance,
appearance,
- ...(auth ? { auth } : {}),
+ // Identity was resolved at the upgrade; hello carries no identity field.
})
})
}
}
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"iovalkey": "^0.4.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.30.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -177,7 +177,15 @@ class TestClient {
@@ -177,7 +177,15 @@ class TestClient {
[]
[]
async connect(name: string, token: string, appearance?: Appearance): Promise<void> {
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) => {
await new Promise<void>((res, rej) => {
this.ws.on('open', () => res())
this.ws.on('open', () => res())
this.ws.on('error', rej)
this.ws.on('error', rej)
@@ -186,7 +194,6 @@ class TestClient {
@@ -186,7 +194,6 @@ class TestClient {
this.send({
this.send({
t: 'hello',
t: 'hello',
v: PROTOCOL_VERSION,
v: PROTOCOL_VERSION,
- token,
slot: 0,
slot: 0,
name,
name,
appearance: appearance ?? defaultAppearance(),
appearance: appearance ?? defaultAppearance(),
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-import { existsSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
import { dirname, isAbsolute, join } from 'node:path'
import { dirname, isAbsolute, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
@@ -14,6 +14,28 @@ import { loadConfig } from './config.js'
@@ -14,6 +14,28 @@ import { loadConfig } from './config.js'
/** apps/server (the dev cwd), from this test file's location. */
/** apps/server (the dev cwd), from this test file's location. */
const SERVER_DIR = join(dirname(fileURLToPath(import.meta.url)), '..')
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()
const originalCwd = process.cwd()
afterEach(() => process.chdir(originalCwd))
afterEach(() => process.chdir(originalCwd))
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { MIGRATIONS_DIR } from '@openvibe/persistence'
import { MIGRATIONS_DIR } from '@openvibe/persistence'
+import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'
export interface DbConfig {
export interface DbConfig {
/** Pooled PostgreSQL URL (through PgBouncer); null in development, where an embedded PGlite is used. */
/** Pooled PostgreSQL URL (through PgBouncer); null in development, where an embedded PGlite is used. */
@@ -27,7 +28,15 @@ export interface ServerConfig {
@@ -27,7 +28,15 @@ export interface ServerConfig {
/** Directory of built client assets to serve, or null for API/WS only. */
/** Directory of built client assets to serve, or null for API/WS only. */
staticDir: string | null
staticDir: string | null
mapPath: string
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
networkAuthUrl: string | null
/** openvibe.network OAuth client (SSO); null until the secret is configured. */
/** openvibe.network OAuth client (SSO); null until the secret is configured. */
oauth: {
oauth: {
@@ -122,8 +131,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -122,8 +131,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
},
},
staticDir: env.STATIC_DIR ?? null,
staticDir: env.STATIC_DIR ?? null,
mapPath: env.MAP_PATH ?? 'data/map.json',
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. */
/** openvibe.network auth endpoint; when set, editor tokens validate there. */
networkAuthUrl:
networkAuthUrl:
env.OV_NETWORK_AUTH_URL ??
env.OV_NETWORK_AUTH_URL ??
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* protocol-level connections), across a restart:
* protocol-level connections), across a restart:
*
*
* - a signed-in player is keyed by the canonical subject;
* - 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
* - 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
* capability never takes effect, and a revoked mod's props are gone on
* the next tick;
* the next tick;
@@ -33,6 +33,7 @@ import { createMockPlatform } from 'openvibe-sdk/testing'
@@ -33,6 +33,7 @@ import { createMockPlatform } from 'openvibe-sdk/testing'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { loadConfig } from '../config.js'
import { loadConfig } from '../config.js'
import { loadHavok } from '../havokLoader.js'
import { loadHavok } from '../havokLoader.js'
+import { hashGuestKey } from '../net/guestIdentity.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from '../mods/contentPack.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
import { ModRegistry, type ModActor } from '../mods/registry.js'
import { ModRuntime } from '../mods/runtime.js'
import { ModRuntime } from '../mods/runtime.js'
@@ -129,18 +130,22 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
@@ -129,18 +130,22 @@ async function boot(platform: ReturnType<typeof createMockPlatform>): Promise<Bo
function connect(game: GameServer) {
function connect(game: GameServer) {
const inbox: ServerMessage[] = []
const inbox: ServerMessage[] = []
let closed: { code: number; reason: string } | null = null
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 = {
const conn: GameConnection = {
send: (text) => inbox.push(JSON.parse(text) as ServerMessage),
send: (text) => inbox.push(JSON.parse(text) as ServerMessage),
close: (code, reason) => {
close: (code, reason) => {
closed = { 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 = {
const msg = {
t: 'hello',
t: 'hello',
v: PROTOCOL_VERSION,
v: PROTOCOL_VERSION,
- token,
- ...(auth ? { auth } : {}),
slot: 0,
slot: 0,
name: 'Ana',
name: 'Ana',
appearance: defaultAppearance(),
appearance: defaultAppearance(),
@@ -149,9 +154,24 @@ function connect(game: GameServer) {
@@ -149,9 +154,24 @@ function connect(game: GameServer) {
for (let i = 0; i < 100 && !closed && !inbox.some((m) => m.t === 'welcome'); i++) {
for (let i = 0; i < 100 && !closed && !inbox.some((m) => m.t === 'welcome'); i++) {
await new Promise((r) => setTimeout(r, 5))
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 { 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) =>
const propsOwnedBy = (world: GameWorld, owner: string) =>
@@ -172,11 +192,14 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -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 () => {
it('runs identity, mods and events against the live world', async () => {
const a = await boot(platform)
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.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.
// The signed-in player is keyed by the canonical subject.
const ana = connect(a.game)
const ana = connect(a.game)
@@ -4,6 +4,7 @@ import type { BodyId } from '@openvibe/physics'
@@ -4,6 +4,7 @@ import type { BodyId } from '@openvibe/physics'
import type { ServerMessage, ServerStats } from '@openvibe/protocol'
import type { ServerMessage, ServerStats } from '@openvibe/protocol'
import type { EntityId, Logger, PlayerId } from '@openvibe/shared'
import type { EntityId, Logger, PlayerId } from '@openvibe/shared'
import type { ServerConfig } from '../../config.js'
import type { ServerConfig } from '../../config.js'
+import type { TicketIdentity } from '../../net/wsTicket.js'
import type { ModRuntime } from '../../mods/runtime.js'
import type { ModRuntime } from '../../mods/runtime.js'
import type { ServerMetrics } from '../../observability/metrics.js'
import type { ServerMetrics } from '../../observability/metrics.js'
import type { GameEventRecorder } from '../../platform/gameEvents.js'
import type { GameEventRecorder } from '../../platform/gameEvents.js'
@@ -29,12 +30,31 @@ import type { SocialSystem } from './social.js'
@@ -29,12 +30,31 @@ import type { SocialSystem } from './social.js'
import type { VehicleSystem } from './vehicles.js'
import type { VehicleSystem } from './vehicles.js'
import type { WorldEventSystem } from './worldEvents.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 {
export interface GameConnection {
send(text: string): void
send(text: string): void
close(code: number, reason: 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
* Platform adapters the server drives (roadmap Wave 12). Both optional: the
* game runs exactly as before without them.
* game runs exactly as before without them.
@@ -3,26 +3,12 @@ import { Inventory, SkillSet, type GameEntity } from '@openvibe/gameplay'
@@ -3,26 +3,12 @@ import { Inventory, SkillSet, type GameEntity } from '@openvibe/gameplay'
import { CollisionLayer } from '@openvibe/physics'
import { CollisionLayer } from '@openvibe/physics'
import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
import { PROTOCOL_VERSION, encodeServerMessage, type ClientHello } from '@openvibe/protocol'
import { asPlayerId, newEntityId, newPlayerId, qfromYaw, quat, vec3 } from '@openvibe/shared'
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 type { GameConnection, ServerContext } from './context.js'
import { MOVE } from './movement.js'
import { MOVE } from './movement.js'
import { eventPlayer, progressOf } from './platform.js'
import { eventPlayer, progressOf } from './platform.js'
import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'
import { createSession, HOTBAR_SIZE, INVENTORY_SIZE } from '../playerSession.js'
import type { HandlerMap, System } from './system.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
* 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
* 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 {
@@ -41,12 +27,18 @@ export class SessionsSystem implements System {
hello: (session, msg, conn) => {
hello: (session, msg, conn) => {
// A session already exists: a duplicate hello is silently ignored.
// A session already exists: a duplicate hello is silently ignored.
if (session) return
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.
// frames cannot both create a session for one connection.
if (this.hellosInFlight.has(conn)) {
if (this.hellosInFlight.has(conn)) {
conn.close(4008, 'hello_in_flight')
conn.close(4008, 'hello_in_flight')
return
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)
void this.handleHello(conn, msg as ClientHello)
},
},
}
}
@@ -106,49 +98,51 @@ export class SessionsSystem implements System {
@@ -106,49 +98,51 @@ export class SessionsSystem implements System {
}
}
const slot = msg.slot ?? 0
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 subjectId: string | null = null
let authIat: number | null = null
let authIat: number | null = null
let rank: 'owner' | 'admin' | 'moderator' | 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)
if (g.moved > 0)
ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
ctx.log.info('guest character adopted', { subject: subjectId, slot: g.slot ?? -1 })
else if (g.full)
else if (g.full)
ctx.log.info('guest character kept: account slots full', { subject: subjectId })
ctx.log.info('guest character kept: account slots full', { subject: subjectId })
}
}
} else {
} 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) {
if (slot > 0) {
conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))
conn.send(encodeServerMessage({ t: 'reject', reason: 'guest_one_character' }))
conn.close(4010, 'guest_one_character')
conn.close(4010, 'guest_one_character')
return
return
}
}
+ token = `guest:${id.guestKeyHash}`
}
}
// A disconnect save of this character may still be queued: read after it lands, not before.
// A disconnect save of this character may still be queued: read after it lands, not before.
await ctx.systems.persistence.drain(5000)
await ctx.systems.persistence.drain(5000)
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
+import type { IncomingMessage } from 'node:http'
import { dirname, join } from 'node:path'
import { dirname, join } from 'node:path'
import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'
import { createEventsClient, createPgOutbox, type PgOutbox } from 'openvibe-sdk/events'
import { createValkey } from 'openvibe-sdk/valkey'
import { createValkey } from 'openvibe-sdk/valkey'
@@ -22,7 +23,8 @@ import {
@@ -22,7 +23,8 @@ import {
} from './net/gracefulStop.js'
} from './net/gracefulStop.js'
import { createHttpServer } from './net/httpServer.js'
import { createHttpServer } from './net/httpServer.js'
import { resolveNetworkUser } from './net/networkAuth.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 { ServerMetrics } from './observability/metrics.js'
import { createReadiness } from './observability/readiness.js'
import { createReadiness } from './observability/readiness.js'
import { buildRelease, releaseHandler } from './observability/release.js'
import { buildRelease, releaseHandler } from './observability/release.js'
@@ -49,6 +51,7 @@ import {
@@ -49,6 +51,7 @@ import {
ensureRevocationSubscription,
ensureRevocationSubscription,
} from './platform/revocationEvents.js'
} from './platform/revocationEvents.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
import { createStaffAuthorizer } from './platform/staffAuth.js'
+import { createWsTicketStore } from './net/wsTicket.js'
/**
/**
* Dedicated authoritative server entry point.
* Dedicated authoritative server entry point.
@@ -184,7 +187,6 @@ async function main(): Promise<void> {
@@ -184,7 +187,6 @@ async function main(): Promise<void> {
const authorizeStaff = createStaffAuthorizer({
const authorizeStaff = createStaffAuthorizer({
networkAuthUrl: config.networkAuthUrl,
networkAuthUrl: config.networkAuthUrl,
networkUrl: config.platform.networkUrl,
networkUrl: config.platform.networkUrl,
- editorKey: config.editorKey,
})
})
const pruneTimer = setInterval(() => void outbox?.prune(), 6 * 3600 * 1000)
const pruneTimer = setInterval(() => void outbox?.prune(), 6 * 3600 * 1000)
pruneTimer.unref()
pruneTimer.unref()
@@ -280,13 +282,17 @@ async function main(): Promise<void> {
@@ -280,13 +282,17 @@ async function main(): Promise<void> {
onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),
onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),
valkey,
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(
const http = createHttpServer(
config.staticDir,
config.staticDir,
metrics,
metrics,
log.child({ system: 'http' }),
log.child({ system: 'http' }),
config.mapPath,
config.mapPath,
{
{
- key: config.editorKey,
networkAuthUrl: config.networkAuthUrl,
networkAuthUrl: config.networkAuthUrl,
},
},
// LIVE map apply. Receives the validated v2 DOCUMENT, not a JSON string
// LIVE map apply. Receives the validated v2 DOCUMENT, not a JSON string
@@ -393,15 +399,22 @@ async function main(): Promise<void> {
@@ -393,15 +399,22 @@ async function main(): Promise<void> {
},
},
onAssetStored: (asset) => mirror?.enqueue(asset),
onAssetStored: (asset) => mirror?.enqueue(asset),
limits: actorLimits,
limits: actorLimits,
+ clientAddress,
},
},
+ ticketStore,
)
)
const drainer = httpDrainer(http)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapNodes()
world.reconcileMapProps()
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(
const editors = attachEditorWs(
http,
http,
- { key: config.editorKey, networkAuthUrl: config.networkAuthUrl },
+ { networkAuthUrl: config.networkAuthUrl },
log.child({ system: 'editor-ws' }),
log.child({ system: 'editor-ws' }),
)
)
http.on('upgrade', (req, socket, head) => {
http.on('upgrade', (req, socket, head) => {
@@ -410,7 +423,26 @@ async function main(): Promise<void> {
@@ -410,7 +423,26 @@ async function main(): Promise<void> {
// Stopping: no new sessions (the client retries after the restart).
// 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')
socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nRetry-After: 5\r\n\r\n')
} else if (path === '/ws') {
} 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') {
} else if (path === '/editor-ws') {
editors.wss.handleUpgrade(req, socket, head, (ws) => editors.wss.emit('connection', ws, req))
editors.wss.handleUpgrade(req, socket, head, (ws) => editors.wss.emit('connection', ws, req))
} else {
} else {
@@ -1,20 +1,113 @@
@@ -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 { createContent } from '@openvibe/content'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { validateContentPack } from './contentPack.js'
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', () => {
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', () => {
it('accepts a complete manifest, including Media asset refs', () => {
@@ -1,21 +1,22 @@
@@ -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 { createRequire } from 'node:module'
import { Ajv2020 } from 'ajv/dist/2020.js'
import { Ajv2020 } from 'ajv/dist/2020.js'
import { satisfiesRange } from 'openvibe-sdk/core'
import { satisfiesRange } from 'openvibe-sdk/core'
-import { MOD_MANIFEST_SCHEMA } from './manifestSchema.js'
interface ContractsModule {
interface ContractsModule {
schema(ref: string): Record<string, unknown>
schema(ref: string): Record<string, unknown>
}
}
const contracts = createRequire(import.meta.url)('openvibe-contracts') as ContractsModule
const contracts = createRequire(import.meta.url)('openvibe-contracts') as ContractsModule
+export const MOD_MANIFEST_REF = 'mods.mod-manifest@1'
+
export interface ModManifest {
export interface ModManifest {
id: string
id: string
name: string
name: string
@@ -39,11 +40,11 @@ export interface ModManifest {
@@ -39,11 +40,11 @@ export interface ModManifest {
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false })
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false })
ajv.addSchema(contracts.schema('identity.subject-ref@1'))
ajv.addSchema(contracts.schema('identity.subject-ref@1'))
ajv.addSchema(contracts.schema('media.media-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[] }
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> {
export function validateManifest(value: unknown): Validation<ModManifest> {
if (validateSchema(value)) return { ok: true, value: value as unknown as ModManifest }
if (validateSchema(value)) return { ok: true, value: value as unknown as ModManifest }
return {
return {
deleted file mode 100644
deleted file mode 100644
@@ -1,179 +0,0 @@
@@ -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
@@ -116,7 +116,6 @@ describe('staff authorization', () => {
@@ -116,7 +116,6 @@ describe('staff authorization', () => {
const authorize = createStaffAuthorizer({
const authorize = createStaffAuthorizer({
networkAuthUrl: null,
networkAuthUrl: null,
networkUrl: platform.origins.network,
networkUrl: platform.origins.network,
- editorKey: null,
verifyPrincipal: (t) =>
verifyPrincipal: (t) =>
verifyUserToken(t, {
verifyUserToken(t, {
jwks: platform.keys.jwks,
jwks: platform.keys.jwks,
@@ -149,19 +148,18 @@ describe('staff authorization', () => {
@@ -149,19 +148,18 @@ describe('staff authorization', () => {
expect(await authorize(req('not-a-token'))).toBeNull()
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({
const authorize = createStaffAuthorizer({
networkAuthUrl: null,
networkAuthUrl: null,
networkUrl: 'http://x',
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()
})
})
})
})
@@ -41,6 +41,13 @@ export function createGamesActorLimits(
@@ -41,6 +41,13 @@ export function createGamesActorLimits(
const asset = limiter('games.map.asset', { minute: 60, hour: 1200 })
const asset = limiter('games.map.asset', { minute: 60, hour: 1200 })
// Mod installs, grants and revokes each ask OpenVibe.Network first.
// Mod installs, grants and revokes each ask OpenVibe.Network first.
const modWrite = limiter('games.mod.write', { minute: 30, hour: 300 })
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 =
const run =
(mw: ReturnType<typeof limiter>) =>
(mw: ReturnType<typeof limiter>) =>
(req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
(req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
@@ -57,6 +64,8 @@ export function createGamesActorLimits(
@@ -57,6 +64,8 @@ export function createGamesActorLimits(
mapSave: run(mapSave),
mapSave: run(mapSave),
asset: run(asset),
asset: run(asset),
modWrite: run(modWrite),
modWrite: run(modWrite),
+ ticket: run(ticket),
+ upgrade: run(upgrade),
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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'])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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
+}
@@ -12,6 +12,12 @@ import { canEditMap, resolveNetworkUser } from './networkAuth.js'
@@ -12,6 +12,12 @@ import { canEditMap, resolveNetworkUser } from './networkAuth.js'
import { LockManager } from './editorLocks.js'
import { LockManager } from './editorLocks.js'
import type { EditorAuth } from './httpServer.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`).
* Live collaboration for the map editor (`/editor-ws`).
*
*
@@ -127,7 +133,7 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
@@ -127,7 +133,7 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
ws.close(4001, 'hello_first')
ws.close(4001, 'hello_first')
return
return
}
}
- void authorizeEditor(auth, msg.key).then((ok) => {
+ void authorizeEditor(auth, msg.auth).then((ok) => {
if (!ok) {
if (!ok) {
ws.close(4003, 'not_authorized')
ws.close(4003, 'not_authorized')
return
return
@@ -250,10 +256,12 @@ export function attachEditorWs(http: Server, auth: EditorAuth, log: Logger): Edi
@@ -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)
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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')
+}
@@ -9,6 +9,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
@@ -9,6 +9,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { ServerMetrics } from '../observability/metrics.js'
import { ServerMetrics } from '../observability/metrics.js'
import { createReadiness } from '../observability/readiness.js'
import { createReadiness } from '../observability/readiness.js'
import { createHttpServer } from './httpServer.js'
import { createHttpServer } from './httpServer.js'
+import { createWsTicketStore } from './wsTicket.js'
+import { createGamesActorLimits } from './actorLimits.js'
import { escapeHtml, notFoundPage } from './notFound.js'
import { escapeHtml, notFoundPage } from './notFound.js'
/**
/**
@@ -96,7 +98,10 @@ beforeAll(async () => {
@@ -96,7 +98,10 @@ beforeAll(async () => {
metrics,
metrics,
log,
log,
mapPath,
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,
undefined,
async () => [{ slot: 0, name: 'Ana', appearance: null }],
async () => [{ slot: 0, name: 'Ana', appearance: null }],
null,
null,
@@ -299,3 +304,45 @@ describe('404 page', () => {
@@ -299,3 +304,45 @@ describe('404 page', () => {
expect(existsSync(icon)).toBe(true)
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()
+ }
+ })
+})
@@ -27,7 +27,9 @@ import {
@@ -27,7 +27,9 @@ import {
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_MAP_BYTES, loadMap, saveMap } from './mapStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
import { MAX_ASSET_BYTES, isContentAddressed, storeAsset } from './mapAssetStore.js'
import type { GamesActorLimits } from './actorLimits.js'
import type { GamesActorLimits } from './actorLimits.js'
+import { resolveClientAddress } from './clientAddress.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
+import { handleWsTicketRequest, type WsTicketStore } from './wsTicket.js'
import type { MapFileV2 } from '@openvibe/content'
import type { MapFileV2 } from '@openvibe/content'
/**
/**
@@ -55,9 +57,12 @@ const MIME: Record<string, string> = {
@@ -55,9 +57,12 @@ const MIME: Record<string, string> = {
'.map': 'application/json',
'.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 {
export interface EditorAuth {
- /** Shared-secret fallback. */
- key: string | null
/** openvibe.network session endpoint; token validated there when configured. */
/** openvibe.network session endpoint; token validated there when configured. */
networkAuthUrl: string | null
networkAuthUrl: string | null
}
}
@@ -75,17 +80,17 @@ export interface OAuthConfig {
@@ -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> {
async function editorAuthorized(auth: EditorAuth, token: string | undefined): Promise<boolean> {
if (!token) return false
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`. */
/** Reads a small request body as utf8, or null when it exceeds `limit`. */
@@ -113,8 +118,10 @@ export interface HttpPlatformHooks {
@@ -113,8 +118,10 @@ export interface HttpPlatformHooks {
handle?: (req: IncomingMessage, res: ServerResponse) => boolean
handle?: (req: IncomingMessage, res: ServerResponse) => boolean
/** A map asset was stored (or found already stored) locally. */
/** A map asset was stored (or found already stored) locally. */
onAssetStored?: (asset: { hash: string; url: string; bytes: number; mime: string }) => void
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
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. */
/** The token endpoint's answer, for both the code and the jwt-bearer grant. */
@@ -140,6 +147,7 @@ export function createHttpServer(
@@ -140,6 +147,7 @@ export function createHttpServer(
) => Promise<{ slot: number; name: string; appearance: unknown }[]>,
) => Promise<{ slot: number; name: string; appearance: unknown }[]>,
oauth?: OAuthConfig | null,
oauth?: OAuthConfig | null,
platform?: HttpPlatformHooks,
platform?: HttpPlatformHooks,
+ tickets?: WsTicketStore,
): Server {
): Server {
const root = staticDir ? resolve(staticDir) : null
const root = staticDir ? resolve(staticDir) : null
// Sessions the Network confirmed recently: lets a silent login on a signed-in
// Sessions the Network confirmed recently: lets a silent login on a signed-in
@@ -164,6 +172,30 @@ export function createHttpServer(
@@ -164,6 +172,30 @@ export function createHttpServer(
// /map.json, /api, /auth, websockets — behaves identically on both.
// /map.json, /api, /auth, websockets — behaves identically on both.
const playHost = (req.headers.host ?? '').toLowerCase().startsWith('play.')
const playHost = (req.headers.host ?? '').toLowerCase().startsWith('play.')
const notFound = () => sendNotFound(req, res, url, playHost ? playLinks : apexLinks)
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 (platform?.handle?.(req, res)) return
if (url === '/map.json' && mapPath) {
if (url === '/map.json' && mapPath) {
// The canonical v2 document, with its revision as an ETag. This IS the
// The canonical v2 document, with its revision as an ETag. This IS the
@@ -179,7 +211,7 @@ export function createHttpServer(
@@ -179,7 +211,7 @@ export function createHttpServer(
return
return
}
}
if (url === '/api/map' && req.method === 'POST' && mapPath && editorAuth) {
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) => {
void editorAuthorized(editorAuth, token).then(async (ok) => {
if (!ok) {
if (!ok) {
log.warn('editor save rejected', {})
log.warn('editor save rejected', {})
@@ -251,7 +283,7 @@ export function createHttpServer(
@@ -251,7 +283,7 @@ export function createHttpServer(
// artifact instead of being base64-embedded into map.json.
// artifact instead of being base64-embedded into map.json.
// Generic content-addressed upload (textures, paint masks, models).
// Generic content-addressed upload (textures, paint masks, models).
if (url === '/api/map-assets' && req.method === 'POST' && mapPath && editorAuth) {
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) => {
void editorAuthorized(editorAuth, token).then(async (authed) => {
if (!authed) {
if (!authed) {
res.writeHead(403, { 'content-type': 'application/json' })
res.writeHead(403, { 'content-type': 'application/json' })
new file mode 100644
new file mode 100644
@@ -0,0 +1,196 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -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 }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,359 @@
@@ -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
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -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')
+ })
+})
@@ -1,43 +1,164 @@
@@ -1,43 +1,164 @@
import type { Server } from 'node:http'
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 { WebSocketServer, type WebSocket } from 'ws'
import { decodeClientMessage } from '@openvibe/protocol'
import { decodeClientMessage } from '@openvibe/protocol'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import type { GameConnection, GameServer } from '../game/gameServer.js'
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,
* WebSocket transport binding. Enforces wire-level limits (message size,
* rate) before anything reaches game code; malformed or abusive traffic
* rate) before anything reaches game code; malformed or abusive traffic
* drops the connection.
* 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_MESSAGE_BYTES = 4096
const MAX_MESSAGES_PER_SECOND = 120
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
// noServer: upgrades are routed by path in main (two WSS instances bound
// to one http server both complete the handshake and corrupt frames).
// to one http server both complete the handshake and corrupt frames).
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES })
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE_BYTES })
void http
void http
wss.on('connection', (ws: WebSocket, req) => {
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 msgCount = 0
let windowStart = Date.now()
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 = {
const conn: GameConnection = {
send: (text) => {
send: (text) => {
if (ws.readyState === ws.OPEN) ws.send(text)
if (ws.readyState === ws.OPEN) ws.send(text)
},
},
close: (code, reason) => ws.close(code, reason),
close: (code, reason) => ws.close(code, reason),
+ ...(identity ? { identity: identity.identity } : {}),
}
}
ws.on('message', (data, isBinary) => {
ws.on('message', (data, isBinary) => {
@@ -3,10 +3,11 @@
@@ -3,10 +3,11 @@
* rank that may edit the map), or a platform principal whose client-
* rank that may edit the map), or a platform principal whose client-
* credentials token for audience `openvibe.games` carries the
* credentials token for audience `openvibe.games` carries the
* `games.mod.manage` capability (verified offline against the Network JWKS).
* `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 type { IncomingMessage } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
import { verifyUserToken, type UserTokenClaims } from 'openvibe-sdk/auth'
import { verifyUserToken, type UserTokenClaims } from 'openvibe-sdk/auth'
@@ -26,7 +27,6 @@ export interface StaffAuthOptions {
@@ -26,7 +27,6 @@ export interface StaffAuthOptions {
networkAuthUrl: string | null
networkAuthUrl: string | null
/** Network base for the JWKS (`/api/.well-known/jwks`). */
/** Network base for the JWKS (`/api/.well-known/jwks`). */
networkUrl: string
networkUrl: string
- editorKey: string | null
/** Test seam: verifies a principal token. */
/** Test seam: verifies a principal token. */
verifyPrincipal?: (token: string) => Promise<UserTokenClaims>
verifyPrincipal?: (token: string) => Promise<UserTokenClaims>
}
}
@@ -49,12 +49,6 @@ function looksLikePrincipal(token: string): boolean {
@@ -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(
export function createStaffAuthorizer(
opts: StaffAuthOptions,
opts: StaffAuthOptions,
): (req: IncomingMessage) => Promise<ModActor | null> {
): (req: IncomingMessage) => Promise<ModActor | null> {
@@ -69,11 +63,9 @@ export function createStaffAuthorizer(
@@ -69,11 +63,9 @@ export function createStaffAuthorizer(
return async (req) => {
return async (req) => {
const bearer = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '')?.[1]?.trim()
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 {
try {
const claims = await verifyPrincipal(bearer)
const claims = await verifyPrincipal(bearer)
if (!contracts.capabilities.grants(claims.cap, CAP_MOD_MANAGE)) return null
if (!contracts.capabilities.grants(claims.cap, CAP_MOD_MANAGE)) return null
@@ -89,7 +81,7 @@ export function createStaffAuthorizer(
@@ -89,7 +81,7 @@ export function createStaffAuthorizer(
}
}
if (opts.networkAuthUrl) {
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 || !canEditMap(user.rank)) return null
if (user.subjectId?.startsWith('usr_')) {
if (user.subjectId?.startsWith('usr_')) {
return { audit: user.subjectId, subject: { type: 'user', id: user.subjectId } }
return { audit: user.subjectId, subject: { type: 'user', id: user.subjectId } }
@@ -101,9 +93,9 @@ export function createStaffAuthorizer(
@@ -101,9 +93,9 @@ export function createStaffAuthorizer(
subject: { type: 'service', id: 'games' },
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
return null
}
}
}
}
@@ -41,6 +41,16 @@ async function refused(port: number): Promise<boolean> {
@@ -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(
function openSocket(
url: string,
url: string,
): Promise<{ ws: WebSocket; closed: Promise<{ code: number; reason: string }> }> {
): Promise<{ ws: WebSocket; closed: Promise<{ code: number; reason: string }> }> {
@@ -99,7 +109,10 @@ describe('SIGTERM', () => {
@@ -99,7 +109,10 @@ describe('SIGTERM', () => {
}
}
expect(out).toContain('"events on"')
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`)
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.
// A request in flight: POST /internal/events reads its whole body before it answers.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# OpenVibe.Games — authoritative game server (Scraplandia) + portal static host.
# OpenVibe.Games — authoritative game server (Scraplandia) + portal static host.
# Install: cp deploy/openvibe-games.service /etc/systemd/system/ && systemctl daemon-reload
# 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,
# 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.
# EVENTS_URL=http://127.0.0.1:4300, MEDIA_URL=http://127.0.0.1:4100 (MEDIA_NAMESPACE=games) in the same file.
[Unit]
[Unit]
@@ -17,6 +17,10 @@ ExecStart=/usr/bin/env node --import tsx src/main.ts
@@ -17,6 +17,10 @@ ExecStart=/usr/bin/env node --import tsx src/main.ts
Environment=NODE_ENV=production
Environment=NODE_ENV=production
Environment=PORT=8000
Environment=PORT=8000
Environment=HOST=127.0.0.1
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_PLACE_ID=scraplandia
Environment=GAMES_MIGRATIONS_DIR=/opt/openvibe.games/packages/persistence/migrations
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
# DATABASE_URL (pooled), DATABASE_DIRECT_URL (owner role, for migrations) and VALKEY_URL live in the
deleted file mode 100644
deleted file mode 100644
@@ -1,11 +0,0 @@
@@ -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"
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,134 +0,0 @@
@@ -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}$" }
- }
-}
@@ -6,14 +6,9 @@ import { defaultAppearance } from './appearance.js'
@@ -6,14 +6,9 @@ import { defaultAppearance } from './appearance.js'
describe('protocol codec', () => {
describe('protocol codec', () => {
it('roundtrips every client message kind', () => {
it('roundtrips every client message kind', () => {
const messages: ClientMessage[] = [
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: 'input', seq: 42, moveX: 1, moveZ: -0.5, yaw: 1.2, pitch: -0.3, buttons: 5 },
{ t: 'use', target: 'abc123' },
{ t: 'use', target: 'abc123' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
@@ -47,6 +42,22 @@ describe('protocol codec', () => {
@@ -47,6 +42,22 @@ describe('protocol codec', () => {
).toBeNull()
).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', () => {
it('rejects oversized strings', () => {
expect(decodeClientMessage(JSON.stringify({ t: 'use', target: 'x'.repeat(100) }))).toBeNull()
expect(decodeClientMessage(JSON.stringify({ t: 'use', target: 'x'.repeat(100) }))).toBeNull()
})
})
@@ -18,7 +18,7 @@ const server = (v: unknown): EditorServerMessage | null =>
@@ -18,7 +18,7 @@ const server = (v: unknown): EditorServerMessage | null =>
describe('client messages', () => {
describe('client messages', () => {
it('round-trips every kind', () => {
it('round-trips every kind', () => {
const messages: EditorClientMessage[] = [
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: 'camera', pos: [1, 2, 3], yaw: 0.5, pitch: -0.2 },
{ t: 'selection', ids: ['a', 'b'] },
{ t: 'selection', ids: ['a', 'b'] },
{ t: 'lockRequest', ids: ['a'] },
{ t: 'lockRequest', ids: ['a'] },
@@ -38,15 +38,15 @@ describe('client messages', () => {
@@ -38,15 +38,15 @@ describe('client messages', () => {
expect(decodeEditorClientMessage('not json')).toBeNull()
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' })).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', () => {
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', () => {
it('refuses a non-finite camera instead of poisoning presence', () => {
@@ -45,8 +45,8 @@ export const EDITOR_HEARTBEAT_MS = 12_000
@@ -45,8 +45,8 @@ export const EDITOR_HEARTBEAT_MS = 12_000
export interface EditorHello {
export interface EditorHello {
t: 'hello'
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
name: string
}
}
@@ -202,9 +202,12 @@ export function decodeEditorClientMessage(raw: string): EditorClientMessage | nu
@@ -202,9 +202,12 @@ export function decodeEditorClientMessage(raw: string): EditorClientMessage | nu
if (!isRecord(value)) return null
if (!isRecord(value)) return null
switch (value['t']) {
switch (value['t']) {
case 'hello': {
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)
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': {
case 'camera': {
const pos = vec3(value['pos'])
const pos = vec3(value['pos'])
@@ -10,10 +10,6 @@ import { AppearanceSchema } from '../appearance.js'
@@ -10,10 +10,6 @@ import { AppearanceSchema } from '../appearance.js'
export const ClientHelloSchema = z.object({
export const ClientHelloSchema = z.object({
t: z.literal('hello'),
t: z.literal('hello'),
v: z.number().int(),
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). */
/** Character slot under this account (MMO-style, 3 max). */
slot: z.number().int().min(0).max(2).default(0),
slot: z.number().int().min(0).max(2).default(0),
name: z.string().min(1).max(24),
name: z.string().min(1).max(24),
@@ -114,8 +114,8 @@ importers:
@@ -114,8 +114,8 @@ importers:
specifier: ^0.4.0
specifier: ^0.4.0
version: 0.4.0
version: 0.4.0
openvibe-contracts:
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:
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.22.0
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])
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:
@@ -2035,12 +2035,12 @@ packages:
integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==,
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:
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' }
engines: { node: '>=20' }
hasBin: true
hasBin: true
@@ -3751,7 +3751,7 @@ snapshots:
@@ -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:
dependencies:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ajv-formats: 3.0.1([email protected])