openvibers/openvibe.games Commits
Prettier on the files the mod-principal and budget work touched (networkGrants, registry, runtime, routes' tests, revocationEvents, modLifecycleProof): formatting only, no change in behaviour.
Changes
@@@ -49,7 +49,8 @@ async function token(): Promise<string> {}),})const body = (await res.json()) as { access_token?: string; error?: string }-- if (!res.ok || !body.access_token) throw new Error(`no games.mod.manage token: ${res.status} ${body.error ?? ''}`)++ if (!res.ok || !body.access_token)++ throw new Error(`no games.mod.manage token: ${res.status} ${body.error ?? ''}`)return body.access_token}@@@ -61,7 +62,10 @@ async function main(): Promise<void> {headers: { authorization: `Bearer ${tok}`, 'content-type': 'application/json' },...(body ? { body: JSON.stringify(body) } : {}),})-- return { status: res.status, body: (await res.json().catch(() => ({}))) as Record<string, unknown> }++ return {++ status: res.status,++ body: (await res.json().catch(() => ({}))) as Record<string, unknown>,++ }}const id = `mod_${ulid()}`const failures: string[] = []@@@ -73,7 +77,8 @@ async function main(): Promise<void> {id,name: 'Lifecycle proof',version: '1.0.0',-- description: 'WS-M task 6: install, grant, use, revoke. One inert prop far from spawn; retracted at the end.',++ description:++ 'WS-M task 6: install, grant, use, revoke. One inert prop far from spawn; retracted at the end.',// A publisher is a user or an app; the proof publishes as a synthetic app of its own.publisher: { type: 'app', id: `app_${ulid()}` },target: 'games.browser',@@@ -85,11 +90,24 @@ async function main(): Promise<void> {const pack = { props: [{ key: 'proof-bench', item: 'workbench', pos: [3900, 0, 3900] }] }let r = await api('POST', '', { manifest, pack, approve: [], enable: true })-- step('install', r.status === 201 && Array.isArray(r.body.granted) && (r.body.granted as unknown[]).length === 0, `${r.status} ${id} granted=${JSON.stringify(r.body.granted)}`)++ step(++ 'install',++ r.status === 201 && Array.isArray(r.body.granted) && (r.body.granted as unknown[]).length === 0,++ `${r.status} ${id} granted=${JSON.stringify(r.body.granted)}`,++ )r = await api('POST', `/${id}/grants`, { capability: CAP })-- step('grant', r.status === 200 && JSON.stringify(r.body.granted) === JSON.stringify([CAP]), `${r.status} granted=${JSON.stringify(r.body.granted)}`)++ step(++ 'grant',++ r.status === 200 && JSON.stringify(r.body.granted) === JSON.stringify([CAP]),++ `${r.status} granted=${JSON.stringify(r.body.granted)}`,++ )-- const audit = async () => ((await api('GET', `/${id}/audit?limit=100`)).body.audit ?? []) as { action: string; capability: string | null; actor: string }[]++ const audit = async () =>++ ((await api('GET', `/${id}/audit?limit=100`)).body.audit ?? []) as {++ action: string++ capability: string | null++ actor: string++ }[]let used = falsefor (let i = 0; i < 30 && !used; i++) {await new Promise((res) => setTimeout(res, 1000))@@@ -98,7 +116,11 @@ async function main(): Promise<void> {step('use', used, used ? 'the runtime placed the prop' : 'no use within 30 s')r = await api('POST', `/${id}/revoke`, { reason: 'WS-M task 6 lifecycle proof: done' })-- step('revoke', r.status === 200 && r.body.status === 'revoked', `${r.status} status=${String(r.body.status)}`)++ step(++ 'revoke',++ r.status === 200 && r.body.status === 'revoked',++ `${r.status} status=${String(r.body.status)}`,++ )let retracted = falsefor (let i = 0; i < 30 && !retracted; i++) {await new Promise((res) => setTimeout(res, 1000))@@@ -5,7 +5,11 @@ import { openSqliteStore } from '@openvibe/persistence/sqlite'import { createConsoleLogger } from '@openvibe/shared'import { afterEach, describe, expect, it } from 'vitest'import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'--import { createNetworkModGrants, type ModGrantAuthority, type ModPrincipal } from './networkGrants.js'++import {++ createNetworkModGrants,++ type ModGrantAuthority,++ type ModPrincipal,++} from './networkGrants.js'import { ModError, ModRegistry, type ModActor } from './registry.js'import { handleModsRequest } from './routes.js'import { MOD_ID, sampleManifest } from './testFixtures.js'@@@ -17,7 +21,10 @@ import { MOD_ID, sampleManifest } from './testFixtures.js'* client maps Network's answers to ModErrors.*/const log = createConsoleLogger({ app: 'test' }, 'error')--const STAFF: ModActor = { audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' } }++const STAFF: ModActor = {++ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',++ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },++}let server: Server | null = nullafterEach(() => {server?.close()@@@ -30,23 +37,38 @@ function fakeNetwork(allow: string[]) {let principal: ModPrincipal | null = nullconst state = { down: false }const ensure = () => {-- if (state.down) throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)++ if (state.down)++ throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)if (!principal) throw new ModError('mod.not_found', 'no such mod principal', 404)return principal}const grants: ModGrantAuthority = {async register(manifest, approve) {calls.push(`register:${[...approve].join(',')}`)-- if (state.down) throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)-- const requested = (manifest as { permissions: { capabilities: string[] } }).permissions.capabilities++ if (state.down)++ throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)++ const requested = (manifest as { permissions: { capabilities: string[] } }).permissions++ .capabilitiesconst approved = approve.filter((c) => allow.includes(c))-- principal = { mod_id: MOD_ID, owner: 'games', status: 'active', requested, approved, pending: requested.filter((c) => !approved.includes(c)), revoked: [], revision: 1 }++ principal = {++ mod_id: MOD_ID,++ owner: 'games',++ status: 'active',++ requested,++ approved,++ pending: requested.filter((c) => !approved.includes(c)),++ revoked: [],++ revision: 1,++ }return principal},async change(_id, capability, action) {calls.push(`${action}:${capability}`)const p = ensure()-- p.approved = action === 'approve' ? [...new Set([...p.approved, capability])] : p.approved.filter((c) => c !== capability)++ p.approved =++ action === 'approve'++ ? [...new Set([...p.approved, capability])]++ : p.approved.filter((c) => c !== capability)p.revision++return p},@@@ -74,37 +96,68 @@ async function start(grants: ModGrantAuthority) {})await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r))const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/mods`-- const post = (path: string, body: unknown) => fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })++ const post = (path: string, body: unknown) =>++ fetch(`${base}${path}`, {++ method: 'POST',++ headers: { 'content-type': 'application/json' },++ body: JSON.stringify(body),++ })return { registry, base, post }}--const manifest = sampleManifest({ permissions: { capabilities: [CAP_PLACE_PROP, CAP_ANNOUNCE] } } as never)++const manifest = sampleManifest({++ permissions: { capabilities: [CAP_PLACE_PROP, CAP_ANNOUNCE] },++} as never)const pack = { props: [{ key: 'bench', item: 'workbench', pos: [0, 0, 0] }] }describe('mod principals in Network', () => {it('installs with what Network approved, and asks it before every grant change', async () => {const net = fakeNetwork([CAP_PLACE_PROP])const { registry, post, base } = await start(net.grants)-- const created = await post('', { manifest, pack, approve: [CAP_PLACE_PROP, CAP_ANNOUNCE], enable: true })++ const created = await post('', {++ manifest,++ pack,++ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],++ enable: true,++ })expect(created.status).toBe(201)expect(await created.json()).toMatchObject({ id: MOD_ID, granted: [CAP_PLACE_PROP] })expect(net.calls).toEqual([`register:${CAP_PLACE_PROP},${CAP_ANNOUNCE}`])expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)-- expect((await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status).toBe(200)++ expect(++ (await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status,++ ).toBe(200)expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])// Network's events arrive after the API applied its answers: an older revision changes nothing.expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)-- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_PLACE_PROP], revision: 1 })-- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_PLACE_PROP], revision: 3 })++ registry.applyNetwork({++ mod_id: MOD_ID,++ status: 'active',++ approved: [CAP_PLACE_PROP],++ revision: 1,++ })++ registry.applyNetwork({++ mod_id: MOD_ID,++ status: 'active',++ approved: [CAP_PLACE_PROP],++ revision: 3,++ })expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(false)// A newer one (a staff change in Network) applies.-- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_PLACE_PROP], revision: 5 })++ registry.applyNetwork({++ mod_id: MOD_ID,++ status: 'active',++ approved: [CAP_PLACE_PROP],++ revision: 5,++ })expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)-- expect((await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status).toBe(200)++ expect(++ (await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status,++ ).toBe(200)// Network unreachable: 503, and nothing changes here.net.state.down = true@@@ -130,10 +183,20 @@ describe('mod principals in Network', () => {it("applyNetwork makes an install's copy follow its principal", () => {const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())registry.install({ manifest, pack, approve: [CAP_PLACE_PROP], enable: true }, STAFF)-- expect(registry.applyNetwork({ mod_id: 'mod_01JZZZZZZZZZZZZZZZZZZZZZZZ', status: 'active', approved: [] })).toBeNull()++ expect(++ registry.applyNetwork({++ mod_id: 'mod_01JZZZZZZZZZZZZZZZZZZZZZZZ',++ status: 'active',++ approved: [],++ }),++ ).toBeNull()registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE] })expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])-- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE, 'games.not.bindable'] })++ registry.applyNetwork({++ mod_id: MOD_ID,++ status: 'active',++ approved: [CAP_ANNOUNCE, 'games.not.bindable'],++ })expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(true)registry.applyNetwork({ mod_id: MOD_ID, status: 'revoked', approved: [] })@@@ -144,19 +207,51 @@ describe('mod principals in Network', () => {it('the client sends the principal token and maps Network answers', async () => {const seen: { url: string; auth: string | null; body: unknown }[] = []const answers: Response[] = [-- Response.json({ mod_id: MOD_ID, owner: 'games', status: 'active', requested: [], approved: [], pending: [], revoked: [], revision: 1 }, { status: 201 }),++ Response.json(++ {++ mod_id: MOD_ID,++ owner: 'games',++ status: 'active',++ requested: [],++ approved: [],++ pending: [],++ revoked: [],++ revision: 1,++ },++ { status: 201 },++ ),Response.json({ error: 'mod.not_requested', detail: 'not requested' }, { status: 422 }),Response.json({ error: 'server' }, { status: 502 }),]const fetchImpl = (async (url: string, init?: RequestInit) => {-- seen.push({ url, auth: new Headers(init?.headers).get('authorization'), body: init?.body ? JSON.parse(String(init.body)) : null })++ seen.push({++ url,++ auth: new Headers(init?.headers).get('authorization'),++ body: init?.body ? JSON.parse(String(init.body)) : null,++ })return answers.shift() as Response}) as unknown as typeof fetchconst scopes: (string | undefined)[] = []-- const client = createNetworkModGrants({ networkUrl: 'http://network.test', tokens: { getToken: async (ctx) => { scopes.push(ctx?.scope); return 'tok' } }, fetchImpl })++ const client = createNetworkModGrants({++ networkUrl: 'http://network.test',++ tokens: {++ getToken: async (ctx) => {++ scopes.push(ctx?.scope)++ return 'tok'++ },++ },++ fetchImpl,++ })expect((await client.register(manifest, [CAP_PLACE_PROP], 'usr_x')).revision).toBe(1)-- expect(seen[0]).toMatchObject({ url: 'http://network.test/internal/mods', auth: 'Bearer tok', body: { approve: [CAP_PLACE_PROP], actor: 'usr_x' } })-- await expect(client.change(MOD_ID, CAP_ANNOUNCE, 'approve', 'usr_x')).rejects.toMatchObject({ code: 'mod.not_requested', status: 422 })++ expect(seen[0]).toMatchObject({++ url: 'http://network.test/internal/mods',++ auth: 'Bearer tok',++ body: { approve: [CAP_PLACE_PROP], actor: 'usr_x' },++ })++ await expect(client.change(MOD_ID, CAP_ANNOUNCE, 'approve', 'usr_x')).rejects.toMatchObject({++ code: 'mod.not_requested',++ status: 422,++ })await expect(client.revoke(MOD_ID, 'usr_x')).rejects.toMatchObject({ status: 503 })expect(scopes.every((s) => s === 'mods.grant.manage')).toBe(true)})@@@ -22,7 +22,12 @@ export interface ModPrincipal {export interface ModGrantAuthority {register(manifest: unknown, approve: readonly string[], actor: string): Promise<ModPrincipal>-- change(modId: string, capability: string, action: 'approve' | 'revoke', actor: string): Promise<ModPrincipal>++ change(++ modId: string,++ capability: string,++ action: 'approve' | 'revoke',++ actor: string,++ ): Promise<ModPrincipal>revoke(modId: string, actor: string, reason?: string): Promise<ModPrincipal>list(): Promise<ModPrincipal[]>}@@@ -36,30 +41,59 @@ export function createNetworkModGrants(opts: {async function call<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {let res: Responsetry {-- const token = await opts.tokens.getToken({ audience: 'openvibe.network', scope: 'mods.grant.manage' })++ const token = await opts.tokens.getToken({++ audience: 'openvibe.network',++ scope: 'mods.grant.manage',++ })res = await f(`${opts.networkUrl}/internal/mods${path}`, {method,-- headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },++ headers: {++ authorization: `Bearer ${token}`,++ accept: 'application/json',++ ...(body ? { 'content-type': 'application/json' } : {}),++ },...(body ? { body: JSON.stringify(body) } : {}),signal: AbortSignal.timeout(10_000),})} catch (err) {-- throw new ModError('mod.grants_unavailable', `OpenVibe.Network could not be asked: ${String((err as Error)?.message ?? err)}`, 503)++ throw new ModError(++ 'mod.grants_unavailable',++ `OpenVibe.Network could not be asked: ${String((err as Error)?.message ?? err)}`,++ 503,++ )}const data = (await res.json().catch(() => ({}))) as Record<string, unknown>if (!res.ok) {-- const code = typeof data.error === 'string' ? data.error : typeof data.code === 'string' ? data.code : 'mod.grants_refused'-- const status = res.status >= 500 ? 503 : res.status === 401 || res.status === 403 ? 503 : res.status-- throw new ModError(code, `OpenVibe.Network: ${typeof data.detail === 'string' ? data.detail : `HTTP ${res.status}`}`, status)++ const code =++ typeof data.error === 'string'++ ? data.error++ : typeof data.code === 'string'++ ? data.code++ : 'mod.grants_refused'++ const status =++ res.status >= 500 ? 503 : res.status === 401 || res.status === 403 ? 503 : res.status++ throw new ModError(++ code,++ `OpenVibe.Network: ${typeof data.detail === 'string' ? data.detail : `HTTP ${res.status}`}`,++ status,++ )}return data as T}return {-- register: (manifest, approve, actor) => call<ModPrincipal>('POST', '', { manifest, approve: [...approve], actor }),++ register: (manifest, approve, actor) =>++ call<ModPrincipal>('POST', '', { manifest, approve: [...approve], actor }),change: (modId, capability, action, actor) =>-- call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/grants`, { capability, action, actor }),++ call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/grants`, {++ capability,++ action,++ actor,++ }),revoke: (modId, actor, reason) =>-- call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/revoke`, { actor, ...(reason ? { reason } : {}) }),++ call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/revoke`, {++ actor,++ ...(reason ? { reason } : {}),++ }),list: async () => (await call<{ mods: ModPrincipal[] }>('GET', '')).mods ?? [],}}@@@ -60,7 +60,10 @@ export interface ModActor {export const SYSTEM_ACTOR: ModActor = { audit: 'games', subject: { type: 'service', id: 'games' } }/** Changes that come from OpenVibe.Network's mod principal (network.mod.grants_changed). */--export const NETWORK_ACTOR: ModActor = { audit: 'network', subject: { type: 'service', id: 'network' } }++export const NETWORK_ACTOR: ModActor = {++ audit: 'network',++ subject: { type: 'service', id: 'network' },++}export class ModError extends Error {constructor(@@@ -311,7 +314,10 @@ export class ModRegistry {* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.*/-- applyNetwork(principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number }, actor: ModActor = NETWORK_ACTOR): ModView | null {++ applyNetwork(++ principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number },++ actor: ModActor = NETWORK_ACTOR,++ ): ModView | null {const view = this.views.get(principal.mod_id)if (!view) return null// Events can arrive after the API already applied a newer answer: an older (or equal) revision changes nothing.@@@ -319,11 +325,13 @@ export class ModRegistry {if (principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)) return viewthis.networkRevision.set(view.mod.id, principal.revision)}-- if (principal.status === 'revoked') return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')++ if (principal.status === 'revoked')++ return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')if (view.mod.status === 'revoked') return viewconst approved = new Set(principal.approved)let current = view-- for (const cap of [...current.granted]) if (!approved.has(cap)) current = this.revokeGrant(view.mod.id, cap, actor)++ for (const cap of [...current.granted])++ if (!approved.has(cap)) current = this.revokeGrant(view.mod.id, cap, actor)for (const cap of approved) {if (current.granted.has(cap)) continuetry {@@@ -99,7 +99,10 @@ export class ModRuntime {/** Reconciles in a row over the CPU budget, per mod. */private readonly cpuStrikes = new Map<string, number>()/** Host effects per mod in the current minute, and minutes in a row that were throttled. */-- private readonly effects = new Map<string, { windowStart: number; count: number; throttled: boolean; floodWindows: number }>()++ private readonly effects = new Map<++ string,++ { windowStart: number; count: number; throttled: boolean; floodWindows: number }++ >()private readonly now: () => numberconstructor(@@@ -248,7 +251,8 @@ export class ModRuntime {const nowMs = this.now()let e = this.effects.get(id)if (!e || nowMs - e.windowStart >= 60_000) {-- const floodWindows = e && e.throttled && nowMs - e.windowStart < 120_000 ? e.floodWindows + 1 : 0++ const floodWindows =++ e && e.throttled && nowMs - e.windowStart < 120_000 ? e.floodWindows + 1 : 0e = { windowStart: nowMs, count: 0, throttled: false, floodWindows }this.effects.set(id, e)if (floodWindows >= FLOOD_WINDOWS) {@@@ -267,7 +271,11 @@ export class ModRuntime {}/** Disables the offending mod only; the others keep running. */-- private enforce(id: string, reason: 'cpu' | 'event_flood', detail: Record<string, unknown>): void {++ private enforce(++ id: string,++ reason: 'cpu' | 'event_flood',++ detail: Record<string, unknown>,++ ): void {if (!this.registry.isActive(id)) returnthis.registry.audit(id, 'budget_enforced', null, { reason, ...detail })this.registry.disable(id, SYSTEM_ACTOR)@@@ -306,7 +314,11 @@ export class ModRuntime {const strikes = (this.cpuStrikes.get(view.mod.id) ?? 0) + 1this.cpuStrikes.set(view.mod.id, strikes)if (strikes >= CPU_STRIKES) {-- this.enforce(view.mod.id, 'cpu', { cpu_ms: Math.round(ms * 1000) / 1000, budget_ms: budget, reconciles: strikes })++ this.enforce(view.mod.id, 'cpu', {++ cpu_ms: Math.round(ms * 1000) / 1000,++ budget_ms: budget,++ reconciles: strikes,++ })return}const nowMs = this.now()@@@ -4,7 +4,13 @@ import { createConsoleLogger } from '@openvibe/shared'import { describe, expect, it } from 'vitest'import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod, validateContentPack } from './contentPack.js'import { ModRegistry, type ModActor } from './registry.js'--import { CPU_STRIKES, FLOOD_WINDOWS, ModRuntime, ModThrottledError, type ModHost } from './runtime.js'++import {++ CPU_STRIKES,++ FLOOD_WINDOWS,++ ModRuntime,++ ModThrottledError,++ type ModHost,++} from './runtime.js'import { sampleManifest } from './testFixtures.js'/**@@@ -17,7 +23,10 @@ import { sampleManifest } from './testFixtures.js'* forged ownership every prop a mod places is owned by that mod, whatever the pack says; an unknown or* another runtime's principal changes nothing here*/--const STAFF: ModActor = { audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' } }++const STAFF: ModActor = {++ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',++ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },++}const content = createContent()const log = createConsoleLogger({ app: 'test' }, 'error')const A = 'mod_01JABCDEFGHJKMNPQRSTVWXYZA'@@@ -51,13 +60,29 @@ function setup(effectsPerMinute = 60) {const clock = { now: () => now, advance: (ms: number) => (now += ms) }const store = openSqliteStore(':memory:')const registry = new ModRegistry(store, content, undefined, clock.now)-- const runtime = new ModRuntime(registry, store, content, log, { reconcileEveryTicks: 1, now: clock.now, effectsPerMinute })++ const runtime = new ModRuntime(registry, store, content, log, {++ reconcileEveryTicks: 1,++ now: clock.now,++ effectsPerMinute,++ })const install = (id: string, pack: unknown, approve: string[], cpuMs = 50) =>-- registry.install({ manifest: sampleManifest({ id, resources: { cpuMs, memoryMb: 0, storageMb: 0 } }), pack, approve, enable: true }, STAFF)++ registry.install(++ {++ manifest: sampleManifest({ id, resources: { cpuMs, memoryMb: 0, storageMb: 0 } }),++ pack,++ approve,++ enable: true,++ },++ STAFF,++ )return { store, registry, runtime, clock, install }}--const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({ key, item: 'workbench', pos })++const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({++ key,++ item: 'workbench',++ pos,++})describe('mod security', () => {it('namespace escape: a mod reaches only its own placements, through frozen bindings', () => {@@@ -76,12 +101,18 @@ describe('mod security', () => {expect(apiA.removeProp('bench')).toBe(true)expect([...w.entities.values()].map((e) => e.owner)).toEqual([B])expect(apiA.removeProp('bench')).toBe(false)-- expect(registry.auditLog(B).some((a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed'))).toBe(false)++ expect(++ registry++ .auditLog(B)++ .some((a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed')),++ ).toBe(false)// Only inert items: anything with health, storage, a shop, a machine or a vehicle part is refused, at install// and at the binding.const loot = content.allItems().find((i) => placeableByMod(content, i.id) !== null)expect(loot).toBeDefined()-- expect(validateContentPack({ props: [{ key: 'x', item: loot!.id, pos: [0, 0, 0] }] }, content).ok).toBe(false)++ expect(++ validateContentPack({ props: [{ key: 'x', item: loot!.id, pos: [0, 0, 0] }] }, content).ok,++ ).toBe(false)expect(() => apiA.placeProp('x', loot!.id, [0, 0, 0])).toThrow(/not allowed/)})@@@ -117,7 +148,13 @@ describe('mod security', () => {}}expect(registry.get(A)?.mod.status).toBe('disabled')-- expect(registry.auditLog(A).some((a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'))).toBe(true)++ expect(++ registry++ .auditLog(A)++ .some(++ (a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'),++ ),++ ).toBe(true)expect(registry.get(B)?.mod.status).toBe('enabled')expect(() => apiA.announce('after')).toThrow()})@@@ -141,20 +178,35 @@ describe('mod security', () => {expect([...slow.entities.values()].filter((e) => e.owner === A)).toEqual([])})-- it('forged ownership: props are the mod\'s whatever the pack says; unknown or foreign principals change nothing', () => {++ it("forged ownership: props are the mod's whatever the pack says; unknown or foreign principals change nothing", () => {const { registry, runtime, install } = setup()const w = world()-- expect(validateContentPack({ props: [{ ...prop('x'), owner: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' }] }, content).ok).toBe(false)++ expect(++ validateContentPack(++ { props: [{ ...prop('x'), owner: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' }] },++ content,++ ).ok,++ ).toBe(false)install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])runtime.tick(1, w.host)expect([...w.entities.values()]).toEqual([{ item: 'workbench', owner: A }])// A principal Games does not have, or that another runtime owns, is not applied here.-- expect(registry.applyNetwork({ mod_id: B, status: 'active', approved: [CAP_PLACE_PROP, CAP_ANNOUNCE] })).toBeNull()++ expect(++ registry.applyNetwork({++ mod_id: B,++ status: 'active',++ approved: [CAP_PLACE_PROP, CAP_ANNOUNCE],++ }),++ ).toBeNull()expect(registry.get(B)).toBeNull()// A grant the manifest never requested cannot be applied, even from Network.const manifestA = sampleManifest({ id: A, permissions: { capabilities: [CAP_PLACE_PROP] } })expect(manifestA.permissions.capabilities).not.toContain('games.world.teleport')-- registry.applyNetwork({ mod_id: A, status: 'active', approved: [CAP_PLACE_PROP, 'games.world.teleport'] })++ registry.applyNetwork({++ mod_id: A,++ status: 'active',++ approved: [CAP_PLACE_PROP, 'games.world.teleport'],++ })expect([...(registry.get(A)?.granted ?? [])]).toEqual([CAP_PLACE_PROP])})})@@@ -3,7 +3,12 @@ import type { AddressInfo } from 'node:net'import { createRequire } from 'node:module'import { createConsoleLogger } from '@openvibe/shared'import { afterAll, beforeAll, describe, expect, it } from 'vitest'--import { createRevocationEvents, ensureRevocationSubscription, MERGE_TOPIC, TOPIC } from './revocationEvents.js'++import {++ createRevocationEvents,++ ensureRevocationSubscription,++ MERGE_TOPIC,++ TOPIC,++} from './revocationEvents.js'/*** POST /internal/events for network.user.token_valid_after: signature v2 only, loopback only, the@@@ -39,7 +44,8 @@ const events = createRevocationEvents({},onAccountEvent: async (event) => {accountCalls.push(String(event.event_type))-- if (event.payload?.export_id === 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9') throw new Error('Network down')++ if (event.payload?.export_id === 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9')++ throw new Error('Network down')return 'exported'},log,@@@ -65,18 +71,33 @@ const envelope = (over: Record<string, unknown> = {}) => ({event_type: TOPIC,version: 1,source: 'network',-- payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' },++ payload: {++ subject: { type: 'user', id: SUBJECT },++ valid_after: new Date(at).toISOString(),++ reason: 'signed_out_everywhere',++ },...over,})const post = async (event: unknown, headers: Record<string, string> = {}, secret = SECRET) => {const body = JSON.stringify({ event, seq: 1 })-- const r = await fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })++ const r = await fetch(url, {++ method: 'POST',++ body,++ headers: {++ 'content-type': 'application/json',++ ...signDeliveryHeaders(body, secret),++ ...headers,++ },++ })return { status: r.status, body: (await r.json()) as { outcome?: string; closed?: number } }}describe('revocation events', () => {it('applies a signed delivery once and closes the sessions', async () => {-- expect(await post(envelope())).toMatchObject({ status: 200, body: { outcome: 'revoked', closed: 2 } })++ expect(await post(envelope())).toMatchObject({++ status: 200,++ body: { outcome: 'revoked', closed: 2 },++ })expect(closedFor).toEqual([[SUBJECT, at]])expect(events.store.isRevoked({ subject_id: SUBJECT, iat: at / 1000 - 1 })).toBe(true)expect((await post(envelope())).body.outcome).toBe('unchanged')@@@ -87,9 +108,23 @@ describe('revocation events', () => {it('an account merge (ADR-029) moves characters through onMerged; foreign or malformed ones are ignored', async () => {const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4'const merged = (over: Record<string, unknown> = {}) =>-- envelope({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5', event_type: MERGE_TOPIC, payload: { merge_id: 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', from: FROM, into: SUBJECT, merged_at: new Date(at).toISOString(), initiated_by: 'person' }, ...over })++ envelope({++ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5',++ event_type: MERGE_TOPIC,++ payload: {++ merge_id: 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6',++ from: FROM,++ into: SUBJECT,++ merged_at: new Date(at).toISOString(),++ initiated_by: 'person',++ },++ ...over,++ })expect((await post(merged({ source: 'live' }))).body.outcome).toBe('ignored:source')-- expect((await post(merged({ payload: { merge_id: 'nope', from: FROM, into: SUBJECT } }))).body.outcome).toBe('ignored:payload')++ expect(++ (await post(merged({ payload: { merge_id: 'nope', from: FROM, into: SUBJECT } }))).body++ .outcome,++ ).toBe('ignored:payload')expect(mergedCalls).toEqual([])expect((await post(merged())).body.outcome).toBe('merged')expect(mergedCalls).toEqual([[FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6']])@@@ -98,18 +133,49 @@ describe('revocation events', () => {it('account export and deletion (ADR-033) go to onAccountEvent, answered once it resolved (500 when it failed)', async () => {const exp = (id: string) =>-- envelope({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2E7', event_type: 'network.account.export_requested', payload: { export_id: id, subject: SUBJECT } })-- expect(await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Y8'))).toMatchObject({ status: 200, body: { outcome: 'exported' } })++ envelope({++ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2E7',++ event_type: 'network.account.export_requested',++ payload: { export_id: id, subject: SUBJECT },++ })++ expect(await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Y8'))).toMatchObject({++ status: 200,++ body: { outcome: 'exported' },++ })expect((await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9'))).status).toBe(500)-- expect(accountCalls).toEqual(['network.account.export_requested', 'network.account.export_requested'])++ expect(accountCalls).toEqual([++ 'network.account.export_requested',++ 'network.account.export_requested',++ ])})it("network.mod.grants_changed (ADR-013) reaches onModGrants for Games' own mods only", async () => {const MOD = 'mod_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1'const changed = (over: Record<string, unknown> = {}) =>-- envelope({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2M2', event_type: 'network.mod.grants_changed', payload: { mod_id: MOD, owner: 'games', status: 'active', approved: ['games.prop.place'], revision: 2, change: { action: 'revoke', capability: 'games.world.announce' }, by: 'staff' }, ...over })++ envelope({++ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2M2',++ event_type: 'network.mod.grants_changed',++ payload: {++ mod_id: MOD,++ owner: 'games',++ status: 'active',++ approved: ['games.prop.place'],++ revision: 2,++ change: { action: 'revoke', capability: 'games.world.announce' },++ by: 'staff',++ },++ ...over,++ })expect((await post(changed({ source: 'games' }))).body.outcome).toBe('ignored:source')-- expect((await post(changed({ payload: { mod_id: MOD, owner: 'tools', status: 'active', approved: [], revision: 1 } }))).body.outcome).toBe('ignored:owner')++ expect(++ (++ await post(++ changed({++ payload: { mod_id: MOD, owner: 'tools', status: 'active', approved: [], revision: 1 },++ }),++ )++ ).body.outcome,++ ).toBe('ignored:owner')expect((await post(changed())).body.outcome).toBe('mod:enabled')expect(modGrantCalls).toEqual([[MOD, 'active', 'games.prop.place']])})@@@ -129,13 +195,24 @@ describe('revocation events', () => {}return Response.json({ subscriptions: subs.map((s, i) => ({ id: `sub_${i + 1}`, ...s })) })}) as unknown as typeof fetch-- const opts = { eventsUrl: 'http://events.test', endpoint: 'http://127.0.0.1:8000/internal/events', secret: SECRET, tokens: { getToken: async () => 'tok' }, fetchImpl: fake, log }++ const opts = {++ eventsUrl: 'http://events.test',++ endpoint: 'http://127.0.0.1:8000/internal/events',++ secret: SECRET,++ tokens: { getToken: async () => 'tok' },++ fetchImpl: fake,++ log,++ }expect(await ensureRevocationSubscription(opts)).toBe('created')expect(await ensureRevocationSubscription(opts)).toBe('exists')expect(subs).toEqual([{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET },{ topic_pattern: MERGE_TOPIC, endpoint: opts.endpoint, secret: SECRET },-- { topic_pattern: 'network.account.export_requested', endpoint: opts.endpoint, secret: SECRET },++ {++ topic_pattern: 'network.account.export_requested',++ endpoint: opts.endpoint,++ secret: SECRET,++ },{ topic_pattern: 'network.account.deleted', endpoint: opts.endpoint, secret: SECRET },{ topic_pattern: 'network.mod.grants_changed', endpoint: opts.endpoint, secret: SECRET },])@@@ -37,7 +37,16 @@ type ParseDelivery = (event_id?: stringevent_type?: stringsource?: string-- payload?: { subject?: { id?: string } | string; valid_after?: string; merge_id?: string; from?: string; into?: string; export_id?: string; deletion_id?: string; aliases?: unknown }++ payload?: {++ subject?: { id?: string } | string++ valid_after?: string++ merge_id?: string++ from?: string++ into?: string++ export_id?: string++ deletion_id?: string++ aliases?: unknown++ }}} | null@@@ -49,7 +58,13 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDeexport const TOPIC = 'network.user.token_valid_after'export const MERGE_TOPIC = 'network.subject.merged'export const MOD_GRANTS_TOPIC = 'network.mod.grants_changed'--export const TOPICS = [TOPIC, MERGE_TOPIC, 'network.account.export_requested', 'network.account.deleted', MOD_GRANTS_TOPIC] as const++export const TOPICS = [++ TOPIC,++ MERGE_TOPIC,++ 'network.account.export_requested',++ 'network.account.deleted',++ MOD_GRANTS_TOPIC,++] as constconst SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/const PATH = '/internal/events'@@@ -67,11 +82,23 @@ export function createRevocationEvents(opts: {secrets: string[]onRevoked: (subjectId: string, validAfterMs: number) => number/** An account merge: move `from`'s characters to `into` (returns what moved; `already` when applied before). */-- onMerged?: (from: string, into: string, mergeId: string) => { moved: number; kept: number; already: boolean }++ onMerged?: (++ from: string,++ into: string,++ mergeId: string,++ ) => { moved: number; kept: number; already: boolean }/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */-- onAccountEvent?: (event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>) => Promise<string>++ onAccountEvent?: (++ event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,++ ) => Promise<string>/** A mod principal changed in Network: make the install's grants match (returns the outcome). */-- onModGrants?: (payload: { mod_id: string; owner: string; status: string; approved: string[]; revision: number }) => string++ onModGrants?: (payload: {++ mod_id: string++ owner: string++ status: string++ approved: string[]++ revision: number++ }) => stringlog: Logger}): RevocationEvents {const store = createRevocationStore(opts.db, { table: 'token_revocations' })@@@ -87,7 +114,11 @@ export function createRevocationEvents(opts: {send(res, 405, { error: 'POST only' })return true}-- if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {++ if (++ req.headers['x-forwarded-for'] ||++ req.headers['x-real-ip'] ||++ req.headers['cf-connecting-ip']++ ) {send(res, 403, { error: 'internal route' })return true}@@@ -116,7 +147,10 @@ export function createRevocationEvents(opts: {}stats.received++const event = delivery.event-- if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {++ if (++ event.event_type === 'network.account.export_requested' ||++ event.event_type === 'network.account.deleted'++ ) {if (!opts.onAccountEvent) {stats.ignored++send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })@@@ -128,20 +162,41 @@ export function createRevocationEvents(opts: {send(res, 200, { event_id: event.event_id ?? null, outcome })},(err: unknown) => {-- opts.log.warn('account event failed (Events retries)', { type: event.event_type, error: String((err as Error)?.message ?? err) })++ opts.log.warn('account event failed (Events retries)', {++ type: event.event_type,++ error: String((err as Error)?.message ?? err),++ })send(res, 500, { error: 'not applied' })},)return}if (event.event_type === MOD_GRANTS_TOPIC) {-- const p = (event.payload ?? {}) as { mod_id?: unknown; owner?: unknown; status?: unknown; approved?: unknown; revision?: unknown }++ const p = (event.payload ?? {}) as {++ mod_id?: unknown++ owner?: unknown++ status?: unknown++ approved?: unknown++ revision?: unknown++ }let outcome: stringif (event.source !== 'network') outcome = 'ignored:source'else if (!opts.onModGrants) outcome = 'ignored:no_handler'-- else if (typeof p.mod_id !== 'string' || !Array.isArray(p.approved) || typeof p.status !== 'string') outcome = 'ignored:payload'++ else if (++ typeof p.mod_id !== 'string' ||++ !Array.isArray(p.approved) ||++ typeof p.status !== 'string'++ )++ outcome = 'ignored:payload'else if (p.owner !== 'games') outcome = 'ignored:owner'-- else outcome = opts.onModGrants({ mod_id: p.mod_id, owner: p.owner, status: p.status, approved: p.approved.map(String), revision: Number(p.revision) || 0 })++ else++ outcome = opts.onModGrants({++ mod_id: p.mod_id,++ owner: p.owner,++ status: p.status,++ approved: p.approved.map(String),++ revision: Number(p.revision) || 0,++ })if (outcome.startsWith('ignored')) stats.ignored++send(res, 200, { event_id: event.event_id ?? null, outcome })return@@@ -151,11 +206,18 @@ export function createRevocationEvents(opts: {let outcome = 'merged'if (event.source !== 'network') outcome = 'ignored:source'else if (!opts.onMerged) outcome = 'ignored:no_handler'-- else if (!MERGE_RE.test(String(p.merge_id ?? '')) || !SUBJECT_RE.test(String(p.from ?? '')) || !SUBJECT_RE.test(String(p.into ?? '')) || p.from === p.into) outcome = 'ignored:payload'++ else if (++ !MERGE_RE.test(String(p.merge_id ?? '')) ||++ !SUBJECT_RE.test(String(p.from ?? '')) ||++ !SUBJECT_RE.test(String(p.into ?? '')) ||++ p.from === p.into++ )++ outcome = 'ignored:payload'else {const r = opts.onMerged(String(p.from), String(p.into), String(p.merge_id))outcome = r.already ? 'unchanged' : 'merged'-- if (!r.already) opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })++ if (!r.already)++ opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })}if (outcome.startsWith('ignored')) stats.ignored++send(res, 200, { event_id: event.event_id ?? null, outcome })@@@ -192,14 +254,24 @@ export async function ensureRevocationSubscription(opts: {log: Logger}): Promise<'exists' | 'created'> {const f = opts.fetchImpl ?? fetch-- const token = await opts.tokens.getToken({ audience: 'openvibe.events', scope: 'events.subscription.manage' })++ const token = await opts.tokens.getToken({++ audience: 'openvibe.events',++ scope: 'events.subscription.manage',++ })const headers = { authorization: `Bearer ${token}`, accept: 'application/json' }const list = await f(`${opts.eventsUrl}/api/v1/subscriptions`, { headers })if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`)-- const body = (await list.json()) as { subscriptions?: { id: string; topic_pattern: string; endpoint: string }[] }++ const body = (await list.json()) as {++ subscriptions?: { id: string; topic_pattern: string; endpoint: string }[]++ }let created = 0for (const topic of TOPICS) {-- if ((body.subscriptions ?? []).some((s) => s.topic_pattern === topic && s.endpoint === opts.endpoint)) continue++ if (++ (body.subscriptions ?? []).some(++ (s) => s.topic_pattern === topic && s.endpoint === opts.endpoint,++ )++ )++ continueconst r = await f(`${opts.eventsUrl}/api/v1/subscriptions`, {method: 'POST',headers: { ...headers, 'content-type': 'application/json' },
@@ -49,7 +49,8 @@ async function token(): Promise<string> {
@@ -49,7 +49,8 @@ async function token(): Promise<string> {
}),
}),
})
})
const body = (await res.json()) as { access_token?: string; error?: string }
const body = (await res.json()) as { access_token?: string; error?: string }
- if (!res.ok || !body.access_token) throw new Error(`no games.mod.manage token: ${res.status} ${body.error ?? ''}`)
+ if (!res.ok || !body.access_token)
+ throw new Error(`no games.mod.manage token: ${res.status} ${body.error ?? ''}`)
return body.access_token
return body.access_token
}
}
@@ -61,7 +62,10 @@ async function main(): Promise<void> {
@@ -61,7 +62,10 @@ async function main(): Promise<void> {
headers: { authorization: `Bearer ${tok}`, 'content-type': 'application/json' },
headers: { authorization: `Bearer ${tok}`, 'content-type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
...(body ? { body: JSON.stringify(body) } : {}),
})
})
- return { status: res.status, body: (await res.json().catch(() => ({}))) as Record<string, unknown> }
+ return {
+ status: res.status,
+ body: (await res.json().catch(() => ({}))) as Record<string, unknown>,
+ }
}
}
const id = `mod_${ulid()}`
const id = `mod_${ulid()}`
const failures: string[] = []
const failures: string[] = []
@@ -73,7 +77,8 @@ async function main(): Promise<void> {
@@ -73,7 +77,8 @@ async function main(): Promise<void> {
id,
id,
name: 'Lifecycle proof',
name: 'Lifecycle proof',
version: '1.0.0',
version: '1.0.0',
- description: 'WS-M task 6: install, grant, use, revoke. One inert prop far from spawn; retracted at the end.',
+ description:
+ 'WS-M task 6: install, grant, use, revoke. One inert prop far from spawn; retracted at the end.',
// A publisher is a user or an app; the proof publishes as a synthetic app of its own.
// A publisher is a user or an app; the proof publishes as a synthetic app of its own.
publisher: { type: 'app', id: `app_${ulid()}` },
publisher: { type: 'app', id: `app_${ulid()}` },
target: 'games.browser',
target: 'games.browser',
@@ -85,11 +90,24 @@ async function main(): Promise<void> {
@@ -85,11 +90,24 @@ async function main(): Promise<void> {
const pack = { props: [{ key: 'proof-bench', item: 'workbench', pos: [3900, 0, 3900] }] }
const pack = { props: [{ key: 'proof-bench', item: 'workbench', pos: [3900, 0, 3900] }] }
let r = await api('POST', '', { manifest, pack, approve: [], enable: true })
let r = await api('POST', '', { manifest, pack, approve: [], enable: true })
- step('install', r.status === 201 && Array.isArray(r.body.granted) && (r.body.granted as unknown[]).length === 0, `${r.status} ${id} granted=${JSON.stringify(r.body.granted)}`)
+ step(
+ 'install',
+ r.status === 201 && Array.isArray(r.body.granted) && (r.body.granted as unknown[]).length === 0,
+ `${r.status} ${id} granted=${JSON.stringify(r.body.granted)}`,
+ )
r = await api('POST', `/${id}/grants`, { capability: CAP })
r = await api('POST', `/${id}/grants`, { capability: CAP })
- step('grant', r.status === 200 && JSON.stringify(r.body.granted) === JSON.stringify([CAP]), `${r.status} granted=${JSON.stringify(r.body.granted)}`)
+ step(
+ 'grant',
+ r.status === 200 && JSON.stringify(r.body.granted) === JSON.stringify([CAP]),
+ `${r.status} granted=${JSON.stringify(r.body.granted)}`,
+ )
- const audit = async () => ((await api('GET', `/${id}/audit?limit=100`)).body.audit ?? []) as { action: string; capability: string | null; actor: string }[]
+ const audit = async () =>
+ ((await api('GET', `/${id}/audit?limit=100`)).body.audit ?? []) as {
+ action: string
+ capability: string | null
+ actor: string
+ }[]
let used = false
let used = false
for (let i = 0; i < 30 && !used; i++) {
for (let i = 0; i < 30 && !used; i++) {
await new Promise((res) => setTimeout(res, 1000))
await new Promise((res) => setTimeout(res, 1000))
@@ -98,7 +116,11 @@ async function main(): Promise<void> {
@@ -98,7 +116,11 @@ async function main(): Promise<void> {
step('use', used, used ? 'the runtime placed the prop' : 'no use within 30 s')
step('use', used, used ? 'the runtime placed the prop' : 'no use within 30 s')
r = await api('POST', `/${id}/revoke`, { reason: 'WS-M task 6 lifecycle proof: done' })
r = await api('POST', `/${id}/revoke`, { reason: 'WS-M task 6 lifecycle proof: done' })
- step('revoke', r.status === 200 && r.body.status === 'revoked', `${r.status} status=${String(r.body.status)}`)
+ step(
+ 'revoke',
+ r.status === 200 && r.body.status === 'revoked',
+ `${r.status} status=${String(r.body.status)}`,
+ )
let retracted = false
let retracted = false
for (let i = 0; i < 30 && !retracted; i++) {
for (let i = 0; i < 30 && !retracted; i++) {
await new Promise((res) => setTimeout(res, 1000))
await new Promise((res) => setTimeout(res, 1000))
@@ -5,7 +5,11 @@ import { openSqliteStore } from '@openvibe/persistence/sqlite'
@@ -5,7 +5,11 @@ import { openSqliteStore } from '@openvibe/persistence/sqlite'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP } from './contentPack.js'
-import { createNetworkModGrants, type ModGrantAuthority, type ModPrincipal } from './networkGrants.js'
+import {
+ createNetworkModGrants,
+ type ModGrantAuthority,
+ type ModPrincipal,
+} from './networkGrants.js'
import { ModError, ModRegistry, type ModActor } from './registry.js'
import { ModError, ModRegistry, type ModActor } from './registry.js'
import { handleModsRequest } from './routes.js'
import { handleModsRequest } from './routes.js'
import { MOD_ID, sampleManifest } from './testFixtures.js'
import { MOD_ID, sampleManifest } from './testFixtures.js'
@@ -17,7 +21,10 @@ import { MOD_ID, sampleManifest } from './testFixtures.js'
@@ -17,7 +21,10 @@ import { MOD_ID, sampleManifest } from './testFixtures.js'
* client maps Network's answers to ModErrors.
* client maps Network's answers to ModErrors.
*/
*/
const log = createConsoleLogger({ app: 'test' }, 'error')
const log = createConsoleLogger({ app: 'test' }, 'error')
-const STAFF: ModActor = { audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' } }
+const STAFF: ModActor = {
+ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
+ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
+}
let server: Server | null = null
let server: Server | null = null
afterEach(() => {
afterEach(() => {
server?.close()
server?.close()
@@ -30,23 +37,38 @@ function fakeNetwork(allow: string[]) {
@@ -30,23 +37,38 @@ function fakeNetwork(allow: string[]) {
let principal: ModPrincipal | null = null
let principal: ModPrincipal | null = null
const state = { down: false }
const state = { down: false }
const ensure = () => {
const ensure = () => {
- if (state.down) throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)
+ if (state.down)
+ throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)
if (!principal) throw new ModError('mod.not_found', 'no such mod principal', 404)
if (!principal) throw new ModError('mod.not_found', 'no such mod principal', 404)
return principal
return principal
}
}
const grants: ModGrantAuthority = {
const grants: ModGrantAuthority = {
async register(manifest, approve) {
async register(manifest, approve) {
calls.push(`register:${[...approve].join(',')}`)
calls.push(`register:${[...approve].join(',')}`)
- if (state.down) throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)
- const requested = (manifest as { permissions: { capabilities: string[] } }).permissions.capabilities
+ if (state.down)
+ throw new ModError('mod.grants_unavailable', 'OpenVibe.Network could not be asked', 503)
+ const requested = (manifest as { permissions: { capabilities: string[] } }).permissions
+ .capabilities
const approved = approve.filter((c) => allow.includes(c))
const approved = approve.filter((c) => allow.includes(c))
- principal = { mod_id: MOD_ID, owner: 'games', status: 'active', requested, approved, pending: requested.filter((c) => !approved.includes(c)), revoked: [], revision: 1 }
+ principal = {
+ mod_id: MOD_ID,
+ owner: 'games',
+ status: 'active',
+ requested,
+ approved,
+ pending: requested.filter((c) => !approved.includes(c)),
+ revoked: [],
+ revision: 1,
+ }
return principal
return principal
},
},
async change(_id, capability, action) {
async change(_id, capability, action) {
calls.push(`${action}:${capability}`)
calls.push(`${action}:${capability}`)
const p = ensure()
const p = ensure()
- p.approved = action === 'approve' ? [...new Set([...p.approved, capability])] : p.approved.filter((c) => c !== capability)
+ p.approved =
+ action === 'approve'
+ ? [...new Set([...p.approved, capability])]
+ : p.approved.filter((c) => c !== capability)
p.revision++
p.revision++
return p
return p
},
},
@@ -74,37 +96,68 @@ async function start(grants: ModGrantAuthority) {
@@ -74,37 +96,68 @@ async function start(grants: ModGrantAuthority) {
})
})
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r))
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r))
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/mods`
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1/mods`
- const post = (path: string, body: unknown) => fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
+ const post = (path: string, body: unknown) =>
+ fetch(`${base}${path}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
return { registry, base, post }
return { registry, base, post }
}
}
-const manifest = sampleManifest({ permissions: { capabilities: [CAP_PLACE_PROP, CAP_ANNOUNCE] } } as never)
+const manifest = sampleManifest({
+ permissions: { capabilities: [CAP_PLACE_PROP, CAP_ANNOUNCE] },
+} as never)
const pack = { props: [{ key: 'bench', item: 'workbench', pos: [0, 0, 0] }] }
const pack = { props: [{ key: 'bench', item: 'workbench', pos: [0, 0, 0] }] }
describe('mod principals in Network', () => {
describe('mod principals in Network', () => {
it('installs with what Network approved, and asks it before every grant change', async () => {
it('installs with what Network approved, and asks it before every grant change', async () => {
const net = fakeNetwork([CAP_PLACE_PROP])
const net = fakeNetwork([CAP_PLACE_PROP])
const { registry, post, base } = await start(net.grants)
const { registry, post, base } = await start(net.grants)
- const created = await post('', { manifest, pack, approve: [CAP_PLACE_PROP, CAP_ANNOUNCE], enable: true })
+ const created = await post('', {
+ manifest,
+ pack,
+ approve: [CAP_PLACE_PROP, CAP_ANNOUNCE],
+ enable: true,
+ })
expect(created.status).toBe(201)
expect(created.status).toBe(201)
expect(await created.json()).toMatchObject({ id: MOD_ID, granted: [CAP_PLACE_PROP] })
expect(await created.json()).toMatchObject({ id: MOD_ID, granted: [CAP_PLACE_PROP] })
expect(net.calls).toEqual([`register:${CAP_PLACE_PROP},${CAP_ANNOUNCE}`])
expect(net.calls).toEqual([`register:${CAP_PLACE_PROP},${CAP_ANNOUNCE}`])
expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)
expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
- expect((await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status).toBe(200)
+ expect(
+ (await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status,
+ ).toBe(200)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])
expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])
// Network's events arrive after the API applied its answers: an older revision changes nothing.
// Network's events arrive after the API applied its answers: an older revision changes nothing.
expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)
expect((await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })).status).toBe(200)
- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_PLACE_PROP], revision: 1 })
- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_PLACE_PROP], revision: 3 })
+ registry.applyNetwork({
+ mod_id: MOD_ID,
+ status: 'active',
+ approved: [CAP_PLACE_PROP],
+ revision: 1,
+ })
+ registry.applyNetwork({
+ mod_id: MOD_ID,
+ status: 'active',
+ approved: [CAP_PLACE_PROP],
+ revision: 3,
+ })
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(true)
expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(false)
expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(false)
// A newer one (a staff change in Network) applies.
// A newer one (a staff change in Network) applies.
- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_PLACE_PROP], revision: 5 })
+ registry.applyNetwork({
+ mod_id: MOD_ID,
+ status: 'active',
+ approved: [CAP_PLACE_PROP],
+ revision: 5,
+ })
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
- expect((await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status).toBe(200)
+ expect(
+ (await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status,
+ ).toBe(200)
// Network unreachable: 503, and nothing changes here.
// Network unreachable: 503, and nothing changes here.
net.state.down = true
net.state.down = true
@@ -130,10 +183,20 @@ describe('mod principals in Network', () => {
@@ -130,10 +183,20 @@ describe('mod principals in Network', () => {
it("applyNetwork makes an install's copy follow its principal", () => {
it("applyNetwork makes an install's copy follow its principal", () => {
const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())
const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())
registry.install({ manifest, pack, approve: [CAP_PLACE_PROP], enable: true }, STAFF)
registry.install({ manifest, pack, approve: [CAP_PLACE_PROP], enable: true }, STAFF)
- expect(registry.applyNetwork({ mod_id: 'mod_01JZZZZZZZZZZZZZZZZZZZZZZZ', status: 'active', approved: [] })).toBeNull()
+ expect(
+ registry.applyNetwork({
+ mod_id: 'mod_01JZZZZZZZZZZZZZZZZZZZZZZZ',
+ status: 'active',
+ approved: [],
+ }),
+ ).toBeNull()
registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE] })
registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE] })
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
- registry.applyNetwork({ mod_id: MOD_ID, status: 'active', approved: [CAP_ANNOUNCE, 'games.not.bindable'] })
+ registry.applyNetwork({
+ mod_id: MOD_ID,
+ status: 'active',
+ approved: [CAP_ANNOUNCE, 'games.not.bindable'],
+ })
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
expect([...(registry.get(MOD_ID)?.granted ?? [])]).toEqual([CAP_ANNOUNCE])
expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(true)
expect(registry.auditLog(MOD_ID).some((a) => a.actor === 'network')).toBe(true)
registry.applyNetwork({ mod_id: MOD_ID, status: 'revoked', approved: [] })
registry.applyNetwork({ mod_id: MOD_ID, status: 'revoked', approved: [] })
@@ -144,19 +207,51 @@ describe('mod principals in Network', () => {
@@ -144,19 +207,51 @@ describe('mod principals in Network', () => {
it('the client sends the principal token and maps Network answers', async () => {
it('the client sends the principal token and maps Network answers', async () => {
const seen: { url: string; auth: string | null; body: unknown }[] = []
const seen: { url: string; auth: string | null; body: unknown }[] = []
const answers: Response[] = [
const answers: Response[] = [
- Response.json({ mod_id: MOD_ID, owner: 'games', status: 'active', requested: [], approved: [], pending: [], revoked: [], revision: 1 }, { status: 201 }),
+ Response.json(
+ {
+ mod_id: MOD_ID,
+ owner: 'games',
+ status: 'active',
+ requested: [],
+ approved: [],
+ pending: [],
+ revoked: [],
+ revision: 1,
+ },
+ { status: 201 },
+ ),
Response.json({ error: 'mod.not_requested', detail: 'not requested' }, { status: 422 }),
Response.json({ error: 'mod.not_requested', detail: 'not requested' }, { status: 422 }),
Response.json({ error: 'server' }, { status: 502 }),
Response.json({ error: 'server' }, { status: 502 }),
]
]
const fetchImpl = (async (url: string, init?: RequestInit) => {
const fetchImpl = (async (url: string, init?: RequestInit) => {
- seen.push({ url, auth: new Headers(init?.headers).get('authorization'), body: init?.body ? JSON.parse(String(init.body)) : null })
+ seen.push({
+ url,
+ auth: new Headers(init?.headers).get('authorization'),
+ body: init?.body ? JSON.parse(String(init.body)) : null,
+ })
return answers.shift() as Response
return answers.shift() as Response
}) as unknown as typeof fetch
}) as unknown as typeof fetch
const scopes: (string | undefined)[] = []
const scopes: (string | undefined)[] = []
- const client = createNetworkModGrants({ networkUrl: 'http://network.test', tokens: { getToken: async (ctx) => { scopes.push(ctx?.scope); return 'tok' } }, fetchImpl })
+ const client = createNetworkModGrants({
+ networkUrl: 'http://network.test',
+ tokens: {
+ getToken: async (ctx) => {
+ scopes.push(ctx?.scope)
+ return 'tok'
+ },
+ },
+ fetchImpl,
+ })
expect((await client.register(manifest, [CAP_PLACE_PROP], 'usr_x')).revision).toBe(1)
expect((await client.register(manifest, [CAP_PLACE_PROP], 'usr_x')).revision).toBe(1)
- expect(seen[0]).toMatchObject({ url: 'http://network.test/internal/mods', auth: 'Bearer tok', body: { approve: [CAP_PLACE_PROP], actor: 'usr_x' } })
- await expect(client.change(MOD_ID, CAP_ANNOUNCE, 'approve', 'usr_x')).rejects.toMatchObject({ code: 'mod.not_requested', status: 422 })
+ expect(seen[0]).toMatchObject({
+ url: 'http://network.test/internal/mods',
+ auth: 'Bearer tok',
+ body: { approve: [CAP_PLACE_PROP], actor: 'usr_x' },
+ })
+ await expect(client.change(MOD_ID, CAP_ANNOUNCE, 'approve', 'usr_x')).rejects.toMatchObject({
+ code: 'mod.not_requested',
+ status: 422,
+ })
await expect(client.revoke(MOD_ID, 'usr_x')).rejects.toMatchObject({ status: 503 })
await expect(client.revoke(MOD_ID, 'usr_x')).rejects.toMatchObject({ status: 503 })
expect(scopes.every((s) => s === 'mods.grant.manage')).toBe(true)
expect(scopes.every((s) => s === 'mods.grant.manage')).toBe(true)
})
})
@@ -22,7 +22,12 @@ export interface ModPrincipal {
@@ -22,7 +22,12 @@ export interface ModPrincipal {
export interface ModGrantAuthority {
export interface ModGrantAuthority {
register(manifest: unknown, approve: readonly string[], actor: string): Promise<ModPrincipal>
register(manifest: unknown, approve: readonly string[], actor: string): Promise<ModPrincipal>
- change(modId: string, capability: string, action: 'approve' | 'revoke', actor: string): Promise<ModPrincipal>
+ change(
+ modId: string,
+ capability: string,
+ action: 'approve' | 'revoke',
+ actor: string,
+ ): Promise<ModPrincipal>
revoke(modId: string, actor: string, reason?: string): Promise<ModPrincipal>
revoke(modId: string, actor: string, reason?: string): Promise<ModPrincipal>
list(): Promise<ModPrincipal[]>
list(): Promise<ModPrincipal[]>
}
}
@@ -36,30 +41,59 @@ export function createNetworkModGrants(opts: {
@@ -36,30 +41,59 @@ export function createNetworkModGrants(opts: {
async function call<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
async function call<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
let res: Response
let res: Response
try {
try {
- const token = await opts.tokens.getToken({ audience: 'openvibe.network', scope: 'mods.grant.manage' })
+ const token = await opts.tokens.getToken({
+ audience: 'openvibe.network',
+ scope: 'mods.grant.manage',
+ })
res = await f(`${opts.networkUrl}/internal/mods${path}`, {
res = await f(`${opts.networkUrl}/internal/mods${path}`, {
method,
method,
- headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
+ headers: {
+ authorization: `Bearer ${token}`,
+ accept: 'application/json',
+ ...(body ? { 'content-type': 'application/json' } : {}),
+ },
...(body ? { body: JSON.stringify(body) } : {}),
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(10_000),
signal: AbortSignal.timeout(10_000),
})
})
} catch (err) {
} catch (err) {
- throw new ModError('mod.grants_unavailable', `OpenVibe.Network could not be asked: ${String((err as Error)?.message ?? err)}`, 503)
+ throw new ModError(
+ 'mod.grants_unavailable',
+ `OpenVibe.Network could not be asked: ${String((err as Error)?.message ?? err)}`,
+ 503,
+ )
}
}
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
if (!res.ok) {
- const code = typeof data.error === 'string' ? data.error : typeof data.code === 'string' ? data.code : 'mod.grants_refused'
- const status = res.status >= 500 ? 503 : res.status === 401 || res.status === 403 ? 503 : res.status
- throw new ModError(code, `OpenVibe.Network: ${typeof data.detail === 'string' ? data.detail : `HTTP ${res.status}`}`, status)
+ const code =
+ typeof data.error === 'string'
+ ? data.error
+ : typeof data.code === 'string'
+ ? data.code
+ : 'mod.grants_refused'
+ const status =
+ res.status >= 500 ? 503 : res.status === 401 || res.status === 403 ? 503 : res.status
+ throw new ModError(
+ code,
+ `OpenVibe.Network: ${typeof data.detail === 'string' ? data.detail : `HTTP ${res.status}`}`,
+ status,
+ )
}
}
return data as T
return data as T
}
}
return {
return {
- register: (manifest, approve, actor) => call<ModPrincipal>('POST', '', { manifest, approve: [...approve], actor }),
+ register: (manifest, approve, actor) =>
+ call<ModPrincipal>('POST', '', { manifest, approve: [...approve], actor }),
change: (modId, capability, action, actor) =>
change: (modId, capability, action, actor) =>
- call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/grants`, { capability, action, actor }),
+ call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/grants`, {
+ capability,
+ action,
+ actor,
+ }),
revoke: (modId, actor, reason) =>
revoke: (modId, actor, reason) =>
- call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/revoke`, { actor, ...(reason ? { reason } : {}) }),
+ call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/revoke`, {
+ actor,
+ ...(reason ? { reason } : {}),
+ }),
list: async () => (await call<{ mods: ModPrincipal[] }>('GET', '')).mods ?? [],
list: async () => (await call<{ mods: ModPrincipal[] }>('GET', '')).mods ?? [],
}
}
}
}
@@ -60,7 +60,10 @@ export interface ModActor {
@@ -60,7 +60,10 @@ export interface ModActor {
export const SYSTEM_ACTOR: ModActor = { audit: 'games', subject: { type: 'service', id: 'games' } }
export const SYSTEM_ACTOR: ModActor = { audit: 'games', subject: { type: 'service', id: 'games' } }
/** Changes that come from OpenVibe.Network's mod principal (network.mod.grants_changed). */
/** Changes that come from OpenVibe.Network's mod principal (network.mod.grants_changed). */
-export const NETWORK_ACTOR: ModActor = { audit: 'network', subject: { type: 'service', id: 'network' } }
+export const NETWORK_ACTOR: ModActor = {
+ audit: 'network',
+ subject: { type: 'service', id: 'network' },
+}
export class ModError extends Error {
export class ModError extends Error {
constructor(
constructor(
@@ -311,7 +314,10 @@ export class ModRegistry {
@@ -311,7 +314,10 @@ export class ModRegistry {
* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and
* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and
* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.
* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.
*/
*/
- applyNetwork(principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number }, actor: ModActor = NETWORK_ACTOR): ModView | null {
+ applyNetwork(
+ principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number },
+ actor: ModActor = NETWORK_ACTOR,
+ ): ModView | null {
const view = this.views.get(principal.mod_id)
const view = this.views.get(principal.mod_id)
if (!view) return null
if (!view) return null
// Events can arrive after the API already applied a newer answer: an older (or equal) revision changes nothing.
// Events can arrive after the API already applied a newer answer: an older (or equal) revision changes nothing.
@@ -319,11 +325,13 @@ export class ModRegistry {
@@ -319,11 +325,13 @@ export class ModRegistry {
if (principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)) return view
if (principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)) return view
this.networkRevision.set(view.mod.id, principal.revision)
this.networkRevision.set(view.mod.id, principal.revision)
}
}
- if (principal.status === 'revoked') return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')
+ if (principal.status === 'revoked')
+ return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')
if (view.mod.status === 'revoked') return view
if (view.mod.status === 'revoked') return view
const approved = new Set(principal.approved)
const approved = new Set(principal.approved)
let current = view
let current = view
- for (const cap of [...current.granted]) if (!approved.has(cap)) current = this.revokeGrant(view.mod.id, cap, actor)
+ for (const cap of [...current.granted])
+ if (!approved.has(cap)) current = this.revokeGrant(view.mod.id, cap, actor)
for (const cap of approved) {
for (const cap of approved) {
if (current.granted.has(cap)) continue
if (current.granted.has(cap)) continue
try {
try {
@@ -99,7 +99,10 @@ export class ModRuntime {
@@ -99,7 +99,10 @@ export class ModRuntime {
/** Reconciles in a row over the CPU budget, per mod. */
/** Reconciles in a row over the CPU budget, per mod. */
private readonly cpuStrikes = new Map<string, number>()
private readonly cpuStrikes = new Map<string, number>()
/** Host effects per mod in the current minute, and minutes in a row that were throttled. */
/** Host effects per mod in the current minute, and minutes in a row that were throttled. */
- private readonly effects = new Map<string, { windowStart: number; count: number; throttled: boolean; floodWindows: number }>()
+ private readonly effects = new Map<
+ string,
+ { windowStart: number; count: number; throttled: boolean; floodWindows: number }
+ >()
private readonly now: () => number
private readonly now: () => number
constructor(
constructor(
@@ -248,7 +251,8 @@ export class ModRuntime {
@@ -248,7 +251,8 @@ export class ModRuntime {
const nowMs = this.now()
const nowMs = this.now()
let e = this.effects.get(id)
let e = this.effects.get(id)
if (!e || nowMs - e.windowStart >= 60_000) {
if (!e || nowMs - e.windowStart >= 60_000) {
- const floodWindows = e && e.throttled && nowMs - e.windowStart < 120_000 ? e.floodWindows + 1 : 0
+ const floodWindows =
+ e && e.throttled && nowMs - e.windowStart < 120_000 ? e.floodWindows + 1 : 0
e = { windowStart: nowMs, count: 0, throttled: false, floodWindows }
e = { windowStart: nowMs, count: 0, throttled: false, floodWindows }
this.effects.set(id, e)
this.effects.set(id, e)
if (floodWindows >= FLOOD_WINDOWS) {
if (floodWindows >= FLOOD_WINDOWS) {
@@ -267,7 +271,11 @@ export class ModRuntime {
@@ -267,7 +271,11 @@ export class ModRuntime {
}
}
/** Disables the offending mod only; the others keep running. */
/** Disables the offending mod only; the others keep running. */
- private enforce(id: string, reason: 'cpu' | 'event_flood', detail: Record<string, unknown>): void {
+ private enforce(
+ id: string,
+ reason: 'cpu' | 'event_flood',
+ detail: Record<string, unknown>,
+ ): void {
if (!this.registry.isActive(id)) return
if (!this.registry.isActive(id)) return
this.registry.audit(id, 'budget_enforced', null, { reason, ...detail })
this.registry.audit(id, 'budget_enforced', null, { reason, ...detail })
this.registry.disable(id, SYSTEM_ACTOR)
this.registry.disable(id, SYSTEM_ACTOR)
@@ -306,7 +314,11 @@ export class ModRuntime {
@@ -306,7 +314,11 @@ export class ModRuntime {
const strikes = (this.cpuStrikes.get(view.mod.id) ?? 0) + 1
const strikes = (this.cpuStrikes.get(view.mod.id) ?? 0) + 1
this.cpuStrikes.set(view.mod.id, strikes)
this.cpuStrikes.set(view.mod.id, strikes)
if (strikes >= CPU_STRIKES) {
if (strikes >= CPU_STRIKES) {
- this.enforce(view.mod.id, 'cpu', { cpu_ms: Math.round(ms * 1000) / 1000, budget_ms: budget, reconciles: strikes })
+ this.enforce(view.mod.id, 'cpu', {
+ cpu_ms: Math.round(ms * 1000) / 1000,
+ budget_ms: budget,
+ reconciles: strikes,
+ })
return
return
}
}
const nowMs = this.now()
const nowMs = this.now()
@@ -4,7 +4,13 @@ import { createConsoleLogger } from '@openvibe/shared'
@@ -4,7 +4,13 @@ import { createConsoleLogger } from '@openvibe/shared'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod, validateContentPack } from './contentPack.js'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod, validateContentPack } from './contentPack.js'
import { ModRegistry, type ModActor } from './registry.js'
import { ModRegistry, type ModActor } from './registry.js'
-import { CPU_STRIKES, FLOOD_WINDOWS, ModRuntime, ModThrottledError, type ModHost } from './runtime.js'
+import {
+ CPU_STRIKES,
+ FLOOD_WINDOWS,
+ ModRuntime,
+ ModThrottledError,
+ type ModHost,
+} from './runtime.js'
import { sampleManifest } from './testFixtures.js'
import { sampleManifest } from './testFixtures.js'
/**
/**
@@ -17,7 +23,10 @@ import { sampleManifest } from './testFixtures.js'
@@ -17,7 +23,10 @@ import { sampleManifest } from './testFixtures.js'
* forged ownership every prop a mod places is owned by that mod, whatever the pack says; an unknown or
* forged ownership every prop a mod places is owned by that mod, whatever the pack says; an unknown or
* another runtime's principal changes nothing here
* another runtime's principal changes nothing here
*/
*/
-const STAFF: ModActor = { audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' } }
+const STAFF: ModActor = {
+ audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0',
+ subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' },
+}
const content = createContent()
const content = createContent()
const log = createConsoleLogger({ app: 'test' }, 'error')
const log = createConsoleLogger({ app: 'test' }, 'error')
const A = 'mod_01JABCDEFGHJKMNPQRSTVWXYZA'
const A = 'mod_01JABCDEFGHJKMNPQRSTVWXYZA'
@@ -51,13 +60,29 @@ function setup(effectsPerMinute = 60) {
@@ -51,13 +60,29 @@ function setup(effectsPerMinute = 60) {
const clock = { now: () => now, advance: (ms: number) => (now += ms) }
const clock = { now: () => now, advance: (ms: number) => (now += ms) }
const store = openSqliteStore(':memory:')
const store = openSqliteStore(':memory:')
const registry = new ModRegistry(store, content, undefined, clock.now)
const registry = new ModRegistry(store, content, undefined, clock.now)
- const runtime = new ModRuntime(registry, store, content, log, { reconcileEveryTicks: 1, now: clock.now, effectsPerMinute })
+ const runtime = new ModRuntime(registry, store, content, log, {
+ reconcileEveryTicks: 1,
+ now: clock.now,
+ effectsPerMinute,
+ })
const install = (id: string, pack: unknown, approve: string[], cpuMs = 50) =>
const install = (id: string, pack: unknown, approve: string[], cpuMs = 50) =>
- registry.install({ manifest: sampleManifest({ id, resources: { cpuMs, memoryMb: 0, storageMb: 0 } }), pack, approve, enable: true }, STAFF)
+ registry.install(
+ {
+ manifest: sampleManifest({ id, resources: { cpuMs, memoryMb: 0, storageMb: 0 } }),
+ pack,
+ approve,
+ enable: true,
+ },
+ STAFF,
+ )
return { store, registry, runtime, clock, install }
return { store, registry, runtime, clock, install }
}
}
-const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({ key, item: 'workbench', pos })
+const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({
+ key,
+ item: 'workbench',
+ pos,
+})
describe('mod security', () => {
describe('mod security', () => {
it('namespace escape: a mod reaches only its own placements, through frozen bindings', () => {
it('namespace escape: a mod reaches only its own placements, through frozen bindings', () => {
@@ -76,12 +101,18 @@ describe('mod security', () => {
@@ -76,12 +101,18 @@ describe('mod security', () => {
expect(apiA.removeProp('bench')).toBe(true)
expect(apiA.removeProp('bench')).toBe(true)
expect([...w.entities.values()].map((e) => e.owner)).toEqual([B])
expect([...w.entities.values()].map((e) => e.owner)).toEqual([B])
expect(apiA.removeProp('bench')).toBe(false)
expect(apiA.removeProp('bench')).toBe(false)
- expect(registry.auditLog(B).some((a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed'))).toBe(false)
+ expect(
+ registry
+ .auditLog(B)
+ .some((a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed')),
+ ).toBe(false)
// Only inert items: anything with health, storage, a shop, a machine or a vehicle part is refused, at install
// Only inert items: anything with health, storage, a shop, a machine or a vehicle part is refused, at install
// and at the binding.
// and at the binding.
const loot = content.allItems().find((i) => placeableByMod(content, i.id) !== null)
const loot = content.allItems().find((i) => placeableByMod(content, i.id) !== null)
expect(loot).toBeDefined()
expect(loot).toBeDefined()
- expect(validateContentPack({ props: [{ key: 'x', item: loot!.id, pos: [0, 0, 0] }] }, content).ok).toBe(false)
+ expect(
+ validateContentPack({ props: [{ key: 'x', item: loot!.id, pos: [0, 0, 0] }] }, content).ok,
+ ).toBe(false)
expect(() => apiA.placeProp('x', loot!.id, [0, 0, 0])).toThrow(/not allowed/)
expect(() => apiA.placeProp('x', loot!.id, [0, 0, 0])).toThrow(/not allowed/)
})
})
@@ -117,7 +148,13 @@ describe('mod security', () => {
@@ -117,7 +148,13 @@ describe('mod security', () => {
}
}
}
}
expect(registry.get(A)?.mod.status).toBe('disabled')
expect(registry.get(A)?.mod.status).toBe('disabled')
- expect(registry.auditLog(A).some((a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'))).toBe(true)
+ expect(
+ registry
+ .auditLog(A)
+ .some(
+ (a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'),
+ ),
+ ).toBe(true)
expect(registry.get(B)?.mod.status).toBe('enabled')
expect(registry.get(B)?.mod.status).toBe('enabled')
expect(() => apiA.announce('after')).toThrow()
expect(() => apiA.announce('after')).toThrow()
})
})
@@ -141,20 +178,35 @@ describe('mod security', () => {
@@ -141,20 +178,35 @@ describe('mod security', () => {
expect([...slow.entities.values()].filter((e) => e.owner === A)).toEqual([])
expect([...slow.entities.values()].filter((e) => e.owner === A)).toEqual([])
})
})
- it('forged ownership: props are the mod\'s whatever the pack says; unknown or foreign principals change nothing', () => {
+ it("forged ownership: props are the mod's whatever the pack says; unknown or foreign principals change nothing", () => {
const { registry, runtime, install } = setup()
const { registry, runtime, install } = setup()
const w = world()
const w = world()
- expect(validateContentPack({ props: [{ ...prop('x'), owner: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' }] }, content).ok).toBe(false)
+ expect(
+ validateContentPack(
+ { props: [{ ...prop('x'), owner: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' }] },
+ content,
+ ).ok,
+ ).toBe(false)
install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
runtime.tick(1, w.host)
runtime.tick(1, w.host)
expect([...w.entities.values()]).toEqual([{ item: 'workbench', owner: A }])
expect([...w.entities.values()]).toEqual([{ item: 'workbench', owner: A }])
// A principal Games does not have, or that another runtime owns, is not applied here.
// A principal Games does not have, or that another runtime owns, is not applied here.
- expect(registry.applyNetwork({ mod_id: B, status: 'active', approved: [CAP_PLACE_PROP, CAP_ANNOUNCE] })).toBeNull()
+ expect(
+ registry.applyNetwork({
+ mod_id: B,
+ status: 'active',
+ approved: [CAP_PLACE_PROP, CAP_ANNOUNCE],
+ }),
+ ).toBeNull()
expect(registry.get(B)).toBeNull()
expect(registry.get(B)).toBeNull()
// A grant the manifest never requested cannot be applied, even from Network.
// A grant the manifest never requested cannot be applied, even from Network.
const manifestA = sampleManifest({ id: A, permissions: { capabilities: [CAP_PLACE_PROP] } })
const manifestA = sampleManifest({ id: A, permissions: { capabilities: [CAP_PLACE_PROP] } })
expect(manifestA.permissions.capabilities).not.toContain('games.world.teleport')
expect(manifestA.permissions.capabilities).not.toContain('games.world.teleport')
- registry.applyNetwork({ mod_id: A, status: 'active', approved: [CAP_PLACE_PROP, 'games.world.teleport'] })
+ registry.applyNetwork({
+ mod_id: A,
+ status: 'active',
+ approved: [CAP_PLACE_PROP, 'games.world.teleport'],
+ })
expect([...(registry.get(A)?.granted ?? [])]).toEqual([CAP_PLACE_PROP])
expect([...(registry.get(A)?.granted ?? [])]).toEqual([CAP_PLACE_PROP])
})
})
})
})
@@ -3,7 +3,12 @@ import type { AddressInfo } from 'node:net'
@@ -3,7 +3,12 @@ import type { AddressInfo } from 'node:net'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
-import { createRevocationEvents, ensureRevocationSubscription, MERGE_TOPIC, TOPIC } from './revocationEvents.js'
+import {
+ createRevocationEvents,
+ ensureRevocationSubscription,
+ MERGE_TOPIC,
+ TOPIC,
+} from './revocationEvents.js'
/**
/**
* POST /internal/events for network.user.token_valid_after: signature v2 only, loopback only, the
* POST /internal/events for network.user.token_valid_after: signature v2 only, loopback only, the
@@ -39,7 +44,8 @@ const events = createRevocationEvents({
@@ -39,7 +44,8 @@ const events = createRevocationEvents({
},
},
onAccountEvent: async (event) => {
onAccountEvent: async (event) => {
accountCalls.push(String(event.event_type))
accountCalls.push(String(event.event_type))
- if (event.payload?.export_id === 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9') throw new Error('Network down')
+ if (event.payload?.export_id === 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9')
+ throw new Error('Network down')
return 'exported'
return 'exported'
},
},
log,
log,
@@ -65,18 +71,33 @@ const envelope = (over: Record<string, unknown> = {}) => ({
@@ -65,18 +71,33 @@ const envelope = (over: Record<string, unknown> = {}) => ({
event_type: TOPIC,
event_type: TOPIC,
version: 1,
version: 1,
source: 'network',
source: 'network',
- payload: { subject: { type: 'user', id: SUBJECT }, valid_after: new Date(at).toISOString(), reason: 'signed_out_everywhere' },
+ payload: {
+ subject: { type: 'user', id: SUBJECT },
+ valid_after: new Date(at).toISOString(),
+ reason: 'signed_out_everywhere',
+ },
...over,
...over,
})
})
const post = async (event: unknown, headers: Record<string, string> = {}, secret = SECRET) => {
const post = async (event: unknown, headers: Record<string, string> = {}, secret = SECRET) => {
const body = JSON.stringify({ event, seq: 1 })
const body = JSON.stringify({ event, seq: 1 })
- const r = await fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })
+ const r = await fetch(url, {
+ method: 'POST',
+ body,
+ headers: {
+ 'content-type': 'application/json',
+ ...signDeliveryHeaders(body, secret),
+ ...headers,
+ },
+ })
return { status: r.status, body: (await r.json()) as { outcome?: string; closed?: number } }
return { status: r.status, body: (await r.json()) as { outcome?: string; closed?: number } }
}
}
describe('revocation events', () => {
describe('revocation events', () => {
it('applies a signed delivery once and closes the sessions', async () => {
it('applies a signed delivery once and closes the sessions', async () => {
- expect(await post(envelope())).toMatchObject({ status: 200, body: { outcome: 'revoked', closed: 2 } })
+ expect(await post(envelope())).toMatchObject({
+ status: 200,
+ body: { outcome: 'revoked', closed: 2 },
+ })
expect(closedFor).toEqual([[SUBJECT, at]])
expect(closedFor).toEqual([[SUBJECT, at]])
expect(events.store.isRevoked({ subject_id: SUBJECT, iat: at / 1000 - 1 })).toBe(true)
expect(events.store.isRevoked({ subject_id: SUBJECT, iat: at / 1000 - 1 })).toBe(true)
expect((await post(envelope())).body.outcome).toBe('unchanged')
expect((await post(envelope())).body.outcome).toBe('unchanged')
@@ -87,9 +108,23 @@ describe('revocation events', () => {
@@ -87,9 +108,23 @@ describe('revocation events', () => {
it('an account merge (ADR-029) moves characters through onMerged; foreign or malformed ones are ignored', async () => {
it('an account merge (ADR-029) moves characters through onMerged; foreign or malformed ones are ignored', async () => {
const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4'
const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4'
const merged = (over: Record<string, unknown> = {}) =>
const merged = (over: Record<string, unknown> = {}) =>
- envelope({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5', event_type: MERGE_TOPIC, payload: { merge_id: 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', from: FROM, into: SUBJECT, merged_at: new Date(at).toISOString(), initiated_by: 'person' }, ...over })
+ envelope({
+ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5',
+ event_type: MERGE_TOPIC,
+ payload: {
+ merge_id: 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6',
+ from: FROM,
+ into: SUBJECT,
+ merged_at: new Date(at).toISOString(),
+ initiated_by: 'person',
+ },
+ ...over,
+ })
expect((await post(merged({ source: 'live' }))).body.outcome).toBe('ignored:source')
expect((await post(merged({ source: 'live' }))).body.outcome).toBe('ignored:source')
- expect((await post(merged({ payload: { merge_id: 'nope', from: FROM, into: SUBJECT } }))).body.outcome).toBe('ignored:payload')
+ expect(
+ (await post(merged({ payload: { merge_id: 'nope', from: FROM, into: SUBJECT } }))).body
+ .outcome,
+ ).toBe('ignored:payload')
expect(mergedCalls).toEqual([])
expect(mergedCalls).toEqual([])
expect((await post(merged())).body.outcome).toBe('merged')
expect((await post(merged())).body.outcome).toBe('merged')
expect(mergedCalls).toEqual([[FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6']])
expect(mergedCalls).toEqual([[FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6']])
@@ -98,18 +133,49 @@ describe('revocation events', () => {
@@ -98,18 +133,49 @@ describe('revocation events', () => {
it('account export and deletion (ADR-033) go to onAccountEvent, answered once it resolved (500 when it failed)', async () => {
it('account export and deletion (ADR-033) go to onAccountEvent, answered once it resolved (500 when it failed)', async () => {
const exp = (id: string) =>
const exp = (id: string) =>
- envelope({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2E7', event_type: 'network.account.export_requested', payload: { export_id: id, subject: SUBJECT } })
- expect(await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Y8'))).toMatchObject({ status: 200, body: { outcome: 'exported' } })
+ envelope({
+ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2E7',
+ event_type: 'network.account.export_requested',
+ payload: { export_id: id, subject: SUBJECT },
+ })
+ expect(await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Y8'))).toMatchObject({
+ status: 200,
+ body: { outcome: 'exported' },
+ })
expect((await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9'))).status).toBe(500)
expect((await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9'))).status).toBe(500)
- expect(accountCalls).toEqual(['network.account.export_requested', 'network.account.export_requested'])
+ expect(accountCalls).toEqual([
+ 'network.account.export_requested',
+ 'network.account.export_requested',
+ ])
})
})
it("network.mod.grants_changed (ADR-013) reaches onModGrants for Games' own mods only", async () => {
it("network.mod.grants_changed (ADR-013) reaches onModGrants for Games' own mods only", async () => {
const MOD = 'mod_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1'
const MOD = 'mod_01J8Z3Q4R5S6T7V8W9X0Y1Z2M1'
const changed = (over: Record<string, unknown> = {}) =>
const changed = (over: Record<string, unknown> = {}) =>
- envelope({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2M2', event_type: 'network.mod.grants_changed', payload: { mod_id: MOD, owner: 'games', status: 'active', approved: ['games.prop.place'], revision: 2, change: { action: 'revoke', capability: 'games.world.announce' }, by: 'staff' }, ...over })
+ envelope({
+ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2M2',
+ event_type: 'network.mod.grants_changed',
+ payload: {
+ mod_id: MOD,
+ owner: 'games',
+ status: 'active',
+ approved: ['games.prop.place'],
+ revision: 2,
+ change: { action: 'revoke', capability: 'games.world.announce' },
+ by: 'staff',
+ },
+ ...over,
+ })
expect((await post(changed({ source: 'games' }))).body.outcome).toBe('ignored:source')
expect((await post(changed({ source: 'games' }))).body.outcome).toBe('ignored:source')
- expect((await post(changed({ payload: { mod_id: MOD, owner: 'tools', status: 'active', approved: [], revision: 1 } }))).body.outcome).toBe('ignored:owner')
+ expect(
+ (
+ await post(
+ changed({
+ payload: { mod_id: MOD, owner: 'tools', status: 'active', approved: [], revision: 1 },
+ }),
+ )
+ ).body.outcome,
+ ).toBe('ignored:owner')
expect((await post(changed())).body.outcome).toBe('mod:enabled')
expect((await post(changed())).body.outcome).toBe('mod:enabled')
expect(modGrantCalls).toEqual([[MOD, 'active', 'games.prop.place']])
expect(modGrantCalls).toEqual([[MOD, 'active', 'games.prop.place']])
})
})
@@ -129,13 +195,24 @@ describe('revocation events', () => {
@@ -129,13 +195,24 @@ describe('revocation events', () => {
}
}
return Response.json({ subscriptions: subs.map((s, i) => ({ id: `sub_${i + 1}`, ...s })) })
return Response.json({ subscriptions: subs.map((s, i) => ({ id: `sub_${i + 1}`, ...s })) })
}) as unknown as typeof fetch
}) as unknown as typeof fetch
- const opts = { eventsUrl: 'http://events.test', endpoint: 'http://127.0.0.1:8000/internal/events', secret: SECRET, tokens: { getToken: async () => 'tok' }, fetchImpl: fake, log }
+ const opts = {
+ eventsUrl: 'http://events.test',
+ endpoint: 'http://127.0.0.1:8000/internal/events',
+ secret: SECRET,
+ tokens: { getToken: async () => 'tok' },
+ fetchImpl: fake,
+ log,
+ }
expect(await ensureRevocationSubscription(opts)).toBe('created')
expect(await ensureRevocationSubscription(opts)).toBe('created')
expect(await ensureRevocationSubscription(opts)).toBe('exists')
expect(await ensureRevocationSubscription(opts)).toBe('exists')
expect(subs).toEqual([
expect(subs).toEqual([
{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET },
{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET },
{ topic_pattern: MERGE_TOPIC, endpoint: opts.endpoint, secret: SECRET },
{ topic_pattern: MERGE_TOPIC, endpoint: opts.endpoint, secret: SECRET },
- { topic_pattern: 'network.account.export_requested', endpoint: opts.endpoint, secret: SECRET },
+ {
+ topic_pattern: 'network.account.export_requested',
+ endpoint: opts.endpoint,
+ secret: SECRET,
+ },
{ topic_pattern: 'network.account.deleted', endpoint: opts.endpoint, secret: SECRET },
{ topic_pattern: 'network.account.deleted', endpoint: opts.endpoint, secret: SECRET },
{ topic_pattern: 'network.mod.grants_changed', endpoint: opts.endpoint, secret: SECRET },
{ topic_pattern: 'network.mod.grants_changed', endpoint: opts.endpoint, secret: SECRET },
])
])
@@ -37,7 +37,16 @@ type ParseDelivery = (
@@ -37,7 +37,16 @@ type ParseDelivery = (
event_id?: string
event_id?: string
event_type?: string
event_type?: string
source?: string
source?: string
- payload?: { subject?: { id?: string } | string; valid_after?: string; merge_id?: string; from?: string; into?: string; export_id?: string; deletion_id?: string; aliases?: unknown }
+ payload?: {
+ subject?: { id?: string } | string
+ valid_after?: string
+ merge_id?: string
+ from?: string
+ into?: string
+ export_id?: string
+ deletion_id?: string
+ aliases?: unknown
+ }
}
}
} | null
} | null
@@ -49,7 +58,13 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDe
@@ -49,7 +58,13 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDe
export const TOPIC = 'network.user.token_valid_after'
export const TOPIC = 'network.user.token_valid_after'
export const MERGE_TOPIC = 'network.subject.merged'
export const MERGE_TOPIC = 'network.subject.merged'
export const MOD_GRANTS_TOPIC = 'network.mod.grants_changed'
export const MOD_GRANTS_TOPIC = 'network.mod.grants_changed'
-export const TOPICS = [TOPIC, MERGE_TOPIC, 'network.account.export_requested', 'network.account.deleted', MOD_GRANTS_TOPIC] as const
+export const TOPICS = [
+ TOPIC,
+ MERGE_TOPIC,
+ 'network.account.export_requested',
+ 'network.account.deleted',
+ MOD_GRANTS_TOPIC,
+] as const
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/
const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/
const PATH = '/internal/events'
const PATH = '/internal/events'
@@ -67,11 +82,23 @@ export function createRevocationEvents(opts: {
@@ -67,11 +82,23 @@ export function createRevocationEvents(opts: {
secrets: string[]
secrets: string[]
onRevoked: (subjectId: string, validAfterMs: number) => number
onRevoked: (subjectId: string, validAfterMs: number) => number
/** An account merge: move `from`'s characters to `into` (returns what moved; `already` when applied before). */
/** An account merge: move `from`'s characters to `into` (returns what moved; `already` when applied before). */
- onMerged?: (from: string, into: string, mergeId: string) => { moved: number; kept: number; already: boolean }
+ onMerged?: (
+ from: string,
+ into: string,
+ mergeId: string,
+ ) => { moved: number; kept: number; already: boolean }
/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
- onAccountEvent?: (event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>) => Promise<string>
+ onAccountEvent?: (
+ event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,
+ ) => Promise<string>
/** A mod principal changed in Network: make the install's grants match (returns the outcome). */
/** A mod principal changed in Network: make the install's grants match (returns the outcome). */
- onModGrants?: (payload: { mod_id: string; owner: string; status: string; approved: string[]; revision: number }) => string
+ onModGrants?: (payload: {
+ mod_id: string
+ owner: string
+ status: string
+ approved: string[]
+ revision: number
+ }) => string
log: Logger
log: Logger
}): RevocationEvents {
}): RevocationEvents {
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
@@ -87,7 +114,11 @@ export function createRevocationEvents(opts: {
@@ -87,7 +114,11 @@ export function createRevocationEvents(opts: {
send(res, 405, { error: 'POST only' })
send(res, 405, { error: 'POST only' })
return true
return true
}
}
- if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {
+ if (
+ req.headers['x-forwarded-for'] ||
+ req.headers['x-real-ip'] ||
+ req.headers['cf-connecting-ip']
+ ) {
send(res, 403, { error: 'internal route' })
send(res, 403, { error: 'internal route' })
return true
return true
}
}
@@ -116,7 +147,10 @@ export function createRevocationEvents(opts: {
@@ -116,7 +147,10 @@ export function createRevocationEvents(opts: {
}
}
stats.received++
stats.received++
const event = delivery.event
const event = delivery.event
- if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {
+ if (
+ event.event_type === 'network.account.export_requested' ||
+ event.event_type === 'network.account.deleted'
+ ) {
if (!opts.onAccountEvent) {
if (!opts.onAccountEvent) {
stats.ignored++
stats.ignored++
send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })
send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })
@@ -128,20 +162,41 @@ export function createRevocationEvents(opts: {
@@ -128,20 +162,41 @@ export function createRevocationEvents(opts: {
send(res, 200, { event_id: event.event_id ?? null, outcome })
send(res, 200, { event_id: event.event_id ?? null, outcome })
},
},
(err: unknown) => {
(err: unknown) => {
- opts.log.warn('account event failed (Events retries)', { type: event.event_type, error: String((err as Error)?.message ?? err) })
+ opts.log.warn('account event failed (Events retries)', {
+ type: event.event_type,
+ error: String((err as Error)?.message ?? err),
+ })
send(res, 500, { error: 'not applied' })
send(res, 500, { error: 'not applied' })
},
},
)
)
return
return
}
}
if (event.event_type === MOD_GRANTS_TOPIC) {
if (event.event_type === MOD_GRANTS_TOPIC) {
- const p = (event.payload ?? {}) as { mod_id?: unknown; owner?: unknown; status?: unknown; approved?: unknown; revision?: unknown }
+ const p = (event.payload ?? {}) as {
+ mod_id?: unknown
+ owner?: unknown
+ status?: unknown
+ approved?: unknown
+ revision?: unknown
+ }
let outcome: string
let outcome: string
if (event.source !== 'network') outcome = 'ignored:source'
if (event.source !== 'network') outcome = 'ignored:source'
else if (!opts.onModGrants) outcome = 'ignored:no_handler'
else if (!opts.onModGrants) outcome = 'ignored:no_handler'
- else if (typeof p.mod_id !== 'string' || !Array.isArray(p.approved) || typeof p.status !== 'string') outcome = 'ignored:payload'
+ else if (
+ typeof p.mod_id !== 'string' ||
+ !Array.isArray(p.approved) ||
+ typeof p.status !== 'string'
+ )
+ outcome = 'ignored:payload'
else if (p.owner !== 'games') outcome = 'ignored:owner'
else if (p.owner !== 'games') outcome = 'ignored:owner'
- else outcome = opts.onModGrants({ mod_id: p.mod_id, owner: p.owner, status: p.status, approved: p.approved.map(String), revision: Number(p.revision) || 0 })
+ else
+ outcome = opts.onModGrants({
+ mod_id: p.mod_id,
+ owner: p.owner,
+ status: p.status,
+ approved: p.approved.map(String),
+ revision: Number(p.revision) || 0,
+ })
if (outcome.startsWith('ignored')) stats.ignored++
if (outcome.startsWith('ignored')) stats.ignored++
send(res, 200, { event_id: event.event_id ?? null, outcome })
send(res, 200, { event_id: event.event_id ?? null, outcome })
return
return
@@ -151,11 +206,18 @@ export function createRevocationEvents(opts: {
@@ -151,11 +206,18 @@ export function createRevocationEvents(opts: {
let outcome = 'merged'
let outcome = 'merged'
if (event.source !== 'network') outcome = 'ignored:source'
if (event.source !== 'network') outcome = 'ignored:source'
else if (!opts.onMerged) outcome = 'ignored:no_handler'
else if (!opts.onMerged) outcome = 'ignored:no_handler'
- else if (!MERGE_RE.test(String(p.merge_id ?? '')) || !SUBJECT_RE.test(String(p.from ?? '')) || !SUBJECT_RE.test(String(p.into ?? '')) || p.from === p.into) outcome = 'ignored:payload'
+ else if (
+ !MERGE_RE.test(String(p.merge_id ?? '')) ||
+ !SUBJECT_RE.test(String(p.from ?? '')) ||
+ !SUBJECT_RE.test(String(p.into ?? '')) ||
+ p.from === p.into
+ )
+ outcome = 'ignored:payload'
else {
else {
const r = opts.onMerged(String(p.from), String(p.into), String(p.merge_id))
const r = opts.onMerged(String(p.from), String(p.into), String(p.merge_id))
outcome = r.already ? 'unchanged' : 'merged'
outcome = r.already ? 'unchanged' : 'merged'
- if (!r.already) opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })
+ if (!r.already)
+ opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })
}
}
if (outcome.startsWith('ignored')) stats.ignored++
if (outcome.startsWith('ignored')) stats.ignored++
send(res, 200, { event_id: event.event_id ?? null, outcome })
send(res, 200, { event_id: event.event_id ?? null, outcome })
@@ -192,14 +254,24 @@ export async function ensureRevocationSubscription(opts: {
@@ -192,14 +254,24 @@ export async function ensureRevocationSubscription(opts: {
log: Logger
log: Logger
}): Promise<'exists' | 'created'> {
}): Promise<'exists' | 'created'> {
const f = opts.fetchImpl ?? fetch
const f = opts.fetchImpl ?? fetch
- const token = await opts.tokens.getToken({ audience: 'openvibe.events', scope: 'events.subscription.manage' })
+ const token = await opts.tokens.getToken({
+ audience: 'openvibe.events',
+ scope: 'events.subscription.manage',
+ })
const headers = { authorization: `Bearer ${token}`, accept: 'application/json' }
const headers = { authorization: `Bearer ${token}`, accept: 'application/json' }
const list = await f(`${opts.eventsUrl}/api/v1/subscriptions`, { headers })
const list = await f(`${opts.eventsUrl}/api/v1/subscriptions`, { headers })
if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`)
if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`)
- const body = (await list.json()) as { subscriptions?: { id: string; topic_pattern: string; endpoint: string }[] }
+ const body = (await list.json()) as {
+ subscriptions?: { id: string; topic_pattern: string; endpoint: string }[]
+ }
let created = 0
let created = 0
for (const topic of TOPICS) {
for (const topic of TOPICS) {
- if ((body.subscriptions ?? []).some((s) => s.topic_pattern === topic && s.endpoint === opts.endpoint)) continue
+ if (
+ (body.subscriptions ?? []).some(
+ (s) => s.topic_pattern === topic && s.endpoint === opts.endpoint,
+ )
+ )
+ continue
const r = await f(`${opts.eventsUrl}/api/v1/subscriptions`, {
const r = await f(`${opts.eventsUrl}/api/v1/subscriptions`, {
method: 'POST',
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
headers: { ...headers, 'content-type': 'application/json' },