openvibers/openvibe.games Commits

A late network.mod.grants_changed no longer undoes a newer grant (found by the production lifecycle proof: the register event, revision 1 with nothing approved, arrived after the API had applied revision 2's grant, so the prop flapped off and back on). ModRegistry keeps the Network revision each install was last set from: the API notes the revision of every answer it applied (install, grant, revoke-grant, revoke), and applyNetwork ignores an event at or below it, as network.mod.grants_changed@1 says. networkGrants.test.ts: older revisions change nothing, a newer one (a staff change) applies. pnpm test and typecheck pass.

OP OpenVibers committed 27 Sept 2026 · 1529a52722 · 3 files, +35 −4


Changes

ddiff --git a/apps/server/src/mods/networkGrants.test.ts b/apps/server/src/mods/networkGrants.test.ts
iindex f8bac8e..e4fbe63 100644
---- a/apps/server/src/mods/networkGrants.test.ts
++++ b/apps/server/src/mods/networkGrants.test.ts
@@@ -95,6 +95,16 @@ describe('mod principals in Network', () => {
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 })
++ 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 })
++ expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
++ 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
ddiff --git a/apps/server/src/mods/registry.ts b/apps/server/src/mods/registry.ts
iindex 4067f7d..4923795 100644
---- a/apps/server/src/mods/registry.ts
++++ b/apps/server/src/mods/registry.ts
@@@ -93,6 +93,8 @@ export interface ModView {
export class ModRegistry {
private readonly views = new Map<string, ModView>()
++ /** The Network principal revision each install's grants were last set from (an older event changes nothing). */
++ private readonly networkRevision = new Map<string, number>()
private revision = 0
constructor(
@@@ -290,6 +292,11 @@ export class ModRegistry {
return changed ? this.refresh(id) : view
}
++ /** The Network revision the API just applied (install, grant, revoke), so a late older event is ignored. */
++ noteNetworkRevision(id: string, revision: number): void {
++ if (revision > (this.networkRevision.get(id) ?? 0)) this.networkRevision.set(id, revision)
++ }
++
/** What grant checks before it writes (the Network call comes between). */
checkGrant(id: string, capability: string): void {
const view = this.require(id)
@@@ -304,9 +311,14 @@ 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[] }, 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.
++ if (principal.revision !== undefined) {
++ if (principal.revision <= (this.networkRevision.get(view.mod.id) ?? 0)) return view
++ this.networkRevision.set(view.mod.id, principal.revision)
++ }
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)
ddiff --git a/apps/server/src/mods/routes.ts b/apps/server/src/mods/routes.ts
iindex 212b59f..8a531a5 100644
---- a/apps/server/src/mods/routes.ts
++++ b/apps/server/src/mods/routes.ts
@@@ -166,7 +166,10 @@ export function handleModsRequest(
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'grants') {
req.resume()
const cap = parts[2] ?? ''
-- if (api.grants && api.registry.get(id)?.granted.has(cap)) await api.grants.change(id, cap, 'revoke', actor.audit)
++ if (api.grants && api.registry.get(id)?.granted.has(cap)) {
++ const principal = await api.grants.change(id, cap, 'revoke', actor.audit)
++ api.registry.noteNetworkRevision(id, principal.revision)
++ }
json(res, 200, publicView(api.registry.revokeGrant(id, cap, actor)))
return
}
@@@ -176,13 +179,16 @@ export function handleModsRequest(
const b = body.body
if (parts.length === 0) {
const request = { manifest: b.manifest, pack: b.pack, approve: b.approve, trustTier: b.trustTier, enable: b.enable }
++ let revision = 0
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 : []
++ revision = principal.revision
}
const view = api.registry.install(request, actor)
++ if (revision) api.registry.noteNetworkRevision(view.mod.id, revision)
api.log.info('mod installed', { mod: view.mod.id, by: actor.audit, status: view.mod.status })
json(res, 201, publicView(view))
return
@@@ -196,7 +202,9 @@ 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)
++ if (api.grants && api.registry.get(id) && api.registry.get(id)?.mod.status !== 'revoked') {
++ api.registry.noteNetworkRevision(id, (await api.grants.revoke(id, actor.audit, reason)).revision)
++ }
const view = api.registry.revoke(id, actor, reason)
api.log.info('mod revoked', { mod: id, by: actor.audit })
json(res, 200, publicView(view))
@@@ -208,7 +216,8 @@ export function handleModsRequest(
}
if (api.grants) {
api.registry.checkGrant(id, b.capability)
-- await api.grants.change(id, b.capability, 'approve', actor.audit)
++ const principal = await api.grants.change(id, b.capability, 'approve', actor.audit)
++ api.registry.noteNetworkRevision(id, principal.revision)
}
json(res, 200, publicView(api.registry.grant(id, b.capability, actor)))
return