openvibers/openvibe.vip Commits
Staff powers from the contracts staff map (WS-D, ADR-022; Contracts 0.38.0): managing network plans and any creator's plans is staff.site.configure (admin, as before), decided once from the token in userPrincipal; VIP_STAFF_ROLES is gone. test/staff-map.test.js fails on raw role checks
Changes
@@@ -21,8 +21,8 @@ OV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.vip/auth/callback# Signs the pages' anti-forgery tokens (required in production): openssl rand -hex 32VIP_FORM_SECRET=--# Network roles that manage network plans and see any creator's members--VIP_STAFF_ROLES=admin++# Staff who manage network plans and see any creator's members hold staff.site.configure in the++# contracts staff map (Network admins); VIP_STAFF_ROLES is gone.# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──BILLING_URL=http://127.0.0.1:4600@@@ -149,7 +149,7 @@ Service tokens: Network client-credentials, audience `openvibe.vip`, one capabil`vip.*` ids are released in openvibe-contracts v0.19.0; the drafts stay in[docs/capabilities-proposal/](docs/capabilities-proposal/); grants are matched with contracts'`capabilities.grants()` — exact id or a `.*` family). User tokens: a Network user JWT; people act on their own things (their plans,--perks, rules, members; their own memberships and checks); staff roles (`VIP_STAFF_ROLES`) manage++perks, rules, members; their own memberships and checks); staff holding `staff.site.configure` (the contracts staff map) managenetwork plans. Errors are RFC 9457 problem+json. Creators are named by SubjectRef, `usr_` id, VIPhandle or `network`.@@@ -14,7 +14,7 @@"dotenv": "^16.4.7","express": "^4.21.2","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"},@@@ -983,9 +983,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",-- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",++ "version": "0.38.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",++ "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"dotenv": "^16.4.7","express": "^4.21.2","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"},@@@ -11,7 +11,7 @@* { kind: 'user', subject: 'usr_…', username, name, avatar, role }* A browser's Network user JWT, presented as `Authorization: Bearer`. Users act only on their* own things (their plans, perks, rules and members as a creator; their own memberships and-- * entitlement checks as a member). Staff roles (VIP_STAFF_ROLES) manage network plans. The API never++ * entitlement checks as a member). Staff (staff.site.configure) manage network plans. The API never* reads cookies, so a cross-site form cannot drive it; the SSR pages carry their own* anti-forgery tokens.* { kind: 'anonymous' }@@@ -19,7 +19,7 @@* A request that presents a token is judged on that token alone: a bad one is refused, never* downgraded to anonymous.*/--const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++const { serviceAuth, capabilities, http, ids, staff: staffMap } = require('openvibe-contracts');const PRINCIPAL_SUB = /^(svc|app|mod):/;const ANON = Object.freeze({ kind: 'anonymous' });@@@ -38,6 +38,8 @@ function userPrincipal(claims) {return {kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,avatar: claims.avatar_url || null, role: claims.role || 'user',++ // Staff (network plans, any creator's plans) = the contracts staff map's staff.site.configure (ADR-022).++ staff: staffMap.can(claims, 'staff.site.configure'),};}@@@ -69,7 +71,7 @@ function createApiAuth({ config, keys, userAuth }) {}const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);-- const isStaff = (p) => p.kind === 'user' && (config.staffRoles || []).includes(p.role);++ const isStaff = (p) => p.kind === 'user' && p.staff === true;return { middleware, resolve, granted, isStaff };}@@@ -49,8 +49,6 @@ function loadConfig(env = process.env) {cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },// Signs the anti-forgery tokens of the server-rendered forms (required in production).formSecret: env.VIP_FORM_SECRET || (isProduction ? '' : 'dev-form-secret'),-- // Network roles that may manage network-wide plans and see any creator's members.-- staffRoles: list(env.VIP_STAFF_ROLES || 'admin'),billing: {url: trim(env.BILLING_URL || 'http://127.0.0.1:4600'),@@@ -28,7 +28,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {const router = express.Router();const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;const forms = createForms({ secret: config.formSecret, now: domain.now });-- const isStaff = (v) => !!v && (config.staffRoles || []).includes(v.role);++ const isStaff = (v) => !!v && v.staff === true;router.use(viewerMiddleware(userAuth));const body = express.urlencoded({ extended: false, limit: '32kb' });nnew file mode 100644@@@ -0,0 +1,28 @@++'use strict';++// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.site.configure makes a person VIP staff (network plans, any creator's plans).++// No server file compares a person's role by hand.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { staff } = require('openvibe-contracts');++++const CAPS = ["staff.site.configure"];++for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [false]], [{"role": "admin"}, [true]], [{"role": "admin", "is_owner": true}, [true]]]) {++ CAPS.forEach((cap, i) => assert.strictEqual(staff.can(claims, cap), want[i], `${JSON.stringify(claims)} ${cap}`));++}++++// Not staff decisions (display reads, an entity's own roles), allowed by file and snippet.++const ALLOW = [["server/importer/live.js", "CREATOR_ROLES.includes(u.role)"]];++const offenders = [];++(function walk(dir) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const f = path.join(dir, e.name);++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(f); continue; }++ if (!/\.(js|ts)$/.test(e.name)) continue;++ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {++ if (/STAFF_ROLES|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) { const rel = path.relative(path.join(__dirname, '..'), f).split(path.sep).join('/'); if (!ALLOW.some(([file, snip]) => file === rel && line.includes(snip))) offenders.push(`${rel}:${i + 1}`); }++ });++ }++})(path.join(__dirname, '..', "server"));++assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');++console.log('staff map: all checks passed');
@@ -21,8 +21,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -21,8 +21,8 @@ OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.vip/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.vip/auth/callback
# Signs the pages' anti-forgery tokens (required in production): openssl rand -hex 32
# Signs the pages' anti-forgery tokens (required in production): openssl rand -hex 32
VIP_FORM_SECRET=
VIP_FORM_SECRET=
-# Network roles that manage network plans and see any creator's members
-VIP_STAFF_ROLES=admin
+# Staff who manage network plans and see any creator's members hold staff.site.configure in the
+# contracts staff map (Network admins); VIP_STAFF_ROLES is gone.
# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──
# ── OpenVibe.Billing (entitlement truth, checkout, cancel) ──
BILLING_URL=http://127.0.0.1:4600
BILLING_URL=http://127.0.0.1:4600
@@ -149,7 +149,7 @@ Service tokens: Network client-credentials, audience `openvibe.vip`, one capabil
@@ -149,7 +149,7 @@ Service tokens: Network client-credentials, audience `openvibe.vip`, one capabil
`vip.*` ids are released in openvibe-contracts v0.19.0; the drafts stay in
`vip.*` ids are released in openvibe-contracts v0.19.0; the drafts stay in
[docs/capabilities-proposal/](docs/capabilities-proposal/); grants are matched with contracts'
[docs/capabilities-proposal/](docs/capabilities-proposal/); grants are matched with contracts'
`capabilities.grants()` — exact id or a `.*` family). User tokens: a Network user JWT; people act on their own things (their plans,
`capabilities.grants()` — exact id or a `.*` family). User tokens: a Network user JWT; people act on their own things (their plans,
-perks, rules, members; their own memberships and checks); staff roles (`VIP_STAFF_ROLES`) manage
+perks, rules, members; their own memberships and checks); staff holding `staff.site.configure` (the contracts staff map) manage
network plans. Errors are RFC 9457 problem+json. Creators are named by SubjectRef, `usr_` id, VIP
network plans. Errors are RFC 9457 problem+json. Creators are named by SubjectRef, `usr_` id, VIP
handle or `network`.
handle or `network`.
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
@@ -983,9 +983,9 @@
@@ -983,9 +983,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
+ "version": "0.38.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
+ "integrity": "sha512-nHYCcKM9N4TJS+BlHOax9xwt4jU87qhXsLZ2gnuK4nOHpGlkLk2OI49PCQVvfHPe+V7bzBK7L4MKjFcTNv8ORA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.38.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"
},
},
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* { kind: 'user', subject: 'usr_…', username, name, avatar, role }
* { kind: 'user', subject: 'usr_…', username, name, avatar, role }
* A browser's Network user JWT, presented as `Authorization: Bearer`. Users act only on their
* A browser's Network user JWT, presented as `Authorization: Bearer`. Users act only on their
* own things (their plans, perks, rules and members as a creator; their own memberships and
* own things (their plans, perks, rules and members as a creator; their own memberships and
- * entitlement checks as a member). Staff roles (VIP_STAFF_ROLES) manage network plans. The API never
+ * entitlement checks as a member). Staff (staff.site.configure) manage network plans. The API never
* reads cookies, so a cross-site form cannot drive it; the SSR pages carry their own
* reads cookies, so a cross-site form cannot drive it; the SSR pages carry their own
* anti-forgery tokens.
* anti-forgery tokens.
* { kind: 'anonymous' }
* { kind: 'anonymous' }
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
* A request that presents a token is judged on that token alone: a bad one is refused, never
* A request that presents a token is judged on that token alone: a bad one is refused, never
* downgraded to anonymous.
* downgraded to anonymous.
*/
*/
-const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+const { serviceAuth, capabilities, http, ids, staff: staffMap } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const ANON = Object.freeze({ kind: 'anonymous' });
const ANON = Object.freeze({ kind: 'anonymous' });
@@ -38,6 +38,8 @@ function userPrincipal(claims) {
@@ -38,6 +38,8 @@ function userPrincipal(claims) {
return {
return {
kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,
kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,
avatar: claims.avatar_url || null, role: claims.role || 'user',
avatar: claims.avatar_url || null, role: claims.role || 'user',
+ // Staff (network plans, any creator's plans) = the contracts staff map's staff.site.configure (ADR-022).
+ staff: staffMap.can(claims, 'staff.site.configure'),
};
};
}
}
@@ -69,7 +71,7 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -69,7 +71,7 @@ function createApiAuth({ config, keys, userAuth }) {
}
}
const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
- const isStaff = (p) => p.kind === 'user' && (config.staffRoles || []).includes(p.role);
+ const isStaff = (p) => p.kind === 'user' && p.staff === true;
return { middleware, resolve, granted, isStaff };
return { middleware, resolve, granted, isStaff };
}
}
@@ -49,8 +49,6 @@ function loadConfig(env = process.env) {
@@ -49,8 +49,6 @@ function loadConfig(env = process.env) {
cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
// Signs the anti-forgery tokens of the server-rendered forms (required in production).
// Signs the anti-forgery tokens of the server-rendered forms (required in production).
formSecret: env.VIP_FORM_SECRET || (isProduction ? '' : 'dev-form-secret'),
formSecret: env.VIP_FORM_SECRET || (isProduction ? '' : 'dev-form-secret'),
- // Network roles that may manage network-wide plans and see any creator's members.
- staffRoles: list(env.VIP_STAFF_ROLES || 'admin'),
billing: {
billing: {
url: trim(env.BILLING_URL || 'http://127.0.0.1:4600'),
url: trim(env.BILLING_URL || 'http://127.0.0.1:4600'),
@@ -28,7 +28,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -28,7 +28,7 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
const router = express.Router();
const router = express.Router();
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
const { creators, plans, perks, memberships, entitlements, policies, checkout, billing } = domain;
const forms = createForms({ secret: config.formSecret, now: domain.now });
const forms = createForms({ secret: config.formSecret, now: domain.now });
- const isStaff = (v) => !!v && (config.staffRoles || []).includes(v.role);
+ const isStaff = (v) => !!v && v.staff === true;
router.use(viewerMiddleware(userAuth));
router.use(viewerMiddleware(userAuth));
const body = express.urlencoded({ extended: false, limit: '32kb' });
const body = express.urlencoded({ extended: false, limit: '32kb' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+'use strict';
+// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.site.configure makes a person VIP staff (network plans, any creator's plans).
+// No server file compares a person's role by hand.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { staff } = require('openvibe-contracts');
+
+const CAPS = ["staff.site.configure"];
+for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [false]], [{"role": "admin"}, [true]], [{"role": "admin", "is_owner": true}, [true]]]) {
+ CAPS.forEach((cap, i) => assert.strictEqual(staff.can(claims, cap), want[i], `${JSON.stringify(claims)} ${cap}`));
+}
+
+// Not staff decisions (display reads, an entity's own roles), allowed by file and snippet.
+const ALLOW = [["server/importer/live.js", "CREATOR_ROLES.includes(u.role)"]];
+const offenders = [];
+(function walk(dir) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const f = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(f); continue; }
+ if (!/\.(js|ts)$/.test(e.name)) continue;
+ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
+ if (/STAFF_ROLES|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) { const rel = path.relative(path.join(__dirname, '..'), f).split(path.sep).join('/'); if (!ALLOW.some(([file, snip]) => file === rel && line.includes(snip))) offenders.push(`${rel}:${i + 1}`); }
+ });
+ }
+})(path.join(__dirname, '..', "server"));
+assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');
+console.log('staff map: all checks passed');