domma-cms 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,15 @@ const LAST_USED_THROTTLE_MS = 60_000;
22
22
  /** In-memory hash → entry cache; null = needs rebuild. */
23
23
  let tokenCache = null;
24
24
 
25
+ /**
26
+ * Serialises this service's fire-and-forget writes (the throttled lastUsedAt
27
+ * update) against its mutations. FileAdapter does unlocked read-modify-write,
28
+ * so an in-flight lastUsedAt update that read the data file BEFORE a revoke
29
+ * wrote it would write the revoked token straight back — resurrection.
30
+ * Mutations await the chain; lastUsedAt updates append to it.
31
+ */
32
+ let writeChain = Promise.resolve();
33
+
25
34
  function invalidateCache() {
26
35
  tokenCache = null;
27
36
  }
@@ -178,7 +187,10 @@ export async function validateToken(plaintext) {
178
187
  // updateEntry replaces data wholesale — pass the full object.
179
188
  const data = {...entry.data, lastUsedAt: new Date().toISOString()};
180
189
  entry.data = data; // keep the cached copy current without invalidating
181
- updateEntry(API_TOKENS_COLLECTION_SLUG, entry.id, data).catch(() => {});
190
+ // Fire-and-forget, but chained so revoke/update can await it.
191
+ writeChain = writeChain
192
+ .then(() => updateEntry(API_TOKENS_COLLECTION_SLUG, entry.id, data))
193
+ .catch(() => {});
182
194
  }
183
195
 
184
196
  return sanitise(entry);
@@ -218,6 +230,7 @@ export async function getTokenSanitised(id) {
218
230
  * @throws {Error} On validation failure
219
231
  */
220
232
  export async function updateToken(id, patch = {}) {
233
+ await writeChain; // drain any in-flight lastUsedAt write first
221
234
  const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
222
235
  if (!entry) return null;
223
236
 
@@ -251,6 +264,7 @@ export async function updateToken(id, patch = {}) {
251
264
  * @returns {Promise<boolean>}
252
265
  */
253
266
  export async function revokeToken(id) {
267
+ await writeChain; // drain any in-flight lastUsedAt write — revoke must be final
254
268
  const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
255
269
  if (!entry) return false;
256
270
  await deleteEntry(API_TOKENS_COLLECTION_SLUG, id);
@@ -215,6 +215,19 @@ export const REGISTRY = [
215
215
  {key: 'update', label: 'Edit', description: 'Rename, enable/disable, or edit token scopes'},
216
216
  {key: 'delete', label: 'Revoke', description: 'Revoke (delete) API tokens'}
217
217
  ]
218
+ },
219
+ {
220
+ key: 'api-endpoints',
221
+ label: 'API Builder',
222
+ description: 'Build custom REST endpoints over collection data (/api/x/*).',
223
+ icon: 'code',
224
+ group: 'Configuration',
225
+ actions: [
226
+ {key: 'read', label: 'View', description: 'View custom API endpoint definitions'},
227
+ {key: 'create', label: 'Create', description: 'Create new endpoint definitions'},
228
+ {key: 'update', label: 'Edit', description: 'Edit endpoint definitions'},
229
+ {key: 'delete', label: 'Delete', description: 'Delete endpoint definitions'}
230
+ ]
218
231
  }
219
232
  ];
220
233
 
@@ -85,6 +85,35 @@ const PRESETS = [
85
85
  delete: {enabled: false, access: 'admin'}
86
86
  }
87
87
  }
88
+ ,
89
+ {
90
+ slug: 'api-endpoints',
91
+ title: 'API Endpoints',
92
+ description: 'Curated custom REST endpoints over collection data (/api/x/*).',
93
+ preset: true,
94
+ systemManaged: true,
95
+ fields: [
96
+ {name: 'name', label: 'Name', type: 'text', required: true},
97
+ {name: 'project', label: 'Project', type: 'text', required: true},
98
+ {name: 'path', label: 'Path', type: 'text', required: true},
99
+ {name: 'collection', label: 'Collection', type: 'text', required: true},
100
+ {name: 'auth', label: 'Auth', type: 'text', default: 'public'},
101
+ {name: 'mode', label: 'Mode', type: 'select', options: ['list', 'single'], default: 'list'},
102
+ {name: 'filter', label: 'Filter', type: 'object', default: {}},
103
+ {name: 'sort', label: 'Sort Field', type: 'text'},
104
+ {name: 'order', label: 'Sort Order', type: 'select', options: ['asc', 'desc'], default: 'desc'},
105
+ {name: 'limit', label: 'Limit', type: 'number', default: 50},
106
+ {name: 'fields', label: 'Fields', type: 'array', items: 'string', default: []},
107
+ {name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
108
+ {name: 'createdBy', label: 'Created By', type: 'text'}
109
+ ],
110
+ api: {
111
+ create: {enabled: false, access: 'admin'},
112
+ read: {enabled: false, access: 'admin'},
113
+ update: {enabled: false, access: 'admin'},
114
+ delete: {enabled: false, access: 'admin'}
115
+ }
116
+ }
88
117
  ];
89
118
 
90
119
  /** Slugs exported for use in adapterRegistry and the delete guard. */
@@ -337,7 +337,7 @@ export async function getProjectForPage(urlPath, explicitProject) {
337
337
  export async function getArtefactsForProject(projectSlug) {
338
338
  const out = {
339
339
  pages: [], collections: [], forms: [], actions: [],
340
- menus: [], blocks: [], views: [], roles: [], users: []
340
+ menus: [], blocks: [], views: [], roles: [], users: [], apis: []
341
341
  };
342
342
 
343
343
  try {
@@ -411,6 +411,17 @@ export async function getArtefactsForProject(projectSlug) {
411
411
  }
412
412
  } catch { /* skip */ }
413
413
 
414
+ try {
415
+ // Custom API endpoints store their project in data.project (required
416
+ // field — no untagged-→core fallback applies, so match directly).
417
+ const {entries} = await listEntries('api-endpoints', {limit: 0});
418
+ for (const e of entries) {
419
+ if (e.data?.project === projectSlug) {
420
+ out.apis.push({id: e.id, name: e.data.name, path: e.data.path, collection: e.data.collection});
421
+ }
422
+ }
423
+ } catch { /* skip */ }
424
+
414
425
  return out;
415
426
  }
416
427
 
@@ -436,7 +447,7 @@ export async function untagAllForProject(projectSlug) {
436
447
  }
437
448
  const counts = {
438
449
  pages: 0, collections: 0, forms: 0, actions: 0,
439
- menus: 0, blocks: 0, views: 0, roles: 0, users: 0
450
+ menus: 0, blocks: 0, views: 0, roles: 0, users: 0, apis: 0
440
451
  };
441
452
  const grouped = await getArtefactsForProject(projectSlug);
442
453
 
@@ -516,6 +527,11 @@ export async function untagAllForProject(projectSlug) {
516
527
  // plumbing to land first; pages need frontmatter rewriting which is a
517
528
  // separate concern. Counts remain 0 for those types in this task; later
518
529
  // tasks may revisit.
530
+ //
531
+ // API endpoints: intentionally skipped. An endpoint's project IS its URL
532
+ // namespace (/api/x/<project>/...) — silently untagging would move live
533
+ // endpoints to /api/x/core/... and break external callers. Delete or
534
+ // recreate them explicitly instead.
519
535
 
520
536
  return counts;
521
537
  }
@@ -73,7 +73,8 @@ const SEED_ENTRIES = [
73
73
  permissions: [
74
74
  'pages', 'media', 'blocks', 'navigation', 'layouts',
75
75
  'collections', 'views', 'actions',
76
- 'users', 'settings', 'notifications', 'plugins'
76
+ 'users', 'settings', 'notifications', 'plugins',
77
+ 'api-tokens', 'api-endpoints'
77
78
  ],
78
79
  badgeClass: 'badge-warning'
79
80
  },
@@ -198,21 +199,30 @@ export async function seed() {
198
199
  await writeData(entries);
199
200
  }
200
201
 
201
- // Self-heal: ensure the level-0 root role always carries every registry
202
- // resource. Role permissions are a persisted snapshot taken at seed time,
203
- // so new permission families added in an update (e.g. api-tokens) would
204
- // otherwise be invisible even to the super-admin on existing installs.
205
- // Other roles are intentionally NOT back-filled; admins grant new
206
- // families via the role editor.
207
- const root = entries.find(e => e.data?.level === 0);
208
- if (root) {
209
- const perms = root.data.permissions || [];
210
- const missing = RESOURCES.filter(r => !perms.some(p => p === r || p.startsWith(`${r}.`)));
202
+ // Self-heal: base roles always gain permissions newly added to their SEED
203
+ // definitions in an update. Role permissions are a persisted snapshot
204
+ // taken at seed time, so a new family (e.g. api-endpoints) would
205
+ // otherwise be invisible on existing installs until granted by hand.
206
+ // Append-only permissions an admin ADDED are preserved; note that a
207
+ // seeded permission deliberately removed from a base role is re-added on
208
+ // boot (use a custom role to run with less than the base set). The root
209
+ // role's seed list is the full registry, so it always carries every
210
+ // resource. Custom (non-seed) roles are never touched.
211
+ let healed = false;
212
+ for (const seedRole of SEED_ENTRIES) {
213
+ const onDisk = seedRole.level === 0
214
+ ? entries.find(e => e.data?.level === 0) // root may be renamed
215
+ : entries.find(e => e.data?.name === seedRole.name);
216
+ if (!onDisk) continue;
217
+ const perms = onDisk.data.permissions || [];
218
+ const seedPerms = seedRole.level === 0 ? RESOURCES : seedRole.permissions;
219
+ const missing = seedPerms.filter(r => !perms.some(p => p === r || p.startsWith(`${r}.`)));
211
220
  if (missing.length) {
212
- root.data.permissions = [...perms, ...missing];
213
- await writeData(entries);
221
+ onDisk.data.permissions = [...perms, ...missing];
222
+ healed = true;
214
223
  }
215
224
  }
225
+ if (healed) await writeData(entries);
216
226
 
217
227
  // Migrate existing user files whose role is no longer recognised
218
228
  await migrateUserRoles(entries);
@@ -279,7 +279,7 @@ export async function applyRecipe(recipeSlug, opts = {}) {
279
279
  throw err;
280
280
  }
281
281
 
282
- const created = { collection: null, form: null, actions: [], roles: [], users: [], menus: [], apiTokens: [] };
282
+ const created = { collection: null, form: null, actions: [], roles: [], users: [], menus: [], apiTokens: [], apiEndpoints: [] };
283
283
  const skipped = [];
284
284
  const warnings = [];
285
285
 
@@ -489,6 +489,29 @@ export async function applyRecipe(recipeSlug, opts = {}) {
489
489
  }
490
490
  }
491
491
 
492
+ // Custom API endpoints — declared as definition objects (path, collection,
493
+ // filter, ...). Idempotent: an existing (project, path-shape) match is
494
+ // skipped so re-applying a recipe never clobbers a tuned definition.
495
+ const endpointDecls = resolved.apiEndpoints ? [].concat(resolved.apiEndpoints) : [];
496
+ if (endpointDecls.length) {
497
+ const {createEndpoint, findEndpointByPath} = await import('./apiEndpoints.js');
498
+ const epProject = projectSlug || tokens.namespace || 'core';
499
+ for (const ep of endpointDecls) {
500
+ if (!ep.path) continue;
501
+ if (await findEndpointByPath(epProject, ep.path)) {
502
+ skipped.push(`apiEndpoint:${ep.path}`);
503
+ warnings.push(`API endpoint "${ep.path}" already exists for project "${epProject}" — left unchanged`);
504
+ continue;
505
+ }
506
+ try {
507
+ await createEndpoint({...ep, project: epProject, createdBy: opts.createdBy || null});
508
+ created.apiEndpoints.push(ep.path);
509
+ } catch (err) {
510
+ warnings.push(`API endpoint "${ep.path}" failed: ${err.message}`);
511
+ }
512
+ }
513
+ }
514
+
492
515
  const snippet = resolved.snippet || null;
493
516
 
494
517
  return { created, skipped, warnings, snippet };
@@ -41,6 +41,7 @@ const SEED_ITEMS = [
41
41
  {text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections'},
42
42
  {text: 'Views', url: '#/views', icon: 'eye', permission: 'views'},
43
43
  {text: 'Actions', url: '#/actions', icon: 'zap', permission: 'actions'},
44
+ {text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints'},
44
45
  {text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages'},
45
46
  {text: 'Components', url: '#/components', icon: 'component', permission: 'components'}
46
47
  ]