openvibers/openvibe.games Commits

Games M3: scripted mods finished (capability grants, storage, seeded random, hot reload, multi-file, API install) (#43)

OP OpenVibers committed 11 Oct 2026 · d058d1cbe7 · 40 files, +1504 −143

**Milestone M3: scripted mods, finished** (ADR-0007; the "Not yet" list in `docs/mods.md`).

- **Capability API v2:** a `games-quickjs@1` manifest names the bindings it asks for (`players.read`, `player.message`, `world.emit`, `storage.kv`). The server grants only what the pack's policy allows; a binding without its grant is absent from the script's `game` object (not stubbed), and grants travel in the def-set digest. Manifests that name no capabilities keep today's defaults.
- **Key/value storage:** `game.storage.get/set/delete/keys`, per mod per place, on PostgreSQL (migration `0002_mod_storage.sql`, written through the write-behind path). Keys ≤ 128 bytes, values ≤ 8 KB of JSON, ≤ 1000 keys and ≤ 1 MB per mod per place. A refusal is an error inside the mod, and writes count against the CPU budget.
- **Seeded randomness:** `game.random()` is seeded from the place and the mod, and `Math.random` inside the sandbox is replaced by it.
- **Hot reload:** staff `POST /api/v1/mods/:id/reload` swaps a running script's code and keeps its storage. A reload that fails to start keeps the old version running and reports why. Fresh clients fetch the active pack set before the digest handshake.
- **Multi-file mods:** module files with one entry. Imports resolve inside the pack only; an escaping or missing import fails the install.
- **Installing script mods through the mods API:** the same validation as packs, staff only.

Contracts: openvibe-contracts **v0.142.0** (pinned here) releases `games.script.run`, `games.players.read`, `games.player.message`, `games.world.emit` and `games.storage.kv` with schemas, and adds the reload route to `games.mod.manage`. `docs/capabilities-proposal/` mirrors the released text.

Built by Codex from my spec. I added the Contracts release, the pin and the proposal sync, and ran the loopback tests the Codex sandbox could not. Checks:
- `pnpm typecheck`, `pnpm lint`, `pnpm build` and prettier pass;
- the content, persistence, sandbox, manifest, `routesNoSocket`, `routes` and `proposals` tests pass, 27 covering files in all;
- the QuickJS test was flaky once under machine load and passed alone.

Remaining for the operator: Network grants for the new capabilities to the staff role or mod principals that should approve script bindings (mod principals, WS-M).

Changes

ddiff --git a/README.md b/README.md
iindex dd3ac51..83a0359 100644
---- a/README.md
++++ b/README.md
@@@ -25,8 +25,8 @@ WebSocket protocol · PostgreSQL 18 + Valkey persistence (openvibe-sdk) · pnpm
- identity (OpenVibe.Network: players are keyed by Network subjects), file storage beyond the local
copy (OpenVibe.Media mirrors map-editor assets), events delivery (OpenVibe.Events)
--- mod grants' authority (Network keeps them, `mods.grant.manage`) and installable executable mods
-- (they wait for OpenVibe.Host Stage C; script mods ship in the place's own packs)
++- mod grants' authority (Network keeps them, `mods.grant.manage`) and general-purpose executable
++ mods (OpenVibe.Host Stage C); Games runs its own bounded `games-quickjs@1` scripts
## Depends on
@@@ -180,7 +180,7 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
- **Mods** (ADR-013 in OpenVibe.Contracts). Manifests follow
`[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@2`:
++ decision 12). The content runtime is `games-content@2`:
declarative data packs checked against `@openvibe/content` (announcements;
inert, mod-owned props; definitions — items, recipes, crops, NPC archetypes —
merged into the content registry at boot, so they take effect on the next
@@@ -188,16 +188,16 @@ do not know the platform exists. See [ADR-0006](docs/adr/0006-canonical-subjects
requested capabilities; every runtime binding checks it at call time, a
revoked install or capability stops affecting the world on the next tick,
and install/grant/use/deny/revoke are audited (`mod_audit`). Trust tiers are
-- metadata only. Executable mods are refused by the mods API until sandboxed
-- execution exists in OpenVibe.Host (Stage C); `games-quickjs@1` script mods
-- ship in the place's own packs instead, pinned by the def-set digest, and run
++ metadata only. Staff can install `games-quickjs@1` script packs through the mods API;
++ built-in packs can carry scripts too. Scripts are pinned by the def-set digest and run
in a per-mod QuickJS sandbox only where `GAMES_SCRIPT_MODS=1` (budgets
`cpuMs` 2 per tick, `memoryMb` 8, `stackKb` 128 by default; over budget
disables the mod for the instance; host API and limits in docs/mods.md). API: `GET /api/v1/mods`,
`GET /api/v1/mods/:id` (public); `POST /api/v1/mods`,
-- `POST /api/v1/mods/:id/enable|disable|revoke|grants`,
++ `POST /api/v1/mods/:id/enable|disable|revoke|grants|reload`,
`DELETE /api/v1/mods/:id/grants/:capability`, `GET /api/v1/mods/:id/audit`
-- (owner/admin session, or a principal token with `games.mod.manage`).
++ (owner/admin session, or a principal token with `games.mod.manage`). Fresh clients fetch
++ `GET /api/v1/content` before the digest handshake so live script changes can be joined.
- **Status:** `GET /api/v1/platform` reports whether events and the Media
mirror are on, the outbox backlog and mirror counts (no secrets).
- **In production (2026-09-23, release `f11e21c`):** events and the Media
@@@ -226,9 +226,11 @@ Platform environment (all optional; unset = off):
## Capabilities
Implemented here (the service manifest's `capabilities`): `games.mod.manage` and `games.mod.read` (the
--mods API, for a principal token or an owner/admin session), and `games.world.announce`,
--`games.prop.place` and `games.def.define`, the capabilities a mod install may be granted and every runtime binding checks at
--call time.
++mods API, for a principal token or an owner/admin session); `games.world.announce`,
++`games.prop.place`, and `games.def.define` for declarative packs; and
++`games.script.run`, `games.players.read`, `games.player.message`,
++`games.world.emit`, and `games.storage.kv` for script packs. Script bindings
++are absent unless approved for that install.
Grants the `games` principal needs in OpenVibe.Network:
`events.event.publish` and `events.subscription.manage` (openvibe.events: the outbox, and the account,
@@@ -253,8 +255,8 @@ run `node test/run.js --index` to regenerate the pointers — a missing or stale
Reporting a vulnerability: [SECURITY.md](SECURITY.md). The server is authoritative: inbound protocol
messages are validated (zod), the WebSocket has per-socket flood control, and HTTP writes that cross a
capability boundary (editor saves and uploads, the mods API) have per-actor limits (openvibe-sdk/limits).
--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.
++Players are keyed by Network subjects; guest tokens that look like account keys are refused.
++Script mods run in isolated QuickJS sandboxes with only their approved bindings.
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 `OV_NETWORK_AUTH_URL` live in `/etc/openvibe/games.env`, by name only; there are no shared keys
between services.
ddiff --git a/STATUS.json b/STATUS.json
iindex ae157e6..d87c63b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -12,7 +12,12 @@
"games.mod.read",
"games.world.announce",
"games.prop.place",
-- "games.def.define"
++ "games.def.define",
++ "games.script.run",
++ "games.players.read",
++ "games.player.message",
++ "games.world.emit",
++ "games.storage.kv"
],
"deployed": true,
"deployment": "Public at https://openvibe.games and https://play.openvibe.games: deployed with `sudo ovhost deploy games` (OpenVibe.Host strategy pnpm-build since the 2026-09-27 cutover; deploy/scripts/deploy.sh is the wrapper), unit openvibe-games on 127.0.0.1:8000, env /etc/openvibe/games.env, principal games.",
@@@ -22,12 +27,12 @@
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
-- "server script mods (games-quickjs@1 in packs, pinned by the def-set digest): one QuickJS WASM sandbox per mod, cpuMs/memoryMb/stack budgets, intents only; off unless GAMES_SCRIPT_MODS=1",
++ "server script mods (games-quickjs@1): isolated QuickJS sandboxes, per-binding grants, per-mod/place PostgreSQL storage, seeded randomness, multi-file imports, staff installs and reload, active definition delivery to new clients; off unless GAMES_SCRIPT_MODS=1",
"players keyed by Network subjects",
"games.* events through a transactional outbox",
"games.progress.summary user module",
"the OpenVibe Frame from its own pinned copy",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits)"
],
-- "updated": "2026-10-10"
++ "updated": "2026-10-11"
}
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 2fb5a71..0437103 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -67,7 +67,24 @@ async function start(): Promise<void> {
const uiRoot = document.getElementById('ui') as HTMLElement
const identity = getIdentity()
-- const content = createContent()
++ // Fetch the active definition set before constructing game systems or sending hello.
++ // A fresh client can join after a staff script install or reload changed the digest.
++ let content = createContent()
++ try {
++ const response = await fetch('/api/v1/content', { cache: 'no-store' })
++ if (response.ok) {
++ const data = (await response.json()) as {
++ digest?: string
++ packs?: Parameters<typeof createContent>[0]
++ }
++ if (Array.isArray(data.packs)) {
++ const active = createContent(data.packs)
++ if (active.digest === data.digest) content = active
++ }
++ }
++ } catch {
++ // The hello digest still refuses a mismatched join if content is unavailable.
++ }
// Edited map (must match the server's copy for prediction parity).
// The map as the runtime currently has it, so a reload can diff against
// it rather than rebuilding every category.
ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex 346c348..0e84c67 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -20,7 +20,7 @@
"@openvibe/shared": "workspace:*",
"ajv": "^8.17.1",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.142.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0",
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 82925b9..2c05508 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -196,6 +196,10 @@ export class GameServer {
this.systems.editor.broadcastMapReload()
}
++ announce(text: string): void {
++ this.ctx.net.broadcastAll({ t: 'announce', text })
++ }
++
/**
* Network moved this person's token cutoff (sign out everywhere, password changed, banned): close
* every session they opened with an older Network sign-in. Returns how many closed.
ddiff --git a/apps/server/src/game/systems/mods.ts b/apps/server/src/game/systems/mods.ts
iindex 1fc04d7..cb1e37c 100644
---- a/apps/server/src/game/systems/mods.ts
++++ b/apps/server/src/game/systems/mods.ts
@@@ -68,6 +68,12 @@ export class ModSystem implements System {
case 'announce':
this.ctx.net.broadcastAll({ t: 'announce', text: intent.text })
return null
++ case 'message': {
++ const session = this.ctx.sessions.get(intent.playerId)
++ if (!session) return 'no such player'
++ this.ctx.net.send(session, { t: 'announce', text: intent.text })
++ return null
++ }
case 'giveItem': {
const session = this.ctx.sessions.get(intent.playerId)
if (!session) return 'no such player'
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex b7799e2..e31957a 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -3,7 +3,13 @@ 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'
--import { compileMapFileV2, createContent, parseMapFile, setMapOverride } from '@openvibe/content'
++import {
++ compileMapFileV2,
++ createContent,
++ parseMapFile,
++ SCRAPLANDIA_PACK,
++ setMapOverride,
++} from '@openvibe/content'
import { openPgStore } from '@openvibe/persistence'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
@@@ -29,13 +35,15 @@ import { ServerMetrics } from './observability/metrics.js'
import { createReadiness } from './observability/readiness.js'
import { buildRelease, releaseHandler } from './observability/release.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
--import { loadDefinitionPacks } from './mods/contentPack.js'
--import { ModRegistry } from './mods/registry.js'
++import { loadDefinitionPacks, scriptGrant } from './mods/contentPack.js'
++import { ModError, ModRegistry, type ModView } 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 { ScriptModHost } from './mods/script/host.js'
++import { PgScriptStorage } from './mods/script/storage.js'
++import { ScriptModSchema, type ScriptCapability } from '@openvibe/content'
import { isGuestToken } from './platform/accounts.js'
import { ProgressSummaryWriter } from './platform/progressSummary.js'
import {
@@@ -80,6 +88,9 @@ async function main(): Promise<void> {
log.warn('mod definitions skipped', { mod: modId, reason }),
)
const content = createContent(packs)
++ const builtInModIds = new Set(
++ (SCRAPLANDIA_PACK.mods ?? []).map((raw) => ScriptModSchema.parse(raw).id),
++ )
// Edited map (from /editor): heightfield + extra statics replace the
// procedural terrain for EVERYTHING (physics, spawns, clients fetch the
// same file over /map.json).
@@@ -207,9 +218,35 @@ async function main(): Promise<void> {
// even loaded otherwise. They stay in the def set, so the handshake digest is the same either way.
const scriptLog = log.child({ system: 'script-mods' })
let scripts: ScriptModHost | undefined
-- if (config.scriptMods && content.allMods().length > 0) {
++ if (config.scriptMods) {
const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
-- scripts = new ScriptModHost(content.allMods(), createQuickJsSandboxFactory(), scriptLog)
++ // Built-in pack code is reviewed with the place; installed packs use only the approved subset.
++ const grants: Record<string, string[]> = Object.fromEntries(
++ content
++ .allMods()
++ .filter((mod) => mod.granted === undefined)
++ .map((mod) => [mod.id, mod.capabilities ?? ['players.read', 'world.emit']]),
++ )
++ for (const view of mods.list()) {
++ if (view.mod.status !== 'enabled' || !view.granted.has('games.script.run')) continue
++ for (const script of view.pack.mods ?? []) {
++ if (script && typeof script === 'object' && 'id' in script && typeof script.id === 'string')
++ grants[script.id] = [...view.granted]
++ .filter((cap) => cap.startsWith('games.'))
++ .map((cap) => cap.slice(6))
++ }
++ }
++ scripts = new ScriptModHost(
++ content.allMods(),
++ createQuickJsSandboxFactory(),
++ scriptLog,
++ undefined,
++ {
++ placeSeed: config.placeId,
++ grants: grants as Record<string, ScriptCapability[]>,
++ storage: new PgScriptStorage(store.mods, config.placeId, scriptLog),
++ },
++ )
} else if (content.allMods().length > 0) {
scriptLog.info('script mods off for this place', { mods: content.allMods().length })
}
@@@ -223,6 +260,36 @@ async function main(): Promise<void> {
await game.load()
await scripts?.start()
++ const syncScriptView = async (view: ModView): Promise<void> => {
++ if (view.mod.runtime !== 'games-quickjs@1') return
++ for (const raw of view.pack.mods ?? []) {
++ const script = ScriptModSchema.parse(raw)
++ const active = view.mod.status === 'enabled' && view.granted.has('games.script.run')
++ scripts?.remove(script.id)
++ if (!active) {
++ content.removeScriptMod(script.id)
++ continue
++ }
++ const granted = (script.capabilities ?? (['players.read', 'world.emit'] as const)).filter(
++ (cap) => view.granted.has(scriptGrant(cap)),
++ )
++ const pinned = { ...script, granted }
++ if (content.allMods().some((mod) => mod.id === script.id)) content.replaceScriptMod(pinned)
++ else content.addScriptMod(pinned)
++ if (scripts) {
++ const result = await scripts.add(pinned, granted)
++ if (!result.ok)
++ log.warn('script failed to start after grant change', {
++ mod: script.id,
++ error: result.error,
++ })
++ }
++ }
++ game.announce(
++ 'Script mod definitions changed. New connections must use the updated content set.',
++ )
++ }
++
const accountData = platform
? createAccountData({
db,
@@@ -255,6 +322,7 @@ async function main(): Promise<void> {
// Mod principals (ADR-013): an install's grants follow its principal in Network (a change staff made there).
onModGrants: async (p) => {
const view = await mods.applyNetwork(p)
++ if (view) await syncScriptView(view)
return view ? `mod:${view.mod.status}` : 'ignored:unknown_mod'
},
log: platformLog,
@@@ -356,6 +424,22 @@ async function main(): Promise<void> {
config.oauth,
{
handle: (req, res) => {
++ if (req.method === 'GET' && req.url?.split('?')[0] === '/api/v1/content') {
++ const staticPacks = packs.map(({ mods: _mods, ...pack }) => pack)
++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
++ res.end(
++ JSON.stringify({
++ digest: content.digest,
++ packs: [
++ ...staticPacks,
++ {
++ mods: content.allMods().filter((mod) => !builtInModIds.has(mod.id)),
++ },
++ ],
++ }),
++ )
++ return true
++ }
if (revocations.handle(req, res)) return true
if (readiness.handle(req, res)) return true
if (serveRelease(req, res)) return true
@@@ -413,6 +497,47 @@ async function main(): Promise<void> {
return handleModsRequest(req, res, {
registry: mods,
authorize: authorizeStaff,
++ onInstalled: syncScriptView,
++ onChanged: syncScriptView,
++ reload: async (id, request, actor) => {
++ const prior = mods.get(id)
++ if (!prior) throw new ModError('mod.not_found', `no mod ${id}`, 404)
++ const checked = await mods.checkInstall(request, id)
++ if (
++ checked.manifest.runtime !== 'games-quickjs@1' ||
++ checked.pack.mods?.length !== 1 ||
++ prior.pack.mods?.length !== 1
++ )
++ throw new ModError('mod.pack_invalid', 'reload requires one script mod', 422)
++ const script = ScriptModSchema.parse(checked.pack.mods[0])
++ const old = ScriptModSchema.parse(prior.pack.mods[0])
++ if (script.id !== old.id)
++ throw new ModError('mod.pack_invalid', 'script id cannot change on reload', 422)
++ if (!content.allMods().some((mod) => mod.id === script.id) || !scripts?.has(script.id))
++ throw new ModError(
++ 'mod.reload_unavailable',
++ 'script is not running on this instance',
++ 409,
++ )
++ const grants = (
++ script.capabilities ?? (['players.read', 'world.emit'] as const)
++ ).filter((cap) => prior.granted.has(scriptGrant(cap)))
++ const pinned = { ...script, granted: grants }
++ const prepared = await scripts.prepareReload(pinned, grants)
++ if (!prepared.ok) throw new ModError('mod.reload_failed', prepared.error, 422)
++ try {
++ const view = await mods.replace(id, request, actor)
++ content.replaceScriptMod(pinned)
++ prepared.commit()
++ game.announce(
++ `Script mod ${script.id} reloaded. New connections must use the updated content set.`,
++ )
++ return view
++ } catch (error) {
++ prepared.cancel()
++ throw error
++ }
++ },
...(modGrants ? { grants: modGrants } : {}),
limits: actorLimits,
log: log.child({ system: 'mods-api' }),
@@@ -534,6 +659,7 @@ async function main(): Promise<void> {
// (that checkpoint plus every disconnect save queued before it) drain.
game.shutdown()
const drained = await game.drain()
++ await scripts?.flushWrites()
scripts?.dispose()
const cut = await httpDone
await within(2000, mirrorDone)
ddiff --git a/apps/server/src/mods/contentPack.test.ts b/apps/server/src/mods/contentPack.test.ts
iindex 0b8751c..bf1a663 100644
---- a/apps/server/src/mods/contentPack.test.ts
++++ b/apps/server/src/mods/contentPack.test.ts
@@@ -41,6 +41,93 @@ async function setup() {
}
describe('games-content@2 definitions', () => {
++ it('installs an approved script pack and pins only its grants at boot', async () => {
++ const { store, registry } = await setup()
++ const manifest = sampleManifest({
++ id: A,
++ runtime: 'games-quickjs@1',
++ compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ permissions: { capabilities: ['games.script.run', 'games.players.read', 'games.world.emit'] },
++ resources: { cpuMs: 2, memoryMb: 8, storageMb: 1 },
++ })
++ await registry.install(
++ {
++ manifest,
++ pack: {
++ mods: [
++ {
++ format: 'games-quickjs@1',
++ id: 'sample',
++ version: '1.0.0',
++ entry: 'sample.js',
++ source: 'function onTick() {}',
++ hooks: ['onTick'],
++ capabilities: ['players.read', 'world.emit'],
++ },
++ ],
++ },
++ approve: ['games.script.run', 'games.players.read'],
++ enable: true,
++ },
++ STAFF,
++ )
++ const packs = await loadDefinitionPacks(store.mods)
++ expect(packs[0]?.mods).toMatchObject([{ granted: ['players.read'] }])
++ expect(createContent(packs).allMods()).toHaveLength(1)
++ const nextManifest = { ...manifest, version: '1.0.1' }
++ const nextPack = {
++ mods: [
++ {
++ format: 'games-quickjs@1',
++ id: 'sample',
++ version: '1.0.1',
++ entry: 'sample.js',
++ source: 'function onTick() { game.random() }',
++ hooks: ['onTick'],
++ capabilities: ['players.read', 'world.emit'],
++ },
++ ],
++ }
++ const before = createContent(packs).digest
++ await registry.replace(A, { manifest: nextManifest, pack: nextPack }, STAFF)
++ expect((await registry.auditLog(A)).map((entry) => entry.action)).toContain('reload')
++ expect(createContent(await loadDefinitionPacks(store.mods)).digest).not.toBe(before)
++ })
++ it('refuses a script install whose module import leaves its pack', async () => {
++ const { registry } = await setup()
++ const manifest = sampleManifest({
++ id: A,
++ runtime: 'games-quickjs@1',
++ compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ permissions: { capabilities: ['games.script.run'] },
++ resources: { cpuMs: 2, memoryMb: 8, storageMb: 1 },
++ })
++ await expect(
++ registry.install(
++ {
++ manifest,
++ pack: {
++ mods: [
++ {
++ format: 'games-quickjs@1',
++ id: 'escape',
++ version: '1.0.0',
++ entry: 'main.js',
++ files: { 'main.js': 'import "../outside.js"; export function onTick() {}' },
++ hooks: ['onTick'],
++ },
++ ],
++ },
++ approve: ['games.script.run'],
++ enable: true,
++ },
++ STAFF,
++ ),
++ ).rejects.toMatchObject({
++ code: 'mod.pack_invalid',
++ errors: expect.arrayContaining([expect.stringMatching(/import leaves pack/)]),
++ })
++ })
it('recognizes an authored map as a definition-capability section', () => {
const mapPack = { map: SCRAPLANDIA_PACK.map! }
expect(capabilitiesUsedBy(mapPack)).toEqual([CAP_DEFINE])
ddiff --git a/apps/server/src/mods/contentPack.ts b/apps/server/src/mods/contentPack.ts
iindex 747a891..f733429 100644
---- a/apps/server/src/mods/contentPack.ts
++++ b/apps/server/src/mods/contentPack.ts
@@@ -1,6 +1,6 @@
/**
-- * `games-content@2`: the first mod kind — a declarative data pack, checked
-- * against the live `@openvibe/content` registry. It adds no code. Each section
++ * `games-content@2` data and `games-quickjs@1` scripts, checked against the
++ * live `@openvibe/content` registry. Each section
* needs one capability:
*
* announcements games.world.announce periodic server announcements
@@@ -25,6 +25,9 @@ import {
mergePackDefs,
type ContentPackV2,
type ContentRegistry,
++ ScriptModSchema,
++ MAX_PACK_MODS,
++ type ScriptCapability,
} from '@openvibe/content'
import type { ModRepository } from '@openvibe/persistence'
import { Ajv2020 } from 'ajv/dist/2020.js'
@@@ -33,12 +36,15 @@ import type { Validation } from './manifest.js'
export const CAP_ANNOUNCE = 'games.world.announce'
export const CAP_PLACE_PROP = 'games.prop.place'
export const CAP_DEFINE = 'games.def.define'
++export const CAP_SCRIPT_RUN = 'games.script.run'
++export const scriptGrant = (capability: ScriptCapability): string => `games.${capability}`
/** Capabilities the games-content@2 runtime can bind. Anything else is never granted here. */
export const CONTENT_RUNTIME_CAPABILITIES: readonly string[] = [
CAP_ANNOUNCE,
CAP_PLACE_PROP,
CAP_DEFINE,
++ CAP_SCRIPT_RUN,
]
export interface ContentAnnouncement {
@@@ -59,6 +65,7 @@ export interface ContentPack {
map?: NonNullable<ContentPackV2['map']>
announcements?: ContentAnnouncement[]
props?: ContentProp[]
++ mods?: NonNullable<ContentPackV2['mods']>
}
export const CONTENT_PACK_SCHEMA = {
@@@ -112,6 +119,7 @@ export const CONTENT_PACK_SCHEMA = {
},
},
},
++ mods: { type: 'array', maxItems: MAX_PACK_MODS, items: { type: 'object' } },
},
} as const
@@@ -125,6 +133,15 @@ export function capabilitiesUsedBy(pack: ContentPack): string[] {
used.push(CAP_DEFINE)
if ((pack.announcements ?? []).length > 0) used.push(CAP_ANNOUNCE)
if ((pack.props ?? []).length > 0) used.push(CAP_PLACE_PROP)
++ if ((pack.mods ?? []).length > 0) used.push(CAP_SCRIPT_RUN)
++ for (const raw of pack.mods ?? []) {
++ const parsed = ScriptModSchema.safeParse(raw)
++ if (parsed.success)
++ for (const cap of parsed.data.capabilities ?? ['players.read', 'world.emit']) {
++ const grant = scriptGrant(cap)
++ if (!used.includes(grant)) used.push(grant)
++ }
++ }
return used
}
@@@ -155,6 +172,11 @@ export function validateContentPack(
}
const pack = value as unknown as ContentPack
const errors: string[] = []
++ for (const [index, raw] of (pack.mods ?? []).entries()) {
++ const parsed = ScriptModSchema.safeParse(raw)
++ if (!parsed.success) errors.push(`mods[${index}]: ${parsed.error.message}`)
++ else if (parsed.data.granted) errors.push(`mods[${index}]: granted is server-owned`)
++ }
// The built-in pack always supplies the map, so a mod's map would be skipped at boot as a duplicate.
if (pack.map) errors.push('map is defined by the built-in pack and cannot be replaced by a mod')
if (pack.defs || pack.map) {
@@@ -196,15 +218,39 @@ export async function loadDefinitionPacks(
for (const mod of await mods.list()) {
if (mod.status !== 'enabled') continue
const pack = mod.pack as unknown as ContentPackV2
-- if (!pack.map && Object.values(pack.defs ?? {}).every((list) => (list ?? []).length === 0))
++ if (
++ !pack.map &&
++ !pack.mods?.length &&
++ Object.values(pack.defs ?? {}).every((list) => (list ?? []).length === 0)
++ )
continue
-- const granted = (await mods.grants(mod.id)).some(
-- (g) => g.capability === CAP_DEFINE && g.revokedAt === null,
++ const grants = new Set(
++ (await mods.grants(mod.id)).filter((g) => g.revokedAt === null).map((g) => g.capability),
)
-- if (!granted) continue
++ const hasDefs =
++ !!pack.map || Object.values(pack.defs ?? {}).some((list) => (list ?? []).length > 0)
++ const hasScripts = !!pack.mods?.length
++ if ((hasDefs && !grants.has(CAP_DEFINE)) || (hasScripts && !grants.has(CAP_SCRIPT_RUN)))
++ continue
try {
-- createContent([...accepted, pack])
-- accepted.push(pack)
++ const approved: ContentPackV2 = {
++ ...pack,
++ ...(pack.mods
++ ? {
++ mods: pack.mods.map((raw) => {
++ const parsed = ScriptModSchema.parse(raw)
++ return {
++ ...parsed,
++ granted: (parsed.capabilities ?? ['players.read', 'world.emit'])
++ .filter((cap) => grants.has(scriptGrant(cap)))
++ .sort(),
++ }
++ }),
++ }
++ : {}),
++ }
++ createContent([...accepted, approved])
++ accepted.push(approved)
} catch (err) {
onSkip(mod.id, err instanceof Error ? err.message : String(err))
}
ddiff --git a/apps/server/src/mods/manifest.ts b/apps/server/src/mods/manifest.ts
iindex efba5e0..0134ee0 100644
---- a/apps/server/src/mods/manifest.ts
++++ b/apps/server/src/mods/manifest.ts
@@@ -58,22 +58,28 @@ export const GAMES_TARGET = 'games.browser'
export const CONTENT_RUNTIME = 'games-content@2'
/** Version of the content runtime, matched against compatibility.runtime. */
export const CONTENT_RUNTIME_VERSION = '2.0.0'
++export const SCRIPT_RUNTIME = 'games-quickjs@1'
++export const SCRIPT_RUNTIME_VERSION = '1.0.0'
/**
-- * Whether this server can run the manifest at all. Executable runtimes
-- * (scripts) are refused here: they wait for OpenVibe.Host's sandbox
-- * (Stage C). Only declarative content packs run in Games today.
++ * Whether this server can run the manifest at all. Games accepts its
++ * declarative and bounded QuickJS runtimes; general executables wait for Host.
*/
export function checkForGames(m: ModManifest): string[] {
const errors: string[] = []
if (m.target !== GAMES_TARGET) errors.push(`target ${m.target} is not ${GAMES_TARGET}`)
-- if (m.runtime !== CONTENT_RUNTIME) {
++ if (m.runtime !== CONTENT_RUNTIME && m.runtime !== SCRIPT_RUNTIME) {
errors.push(
`runtime ${m.runtime} is not available here: only ${CONTENT_RUNTIME} (declarative data packs) runs in Games until sandboxed execution exists`,
)
-- } else if (!safeSatisfies(CONTENT_RUNTIME_VERSION, m.compatibility.runtime)) {
++ } else if (
++ !safeSatisfies(
++ m.runtime === SCRIPT_RUNTIME ? SCRIPT_RUNTIME_VERSION : CONTENT_RUNTIME_VERSION,
++ m.compatibility.runtime,
++ )
++ ) {
errors.push(
-- `compatibility.runtime ${m.compatibility.runtime} does not include ${CONTENT_RUNTIME_VERSION}`,
++ `compatibility.runtime ${m.compatibility.runtime} does not include ${m.runtime === SCRIPT_RUNTIME ? SCRIPT_RUNTIME_VERSION : CONTENT_RUNTIME_VERSION}`,
)
}
if (m.publisher.type !== 'user' && m.publisher.type !== 'app') {
ddiff --git a/apps/server/src/mods/registry.ts b/apps/server/src/mods/registry.ts
iindex fc42e2e..eda7960 100644
---- a/apps/server/src/mods/registry.ts
++++ b/apps/server/src/mods/registry.ts
@@@ -27,6 +27,7 @@
* staff made in Network). The approved subset here is the hot path's copy.
*/
import type { ContentRegistry } from '@openvibe/content'
++import { SCRIPT_CAPABILITIES, ScriptModSchema } from '@openvibe/content'
import type {
ModAuditDto,
ModGrantDto,
@@@ -48,6 +49,8 @@ import {
import {
capabilitiesUsedBy,
CONTENT_RUNTIME_CAPABILITIES,
++ CAP_SCRIPT_RUN,
++ scriptGrant,
validateContentPack,
type ContentPack,
} from './contentPack.js'
@@@ -263,7 +266,10 @@ export class ModRegistry {
}
/** Everything install checks before it writes: the manifest, the pack, the approved subset and the options. */
-- async checkInstall(req: InstallRequest): Promise<{
++ async checkInstall(
++ req: InstallRequest,
++ replacing?: string,
++ ): Promise<{
manifest: ModManifest
pack: ContentPack
approve: string[]
@@@ -284,6 +290,22 @@ export class ModRegistry {
throw new ModError('mod.pack_invalid', 'content pack is invalid', 422, packCheck.errors)
}
const pack = packCheck.value
++ const ownIds = new Set<string>()
++ for (const raw of pack.mods ?? []) {
++ const script = ScriptModSchema.parse(raw)
++ if (ownIds.has(script.id))
++ throw new ModError('mod.pack_invalid', `script id ${script.id} occurs twice`, 422)
++ ownIds.add(script.id)
++ const owner = [...this.views.values()].find(
++ (view) =>
++ view.mod.id !== replacing &&
++ (view.pack.mods ?? []).some(
++ (candidate) => ScriptModSchema.safeParse(candidate).data?.id === script.id,
++ ),
++ )
++ if (owner || (!replacing && this.content.allMods().some((mod) => mod.id === script.id)))
++ throw new ModError('mod.pack_invalid', `script id ${script.id} already exists`, 422)
++ }
const requested = new Set(manifest.permissions.capabilities)
const unrequested = capabilitiesUsedBy(pack).filter((c) => !requested.has(c))
if (unrequested.length > 0) {
@@@ -298,7 +320,10 @@ export class ModRegistry {
for (const cap of approve) this.assertGrantable(manifest, cap)
const trustTier = parseTrustTier(req.trustTier ?? 'unreviewed')
const enable = req.enable === true
-- if (this.views.has(manifest.id) || (await this.store.mods.get(manifest.id))) {
++ if (
++ manifest.id !== replacing &&
++ (this.views.has(manifest.id) || (await this.store.mods.get(manifest.id)))
++ ) {
throw new ModError('mod.exists', `mod ${manifest.id} is already installed`, 409)
}
return { manifest, pack, approve, trustTier, enable }
@@@ -344,6 +369,56 @@ export class ModRegistry {
return this.refresh(mod.id)
}
++ /** Revalidate and atomically replace the persisted code and provenance of one script install. */
++ async replace(id: string, req: InstallRequest, actor: ModActor): Promise<ModView> {
++ const old = this.require(id)
++ if (old.mod.status === 'revoked')
++ throw new ModError('mod.revoked', 'revoked installs cannot reload', 409)
++ if (old.mod.runtime !== 'games-quickjs@1')
++ throw new ModError('mod.runtime_unsupported', 'only script mods reload', 422)
++ const { manifest, pack } = await this.checkInstall(req, id)
++ if (manifest.runtime !== old.mod.runtime)
++ throw new ModError('mod.runtime_unsupported', 'runtime cannot change on reload', 422)
++ const newlyRequested = manifest.permissions.capabilities.filter(
++ (cap) => !old.manifest.permissions.capabilities.includes(cap),
++ )
++ if (newlyRequested.length)
++ throw new ModError(
++ 'mod.grant_not_requested',
++ 'new capabilities require a new install',
++ 422,
++ newlyRequested,
++ )
++ const at = this.now()
++ const updated: ModInstallDto = {
++ ...old.mod,
++ name: manifest.name,
++ version: manifest.version,
++ manifest: manifest as unknown as Record<string, unknown>,
++ pack: pack as unknown as Record<string, unknown>,
++ updatedAt: at,
++ }
++ await this.store.transaction(async (t) => {
++ await this.store.mods.replace(updated)
++ for (const cap of old.granted) {
++ if (manifest.permissions.capabilities.includes(cap)) continue
++ await this.store.mods.revokeGrant(id, cap, actor.audit, at)
++ await this.writeAudit(id, 'revoke_grant', cap, actor, {
++ reason: 'not requested by reloaded manifest',
++ })
++ }
++ await this.writeAudit(id, 'reload', null, actor, {
++ from: old.mod.version,
++ to: manifest.version,
++ })
++ await this.emit(t, 'grants_changed', updated, actor, {
++ reload: true,
++ version: manifest.version,
++ })
++ })
++ return this.refresh(id)
++ }
++
enable(id: string, actor: ModActor): Promise<ModView> {
return this.setStatus(id, 'enabled', actor)
}
@@@ -493,7 +568,7 @@ export class ModRegistry {
if (!manifest.permissions.capabilities.includes(capability)) {
throw new ModError('mod.grant_not_requested', `${capability} was not requested`, 422)
}
-- if (!CONTENT_RUNTIME_CAPABILITIES.includes(capability)) {
++ if (!viewRuntimeCaps(manifest.runtime).includes(capability)) {
throw new ModError(
'mod.grant_unsupported',
`${capability} has no binding in ${manifest.runtime}`,
@@@ -585,6 +660,12 @@ export class ModRegistry {
}
}
++function viewRuntimeCaps(runtime: string): readonly string[] {
++ return runtime === 'games-quickjs@1'
++ ? [CAP_SCRIPT_RUN, ...SCRIPT_CAPABILITIES.map(scriptGrant)]
++ : CONTENT_RUNTIME_CAPABILITIES.filter((c) => c !== CAP_SCRIPT_RUN)
++}
++
function grantRow(modId: string, capability: string, actor: ModActor, at: number): ModGrantDto {
return {
modId,
ddiff --git a/apps/server/src/mods/routes.test.ts b/apps/server/src/mods/routes.test.ts
iindex 3e543bb..0763dd2 100644
---- a/apps/server/src/mods/routes.test.ts
++++ b/apps/server/src/mods/routes.test.ts
@@@ -46,6 +46,50 @@ const installBody = {
}
describe('mods HTTP API', () => {
++ it('requires staff for a validated script install and records its grants', async () => {
++ const { base, registry } = await start(async (req) =>
++ req.headers.authorization === 'Bearer staff' ? STAFF : null,
++ )
++ const body = {
++ manifest: sampleManifest({
++ runtime: 'games-quickjs@1',
++ compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ permissions: { capabilities: ['games.script.run', 'games.players.read'] },
++ resources: { cpuMs: 2, memoryMb: 8, storageMb: 1 },
++ }),
++ pack: {
++ mods: [
++ {
++ format: 'games-quickjs@1',
++ id: 'greeter',
++ version: '1.0.0',
++ entry: 'greeter.js',
++ source: 'function onTick() {}',
++ hooks: ['onTick'],
++ capabilities: ['players.read'],
++ },
++ ],
++ },
++ approve: ['games.script.run', 'games.players.read'],
++ enable: true,
++ }
++ const post = (auth: string) =>
++ fetch(base, {
++ method: 'POST',
++ headers: {
++ authorization: auth,
++ 'content-type': 'application/json',
++ },
++ body: JSON.stringify(body),
++ })
++ expect((await post('Bearer player')).status).toBe(403)
++ const installed = await post('Bearer staff')
++ expect(installed.status).toBe(201)
++ expect(registry.get(MOD_ID)?.granted).toEqual(
++ new Set(['games.script.run', 'games.players.read']),
++ )
++ expect((await registry.auditLog(MOD_ID)).map((entry) => entry.action)).toContain('install')
++ })
it('lets staff install, grant and revoke, and anyone read', async () => {
const { base } = await start(async (req) =>
req.headers.authorization === 'Bearer staff' ? STAFF : null,
ddiff --git a/apps/server/src/mods/routes.ts b/apps/server/src/mods/routes.ts
iindex a9fa031..aa36c52 100644
---- a/apps/server/src/mods/routes.ts
++++ b/apps/server/src/mods/routes.ts
@@@ -48,6 +48,13 @@ export interface ModsApi {
/** Per-actor limits on writes (../net/actorLimits.ts). */
limits?: GamesActorLimits
log: Logger
++ onInstalled?: (view: ModView) => Promise<void>
++ onChanged?: (view: ModView) => Promise<void>
++ reload?: (
++ id: string,
++ request: { manifest: unknown; pack: unknown },
++ actor: ModActor,
++ ) => Promise<ModView>
}
export function publicView(v: ModView): Record<string, unknown> {
@@@ -179,7 +186,9 @@ export function handleModsRequest(
const principal = await api.grants.change(id, cap, 'revoke', actor.audit)
api.registry.noteNetworkRevision(id, principal.revision)
}
-- json(res, 200, publicView(await api.registry.revokeGrant(id, cap, actor)))
++ const view = await api.registry.revokeGrant(id, cap, actor)
++ await api.onChanged?.(view)
++ json(res, 200, publicView(view))
return
}
if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)
@@@ -203,6 +212,7 @@ export function handleModsRequest(
revision = principal.revision
}
const view = await api.registry.install(request, actor)
++ await api.onInstalled?.(view)
if (revision) api.registry.noteNetworkRevision(view.mod.id, revision)
api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })
json(res, 201, publicView(view))
@@@ -213,10 +223,18 @@ export function handleModsRequest(
parts[1] === 'enable'
? await api.registry.enable(id, actor)
: await api.registry.disable(id, actor)
++ await api.onChanged?.(view)
api.log.info(`mod ${parts[1]}d`, { mod: id, by: actor.audit })
json(res, 200, publicView(view))
return
}
++ if (parts.length === 2 && parts[1] === 'reload') {
++ if (!api.reload)
++ return problem(res, 501, 'mod.reload_unavailable', 'live script reload is unavailable')
++ const view = await api.reload(id, { manifest: b.manifest, pack: b.pack }, actor)
++ json(res, 200, publicView(view))
++ return
++ }
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') {
@@@ -226,6 +244,7 @@ export function handleModsRequest(
)
}
const view = await api.registry.revoke(id, actor, reason)
++ await api.onChanged?.(view)
api.log.info('mod revoked', { mod: id, by: actor.audit })
json(res, 200, publicView(view))
return
@@@ -239,7 +258,9 @@ export function handleModsRequest(
const principal = await api.grants.change(id, b.capability, 'approve', actor.audit)
api.registry.noteNetworkRevision(id, principal.revision)
}
-- json(res, 200, publicView(await api.registry.grant(id, b.capability, actor)))
++ const view = await api.registry.grant(id, b.capability, actor)
++ await api.onChanged?.(view)
++ json(res, 200, publicView(view))
return
}
problem(res, 404, 'request.not_found', 'no such mods route')
ddiff --git a/apps/server/src/mods/routesNoSocket.test.ts b/apps/server/src/mods/routesNoSocket.test.ts
nnew file mode 100644
iindex 0000000..98a010a
---- /dev/null
++++ b/apps/server/src/mods/routesNoSocket.test.ts
@@@ -0,0 +1,85 @@
++import { Readable } from 'node:stream'
++import type { IncomingMessage, ServerResponse } from 'node:http'
++import { createContent } from '@openvibe/content'
++import { openTestStore } from '@openvibe/persistence/testing'
++import { createConsoleLogger } from '@openvibe/shared'
++import { describe, expect, it } from 'vitest'
++import { ModRegistry, type ModActor } from './registry.js'
++import { handleModsRequest } from './routes.js'
++import { sampleManifest } from './testFixtures.js'
++
++const STAFF: ModActor = {
++ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
++ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
++}
++const log = createConsoleLogger({ app: 'test' }, 'error')
++
++function post(
++ registry: ModRegistry,
++ body: unknown,
++ staff: boolean,
++): Promise<{ status: number; body: Record<string, unknown> }> {
++ return new Promise((resolve) => {
++ const req = Readable.from([Buffer.from(JSON.stringify(body))]) as IncomingMessage
++ req.method = 'POST'
++ req.url = '/api/v1/mods'
++ const response = {
++ statusCode: 200,
++ setHeader() {
++ return undefined
++ },
++ writeHead(status: number) {
++ response.statusCode = status
++ return undefined
++ },
++ end(value?: string) {
++ resolve({
++ status: response.statusCode,
++ body: JSON.parse(value ?? '{}') as Record<string, unknown>,
++ })
++ return undefined
++ },
++ }
++ const res = response as unknown as ServerResponse
++ handleModsRequest(req, res, { registry, authorize: async () => (staff ? STAFF : null), log })
++ })
++}
++
++describe('script install API without a listening socket', () => {
++ it('refuses a non-staff install and accepts the same pack for staff', async () => {
++ const registry = new ModRegistry(await openTestStore(), createContent())
++ const request = {
++ manifest: sampleManifest({
++ runtime: 'games-quickjs@1',
++ compatibility: { runtime: '>=1.0.0 <2.0.0' },
++ permissions: { capabilities: ['games.script.run', 'games.players.read'] },
++ resources: { cpuMs: 2, memoryMb: 8, storageMb: 1 },
++ }),
++ pack: {
++ mods: [
++ {
++ format: 'games-quickjs@1',
++ id: 'api_script',
++ version: '1.0.0',
++ entry: 'main.js',
++ source: 'function onTick() {}',
++ hooks: ['onTick'],
++ capabilities: ['players.read'],
++ },
++ ],
++ },
++ approve: ['games.script.run', 'games.players.read'],
++ enable: true,
++ }
++ expect(await post(registry, request, false)).toMatchObject({
++ status: 403,
++ body: { code: 'capability.denied' },
++ })
++ expect(registry.list()).toHaveLength(0)
++ expect(await post(registry, request, true)).toMatchObject({
++ status: 201,
++ body: { status: 'enabled' },
++ })
++ expect(registry.list()).toHaveLength(1)
++ })
++})
ddiff --git a/apps/server/src/mods/runtime.ts b/apps/server/src/mods/runtime.ts
iindex 01fd6ef..30e3a70 100644
---- a/apps/server/src/mods/runtime.ts
++++ b/apps/server/src/mods/runtime.ts
@@@ -7,12 +7,9 @@
* was not granted — or one revoked a moment ago — throws before anything
* happens, so it cannot be reached from the mod side at all.
*
-- * `games-content@2` packs are interpreted here through that same API; an
-- * executable runtime (a script sandbox) would receive the same bindings.
-- * Executable mods are not accepted yet: sandboxed execution with enforced
-- * CPU, memory, storage and network budgets belongs to OpenVibe.Host
-- * (Stage C). Until then the runtime enforces what it can see (roadmap WS-M
-- * task 4), and only on the offending mod:
++ * `games-content@2` packs are interpreted here through that API. The bounded
++ * `games-quickjs@1` runtime is separate (script/host.ts). This content runtime
++ * enforces what it can see (roadmap WS-M task 4), and only on the offending mod:
* - CPU: each reconcile is timed against the manifest's cpuMs; a mod over
* its budget on CPU_STRIKES reconciles in a row is disabled.
* - Event flood: every host effect (an announcement, a prop placed or
ddiff --git a/apps/server/src/mods/script/host.test.ts b/apps/server/src/mods/script/host.test.ts
iindex d455cfe..57de395 100644
---- a/apps/server/src/mods/script/host.test.ts
++++ b/apps/server/src/mods/script/host.test.ts
@@@ -83,6 +83,41 @@ async function boot(scripts: Record<string, FakeScript>, mods: ScriptMod[]) {
}
describe('ScriptModHost', () => {
++ it('enforces key, value, count and total storage limits inside the mod', async () => {
++ const errors: string[] = []
++ const { host } = await boot(
++ {
++ limited: (game) => {
++ const set = game['storage.set']!
++ const run = (fn: () => unknown) => {
++ try {
++ fn()
++ } catch (error) {
++ errors.push(String(error))
++ }
++ }
++ run(() => set('é'.repeat(65), 1))
++ run(() => set('large', 'x'.repeat(8192)))
++ for (let i = 0; i < 1000; i++) set(`k${i}`, null)
++ run(() => set('overflow', 1))
++ for (let i = 0; i < 128; i++) set(`k${i}`, 'x'.repeat(8100))
++ run(() => set('k128', 'x'.repeat(8100)))
++ expect(game['storage.keys']!()).toHaveLength(1000)
++ game['storage.delete']!('k0')
++ expect(game['storage.get']!('k0')).toBeNull()
++ return { ok: true }
++ },
++ },
++ [{ ...mod('limited'), capabilities: ['storage.kv'] }],
++ )
++ // Explicit grants are supplied by policy, never taken from the request list.
++ await host.reload({ ...mod('limited'), capabilities: ['storage.kv'] }, ['storage.kv'])
++ host.tick(1, 1 / 30)
++ expect(errors.join(' ')).toMatch(/128 UTF-8 bytes/)
++ expect(errors.join(' ')).toMatch(/8 KiB/)
++ expect(errors.join(' ')).toMatch(/1000 keys/)
++ expect(errors.join(' ')).toMatch(/1 MiB/)
++ })
it('applies intents only after the call completes; a failed call changes nothing', async () => {
const { host, world } = await boot(
{
ddiff --git a/apps/server/src/mods/script/host.ts b/apps/server/src/mods/script/host.ts
iindex ce1e2e5..5b7a267 100644
---- a/apps/server/src/mods/script/host.ts
++++ b/apps/server/src/mods/script/host.ts
@@@ -3,7 +3,7 @@
* sandbox each, from the tick and the join/leave hooks.
*
* A mod reads the world and asks for changes; it never makes them. What it
-- * can reach is the frozen global `game` (see docs/mods.md):
++ * can reach is its granted subset of the frozen global `game` (see docs/mods.md):
*
* game.players() every player in the world: { id, name, pos, yaw }
* game.player(id) one player, or null
@@@ -22,7 +22,12 @@
* caller as an exception.
*/
import { performance } from 'node:perf_hooks'
--import { SCRIPT_MOD_BUDGETS, type ScriptMod, type ScriptModHook } from '@openvibe/content'
++import {
++ SCRIPT_MOD_BUDGETS,
++ type ScriptCapability,
++ type ScriptMod,
++ type ScriptModHook,
++} from '@openvibe/content'
import type { Logger } from '@openvibe/shared'
import type {
Json,
@@@ -38,6 +43,17 @@ export const LOAD_CPU_FACTOR = 25
export const ERROR_STRIKES = 5
export const MAX_INTENTS_PER_CALL = 16
export const MAX_LOGS_PER_TICK = 4
++export const STORAGE_MAX_KEYS = 1000
++export const STORAGE_MAX_BYTES = 1024 * 1024
++export const STORAGE_MAX_VALUE_BYTES = 8 * 1024
++export const STORAGE_MAX_KEY_BYTES = 128
++
++export interface ScriptStorage {
++ load(modId: string): Promise<Record<string, Json>>
++ set(modId: string, key: string, value: Json): void
++ delete(modId: string, key: string): void
++ flushWrites(): Promise<void>
++}
export interface ScriptPlayer {
id: string
@@@ -49,6 +65,7 @@ export interface ScriptPlayer {
/** Everything a mod may ask for. Each is applied by the gameplay modules, never by the mod. */
export type ScriptIntent =
| { kind: 'announce'; text: string }
++ | { kind: 'message'; playerId: string; text: string }
| { kind: 'giveItem'; playerId: string; item: string; count: number }
/** What the game lends the host: reads, and one door for changes. */
@@@ -115,6 +132,11 @@ export function parseIntent(raw: Json): ScriptIntent | string {
return 'giveItem: count must be an integer 1-100'
return { kind: 'giveItem', playerId, item, count }
}
++ case 'message': {
++ if (!shortString(raw.playerId, 64) || !shortString(raw.text, 200))
++ return 'message: playerId and text are required'
++ return { kind: 'message', playerId: raw.playerId, text: raw.text }
++ }
default:
return `unknown intent kind ${JSON.stringify(raw.kind ?? null)}`
}
@@@ -135,6 +157,16 @@ interface ModSlot {
failures: Record<SandboxFailureReason, number>
intentsApplied: number
intentsRejected: number
++ grants: ReadonlySet<ScriptCapability>
++ storage: Map<string, Json>
++ randomState: number
++ loadError: string | null
++}
++
++function seedRandom(placeSeed: string, modId: string): number {
++ let h = 0x811c9dc5
++ for (const c of `${placeSeed}:${modId}`) h = Math.imul(h ^ c.charCodeAt(0), 0x01000193) >>> 0
++ return h || 1
}
export class ScriptModHost {
@@@ -148,6 +180,11 @@ export class ScriptModHost {
private readonly factory: ModSandboxFactory,
private readonly log: Logger,
private readonly now: () => number = () => performance.now(),
++ private readonly options: {
++ placeSeed?: string
++ grants?: Readonly<Record<string, readonly ScriptCapability[]>>
++ storage?: ScriptStorage
++ } = {},
) {
this.slots = mods.map((mod) => ({
mod,
@@@ -163,6 +200,14 @@ export class ScriptModHost {
failures: { cpu: 0, memory: 0, stack: 0, error: 0 },
intentsApplied: 0,
intentsRejected: 0,
++ grants: new Set(
++ this.options.grants?.[mod.id] ??
++ mod.granted ??
++ (mod.capabilities ? [] : ['players.read', 'world.emit']),
++ ),
++ storage: new Map(),
++ randomState: seedRandom(this.options.placeSeed ?? 'scraplandia', mod.id),
++ loadError: null,
}))
}
@@@ -174,40 +219,148 @@ export class ScriptModHost {
/** Creates every sandbox and evaluates each mod once. A mod that fails to load is disabled. */
async start(): Promise<void> {
for (const slot of this.slots) {
-- const { mod, budget } = slot
-- let created: Awaited<ReturnType<ModSandboxFactory['create']>>
-- try {
-- created = await this.factory.create({
-- id: mod.id,
-- filename: mod.entry,
-- source: mod.source,
-- limits: { memoryBytes: budget.memoryMb * 1024 * 1024, stackBytes: budget.stackKb * 1024 },
-- loadCpuMs: budget.cpuMs * LOAD_CPU_FACTOR,
-- host: this.hostFunctions(slot),
-- })
-- } catch (err) {
-- created = {
-- ok: false,
-- reason: 'error',
-- message: err instanceof Error ? err.message : String(err),
-- }
++ if (this.options.storage) {
++ const saved = await this.options.storage.load(slot.mod.id)
++ slot.storage = new Map(Object.entries(saved))
}
-- if (!created.ok) {
-- slot.failures[created.reason]++
-- this.disable(slot, created.reason, `load: ${created.message}`)
-- continue
-- }
-- slot.sandbox = created.sandbox
-- this.log.info('script mod loaded', {
-- mod: mod.id,
-- version: mod.version,
-- engine: this.factory.engine,
-- hooks: mod.hooks.join(','),
-- cpuMs: budget.cpuMs,
-- memoryMb: budget.memoryMb,
-- stackKb: budget.stackKb,
++ await this.startSlot(slot)
++ }
++ }
++
++ private async startSlot(slot: ModSlot): Promise<void> {
++ const { mod, budget } = slot
++ let created: Awaited<ReturnType<ModSandboxFactory['create']>>
++ try {
++ created = await this.factory.create({
++ id: mod.id,
++ filename: mod.entry,
++ source: mod.source ?? mod.files?.[mod.entry] ?? '',
++ ...(mod.files ? { files: mod.files, hooks: mod.hooks } : {}),
++ limits: { memoryBytes: budget.memoryMb * 1024 * 1024, stackBytes: budget.stackKb * 1024 },
++ loadCpuMs: budget.cpuMs * LOAD_CPU_FACTOR,
++ host: this.hostFunctions(slot),
})
++ } catch (err) {
++ created = {
++ ok: false,
++ reason: 'error',
++ message: err instanceof Error ? err.message : String(err),
++ }
++ }
++ if (!created.ok) {
++ slot.loadError = created.message
++ slot.failures[created.reason]++
++ this.disable(slot, created.reason, `load: ${created.message}`)
++ return
++ }
++ slot.sandbox = created.sandbox
++ this.log.info('script mod loaded', {
++ mod: mod.id,
++ version: mod.version,
++ engine: this.factory.engine,
++ hooks: mod.hooks.join(','),
++ cpuMs: budget.cpuMs,
++ memoryMb: budget.memoryMb,
++ stackKb: budget.stackKb,
++ })
++ }
++
++ /** Prepare the replacement first. A failed load leaves the old sandbox running. */
++ async reload(
++ mod: ScriptMod,
++ grants?: readonly ScriptCapability[],
++ ): Promise<{ ok: true } | { ok: false; error: string }> {
++ const prepared = await this.prepareReload(mod, grants)
++ if (!prepared.ok) return prepared
++ prepared.commit()
++ return { ok: true }
++ }
++
++ async prepareReload(
++ mod: ScriptMod,
++ grants?: readonly ScriptCapability[],
++ ): Promise<{ ok: true; commit(): void; cancel(): void } | { ok: false; error: string }> {
++ const old = this.slots.find((s) => s.mod.id === mod.id)
++ if (!old) return { ok: false, error: `unknown mod ${mod.id}` }
++ const candidate: ModSlot = {
++ ...old,
++ mod,
++ budget: resolveBudget(mod),
++ hooks: new Set(mod.hooks),
++ sandbox: null,
++ state: 'running',
++ reason: null,
++ strikes: 0,
++ spentMs: 0,
++ logsThisTick: 0,
++ loadError: null,
++ storage: new Map(old.storage),
++ grants: new Set(
++ grants ??
++ this.options.grants?.[mod.id] ??
++ mod.granted ??
++ (mod.capabilities ? [] : ['players.read', 'world.emit']),
++ ),
++ }
++ await this.startSlot(candidate)
++ if (!candidate.sandbox)
++ return { ok: false, error: candidate.loadError ?? `failed to start ${mod.id}` }
++ return {
++ ok: true,
++ commit: () => {
++ old.sandbox?.dispose()
++ this.slots[this.slots.indexOf(old)] = candidate
++ },
++ cancel: () => candidate.sandbox?.dispose(),
++ }
++ }
++
++ async add(
++ mod: ScriptMod,
++ grants?: readonly ScriptCapability[],
++ ): Promise<{ ok: true } | { ok: false; error: string }> {
++ if (this.slots.some((s) => s.mod.id === mod.id))
++ return { ok: false, error: `mod ${mod.id} already runs` }
++ const slot: ModSlot = {
++ mod,
++ budget: resolveBudget(mod),
++ hooks: new Set(mod.hooks),
++ sandbox: null,
++ state: 'running',
++ reason: null,
++ spentMs: 0,
++ logsThisTick: 0,
++ strikes: 0,
++ calls: 0,
++ failures: { cpu: 0, memory: 0, stack: 0, error: 0 },
++ intentsApplied: 0,
++ intentsRejected: 0,
++ grants: new Set(
++ grants ?? mod.granted ?? (mod.capabilities ? [] : ['players.read', 'world.emit']),
++ ),
++ storage: new Map(Object.entries((await this.options.storage?.load(mod.id)) ?? {})),
++ randomState: seedRandom(this.options.placeSeed ?? 'scraplandia', mod.id),
++ loadError: null,
}
++ await this.startSlot(slot)
++ if (!slot.sandbox) return { ok: false, error: slot.loadError ?? `failed to start ${mod.id}` }
++ this.slots.push(slot)
++ return { ok: true }
++ }
++
++ has(id: string): boolean {
++ return this.slots.some((slot) => slot.mod.id === id)
++ }
++
++ remove(id: string): void {
++ const at = this.slots.findIndex((slot) => slot.mod.id === id)
++ if (at < 0) return
++ this.slots[at]?.sandbox?.dispose()
++ this.slots.splice(at, 1)
++ }
++
++ flushWrites(): Promise<void> {
++ return this.options.storage?.flushWrites() ?? Promise.resolve()
}
tick(tick: number, dtSeconds: number): void {
@@@ -322,13 +475,33 @@ export class ScriptModHost {
/** The functions behind `game`; each knows its mod, so no mod can act as another. */
private hostFunctions(slot: ModSlot): SandboxHostFunctions {
-- return {
-- players: () => (this.world?.players() ?? []) as unknown as Json,
-- player: (id) => {
-- if (typeof id !== 'string') throw new Error('game.player(id): id must be a string')
-- return (this.world?.player(id) ?? null) as unknown as Json
++ const host: Record<string, (...args: Json[]) => Json | undefined> = {
++ random: () => {
++ let x = slot.randomState
++ x ^= x << 13
++ x ^= x >>> 17
++ x ^= x << 5
++ slot.randomState = x >>> 0
++ return slot.randomState / 0x1_0000_0000
},
-- emit: (raw) => {
++ log: (...values) => {
++ if (slot.logsThisTick >= MAX_LOGS_PER_TICK) return undefined
++ slot.logsThisTick++
++ const text = values.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
++ this.log.info('script mod log', { mod: slot.mod.id, text: text.slice(0, 500) })
++ return undefined
++ },
++ }
++ if (slot.grants.has('players.read'))
++ Object.assign(host, {
++ players: () => (this.world?.players() ?? []) as unknown as Json,
++ player: (id: Json) => {
++ if (typeof id !== 'string') throw new Error('game.player(id): id must be a string')
++ return (this.world?.player(id) ?? null) as unknown as Json
++ },
++ })
++ if (slot.grants.has('world.emit'))
++ host.emit = (raw) => {
const call = this.current
if (!call || call.slot !== slot)
throw new Error('game.emit is only available inside a hook')
@@@ -336,16 +509,70 @@ export class ScriptModHost {
throw new Error(`at most ${MAX_INTENTS_PER_CALL} intents per call`)
const intent = parseIntent(raw ?? null)
if (typeof intent === 'string') throw new Error(intent)
++ if (intent.kind === 'message') throw new Error('message requires game.message')
call.intents.push(intent)
return undefined
-- },
-- log: (...values) => {
-- if (slot.logsThisTick >= MAX_LOGS_PER_TICK) return undefined
-- slot.logsThisTick++
-- const text = values.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(' ')
-- this.log.info('script mod log', { mod: slot.mod.id, text: text.slice(0, 500) })
++ }
++ if (slot.grants.has('player.message'))
++ host.message = (playerId, text) => {
++ const call = this.current
++ if (!call || call.slot !== slot)
++ throw new Error('game.message is only available inside a hook')
++ if (call.intents.length >= MAX_INTENTS_PER_CALL)
++ throw new Error(`at most ${MAX_INTENTS_PER_CALL} intents per call`)
++ const intent = parseIntent({
++ kind: 'message',
++ playerId: playerId ?? null,
++ text: text ?? null,
++ })
++ if (typeof intent === 'string') throw new Error(intent)
++ call.intents.push(intent)
return undefined
-- },
++ }
++ if (slot.grants.has('storage.kv')) {
++ const checkedKey = (key: Json | undefined): string => {
++ if (typeof key !== 'string' || new TextEncoder().encode(key).length > STORAGE_MAX_KEY_BYTES)
++ throw new Error('storage key must be at most 128 UTF-8 bytes')
++ return key
++ }
++ host['storage.get'] = (key) => slot.storage.get(checkedKey(key)) ?? null
++ host['storage.keys'] = (prefix) => {
++ if (prefix !== undefined && typeof prefix !== 'string')
++ throw new Error('storage prefix must be a string')
++ return [...slot.storage.keys()].filter((k) => k.startsWith(prefix ?? '')).sort()
++ }
++ host['storage.set'] = (key, value) => {
++ if (this.current?.slot !== slot)
++ throw new Error('storage writes are only available inside a hook')
++ const k = checkedKey(key)
++ if (value === undefined) throw new Error('storage value must be JSON')
++ const bytes = new TextEncoder().encode(JSON.stringify(value)).length
++ if (bytes > STORAGE_MAX_VALUE_BYTES) throw new Error('storage value exceeds 8 KiB')
++ if (!slot.storage.has(k) && slot.storage.size >= STORAGE_MAX_KEYS)
++ throw new Error('storage exceeds 1000 keys')
++ const total = [...slot.storage].reduce(
++ (n, [name, item]) =>
++ n +
++ (name === k
++ ? 0
++ : new TextEncoder().encode(name).length +
++ new TextEncoder().encode(JSON.stringify(item)).length),
++ bytes + new TextEncoder().encode(k).length,
++ )
++ if (total > STORAGE_MAX_BYTES) throw new Error('storage exceeds 1 MiB')
++ slot.storage.set(k, value)
++ this.options.storage?.set(slot.mod.id, k, value)
++ return undefined
++ }
++ host['storage.delete'] = (key) => {
++ if (this.current?.slot !== slot)
++ throw new Error('storage writes are only available inside a hook')
++ const k = checkedKey(key)
++ const existed = slot.storage.delete(k)
++ if (existed) this.options.storage?.delete(slot.mod.id, k)
++ return existed
++ }
}
++ return host
}
}
ddiff --git a/apps/server/src/mods/script/quickjs.test.ts b/apps/server/src/mods/script/quickjs.test.ts
iindex 83cc2f3..c47b3cb 100644
---- a/apps/server/src/mods/script/quickjs.test.ts
++++ b/apps/server/src/mods/script/quickjs.test.ts
@@@ -7,8 +7,10 @@ import { performance } from 'node:perf_hooks'
import { describe, expect, it } from 'vitest'
import type { ScriptMod } from '@openvibe/content'
import type { Logger } from '@openvibe/shared'
++import { openTestStore } from '@openvibe/persistence/testing'
import { ScriptModHost, type ScriptIntent, type ScriptPlayer, type ScriptWorld } from './host.js'
import { createQuickJsSandboxFactory } from './quickjs.js'
++import { PgScriptStorage } from './storage.js'
import type { Json, ModSandbox, SandboxHostFunctions, SandboxSpec } from './sandbox.js'
const factory = createQuickJsSandboxFactory()
@@@ -216,6 +218,120 @@ describe('ScriptModHost on QuickJS', () => {
...extra,
})
++ it('omits ungranted bindings and replaces Math.random with seeded game.random', async () => {
++ const answers: Json[] = []
++ const make = (seed: string) =>
++ new ScriptModHost(
++ [
++ scriptMod(
++ 'caps',
++ `function onTick() {
++ game.log(typeof game.players, typeof game.emit, typeof game.storage,
++ game.random(), Math.random());
++ }`,
++ { capabilities: ['players.read', 'world.emit', 'storage.kv'] },
++ ),
++ ],
++ factory,
++ {
++ ...quiet,
++ info: (_message, fields) => {
++ if (fields && typeof fields === 'object' && 'text' in fields)
++ answers.push(String(fields.text))
++ },
++ },
++ undefined,
++ { placeSeed: seed, grants: { caps: ['players.read'] } },
++ )
++ const a = make('place-a')
++ await a.start()
++ a.tick(1, 1 / 30)
++ a.dispose()
++ const b = make('place-a')
++ await b.start()
++ b.tick(1, 1 / 30)
++ b.dispose()
++ const c = make('place-b')
++ await c.start()
++ c.tick(1, 1 / 30)
++ c.dispose()
++ expect(answers[0]).toBe(answers[1])
++ expect(answers[2]).not.toBe(answers[0])
++ expect(String(answers[0])).toMatch(/^function undefined undefined /)
++ const numbers = String(answers[0]).split(' ').slice(-2)
++ expect(numbers[0]).not.toBe(numbers[1])
++ })
++
++ it('loads a relative module import and calls an exported hook', async () => {
++ const seen: Json[] = []
++ const made = await factory.create({
++ id: 'modules',
++ filename: 'main.js',
++ source: 'import { value } from "./lib.js"; export function onTick() { game.report(value) }',
++ files: {
++ 'main.js':
++ 'import { value } from "./lib.js"; export function onTick() { game.report(value) }',
++ 'lib.js': 'export const value = 17',
++ },
++ hooks: ['onTick'],
++ host: {
++ report: (value) => {
++ seen.push(value ?? null)
++ return undefined
++ },
++ },
++ limits: { memoryBytes: 8 * MB, stackBytes: 128 * 1024 },
++ loadCpuMs: 200,
++ })
++ expect(made.ok).toBe(true)
++ if (!made.ok) return
++ expect(made.sandbox.call('onTick', null, 100)).toEqual({ ok: true })
++ expect(seen).toEqual([17])
++ made.sandbox.dispose()
++ })
++
++ it('keeps storage through reload and a new host on the same database; failed reload keeps old code', async () => {
++ const store = await openTestStore()
++ const storage = new PgScriptStorage(store.mods, store.placeId, quiet)
++ const code = (step: number) => `function onTick() {
++ game.storage.set('count', (game.storage.get('count') || 0) + ${step});
++ }`
++ const make = (source: string, backing = storage) =>
++ new ScriptModHost(
++ [scriptMod('persist', source, { capabilities: ['storage.kv'] })],
++ factory,
++ quiet,
++ undefined,
++ { placeSeed: 'place', grants: { persist: ['storage.kv'] }, storage: backing },
++ )
++ const host = make(code(1))
++ await host.start()
++ host.tick(1, 1 / 30)
++ const failed = await host.reload(
++ scriptMod('persist', 'function onTick( {', { capabilities: ['storage.kv'] }),
++ ['storage.kv'],
++ )
++ expect(failed.ok).toBe(false)
++ host.tick(2, 1 / 30)
++ expect(
++ (
++ await host.reload(scriptMod('persist', code(3), { capabilities: ['storage.kv'] }), [
++ 'storage.kv',
++ ])
++ ).ok,
++ ).toBe(true)
++ host.tick(3, 1 / 30)
++ await host.flushWrites()
++ host.dispose()
++ expect(await store.mods.storage('persist', store.placeId)).toEqual({ count: 5 })
++ const restarted = make(code(1), new PgScriptStorage(store.mods, store.placeId, quiet))
++ await restarted.start()
++ restarted.tick(1, 1 / 30)
++ await restarted.flushWrites()
++ restarted.dispose()
++ expect(await store.mods.storage('persist', store.placeId)).toEqual({ count: 6 })
++ })
++
function world(): ScriptWorld & { applied: ScriptIntent[]; ana: ScriptPlayer } {
const ana: ScriptPlayer = { id: 'p1', name: 'Ana', pos: [1, 2, 3], yaw: 0 }
const applied: ScriptIntent[] = []
ddiff --git a/apps/server/src/mods/script/quickjs.ts b/apps/server/src/mods/script/quickjs.ts
iindex d4c0dc0..3af17f2 100644
---- a/apps/server/src/mods/script/quickjs.ts
++++ b/apps/server/src/mods/script/quickjs.ts
@@@ -24,6 +24,7 @@
* instance is treated as dead.
*/
import { performance } from 'node:perf_hooks'
++import path from 'node:path'
import {
newQuickJSWASMModule,
newVariant,
@@@ -69,14 +70,18 @@ const PRELUDE = `(function (bridge, namesJson) {
const names = parse(namesJson);
for (let i = 0; i < names.length; i++) {
const name = names[i];
-- game[name] = function () {
++ const target = name.startsWith('storage.') ? (game.storage || (game.storage = {})) : game;
++ const member = name.startsWith('storage.') ? name.slice(8) : name;
++ target[member] = function () {
const args = [];
for (let k = 0; k < arguments.length; k++) args[k] = arguments[k];
const out = bridge(name, stringify(args));
return out === undefined ? undefined : parse(out);
};
-- freeze(game[name]);
++ freeze(target[member]);
}
++ if (game.storage) freeze(game.storage);
++ if (game.random) Object.defineProperty(Math, 'random', { value: game.random, writable: false, configurable: false });
freeze(game);
Object.defineProperty(globalThis, 'game', { value: game, writable: false, enumerable: false, configurable: false });
return function dispatch(hook, payload) {
@@@ -204,8 +209,37 @@ class QuickJsSandbox implements ModSandbox {
names.dispose()
if (made.error) return made.error
this.dispatch = made.value
-- const evaluated = ctx.evalCode(spec.source, spec.filename)
++ if (spec.files) {
++ this.runtime.setModuleLoader(
++ (name) => {
++ const source = spec.files?.[name]
++ if (source === undefined) throw new Error('missing mod module: ' + name)
++ return source
++ },
++ (base, requested) => {
++ if (!requested.startsWith('./') && !requested.startsWith('../'))
++ throw new Error('only relative mod imports are allowed: ' + requested)
++ const name = path.posix.normalize(path.posix.join(path.posix.dirname(base), requested))
++ if (name.startsWith('../') || name.startsWith('/') || name === '..')
++ throw new Error('mod import leaves pack: ' + requested)
++ if (spec.files?.[name] === undefined) throw new Error('missing mod module: ' + name)
++ return name
++ },
++ )
++ }
++ const evaluated = ctx.evalCode(
++ spec.source,
++ spec.filename,
++ spec.files ? { type: 'module' } : { type: 'global' },
++ )
if (evaluated.error) return evaluated.error
++ if (spec.files) {
++ for (const hook of spec.hooks ?? []) {
++ const fn = ctx.getProp(evaluated.value, hook)
++ ctx.setProp(ctx.global, hook, fn)
++ fn.dispose()
++ }
++ }
evaluated.value.dispose()
return null
})
ddiff --git a/apps/server/src/mods/script/sandbox.ts b/apps/server/src/mods/script/sandbox.ts
iindex 2f299bd..d0f54ae 100644
---- a/apps/server/src/mods/script/sandbox.ts
++++ b/apps/server/src/mods/script/sandbox.ts
@@@ -9,8 +9,8 @@
* - only JSON crosses the boundary, in both directions: a host function gets
* JSON arguments and returns JSON, a hook gets a JSON payload. No host
* object, function or prototype is ever reachable from the script;
-- * - nothing but the lent functions: no require/import, fs, net, process,
-- * timers or clock beyond what the engine's language intrinsics provide;
++ * - nothing but the lent functions: no require, fs, net, process or timers;
++ * module imports, when enabled, resolve only inside the mod's pack;
* - the host functions appear in the script as the frozen global `game`;
* - limits are enforced by the engine, not by trusting the script: `cpuMs`
* per call (wall clock on the single-threaded tick), a heap of
@@@ -59,6 +59,9 @@ export interface SandboxSpec {
/** File name shown in the script's stack traces. */
filename: string
source: string
++ /** Pack-contained ES modules; absent for legacy global scripts. */
++ files?: Readonly<Record<string, string>>
++ hooks?: readonly string[]
limits: SandboxLimits
/** CPU budget for evaluating the source once (top-level code). */
loadCpuMs: number
ddiff --git a/apps/server/src/mods/script/storage.ts b/apps/server/src/mods/script/storage.ts
nnew file mode 100644
iindex 0000000..08db747
---- /dev/null
++++ b/apps/server/src/mods/script/storage.ts
@@@ -0,0 +1,45 @@
++import type { ModRepository } from '@openvibe/persistence'
++import type { Logger } from '@openvibe/shared'
++import type { Json } from './sandbox.js'
++import type { ScriptStorage } from './host.js'
++
++/** Serialized write-behind for script state. Reads load once before any sandbox starts. */
++export class PgScriptStorage implements ScriptStorage {
++ private writes: Promise<void> = Promise.resolve()
++
++ constructor(
++ private readonly mods: ModRepository,
++ private readonly placeId: string,
++ private readonly log: Logger,
++ ) {}
++
++ async load(modId: string): Promise<Record<string, Json>> {
++ return (await this.mods.storage(modId, this.placeId)) as Record<string, Json>
++ }
++
++ set(modId: string, key: string, value: Json): void {
++ this.enqueue(() => this.mods.setStorage(modId, this.placeId, key, value))
++ }
++
++ delete(modId: string, key: string): void {
++ this.enqueue(() => this.mods.deleteStorage(modId, this.placeId, key))
++ }
++
++ async flushWrites(): Promise<void> {
++ await this.writes
++ }
++
++ private enqueue(write: () => Promise<void>): void {
++ this.writes = this.writes.then(async () => {
++ for (let attempt = 0; attempt < 3; attempt++) {
++ try {
++ await write()
++ return
++ } catch (error) {
++ if (attempt === 2) this.log.error('script storage write failed', { error: String(error) })
++ else await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)))
++ }
++ }
++ })
++ }
++}
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex 468d983..ae08b14 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -120,8 +120,9 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
- **M2.2 def-set handshake — done.** Client and server exchange the versioned definition digest in `hello` and `welcome`; a mismatch refuses the join before player state is created.
- **M2.3 Scraplandia as a pack — default map done.** The base loads Scraplandia's blank-slate map through the built-in `games-content@2` pack. The released spawn, zone and empty seeded entity set are unchanged. Moving gameplay modules behind pack enablement remains future work.
- **M2.4 touch controls — done.** `TouchInputSource` (`apps/client/src/input/touchInputSource.ts`) produces the same `InputSample` stream as the keyboard: an analog left stick (radial dead zone 0.15, length clamped to 1), right-half look drag with InputTracker's pitch clamp, and on-screen jump, sprint (tap latches, press holds), crouch and prone buttons on the existing `Intents` bits — no protocol change. Use, fire and menu buttons emit the same `InputAction`s as E, the left mouse button and Tab. Gesture state is DOM-free and unit-tested; `touchControls.ts` is the pointer-event overlay. The client starts on touch for a coarse pointer, switches to whichever device was used last, and never pointer-locks for touch.
--- **M3: scripted mods.** QuickJS runtime, capability API v2, budgets enforced, hot reload, escape tests.
-- - **M3.1 runtime, budgets, escape tests — done (docs/mods.md).** `games-quickjs@1` manifests ride in packs and in the def-set digest; `apps/server/src/mods/script` runs each mod in its own `quickjs-emscripten` WASM instance behind the small adapter in `sandbox.ts` (the one OpenVibe.Run implements later); the host API is `game.players/player/emit/log`, changes arrive only as validated intents applied after a completed call; `cpuMs`/`memoryMb`/`stackKb` are enforced by the interrupt handler, a per-mod capped `WebAssembly.Memory` (QuickJS's malloc limit cannot count bytes under emscripten) and the max stack, and a breach disables the mod for the instance. Off unless the place sets `GAMES_SCRIPT_MODS=1`. Open: capability API v2, seeded randomness, hot reload, installing script mods through the mods API.
++- **M3: scripted mods — code done.** QuickJS runtime, per-binding grants, storage, seeded randomness, budgets, hot reload, multi-file imports, staff API installs, escape tests and current definition delivery to fresh browser clients. Production grant approval still requires Network to register the proposed `games.*` capability definitions; this repository does not publish them.
++ - **M3.1 runtime, budgets, escape tests — done (docs/mods.md).** One QuickJS WASM instance per mod behind `sandbox.ts`; validated intents, CPU/memory/stack enforcement and escape tests. Off unless `GAMES_SCRIPT_MODS=1`.
++ - **M3.2 script capabilities and lifecycle — done.** Optional requested bindings on `games-quickjs@1`, approved grants pinned in the digest, per-mod/place PostgreSQL storage, deterministic random, contained ES modules, staff install/reload with audit and announcement, and a public validated pack snapshot fetched before browser bootstrap.
- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
ddiff --git a/docs/capabilities-proposal/games.mod.manage.json b/docs/capabilities-proposal/games.mod.manage.json
iindex 00e3193..a679d1d 100644
---- a/docs/capabilities-proposal/games.mod.manage.json
++++ b/docs/capabilities-proposal/games.mod.manage.json
@@@ -4,10 +4,14 @@
"owner": "games",
"status": "active",
"visibility": "first-party",
-- "description": "Manage mod installs in the browser game's registry (ADR-013): install a mods/mod-manifest.v1 manifest with its games-content@1 pack, approve or revoke individual capabilities of an install, enable, disable, and revoke an install for good (its effects end on the next server tick). Every action is audited and published as a games.mod.* event. Trust tiers are metadata and never grant anything. Also accepted from an openvibe.network owner/admin session.",
-- "inputSchema": "mods.mod-manifest@1",
-- "permissions": ["games:mods:admin"],
-- "resourceConstraints": ["none"],
++ "description": "Manage mod installs in the browser game's registry (ADR-013): install a mods/mod-manifest.v1 manifest with its games-content@1 pack, approve or revoke individual capabilities of an install, enable, disable, and revoke an install for good (its effects end on the next server tick). Every action is audited and published as a games.mod.* event. Trust tiers are metadata and never grant anything. Also accepted from an openvibe.network owner/admin session. Reload replaces a running script mod's code (games-quickjs@1) and keeps its storage; a reload that fails to start keeps the previous version.",
++ "inputSchema": "games.mod-manage-request@1",
++ "permissions": [
++ "games:mods:admin"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
"quotaClass": "games-admin",
"events": [
"games.mod.installed",
@@@ -20,9 +24,11 @@
"POST /api/v1/mods",
"POST /api/v1/mods/:id/enable",
"POST /api/v1/mods/:id/disable",
++ "POST /api/v1/mods/:id/reload",
"POST /api/v1/mods/:id/revoke",
"POST /api/v1/mods/:id/grants",
"DELETE /api/v1/mods/:id/grants/:capability",
"GET /api/v1/mods/:id/audit"
-- ]
++ ],
++ "outputSchema": "games.mod-manage-result@1"
}
ddiff --git a/docs/capabilities-proposal/games.player.message.json b/docs/capabilities-proposal/games.player.message.json
nnew file mode 100644
iindex 0000000..3cd8110
---- /dev/null
++++ b/docs/capabilities-proposal/games.player.message.json
@@@ -0,0 +1,21 @@
++{
++ "id": "games.player.message",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Script binding for mods (games-quickjs@1): game.message, a validated private announcement to one player in the mod's current place, prefixed with the mod's name. Absent unless granted.",
++ "permissions": [
++ "games:player:message"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": [
++ "mod runtime binding game.message (apps/server/src/mods/script/host.ts)"
++ ],
++ "inputSchema": "games.script-message@1",
++ "outputSchema": "common.no-body@1"
++}
ddiff --git a/docs/capabilities-proposal/games.players.read.json b/docs/capabilities-proposal/games.players.read.json
nnew file mode 100644
iindex 0000000..5b3bb16
---- /dev/null
++++ b/docs/capabilities-proposal/games.players.read.json
@@@ -0,0 +1,21 @@
++{
++ "id": "games.players.read",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Script binding for mods (games-quickjs@1): game.players and game.player, read-only views of the players in the mod's current place. Absent from the script's game object unless the install was granted it; checked against the install's approved capabilities, which travel in the def-set digest.",
++ "permissions": [
++ "games:players:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": [
++ "mod runtime binding game.players / game.player (apps/server/src/mods/script/host.ts)"
++ ],
++ "inputSchema": "common.no-body@1",
++ "outputSchema": "games.script-players@1"
++}
ddiff --git a/docs/capabilities-proposal/games.script.run.json b/docs/capabilities-proposal/games.script.run.json
nnew file mode 100644
iindex 0000000..fb8c203
---- /dev/null
++++ b/docs/capabilities-proposal/games.script.run.json
@@@ -0,0 +1,23 @@
++{
++ "id": "games.script.run",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Run a staff-installed script mod (games-quickjs@1, one or more module files in its content pack, imports resolved inside the pack only) in its own QuickJS sandbox per place, under the pack's CPU and memory budgets; a staff hot reload replaces the code and keeps its storage, and a reload that fails to start keeps the previous version running. The script sees only the bindings its install was granted.",
++ "permissions": [
++ "games:script:run"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": [
++ "mod runtime: games-quickjs@1 sandbox (apps/server/src/mods/script/host.ts, quickjs.ts)",
++ "POST /api/mods (install, staff)",
++ "POST /api/mods/:id/reload (staff)"
++ ],
++ "inputSchema": "games.script-call@1",
++ "outputSchema": "games.script-call-result@1"
++}
ddiff --git a/docs/capabilities-proposal/games.storage.kv.json b/docs/capabilities-proposal/games.storage.kv.json
nnew file mode 100644
iindex 0000000..122c6ee
---- /dev/null
++++ b/docs/capabilities-proposal/games.storage.kv.json
@@@ -0,0 +1,21 @@
++{
++ "id": "games.storage.kv",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Script binding for mods (games-quickjs@1): game.storage get/set/delete/keys, key/value storage per mod per place on the Games database: keys up to 128 bytes, values up to 8 KB of JSON, at most 1000 keys and 1 MB per mod per place, writes counted against the mod's CPU budget; a refusal is an error inside the mod. Absent unless granted.",
++ "permissions": [
++ "games:storage:kv"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": [
++ "mod runtime binding game.storage (apps/server/src/mods/script/host.ts, storage.ts)"
++ ],
++ "inputSchema": "games.script-storage-request@1",
++ "outputSchema": "games.script-storage-result@1"
++}
ddiff --git a/docs/capabilities-proposal/games.world.emit.json b/docs/capabilities-proposal/games.world.emit.json
nnew file mode 100644
iindex 0000000..f082638
---- /dev/null
++++ b/docs/capabilities-proposal/games.world.emit.json
@@@ -0,0 +1,21 @@
++{
++ "id": "games.world.emit",
++ "version": "1.0",
++ "owner": "games",
++ "status": "active",
++ "visibility": "public",
++ "description": "Script binding for mods (games-quickjs@1): game.emit, which queues world intents that the server validates like any player intent before applying them; a mod never mutates state directly. Absent unless granted.",
++ "permissions": [
++ "games:world:emit"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "games-mod-runtime",
++ "events": [],
++ "implementedBy": [
++ "mod runtime binding game.emit (apps/server/src/mods/script/host.ts)"
++ ],
++ "inputSchema": "games.script-intent@1",
++ "outputSchema": "common.no-body@1"
++}
ddiff --git a/docs/mods.md b/docs/mods.md
iindex 4767ae0..6e53263 100644
---- a/docs/mods.md
++++ b/docs/mods.md
@@@ -16,18 +16,22 @@ A pack carries script mods in `mods: [...]`. Each entry is a manifest and its
source together (`packages/content/src/schema/scriptMod.ts`, strict, so an
unknown field is refused):
--| field | rule |
--| --------- | ------------------------------------------------------------------------ |
--| `format` | `"games-quickjs@1"` |
--| `id` | `^[a-z0-9][a-z0-9_-]{0,39}$`, unique across all packs (never overridden) |
--| `version` | `major.minor.patch` |
--| `entry` | the script's file name (`*.js`), shown in stack traces |
--| `source` | the script, at most 64 Ki characters |
--| `hooks` | one or more of `onTick`, `onPlayerJoin`, `onPlayerLeave` |
--| `budgets` | optional `{ cpuMs, memoryMb, stackKb }`, each up to its ceiling |
--
--A pack may carry up to 8 mods. v1 mods are a single script: there is no module
--loader, so `import` and `require` do not exist.
++| field | rule |
++| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
++| `format` | `"games-quickjs@1"` |
++| `id` | `^[a-z0-9][a-z0-9_-]{0,39}$`, unique across all packs (never overridden) |
++| `version` | `major.minor.patch` |
++| `entry` | the entry file (`*.js`, optionally in a pack subdirectory) |
++| `source` | legacy single-file script, at most 64 Ki characters |
++| `files` | optional map of pack-relative `.js` paths to module source, at most 32 files and 64 KiB total including paths; exclusive with `source` |
++| `capabilities` | optional requested bindings; absent on old manifests keeps the original `players`, `player`, `emit` API |
++| `hooks` | one or more of `onTick`, `onPlayerJoin`, `onPlayerLeave` |
++| `budgets` | optional `{ cpuMs, memoryMb, stackKb }`, each up to its ceiling |
++
++A pack may carry up to 8 mods. A `files` mod uses ES modules and exports its
++declared hooks. Static imports must be relative, resolve inside `files`, and
++cannot leave the pack. `require`, Node built-ins and network imports remain
++unavailable. The same 64 KiB source budget covers the whole mod.
**Pinned by the digest.** Manifests and sources are merged into `ContentDefs.mods`
and hashed by `contentDigest` with every other definition. A client whose copy of
@@@ -35,9 +39,22 @@ a mod differs by one byte (or that lacks it) is refused with `content_mismatch`
before any player state is created. A def set without mods keeps the digest it
had before mods existed.
--**Where mods come from.** Packs passed to `createContent` (the built-in packs in
--`packages/content/src/packs`). The mods API still refuses executable installs: a
--script mod ships with the code of the place, reviewed like code.
++**Where mods come from.** Built-in packs passed to `createContent` and
++staff-installed packs through `POST /api/v1/mods`. An API install uses the
++Contracts manifest with runtime `games-quickjs@1`, requests `games.script.run`
++and the qualified capabilities below, and receives only its approved subset.
++The install and its grants are audited. The staff-only `POST
++/api/v1/mods/:id/reload` takes a new `{ manifest, pack }` with the same install
++and script ids. It validates and starts the replacement before disposing the old
++sandbox; a load error leaves the old code running. Storage survives reload.
++New capability requests require a new install, because Network's mod principal
++keeps the original requested set.
++The five new `games.*` capability definitions are proposed under
++`docs/capabilities-proposal/`; Network must register them before it can approve
++script grants in a deployed place.
++Connected clients receive an announcement, and the changed definition digest
++is checked on later joins. A fresh browser fetches `GET /api/v1/content` before
++building its registry, so it uses the current validated packs and digest.
**Off unless the place enables it.** `GAMES_SCRIPT_MODS=1` (or `true`) runs the
def set's script mods on this instance. Without it the engine is never loaded
@@@ -70,12 +87,47 @@ The only thing the host adds to a fresh QuickJS context is the frozen global
`game`. Everything else is the language's own intrinsics: no `require`,
`process`, `fs`, network, timers, `console` or QuickJS `std`/`os`.
--| call | does |
--| --------------------- | ---------------------------------------------------------------- |
--| `game.players()` | every player: `[{ id, name, pos: [x, y, z], yaw }]` (a copy) |
--| `game.player(id)` | one player, or `null` |
--| `game.emit(intent)` | queues an intent; a malformed one throws inside the script |
--| `game.log(...values)` | one `script mod log` line in the server log (4 per tick at most) |
++| call | does |
++| ------------------------------ | -------------------------------------------------------------------------------------------------- |
++| `game.players()` | every player: `[{ id, name, pos: [x, y, z], yaw }]` (a copy) |
++| `game.player(id)` | one player, or `null` |
++| `game.emit(intent)` | queues an intent; a malformed one throws inside the script |
++| `game.log(...values)` | one `script mod log` line in the server log (4 per tick at most) |
++| `game.random()` | deterministic value in `[0, 1)` from the place id and mod id; `Math.random()` uses the same stream |
++| `game.message(playerId, text)` | queue a private announcement to one player (requires `player.message`) |
++| `game.storage.get(key)` | read JSON, or `null` if absent |
++| `game.storage.set(key, value)` | queue a persisted JSON value; writes are allowed during hooks |
++| `game.storage.delete(key)` | remove a key, returning whether it existed |
++| `game.storage.keys(prefix?)` | sorted keys with an optional prefix |
++
++`game.players` and `game.player` require `players.read`; `game.emit` requires
++`world.emit`; `game.message` requires `player.message`; the storage object
++requires `storage.kv`. A binding without its grant is absent from the frozen
++`game` object. `game.log` and `game.random` are always available. The Contracts
++install manifest uses `games.players.read`, `games.world.emit`,
++`games.player.message`, and `games.storage.kv` for these script requests.
++For example:
++
++```json
++{
++ "format": "games-quickjs@1",
++ "id": "greeter",
++ "version": "1.0.0",
++ "entry": "main.js",
++ "files": {
++ "main.js": "import { greeting } from './words.js'; export function onPlayerJoin(e) { game.message(e.player.id, greeting) }",
++ "words.js": "export const greeting = 'Welcome!'"
++ },
++ "hooks": ["onPlayerJoin"],
++ "capabilities": ["player.message"]
++}
++```
++
++Storage is isolated by place and mod in PostgreSQL. Keys are at most 128 UTF-8
++bytes, JSON values at most 8 KiB, and each mod in a place may use at most 1000
++keys and 1 MiB for keys and JSON values together. A refusal throws inside the
++script. Writes use a serialized write-behind queue, drained on shutdown; host
++call time counts toward the mod's CPU deadline.
Intents (at most 16 per call):
@@@ -146,8 +198,7 @@ same interface later (ADR-036) without the host changing. QuickJS
(`quickjs-emscripten`, exact version pinned in `apps/server/package.json`) is the
first implementation.
--### Not yet
++### Persistence note
--Capability API v2 (per-mod grants for script bindings, key/value storage),
--seeded randomness, hot reload, multi-file mods, and installing script mods
--through the mods API.
++Script storage writes are durable after the write-behind queue drains; a
++database failure is logged and retried twice.
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex 656190e..bf1c3de 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -15,7 +15,12 @@
"games.mod.read",
"games.world.announce",
"games.prop.place",
-- "games.def.define"
++ "games.def.define",
++ "games.script.run",
++ "games.players.read",
++ "games.player.message",
++ "games.world.emit",
++ "games.storage.kv"
],
"eventsProduced": [
"games.player.joined",
@@@ -32,5 +37,5 @@
],
"eventsConsumed": [],
"namespacesOwned": ["games.*"],
-- "notes": "Scraplandia (browser runtime). Players are keyed by canonical Network subjects (ADR-0006 in the repo). Calls Events (events.event.publish), Media (media.object.upload, namespace games: map-editor assets) and Network identity (identity.subject.resolve, batch legacy adoption) with the games client-credentials principal. Mods: games-content@2 declarative packs only (definitions plus placements); executable mods wait for OpenVibe.Host Stage C. No achievement system exists, so there are no games.achievement.* events."
++ "notes": "Scraplandia (browser runtime). Players are keyed by canonical Network subjects (ADR-0006 in the repo). Calls Events (events.event.publish), Media (media.object.upload, namespace games: map-editor assets) and Network identity (identity.subject.resolve) with the games client-credentials principal. Mods: games-content@2 declarative packs and bounded games-quickjs@1 scripts; general executables wait for OpenVibe.Host Stage C. No achievement system exists, so there are no games.achievement.* events."
}
ddiff --git a/packages/content/src/pack.test.ts b/packages/content/src/pack.test.ts
iindex 0ae2350..743fc44 100644
---- a/packages/content/src/pack.test.ts
++++ b/packages/content/src/pack.test.ts
@@@ -9,6 +9,7 @@ import {
JOBS,
MARKETS,
MAX_PACK_DEFS,
++ ScriptModSchema,
NPC_ARCHETYPES,
RECIPES,
RESOURCE_NODES,
@@@ -19,6 +20,69 @@ import {
type ContentPackV2,
} from './index.js'
++describe('script mod definitions', () => {
++ const baseMod = {
++ format: 'games-quickjs@1',
++ id: 'example',
++ version: '1.0.0',
++ entry: 'main.js',
++ hooks: ['onTick'],
++ }
++ it('pins approved grants in the digest', () => {
++ const pack = (granted: string[]) => ({
++ mods: [
++ { ...baseMod, source: 'function onTick() {}', capabilities: ['players.read'], granted },
++ ],
++ })
++ expect(createContent([pack([])]).digest).not.toBe(
++ createContent([pack(['players.read'])]).digest,
++ )
++ const server = createContent([pack(['players.read'])])
++ const snapshot = JSON.parse(
++ JSON.stringify({ digest: server.digest, packs: [{ mods: server.allMods() }] }),
++ ) as {
++ digest: string
++ packs: ContentPackV2[]
++ }
++ expect(createContent(snapshot.packs).digest).toBe(snapshot.digest)
++ })
++ it('accepts a pack-contained module and rejects missing or escaping imports', () => {
++ expect(
++ ScriptModSchema.safeParse({
++ ...baseMod,
++ files: {
++ 'main.js': 'import { x } from "./lib.js"; export function onTick() { return x }',
++ 'lib.js': 'export const x = 1',
++ },
++ }).success,
++ ).toBe(true)
++ expect(
++ ScriptModSchema.safeParse({
++ ...baseMod,
++ files: {
++ 'main.js': 'import "../escape.js"; export function onTick() {}',
++ },
++ }).error?.message,
++ ).toMatch(/leaves pack/)
++ expect(
++ ScriptModSchema.safeParse({
++ ...baseMod,
++ files: {
++ 'main.js': 'import "./missing.js"; export function onTick() {}',
++ },
++ }).error?.message,
++ ).toMatch(/missing module/)
++ expect(
++ ScriptModSchema.safeParse({
++ ...baseMod,
++ files: {
++ 'main.js': 'import("../outside.js"); export function onTick() {}',
++ },
++ }).error?.message,
++ ).toMatch(/dynamic imports are not supported/)
++ })
++})
++
const base: ContentDefs = {
items: ITEMS,
recipes: RECIPES,
ddiff --git a/packages/content/src/registry.ts b/packages/content/src/registry.ts
iindex 63dd6a6..1b94055 100644
---- a/packages/content/src/registry.ts
++++ b/packages/content/src/registry.ts
@@@ -38,7 +38,8 @@ export interface ContentDefs {
*/
export class ContentRegistry {
/** Identity of the exact validated definition set used by this world. */
-- readonly digest: string
++ digest: string
++ private readonly digestDefs: ContentDefs
private readonly items = new Map<string, ItemDef>()
private readonly recipes = new Map<string, Recipe>()
private readonly skills = new Map<string, SkillDef>()
@@@ -52,6 +53,7 @@ export class ContentRegistry {
readonly world: WorldDef
constructor(defs: ContentDefs) {
++ this.digestDefs = defs
this.digest = contentDigest(defs)
const errors: string[] = []
@@@ -355,6 +357,28 @@ export class ContentRegistry {
return [...this.mods.values()]
}
++ /** Changes only the script portion of the handshake after a validated live reload. */
++ replaceScriptMod(mod: ScriptMod): void {
++ if (!this.mods.has(mod.id)) throw new Error(`unknown script mod ${mod.id}`)
++ this.mods.set(mod.id, mod)
++ this.digestDefs.mods = [...this.mods.values()]
++ this.digest = contentDigest(this.digestDefs)
++ }
++
++ addScriptMod(mod: ScriptMod): void {
++ if (this.mods.has(mod.id)) throw new Error(`script mod ${mod.id} already exists`)
++ this.mods.set(mod.id, mod)
++ this.digestDefs.mods = [...this.mods.values()]
++ this.digest = contentDigest(this.digestDefs)
++ }
++
++ removeScriptMod(id: string): void {
++ if (!this.mods.delete(id)) return
++ if (this.mods.size) this.digestDefs.mods = [...this.mods.values()]
++ else delete this.digestDefs.mods
++ this.digest = contentDigest(this.digestDefs)
++ }
++
market(id: string): MarketDef | undefined {
return this.markets.get(id)
}
ddiff --git a/packages/content/src/schema/scriptMod.ts b/packages/content/src/schema/scriptMod.ts
iindex 1db10df..696c6f2 100644
---- a/packages/content/src/schema/scriptMod.ts
++++ b/packages/content/src/schema/scriptMod.ts
@@@ -30,17 +30,61 @@ export const SCRIPT_MOD_BUDGETS = {
/** Longest source a mod may carry, in UTF-16 code units. */
export const MAX_SCRIPT_SOURCE = 64 * 1024
++export const SCRIPT_CAPABILITIES = [
++ 'players.read',
++ 'player.message',
++ 'world.emit',
++ 'storage.kv',
++] as const
++export type ScriptCapability = (typeof SCRIPT_CAPABILITIES)[number]
const slug = /^[a-z0-9][a-z0-9_-]{0,39}$/
++const modulePath = /^(?:[a-z0-9][a-z0-9_-]{0,39}\/)*[a-z0-9][a-z0-9_-]{0,39}\.js$/
++
++/** Checks static imports before install; the runtime loader repeats the containment check. */
++export function moduleImportErrors(files: Record<string, string>): string[] {
++ const errors: string[] = []
++ for (const [file, source] of Object.entries(files)) {
++ if (/\bimport\s*\(/.test(source)) errors.push(`${file}: dynamic imports are not supported`)
++ const refs = source.matchAll(/\b(?:import|export)\s+(?:[^;'"\n]*?\s+from\s+)?['"]([^'"]+)['"]/g)
++ for (const [, ref] of refs) {
++ if (!ref?.startsWith('./') && !ref?.startsWith('../')) {
++ errors.push(`${file}: only relative imports are allowed: ${ref}`)
++ continue
++ }
++ const parts: string[] = []
++ let escaped = false
++ for (const part of `${file.slice(0, file.lastIndexOf('/') + 1)}${ref}`.split('/')) {
++ if (part === '..') {
++ if (parts.length === 0) escaped = true
++ else parts.pop()
++ } else if (part !== '.' && part !== '') parts.push(part)
++ }
++ const resolved = parts.join('/')
++ if (escaped) errors.push(`${file}: import leaves pack: ${ref}`)
++ else if (!(resolved in files)) errors.push(`${file}: missing module: ${resolved}`)
++ }
++ }
++ return errors
++}
export const ScriptModSchema = z
.object({
format: z.literal(SCRIPT_MOD_FORMAT),
id: z.string().regex(slug),
version: z.string().regex(/^\d{1,4}\.\d{1,4}\.\d{1,4}$/),
-- /** The script's file name, used in stack traces; v1 mods are one script (no module loader). */
-- entry: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}\.js$/),
-- source: z.string().min(1).max(MAX_SCRIPT_SOURCE),
++ /** Entry and module paths are pack-relative. Legacy single-source entries remain valid. */
++ entry: z.string().regex(modulePath),
++ source: z.string().min(1).max(MAX_SCRIPT_SOURCE).optional(),
++ files: z.record(z.string()).optional(),
++ /** Absent means the legacy bindings; an explicit list opts in to per-binding grants. */
++ capabilities: z
++ .array(z.enum(SCRIPT_CAPABILITIES))
++ .max(SCRIPT_CAPABILITIES.length)
++ .refine((c) => new Set(c).size === c.length, 'capabilities must be unique')
++ .optional(),
++ /** Server-approved subset, pinned by the definition digest. Populated by the pack loader. */
++ granted: z.array(z.enum(SCRIPT_CAPABILITIES)).optional(),
hooks: z
.array(z.enum(SCRIPT_MOD_HOOKS))
.min(1)
@@@ -57,5 +101,24 @@ export const ScriptModSchema = z
.optional(),
})
.strict()
++ .superRefine((mod, ctx) => {
++ if (!mod.source && !mod.files)
++ ctx.addIssue({ code: 'custom', message: 'source or files is required' })
++ if (mod.files) {
++ if (mod.source) ctx.addIssue({ code: 'custom', message: 'source and files are exclusive' })
++ if (Object.keys(mod.files).length > 32)
++ ctx.addIssue({ code: 'custom', message: 'at most 32 mod files' })
++ if (!(mod.entry in mod.files))
++ ctx.addIssue({ code: 'custom', message: 'entry is missing from files' })
++ const bytes = Object.entries(mod.files).reduce((n, [path, body]) => {
++ if (!modulePath.test(path))
++ ctx.addIssue({ code: 'custom', message: `invalid module path ${path}` })
++ return n + new TextEncoder().encode(path).length + new TextEncoder().encode(body).length
++ }, 0)
++ if (bytes > MAX_SCRIPT_SOURCE)
++ ctx.addIssue({ code: 'custom', message: 'mod files exceed 64 KiB' })
++ for (const message of moduleImportErrors(mod.files)) ctx.addIssue({ code: 'custom', message })
++ }
++ })
export type ScriptMod = z.infer<typeof ScriptModSchema>
ddiff --git a/packages/persistence/migrations/0002_mod_storage.sql b/packages/persistence/migrations/0002_mod_storage.sql
nnew file mode 100644
iindex 0000000..c77963d
---- /dev/null
++++ b/packages/persistence/migrations/0002_mod_storage.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- Script state belongs to a mod in one place. Limits are enforced by the script host.
++CREATE TABLE mod_storage (
++ place_id text COLLATE "C" NOT NULL REFERENCES places(id),
++ mod_id text COLLATE "C" NOT NULL,
++ key text COLLATE "C" NOT NULL,
++ value jsonb NOT NULL,
++ PRIMARY KEY (place_id, mod_id, key)
++);
ddiff --git a/packages/persistence/src/pg/platformRepositories.test.ts b/packages/persistence/src/pg/platformRepositories.test.ts
iindex 23f799b..ffee9cc 100644
---- a/packages/persistence/src/pg/platformRepositories.test.ts
++++ b/packages/persistence/src/pg/platformRepositories.test.ts
@@@ -181,6 +181,18 @@ describe('player subject_id', () => {
})
describe('mod repository', () => {
++ it('keeps script storage separate by mod and place', async () => {
++ const store = await openTestStore()
++ await store.mods.setStorage('alpha', store.placeId, 'score', { n: 1 })
++ await store.mods.setStorage('beta', store.placeId, 'score', { n: 2 })
++ expect(await store.mods.storage('alpha', store.placeId)).toEqual({ score: { n: 1 } })
++ expect(await store.mods.storage('beta', store.placeId)).toEqual({ score: { n: 2 } })
++ await store.db.exec(sql`INSERT INTO places (id, name) VALUES ('other', 'Other')`)
++ await store.mods.setStorage('alpha', 'other', 'score', { n: 3 })
++ expect(await store.mods.storage('alpha', 'other')).toEqual({ score: { n: 3 } })
++ await store.mods.deleteStorage('alpha', store.placeId, 'score')
++ expect(await store.mods.storage('alpha', store.placeId)).toEqual({})
++ })
it('stores installs, grants, placements and an append-only audit log', async () => {
const store = await openTestStore()
const id = 'mod_01JABCDEFGHJKMNPQRSTVWXYZ0'
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex cf6b47d..202be1a 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -243,6 +243,12 @@ export function createModRepository(db: Db): ModRepository {
${mod.installedBy}, ${mod.installedAt}, ${mod.updatedAt})
`)
},
++ async replace(mod) {
++ await db.exec(sql`
++ UPDATE mods SET name = ${mod.name}, version = ${mod.version}, manifest = ${sql.json(mod.manifest)},
++ pack = ${sql.json(mod.pack)}, updated_at = ${mod.updatedAt} WHERE id = ${mod.id}
++ `)
++ },
async setStatus(id, status, at) {
await db.exec(sql`UPDATE mods SET status = ${status}, updated_at = ${at} WHERE id = ${id}`)
},
@@@ -312,6 +318,24 @@ export function createModRepository(db: Db): ModRepository {
sql`DELETE FROM mod_placements WHERE mod_id = ${modId} AND placement_key = ${key}`,
)
},
++ async storage(modId, placeId) {
++ const rows = await db.many<{ key: string; value: unknown }>(sql`
++ SELECT key, value FROM mod_storage WHERE place_id = ${placeId} AND mod_id = ${modId} ORDER BY key
++ `)
++ return Object.fromEntries(rows.map((r) => [r.key, r.value]))
++ },
++ async setStorage(modId, placeId, key, value) {
++ await db.exec(sql`
++ INSERT INTO mod_storage (place_id, mod_id, key, value)
++ VALUES (${placeId}, ${modId}, ${key}, ${sql.json(value)})
++ ON CONFLICT (place_id, mod_id, key) DO UPDATE SET value = excluded.value
++ `)
++ },
++ async deleteStorage(modId, placeId, key) {
++ await db.exec(
++ sql`DELETE FROM mod_storage WHERE place_id = ${placeId} AND mod_id = ${modId} AND key = ${key}`,
++ )
++ },
}
}
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex 52d17d4..f35ff9e 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -102,6 +102,7 @@ export interface ModRepository {
list(): Promise<ModInstallDto[]>
get(id: string): Promise<ModInstallDto | null>
insert(mod: ModInstallDto): Promise<void>
++ replace(mod: ModInstallDto): Promise<void>
setStatus(id: string, status: ModStatus, at: number): Promise<void>
grants(modId: string): Promise<ModGrantDto[]>
/** Grants (or re-grants) a capability. */
@@@ -113,6 +114,9 @@ export interface ModRepository {
placements(modId: string): Promise<ModPlacementDto[]>
setPlacement(placement: ModPlacementDto): Promise<void>
deletePlacement(modId: string, key: string): Promise<void>
++ storage(modId: string, placeId: string): Promise<Record<string, unknown>>
++ setStorage(modId: string, placeId: string, key: string, value: unknown): Promise<void>
++ deleteStorage(modId: string, placeId: string, key: string): Promise<void>
}
/** Queue of local assets to copy into OpenVibe.Media. */
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex bc1ac70..7239984 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -20,6 +20,7 @@ const TABLES = [
'mods',
'mod_grants',
'mod_placements',
++ 'mod_storage',
'mod_audit',
'media_mirrors',
'identity_adoptions',
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 0980fb9..dd33b35 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -112,8 +112,8 @@ importers:
specifier: ^0.4.0
version: 0.4.0
openvibe-contracts:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.142.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.142.0
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0(@electric-sql/[email protected])([email protected])([email protected])
@@@ -1320,9 +1320,9 @@ packages:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0}
-- version: 0.129.0
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.142.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.142.0}
++ version: 0.142.0
engines: {node: '>=20'}
hasBin: true
@@@ -2738,7 +2738,7 @@ snapshots:
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0:
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.142.0:
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ddiff --git a/test/apps__server__src__mods__routesNoSocket.test.js b/test/apps__server__src__mods__routesNoSocket.test.js
nnew file mode 100644
iindex 0000000..0894f4f
---- /dev/null
++++ b/test/apps__server__src__mods__routesNoSocket.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/mods/routesNoSocket.test.ts'
++// covers: 'apps/server/src/mods'
++// Generated by node test/run.js --index; do not edit.