domma-cms 0.43.1 → 0.45.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 +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 +25 -25
- package/admin/js/templates/form-editor.html +5 -5
- package/admin/js/templates/login.html +5 -5
- package/admin/js/templates/menu-editor.html +10 -10
- 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 +1 -1
- 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 +5 -5
- package/bin/update.js +16 -16
- package/config/site.json +86 -86
- 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 +100 -6
- 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 +11 -11
- 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 +10 -10
- 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/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 +1 -1
- 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 +4 -4
- package/server/services/userRoles.js +7 -7
- package/server/services/users.js +5 -5
- 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
- package/config/navigation.json.bak +0 -31
|
@@ -63,7 +63,7 @@ export function requireRole(allowedRoles) {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
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
|
|
66
|
+
* Reads from the roles cache at request time - reflects live role changes.
|
|
67
67
|
*
|
|
68
68
|
* @param {string} resource - Resource key (e.g. 'pages', 'users')
|
|
69
69
|
* @param {string} [action] - Optional action (read | create | update | delete)
|
|
@@ -80,7 +80,7 @@ export function requirePermission(resource, action) {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
// Check ANY of the user's effective roles (primary + additional) against
|
|
83
|
-
// the resource's permission list
|
|
83
|
+
// the resource's permission list - union semantics so a user with both
|
|
84
84
|
// candidate and recruiter roles can do either role's actions.
|
|
85
85
|
const allowed = getPermissionsFor(resource, action);
|
|
86
86
|
const roles = getEffectiveRoles(request.user);
|
|
@@ -103,7 +103,7 @@ export function requirePermission(resource, action) {
|
|
|
103
103
|
export {getPermissionsForRole};
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
* Shorthand preHandler
|
|
106
|
+
* Shorthand preHandler - admin-tier role (level ≤ 1) or above.
|
|
107
107
|
* Matches the base role hierarchy documented in roles.js:
|
|
108
108
|
* super-admin (0), admin (1), user (2).
|
|
109
109
|
* Both super-admin and admin pass; regular users and anything below do not.
|
|
@@ -116,7 +116,7 @@ export async function requireAdmin(request, reply) {
|
|
|
116
116
|
if (!request.user) {
|
|
117
117
|
return reply.code(401).send({ statusCode: 401, error: 'Unauthorised', message: 'Authentication required' });
|
|
118
118
|
}
|
|
119
|
-
// Use effective level
|
|
119
|
+
// Use effective level - a user with "additionalRoles: ['admin']" can do admin work
|
|
120
120
|
// even if their primary role is something lower-privilege.
|
|
121
121
|
if (getEffectiveLevel(request.user) > 1) {
|
|
122
122
|
return reply.code(403).send({ statusCode: 403, error: 'Forbidden', message: 'Admin access required' });
|
|
@@ -127,7 +127,7 @@ export async function requireAdmin(request, reply) {
|
|
|
127
127
|
* Determine whether an actor can manage a target user.
|
|
128
128
|
* Managers cannot create, edit, or delete users with a lower level number (higher privilege).
|
|
129
129
|
*
|
|
130
|
-
* Accepts either user objects (preferred
|
|
130
|
+
* Accepts either user objects (preferred - uses effective level across all roles)
|
|
131
131
|
* or bare role-name strings (legacy compat). Strings are looked up via
|
|
132
132
|
* `getRoleLevel`; objects via `getEffectiveLevel` so multi-role actors and
|
|
133
133
|
* targets are compared by their HIGHEST-privilege role.
|
|
@@ -148,19 +148,19 @@ export function canManageUser(actor, target) {
|
|
|
148
148
|
*
|
|
149
149
|
* Visibility may be either:
|
|
150
150
|
* - A single string ('public', 'private', or a role name)
|
|
151
|
-
* - An array of role names
|
|
151
|
+
* - An array of role names - granted if ANY entry passes the per-role check
|
|
152
152
|
*
|
|
153
153
|
* Per-role semantics are unchanged: each role check passes if the visitor's
|
|
154
154
|
* role level is at or above the required role (lower or equal level number).
|
|
155
155
|
* 'private' resolves to super-admin only (Infinity → level 0).
|
|
156
156
|
*
|
|
157
157
|
* The "any of" semantics for arrays means siblings at different tiers of the
|
|
158
|
-
* hierarchy are all granted access
|
|
158
|
+
* hierarchy are all granted access - e.g. `visibility: [candidate, employer]`
|
|
159
159
|
* lets both roles in, plus anyone more privileged than either (typically
|
|
160
160
|
* admins inherit access automatically via the level comparison).
|
|
161
161
|
*
|
|
162
162
|
* @param {string|null} userRole - The visitor's role, or null if unauthenticated
|
|
163
|
-
* @param {string|string[]} visibility - Required visibility
|
|
163
|
+
* @param {string|string[]} visibility - Required visibility - single value or array
|
|
164
164
|
* @returns {boolean} true if access is granted
|
|
165
165
|
*/
|
|
166
166
|
export function checkVisibility(userRoleOrObj, visibility) {
|
|
@@ -168,7 +168,7 @@ export function checkVisibility(userRoleOrObj, visibility) {
|
|
|
168
168
|
|
|
169
169
|
// Accept either a bare role name (legacy) or a user object with multi-role
|
|
170
170
|
// support. When given an object we walk every effective role and grant
|
|
171
|
-
// access if ANY satisfies
|
|
171
|
+
// access if ANY satisfies - same union semantics as permissions.
|
|
172
172
|
const roles = typeof userRoleOrObj === 'string'
|
|
173
173
|
? (userRoleOrObj ? [userRoleOrObj] : [])
|
|
174
174
|
: getEffectiveRoles(userRoleOrObj);
|
|
@@ -186,7 +186,7 @@ export function checkVisibility(userRoleOrObj, visibility) {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* Internal helper
|
|
189
|
+
* Internal helper - single-role visibility check.
|
|
190
190
|
* Returns true if the user role is at or above the required role's level.
|
|
191
191
|
*
|
|
192
192
|
* @param {string} userRole - Must not be null
|
|
@@ -201,7 +201,7 @@ function checkSingleVisibility(userRole, visibility) {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
|
-
* Fastify preHandler factory
|
|
204
|
+
* Fastify preHandler factory - gates a route by visibility level.
|
|
205
205
|
* Works identically to the content-page visibility system; accepts the same
|
|
206
206
|
* single-string or array-of-roles syntax as checkVisibility().
|
|
207
207
|
*
|
|
@@ -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});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* POST /api/auth/login - { email, password } → { token, refreshToken, user }
|
|
6
6
|
* GET /api/auth/me - return current user from token
|
|
7
7
|
* POST /api/auth/refresh - { refreshToken } → { token }
|
|
8
|
-
* POST /api/auth/logout - { refreshToken } → { ok: true }
|
|
8
|
+
* POST /api/auth/logout - { refreshToken } → { ok: true } - blacklists the refresh token
|
|
9
9
|
*/
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import {config, getConfig} from '../../config.js';
|
|
@@ -58,7 +58,7 @@ export async function authRoutes(fastify) {
|
|
|
58
58
|
return { needsSetup: count === 0 };
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
// POST /api/auth/setup
|
|
61
|
+
// POST /api/auth/setup - create the very first admin (only allowed when no users exist)
|
|
62
62
|
fastify.post('/auth/setup', async (request, reply) => {
|
|
63
63
|
if (setupInProgress) {
|
|
64
64
|
return reply.status(409).send({error: 'Setup already in progress'});
|
|
@@ -114,13 +114,13 @@ export async function authRoutes(fastify) {
|
|
|
114
114
|
return { token, refreshToken, user: safeUser };
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
// GET /api/auth/permissions
|
|
117
|
+
// GET /api/auth/permissions - returns the raw permissions array for the authenticated user's role
|
|
118
118
|
fastify.get('/auth/permissions', {preHandler: [authenticate]}, async (request) => {
|
|
119
119
|
const permissions = getPermissionsForRole(request.user.role);
|
|
120
120
|
return {permissions};
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
// GET /api/auth/permissions-registry
|
|
123
|
+
// GET /api/auth/permissions-registry - returns the full permissions registry
|
|
124
124
|
fastify.get('/auth/permissions-registry', {preHandler: [authenticate]}, async () => {
|
|
125
125
|
return {groups: GROUP_ORDER, resources: REGISTRY};
|
|
126
126
|
});
|
|
@@ -133,7 +133,7 @@ export async function authRoutes(fastify) {
|
|
|
133
133
|
return {...user, profile: profileEntry?.data || {}};
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
// PUT /api/auth/me
|
|
136
|
+
// PUT /api/auth/me - self-service update (name, email, password, profile)
|
|
137
137
|
fastify.put('/auth/me', {preHandler: [authenticate]}, async (request, reply) => {
|
|
138
138
|
const id = request.user.id;
|
|
139
139
|
const {name, email, password, profile} = request.body || {};
|
|
@@ -157,10 +157,10 @@ export async function authRoutes(fastify) {
|
|
|
157
157
|
return {...user, profile: profileEntry?.data || {}};
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
-
// POST /api/auth/forgot-password
|
|
160
|
+
// POST /api/auth/forgot-password - send a password reset email (no auth)
|
|
161
161
|
fastify.post('/auth/forgot-password', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (request) => {
|
|
162
162
|
const {email} = request.body || {};
|
|
163
|
-
if (!email) return {ok: true}; // always ok
|
|
163
|
+
if (!email) return {ok: true}; // always ok - prevents enumeration
|
|
164
164
|
|
|
165
165
|
try {
|
|
166
166
|
const user = await getUserByEmail(email);
|
|
@@ -195,7 +195,7 @@ export async function authRoutes(fastify) {
|
|
|
195
195
|
<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
|
|
196
196
|
<h2 style="color:#333;">Password Reset</h2>
|
|
197
197
|
<p>Hi ${user.name},</p>
|
|
198
|
-
<p>We received a request to reset your password. Click the link below
|
|
198
|
+
<p>We received a request to reset your password. Click the link below - it expires in 1 hour.</p>
|
|
199
199
|
<p style="margin:24px 0;">
|
|
200
200
|
<a href="${resetUrl}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Reset Password</a>
|
|
201
201
|
</p>
|
|
@@ -212,7 +212,7 @@ export async function authRoutes(fastify) {
|
|
|
212
212
|
return {ok: true};
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
// POST /api/auth/reset-password
|
|
215
|
+
// POST /api/auth/reset-password - consume token and set new password (no auth)
|
|
216
216
|
fastify.post('/auth/reset-password', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
|
|
217
217
|
const {token, password} = request.body || {};
|
|
218
218
|
if (!token || !password) {
|
|
@@ -237,7 +237,7 @@ export async function authRoutes(fastify) {
|
|
|
237
237
|
return {ok: true};
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
// POST /api/auth/logout
|
|
240
|
+
// POST /api/auth/logout - blacklists the refresh token (fire-and-forget safe: no auth required)
|
|
241
241
|
fastify.post('/auth/logout', async (request) => {
|
|
242
242
|
const {refreshToken} = request.body || {};
|
|
243
243
|
if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
|
|
@@ -73,7 +73,7 @@ export async function blocksRoutes(fastify) {
|
|
|
73
73
|
}
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
// Import a bundle
|
|
76
|
+
// Import a bundle - creates or updates a block from a .dmblock.json payload.
|
|
77
77
|
// Pass { overwrite: true } to replace an existing block by the same name;
|
|
78
78
|
// without it, a collision returns 409 so the caller can confirm.
|
|
79
79
|
fastify.post('/blocks/import', canUpdate, async (request, reply) => {
|
|
@@ -90,7 +90,7 @@ export async function blocksRoutes(fastify) {
|
|
|
90
90
|
return reply.status(400).send({error: 'Bundle is missing html content'});
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// Collision check
|
|
93
|
+
// Collision check - only when the caller hasn't explicitly opted in to overwrite.
|
|
94
94
|
if (!overwrite) {
|
|
95
95
|
try {
|
|
96
96
|
await getBlock(name);
|
|
@@ -98,7 +98,7 @@ export async function blocksRoutes(fastify) {
|
|
|
98
98
|
} catch (err) {
|
|
99
99
|
if (err.code === 'INVALID_NAME') return reply.status(400).send({error: err.message});
|
|
100
100
|
if (err.code !== 'ENOENT') throw err;
|
|
101
|
-
// ENOENT
|
|
101
|
+
// ENOENT - no collision, proceed
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* DELETE /v1/:slug/:id ≡ DELETE /collections/:slug/public/:id
|
|
32
32
|
*
|
|
33
33
|
* Access modes per verb (`schema.api.<verb>.access`): 'public', a role name
|
|
34
|
-
* (JWT + role level), or 'token' (project-scoped API token
|
|
34
|
+
* (JWT + role level), or 'token' (project-scoped API token - see
|
|
35
35
|
* services/apiTokens.js). A token is ONLY accepted when the mode is 'token';
|
|
36
36
|
* it is never a substitute for a role, and a JWT is never accepted in token
|
|
37
37
|
* mode. `schema.api.read.fields` optionally whitelists which data fields the
|
|
@@ -116,7 +116,7 @@ function redactTokenHash(entry) {
|
|
|
116
116
|
/**
|
|
117
117
|
* Apply the read-field allowlist (`schema.api.read.fields`) to a public read
|
|
118
118
|
* payload. Absent or empty allowlist = all fields. Handles both list payloads
|
|
119
|
-
* ({entries: [...]}) and single entries. Only entry.data is filtered
|
|
119
|
+
* ({entries: [...]}) and single entries. Only entry.data is filtered -
|
|
120
120
|
* `_refs` from resolveRefs is not (refs of stripped fields may still appear;
|
|
121
121
|
* documented v1 behaviour).
|
|
122
122
|
*
|
|
@@ -147,10 +147,10 @@ export function applyReadFieldAllowlist(schema, payload) {
|
|
|
147
147
|
*/
|
|
148
148
|
export async function checkPublicAccess(schema, operation, request, reply) {
|
|
149
149
|
// Hard kill-switch: if the artefact's project is disabled, the whole
|
|
150
|
-
// external surface is off
|
|
150
|
+
// external surface is off - 404 (no oracle). This covers /api/v1
|
|
151
151
|
// collections, the synthesized-schema custom endpoints (/api/x/*, whose
|
|
152
152
|
// meta.project is the endpoint's project), and any token bound to the
|
|
153
|
-
// project
|
|
153
|
+
// project - all funnel through here.
|
|
154
154
|
if (!(await isProjectEnabled(resolveArtefactProject(schema)))) {
|
|
155
155
|
return reply.status(404).send({ error: 'Not found' });
|
|
156
156
|
}
|
|
@@ -162,7 +162,7 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
162
162
|
|
|
163
163
|
if (access.access === 'public') return; // No auth needed
|
|
164
164
|
|
|
165
|
-
// Token mode
|
|
165
|
+
// Token mode - project-scoped API token, strict: a token is the ONLY
|
|
166
166
|
// accepted credential here (a JWT never satisfies token mode, and a
|
|
167
167
|
// token never satisfies a role mode).
|
|
168
168
|
if (access.access === 'token') {
|
|
@@ -184,7 +184,7 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
// Auth required
|
|
187
|
+
// Auth required - try to verify JWT
|
|
188
188
|
try {
|
|
189
189
|
await request.jwtVerify();
|
|
190
190
|
} catch {
|
|
@@ -193,7 +193,7 @@ export async function checkPublicAccess(schema, operation, request, reply) {
|
|
|
193
193
|
|
|
194
194
|
const user = request.user;
|
|
195
195
|
const requiredLevel = roleLevel(access.access);
|
|
196
|
-
// Effective level
|
|
196
|
+
// Effective level - multi-role users get the lowest (most privileged) level
|
|
197
197
|
// across their primary + additional roles for this access decision.
|
|
198
198
|
const userLevel = getEffectiveLevel(user);
|
|
199
199
|
|
|
@@ -307,9 +307,9 @@ export async function collectionsRoutes(fastify) {
|
|
|
307
307
|
* GET /api/collections/:slug/entries
|
|
308
308
|
*
|
|
309
309
|
* Query parameters:
|
|
310
|
-
* page, limit, sort, order
|
|
311
|
-
* search
|
|
312
|
-
* filter[<field>_<op>]=val
|
|
310
|
+
* page, limit, sort, order - pagination/sort (limit=0 means "no limit")
|
|
311
|
+
* search - free-text substring across all field values
|
|
312
|
+
* filter[<field>_<op>]=val - structured filter; see filterEngine.js
|
|
313
313
|
*
|
|
314
314
|
* Example:
|
|
315
315
|
* /api/collections/jobs/entries?filter[location]=London&filter[salary_gte]=50000
|
|
@@ -388,7 +388,7 @@ export async function collectionsRoutes(fastify) {
|
|
|
388
388
|
}
|
|
389
389
|
});
|
|
390
390
|
|
|
391
|
-
// Clear all entries
|
|
391
|
+
// Clear all entries - DELETE /collections/:slug/entries (no :id)
|
|
392
392
|
fastify.delete('/collections/:slug/entries', canDelete, async (request, reply) => {
|
|
393
393
|
try {
|
|
394
394
|
await clearEntries(request.params.slug);
|
|
@@ -493,11 +493,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
493
493
|
/*
|
|
494
494
|
* GET /api/collections/:slug/public
|
|
495
495
|
*
|
|
496
|
-
* Public read endpoint
|
|
496
|
+
* Public read endpoint - gated by `api.read` in the collection schema.
|
|
497
497
|
* Supports the same pagination/sort/search/filter as the admin endpoint
|
|
498
498
|
* (see GET /collections/:slug/entries above), plus:
|
|
499
499
|
*
|
|
500
|
-
* scope=mine
|
|
500
|
+
* scope=mine - restrict results to entries created by the current
|
|
501
501
|
* user (`meta.createdBy === user.id`). Requires a valid
|
|
502
502
|
* JWT; returns 401 if unauthenticated. Used by the
|
|
503
503
|
* `[collection scope="mine"]` shortcode for per-user
|
|
@@ -566,11 +566,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
566
566
|
* resolved here and the rendered HTML fragment swapped in client-side.
|
|
567
567
|
*
|
|
568
568
|
* Body: { attrs: <base64 JSON of the original shortcode attributes> }
|
|
569
|
-
* Auth: JWT required
|
|
569
|
+
* Auth: JWT required - `createdBy = <user.id>` is injected server-side
|
|
570
570
|
* (the client cannot tamper with which user's data they see).
|
|
571
571
|
* Returns: { html: '<div class="dm-collection-…">…</div>' }
|
|
572
572
|
*
|
|
573
|
-
* Security: results are always scoped to `createdBy = <user.id>`
|
|
573
|
+
* Security: results are always scoped to `createdBy = <user.id>` -
|
|
574
574
|
* a caller can only ever render their own entries, regardless of the
|
|
575
575
|
* collection's `api.read` setting. This matches the security model
|
|
576
576
|
* of GET /:slug/public?scope=mine.
|
|
@@ -586,11 +586,11 @@ export async function collectionsRoutes(fastify) {
|
|
|
586
586
|
*
|
|
587
587
|
* Body: {
|
|
588
588
|
* attrs: <base64 JSON of the shortcode attributes>,
|
|
589
|
-
* page: <number, optional
|
|
589
|
+
* page: <number, optional - applied via attrs.limit + slicing>
|
|
590
590
|
* }
|
|
591
591
|
*
|
|
592
592
|
* Auth: gated by the target collection's `api.read` setting via
|
|
593
|
-
* checkPublicAccess(). No user identity is injected
|
|
593
|
+
* checkPublicAccess(). No user identity is injected - this
|
|
594
594
|
* endpoint is for general fragment rendering, not per-user
|
|
595
595
|
* scoping (that's render-scope's job).
|
|
596
596
|
*
|
|
@@ -712,7 +712,7 @@ export async function collectionsRoutes(fastify) {
|
|
|
712
712
|
fastify.delete('/collections/:slug/public/:id', publicDeleteEntry);
|
|
713
713
|
|
|
714
714
|
// -------------------------------------------------------------------------
|
|
715
|
-
// External versioned API
|
|
715
|
+
// External versioned API - stable alias of the public endpoints above.
|
|
716
716
|
// Documented surface for external consumers (docs/api-reference.md).
|
|
717
717
|
// -------------------------------------------------------------------------
|
|
718
718
|
|
|
@@ -98,7 +98,7 @@ function buildTopPages(daily, days = 7) {
|
|
|
98
98
|
.slice(0, 5);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// ── Journeys / spikes
|
|
101
|
+
// ── Journeys / spikes - ported verbatim from plugins/analytics so the core
|
|
102
102
|
// dashboard can compute them off disk without the plugin's encapsulated
|
|
103
103
|
// `fastify.analytics` decorator (which it cannot see). Keep in sync with
|
|
104
104
|
// plugins/analytics/plugin.js.
|
|
@@ -181,7 +181,7 @@ function detectSpikes(daily, now = new Date()) {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
/**
|
|
184
|
-
* Recent activity feed
|
|
184
|
+
* Recent activity feed - combines page version events with recent collection
|
|
185
185
|
* entries (form submissions). Returns at most 10 items, newest first.
|
|
186
186
|
*
|
|
187
187
|
* @returns {Promise<Array<object>>}
|
|
@@ -189,7 +189,7 @@ function detectSpikes(daily, now = new Date()) {
|
|
|
189
189
|
async function buildActivity() {
|
|
190
190
|
const items = [];
|
|
191
191
|
|
|
192
|
-
// Versions
|
|
192
|
+
// Versions - read each page's _meta.json via the service so the schema
|
|
193
193
|
// stays in one place. listVersions returns newest-first.
|
|
194
194
|
const versionsDir = path.join(ROOT, 'content', 'versions');
|
|
195
195
|
try {
|
|
@@ -211,7 +211,7 @@ async function buildActivity() {
|
|
|
211
211
|
}
|
|
212
212
|
} catch { /* no versions dir */ }
|
|
213
213
|
|
|
214
|
-
// Collection entries
|
|
214
|
+
// Collection entries - each slug has a single data.json with an array of
|
|
215
215
|
// entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
|
|
216
216
|
const collectionsDir = path.join(ROOT, 'content', 'collections');
|
|
217
217
|
try {
|
|
@@ -277,7 +277,7 @@ export async function dashboardRoutes(fastify, opts = {}) {
|
|
|
277
277
|
const analytics = fastify.analytics;
|
|
278
278
|
// The analytics plugin decorates `analytics` inside its own prefixed,
|
|
279
279
|
// encapsulated scope, so `fastify.analytics` is undefined here in the
|
|
280
|
-
// core dashboard route
|
|
280
|
+
// core dashboard route - traffic/topPages always read 0 even though hits
|
|
281
281
|
// are recorded. Read the plugin's daily counters straight off disk (same
|
|
282
282
|
// pattern as buildActivity reading versions/collections below).
|
|
283
283
|
const daily = (await safe('analytics.daily', async () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Effects API Routes
|
|
3
|
-
* GET /api/effects/settings
|
|
4
|
-
* PUT /api/effects/settings
|
|
3
|
+
* GET /api/effects/settings - public (read by public site IIFE)
|
|
4
|
+
* PUT /api/effects/settings - admin-only (save user overrides)
|
|
5
5
|
*/
|
|
6
6
|
import {authenticate, requireAdmin} from '../../middleware/auth.js';
|
|
7
7
|
import {getConfig, saveConfig} from '../../config.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Custom API Endpoints
|
|
2
|
+
* Custom API Endpoints - public surface
|
|
3
3
|
*
|
|
4
4
|
* One catch-all serves every user-defined endpoint:
|
|
5
5
|
* GET /api/x/<project><path> e.g. /api/x/world-cup/fixtures-day/2026-06-11
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Definitions live in the `api-endpoints` preset collection and are matched
|
|
8
8
|
* at request time by services/apiEndpoints.js. Auth (public / token / role),
|
|
9
9
|
* project binding, token scopes, and the read field allowlist all reuse the
|
|
10
|
-
* public collections machinery via a synthesized schema
|
|
10
|
+
* public collections machinery via a synthesized schema - one battle-tested
|
|
11
11
|
* code path for every external read.
|
|
12
12
|
*
|
|
13
13
|
* Unknown project, unknown path, and disabled endpoints all 404 identically
|
|
@@ -17,7 +17,7 @@ import {EndpointParamError, executeEndpoint, matchEndpoint} from '../../services
|
|
|
17
17
|
import {applyReadFieldAllowlist, checkPublicAccess} from './collections.js';
|
|
18
18
|
import * as cache from '../../services/cache/index.js';
|
|
19
19
|
|
|
20
|
-
/** Cached sentinel for single-mode misses
|
|
20
|
+
/** Cached sentinel for single-mode misses - a miss is a cacheable answer. */
|
|
21
21
|
const NOT_FOUND = {__endpointNotFound: true};
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -44,7 +44,7 @@ export async function endpointsPublicRoutes(fastify) {
|
|
|
44
44
|
|
|
45
45
|
const {def, params} = match;
|
|
46
46
|
|
|
47
|
-
// Synthesized schema
|
|
47
|
+
// Synthesized schema - checkPublicAccess reads exactly api.read,
|
|
48
48
|
// slug (token scopes), and meta.project (token project binding).
|
|
49
49
|
const synth = {
|
|
50
50
|
slug: def.collection,
|
|
@@ -63,7 +63,7 @@ export async function endpointsPublicRoutes(fastify) {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
|
-
// Only public responses are cacheable
|
|
66
|
+
// Only public responses are cacheable - token/role responses are
|
|
67
67
|
// caller-dependent. Both tags are invalidated by the service layer
|
|
68
68
|
// on every write path: entry mutations bust collection:<slug>, and
|
|
69
69
|
// definition edits (entries in api-endpoints) bust
|