domma-cms 0.44.0 → 0.45.1
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 +53 -53
- package/README.md +27 -27
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +2 -2
- package/admin/js/app.js +1 -1
- package/admin/js/lib/card-builder.js +2 -2
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/image-editor.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +2 -2
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/lib/project-quick-create.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/server-restart.js +1 -1
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/simple-editor.js +1 -1
- package/admin/js/lib/themes.js +1 -1
- package/admin/js/lib/timeline-builder.js +1 -1
- package/admin/js/templates/action-editor.html +8 -8
- package/admin/js/templates/api-endpoint-editor.html +7 -7
- package/admin/js/templates/api-endpoints.html +1 -1
- package/admin/js/templates/api-reference.html +1 -1
- package/admin/js/templates/block-editor.html +6 -6
- package/admin/js/templates/collection-editor.html +1 -1
- package/admin/js/templates/component-editor.html +2 -2
- package/admin/js/templates/dashboard/cache.html +3 -3
- package/admin/js/templates/dashboard/journeys.html +3 -3
- package/admin/js/templates/dashboard/kpi-strip.html +3 -3
- package/admin/js/templates/dashboard/top-pages.html +1 -1
- package/admin/js/templates/dashboard/traffic-chart.html +1 -1
- package/admin/js/templates/docs/api-actions.html +1 -1
- package/admin/js/templates/docs/api-collections.html +4 -4
- package/admin/js/templates/docs/api-pages.html +2 -2
- package/admin/js/templates/docs/api-plugins.html +1 -1
- package/admin/js/templates/docs/api-settings.html +2 -2
- package/admin/js/templates/docs/api-users.html +1 -1
- package/admin/js/templates/docs/components-howto.html +4 -4
- package/admin/js/templates/docs/components-reference.html +21 -21
- package/admin/js/templates/docs/components-rules.html +14 -14
- package/admin/js/templates/docs/components-walkthrough.html +13 -13
- package/admin/js/templates/docs/tutorial-crud.html +49 -49
- package/admin/js/templates/docs/tutorial-forms.html +4 -4
- package/admin/js/templates/docs/tutorial-plugin.html +10 -10
- package/admin/js/templates/docs/usage-actions.html +6 -6
- package/admin/js/templates/docs/usage-cta-shortcode.html +9 -9
- package/admin/js/templates/docs/usage-dconfig.html +4 -4
- package/admin/js/templates/docs/usage-navigation.html +1 -1
- package/admin/js/templates/docs/usage-pages.html +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +24 -24
- package/admin/js/templates/docs/usage-users-roles.html +3 -3
- package/admin/js/templates/docs/usage-views.html +4 -4
- package/admin/js/templates/documentation.html +1 -1
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-editor.html +5 -5
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/login.html +5 -5
- package/admin/js/templates/menu-editor.html +10 -10
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/navigation.html +11 -11
- package/admin/js/templates/notifications.html +1 -1
- package/admin/js/templates/page-editor.html +4 -4
- package/admin/js/templates/pro-docs.html +14 -14
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/templates/settings.html +10 -10
- package/admin/js/templates/tutorials.html +3 -3
- package/admin/js/templates/view-editor.html +15 -15
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/api-endpoint-editor.js +2 -2
- package/admin/js/views/api-endpoints.js +2 -2
- package/admin/js/views/api-tokens.js +3 -3
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +1 -1
- package/admin/js/views/collection-entries.js +1 -1
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/component-editor.js +1 -1
- package/admin/js/views/components.js +4 -4
- package/admin/js/views/dashboard/widgets/cache.js +1 -1
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +1 -1
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/layouts.js +1 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/menu-editor.js +3 -3
- package/admin/js/views/menu-locations.js +1 -1
- package/admin/js/views/menus.js +3 -3
- package/admin/js/views/navigation.js +8 -8
- package/admin/js/views/page-editor.js +40 -40
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/plugin-code.js +2 -2
- package/admin/js/views/plugins.js +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +1 -1
- package/admin/js/views/roles.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/bin/cli.js +8 -8
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +16 -16
- package/config/plugins.json +1 -1
- package/package.json +2 -2
- package/plugins/_template/admin/views/index.js +1 -1
- package/plugins/_template/config.js +1 -1
- package/plugins/_template/plugin.js +1 -1
- package/plugins/analytics/admin/templates/analytics.html +3 -3
- package/plugins/analytics/admin/views/analytics.js +1 -1
- package/plugins/analytics/config.js +1 -1
- package/plugins/analytics/plugin.js +4 -4
- package/plugins/analytics/plugin.json +1 -1
- package/plugins/analytics/public/inject-body.html +2 -2
- package/plugins/analytics/public/inject-head.html +1 -1
- package/plugins/blog/admin/templates/blog.html +4 -4
- package/plugins/blog/admin/views/blog.js +3 -3
- package/plugins/blog/admin/views/categories.js +2 -2
- package/plugins/blog/admin/views/comments.js +4 -4
- package/plugins/blog/admin/views/post-editor.js +1 -1
- package/plugins/blog/plugin.js +2 -2
- package/plugins/blog/plugin.json +4 -4
- package/plugins/contacts/admin/views/contacts.js +8 -8
- package/plugins/contacts/plugin.js +4 -4
- package/plugins/contacts/plugin.json +1 -1
- package/plugins/invoice/admin/templates/editor.html +6 -6
- package/plugins/invoice/admin/templates/index.html +4 -4
- package/plugins/invoice/admin/views/editor.js +3 -3
- package/plugins/invoice/admin/views/index.js +4 -4
- package/plugins/invoice/admin/views/issuers.js +1 -1
- package/plugins/invoice/admin/views/party-view.js +3 -3
- package/plugins/invoice/admin/views/receivers.js +1 -1
- package/plugins/invoice/config.js +1 -1
- package/plugins/invoice/plugin.js +1 -1
- package/plugins/invoice/plugin.json +4 -4
- package/plugins/notes/admin/views/notes.js +4 -4
- package/plugins/notes/plugin.js +1 -1
- package/plugins/notes/plugin.json +1 -1
- package/plugins/site-search/admin/templates/site-search.html +3 -3
- package/plugins/site-search/admin/views/site-search.js +1 -1
- package/plugins/site-search/config.js +1 -1
- package/plugins/site-search/plugin.js +4 -4
- package/plugins/site-search/plugin.json +1 -1
- package/plugins/surveys/admin/templates/results.html +1 -1
- package/plugins/surveys/admin/templates/survey-editor.html +4 -4
- package/plugins/surveys/admin/views/audience.js +6 -6
- package/plugins/surveys/admin/views/results.js +3 -3
- package/plugins/surveys/admin/views/survey-editor.js +1 -1
- package/plugins/surveys/admin/views/surveys.js +2 -2
- package/plugins/surveys/plugin.js +1 -1
- package/plugins/surveys/plugin.json +4 -4
- package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
- package/plugins/theme-switcher/plugin.json +3 -3
- package/plugins/theme-switcher/public/inject-body.html +28 -28
- package/plugins/theme-switcher/public/inject-head.html +1 -1
- package/plugins/todo/plugin.js +1 -1
- package/public/css/forms.css +1 -1
- package/public/js/collection-browser.js +1 -1
- package/public/js/collection-context.js +1 -1
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -1
- package/public/js/site.js +1 -1
- package/scripts/build.js +14 -14
- package/scripts/create-plugin.js +2 -2
- package/scripts/fresh.js +2 -2
- package/scripts/gen-instance-secret.js +1 -1
- package/scripts/pro.js +12 -12
- package/scripts/reset.js +2 -2
- package/scripts/setup.js +15 -15
- package/scripts/users.js +5 -5
- package/scripts/verify-assets.mjs +6 -6
- package/server/config.js +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/middleware/managerAuth.js +3 -3
- package/server/routes/api/actions.js +4 -4
- package/server/routes/api/api-endpoints.js +7 -7
- package/server/routes/api/api-tokens.js +5 -5
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/blocks.js +3 -3
- package/server/routes/api/collections.js +18 -18
- package/server/routes/api/dashboard.js +5 -5
- package/server/routes/api/effects.js +2 -2
- package/server/routes/api/endpoints-public.js +5 -5
- package/server/routes/api/forms.js +47 -47
- package/server/routes/api/menu-locations.js +3 -3
- package/server/routes/api/menus.js +7 -7
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/notifications.js +2 -2
- package/server/routes/api/pages.js +14 -2
- package/server/routes/api/plugin-marketplace.js +3 -3
- package/server/routes/api/plugins.js +6 -6
- package/server/routes/api/projects.js +9 -9
- package/server/routes/api/scaffold.js +7 -7
- package/server/routes/api/settings.js +6 -6
- package/server/routes/api/sidebar.js +1 -1
- package/server/routes/api/users.js +5 -5
- package/server/routes/api/versions.js +1 -1
- package/server/routes/api/views.js +1 -1
- package/server/routes/docs-public.js +3 -3
- package/server/routes/public.js +14 -14
- package/server/server.js +34 -16
- package/server/services/actions.js +19 -19
- package/server/services/adapters/FileAdapter.js +2 -2
- package/server/services/adapters/MongoAdapter.js +5 -5
- package/server/services/apiEndpoints.js +15 -15
- package/server/services/apiTokens.js +7 -7
- package/server/services/blocks.js +13 -13
- package/server/services/cache/drivers/MemoryDriver.js +3 -3
- package/server/services/cache/drivers/NoneDriver.js +1 -1
- package/server/services/cache/index.js +5 -5
- package/server/services/collections.js +14 -14
- package/server/services/components.js +9 -9
- package/server/services/connectionManager.js +4 -4
- package/server/services/content.js +25 -2
- package/server/services/docs.js +14 -14
- package/server/services/email.js +167 -167
- package/server/services/filterEngine.js +6 -6
- package/server/services/forms.js +18 -10
- package/server/services/health.js +1 -1
- package/server/services/hooks.js +7 -7
- package/server/services/images.js +4 -4
- package/server/services/managerClient.js +1 -1
- package/server/services/markdown.js +79 -79
- package/server/services/menuRender.js +5 -5
- package/server/services/menus-migration.js +3 -3
- package/server/services/menus.js +31 -31
- package/server/services/permissionRegistry.js +1 -1
- package/server/services/pluginFiles.js +5 -5
- package/server/services/pluginInstaller.js +17 -17
- package/server/services/pluginScaffold.js +1 -1
- package/server/services/plugins.js +17 -17
- package/server/services/presetCollections.js +2 -2
- package/server/services/projects.js +35 -26
- package/server/services/recipes/contact-list.json +1 -1
- package/server/services/recipes/onboarding.json +8 -8
- package/server/services/references.js +8 -8
- package/server/services/renderer.js +9 -9
- package/server/services/roles.js +9 -9
- package/server/services/rowAccess.js +10 -10
- package/server/services/scaffolder.js +30 -30
- package/server/services/sidebar-migration.js +10 -10
- package/server/services/sitemap.js +1 -1
- package/server/services/userProfiles.js +199 -199
- package/server/services/userRoles.js +7 -7
- package/server/services/users.js +302 -302
- package/server/services/versions.js +3 -3
- package/server/services/viewPipeline.js +6 -6
- package/server/services/views.js +11 -11
- package/server/templates/page.html +5 -5
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Manager auth middleware
|
|
2
|
+
* Manager auth middleware - shared-secret guard for inbound requests from domma-cms-manager.
|
|
3
3
|
* Completely separate from JWT authenticate. Do NOT mix these two auth surfaces.
|
|
4
4
|
*/
|
|
5
5
|
import { timingSafeEqual, createHmac } from 'crypto';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Fastify preHandler
|
|
8
|
+
* Fastify preHandler - validates the X-Manager-Token or Authorization: Bearer header
|
|
9
9
|
* against the MANAGER_SECRET environment variable using timing-safe comparison.
|
|
10
10
|
*
|
|
11
11
|
* @param {import('fastify').FastifyRequest} request
|
|
@@ -26,7 +26,7 @@ export function requireManager(request, reply, done) {
|
|
|
26
26
|
return reply.code(401).send({ error: 'Manager token required.' });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Compare HMAC digests
|
|
29
|
+
// Compare HMAC digests - always 32 bytes regardless of input length, preventing timing oracle
|
|
30
30
|
const digest = (s) => createHmac('sha256', 'domma-cms').update(s).digest();
|
|
31
31
|
if (!timingSafeEqual(digest(token), digest(secret))) {
|
|
32
32
|
return reply.code(403).send({ error: 'Invalid manager token.' });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Actions API (Pro
|
|
2
|
+
* Actions API (Pro - requires MongoDB)
|
|
3
3
|
*
|
|
4
4
|
* Admin endpoints (authenticated + actions permission):
|
|
5
5
|
* GET /actions - List all action configs
|
|
@@ -43,7 +43,7 @@ export async function actionsRoutes(fastify) {
|
|
|
43
43
|
try {
|
|
44
44
|
const {canSeeArtefact} = await import('../../services/projects.js');
|
|
45
45
|
const all = await listActions();
|
|
46
|
-
// listActions returns full records with meta inline
|
|
46
|
+
// listActions returns full records with meta inline - filter directly
|
|
47
47
|
return all.filter(a => canSeeArtefact(request.user, a));
|
|
48
48
|
} catch (err) {
|
|
49
49
|
return reply.status(503).send({ error: err.message });
|
|
@@ -188,7 +188,7 @@ export async function actionsRoutes(fastify) {
|
|
|
188
188
|
|
|
189
189
|
const user = request.user;
|
|
190
190
|
const allowedRoles = action.access?.roles || ['admin', 'super-admin'];
|
|
191
|
-
// Use effective level
|
|
191
|
+
// Use effective level - a candidate-with-also-admin can run admin-tier actions.
|
|
192
192
|
const userLevel = getEffectiveLevel(user);
|
|
193
193
|
const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
|
|
194
194
|
|
|
@@ -217,7 +217,7 @@ export async function actionsRoutes(fastify) {
|
|
|
217
217
|
* shortcode to render only the buttons that will actually succeed.
|
|
218
218
|
*
|
|
219
219
|
* Auth: optional. Anonymous calls return actions accessible to anonymous
|
|
220
|
-
* users (rare
|
|
220
|
+
* users (rare - most transitions require a role). With a JWT we narrow
|
|
221
221
|
* to what the signed-in user can do.
|
|
222
222
|
*
|
|
223
223
|
* Response: { transitions: [{slug, title, trigger, transition}, ...] }
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API Endpoints API (admin management of custom endpoint definitions)
|
|
3
3
|
*
|
|
4
|
-
* GET /api/api-endpoints
|
|
5
|
-
* GET /api/api-endpoints/:id
|
|
6
|
-
* POST /api/api-endpoints
|
|
7
|
-
* PUT /api/api-endpoints/:id
|
|
8
|
-
* DELETE /api/api-endpoints/:id
|
|
4
|
+
* GET /api/api-endpoints - list definitions visible to the caller
|
|
5
|
+
* GET /api/api-endpoints/:id - single definition (the builder editor)
|
|
6
|
+
* POST /api/api-endpoints - create (400 validation, 409 duplicate path shape)
|
|
7
|
+
* PUT /api/api-endpoints/:id - update (project binding immutable)
|
|
8
|
+
* DELETE /api/api-endpoints/:id - delete
|
|
9
9
|
*
|
|
10
10
|
* Endpoints are project-scoped artefacts: non-super-admin users with a
|
|
11
11
|
* `projects: []` access scope only see/manage definitions in their projects.
|
|
@@ -28,7 +28,7 @@ import {getCollection} from '../../services/collections.js';
|
|
|
28
28
|
/**
|
|
29
29
|
* Scoped users must not expose data they cannot see: the collection an
|
|
30
30
|
* endpoint queries is itself an artefact subject to project access scope.
|
|
31
|
-
* Returns true when the user may use the collection (or it doesn't exist
|
|
31
|
+
* Returns true when the user may use the collection (or it doesn't exist -
|
|
32
32
|
* the service's validation produces the clearer 400 for that case).
|
|
33
33
|
*
|
|
34
34
|
* @param {object} user
|
|
@@ -96,7 +96,7 @@ export async function apiEndpointsRoutes(fastify, opts = {}) {
|
|
|
96
96
|
if (!canSeeArtefact(request.user, {meta: {project: existing.project}})) {
|
|
97
97
|
return reply.status(403).send({error: 'Access denied for this project'});
|
|
98
98
|
}
|
|
99
|
-
// The project binding is the endpoint's URL namespace
|
|
99
|
+
// The project binding is the endpoint's URL namespace - immutable.
|
|
100
100
|
const {project: _ignored, ...patch} = request.body || {};
|
|
101
101
|
if (!await canUseCollection(request.user, patch.collection ?? existing.collection)) {
|
|
102
102
|
return reply.status(403).send({error: 'Access denied for this collection'});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API Tokens API
|
|
3
3
|
*
|
|
4
|
-
* GET /api/api-tokens
|
|
5
|
-
* POST /api/api-tokens
|
|
6
|
-
* PUT /api/api-tokens/:id
|
|
7
|
-
* DELETE /api/api-tokens/:id
|
|
4
|
+
* GET /api/api-tokens - list tokens visible to the caller (sanitised; never the hash)
|
|
5
|
+
* POST /api/api-tokens - create; response carries the plaintext ONCE
|
|
6
|
+
* PUT /api/api-tokens/:id - rename, enable/disable, edit scopes/expiry
|
|
7
|
+
* DELETE /api/api-tokens/:id - revoke (immediate)
|
|
8
8
|
*
|
|
9
9
|
* Tokens are project-scoped artefacts: non-super-admin users with a
|
|
10
10
|
* `projects: []` access scope only see/manage tokens in their projects.
|
|
@@ -51,7 +51,7 @@ export async function apiTokensRoutes(fastify, opts = {}) {
|
|
|
51
51
|
try {
|
|
52
52
|
const creator = request.user?.name || request.user?.email || null;
|
|
53
53
|
const {entry, plaintext} = await createToken({name, project, scopes, expiresAt, createdBy: creator});
|
|
54
|
-
// The plaintext appears here and nowhere else
|
|
54
|
+
// The plaintext appears here and nowhere else - only the hash is stored.
|
|
55
55
|
return reply.status(201).send({token: entry, plaintext});
|
|
56
56
|
} catch (err) {
|
|
57
57
|
return reply.status(400).send({error: err.message});
|