domma-cms 0.22.6 → 0.24.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 (45) hide show
  1. package/CLAUDE.md +16 -5
  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-tokens.html +13 -0
  7. package/admin/js/templates/effects.html +752 -752
  8. package/admin/js/templates/form-submissions.html +30 -30
  9. package/admin/js/templates/forms.html +17 -17
  10. package/admin/js/templates/my-profile.html +17 -17
  11. package/admin/js/templates/project-settings.html +1 -1
  12. package/admin/js/templates/role-editor.html +70 -70
  13. package/admin/js/templates/roles.html +10 -10
  14. package/admin/js/views/api-tokens.js +8 -0
  15. package/admin/js/views/collection-editor.js +4 -4
  16. package/admin/js/views/index.js +1 -1
  17. package/admin/js/views/project-settings.js +1 -1
  18. package/admin/js/views/projects.js +3 -3
  19. package/admin/js/views/roles.js +1 -1
  20. package/bin/lib/config-merge.js +44 -44
  21. package/bin/update.js +547 -547
  22. package/config/menus/admin-sidebar.json +7 -1
  23. package/package.json +3 -2
  24. package/server/middleware/auth.js +253 -253
  25. package/server/routes/api/api-tokens.js +83 -0
  26. package/server/routes/api/auth.js +309 -309
  27. package/server/routes/api/collections.js +113 -16
  28. package/server/routes/api/forms.js +765 -746
  29. package/server/routes/api/navigation.js +42 -42
  30. package/server/routes/api/projects.js +9 -2
  31. package/server/routes/api/settings.js +141 -141
  32. package/server/routes/public.js +202 -202
  33. package/server/server.js +10 -1
  34. package/server/services/apiTokens.js +259 -0
  35. package/server/services/email.js +167 -167
  36. package/server/services/forms.js +345 -255
  37. package/server/services/permissionRegistry.js +13 -0
  38. package/server/services/presetCollections.js +27 -1
  39. package/server/services/projects.js +115 -24
  40. package/server/services/roles.js +16 -0
  41. package/server/services/scaffolder.js +31 -1
  42. package/server/services/sidebar-migration.js +44 -0
  43. package/server/services/userProfiles.js +199 -199
  44. package/server/services/users.js +302 -302
  45. 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
+ 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).
@@ -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
  }