openvibers/openvibe.deals Commits
Staff powers from the contracts staff map (WS-D, ADR-022; Contracts 0.38.0): moderators are staff.content.moderate (global_mod and up, as before) or DEALS_MODERATORS (the product's own role), not a role list. test/staff-map.test.js fails on raw role checks
Changes
@@@ -32,7 +32,8 @@ OV_OAUTH_CLIENT_SECRET=# process, so hashes reset on restart (/api/ready reports abuse_keys as degraded).#DEALS_IP_HASH_SECRET=--# Moderators besides Network admins / global mods: usr_… subjects, comma-separated++# Moderators besides staff with staff.content.moderate (Network global mods and admins): usr_… subjects,++# comma-separated (a product role)#DEALS_MODERATORS=# Freshness: an observation older than this is shown as stale and the page is noindex@@@ -15,7 +15,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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"@@@ -1015,9 +1015,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",@@@ -20,7 +20,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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"@@@ -6,7 +6,7 @@* { kind: 'anonymous', subject: null, staff: false }* { kind: 'user', subject: 'usr_…'|null, staff, user, token }* A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the-- * token's subject_id claim. Role admin or global_mod, or a subject in DEALS_MODERATORS, makes++ * token's subject_id claim. staff.content.moderate (the contracts staff map), or a subject in DEALS_MODERATORS, makes* the viewer a moderator (staff). X-OV-* headers are ignored for browsers.* { kind: 'service', service: 'svc:ai', claims, subject: 'usr_…'|null, origin: 'user'|'ai' }* A first-party service with a Network client-credentials token for audience openvibe.deals.@@@ -20,8 +20,7 @@ const contracts = require('openvibe-contracts');const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');const { checkCapability } = require('./capabilities');--const { ids, serviceAuth, http } = contracts;--const STAFF_ROLES = new Set(['admin', 'global_mod']);++const { ids, serviceAuth, http, staff: staffMap } = contracts;const PRINCIPAL_SUB = /^(svc|app|mod):/;const AUDIENCE = 'openvibe.deals';@@@ -68,7 +67,8 @@ function createViewerResolver({ auth, config, people }) {if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }-- const staff = STAFF_ROLES.has(claims.role) || Boolean(subject && moderators.has(subject));++ // Staff = the contracts staff map's staff.content.moderate (ADR-022), or one of this product's own moderators.++ const staff = staffMap.can(claims, 'staff.content.moderate') || Boolean(subject && moderators.has(subject));return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };}@@@ -117,4 +117,4 @@ function guard(cap) {};}--module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES };++module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError };@@@ -49,7 +49,7 @@ function load(env = process.env) {// random per-process key (hashes are then not comparable across restarts; /api/ready says so).ipHashSecret: env.DEALS_IP_HASH_SECRET || '',-- // Moderators besides Network admins and global mods: usr_… subjects, comma-separated.++ // Moderators besides staff with staff.content.moderate: usr_… subjects, comma-separated (a product role).moderators: list(env.DEALS_MODERATORS),// Freshness: an observation older than this is shown as stale, never as the price now.@@@ -3,7 +3,7 @@/*** Who may do what (the domain's decisions; routes only check capabilities of service tokens).*-- * moderators Network role admin or global_mod, or a subject listed in DEALS_MODERATORS; a++ * moderators staff with staff.content.moderate (the contracts staff map), or a subject in DEALS_MODERATORS; a* service token is a moderator only for the moderation capability it holds* (deals.offer.moderate / deals.offer.merge) — the grant is the decision* editors the person who submitted an offer (or a service acting for them), and moderatorsnnew file mode 100644@@@ -0,0 +1,26 @@++'use strict';++// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.content.moderate makes a person a moderator (DEALS_MODERATORS names more).++// 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.content.moderate"];++for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [true]], [{"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}`));++}++++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|STAFF_SUBJECTS|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) offenders.push(`${path.relative(path.join(__dirname, '..'), f)}:${i + 1}`);++ });++ }++})(path.join(__dirname, '..', "server"));++assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');++console.log('staff map: all checks passed');
@@ -32,7 +32,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -32,7 +32,8 @@ OV_OAUTH_CLIENT_SECRET=
# process, so hashes reset on restart (/api/ready reports abuse_keys as degraded).
# process, so hashes reset on restart (/api/ready reports abuse_keys as degraded).
#DEALS_IP_HASH_SECRET=
#DEALS_IP_HASH_SECRET=
-# Moderators besides Network admins / global mods: usr_… subjects, comma-separated
+# Moderators besides staff with staff.content.moderate (Network global mods and admins): usr_… subjects,
+# comma-separated (a product role)
#DEALS_MODERATORS=
#DEALS_MODERATORS=
# Freshness: an observation older than this is shown as stale and the page is noindex
# Freshness: an observation older than this is shown as stale and the page is noindex
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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"
@@ -1015,9 +1015,9 @@
@@ -1015,9 +1015,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",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.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"
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* { kind: 'anonymous', subject: null, staff: false }
* { kind: 'anonymous', subject: null, staff: false }
* { kind: 'user', subject: 'usr_…'|null, staff, user, token }
* { kind: 'user', subject: 'usr_…'|null, staff, user, token }
* A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the
* A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from the
- * token's subject_id claim. Role admin or global_mod, or a subject in DEALS_MODERATORS, makes
+ * token's subject_id claim. staff.content.moderate (the contracts staff map), or a subject in DEALS_MODERATORS, makes
* the viewer a moderator (staff). X-OV-* headers are ignored for browsers.
* the viewer a moderator (staff). X-OV-* headers are ignored for browsers.
* { kind: 'service', service: 'svc:ai', claims, subject: 'usr_…'|null, origin: 'user'|'ai' }
* { kind: 'service', service: 'svc:ai', claims, subject: 'usr_…'|null, origin: 'user'|'ai' }
* A first-party service with a Network client-credentials token for audience openvibe.deals.
* A first-party service with a Network client-credentials token for audience openvibe.deals.
@@ -20,8 +20,7 @@ const contracts = require('openvibe-contracts');
@@ -20,8 +20,7 @@ const contracts = require('openvibe-contracts');
const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');
const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');
const { checkCapability } = require('./capabilities');
const { checkCapability } = require('./capabilities');
-const { ids, serviceAuth, http } = contracts;
-const STAFF_ROLES = new Set(['admin', 'global_mod']);
+const { ids, serviceAuth, http, staff: staffMap } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const AUDIENCE = 'openvibe.deals';
const AUDIENCE = 'openvibe.deals';
@@ -68,7 +67,8 @@ function createViewerResolver({ auth, config, people }) {
@@ -68,7 +67,8 @@ function createViewerResolver({ auth, config, people }) {
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
if (subject && people) { try { people.rememberClaims(subject, claims); } catch { /* display cache only */ } }
- const staff = STAFF_ROLES.has(claims.role) || Boolean(subject && moderators.has(subject));
+ // Staff = the contracts staff map's staff.content.moderate (ADR-022), or one of this product's own moderators.
+ const staff = staffMap.can(claims, 'staff.content.moderate') || Boolean(subject && moderators.has(subject));
return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };
return { kind: 'user', subject, staff, origin: 'user', user: claimsToUser(claims), token };
}
}
@@ -117,4 +117,4 @@ function guard(cap) {
@@ -117,4 +117,4 @@ function guard(cap) {
};
};
}
}
-module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES };
+module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError };
@@ -49,7 +49,7 @@ function load(env = process.env) {
@@ -49,7 +49,7 @@ function load(env = process.env) {
// random per-process key (hashes are then not comparable across restarts; /api/ready says so).
// random per-process key (hashes are then not comparable across restarts; /api/ready says so).
ipHashSecret: env.DEALS_IP_HASH_SECRET || '',
ipHashSecret: env.DEALS_IP_HASH_SECRET || '',
- // Moderators besides Network admins and global mods: usr_… subjects, comma-separated.
+ // Moderators besides staff with staff.content.moderate: usr_… subjects, comma-separated (a product role).
moderators: list(env.DEALS_MODERATORS),
moderators: list(env.DEALS_MODERATORS),
// Freshness: an observation older than this is shown as stale, never as the price now.
// Freshness: an observation older than this is shown as stale, never as the price now.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* Who may do what (the domain's decisions; routes only check capabilities of service tokens).
* Who may do what (the domain's decisions; routes only check capabilities of service tokens).
*
*
- * moderators Network role admin or global_mod, or a subject listed in DEALS_MODERATORS; a
+ * moderators staff with staff.content.moderate (the contracts staff map), or a subject in DEALS_MODERATORS; a
* service token is a moderator only for the moderation capability it holds
* service token is a moderator only for the moderation capability it holds
* (deals.offer.moderate / deals.offer.merge) — the grant is the decision
* (deals.offer.moderate / deals.offer.merge) — the grant is the decision
* editors the person who submitted an offer (or a service acting for them), and moderators
* editors the person who submitted an offer (or a service acting for them), and moderators
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+'use strict';
+// Staff powers come from the openvibe-contracts staff map (ADR-022): staff.content.moderate makes a person a moderator (DEALS_MODERATORS names more).
+// 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.content.moderate"];
+for (const [claims, want] of [[{"role": "user"}, [false]], [{"role": "global_mod"}, [true]], [{"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}`));
+}
+
+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|STAFF_SUBJECTS|claims\.role\s*[!=]==?|\.(has|includes)\(\s*(claims|u|user|p)\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]/.test(line)) offenders.push(`${path.relative(path.join(__dirname, '..'), f)}:${i + 1}`);
+ });
+ }
+})(path.join(__dirname, '..', "server"));
+assert.deepStrictEqual(offenders, [], 'raw role checks; ask staff.can(claims, \'staff.…\')');
+console.log('staff map: all checks passed');