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.
Changes
@@@ -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"@@@ -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 })++ : nullif (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)@@@ -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?: GamesActorLimitslog: 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.bodyif (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 = 0if (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) : undefinedif (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 })nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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>@@@ -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 = 0let 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 = 0let aborted = false@@@ -115,8 +115,8 @@ importers:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0openvibe-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.0openvibe-shared:specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0version: 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.0engines: {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.0ajv-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:
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"better-sqlite3": "^11.9.1",
"better-sqlite3": "^11.9.1",
"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.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
"ws": "^8.18.1"
"ws": "^8.18.1"
@@ -32,6 +32,7 @@ import { buildRelease, releaseHandler } from './observability/release.js'
@@ -32,6 +32,7 @@ import { buildRelease, releaseHandler } from './observability/release.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
import { ModRegistry } from './mods/registry.js'
import { ModRegistry } from './mods/registry.js'
import { createNetworkModGrants } from './mods/networkGrants.js'
import { createNetworkModGrants } from './mods/networkGrants.js'
+import { createGamesActorLimits } from './net/actorLimits.js'
import { handleModsRequest } from './mods/routes.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { ModRuntime } from './mods/runtime.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
@@ -143,17 +144,27 @@ async function main(): Promise<void> {
@@ -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
// 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).
// 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) {
if (modGrants) {
modGrants
modGrants
.list()
.list()
.then((principals) => {
.then((principals) => {
for (const p of principals) mods.applyNetwork(p)
for (const p of principals) mods.applyNetwork(p)
const known = new Set(principals.map((p) => p.mod_id))
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 =
const mirror =
platform && config.platform.mediaUrl
platform && config.platform.mediaUrl
@@ -258,6 +269,10 @@ async function main(): Promise<void> {
@@ -258,6 +269,10 @@ async function main(): Promise<void> {
online: () => game.onlineCount(),
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(
const http = createHttpServer(
config.staticDir,
config.staticDir,
metrics,
metrics,
@@ -342,10 +357,12 @@ async function main(): Promise<void> {
@@ -342,10 +357,12 @@ async function main(): Promise<void> {
registry: mods,
registry: mods,
authorize: authorizeStaff,
authorize: authorizeStaff,
...(modGrants ? { grants: modGrants } : {}),
...(modGrants ? { grants: modGrants } : {}),
+ limits: actorLimits,
log: log.child({ system: 'mods-api' }),
log: log.child({ system: 'mods-api' }),
})
})
},
},
onAssetStored: (asset) => mirror?.enqueue(asset),
onAssetStored: (asset) => mirror?.enqueue(asset),
+ limits: actorLimits,
},
},
)
)
const drainer = httpDrainer(http)
const drainer = httpDrainer(http)
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
+import type { GamesActorLimits } from '../net/actorLimits.js'
import type { ModGrantAuthority } from './networkGrants.js'
import type { ModGrantAuthority } from './networkGrants.js'
import { ModError, type ModActor, type ModRegistry, type ModView } from './registry.js'
import { ModError, type ModActor, type ModRegistry, type ModView } from './registry.js'
@@ -44,6 +45,8 @@ export interface ModsApi {
@@ -44,6 +45,8 @@ export interface ModsApi {
authorize(req: IncomingMessage): Promise<ModActor | null>
authorize(req: IncomingMessage): Promise<ModActor | null>
/** OpenVibe.Network's mod principals; absent without a platform client (tests, local runs). */
/** OpenVibe.Network's mod principals; absent without a platform client (tests, local runs). */
grants?: ModGrantAuthority
grants?: ModGrantAuthority
+ /** Per-actor limits on writes (../net/actorLimits.ts). */
+ limits?: GamesActorLimits
log: Logger
log: Logger
}
}
@@ -156,6 +159,10 @@ export function handleModsRequest(
@@ -156,6 +159,10 @@ export function handleModsRequest(
problem(res, 403, 'capability.denied', 'games.mod.manage required')
problem(res, 403, 'capability.denied', 'games.mod.manage required')
return
return
}
}
+ if (method !== 'GET' && api.limits && !(await api.limits.modWrite(req, res, actor.audit))) {
+ req.resume()
+ return
+ }
const id = parts[0] ?? ''
const id = parts[0] ?? ''
if (method === 'GET' && parts.length === 2 && parts[1] === 'audit') {
if (method === 'GET' && parts.length === 2 && parts[1] === 'audit') {
const limit = Number(url.searchParams.get('limit') ?? 100)
const limit = Number(url.searchParams.get('limit') ?? 100)
@@ -178,7 +185,13 @@ export function handleModsRequest(
@@ -178,7 +185,13 @@ export function handleModsRequest(
if (!body.ok) return problem(res, body.status, body.code, 'request body must be a JSON object')
if (!body.ok) return problem(res, body.status, body.code, 'request body must be a JSON object')
const b = body.body
const b = body.body
if (parts.length === 0) {
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
let revision = 0
if (api.grants) {
if (api.grants) {
// Network registers mod:<id> first; what it approved is what is granted here.
// Network registers mod:<id> first; what it approved is what is granted here.
@@ -203,7 +216,10 @@ export function handleModsRequest(
@@ -203,7 +216,10 @@ export function handleModsRequest(
if (parts.length === 2 && parts[1] === 'revoke') {
if (parts.length === 2 && parts[1] === 'revoke') {
const reason = typeof b.reason === 'string' ? b.reason.slice(0, 500) : undefined
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') {
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)
const view = api.registry.revoke(id, actor, reason)
api.log.info('mod revoked', { mod: id, by: actor.audit })
api.log.info('mod revoked', { mod: id, by: actor.audit })
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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>
@@ -26,6 +26,7 @@ import {
@@ -26,6 +26,7 @@ import {
} from './sso.js'
} from './sso.js'
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 { sendNotFound, type NotFoundLink } from './notFound.js'
import { sendNotFound, type NotFoundLink } from './notFound.js'
import type { MapFileV2 } from '@openvibe/content'
import type { MapFileV2 } from '@openvibe/content'
@@ -112,6 +113,8 @@ export interface HttpPlatformHooks {
@@ -112,6 +113,8 @@ 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. */
+ limits?: GamesActorLimits
}
}
/** 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. */
@@ -177,13 +180,18 @@ export function createHttpServer(
@@ -177,13 +180,18 @@ export function createHttpServer(
}
}
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 = (req.headers['x-editor-key'] as string | undefined) ?? undefined
- void editorAuthorized(editorAuth, token).then((ok) => {
+ void editorAuthorized(editorAuth, token).then(async (ok) => {
if (!ok) {
if (!ok) {
log.warn('editor save rejected', {})
log.warn('editor save rejected', {})
res.writeHead(403, { 'content-type': 'application/json' })
res.writeHead(403, { 'content-type': 'application/json' })
res.end('{"error":"forbidden"}')
res.end('{"error":"forbidden"}')
return
return
}
}
+ const limits = platform?.limits
+ if (limits && !(await limits.mapSave(req, res, limits.editorActor(token ?? '')))) {
+ req.resume()
+ return
+ }
const chunks: Buffer[] = []
const chunks: Buffer[] = []
let size = 0
let size = 0
let aborted = false
let aborted = false
@@ -251,12 +259,17 @@ export function createHttpServer(
@@ -251,12 +259,17 @@ export function createHttpServer(
editorAuth
editorAuth
) {
) {
const token = (req.headers['x-editor-key'] as string | undefined) ?? undefined
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) {
if (!authed) {
res.writeHead(403, { 'content-type': 'application/json' })
res.writeHead(403, { 'content-type': 'application/json' })
res.end('{"error":"forbidden"}')
res.end('{"error":"forbidden"}')
return
return
}
}
+ const limits = platform?.limits
+ if (limits && !(await limits.asset(req, res, limits.editorActor(token ?? '')))) {
+ req.resume()
+ return
+ }
const chunks: Buffer[] = []
const chunks: Buffer[] = []
let size = 0
let size = 0
let aborted = false
let aborted = false
@@ -115,8 +115,8 @@ importers:
@@ -115,8 +115,8 @@ importers:
specifier: 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.71.0
version: 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:
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:
openvibe-shared:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0
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
version: https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0
@@ -1372,9 +1372,9 @@ packages:
@@ -1372,9 +1372,9 @@ packages:
engines: {node: '>=20'}
engines: {node: '>=20'}
hasBin: true
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'}
engines: {node: '>=20'}
openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:
openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:
@@ -2835,7 +2835,7 @@ snapshots:
@@ -2835,7 +2835,7 @@ snapshots:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
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:
openvibe-shared@https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0:
dependencies:
dependencies: