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

OP OpenVibers committed 24 Sept 2026 · f0451dff7a · 7 files, +47 −14


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 90fe4bc..723c39e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 83bf931..ce1c7af 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex be53fdd..5385fa2 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -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 };
ddiff --git a/server/domain/access.js b/server/domain/access.js
iindex efb6cde..6b2d5e0 100644
---- a/server/domain/access.js
++++ b/server/domain/access.js
@@@ -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;
}
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex e7f0f8a..45000aa 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -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) }));
}));
ddiff --git a/server/seed.js b/server/seed.js
iindex c4edb46..6c9a8bc 100644
---- a/server/seed.js
++++ b/server/seed.js
@@@ -55,7 +55,7 @@ function seedOfficialPost(ctx, { author, reviewer = null, publish = false, facts
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 (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, facts
let 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 };
ddiff --git a/test/staff-map.test.js b/test/staff-map.test.js
nnew file mode 100644
iindex 0000000..728f1b5
---- /dev/null
++++ b/test/staff-map.test.js
@@@ -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');