domma-cms 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CLAUDE.md +14 -0
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/crud-tutorial.js +1 -1
  5. package/admin/js/lib/project-context.js +1 -1
  6. package/admin/js/templates/api-endpoint-editor.html +120 -0
  7. package/admin/js/templates/api-endpoints.html +13 -0
  8. package/admin/js/templates/api-tokens.html +13 -0
  9. package/admin/js/templates/effects.html +752 -752
  10. package/admin/js/templates/form-submissions.html +30 -30
  11. package/admin/js/templates/forms.html +17 -17
  12. package/admin/js/templates/my-profile.html +17 -17
  13. package/admin/js/templates/role-editor.html +70 -70
  14. package/admin/js/templates/roles.html +10 -10
  15. package/admin/js/views/api-endpoint-editor.js +1 -0
  16. package/admin/js/views/api-endpoints.js +7 -0
  17. package/admin/js/views/api-tokens.js +8 -0
  18. package/admin/js/views/collection-editor.js +4 -4
  19. package/admin/js/views/index.js +1 -1
  20. package/admin/js/views/project-detail.js +1 -1
  21. package/admin/js/views/roles.js +1 -1
  22. package/bin/lib/config-merge.js +44 -44
  23. package/bin/update.js +547 -547
  24. package/config/menus/admin-sidebar.json +13 -1
  25. package/package.json +1 -1
  26. package/server/middleware/auth.js +253 -253
  27. package/server/routes/api/api-endpoints.js +96 -0
  28. package/server/routes/api/api-tokens.js +83 -0
  29. package/server/routes/api/auth.js +309 -309
  30. package/server/routes/api/collections.js +114 -17
  31. package/server/routes/api/endpoints-public.js +88 -0
  32. package/server/routes/api/navigation.js +42 -42
  33. package/server/routes/api/settings.js +141 -141
  34. package/server/routes/public.js +202 -202
  35. package/server/server.js +16 -1
  36. package/server/services/apiEndpoints.js +402 -0
  37. package/server/services/apiTokens.js +273 -0
  38. package/server/services/email.js +167 -167
  39. package/server/services/permissionRegistry.js +26 -0
  40. package/server/services/presetCollections.js +54 -0
  41. package/server/services/projects.js +18 -2
  42. package/server/services/roles.js +16 -0
  43. package/server/services/scaffolder.js +54 -1
  44. package/server/services/sidebar-migration.js +45 -0
  45. package/server/services/userProfiles.js +199 -199
  46. package/server/services/users.js +302 -302
  47. package/config/connections.json.bak +0 -9
@@ -95,6 +95,12 @@
95
95
  "url": "#/components",
96
96
  "icon": "component",
97
97
  "permission": "components"
98
+ },
99
+ {
100
+ "text": "API Builder",
101
+ "url": "#/api-endpoints",
102
+ "icon": "code",
103
+ "permission": "api-endpoints"
98
104
  }
99
105
  ]
100
106
  },
@@ -143,6 +149,12 @@
143
149
  "text": "My Profile",
144
150
  "url": "#/my-profile",
145
151
  "icon": "user"
152
+ },
153
+ {
154
+ "text": "API Tokens",
155
+ "url": "#/api-tokens",
156
+ "icon": "key",
157
+ "permission": "api-tokens"
146
158
  }
147
159
  ]
148
160
  },
@@ -171,7 +183,7 @@
171
183
  ],
172
184
  "meta": {
173
185
  "createdAt": "2026-05-25T11:11:15.223Z",
174
- "updatedAt": "2026-05-25T13:48:21.541Z",
186
+ "updatedAt": "2026-06-10T12:43:41.918Z",
175
187
  "bundled": false,
176
188
  "presetOwner": null,
177
189
  "project": null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * API Endpoints API (admin management of custom endpoint definitions)
3
+ *
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
+ *
10
+ * Endpoints are project-scoped artefacts: non-super-admin users with a
11
+ * `projects: []` access scope only see/manage definitions in their projects.
12
+ * Auth middlewares are accepted as DI options so tests can supply no-ops.
13
+ */
14
+ import {
15
+ authenticate as defaultAuthenticate,
16
+ requirePermission as defaultRequirePermission
17
+ } from '../../middleware/auth.js';
18
+ import {
19
+ createEndpoint,
20
+ deleteEndpoint,
21
+ getEndpointSanitised,
22
+ listEndpointsSanitised,
23
+ updateEndpoint
24
+ } from '../../services/apiEndpoints.js';
25
+ import {canSeeArtefact} from '../../services/projects.js';
26
+
27
+ /**
28
+ * Register the api-endpoints routes.
29
+ *
30
+ * @param {import('fastify').FastifyInstance} fastify
31
+ * @param {{authenticate?: Function, requirePermission?: Function}} [opts]
32
+ * @returns {Promise<void>}
33
+ */
34
+ export async function apiEndpointsRoutes(fastify, opts = {}) {
35
+ const authenticate = opts.authenticate || defaultAuthenticate;
36
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
37
+
38
+ const canRead = {preHandler: [authenticate, requirePermission('api-endpoints', 'read')]};
39
+ const canCreate = {preHandler: [authenticate, requirePermission('api-endpoints', 'create')]};
40
+ const canUpdate = {preHandler: [authenticate, requirePermission('api-endpoints', 'update')]};
41
+ const canDelete = {preHandler: [authenticate, requirePermission('api-endpoints', 'delete')]};
42
+
43
+ const statusFor = (err) => err.code === 'DUPLICATE' ? 409 : 400;
44
+
45
+ fastify.get('/api-endpoints', canRead, async (request) => {
46
+ return listEndpointsSanitised(request.user);
47
+ });
48
+
49
+ fastify.get('/api-endpoints/:id', canRead, async (request, reply) => {
50
+ const def = await getEndpointSanitised(request.params.id);
51
+ if (!def) return reply.status(404).send({error: 'Endpoint not found'});
52
+ if (!canSeeArtefact(request.user, {meta: {project: def.project}})) {
53
+ return reply.status(403).send({error: 'Access denied for this project'});
54
+ }
55
+ return def;
56
+ });
57
+
58
+ fastify.post('/api-endpoints', canCreate, async (request, reply) => {
59
+ const input = request.body || {};
60
+ if (input.project && !canSeeArtefact(request.user, {meta: {project: input.project}})) {
61
+ return reply.status(403).send({error: 'Access denied for this project'});
62
+ }
63
+ try {
64
+ const creator = request.user?.name || request.user?.email || null;
65
+ const def = await createEndpoint({...input, createdBy: creator});
66
+ return reply.status(201).send(def);
67
+ } catch (err) {
68
+ return reply.status(statusFor(err)).send({error: err.message});
69
+ }
70
+ });
71
+
72
+ fastify.put('/api-endpoints/:id', canUpdate, async (request, reply) => {
73
+ const existing = await getEndpointSanitised(request.params.id);
74
+ if (!existing) return reply.status(404).send({error: 'Endpoint not found'});
75
+ if (!canSeeArtefact(request.user, {meta: {project: existing.project}})) {
76
+ return reply.status(403).send({error: 'Access denied for this project'});
77
+ }
78
+ // The project binding is the endpoint's URL namespace — immutable.
79
+ const {project: _ignored, ...patch} = request.body || {};
80
+ try {
81
+ return await updateEndpoint(request.params.id, patch);
82
+ } catch (err) {
83
+ return reply.status(statusFor(err)).send({error: err.message});
84
+ }
85
+ });
86
+
87
+ fastify.delete('/api-endpoints/:id', canDelete, async (request, reply) => {
88
+ const existing = await getEndpointSanitised(request.params.id);
89
+ if (!existing) return reply.status(404).send({error: 'Endpoint not found'});
90
+ if (!canSeeArtefact(request.user, {meta: {project: existing.project}})) {
91
+ return reply.status(403).send({error: 'Access denied for this project'});
92
+ }
93
+ await deleteEndpoint(request.params.id);
94
+ return {success: true};
95
+ });
96
+ }