openvibers/openvibe.games Commits

Mod grants live in OpenVibe.Network (roadmap WS-M task 3, ADR-013; Network c3a6441, Contracts 0.72.0 mods.grant.manage). With a platform client, the mods API asks Network first and applies only what it answered (apps/server/src/mods/networkGrants.ts):

OP OpenVibers committed 27 Sept 2026 · 86fe34d7c1 · 7 files, +350 −19

- install checks everything locally (ModRegistry.checkInstall), registers mod:<id> in Network, and grants what Network approved;
- grant (checkGrant first), revoke-grant and revoke change the principal before the local copy.
A Network refusal keeps its code and status; Network unreachable is 503 mod.grants_unavailable; in both cases nothing changes here. Enable and disable stay the runtime's own switch.

network.mod.grants_changed (a fifth events subscription) and a read at boot make an install's copy follow its principal (ModRegistry.applyNetwork, audited as 'network'):
- a revoked principal revokes the install;
- capabilities Network does not approve are revoked;
- approved ones this runtime can bind are granted.
Only Games' own mods are applied, and boot logs local installs without a principal.

apps/server/src/mods/networkGrants.test.ts: install with what Network approved, calls in order, 503 changes nothing, an unregistered install stores nothing, applyNetwork, and the client's token scope and error mapping. revocationEvents.test.ts: dispatch and owner check, five subscriptions. pnpm test 876/876 (a rerun: sqliteStore's v1 migration test timed out once under parallel load, 0.5 s alone), typecheck clean.

Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 0b6c161..6ec8ec7 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -31,6 +31,7 @@ import { createReadiness } from './observability/readiness.js'
import { buildRelease, releaseHandler } from './observability/release.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
import { ModRegistry } from './mods/registry.js'
++import { createNetworkModGrants } from './mods/networkGrants.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
@@@ -140,6 +141,20 @@ async function main(): Promise<void> {
const modRuntime = new ModRuntime(mods, store, content, log.child({ system: 'mods' }), {
reconcileEveryTicks: config.tickRate,
})
++ // Mod principals live in Network (ADR-013, WS-M task 3): the API asks it first; at boot every install's copy
++ // catches up with its principal (a change staff made while this server was down).
++ const modGrants = platform ? createNetworkModGrants({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }) : null
++ if (modGrants) {
++ modGrants
++ .list()
++ .then((principals) => {
++ for (const p of principals) mods.applyNetwork(p)
++ const known = new Set(principals.map((p) => p.mod_id))
++ const unregistered = mods.list().filter((v) => !known.has(v.mod.id) && v.mod.status !== 'revoked').map((v) => v.mod.id)
++ if (unregistered.length) log.warn('mod installs without a Network principal', { mods: unregistered.join(',') })
++ })
++ .catch((err: unknown) => log.warn('mod principals not read at boot', { error: String((err as Error)?.message ?? err) }))
++ }
const mirror =
platform && config.platform.mediaUrl
? new MediaMirror({
@@@ -199,6 +214,11 @@ async function main(): Promise<void> {
// Account export and deletion (ADR-033): the part goes to Network; a deleted account's sessions close (each
// close saves its character) before its characters are erased.
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
++ // Mod principals (ADR-013): an install's grants follow its principal in Network (a change staff made there).
++ onModGrants: (p) => {
++ const view = mods.applyNetwork(p)
++ return view ? `mod:${view.mod.status}` : 'ignored:unknown_mod'
++ },
log: platformLog,
})
if (
@@@ -321,6 +341,7 @@ async function main(): Promise<void> {
return handleModsRequest(req, res, {
registry: mods,
authorize: authorizeStaff,
++ ...(modGrants ? { grants: modGrants } : {}),
log: log.child({ system: 'mods-api' }),
})
},
ddiff --git a/apps/server/src/mods/networkGrants.test.ts b/apps/server/src/mods/networkGrants.test.ts
nnew file mode 100644
iindex 0000000..f8bac8e
---- /dev/null
++++ b/apps/server/src/mods/networkGrants.test.ts
@@@ -0,0 +1,153 @@
++import { createServer, type Server } from 'node:http'
++import type { AddressInfo } from 'node:net'
++import { createContent } from '@openvibe/content'
++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 { ModError, ModRegistry, type ModActor } from './registry.js'
++import { handleModsRequest } from './routes.js'
++import { MOD_ID, sampleManifest } from './testFixtures.js'
++
++/**
++ * Mod principals in Network (roadmap WS-M task 3, ADR-013): the mods API asks Network first and applies only what
++ * it answered (install grants what Network approved; a refusal or an unreachable Network changes nothing here);
++ * applyNetwork makes an install's copy follow its principal (a staff change in Network, or the boot read); the
++ * 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' } }
++let server: Server | null = null
++afterEach(() => {
++ server?.close()
++ server = null
++})
++
++/** A Network stand-in: approves only what `allow` lists; `down` makes every call fail. */
++function fakeNetwork(allow: string[]) {
++ const calls: string[] = []
++ let principal: ModPrincipal | null = null
++ const state = { down: false }
++ const ensure = () => {
++ 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
++ 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 }
++ 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.revision++
++ return p
++ },
++ async revoke() {
++ calls.push('revoke')
++ const p = ensure()
++ p.status = 'revoked'
++ p.approved = []
++ return p
++ },
++ async list() {
++ return principal ? [principal] : []
++ },
++ }
++ return { grants, calls, state }
++}
++
++async function start(grants: ModGrantAuthority) {
++ const registry = new ModRegistry(openSqliteStore(':memory:'), createContent())
++ server = createServer((req, res) => {
++ if (!handleModsRequest(req, res, { registry, authorize: async () => STAFF, grants, log })) {
++ res.writeHead(404)
++ res.end()
++ }
++ })
++ 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) })
++ return { registry, base, post }
++}
++
++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 })
++ 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(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
++ expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])
++
++ // Network unreachable: 503, and nothing changes here.
++ net.state.down = true
++ const refused = await post(`/${MOD_ID}/grants`, { capability: CAP_ANNOUNCE })
++ expect(refused.status).toBe(503)
++ expect(((await refused.json()) as { code: string }).code).toBe('mod.grants_unavailable')
++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
++ net.state.down = false
++
++ expect((await post(`/${MOD_ID}/revoke`, { reason: 'done' })).status).toBe(200)
++ expect(registry.get(MOD_ID)?.mod.status).toBe('revoked')
++ expect(net.calls.at(-1)).toBe('revoke')
++ })
++
++ it('refuses an install Network could not register, and stores nothing', async () => {
++ const net = fakeNetwork([])
++ net.state.down = true
++ const { registry, post } = await start(net.grants)
++ expect((await post('', { manifest, pack, approve: [CAP_PLACE_PROP] })).status).toBe(503)
++ expect(registry.get(MOD_ID)).toBeNull()
++ })
++
++ 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()
++ 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'] })
++ 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: [] })
++ expect(registry.get(MOD_ID)?.mod.status).toBe('revoked')
++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
++ })
++
++ 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({ 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 })
++ return answers.shift() as Response
++ }) as unknown as typeof fetch
++ const scopes: (string | undefined)[] = []
++ 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 })
++ await expect(client.revoke(MOD_ID, 'usr_x')).rejects.toMatchObject({ status: 503 })
++ expect(scopes.every((s) => s === 'mods.grant.manage')).toBe(true)
++ })
++})
ddiff --git a/apps/server/src/mods/networkGrants.ts b/apps/server/src/mods/networkGrants.ts
nnew file mode 100644
iindex 0000000..295d719
---- /dev/null
++++ b/apps/server/src/mods/networkGrants.ts
@@@ -0,0 +1,65 @@
++/**
++ * Mod grants in OpenVibe.Network (roadmap WS-M task 3, ADR-013; Contracts 0.72.0 mods.grant.manage). Every mod
++ * install is the principal mod:<mod_id> in Network, registered by Games (its owner). The mods API asks Network
++ * first and applies only what Network answered, so Network holds the grants and the registry keeps the hot path's
++ * copy; a change staff make in Network arrives as network.mod.grants_changed (ModRegistry.applyNetwork).
++ *
++ * Network answering 4xx is the answer (a ModError with its code); Network unreachable is 503
++ * mod.grants_unavailable, and nothing changes here.
++ */
++import { ModError } from './registry.js'
++
++export interface ModPrincipal {
++ mod_id: string
++ owner: string
++ status: 'active' | 'revoked'
++ requested: string[]
++ approved: string[]
++ pending: string[]
++ revoked: string[]
++ revision: number
++}
++
++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>
++ revoke(modId: string, actor: string, reason?: string): Promise<ModPrincipal>
++ list(): Promise<ModPrincipal[]>
++}
++
++export function createNetworkModGrants(opts: {
++ networkUrl: string
++ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> }
++ fetchImpl?: typeof fetch
++}): ModGrantAuthority {
++ const f = opts.fetchImpl ?? fetch
++ async function call<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
++ let res: Response
++ try {
++ 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' } : {}) },
++ ...(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)
++ }
++ 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)
++ }
++ return data as T
++ }
++ return {
++ 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 }),
++ revoke: (modId, actor, reason) =>
++ call<ModPrincipal>('POST', `/${encodeURIComponent(modId)}/revoke`, { actor, ...(reason ? { reason } : {}) }),
++ list: async () => (await call<{ mods: ModPrincipal[] }>('GET', '')).mods ?? [],
++ }
++}
ddiff --git a/apps/server/src/mods/registry.ts b/apps/server/src/mods/registry.ts
iindex 5da08aa..4067f7d 100644
---- a/apps/server/src/mods/registry.ts
++++ b/apps/server/src/mods/registry.ts
@@@ -18,10 +18,11 @@
*
* Trust tiers are metadata: `isGranted` never looks at them.
*
-- * ADR-013 puts grants in OpenVibe.Network (a `mod` principal per install).
-- * Until Network issues mod principals, the approved subset lives here; the
-- * shape (install -> approved capability ids, revocable at once) is the same
-- * so the source of truth can move without changing the runtime seam.
++ * ADR-013 puts grants in OpenVibe.Network (a `mod` principal per install,
++ * roadmap WS-M task 3): with a platform client, the mods API asks Network
++ * first (./networkGrants.ts) and applies only what Network answered, and
++ * `applyNetwork` makes this copy follow network.mod.grants_changed (a change
++ * staff made in Network). The approved subset here is the hot path's copy.
*/
import type { ContentRegistry } from '@openvibe/content'
import type {
@@@ -58,6 +59,8 @@ 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 class ModError extends Error {
constructor(
@@@ -144,7 +147,14 @@ export class ModRegistry {
})
}
-- install(req: InstallRequest, actor: ModActor): ModView {
++ /** Everything install checks before it writes: the manifest, the pack, the approved subset and the options. */
++ checkInstall(req: InstallRequest): {
++ manifest: ModManifest
++ pack: ContentPack
++ approve: string[]
++ trustTier: ModTrustTier
++ enable: boolean
++ } {
const manifestCheck = validateManifest(req.manifest)
if (!manifestCheck.ok) {
throw new ModError('mod.manifest_invalid', 'manifest is invalid', 422, manifestCheck.errors)
@@@ -176,7 +186,12 @@ export class ModRegistry {
if (this.views.has(manifest.id) || this.store.mods.get(manifest.id)) {
throw new ModError('mod.exists', `mod ${manifest.id} is already installed`, 409)
}
++ return { manifest, pack, approve, trustTier, enable }
++ }
++ install(req: InstallRequest, actor: ModActor): ModView {
++ const { manifest, pack, approve, trustTier, enable } = this.checkInstall(req)
++ const requested = new Set(manifest.permissions.capabilities)
const at = this.now()
const mod: ModInstallDto = {
id: manifest.id,
@@@ -275,6 +290,40 @@ export class ModRegistry {
return changed ? this.refresh(id) : view
}
++ /** What grant checks before it writes (the Network call comes between). */
++ checkGrant(id: string, capability: string): void {
++ const view = this.require(id)
++ if (view.mod.status === 'revoked') {
++ throw new ModError('mod.revoked', 'a revoked install cannot be granted anything', 409)
++ }
++ this.assertGrantable(view.manifest, capability)
++ }
++
++ /**
++ * Make this copy match the install's principal in Network (network.mod.grants_changed, or a read at boot):
++ * 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[] }, actor: ModActor = NETWORK_ACTOR): ModView | null {
++ const view = this.views.get(principal.mod_id)
++ if (!view) return null
++ if (principal.status === 'revoked') return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')
++ if (view.mod.status === 'revoked') return view
++ const 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 approved) {
++ if (current.granted.has(cap)) continue
++ try {
++ this.assertGrantable(current.manifest, cap)
++ } catch {
++ continue
++ }
++ current = this.grant(view.mod.id, cap, actor)
++ }
++ return current
++ }
++
private setStatus(id: string, status: ModStatus, actor: ModActor): ModView {
const view = this.require(id)
if (view.mod.status === 'revoked') {
ddiff --git a/apps/server/src/mods/routes.ts b/apps/server/src/mods/routes.ts
iindex 1170af7..212b59f 100644
---- a/apps/server/src/mods/routes.ts
++++ b/apps/server/src/mods/routes.ts
@@@ -13,10 +13,14 @@
*
* Staff = an openvibe.network owner/admin session, or a service principal
* whose token (audience openvibe.games) carries `games.mod.manage`.
++ *
++ * With `grants` (OpenVibe.Network's mod principals, ADR-013), install, grant, revoke-grant and revoke ask
++ * Network first and apply only what it answered; enable and disable stay this runtime's own switch.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import type { Logger } from '@openvibe/shared'
++import type { ModGrantAuthority } from './networkGrants.js'
import { ModError, type ModActor, type ModRegistry, type ModView } from './registry.js'
interface ContractsHttp {
@@@ -38,6 +42,8 @@ export interface ModsApi {
registry: ModRegistry
/** Resolves the staff actor behind a request, or null. */
authorize(req: IncomingMessage): Promise<ModActor | null>
++ /** OpenVibe.Network's mod principals; absent without a platform client (tests, local runs). */
++ grants?: ModGrantAuthority
log: Logger
}
@@@ -159,7 +165,9 @@ export function handleModsRequest(
}
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {
req.resume()
-- json(res, 200, publicView(api.registry.revokeGrant(id, parts[2] ?? '', actor)))
++ const cap = parts[2] ?? ''
++ if (api.grants && api.registry.get(id)?.granted.has(cap)) await api.grants.change(id, cap, 'revoke', actor.audit)
++ json(res, 200, publicView(api.registry.revokeGrant(id, cap, actor)))
return
}
if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)
@@@ -167,16 +175,14 @@ export function handleModsRequest(
if (!body.ok) return problem(res, body.status, body.code, 'request body must be a JSON object')
const b = body.body
if (parts.length === 0) {
-- const view = api.registry.install(
-- {
-- manifest: b.manifest,
-- pack: b.pack,
-- approve: b.approve,
-- trustTier: b.trustTier,
-- enable: b.enable,
-- },
-- actor,
-- )
++ const request = { manifest: b.manifest, pack: b.pack, approve: b.approve, trustTier: b.trustTier, enable: b.enable }
++ if (api.grants) {
++ // Network registers mod:<id> first; what it approved is what is granted here.
++ const checked = api.registry.checkInstall(request)
++ const principal = await api.grants.register(checked.manifest, checked.approve, actor.audit)
++ request.approve = principal.status === 'active' ? principal.approved : []
++ }
++ const view = api.registry.install(request, actor)
api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })
json(res, 201, publicView(view))
return
@@@ -190,6 +196,7 @@ export function handleModsRequest(
}
if (parts.length === 2 && parts[1] === 'revoke') {
const reason = typeof b.reason === 'string' ? b.reason.slice(0, 500) : undefined
++ if (api.grants && api.registry.get(id) && api.registry.get(id)?.mod.status !== 'revoked') await api.grants.revoke(id, actor.audit, reason)
const view = api.registry.revoke(id, actor, reason)
api.log.info('mod revoked', { mod: id, by: actor.audit })
json(res, 200, publicView(view))
@@@ -199,6 +206,10 @@ export function handleModsRequest(
if (typeof b.capability !== 'string') {
return problem(res, 400, 'mod.invalid_request', 'capability is required')
}
++ if (api.grants) {
++ api.registry.checkGrant(id, b.capability)
++ await api.grants.change(id, b.capability, 'approve', actor.audit)
++ }
json(res, 200, publicView(api.registry.grant(id, b.capability, actor)))
return
}
ddiff --git a/apps/server/src/platform/revocationEvents.test.ts b/apps/server/src/platform/revocationEvents.test.ts
iindex ba44f6f..68bfd87 100644
---- a/apps/server/src/platform/revocationEvents.test.ts
++++ b/apps/server/src/platform/revocationEvents.test.ts
@@@ -21,6 +21,7 @@ const log = createConsoleLogger({ app: 'test' }, 'error')
const closedFor: [string, number][] = []
const mergedCalls: [string, string, string][] = []
const accountCalls: string[] = []
++const modGrantCalls: [string, string, string][] = []
const events = createRevocationEvents({
db: new Database(':memory:'),
secrets: [SECRET],
@@@ -32,6 +33,10 @@ const events = createRevocationEvents({
mergedCalls.push([from, into, id])
return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
},
++ onModGrants: (p) => {
++ modGrantCalls.push([p.mod_id, p.status, p.approved.join(',')])
++ return 'mod:enabled'
++ },
onAccountEvent: async (event) => {
accountCalls.push(String(event.event_type))
if (event.payload?.export_id === 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9') throw new Error('Network down')
@@@ -99,6 +104,16 @@ describe('revocation events', () => {
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 })
++ 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())).body.outcome).toBe('mod:enabled')
++ expect(modGrantCalls).toEqual([[MOD, 'active', 'games.prop.place']])
++ })
++
it('refuses forged, proxied and non-POST requests', async () => {
expect((await post(envelope(), {}, 'y'.repeat(40))).status).toBe(401)
expect((await post(envelope(), { 'x-forwarded-for': '1.2.3.4' })).status).toBe(403)
@@@ -122,6 +137,7 @@ describe('revocation events', () => {
{ topic_pattern: MERGE_TOPIC, 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 },
])
})
})
ddiff --git a/apps/server/src/platform/revocationEvents.ts b/apps/server/src/platform/revocationEvents.ts
iindex 5946196..6c53420 100644
---- a/apps/server/src/platform/revocationEvents.ts
++++ b/apps/server/src/platform/revocationEvents.ts
@@@ -12,8 +12,9 @@
* The same endpoint takes network.subject.merged (roadmap WS-B task 5, ADR-029): two accounts became one, and
* `onMerged` moves the folded-in account's characters to the survivor (store.identity.mergeSubject), once per
* merge. network.account.export_requested and network.account.deleted (roadmap WS-B task 7, ADR-033) go to
-- * `onAccountEvent` (./accountData.ts), answered once it resolved (500 when it failed, so Events retries). One
-- * subscription per topic (TOPICS).
++ * `onAccountEvent` (./accountData.ts), answered once it resolved (500 when it failed, so Events retries).
++ * network.mod.grants_changed (WS-M task 3, ADR-013) goes to `onModGrants`: the install's copy of its grants follows
++ * its principal in Network. One subscription per topic (TOPICS).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
@@@ -47,7 +48,8 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDe
export const TOPIC = 'network.user.token_valid_after'
export const MERGE_TOPIC = 'network.subject.merged'
--export const TOPICS = [TOPIC, MERGE_TOPIC, 'network.account.export_requested', 'network.account.deleted'] as const
++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
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/
const PATH = '/internal/events'
@@@ -68,6 +70,8 @@ export function createRevocationEvents(opts: {
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>
++ /** 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
log: Logger
}): RevocationEvents {
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
@@@ -130,6 +134,18 @@ export function createRevocationEvents(opts: {
)
return
}
++ if (event.event_type === MOD_GRANTS_TOPIC) {
++ const p = (event.payload ?? {}) as { mod_id?: unknown; owner?: unknown; status?: unknown; approved?: unknown; revision?: unknown }
++ let outcome: string
++ if (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 (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 })
++ if (outcome.startsWith('ignored')) stats.ignored++
++ send(res, 200, { event_id: event.event_id ?? null, outcome })
++ return
++ }
if (event.event_type === MERGE_TOPIC) {
const p = event.payload ?? {}
let outcome = 'merged'