openvibers/openvibe.contracts Commits

v0.38.0: staff map 1.1.0 — staff.content.moderate (global_mod), staff.editorial.manage and staff.games.manage (admin) for the content products and Games; capabilities record the map version that added them (since) and Network issues staff_map, so staff.can() judges a capability newer than a token's map by its role and additions reach existing tokens at once

OP OpenVibers committed 24 Sept 2026 · a3bef6f091 · 9 files, +151 −11


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a48152e3..a62247fc 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,17 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
++## 0.38.0 — 2026-09-24
++
++Staff map 1.1.0 (ADR-022), so the content products and Games can check staff capabilities instead of comparing role names:
++
++- **`staff.content.moderate`** (global_mod): moderate what people publish on Blog, Deals, Coupons, Codes and Host.
++- **`staff.editorial.manage`** (admin): the network's own publications: the official blog and wiki spaces, News stories, Reviews entities, Trade context, Coupons merchants.
++- **`staff.games.manage`** (admin): the Games map editor and mod administration.
++- **`since` and the `staff_map` claim.** Capabilities record the map version that added them, and Network issues `staff_map` with `staff_caps`. `staff.can()` judges a capability newer than a token's map by the token's role, so an addition reaches existing tokens (which live for days) at once. What a token was issued still wins for everything its map knew.
++
++Additive.
++
## 0.37.0 — 2026-09-24
`network.integration.github.read` (first-party only) lets a service read the network's GitHub API token. The owner sets the token in Network's admin panel, or in `GITHUB_TOKEN`. OpenVibe.Blog's network changelog uses it, so its GitHub calls do not share the host's anonymous rate limit. It is never granted to apps or mods. Additive.
ddiff --git a/contracts/policy/staff-role-map.v1.json b/contracts/policy/staff-role-map.v1.json
iindex 7ab5df29..83da8ec8 100644
---- a/contracts/policy/staff-role-map.v1.json
++++ b/contracts/policy/staff-role-map.v1.json
@@@ -36,6 +36,7 @@
"properties": {
"id": { "type": "string", "pattern": "^staff\\.[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$", "description": "staff.<area>.<action>." },
"minRole": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,31}$", "description": "The lowest role that holds it; must be a staff role in roles." },
++ "since": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$", "description": "The map version that added this capability. A token whose issued staff_caps come from an older map (its staff_map claim is lower, or absent = 1.0.0) is judged for it by its role instead, so an addition reaches existing tokens at once." },
"description": { "type": "string", "minLength": 1, "maxLength": 1000 },
"gates": {
"type": "object",
@@@ -54,7 +55,8 @@
"properties": {
"role": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,31}$", "description": "The stored role (user, streamer, global_mod or admin). owner is never stored as a role." },
"owner": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,31}$", "description": "Boolean claim, true only for the owner account. With role admin it makes the effective role owner; on any other role it is ignored." },
-- "capabilities": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,31}$", "description": "Array claim of the staff capabilities the effective role holds, issued only to staff." }
++ "capabilities": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,31}$", "description": "Array claim of the staff capabilities the effective role holds, issued only to staff." },
++ "map": { "type": "string", "pattern": "^[a-z][a-z0-9_]{1,31}$", "description": "String claim: the version of this map the issued capabilities come from (absent means 1.0.0)." }
}
},
"local": {
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 49e596d1..85bf58d9 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -2565,6 +2565,11 @@
"pattern": "^[a-z][a-z0-9_]{1,31}$",
"description": "The lowest role that holds it; must be a staff role in roles."
},
++ "since": {
++ "type": "string",
++ "pattern": "^\\d+\\.\\d+\\.\\d+$",
++ "description": "The map version that added this capability. A token whose issued staff_caps come from an older map (its staff_map claim is lower, or absent = 1.0.0) is judged for it by its role instead, so an addition reaches existing tokens at once."
++ },
"description": {
"type": "string",
"minLength": 1,
@@@ -2613,6 +2618,11 @@
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,31}$",
"description": "Array claim of the staff capabilities the effective role holds, issued only to staff."
++ },
++ "map": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]{1,31}$",
++ "description": "String claim: the version of this map the issued capabilities come from (absent means 1.0.0)."
}
}
},
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex ecd226b2..309c6dda 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -5280,6 +5280,10 @@ export interface StaffRoleMap {
* The lowest role that holds it; must be a staff role in roles.
*/
minRole: string;
++ /**
++ * The map version that added this capability. A token whose issued staff_caps come from an older map (its staff_map claim is lower, or absent = 1.0.0) is judged for it by its role instead, so an addition reaches existing tokens at once.
++ */
++ since?: string;
description: string;
/**
* Where the raw role check this capability replaces lives today, per service (file:line and what it guards). Adoption swaps each for a capability check.
@@@ -5300,6 +5304,10 @@ export interface StaffRoleMap {
* The lowest role that holds it; must be a staff role in roles.
*/
minRole: string;
++ /**
++ * The map version that added this capability. A token whose issued staff_caps come from an older map (its staff_map claim is lower, or absent = 1.0.0) is judged for it by its role instead, so an addition reaches existing tokens at once.
++ */
++ since?: string;
description: string;
/**
* Where the raw role check this capability replaces lives today, per service (file:line and what it guards). Adoption swaps each for a capability check.
@@@ -5328,6 +5336,10 @@ export interface StaffRoleMap {
* Array claim of the staff capabilities the effective role holds, issued only to staff.
*/
capabilities: string;
++ /**
++ * String claim: the version of this map the issued capabilities come from (absent means 1.0.0).
++ */
++ map?: string;
};
/**
* Channel-scoped or self-scoped powers that are not staff capabilities and stay with the product that owns the resource.
ddiff --git a/lib/staff.js b/lib/staff.js
iindex 143425ac..d35a46c1 100644
---- a/lib/staff.js
++++ b/lib/staff.js
@@@ -11,7 +11,9 @@
* claim (a role claim of 'owner' counts as user: owner is never stored)
* staff.atLeast(roleOrClaims, 'admin') role order check (prefer can())
* staff.capabilitiesOf(roleOrClaims) every staff capability the role holds, sorted
-- * staff.can(roleOrClaims, capability) exact id or a family grant ('staff.moderation.*')
++ * staff.can(roleOrClaims, capability) exact id or a family grant ('staff.moderation.*'); a capability
++ * newer than the token's staff_map claim comes from its role
++ * staff.map.version what Network puts in the staff_map claim
*/
const fs = require('fs');
const path = require('path');
@@@ -41,12 +43,26 @@ function capabilitiesOf(v) {
return MAP.capabilities.filter(c => RANK.get(c.minRole) <= rank).map(c => c.id).sort();
}
--/** Does this holder have the staff capability? Issued claims win; otherwise derived from the role. */
++const semver = (s) => String(s || '1.0.0').split('.').map(n => parseInt(n, 10) || 0);
++function older(a, b) {
++ const x = semver(a), y = semver(b);
++ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
++ return false;
++}
++
++/**
++ * Does this holder have the staff capability? Issued claims win; otherwise derived from the role.
++ * A capability added after the map the token's claims were issued from (staff_map claim, absent =
++ * 1.0.0) is judged by the role: tokens live for days, and an addition should not wait for them.
++ */
function can(v, capability) {
-- if (!byId.has(capability)) throw new Error(`unknown staff capability ${capability}`);
++ const cap = byId.get(capability);
++ if (!cap) throw new Error(`unknown staff capability ${capability}`);
const issued = v && typeof v === 'object' && Array.isArray(v[MAP.claims.capabilities]) ? v[MAP.claims.capabilities] : null;
-- const held = issued || capabilitiesOf(v);
-- return held.some(g => g === capability || (g.endsWith('.*') && capability.startsWith(g.slice(0, -1)) && g.split('.').length === 3));
++ const match = (held) => held.some(g => g === capability || (g.endsWith('.*') && capability.startsWith(g.slice(0, -1)) && g.split('.').length === 3));
++ if (!issued) return match(capabilitiesOf(v));
++ if (match(issued)) return true;
++ return Boolean(cap.since) && older(v[MAP.claims.map], cap.since) && match(capabilitiesOf(v));
}
module.exports = { map: MAP, roles, effectiveRole, atLeast, capabilitiesOf, can, get: (id) => byId.get(id) };
ddiff --git a/manifests/policy/staff-roles.json b/manifests/policy/staff-roles.json
iindex dcc8ecd3..e78614d5 100644
---- a/manifests/policy/staff-roles.json
++++ b/manifests/policy/staff-roles.json
@@@ -1,5 +1,5 @@
{
-- "version": "1.0.0",
++ "version": "1.1.0",
"roles": [
{
"id": "user",
@@@ -192,6 +192,34 @@
]
}
},
++ {
++ "id": "staff.content.moderate",
++ "minRole": "global_mod",
++ "since": "1.1.0",
++ "description": "Moderate what people publish on the content products: see private, draft and suspended items; hide, unpublish, disable, retract, take down or delete someone else's blog posts, deals, coupons, Codes releases and projects, and Host sites; decide flags and review queues. Never the owner's content. Every use is reported as <service>.moderation.action.",
++ "gates": {
++ "blog": [
++ "domain/access.js:105,106,120,130 isStaff (moderation on any blog; suspended blogs)",
++ "http/editor.js:79,86,97; http/api.js:66,69,234,293; http/public.js:64,74; domain/posts.js:215"
++ ],
++ "deals": [
++ "auth/viewer.js:71 STAFF_ROLES admin|global_mod",
++ "domain/access.js:18,25; domain/offers.js:281 requireStaff; domain/flags.js:67; http/api.js:76,145; http/pages.js:172,367"
++ ],
++ "coupons": [
++ "auth/viewer.js:43 role admin",
++ "http/public.js:296 /staff review pages"
++ ],
++ "codes": [
++ "auth/sso.js:109 role admin",
++ "domain/releases.js:71 revoke any release; http/portal.js:248,646 delete any project, revoke"
++ ],
++ "host": [
++ "auth/viewer.js:67 STAFF_ROLES admin",
++ "domain/access.js:73 read/maintain/delete any project; domain/takedowns.js:49,55 takedowns"
++ ]
++ }
++ },
{
"id": "staff.site.view",
"minRole": "admin",
@@@ -220,6 +248,57 @@
],
"chat": [
"chat/tts-routes.js:112,117,130,140,162"
++ ],
++ "codes": [
++ "domain/trust.js:23 app trust tiers; http/pages.js:244,257 /staff"
++ ],
++ "host": [
++ "domain/access.js:68 quotas (domain/projects.js:128 setQuota)"
++ ],
++ "vip": [
++ "api/auth.js:72 VIP_STAFF_ROLES → network plans and other creators' plans (api/v1.js:64,68,78; web/routes.js:31,119)"
++ ]
++ }
++ },
++ {
++ "id": "staff.editorial.manage",
++ "minRole": "admin",
++ "since": "1.1.0",
++ "description": "Editorial powers over the network's own publications: own the official blog and official wiki spaces and create official spaces; write, publish and retract News stories and manage clusters; curate Reviews entities (create, edit, merge, split, confirm, import); write and retract Trade context and instruments; manage Coupons merchants, whose submissions publish as staff. Products may also name their own editors (a product role, not staff).",
++ "gates": {
++ "blog": [
++ "domain/access.js:42 official blog owner"
++ ],
++ "wiki": [
++ "auth/viewer.js:86 STAFF_ROLES admin|global_mod",
++ "wiki/access.js:36 owner of official spaces; wiki/service.js:497 create official spaces; http/pages.js:117,123"
++ ],
++ "news": [
++ "auth/viewer.js:71 STAFF_ROLES admin",
++ "domain/access.js:20 editor powers"
++ ],
++ "reviews": [
++ "auth/viewer.js:86 STAFF_ROLES admin|global_mod",
++ "reviews/access.js:29 isEditor"
++ ],
++ "trade": [
++ "auth/viewer.js:72 role admin",
++ "domain/context.js:76,181; http/api.js:91,174; http/editor.js:31; http/pages.js:108"
++ ],
++ "coupons": [
++ "http/api.js:105 requireStaffOrService (merchants, coupon status); http/public.js:250 and api.js:164 staff-origin submissions"
++ ]
++ }
++ },
++ {
++ "id": "staff.games.manage",
++ "minRole": "admin",
++ "since": "1.1.0",
++ "description": "Operate OpenVibe.Games: the map editor and noclip edit mode, and installing, enabling and revoking mods and their grants.",
++ "gates": {
++ "games": [
++ "apps/server/src/net/networkAuth.ts:91-103 owner by username, role admin → canEditMap",
++ "httpServer.ts:85; editorWs.ts:250; platform/staffAuth.ts:94; game/gameServer.ts:561"
]
}
},
@@@ -452,7 +531,8 @@
"claims": {
"role": "role",
"owner": "is_owner",
-- "capabilities": "staff_caps"
++ "capabilities": "staff_caps",
++ "map": "staff_map"
},
"local": [
{
ddiff --git a/package-lock.json b/package-lock.json
iindex be673fee..ead7e14d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.37.0",
++ "version": "0.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
ddiff --git a/package.json b/package.json
iindex cebf918b..0bbfded8 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.37.0",
++ "version": "0.38.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
ddiff --git a/test/run.js b/test/run.js
iindex 6fbc1a5a..b7c6c376 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -388,7 +388,16 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(staff.can({ role: 'user', staff_caps: ['staff.moderation.*'] }, 'staff.moderation.bans') && !staff.can({ role: 'admin', staff_caps: ['staff.moderation.*'] }, 'staff.users.manage'), 'issued staff_caps win over the role, and a family grant stays in its area');
ok(!staff.can({ staff_caps: ['staff.*'] }, 'staff.money.freeze') && !staff.can({ staff_caps: ['staff.moderation'] }, 'staff.moderation.chat'), 'there is no staff-wide wildcard, and a prefix without .* grants nothing');
assert.throws(() => staff.can(owner, 'staff.nope.nope'), /unknown staff capability/);
-- ok(JSON.stringify(map.claims) === '{"role":"role","owner":"is_owner","capabilities":"staff_caps"}', 'claims: role, is_owner, staff_caps');
++ ok(JSON.stringify(map.claims) === '{"role":"role","owner":"is_owner","capabilities":"staff_caps","map":"staff_map"}', 'claims: role, is_owner, staff_caps, staff_map');
++ // Additions reach tokens issued from an older map through the role (v0.38.0, map 1.1.0).
++ const ver = (x) => x.split('.').map(Number).reduce((a, n) => a * 1000 + n, 0);
++ ok(map.capabilities.every(c => !c.since || ver(c.since) <= ver(map.version)), 'no capability is newer than the map');
++ for (const id of ['staff.content.moderate', 'staff.editorial.manage', 'staff.games.manage']) ok(staff.get(id) && staff.get(id).since === '1.1.0', `${id} arrived in map 1.1.0`);
++ const oldAdmin = { role: 'admin', staff_caps: staff.capabilitiesOf('admin').filter(id => !staff.get(id).since) };
++ ok(staff.can(oldAdmin, 'staff.editorial.manage') && staff.can({ ...oldAdmin, staff_map: '1.0.0' }, 'staff.games.manage'), 'a token from map 1.0.0 gets 1.1.0 capabilities from its role');
++ ok(!staff.can({ ...oldAdmin, staff_map: '1.1.0' }, 'staff.editorial.manage'), 'a token from map 1.1.0 holds only what was issued');
++ ok(!staff.can({ role: 'global_mod', staff_caps: ['staff.moderation.chat'] }, 'staff.editorial.manage') && staff.can({ role: 'global_mod', staff_caps: ['staff.moderation.chat'] }, 'staff.content.moderate'), 'the role decides, never more than it holds');
++ ok(!staff.can({ role: 'admin', staff_caps: ['staff.moderation.chat'] }, 'staff.users.manage'), 'capabilities from map 1.0.0 still come only from what was issued');
ok(map.rules.some(x => /never delegated/.test(x)) && map.rules.some(x => /equal or higher/.test(x)) && map.rules.some(x => /owner's account/.test(x)), 'rules: no delegation, rank protection, owner protection');
const adr = fs.readFileSync(path.join(ROOT, 'docs/adr/ADR-022-moderation-console.md'), 'utf8');
ok(/manifests\/policy\/staff-roles\.json/.test(adr), 'ADR-022 points at the staff map');