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.
Changes
@@@ -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@@@ -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 = 0constructor(@@@ -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 viewconst approved = new Set(principal.approved)@@@ -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.bodyif (parts.length === 0) {const request = { manifest: b.manifest, pack: b.pack, approve: b.approve, trustTier: b.trustTier, enable: b.enable }++ let revision = 0if (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
@@ -95,6 +95,16 @@ describe('mod principals in Network', () => {
@@ -95,6 +95,16 @@ describe('mod principals in Network', () => {
expect((await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status).toBe(200)
expect((await fetch(`${base}/${MOD_ID}/grants/${CAP_ANNOUNCE}`, { method: 'DELETE' })).status).toBe(200)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(registry.isGranted(MOD_ID, CAP_ANNOUNCE)).toBe(false)
expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])
expect(net.calls.slice(1)).toEqual([`approve:${CAP_ANNOUNCE}`, `revoke:${CAP_ANNOUNCE}`])
+ // Network's events arrive after the API applied its answers: an older revision changes nothing.
+ 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.
// Network unreachable: 503, and nothing changes here.
net.state.down = true
net.state.down = true
@@ -93,6 +93,8 @@ export interface ModView {
@@ -93,6 +93,8 @@ export interface ModView {
export class ModRegistry {
export class ModRegistry {
private readonly views = new Map<string, ModView>()
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
private revision = 0
constructor(
constructor(
@@ -290,6 +292,11 @@ export class ModRegistry {
@@ -290,6 +292,11 @@ export class ModRegistry {
return changed ? this.refresh(id) : view
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). */
/** What grant checks before it writes (the Network call comes between). */
checkGrant(id: string, capability: string): void {
checkGrant(id: string, capability: string): void {
const view = this.require(id)
const view = this.require(id)
@@ -304,9 +311,14 @@ export class ModRegistry {
@@ -304,9 +311,14 @@ export class ModRegistry {
* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and
* a revoked principal revokes the install; otherwise capabilities Network does not approve are revoked here and
* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.
* approved ones this runtime can bind are granted. Unknown installs are ignored. Returns the view, or null.
*/
*/
- applyNetwork(principal: { mod_id: string; status: string; approved: readonly string[] }, actor: ModActor = NETWORK_ACTOR): ModView | null {
+ applyNetwork(principal: { mod_id: string; status: string; approved: readonly string[]; revision?: number }, actor: ModActor = NETWORK_ACTOR): ModView | null {
const view = this.views.get(principal.mod_id)
const view = this.views.get(principal.mod_id)
if (!view) return null
if (!view) return null
+ // Events can arrive after the API already applied a newer answer: an older (or equal) revision changes nothing.
+ 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 (principal.status === 'revoked') return this.revoke(view.mod.id, actor, 'revoked in OpenVibe.Network')
if (view.mod.status === 'revoked') return view
if (view.mod.status === 'revoked') return view
const approved = new Set(principal.approved)
const approved = new Set(principal.approved)
@@ -166,7 +166,10 @@ export function handleModsRequest(
@@ -166,7 +166,10 @@ 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()
const cap = parts[2] ?? ''
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)))
json(res, 200, publicView(api.registry.revokeGrant(id, cap, actor)))
return
return
}
}
@@ -176,13 +179,16 @@ export function handleModsRequest(
@@ -176,13 +179,16 @@ export function handleModsRequest(
const b = body.body
const b = body.body
if (parts.length === 0) {
if (parts.length === 0) {
const request = { manifest: b.manifest, pack: b.pack, approve: b.approve, trustTier: b.trustTier, enable: b.enable }
const request = { manifest: b.manifest, pack: b.pack, approve: b.approve, trustTier: b.trustTier, enable: b.enable }
+ let revision = 0
if (api.grants) {
if (api.grants) {
// Network registers mod:<id> first; what it approved is what is granted here.
// Network registers mod:<id> first; what it approved is what is granted here.
const checked = api.registry.checkInstall(request)
const checked = api.registry.checkInstall(request)
const principal = await api.grants.register(checked.manifest, checked.approve, actor.audit)
const principal = await api.grants.register(checked.manifest, checked.approve, actor.audit)
request.approve = principal.status === 'active' ? principal.approved : []
request.approve = principal.status === 'active' ? principal.approved : []
+ revision = principal.revision
}
}
const view = api.registry.install(request, actor)
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 })
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
@@ -196,7 +202,9 @@ export function handleModsRequest(
@@ -196,7 +202,9 @@ 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)
+ 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)
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))
@@ -208,7 +216,8 @@ export function handleModsRequest(
@@ -208,7 +216,8 @@ export function handleModsRequest(
}
}
if (api.grants) {
if (api.grants) {
api.registry.checkGrant(id, b.capability)
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)))
json(res, 200, publicView(api.registry.grant(id, b.capability, actor)))
return
return