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.
- package/CLAUDE.md +36 -2
- package/admin/js/templates/collection-editor.html +1 -1
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/block-editor.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/navigation.js +16 -16
- package/admin/js/views/page-editor.js +41 -41
- package/admin/js/views/view-editor.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/config/plugins.json +1 -1
- package/config/site.json +86 -86
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/collection-export.mjs +3 -0
- package/public/js/collection-query.mjs +1 -0
- package/public/js/collection-sort.mjs +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +94 -0
- package/scripts/setup.js +23 -2
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/navigation.js +42 -42
- package/server/services/collections.js +12 -11
- package/server/services/email.js +167 -167
- package/server/services/markdown.js +106 -33
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/server/templates/page.html +1 -1
- 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
|
-
|
|
163
|
-
|
|
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
|
+
}
|