openvibers/openvibe.blog Commits
Staff powers from the contracts staff map (WS-D, ADR-022; Contracts 0.38.0): moderation on any blog is staff.content.moderate (global_mod and up, was admin only), the official blog's ownership is staff.editorial.manage (admin); no role names in the viewer. test/staff-map.test.js fails on raw role checks
Changes
@@@ -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.7.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",@@@ -21,7 +21,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.7.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0"@@@ -22,8 +22,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']);++const { ids, serviceAuth, http, staff: staffMap } = contracts;const PRINCIPAL_SUB = /^(svc|app|mod):/;const AUDIENCE = 'openvibe.blog';@@@ -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 */ } }-- return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), origin: 'user', user: claimsToUser(claims), token };++ // Staff powers come from the contracts staff map (ADR-022): moderation on any blog, and the official blog.++ return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.content.moderate'), editorial: staffMap.can(claims, 'staff.editorial.manage'), origin: 'user', user: claimsToUser(claims), token };}/** opts.services=false (pages) treats a service token as no identity: pages are for browsers. */@@@ -116,4 +116,4 @@ function guard(cap) {};}--module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES };++module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError };@@@ -36,10 +36,10 @@ function roleOf(store, blogId, subject) {return row ? row.role : null;}--/** Effective role of the viewer on a blog, staff included ('owner' on the official blog). */++/** Effective role of the viewer on a blog, staff included ('owner' of the official blog with staff.editorial.manage). */function effectiveRole(store, viewer, blog) {const role = roleOf(store, blog.id, viewer && viewer.subject);-- if (viewer && viewer.staff && blog.kind === 'official') return 'owner';++ if (viewer && viewer.editorial && blog.kind === 'official') return 'owner';return role;}@@@ -94,7 +94,7 @@ function createEditorRoutes(ctx) {router.get('/', wrap(async (req, res) => {const mine = blogs.blogsOf(req.viewer.subject);const official = blogs.official();-- if (access.isStaff(req.viewer) && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });++ if (req.viewer.editorial && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));}));@@@ -55,7 +55,7 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, factsconst subject = author || owners[0];if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');-- const actor = { kind: 'user', subject, staff: true, user: {} };++ const actor = { kind: 'user', subject, staff: true, editorial: true, user: {} };let post = posts.bySlug(blog, SLUG);let created = false;@@@ -78,7 +78,7 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, factslet published = false;if (publish) {const head = store.revisions.head(post.id);-- review = posts.review({ kind: 'user', subject: reviewer, staff: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });++ review = posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;}return { post: posts.get(post.id), created, review, published };nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++// Staff powers come from the openvibe-contracts staff map (ADR-022): the viewer asks staff.can() for++// staff.content.moderate (any blog) and staff.editorial.manage (the official blog); 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');++++for (const [claims, mod, ed] of [++ [{ role: 'user' }, false, false],++ [{ role: 'global_mod' }, true, false],++ [{ role: 'admin' }, true, true],++ [{ role: 'admin', staff_caps: ['staff.moderation.chat'], staff_map: '1.1.0' }, false, false],++ [{ role: 'admin', staff_caps: ['staff.moderation.chat'] }, true, true],++]) {++ assert.strictEqual(staff.can(claims, 'staff.content.moderate'), mod, JSON.stringify(claims));++ assert.strictEqual(staff.can(claims, 'staff.editorial.manage'), ed, JSON.stringify(claims));++}++++const offenders = [];++(function walk(dir) {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const f = path.join(dir, e.name);++ if (e.isDirectory()) { walk(f); continue; }++ if (!e.name.endsWith('.js')) continue;++ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {++ if (/STAFF_ROLES|claims\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]|\.includes\(\s*claims\.role/.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.…\') in the viewer');++console.log('staff map: all checks passed');
@@ -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.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.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",
@@ -21,7 +21,7 @@
@@ -21,7 +21,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.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.7.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"
@@ -22,8 +22,7 @@ const contracts = require('openvibe-contracts');
@@ -22,8 +22,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']);
+const { ids, serviceAuth, http, staff: staffMap } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const AUDIENCE = 'openvibe.blog';
const AUDIENCE = 'openvibe.blog';
@@ -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 */ } }
- return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), origin: 'user', user: claimsToUser(claims), token };
+ // Staff powers come from the contracts staff map (ADR-022): moderation on any blog, and the official blog.
+ return { kind: 'user', subject, staff: staffMap.can(claims, 'staff.content.moderate'), editorial: staffMap.can(claims, 'staff.editorial.manage'), origin: 'user', user: claimsToUser(claims), token };
}
}
/** opts.services=false (pages) treats a service token as no identity: pages are for browsers. */
/** opts.services=false (pages) treats a service token as no identity: pages are for browsers. */
@@ -116,4 +116,4 @@ function guard(cap) {
@@ -116,4 +116,4 @@ function guard(cap) {
};
};
}
}
-module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError, STAFF_ROLES };
+module.exports = { createViewerResolver, guard, ANONYMOUS, ViewerError };
@@ -36,10 +36,10 @@ function roleOf(store, blogId, subject) {
@@ -36,10 +36,10 @@ function roleOf(store, blogId, subject) {
return row ? row.role : null;
return row ? row.role : null;
}
}
-/** Effective role of the viewer on a blog, staff included ('owner' on the official blog). */
+/** Effective role of the viewer on a blog, staff included ('owner' of the official blog with staff.editorial.manage). */
function effectiveRole(store, viewer, blog) {
function effectiveRole(store, viewer, blog) {
const role = roleOf(store, blog.id, viewer && viewer.subject);
const role = roleOf(store, blog.id, viewer && viewer.subject);
- if (viewer && viewer.staff && blog.kind === 'official') return 'owner';
+ if (viewer && viewer.editorial && blog.kind === 'official') return 'owner';
return role;
return role;
}
}
@@ -94,7 +94,7 @@ function createEditorRoutes(ctx) {
@@ -94,7 +94,7 @@ function createEditorRoutes(ctx) {
router.get('/', wrap(async (req, res) => {
router.get('/', wrap(async (req, res) => {
const mine = blogs.blogsOf(req.viewer.subject);
const mine = blogs.blogsOf(req.viewer.subject);
const official = blogs.official();
const official = blogs.official();
- if (access.isStaff(req.viewer) && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });
+ if (req.viewer.editorial && !mine.some((b) => b.id === official.id)) mine.unshift({ ...official, role: 'owner (staff)' });
page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));
page(req, res, 'Write', editor.home({ viewer: req.viewer, blogs: mine, csrf: csrf(req), suggestedHandle: req.viewer.user && req.viewer.user.username ? String(req.viewer.user.username).toLowerCase() : '', message: flashOf(req) }));
}));
}));
@@ -55,7 +55,7 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
@@ -55,7 +55,7 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
const subject = author || owners[0];
const subject = author || owners[0];
if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');
if (!ids.isSubjectId('user', subject)) throw new Error('The seed needs an accountable author: set BLOG_OFFICIAL_OWNERS or pass --author usr_…');
if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');
if (publish && !ids.isSubjectId('user', reviewer)) throw new Error('Publishing the seed needs the person who reviewed it: --reviewer usr_…');
- const actor = { kind: 'user', subject, staff: true, user: {} };
+ const actor = { kind: 'user', subject, staff: true, editorial: true, user: {} };
let post = posts.bySlug(blog, SLUG);
let post = posts.bySlug(blog, SLUG);
let created = false;
let created = false;
@@ -78,7 +78,7 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
@@ -78,7 +78,7 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
let published = false;
let published = false;
if (publish) {
if (publish) {
const head = store.revisions.head(post.id);
const head = store.revisions.head(post.id);
- review = posts.review({ kind: 'user', subject: reviewer, staff: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });
+ review = posts.review({ kind: 'user', subject: reviewer, staff: true, editorial: true, user: {} }, post, { revision: head.number, decision: 'approved', note: 'Seed post reviewed: every quote checked against its source.' });
published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;
published = posts.publish(actor, posts.get(post.id), { revision: head.number }).changed;
}
}
return { post: posts.get(post.id), created, review, published };
return { post: posts.get(post.id), created, review, published };
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+// Staff powers come from the openvibe-contracts staff map (ADR-022): the viewer asks staff.can() for
+// staff.content.moderate (any blog) and staff.editorial.manage (the official blog); 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');
+
+for (const [claims, mod, ed] of [
+ [{ role: 'user' }, false, false],
+ [{ role: 'global_mod' }, true, false],
+ [{ role: 'admin' }, true, true],
+ [{ role: 'admin', staff_caps: ['staff.moderation.chat'], staff_map: '1.1.0' }, false, false],
+ [{ role: 'admin', staff_caps: ['staff.moderation.chat'] }, true, true],
+]) {
+ assert.strictEqual(staff.can(claims, 'staff.content.moderate'), mod, JSON.stringify(claims));
+ assert.strictEqual(staff.can(claims, 'staff.editorial.manage'), ed, JSON.stringify(claims));
+}
+
+const offenders = [];
+(function walk(dir) {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const f = path.join(dir, e.name);
+ if (e.isDirectory()) { walk(f); continue; }
+ if (!e.name.endsWith('.js')) continue;
+ fs.readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
+ if (/STAFF_ROLES|claims\.role\b|\brole\s*[!=]==?\s*['"](admin|global_mod|moderator)['"]|\.includes\(\s*claims\.role/.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.…\') in the viewer');
+console.log('staff map: all checks passed');