openvibers/openvibe.games Commits

Per-actor limits on Games' HTTP writes (roadmap WS-R task 4; openvibe-sdk 0.11.0 -> 0.12.0 openvibe-sdk/limits). apps/server/src/net/actorLimits.ts counts the capability boundaries after the caller is authorised and before the body is read: editor map saves (30 a minute, 600 an hour) and asset uploads (60, 1200), by a hash of the editor credential, never the credential; and the mods API's writes (30, 300) by the staff or service actor. Past a limit: 429 problem+json rate_limited with Retry-After, logged. Gameplay is the WebSocket with its own per-socket flood control; reads and signed-out pages keep what they had. GAMES_LIMITS_MINUTE / _HOUR set the defaults (120 / 3000). apps/server/src/net/actorLimits.test.ts: one actor refused before the handler, another passes, the window reopens, the credential is hashed. Prettier on routes.ts and main.ts. pnpm test 882/882, typecheck clean.

OP OpenVibers committed 28 Sept 2026 · 51d86883a1 · 7 files, +176 −15


Changes

ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex f353b9f..003526e 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -20,7 +20,7 @@
"ajv": "^8.17.1",
"better-sqlite3": "^11.9.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"tsx": "^4.19.3",
"ws": "^8.18.1"
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 6ec8ec7..e1daaef 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -32,6 +32,7 @@ import { buildRelease, releaseHandler } from './observability/release.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
import { ModRegistry } from './mods/registry.js'
import { createNetworkModGrants } from './mods/networkGrants.js'
++import { createGamesActorLimits } from './net/actorLimits.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
@@@ -143,17 +144,27 @@ async function main(): Promise<void> {
})
// Mod principals live in Network (ADR-013, WS-M task 3): the API asks it first; at boot every install's copy
// catches up with its principal (a change staff made while this server was down).
-- const modGrants = platform ? createNetworkModGrants({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }) : null
++ const modGrants = platform
++ ? createNetworkModGrants({ networkUrl: config.platform.networkUrl, tokens: platform.tokens })
++ : null
if (modGrants) {
modGrants
.list()
.then((principals) => {
for (const p of principals) mods.applyNetwork(p)
const known = new Set(principals.map((p) => p.mod_id))
-- const unregistered = mods.list().filter((v) => !known.has(v.mod.id) && v.mod.status !== 'revoked').map((v) => v.mod.id)
-- if (unregistered.length) log.warn('mod installs without a Network principal', { mods: unregistered.join(',') })
++ const unregistered = mods
++ .list()
++ .filter((v) => !known.has(v.mod.id) && v.mod.status !== 'revoked')
++ .map((v) => v.mod.id)
++ if (unregistered.length)
++ log.warn('mod installs without a Network principal', { mods: unregistered.join(',') })
})
-- .catch((err: unknown) => log.warn('mod principals not read at boot', { error: String((err as Error)?.message ?? err) }))
++ .catch((err: unknown) =>
++ log.warn('mod principals not read at boot', {
++ error: String((err as Error)?.message ?? err),
++ }),
++ )
}
const mirror =
platform && config.platform.mediaUrl
@@@ -258,6 +269,10 @@ async function main(): Promise<void> {
online: () => game.onlineCount(),
})
++ // Per-actor limits on editor saves, uploads and the mods API (roadmap WS-R task 4).
++ const actorLimits = createGamesActorLimits({
++ onLimited: (name, actor) => log.warn('rate limited', { limit: name, actor }),
++ })
const http = createHttpServer(
config.staticDir,
metrics,
@@@ -342,10 +357,12 @@ async function main(): Promise<void> {
registry: mods,
authorize: authorizeStaff,
...(modGrants ? { grants: modGrants } : {}),
++ limits: actorLimits,
log: log.child({ system: 'mods-api' }),
})
},
onAssetStored: (asset) => mirror?.enqueue(asset),
++ limits: actorLimits,
},
)
const drainer = httpDrainer(http)
ddiff --git a/apps/server/src/mods/routes.ts b/apps/server/src/mods/routes.ts
iindex 8a531a5..a0b5c94 100644
---- a/apps/server/src/mods/routes.ts
++++ b/apps/server/src/mods/routes.ts
@@@ -20,6 +20,7 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import type { Logger } from '@openvibe/shared'
++import type { GamesActorLimits } from '../net/actorLimits.js'
import type { ModGrantAuthority } from './networkGrants.js'
import { ModError, type ModActor, type ModRegistry, type ModView } from './registry.js'
@@@ -44,6 +45,8 @@ export interface ModsApi {
authorize(req: IncomingMessage): Promise<ModActor | null>
/** OpenVibe.Network's mod principals; absent without a platform client (tests, local runs). */
grants?: ModGrantAuthority
++ /** Per-actor limits on writes (../net/actorLimits.ts). */
++ limits?: GamesActorLimits
log: Logger
}
@@@ -156,6 +159,10 @@ export function handleModsRequest(
problem(res, 403, 'capability.denied', 'games.mod.manage required')
return
}
++ if (method !== 'GET' && api.limits && !(await api.limits.modWrite(req, res, actor.audit))) {
++ req.resume()
++ return
++ }
const id = parts[0] ?? ''
if (method === 'GET' && parts.length === 2 && parts[1] === 'audit') {
const limit = Number(url.searchParams.get('limit') ?? 100)
@@@ -178,7 +185,13 @@ export function handleModsRequest(
if (!body.ok) return problem(res, body.status, body.code, 'request body must be a JSON object')
const b = body.body
if (parts.length === 0) {
-- const request = { manifest: b.manifest, pack: b.pack, approve: b.approve, trustTier: b.trustTier, enable: b.enable }
++ const request = {
++ manifest: b.manifest,
++ pack: b.pack,
++ approve: b.approve,
++ trustTier: b.trustTier,
++ enable: b.enable,
++ }
let revision = 0
if (api.grants) {
// Network registers mod:<id> first; what it approved is what is granted here.
@@@ -203,7 +216,10 @@ export function handleModsRequest(
if (parts.length === 2 && parts[1] === 'revoke') {
const reason = typeof b.reason === 'string' ? b.reason.slice(0, 500) : undefined
if (api.grants && api.registry.get(id) && api.registry.get(id)?.mod.status !== 'revoked') {
-- api.registry.noteNetworkRevision(id, (await api.grants.revoke(id, actor.audit, reason)).revision)
++ api.registry.noteNetworkRevision(
++ id,
++ (await api.grants.revoke(id, actor.audit, reason)).revision,
++ )
}
const view = api.registry.revoke(id, actor, reason)
api.log.info('mod revoked', { mod: id, by: actor.audit })
ddiff --git a/apps/server/src/net/actorLimits.test.ts b/apps/server/src/net/actorLimits.test.ts
nnew file mode 100644
iindex 0000000..3dec856
---- /dev/null
++++ b/apps/server/src/net/actorLimits.test.ts
@@@ -0,0 +1,57 @@
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { afterEach, describe, expect, it } from 'vitest'
++import { createGamesActorLimits } from './actorLimits.js'
++
++/**
++ * Per-actor limits on Games' HTTP writes (roadmap WS-R task 4): one actor past its limit gets 429 problem+json
++ * rate_limited with Retry-After before the handler reads anything, another actor still passes, the window reopens;
++ * an editor credential becomes a short hash, never the credential itself.
++ */
++let server: Server | null = null
++afterEach(() => {
++ server?.close()
++ server = null
++})
++
++describe('Games per-actor limits', () => {
++ it('refuses one actor past the limit, lets another through, and reopens', async () => {
++ let t = Date.UTC(2026, 8, 28, 3, 0, 0)
++ const refused: string[] = []
++ const limits = createGamesActorLimits({
++ env: {},
++ now: () => t,
++ onLimited: (name) => refused.push(name),
++ })
++ let handled = 0
++ server = createServer((req, res) => {
++ const actor = String(req.headers['x-actor'] ?? '')
++ void limits.mapSave(req, res, actor).then((ok) => {
++ if (!ok) return
++ handled++
++ res.writeHead(200).end('{}')
++ })
++ })
++ await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r))
++ const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/map`
++ const save = (actor: string) => fetch(url, { method: 'POST', headers: { 'x-actor': actor } })
++ for (let i = 0; i < 30; i++) expect((await save('editor:a')).status).toBe(200)
++ const r = await save('editor:a')
++ expect(r.status).toBe(429)
++ expect(r.headers.get('retry-after')).toBeTruthy()
++ expect(((await r.json()) as { code: string }).code).toBe('rate_limited')
++ expect(handled).toBe(30)
++ expect((await save('editor:b')).status).toBe(200)
++ expect(refused).toEqual(['games.map.save'])
++ t += 60_000
++ expect((await save('editor:a')).status).toBe(200)
++ })
++
++ it('names an editor by a hash of its credential', () => {
++ const limits = createGamesActorLimits({ env: {} })
++ const id = limits.editorActor('secret-editor-key')
++ expect(id).toMatch(/^editor:[0-9a-f]{16}$/)
++ expect(id).not.toContain('secret')
++ expect(limits.editorActor('secret-editor-key')).toBe(id)
++ })
++})
ddiff --git a/apps/server/src/net/actorLimits.ts b/apps/server/src/net/actorLimits.ts
nnew file mode 100644
iindex 0000000..7db5a97
---- /dev/null
++++ b/apps/server/src/net/actorLimits.ts
@@@ -0,0 +1,58 @@
++/**
++ * Per-actor limits on the HTTP writes that are capability boundaries (roadmap WS-R task 4; openvibe-sdk/limits):
++ * editor map saves and asset uploads (an editor key or a staff Network token) and the mods API (staff or a service
++ * holding games.mod.manage). Gameplay is the WebSocket, which has its own per-socket flood control (wsTransport.ts).
++ *
++ * Counted after the caller is authorised, before the body is read. Past a limit: 429 problem+json `rate_limited`
++ * with Retry-After. The actor is a hash of the editor credential (never the credential) or the mod actor's audit id.
++ * Numbers per minute / per hour; GAMES_LIMITS_MINUTE / GAMES_LIMITS_HOUR set the defaults (120 / 3000).
++ */
++import { createHash } from 'node:crypto'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { createActorLimiter } from 'openvibe-sdk/limits'
++
++type Req = IncomingMessage & { ovActor?: string }
++const num = (v: string | undefined, d: number): number => {
++ const n = Number.parseInt(v ?? '', 10)
++ return Number.isFinite(n) && n > 0 ? n : d
++}
++
++export function createGamesActorLimits(
++ opts: {
++ env?: NodeJS.ProcessEnv
++ now?: () => number
++ onLimited?: (name: string, actor: string) => void
++ } = {},
++) {
++ const env = opts.env ?? process.env
++ const limiter = createActorLimiter({
++ limits: { minute: num(env.GAMES_LIMITS_MINUTE, 120), hour: num(env.GAMES_LIMITS_HOUR, 3000) },
++ actor: (req) => (req as Req).ovActor ?? null,
++ ...(opts.now ? { now: opts.now } : {}),
++ onLimited: (e) => opts.onLimited?.(e.name, e.actor),
++ })
++ // A map save validates and migrates the whole map and swaps it live; an asset is hashed and stored on disk.
++ const mapSave = limiter('games.map.save', { minute: 30, hour: 600 })
++ 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 })
++ const run =
++ (mw: ReturnType<typeof limiter>) =>
++ (req: IncomingMessage, res: ServerResponse, actor: string): Promise<boolean> =>
++ new Promise((resolve) => {
++ ;(req as Req).ovActor = actor
++ mw(req, res, () => resolve(true))
++ if (res.writableEnded || res.headersSent) resolve(false)
++ })
++ return {
++ /** An editor credential as an actor id: a short hash, never the credential. */
++ editorActor: (token: string): string =>
++ `editor:${createHash('sha256').update(token).digest('hex').slice(0, 16)}`,
++ /** true = go on; false = refused (the 429 was sent). */
++ mapSave: run(mapSave),
++ asset: run(asset),
++ modWrite: run(modWrite),
++ }
++}
++
++export type GamesActorLimits = ReturnType<typeof createGamesActorLimits>
ddiff --git a/apps/server/src/net/httpServer.ts b/apps/server/src/net/httpServer.ts
iindex 5aedccf..298cac8 100644
---- a/apps/server/src/net/httpServer.ts
++++ b/apps/server/src/net/httpServer.ts
@@@ -26,6 +26,7 @@ import {
} from './sso.js'
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 { sendNotFound, type NotFoundLink } from './notFound.js'
import type { MapFileV2 } from '@openvibe/content'
@@@ -112,6 +113,8 @@ 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. */
++ limits?: GamesActorLimits
}
/** The token endpoint's answer, for both the code and the jwt-bearer grant. */
@@@ -177,13 +180,18 @@ export function createHttpServer(
}
if (url === '/api/map' && req.method === 'POST' && mapPath && editorAuth) {
const token = (req.headers['x-editor-key'] as string | undefined) ?? undefined
-- void editorAuthorized(editorAuth, token).then((ok) => {
++ void editorAuthorized(editorAuth, token).then(async (ok) => {
if (!ok) {
log.warn('editor save rejected', {})
res.writeHead(403, { 'content-type': 'application/json' })
res.end('{"error":"forbidden"}')
return
}
++ const limits = platform?.limits
++ if (limits && !(await limits.mapSave(req, res, limits.editorActor(token ?? '')))) {
++ req.resume()
++ return
++ }
const chunks: Buffer[] = []
let size = 0
let aborted = false
@@@ -251,12 +259,17 @@ export function createHttpServer(
editorAuth
) {
const token = (req.headers['x-editor-key'] as string | undefined) ?? undefined
-- void editorAuthorized(editorAuth, token).then((authed) => {
++ void editorAuthorized(editorAuth, token).then(async (authed) => {
if (!authed) {
res.writeHead(403, { 'content-type': 'application/json' })
res.end('{"error":"forbidden"}')
return
}
++ const limits = platform?.limits
++ if (limits && !(await limits.asset(req, res, limits.editorActor(token ?? '')))) {
++ req.resume()
++ return
++ }
const chunks: Buffer[] = []
let size = 0
let aborted = false
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 5c028e3..4293c3a 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -115,8 +115,8 @@ importers:
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
openvibe-sdk:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0
openvibe-shared:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0
version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0
@@@ -1372,9 +1372,9 @@ packages:
engines: {node: '>=20'}
hasBin: true
-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0}
-- version: 0.11.0
++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0}
++ version: 0.12.0
engines: {node: '>=20'}
openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:
@@@ -2835,7 +2835,7 @@ snapshots:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
-- openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0: {}
++ openvibe-sdk@https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0: {}
openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:
dependencies: