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
Changes
@@@ -4,6 +4,17 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) thfrom `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.@@@ -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": {@@@ -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)."}}},@@@ -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.@@@ -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) };@@@ -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": [{@@@ -1,6 +1,6 @@{"name": "openvibe-contracts",-- "version": "0.37.0",++ "version": "0.38.0","lockfileVersion": 3,"requires": true,"packages": {@@@ -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",@@@ -388,7 +388,16 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscriptok(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');
@@ -4,6 +4,17 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
@@ -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,
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`).
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
## 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.
`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.
@@ -36,6 +36,7 @@
@@ -36,6 +36,7 @@
"properties": {
"properties": {
"id": { "type": "string", "pattern": "^staff\\.[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$", "description": "staff.<area>.<action>." },
"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." },
"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 },
"description": { "type": "string", "minLength": 1, "maxLength": 1000 },
"gates": {
"gates": {
"type": "object",
"type": "object",
@@ -54,7 +55,8 @@
@@ -54,7 +55,8 @@
"properties": {
"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." },
"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." },
"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": {
"local": {
@@ -2565,6 +2565,11 @@
@@ -2565,6 +2565,11 @@
"pattern": "^[a-z][a-z0-9_]{1,31}$",
"pattern": "^[a-z][a-z0-9_]{1,31}$",
"description": "The lowest role that holds it; must be a staff role in roles."
"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": {
"description": {
"type": "string",
"type": "string",
"minLength": 1,
"minLength": 1,
@@ -2613,6 +2618,11 @@
@@ -2613,6 +2618,11 @@
"type": "string",
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,31}$",
"pattern": "^[a-z][a-z0-9_]{1,31}$",
"description": "Array claim of the staff capabilities the effective role holds, issued only to staff."
"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)."
}
}
}
}
},
},
@@ -5280,6 +5280,10 @@ export interface StaffRoleMap {
@@ -5280,6 +5280,10 @@ export interface StaffRoleMap {
* The lowest role that holds it; must be a staff role in roles.
* The lowest role that holds it; must be a staff role in roles.
*/
*/
minRole: string;
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;
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.
* 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 {
@@ -5300,6 +5304,10 @@ export interface StaffRoleMap {
* The lowest role that holds it; must be a staff role in roles.
* The lowest role that holds it; must be a staff role in roles.
*/
*/
minRole: string;
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;
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.
* 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 {
@@ -5328,6 +5336,10 @@ export interface StaffRoleMap {
* Array claim of the staff capabilities the effective role holds, issued only to staff.
* Array claim of the staff capabilities the effective role holds, issued only to staff.
*/
*/
capabilities: string;
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.
* Channel-scoped or self-scoped powers that are not staff capabilities and stay with the product that owns the resource.
@@ -11,7 +11,9 @@
@@ -11,7 +11,9 @@
* claim (a role claim of 'owner' counts as user: owner is never stored)
* claim (a role claim of 'owner' counts as user: owner is never stored)
* staff.atLeast(roleOrClaims, 'admin') role order check (prefer can())
* staff.atLeast(roleOrClaims, 'admin') role order check (prefer can())
* staff.capabilitiesOf(roleOrClaims) every staff capability the role holds, sorted
* 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 fs = require('fs');
const path = require('path');
const path = require('path');
@@ -41,12 +43,26 @@ function capabilitiesOf(v) {
@@ -41,12 +43,26 @@ function capabilitiesOf(v) {
return MAP.capabilities.filter(c => RANK.get(c.minRole) <= rank).map(c => c.id).sort();
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) {
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 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) };
module.exports = { map: MAP, roles, effectiveRole, atLeast, capabilitiesOf, can, get: (id) => byId.get(id) };
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
{
{
- "version": "1.0.0",
+ "version": "1.1.0",
"roles": [
"roles": [
{
{
"id": "user",
"id": "user",
@@ -192,6 +192,34 @@
@@ -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",
"id": "staff.site.view",
"minRole": "admin",
"minRole": "admin",
@@ -220,6 +248,57 @@
@@ -220,6 +248,57 @@
],
],
"chat": [
"chat": [
"chat/tts-routes.js:112,117,130,140,162"
"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 @@
@@ -452,7 +531,8 @@
"claims": {
"claims": {
"role": "role",
"role": "role",
"owner": "is_owner",
"owner": "is_owner",
- "capabilities": "staff_caps"
+ "capabilities": "staff_caps",
+ "map": "staff_map"
},
},
"local": [
"local": [
{
{
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.37.0",
+ "version": "0.38.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"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.",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
"types": "generated/typescript/index.d.ts",
@@ -388,7 +388,16 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
@@ -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({ 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');
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/);
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');
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');
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');
ok(/manifests\/policy\/staff-roles\.json/.test(adr), 'ADR-022 points at the staff map');