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):
Changes
@@@ -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' }),})},nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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 ?? [],++ }++}@@@ -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') {@@@ -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?: ModGrantAuthoritylog: 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.bodyif (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}@@@ -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 },])})})@@@ -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: ParseDeexport 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 constconst 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 }) => stringlog: 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'
@@ -31,6 +31,7 @@ import { createReadiness } from './observability/readiness.js'
@@ -31,6 +31,7 @@ import { createReadiness } from './observability/readiness.js'
import { buildRelease, releaseHandler } from './observability/release.js'
import { buildRelease, releaseHandler } from './observability/release.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
import { sharedAssetsHandler } from './net/sharedAssets.js'
import { ModRegistry } from './mods/registry.js'
import { ModRegistry } from './mods/registry.js'
+import { createNetworkModGrants } from './mods/networkGrants.js'
import { handleModsRequest } from './mods/routes.js'
import { handleModsRequest } from './mods/routes.js'
import { ModRuntime } from './mods/runtime.js'
import { ModRuntime } from './mods/runtime.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
import { accountForNetworkUser, isGuestToken } from './platform/accounts.js'
@@ -140,6 +141,20 @@ async function main(): Promise<void> {
@@ -140,6 +141,20 @@ async function main(): Promise<void> {
const modRuntime = new ModRuntime(mods, store, content, log.child({ system: 'mods' }), {
const modRuntime = new ModRuntime(mods, store, content, log.child({ system: 'mods' }), {
reconcileEveryTicks: config.tickRate,
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 =
const mirror =
platform && config.platform.mediaUrl
platform && config.platform.mediaUrl
? new MediaMirror({
? new MediaMirror({
@@ -199,6 +214,11 @@ async function main(): Promise<void> {
@@ -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
// 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.
// close saves its character) before its characters are erased.
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
...(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,
log: platformLog,
})
})
if (
if (
@@ -321,6 +341,7 @@ async function main(): Promise<void> {
@@ -321,6 +341,7 @@ async function main(): Promise<void> {
return handleModsRequest(req, res, {
return handleModsRequest(req, res, {
registry: mods,
registry: mods,
authorize: authorizeStaff,
authorize: authorizeStaff,
+ ...(modGrants ? { grants: modGrants } : {}),
log: log.child({ system: 'mods-api' }),
log: log.child({ system: 'mods-api' }),
})
})
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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 ?? [],
+ }
+}
@@ -18,10 +18,11 @@
@@ -18,10 +18,11 @@
*
*
* Trust tiers are metadata: `isGranted` never looks at them.
* 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 { ContentRegistry } from '@openvibe/content'
import type {
import type {
@@ -58,6 +59,8 @@ export interface ModActor {
@@ -58,6 +59,8 @@ 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). */
+export const NETWORK_ACTOR: ModActor = { audit: 'network', subject: { type: 'service', id: 'network' } }
export class ModError extends Error {
export class ModError extends Error {
constructor(
constructor(
@@ -144,7 +147,14 @@ export class ModRegistry {
@@ -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)
const manifestCheck = validateManifest(req.manifest)
if (!manifestCheck.ok) {
if (!manifestCheck.ok) {
throw new ModError('mod.manifest_invalid', 'manifest is invalid', 422, manifestCheck.errors)
throw new ModError('mod.manifest_invalid', 'manifest is invalid', 422, manifestCheck.errors)
@@ -176,7 +186,12 @@ export class ModRegistry {
@@ -176,7 +186,12 @@ export class ModRegistry {
if (this.views.has(manifest.id) || this.store.mods.get(manifest.id)) {
if (this.views.has(manifest.id) || this.store.mods.get(manifest.id)) {
throw new ModError('mod.exists', `mod ${manifest.id} is already installed`, 409)
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 at = this.now()
const mod: ModInstallDto = {
const mod: ModInstallDto = {
id: manifest.id,
id: manifest.id,
@@ -275,6 +290,40 @@ export class ModRegistry {
@@ -275,6 +290,40 @@ export class ModRegistry {
return changed ? this.refresh(id) : view
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 {
private setStatus(id: string, status: ModStatus, actor: ModActor): ModView {
const view = this.require(id)
const view = this.require(id)
if (view.mod.status === 'revoked') {
if (view.mod.status === 'revoked') {
@@ -13,10 +13,14 @@
@@ -13,10 +13,14 @@
*
*
* Staff = an openvibe.network owner/admin session, or a service principal
* Staff = an openvibe.network owner/admin session, or a service principal
* whose token (audience openvibe.games) carries `games.mod.manage`.
* 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 type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
+import type { ModGrantAuthority } from './networkGrants.js'
import { ModError, type ModActor, type ModRegistry, type ModView } from './registry.js'
import { ModError, type ModActor, type ModRegistry, type ModView } from './registry.js'
interface ContractsHttp {
interface ContractsHttp {
@@ -38,6 +42,8 @@ export interface ModsApi {
@@ -38,6 +42,8 @@ export interface ModsApi {
registry: ModRegistry
registry: ModRegistry
/** Resolves the staff actor behind a request, or null. */
/** Resolves the staff actor behind a request, or null. */
authorize(req: IncomingMessage): Promise<ModActor | null>
authorize(req: IncomingMessage): Promise<ModActor | null>
+ /** OpenVibe.Network's mod principals; absent without a platform client (tests, local runs). */
+ grants?: ModGrantAuthority
log: Logger
log: Logger
}
}
@@ -159,7 +165,9 @@ export function handleModsRequest(
@@ -159,7 +165,9 @@ export function handleModsRequest(
}
}
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {
req.resume()
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
return
}
}
if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)
if (method !== 'POST') return problem(res, 405, 'request.method_not_allowed', method)
@@ -167,16 +175,14 @@ export function handleModsRequest(
@@ -167,16 +175,14 @@ export function handleModsRequest(
if (!body.ok) return problem(res, body.status, body.code, 'request body must be a JSON object')
if (!body.ok) return problem(res, body.status, body.code, 'request body must be a JSON object')
const b = body.body
const b = body.body
if (parts.length === 0) {
if (parts.length === 0) {
- const 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 })
api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })
json(res, 201, publicView(view))
json(res, 201, publicView(view))
return
return
@@ -190,6 +196,7 @@ export function handleModsRequest(
@@ -190,6 +196,7 @@ export function handleModsRequest(
}
}
if (parts.length === 2 && parts[1] === 'revoke') {
if (parts.length === 2 && parts[1] === 'revoke') {
const reason = typeof b.reason === 'string' ? b.reason.slice(0, 500) : undefined
const reason = typeof b.reason === 'string' ? b.reason.slice(0, 500) : undefined
+ if (api.grants && api.registry.get(id) && api.registry.get(id)?.mod.status !== 'revoked') await api.grants.revoke(id, actor.audit, reason)
const view = api.registry.revoke(id, actor, reason)
const view = api.registry.revoke(id, actor, reason)
api.log.info('mod revoked', { mod: id, by: actor.audit })
api.log.info('mod revoked', { mod: id, by: actor.audit })
json(res, 200, publicView(view))
json(res, 200, publicView(view))
@@ -199,6 +206,10 @@ export function handleModsRequest(
@@ -199,6 +206,10 @@ export function handleModsRequest(
if (typeof b.capability !== 'string') {
if (typeof b.capability !== 'string') {
return problem(res, 400, 'mod.invalid_request', 'capability is required')
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)))
json(res, 200, publicView(api.registry.grant(id, b.capability, actor)))
return
return
}
}
@@ -21,6 +21,7 @@ const log = createConsoleLogger({ app: 'test' }, 'error')
@@ -21,6 +21,7 @@ const log = createConsoleLogger({ app: 'test' }, 'error')
const closedFor: [string, number][] = []
const closedFor: [string, number][] = []
const mergedCalls: [string, string, string][] = []
const mergedCalls: [string, string, string][] = []
const accountCalls: string[] = []
const accountCalls: string[] = []
+const modGrantCalls: [string, string, string][] = []
const events = createRevocationEvents({
const events = createRevocationEvents({
db: new Database(':memory:'),
db: new Database(':memory:'),
secrets: [SECRET],
secrets: [SECRET],
@@ -32,6 +33,10 @@ const events = createRevocationEvents({
@@ -32,6 +33,10 @@ const events = createRevocationEvents({
mergedCalls.push([from, into, id])
mergedCalls.push([from, into, id])
return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
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) => {
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')
@@ -99,6 +104,16 @@ describe('revocation events', () => {
@@ -99,6 +104,16 @@ describe('revocation events', () => {
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 })
+ 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 () => {
it('refuses forged, proxied and non-POST requests', async () => {
expect((await post(envelope(), {}, 'y'.repeat(40))).status).toBe(401)
expect((await post(envelope(), {}, 'y'.repeat(40))).status).toBe(401)
expect((await post(envelope(), { 'x-forwarded-for': '1.2.3.4' })).status).toBe(403)
expect((await post(envelope(), { 'x-forwarded-for': '1.2.3.4' })).status).toBe(403)
@@ -122,6 +137,7 @@ describe('revocation events', () => {
@@ -122,6 +137,7 @@ describe('revocation events', () => {
{ 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 },
])
])
})
})
})
})
@@ -12,8 +12,9 @@
@@ -12,8 +12,9 @@
* The same endpoint takes network.subject.merged (roadmap WS-B task 5, ADR-029): two accounts became one, and
* 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
* `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
* 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 type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
@@ -47,7 +48,8 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDe
@@ -47,7 +48,8 @@ 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 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 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'
@@ -68,6 +70,8 @@ export function createRevocationEvents(opts: {
@@ -68,6 +70,8 @@ export function createRevocationEvents(opts: {
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). */
+ 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' })
@@ -130,6 +134,18 @@ export function createRevocationEvents(opts: {
@@ -130,6 +134,18 @@ export function createRevocationEvents(opts: {
)
)
return
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) {
if (event.event_type === MERGE_TOPIC) {
const p = event.payload ?? {}
const p = event.payload ?? {}
let outcome = 'merged'
let outcome = 'merged'