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
@@ -22,6 +22,20 @@
22
22
  * POST /collections/:slug/public - Create entry (if api.create enabled)
23
23
  * PUT /collections/:slug/public/:id - Update entry (if api.update enabled)
24
24
  * DELETE /collections/:slug/public/:id - Delete entry (if api.delete enabled)
25
+ *
26
+ * External versioned alias (same handlers, stable URL for API consumers):
27
+ * GET /v1/:slug ≡ GET /collections/:slug/public
28
+ * GET /v1/:slug/:id ≡ GET /collections/:slug/public/:id
29
+ * POST /v1/:slug ≡ POST /collections/:slug/public
30
+ * PUT /v1/:slug/:id ≡ PUT /collections/:slug/public/:id
31
+ * DELETE /v1/:slug/:id ≡ DELETE /collections/:slug/public/:id
32
+ *
33
+ * Access modes per verb (`schema.api.<verb>.access`): 'public', a role name
34
+ * (JWT + role level), or 'token' (project-scoped API token — see
35
+ * services/apiTokens.js). A token is ONLY accepted when the mode is 'token';
36
+ * it is never a substitute for a role, and a JWT is never accepted in token
37
+ * mode. `schema.api.read.fields` optionally whitelists which data fields the
38
+ * public/external read endpoints return.
25
39
  */
26
40
  import {
27
41
  clearEntries,
@@ -45,6 +59,8 @@ import {getConfig, saveConfig} from '../../config.js';
45
59
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
46
60
  import {ensureFormForCollection} from '../../services/forms.js';
47
61
  import {hooks} from '../../services/hooks.js';
62
+ import {scopeAllows, validateToken} from '../../services/apiTokens.js';
63
+ import {resolveArtefactProject} from '../../services/projects.js';
48
64
 
49
65
  const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
50
66
 
@@ -82,6 +98,43 @@ function extractBracketed(query, prefix) {
82
98
  return out;
83
99
  }
84
100
 
101
+ /**
102
+ * Redact the stored token hash from an api-tokens entry. The generic admin
103
+ * entry endpoints would otherwise expose it to anyone with collections.read.
104
+ * (SHA-256 of 32 random bytes is not reversible, but there is no reason to
105
+ * show it.) Token management belongs to /api/api-tokens.
106
+ *
107
+ * @param {object} entry
108
+ * @returns {object}
109
+ */
110
+ function redactTokenHash(entry) {
111
+ if (!entry?.data || entry.data.tokenHash === undefined) return entry;
112
+ const { tokenHash, ...data } = entry.data;
113
+ return { ...entry, data };
114
+ }
115
+
116
+ /**
117
+ * Apply the read-field allowlist (`schema.api.read.fields`) to a public read
118
+ * payload. Absent or empty allowlist = all fields. Handles both list payloads
119
+ * ({entries: [...]}) and single entries. Only entry.data is filtered —
120
+ * `_refs` from resolveRefs is not (refs of stripped fields may still appear;
121
+ * documented v1 behaviour).
122
+ *
123
+ * @param {object} schema
124
+ * @param {object} payload - listEntries() result or a single entry
125
+ * @returns {object}
126
+ */
127
+ export function applyReadFieldAllowlist(schema, payload) {
128
+ const fields = schema.api?.read?.fields;
129
+ if (!Array.isArray(fields) || fields.length === 0) return payload;
130
+ const strip = (e) => ({
131
+ ...e,
132
+ data: Object.fromEntries(Object.entries(e.data || {}).filter(([k]) => fields.includes(k)))
133
+ });
134
+ if (Array.isArray(payload?.entries)) return { ...payload, entries: payload.entries.map(strip) };
135
+ return strip(payload);
136
+ }
137
+
85
138
  /**
86
139
  * Check public collection API access.
87
140
  * Returns an error reply if access is denied, otherwise resolves (returns undefined).
@@ -92,7 +145,7 @@ function extractBracketed(query, prefix) {
92
145
  * @param {object} reply - Fastify reply
93
146
  * @returns {Promise<object|undefined>}
94
147
  */
95
- async function checkPublicAccess(schema, operation, request, reply) {
148
+ export async function checkPublicAccess(schema, operation, request, reply) {
96
149
  const access = schema.api?.[operation];
97
150
  if (!access?.enabled) {
98
151
  return reply.status(403).send({ error: `Public ${operation} is disabled for this collection` });
@@ -100,6 +153,28 @@ async function checkPublicAccess(schema, operation, request, reply) {
100
153
 
101
154
  if (access.access === 'public') return; // No auth needed
102
155
 
156
+ // Token mode — project-scoped API token, strict: a token is the ONLY
157
+ // accepted credential here (a JWT never satisfies token mode, and a
158
+ // token never satisfies a role mode).
159
+ if (access.access === 'token') {
160
+ const match = (request.headers.authorization || '').match(/^Bearer (dcms_[a-f0-9]{64})$/);
161
+ if (!match) {
162
+ return reply.status(401).send({ error: 'API token required' });
163
+ }
164
+ const token = await validateToken(match[1]);
165
+ if (!token) {
166
+ return reply.status(401).send({ error: 'Invalid, disabled or expired API token' });
167
+ }
168
+ if (token.project !== resolveArtefactProject(schema)) {
169
+ return reply.status(403).send({ error: "Token is not valid for this collection's project" });
170
+ }
171
+ if (!scopeAllows(token.scopes, schema.slug, operation)) {
172
+ return reply.status(403).send({ error: 'Token scope does not permit this operation' });
173
+ }
174
+ request.apiToken = token;
175
+ return;
176
+ }
177
+
103
178
  // Auth required — try to verify JWT
104
179
  try {
105
180
  await request.jwtVerify();
@@ -240,7 +315,7 @@ export async function collectionsRoutes(fastify) {
240
315
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
241
316
  const { page, limit, sort, order, search } = request.query;
242
317
  const filter = extractBracketed(request.query, 'filter');
243
- return listEntries(request.params.slug, {
318
+ const result = await listEntries(request.params.slug, {
244
319
  page: parseInt(page, 10) || 1,
245
320
  limit: parseInt(limit, 10) || 50,
246
321
  sort: sort || 'createdAt',
@@ -248,12 +323,16 @@ export async function collectionsRoutes(fastify) {
248
323
  search: search || undefined,
249
324
  filter: Object.keys(filter).length ? filter : undefined
250
325
  });
326
+ if (request.params.slug === 'api-tokens') {
327
+ result.entries = result.entries.map(redactTokenHash);
328
+ }
329
+ return result;
251
330
  });
252
331
 
253
332
  fastify.get('/collections/:slug/entries/:id', canRead, async (request, reply) => {
254
333
  const entry = await getEntry(request.params.slug, request.params.id);
255
334
  if (!entry) return reply.status(404).send({ error: 'Entry not found' });
256
- return entry;
335
+ return request.params.slug === 'api-tokens' ? redactTokenHash(entry) : entry;
257
336
  });
258
337
 
259
338
  fastify.post('/collections/:slug/entries', canCreate, async (request, reply) => {
@@ -415,7 +494,7 @@ export async function collectionsRoutes(fastify) {
415
494
  * `[collection scope="mine"]` shortcode for per-user
416
495
  * client-side hydration (e.g. "My Applications").
417
496
  */
418
- fastify.get('/collections/:slug/public', async (request, reply) => {
497
+ async function publicListEntries(request, reply) {
419
498
  const schema = await getCollection(request.params.slug);
420
499
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
421
500
 
@@ -442,7 +521,7 @@ export async function collectionsRoutes(fastify) {
442
521
  if (denied !== undefined) return;
443
522
  }
444
523
 
445
- return listEntries(request.params.slug, {
524
+ const result = await listEntries(request.params.slug, {
446
525
  page: parseInt(page, 10) || 1,
447
526
  limit: parseInt(limit, 10) || 50,
448
527
  sort: sort || 'createdAt',
@@ -451,9 +530,10 @@ export async function collectionsRoutes(fastify) {
451
530
  filter: Object.keys(filter).length ? filter : undefined,
452
531
  resolveRefs: resolveRefs === 'true' || resolveRefs === '1'
453
532
  });
454
- });
533
+ return applyReadFieldAllowlist(schema, result);
534
+ }
455
535
 
456
- fastify.get('/collections/:slug/public/:id', async (request, reply) => {
536
+ async function publicGetEntry(request, reply) {
457
537
  const schema = await getCollection(request.params.slug);
458
538
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
459
539
 
@@ -462,8 +542,11 @@ export async function collectionsRoutes(fastify) {
462
542
 
463
543
  const entry = await getEntry(request.params.slug, request.params.id);
464
544
  if (!entry) return reply.status(404).send({ error: 'Entry not found' });
465
- return entry;
466
- });
545
+ return applyReadFieldAllowlist(schema, entry);
546
+ }
547
+
548
+ fastify.get('/collections/:slug/public', publicListEntries);
549
+ fastify.get('/collections/:slug/public/:id', publicGetEntry);
467
550
 
468
551
  /*
469
552
  * POST /api/collections/render-scope
@@ -567,7 +650,7 @@ export async function collectionsRoutes(fastify) {
567
650
  return { html };
568
651
  });
569
652
 
570
- fastify.post('/collections/:slug/public', async (request, reply) => {
653
+ async function publicCreateEntry(request, reply) {
571
654
  const schema = await getCollection(request.params.slug);
572
655
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
573
656
 
@@ -575,18 +658,17 @@ export async function collectionsRoutes(fastify) {
575
658
  if (denied !== undefined) return;
576
659
 
577
660
  try {
578
- const user = request.user;
579
661
  const entry = await createEntry(request.params.slug, request.body?.data || {}, {
580
- createdBy: user?.id || null,
662
+ createdBy: request.apiToken ? `token:${request.apiToken.id}` : (request.user?.id || null),
581
663
  source: 'api'
582
664
  });
583
665
  return reply.status(201).send(entry);
584
666
  } catch (err) {
585
667
  return reply.status(400).send({ error: err.message });
586
668
  }
587
- });
669
+ }
588
670
 
589
- fastify.put('/collections/:slug/public/:id', async (request, reply) => {
671
+ async function publicUpdateEntry(request, reply) {
590
672
  const schema = await getCollection(request.params.slug);
591
673
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
592
674
 
@@ -599,9 +681,9 @@ export async function collectionsRoutes(fastify) {
599
681
  const status = err.message === 'Entry not found' ? 404 : 400;
600
682
  return reply.status(status).send({ error: err.message });
601
683
  }
602
- });
684
+ }
603
685
 
604
- fastify.delete('/collections/:slug/public/:id', async (request, reply) => {
686
+ async function publicDeleteEntry(request, reply) {
605
687
  const schema = await getCollection(request.params.slug);
606
688
  if (!schema) return reply.status(404).send({ error: 'Collection not found' });
607
689
 
@@ -614,5 +696,20 @@ export async function collectionsRoutes(fastify) {
614
696
  } catch (err) {
615
697
  return reply.status(404).send({ error: err.message });
616
698
  }
617
- });
699
+ }
700
+
701
+ fastify.post('/collections/:slug/public', publicCreateEntry);
702
+ fastify.put('/collections/:slug/public/:id', publicUpdateEntry);
703
+ fastify.delete('/collections/:slug/public/:id', publicDeleteEntry);
704
+
705
+ // -------------------------------------------------------------------------
706
+ // External versioned API — stable alias of the public endpoints above.
707
+ // Documented surface for external consumers (docs/api-reference.md).
708
+ // -------------------------------------------------------------------------
709
+
710
+ fastify.get('/v1/:slug', publicListEntries);
711
+ fastify.get('/v1/:slug/:id', publicGetEntry);
712
+ fastify.post('/v1/:slug', publicCreateEntry);
713
+ fastify.put('/v1/:slug/:id', publicUpdateEntry);
714
+ fastify.delete('/v1/:slug/:id', publicDeleteEntry);
618
715
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Custom API Endpoints — public surface
3
+ *
4
+ * One catch-all serves every user-defined endpoint:
5
+ * GET /api/x/<project><path> e.g. /api/x/world-cup/fixtures-day/2026-06-11
6
+ *
7
+ * Definitions live in the `api-endpoints` preset collection and are matched
8
+ * at request time by services/apiEndpoints.js. Auth (public / token / role),
9
+ * project binding, token scopes, and the read field allowlist all reuse the
10
+ * public collections machinery via a synthesized schema — one battle-tested
11
+ * code path for every external read.
12
+ *
13
+ * Unknown project, unknown path, and disabled endpoints all 404 identically
14
+ * (no existence oracle). GET-only in v1.
15
+ */
16
+ import {EndpointParamError, executeEndpoint, matchEndpoint} from '../../services/apiEndpoints.js';
17
+ import {applyReadFieldAllowlist, checkPublicAccess} from './collections.js';
18
+ import * as cache from '../../services/cache/index.js';
19
+
20
+ /** Cached sentinel for single-mode misses — a miss is a cacheable answer. */
21
+ const NOT_FOUND = {__endpointNotFound: true};
22
+
23
+ /**
24
+ * Stable query-string representation for cache keys.
25
+ *
26
+ * @param {Record<string, string>} query
27
+ * @returns {string}
28
+ */
29
+ function sortedQueryString(query) {
30
+ return Object.keys(query || {})
31
+ .sort()
32
+ .map(k => `${k}=${query[k]}`)
33
+ .join('&');
34
+ }
35
+
36
+ export async function endpointsPublicRoutes(fastify) {
37
+ fastify.get('/x/*', async (request, reply) => {
38
+ const segments = String(request.params['*'] || '').split('/').filter(Boolean);
39
+ if (segments.length < 2) return reply.status(404).send({ error: 'Not found' });
40
+
41
+ const [project, ...rest] = segments;
42
+ const match = await matchEndpoint(project, rest);
43
+ if (!match) return reply.status(404).send({ error: 'Not found' });
44
+
45
+ const {def, params} = match;
46
+
47
+ // Synthesized schema — checkPublicAccess reads exactly api.read,
48
+ // slug (token scopes), and meta.project (token project binding).
49
+ const synth = {
50
+ slug: def.collection,
51
+ api: { read: { enabled: true, access: def.auth, fields: def.fields } },
52
+ meta: { project: def.project }
53
+ };
54
+ const denied = await checkPublicAccess(synth, 'read', request, reply);
55
+ if (denied !== undefined) return;
56
+
57
+ const run = async () => {
58
+ const result = await executeEndpoint(def, params, request.query);
59
+ if (def.mode === 'single') {
60
+ return result === null ? NOT_FOUND : applyReadFieldAllowlist(synth, result);
61
+ }
62
+ return applyReadFieldAllowlist(synth, result);
63
+ };
64
+
65
+ try {
66
+ // Only public responses are cacheable — token/role responses are
67
+ // caller-dependent. Both tags are invalidated by the service layer
68
+ // on every write path: entry mutations bust collection:<slug>, and
69
+ // definition edits (entries in api-endpoints) bust
70
+ // collection:api-endpoints. No manual invalidation needed.
71
+ const payload = def.auth === 'public'
72
+ ? await cache.wrap(
73
+ `apix:${project}:${rest.join('/')}:${sortedQueryString(request.query)}`,
74
+ run,
75
+ { tags: [`collection:${def.collection}`, 'collection:api-endpoints'] }
76
+ )
77
+ : await run();
78
+
79
+ if (payload?.__endpointNotFound) return reply.status(404).send({ error: 'Not found' });
80
+ return payload;
81
+ } catch (err) {
82
+ if (err instanceof EndpointParamError) {
83
+ return reply.status(400).send({ error: err.message });
84
+ }
85
+ throw err;
86
+ }
87
+ });
88
+ }
@@ -1,42 +1,42 @@
1
- /**
2
- * Navigation API
3
- * GET /api/navigation - get navigation config
4
- * PUT /api/navigation - save navigation config
5
- */
6
- import {getConfig, saveConfig} from '../../config.js';
7
- import {authenticate, requirePermission} from '../../middleware/auth.js';
8
- import * as cache from '../../services/cache/index.js';
9
-
10
- export async function navigationRoutes(fastify) {
11
- const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
12
- const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
13
-
14
- fastify.get('/navigation', canRead, async () => {
15
- return getConfig('navigation');
16
- });
17
-
18
- fastify.put('/navigation', canUpdate, async (request, reply) => {
19
- const data = request.body;
20
- if (!data || typeof data !== 'object') {
21
- return reply.status(400).send({ error: 'Invalid navigation data' });
22
- }
23
- if (!Array.isArray(data.items) && !Array.isArray(data)) {
24
- return reply.status(400).send({ error: 'Navigation must be an array of items' });
25
- }
26
- // Normalise child key: Domma navbar expects `items`, not `children`
27
- if (Array.isArray(data.items)) {
28
- data.items = data.items.map(item => {
29
- const children = item.items || item.children;
30
- if (children?.length) {
31
- const { children: _c, ...rest } = item;
32
- return { ...rest, items: children };
33
- }
34
- const { children: _c, ...rest } = item;
35
- return rest;
36
- });
37
- }
38
- saveConfig('navigation', data);
39
- await cache.invalidateTags(['nav']);
40
- return { success: true };
41
- });
42
- }
1
+ /**
2
+ * Navigation API
3
+ * GET /api/navigation - get navigation config
4
+ * PUT /api/navigation - save navigation config
5
+ */
6
+ import {getConfig, saveConfig} from '../../config.js';
7
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
8
+ import * as cache from '../../services/cache/index.js';
9
+
10
+ export async function navigationRoutes(fastify) {
11
+ const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
12
+ const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
13
+
14
+ fastify.get('/navigation', canRead, async () => {
15
+ return getConfig('navigation');
16
+ });
17
+
18
+ fastify.put('/navigation', canUpdate, async (request, reply) => {
19
+ const data = request.body;
20
+ if (!data || typeof data !== 'object') {
21
+ return reply.status(400).send({ error: 'Invalid navigation data' });
22
+ }
23
+ if (!Array.isArray(data.items) && !Array.isArray(data)) {
24
+ return reply.status(400).send({ error: 'Navigation must be an array of items' });
25
+ }
26
+ // Normalise child key: Domma navbar expects `items`, not `children`
27
+ if (Array.isArray(data.items)) {
28
+ data.items = data.items.map(item => {
29
+ const children = item.items || item.children;
30
+ if (children?.length) {
31
+ const { children: _c, ...rest } = item;
32
+ return { ...rest, items: children };
33
+ }
34
+ const { children: _c, ...rest } = item;
35
+ return rest;
36
+ });
37
+ }
38
+ saveConfig('navigation', data);
39
+ await cache.invalidateTags(['nav']);
40
+ return { success: true };
41
+ });
42
+ }