domma-cms 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CLAUDE.md +36 -2
  2. package/admin/js/templates/collection-editor.html +1 -1
  3. package/admin/js/templates/effects.html +752 -752
  4. package/admin/js/templates/forms.html +17 -17
  5. package/admin/js/templates/my-profile.html +17 -17
  6. package/admin/js/templates/role-editor.html +70 -70
  7. package/admin/js/templates/roles.html +10 -10
  8. package/admin/js/views/action-editor.js +1 -1
  9. package/admin/js/views/block-editor.js +2 -2
  10. package/admin/js/views/collection-editor.js +4 -4
  11. package/admin/js/views/form-editor.js +6 -6
  12. package/admin/js/views/navigation.js +16 -16
  13. package/admin/js/views/page-editor.js +41 -41
  14. package/admin/js/views/view-editor.js +1 -1
  15. package/bin/lib/config-merge.js +44 -44
  16. package/config/plugins.json +1 -1
  17. package/config/site.json +86 -86
  18. package/package.json +1 -1
  19. package/public/css/site.css +1 -1
  20. package/public/js/collection-context.js +2 -2
  21. package/public/js/collection-export.mjs +3 -0
  22. package/public/js/collection-query.mjs +1 -0
  23. package/public/js/collection-sort.mjs +1 -0
  24. package/public/js/site.js +1 -1
  25. package/scripts/build.js +94 -0
  26. package/scripts/setup.js +23 -2
  27. package/server/middleware/auth.js +253 -253
  28. package/server/routes/api/auth.js +309 -309
  29. package/server/routes/api/navigation.js +42 -42
  30. package/server/services/collections.js +12 -11
  31. package/server/services/email.js +167 -167
  32. package/server/services/markdown.js +106 -33
  33. package/server/services/userProfiles.js +199 -199
  34. package/server/services/users.js +302 -302
  35. package/server/templates/page.html +1 -1
  36. package/config/navigation.json.bak +0 -31
package/scripts/build.js CHANGED
@@ -169,6 +169,60 @@ async function stampPluginVersions() {
169
169
  }
170
170
  }
171
171
 
172
+ /**
173
+ * Domma Tools is the one Domma asset npm cannot deliver.
174
+ *
175
+ * `domma-js` does not list `domma-tools.*` in its package `files`, so the pair under
176
+ * `admin/dist/domma/` can only ever arrive by hand-copying it out of a domma build. Meanwhile
177
+ * `/dist/domma/domma.min.js` is served straight from `node_modules/domma-js` (server.js), so a
178
+ * `npm install domma-js@next` silently upgrades one half of the admin and leaves the other on
179
+ * whatever version was last copied across. The tools bundle's own banner says
180
+ * `Requires: domma.min.js` — it is built against a specific core and is not version-independent.
181
+ *
182
+ * That skew is invisible: both files load, no import dangles, nothing throws. It surfaces later as
183
+ * a Theme Roller or Page Roller that misbehaves against a core it was not built for. This is the
184
+ * one place that can see both versions at once, so it is the place that checks.
185
+ *
186
+ * @returns {{ok: boolean, installed: string, found: Array<{rel: string, version: string|null}>, problems: string[]}}
187
+ */
188
+ function verifyDommaToolsVersion(toolsRelPaths) {
189
+ const installed = JSON.parse(
190
+ readFileSync(join(ROOT, 'node_modules/domma-js/package.json'), 'utf8'),
191
+ ).version;
192
+
193
+ // Banners differ between the two artefacts: "Domma Tools v0.38.0" (JS) and
194
+ // "Domma Tools CSS v0.38.0" (CSS). Match both rather than only the one you happened to open.
195
+ const BANNER = /\*\s*Domma Tools(?: CSS)? v(\d+\.\d+\.\d+)/;
196
+
197
+ const found = [];
198
+ const problems = [];
199
+
200
+ for (const rel of toolsRelPaths) {
201
+ const src = join(ROOT, rel);
202
+ if (!existsSync(src)) {
203
+ // admin/index.html loads both unconditionally, so an absent file is a dead <script>/<link>
204
+ // in the published admin. The copy step already tolerates absence; flag it rather than
205
+ // silently agreeing.
206
+ problems.push(`${rel} is missing — the published admin will reference a file that isn't there`);
207
+ continue;
208
+ }
209
+ // The banner is in the first few lines; no need to read a 230 KB bundle in full.
210
+ const head = readFileSync(src, 'utf8').slice(0, 512);
211
+ const match = head.match(BANNER);
212
+ if (!match) {
213
+ problems.push(`${rel} carries no recognisable Domma Tools banner — cannot confirm its version`);
214
+ found.push({rel, version: null});
215
+ continue;
216
+ }
217
+ found.push({rel, version: match[1]});
218
+ if (match[1] !== installed) {
219
+ problems.push(`${rel} is v${match[1]} but domma-js is v${installed}`);
220
+ }
221
+ }
222
+
223
+ return {ok: problems.length === 0, installed, found, problems};
224
+ }
225
+
172
226
  async function build() {
173
227
  // Clean and recreate _publish/
174
228
  if (existsSync(OUT)) rmSync(OUT, {recursive: true, force: true});
@@ -191,6 +245,32 @@ async function build() {
191
245
  delete pkg.scripts?.prepublishOnly;
192
246
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
193
247
 
248
+ // Scrub this machine's local config out of the staged copy. config/site.json
249
+ // is a working file on a development install, so it carries whatever SMTP
250
+ // host and site URL were last set here — published releases up to 0.43.1 put
251
+ // a LAN address on the public registry and handed every manual installer a
252
+ // mail host that does not exist for them. Same reasoning as the analytics
253
+ // counters in .npmignore: runtime state must not ride along in the tarball.
254
+ const sitePath = join(OUT, 'config', 'site.json');
255
+ if (existsSync(sitePath)) {
256
+ const site = JSON.parse(readFileSync(sitePath, 'utf8'));
257
+ site.url = 'http://localhost:4096';
258
+ if (site.smtp) {
259
+ site.smtp = {
260
+ host: '', port: 587, user: '', pass: '',
261
+ secure: false, fromAddress: '', fromName: '',
262
+ };
263
+ }
264
+ writeFileSync(sitePath, JSON.stringify(site, null, 4) + '\n');
265
+ }
266
+
267
+ // .bak files are migration leftovers from whichever machine built the
268
+ // release — never something an installer should receive.
269
+ for (const rel of ['config/navigation.json.bak', 'config/connections.json.bak']) {
270
+ const bak = join(OUT, rel);
271
+ if (existsSync(bak)) rmSync(bak, {force: true});
272
+ }
273
+
194
274
  // Copy plugin server-side code verbatim
195
275
  const pluginAsIs = await collectFiles('{' + PLUGIN_AS_IS_PATTERNS.join(',') + '}');
196
276
  for (const rel of pluginAsIs) {
@@ -212,6 +292,20 @@ async function build() {
212
292
  'admin/dist/domma/domma-tools.css',
213
293
  'admin/dist/domma/domma-tools.min.js',
214
294
  ];
295
+
296
+ // Version-skew gate — see verifyDommaToolsVersion() for why this cannot be left to npm.
297
+ const toolsCheck = verifyDommaToolsVersion(dommaTools);
298
+ if (toolsCheck.ok) {
299
+ console.log(`Domma Tools v${toolsCheck.installed} — matches installed domma-js`);
300
+ } else {
301
+ for (const problem of toolsCheck.problems) console.error(` ${problem}`);
302
+ throw new Error(
303
+ `Domma Tools is out of step with domma-js v${toolsCheck.installed} — copy `
304
+ + `domma-tools.min.js and domma-tools.css from that version's build into admin/dist/domma/, `
305
+ + `then rebuild`,
306
+ );
307
+ }
308
+
215
309
  for (const rel of dommaTools) {
216
310
  const src = join(ROOT, rel);
217
311
  if (existsSync(src)) {
package/scripts/setup.js CHANGED
@@ -159,8 +159,11 @@ if (!isPlaceholder) {
159
159
  // ---------------------------------------------------------------------------
160
160
  section('2. Instance Secret');
161
161
 
162
- const existingSecret = env.INSTANCE_SECRET ?? '';
163
- const isInstanceSecretSet = existingSecret && existingSecret.length >= 32;
162
+ // Distinct name from step 1's existingSecret — both are module-scope consts,
163
+ // so reusing the identifier made the whole file a SyntaxError and setup could
164
+ // not run at all.
165
+ const existingInstanceSecret = env.INSTANCE_SECRET ?? '';
166
+ const isInstanceSecretSet = existingInstanceSecret && existingInstanceSecret.length >= 32;
164
167
 
165
168
  if (isInstanceSecretSet) {
166
169
  console.log(' ✓ INSTANCE_SECRET already configured — skipping.');
@@ -298,6 +301,24 @@ section('6. Server Port');
298
301
  console.log(` ✓ Port set to ${port}`);
299
302
  }
300
303
 
304
+ // ---------------------------------------------------------------------------
305
+ // Git hooks
306
+ // ---------------------------------------------------------------------------
307
+ // The hooks in .githooks/ are committed, but git will not run them until this
308
+ // clone points at that directory — core.hooksPath is local config, so it cannot
309
+ // travel with the repository. Every clone that misses this step silently loses
310
+ // both hooks: the commit-msg trailer strip and the pre-commit minified-source
311
+ // guard. Enable it here so a fresh clone is protected by default.
312
+ try {
313
+ const {execSync} = await import('child_process');
314
+ execSync('git rev-parse --git-dir', {stdio: 'ignore'});
315
+ execSync('git config core.hooksPath .githooks', {stdio: 'ignore'});
316
+ console.log(' ✓ Git hooks enabled (core.hooksPath = .githooks)');
317
+ } catch {
318
+ // Not a git checkout — an npm install of the published package, most
319
+ // likely. Nothing to enable, and nothing worth warning about.
320
+ }
321
+
301
322
  // ---------------------------------------------------------------------------
302
323
  // Done
303
324
  // ---------------------------------------------------------------------------
@@ -1,253 +1,253 @@
1
- /**
2
- * Authentication Middleware
3
- * JWT-based authentication with role guards for Domma CMS.
4
- * Role data is read from the roles cache (not config.auth.roles).
5
- */
6
- import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
7
- import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
8
-
9
- /**
10
- * Verify JWT Bearer token. Populates request.user on success.
11
- *
12
- * @param {FastifyRequest} request
13
- * @param {FastifyReply} reply
14
- * @returns {Promise<void>}
15
- */
16
- export async function authenticate(request, reply) {
17
- try {
18
- const decoded = await request.jwtVerify();
19
- if (decoded.type !== 'access') {
20
- return reply.code(401).send({
21
- statusCode: 401,
22
- error: 'Unauthorised',
23
- message: 'Invalid token type'
24
- });
25
- }
26
- } catch {
27
- return reply.code(401).send({
28
- statusCode: 401,
29
- error: 'Unauthorised',
30
- message: 'Invalid or missing authentication token'
31
- });
32
- }
33
- }
34
-
35
- /**
36
- * Return a preHandler that enforces one of the specified roles.
37
- * Must be used after authenticate.
38
- *
39
- * @param {string[]} allowedRoles
40
- * @returns {Function}
41
- */
42
- export function requireRole(allowedRoles) {
43
- const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
44
-
45
- return async (request, reply) => {
46
- if (!request.user) {
47
- return reply.code(401).send({
48
- statusCode: 401,
49
- error: 'Unauthorised',
50
- message: 'Authentication required'
51
- });
52
- }
53
-
54
- if (!allowed.includes(request.user.role)) {
55
- return reply.code(403).send({
56
- statusCode: 403,
57
- error: 'Forbidden',
58
- message: `Access denied. Required role: ${allowed.join(' or ')}`
59
- });
60
- }
61
- };
62
- }
63
-
64
- /**
65
- * Return a preHandler that checks the current user's role has access to a resource.
66
- * Reads from the roles cache at request time — reflects live role changes.
67
- *
68
- * @param {string} resource - Resource key (e.g. 'pages', 'users')
69
- * @param {string} [action] - Optional action (read | create | update | delete)
70
- * @returns {Function}
71
- */
72
- export function requirePermission(resource, action) {
73
- return async (request, reply) => {
74
- if (!request.user) {
75
- return reply.code(401).send({
76
- statusCode: 401,
77
- error: 'Unauthorised',
78
- message: 'Authentication required'
79
- });
80
- }
81
-
82
- // Check ANY of the user's effective roles (primary + additional) against
83
- // the resource's permission list — union semantics so a user with both
84
- // candidate and recruiter roles can do either role's actions.
85
- const allowed = getPermissionsFor(resource, action);
86
- const roles = getEffectiveRoles(request.user);
87
- if (!roles.some(r => allowed.includes(r))) {
88
- return reply.code(403).send({
89
- statusCode: 403,
90
- error: 'Forbidden',
91
- message: 'Insufficient permissions'
92
- });
93
- }
94
- };
95
- }
96
-
97
- /**
98
- * Export getPermissionsForRole for use in route handlers.
99
- *
100
- * @param {string} roleName
101
- * @returns {string[]}
102
- */
103
- export {getPermissionsForRole};
104
-
105
- /**
106
- * Shorthand preHandler — admin-tier role (level ≤ 1) or above.
107
- * Matches the base role hierarchy documented in roles.js:
108
- * super-admin (0), admin (1), user (2).
109
- * Both super-admin and admin pass; regular users and anything below do not.
110
- *
111
- * @param {FastifyRequest} request
112
- * @param {FastifyReply} reply
113
- * @returns {Promise<void>}
114
- */
115
- export async function requireAdmin(request, reply) {
116
- if (!request.user) {
117
- return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
118
- }
119
- // Use effective level — a user with "additionalRoles: ['admin']" can do admin work
120
- // even if their primary role is something lower-privilege.
121
- if (getEffectiveLevel(request.user) > 1) {
122
- return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
123
- }
124
- }
125
-
126
- /**
127
- * Determine whether an actor can manage a target user.
128
- * Managers cannot create, edit, or delete users with a lower level number (higher privilege).
129
- *
130
- * Accepts either user objects (preferred — uses effective level across all roles)
131
- * or bare role-name strings (legacy compat). Strings are looked up via
132
- * `getRoleLevel`; objects via `getEffectiveLevel` so multi-role actors and
133
- * targets are compared by their HIGHEST-privilege role.
134
- *
135
- * @param {string|object} actor - Role name OR user object
136
- * @param {string|object} target - Role name OR user object
137
- * @returns {boolean}
138
- */
139
- export function canManageUser(actor, target) {
140
- const actorLevel = typeof actor === 'string' ? getRoleLevel(actor) : getEffectiveLevel(actor);
141
- const targetLevel = typeof target === 'string' ? getRoleLevel(target) : getEffectiveLevel(target);
142
- return actorLevel < targetLevel;
143
- }
144
-
145
- /**
146
- * Check whether a user role satisfies a visibility requirement.
147
- * Used by both requireVisibility() and the public page renderer.
148
- *
149
- * Visibility may be either:
150
- * - A single string ('public', 'private', or a role name)
151
- * - An array of role names — granted if ANY entry passes the per-role check
152
- *
153
- * Per-role semantics are unchanged: each role check passes if the visitor's
154
- * role level is at or above the required role (lower or equal level number).
155
- * 'private' resolves to super-admin only (Infinity → level 0).
156
- *
157
- * The "any of" semantics for arrays means siblings at different tiers of the
158
- * hierarchy are all granted access — e.g. `visibility: [candidate, employer]`
159
- * lets both roles in, plus anyone more privileged than either (typically
160
- * admins inherit access automatically via the level comparison).
161
- *
162
- * @param {string|null} userRole - The visitor's role, or null if unauthenticated
163
- * @param {string|string[]} visibility - Required visibility — single value or array
164
- * @returns {boolean} true if access is granted
165
- */
166
- export function checkVisibility(userRoleOrObj, visibility) {
167
- if (!visibility) return true;
168
-
169
- // Accept either a bare role name (legacy) or a user object with multi-role
170
- // support. When given an object we walk every effective role and grant
171
- // access if ANY satisfies — same union semantics as permissions.
172
- const roles = typeof userRoleOrObj === 'string'
173
- ? (userRoleOrObj ? [userRoleOrObj] : [])
174
- : getEffectiveRoles(userRoleOrObj);
175
-
176
- if (Array.isArray(visibility)) {
177
- if (visibility.length === 0) return true;
178
- if (visibility.includes('public')) return true;
179
- if (!roles.length) return false;
180
- return roles.some(r => visibility.some(v => checkSingleVisibility(r, v)));
181
- }
182
-
183
- if (visibility === 'public') return true;
184
- if (!roles.length) return false;
185
- return roles.some(r => checkSingleVisibility(r, visibility));
186
- }
187
-
188
- /**
189
- * Internal helper — single-role visibility check.
190
- * Returns true if the user role is at or above the required role's level.
191
- *
192
- * @param {string} userRole - Must not be null
193
- * @param {string} visibility - Single visibility token (role name or 'private')
194
- * @returns {boolean}
195
- */
196
- function checkSingleVisibility(userRole, visibility) {
197
- const userLevel = getRoleLevel(userRole);
198
- const requiredLevel = getRoleLevel(visibility);
199
- const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
200
- return userLevel <= threshold;
201
- }
202
-
203
- /**
204
- * Fastify preHandler factory — gates a route by visibility level.
205
- * Works identically to the content-page visibility system; accepts the same
206
- * single-string or array-of-roles syntax as checkVisibility().
207
- *
208
- * Returns a no-op for 'public' (or any array containing 'public') so it is
209
- * safe to apply unconditionally.
210
- *
211
- * @param {string|string[]} visibility - 'public' | 'private' | role name | array of role names
212
- * @returns {Function} Fastify preHandler
213
- */
214
- export function requireVisibility(visibility) {
215
- const isPublic = !visibility
216
- || visibility === 'public'
217
- || (Array.isArray(visibility) && (visibility.length === 0 || visibility.includes('public')));
218
-
219
- if (isPublic) {
220
- return (_request, _reply, done) => { if (done) done(); };
221
- }
222
-
223
- return async (request, reply) => {
224
- // Build a user-shaped object so checkVisibility can see multi-role
225
- // (primary + additional). Unauthenticated → null → public-only access.
226
- let userObj = null;
227
- try {
228
- const decoded = await request.jwtVerify();
229
- if (decoded.type === 'access') {
230
- userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
231
- }
232
- } catch { /* unauthenticated */ }
233
-
234
- if (!checkVisibility(userObj, visibility)) {
235
- const code = userObj ? 403 : 401;
236
- return reply.code(code).send({
237
- statusCode: code,
238
- error: code === 403 ? 'Forbidden' : 'Unauthorised',
239
- message: code === 403 ? 'Insufficient role for this resource' : 'Authentication required'
240
- });
241
- }
242
- };
243
- }
244
-
245
- /**
246
- * Return role names ordered from most to least privileged.
247
- * Computed from the roles cache.
248
- *
249
- * @returns {string[]}
250
- */
251
- export function getRoleHierarchyList() {
252
- return getRoleHierarchy();
253
- }
1
+ /**
2
+ * Authentication Middleware
3
+ * JWT-based authentication with role guards for Domma CMS.
4
+ * Role data is read from the roles cache (not config.auth.roles).
5
+ */
6
+ import {getPermissionsFor, getPermissionsForRole, getRoleHierarchy, getRoleLevel} from '../services/roles.js';
7
+ import {getEffectiveLevel, getEffectiveRoles} from '../services/userRoles.js';
8
+
9
+ /**
10
+ * Verify JWT Bearer token. Populates request.user on success.
11
+ *
12
+ * @param {FastifyRequest} request
13
+ * @param {FastifyReply} reply
14
+ * @returns {Promise<void>}
15
+ */
16
+ export async function authenticate(request, reply) {
17
+ try {
18
+ const decoded = await request.jwtVerify();
19
+ if (decoded.type !== 'access') {
20
+ return reply.code(401).send({
21
+ statusCode: 401,
22
+ error: 'Unauthorised',
23
+ message: 'Invalid token type'
24
+ });
25
+ }
26
+ } catch {
27
+ return reply.code(401).send({
28
+ statusCode: 401,
29
+ error: 'Unauthorised',
30
+ message: 'Invalid or missing authentication token'
31
+ });
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Return a preHandler that enforces one of the specified roles.
37
+ * Must be used after authenticate.
38
+ *
39
+ * @param {string[]} allowedRoles
40
+ * @returns {Function}
41
+ */
42
+ export function requireRole(allowedRoles) {
43
+ const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
44
+
45
+ return async (request, reply) => {
46
+ if (!request.user) {
47
+ return reply.code(401).send({
48
+ statusCode: 401,
49
+ error: 'Unauthorised',
50
+ message: 'Authentication required'
51
+ });
52
+ }
53
+
54
+ if (!allowed.includes(request.user.role)) {
55
+ return reply.code(403).send({
56
+ statusCode: 403,
57
+ error: 'Forbidden',
58
+ message: `Access denied. Required role: ${allowed.join(' or ')}`
59
+ });
60
+ }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Return a preHandler that checks the current user's role has access to a resource.
66
+ * Reads from the roles cache at request time — reflects live role changes.
67
+ *
68
+ * @param {string} resource - Resource key (e.g. 'pages', 'users')
69
+ * @param {string} [action] - Optional action (read | create | update | delete)
70
+ * @returns {Function}
71
+ */
72
+ export function requirePermission(resource, action) {
73
+ return async (request, reply) => {
74
+ if (!request.user) {
75
+ return reply.code(401).send({
76
+ statusCode: 401,
77
+ error: 'Unauthorised',
78
+ message: 'Authentication required'
79
+ });
80
+ }
81
+
82
+ // Check ANY of the user's effective roles (primary + additional) against
83
+ // the resource's permission list — union semantics so a user with both
84
+ // candidate and recruiter roles can do either role's actions.
85
+ const allowed = getPermissionsFor(resource, action);
86
+ const roles = getEffectiveRoles(request.user);
87
+ if (!roles.some(r => allowed.includes(r))) {
88
+ return reply.code(403).send({
89
+ statusCode: 403,
90
+ error: 'Forbidden',
91
+ message: 'Insufficient permissions'
92
+ });
93
+ }
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Export getPermissionsForRole for use in route handlers.
99
+ *
100
+ * @param {string} roleName
101
+ * @returns {string[]}
102
+ */
103
+ export {getPermissionsForRole};
104
+
105
+ /**
106
+ * Shorthand preHandler — admin-tier role (level ≤ 1) or above.
107
+ * Matches the base role hierarchy documented in roles.js:
108
+ * super-admin (0), admin (1), user (2).
109
+ * Both super-admin and admin pass; regular users and anything below do not.
110
+ *
111
+ * @param {FastifyRequest} request
112
+ * @param {FastifyReply} reply
113
+ * @returns {Promise<void>}
114
+ */
115
+ export async function requireAdmin(request, reply) {
116
+ if (!request.user) {
117
+ return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
118
+ }
119
+ // Use effective level — a user with "additionalRoles: ['admin']" can do admin work
120
+ // even if their primary role is something lower-privilege.
121
+ if (getEffectiveLevel(request.user) > 1) {
122
+ return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Determine whether an actor can manage a target user.
128
+ * Managers cannot create, edit, or delete users with a lower level number (higher privilege).
129
+ *
130
+ * Accepts either user objects (preferred — uses effective level across all roles)
131
+ * or bare role-name strings (legacy compat). Strings are looked up via
132
+ * `getRoleLevel`; objects via `getEffectiveLevel` so multi-role actors and
133
+ * targets are compared by their HIGHEST-privilege role.
134
+ *
135
+ * @param {string|object} actor - Role name OR user object
136
+ * @param {string|object} target - Role name OR user object
137
+ * @returns {boolean}
138
+ */
139
+ export function canManageUser(actor, target) {
140
+ const actorLevel = typeof actor === 'string' ? getRoleLevel(actor) : getEffectiveLevel(actor);
141
+ const targetLevel = typeof target === 'string' ? getRoleLevel(target) : getEffectiveLevel(target);
142
+ return actorLevel < targetLevel;
143
+ }
144
+
145
+ /**
146
+ * Check whether a user role satisfies a visibility requirement.
147
+ * Used by both requireVisibility() and the public page renderer.
148
+ *
149
+ * Visibility may be either:
150
+ * - A single string ('public', 'private', or a role name)
151
+ * - An array of role names — granted if ANY entry passes the per-role check
152
+ *
153
+ * Per-role semantics are unchanged: each role check passes if the visitor's
154
+ * role level is at or above the required role (lower or equal level number).
155
+ * 'private' resolves to super-admin only (Infinity → level 0).
156
+ *
157
+ * The "any of" semantics for arrays means siblings at different tiers of the
158
+ * hierarchy are all granted access — e.g. `visibility: [candidate, employer]`
159
+ * lets both roles in, plus anyone more privileged than either (typically
160
+ * admins inherit access automatically via the level comparison).
161
+ *
162
+ * @param {string|null} userRole - The visitor's role, or null if unauthenticated
163
+ * @param {string|string[]} visibility - Required visibility — single value or array
164
+ * @returns {boolean} true if access is granted
165
+ */
166
+ export function checkVisibility(userRoleOrObj, visibility) {
167
+ if (!visibility) return true;
168
+
169
+ // Accept either a bare role name (legacy) or a user object with multi-role
170
+ // support. When given an object we walk every effective role and grant
171
+ // access if ANY satisfies — same union semantics as permissions.
172
+ const roles = typeof userRoleOrObj === 'string'
173
+ ? (userRoleOrObj ? [userRoleOrObj] : [])
174
+ : getEffectiveRoles(userRoleOrObj);
175
+
176
+ if (Array.isArray(visibility)) {
177
+ if (visibility.length === 0) return true;
178
+ if (visibility.includes('public')) return true;
179
+ if (!roles.length) return false;
180
+ return roles.some(r => visibility.some(v => checkSingleVisibility(r, v)));
181
+ }
182
+
183
+ if (visibility === 'public') return true;
184
+ if (!roles.length) return false;
185
+ return roles.some(r => checkSingleVisibility(r, visibility));
186
+ }
187
+
188
+ /**
189
+ * Internal helper — single-role visibility check.
190
+ * Returns true if the user role is at or above the required role's level.
191
+ *
192
+ * @param {string} userRole - Must not be null
193
+ * @param {string} visibility - Single visibility token (role name or 'private')
194
+ * @returns {boolean}
195
+ */
196
+ function checkSingleVisibility(userRole, visibility) {
197
+ const userLevel = getRoleLevel(userRole);
198
+ const requiredLevel = getRoleLevel(visibility);
199
+ const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
200
+ return userLevel <= threshold;
201
+ }
202
+
203
+ /**
204
+ * Fastify preHandler factory — gates a route by visibility level.
205
+ * Works identically to the content-page visibility system; accepts the same
206
+ * single-string or array-of-roles syntax as checkVisibility().
207
+ *
208
+ * Returns a no-op for 'public' (or any array containing 'public') so it is
209
+ * safe to apply unconditionally.
210
+ *
211
+ * @param {string|string[]} visibility - 'public' | 'private' | role name | array of role names
212
+ * @returns {Function} Fastify preHandler
213
+ */
214
+ export function requireVisibility(visibility) {
215
+ const isPublic = !visibility
216
+ || visibility === 'public'
217
+ || (Array.isArray(visibility) && (visibility.length === 0 || visibility.includes('public')));
218
+
219
+ if (isPublic) {
220
+ return (_request, _reply, done) => { if (done) done(); };
221
+ }
222
+
223
+ return async (request, reply) => {
224
+ // Build a user-shaped object so checkVisibility can see multi-role
225
+ // (primary + additional). Unauthenticated → null → public-only access.
226
+ let userObj = null;
227
+ try {
228
+ const decoded = await request.jwtVerify();
229
+ if (decoded.type === 'access') {
230
+ userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
231
+ }
232
+ } catch { /* unauthenticated */ }
233
+
234
+ if (!checkVisibility(userObj, visibility)) {
235
+ const code = userObj ? 403 : 401;
236
+ return reply.code(code).send({
237
+ statusCode: code,
238
+ error: code === 403 ? 'Forbidden' : 'Unauthorised',
239
+ message: code === 403 ? 'Insufficient role for this resource' : 'Authentication required'
240
+ });
241
+ }
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Return role names ordered from most to least privileged.
247
+ * Computed from the roles cache.
248
+ *
249
+ * @returns {string[]}
250
+ */
251
+ export function getRoleHierarchyList() {
252
+ return getRoleHierarchy();
253
+ }