domma-cms 0.22.6 → 0.23.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.
@@ -6,7 +6,13 @@
6
6
  * a metadata tag (`meta.project: '<slug>'`) on each artefact — no
7
7
  * filesystem isolation. Projects also act as a permission boundary:
8
8
  * users with `projects: []` set only see their projects' artefacts plus
9
- * untagged site-wide artefacts.
9
+ * the core project's artefacts.
10
+ *
11
+ * The built-in **core** project owns every artefact not claimed by another
12
+ * project — by resolution fallback, not by tagging. It is seeded at boot
13
+ * (`seedCoreProject()`), cannot be deleted, and is never an access
14
+ * boundary: core-resolved artefacts are visible to all authenticated
15
+ * users, regardless of their `projects: []` scope.
10
16
  *
11
17
  * Storage: a preset collection at `content/collections/projects/`.
12
18
  * Access: dedicated `/api/projects/*` routes wrap this service.
@@ -18,6 +24,20 @@ import {getRoleLevel} from './roles.js';
18
24
  /** Slug of the preset collection that stores project records. */
19
25
  export const PROJECTS_COLLECTION_SLUG = 'projects';
20
26
 
27
+ /** Slug of the built-in, protected core project. */
28
+ export const CORE_PROJECT_SLUG = 'core';
29
+
30
+ /** Seed record for the core project (created at boot if absent). */
31
+ const CORE_PROJECT_SEED = {
32
+ slug: CORE_PROJECT_SLUG,
33
+ name: 'Core',
34
+ description: 'The core site — everything not assigned to another project.',
35
+ icon: 'home',
36
+ rootUrl: '/',
37
+ sortOrder: -1,
38
+ protected: true
39
+ };
40
+
21
41
  const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
22
42
 
23
43
  /**
@@ -92,17 +112,53 @@ export async function createProject(input) {
92
112
  return data;
93
113
  }
94
114
 
115
+ /**
116
+ * Seed the built-in core project at boot. Create-if-absent by slug — NOT by
117
+ * data.json existence: preset seeding already creates an empty data file, and
118
+ * retrofitted sites have project entries that must be preserved. If a project
119
+ * with the core slug already exists it is adopted: `protected: true` is set
120
+ * and every other field is left untouched (resolution does not depend on the
121
+ * core record's rootUrl).
122
+ *
123
+ * @returns {Promise<object>} The core project record.
124
+ */
125
+ export async function seedCoreProject() {
126
+ const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
127
+ const existing = entries.find(e => e.data?.slug === CORE_PROJECT_SLUG);
128
+ if (existing) {
129
+ if (existing.data.protected === true) return normalise(existing);
130
+ const adopted = {...existing.data, protected: true, updatedAt: new Date().toISOString()};
131
+ await updateEntry(PROJECTS_COLLECTION_SLUG, existing.id, adopted);
132
+ return adopted;
133
+ }
134
+ const now = new Date().toISOString();
135
+ const data = {...CORE_PROJECT_SEED, createdAt: now, updatedAt: now};
136
+ await createEntry(PROJECTS_COLLECTION_SLUG, data);
137
+ return data;
138
+ }
139
+
95
140
  /**
96
141
  * Update a project. Slug is immutable. Preserves createdAt; bumps updatedAt.
142
+ * The core project's `rootUrl` and `protected` flag are locked; its name,
143
+ * description, icon and sortOrder remain editable.
97
144
  *
98
145
  * @param {string} slug
99
146
  * @param {object} input
100
147
  * @returns {Promise<object>} The updated project record.
101
- * @throws {Error} If the project is missing or validation fails.
148
+ * @throws {Error} If the project is missing, validation fails, or a locked
149
+ * core field is changed.
102
150
  */
103
151
  export async function updateProject(slug, input) {
104
152
  const existing = await getProject(slug);
105
153
  if (!existing) throw new Error(`Project "${slug}" not found`);
154
+ if (slug === CORE_PROJECT_SLUG) {
155
+ if (input.rootUrl != null && input.rootUrl !== existing.rootUrl) {
156
+ throw new Error("Cannot change the core project's rootUrl");
157
+ }
158
+ if (input.protected != null && input.protected !== existing.protected) {
159
+ throw new Error("Cannot change the core project's protected flag");
160
+ }
161
+ }
106
162
  const merged = {
107
163
  ...existing,
108
164
  ...(input.name != null && {name: input.name}),
@@ -126,12 +182,15 @@ export async function updateProject(slug, input) {
126
182
  /**
127
183
  * Delete a project. Returns true if removed, false if not found.
128
184
  * Does NOT check for tagged artefacts — caller (API route) must do that.
185
+ * The core project can never be deleted.
129
186
  *
130
187
  * @param {string} slug
131
188
  * @returns {Promise<boolean>}
132
- * @throws {Error} If the projects collection schema is missing.
189
+ * @throws {Error} If the slug is the core project or the projects collection
190
+ * schema is missing.
133
191
  */
134
192
  export async function deleteProject(slug) {
193
+ if (slug === CORE_PROJECT_SLUG) throw new Error('Cannot delete the core project');
135
194
  if (!validateSlug(slug)) return false;
136
195
  const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
137
196
  const entry = entries.find(e => e.data?.slug === slug);
@@ -158,8 +217,8 @@ export function validateProject(project) {
158
217
  if (project.rootUrl != null && project.rootUrl !== '') {
159
218
  if (typeof project.rootUrl !== 'string' || !project.rootUrl.startsWith('/')) {
160
219
  errors.push('rootUrl must start with /');
161
- } else if (project.rootUrl === '/') {
162
- errors.push('rootUrl cannot be exactly "/" (would auto-claim every page)');
220
+ } else if (project.rootUrl === '/' && project.slug !== CORE_PROJECT_SLUG) {
221
+ errors.push('rootUrl cannot be exactly "/" (reserved for the core project)');
163
222
  }
164
223
  }
165
224
  if (project.icon != null && (typeof project.icon !== 'string' || !project.icon.trim())) {
@@ -175,7 +234,8 @@ function normalise(entry) {
175
234
  /**
176
235
  * List projects the given user is allowed to see. Super-admin sees all;
177
236
  * users with empty `projects: []` see all; scoped users see their listed
178
- * projects only.
237
+ * projects plus the core project (whose artefacts they can see anyway —
238
+ * see canSeeArtefact).
179
239
  *
180
240
  * @param {object|null} user
181
241
  * @returns {Promise<Array<object>>}
@@ -185,7 +245,7 @@ export async function listProjectsForUser(user) {
185
245
  if (!user) return [];
186
246
  if (getRoleLevel(user.role) === 0) return all;
187
247
  if (!Array.isArray(user.projects) || user.projects.length === 0) return all;
188
- return all.filter(p => user.projects.includes(p.slug));
248
+ return all.filter(p => p.slug === CORE_PROJECT_SLUG || user.projects.includes(p.slug));
189
249
  }
190
250
 
191
251
  /**
@@ -194,7 +254,8 @@ export async function listProjectsForUser(user) {
194
254
  *
195
255
  * Rules:
196
256
  * - Super-admin (role level 0) bypasses everything.
197
- * - Untagged artefacts (no `meta.project`) are visible to all.
257
+ * - Core artefacts (no `meta.project`, or tagged `core`) are visible to
258
+ * all — the core project is organisational, never an access boundary.
198
259
  * - Users with no scope set (`projects: []` or missing) have no restriction.
199
260
  * - Otherwise: artefact's project must be in user's scope.
200
261
  *
@@ -206,30 +267,49 @@ export function canSeeArtefact(user, artefact) {
206
267
  if (!user) return false;
207
268
  if (getRoleLevel(user.role) === 0) return true;
208
269
  const project = artefact?.meta?.project;
209
- if (!project) return true;
270
+ if (!project || project === CORE_PROJECT_SLUG) return true;
210
271
  if (!Array.isArray(user.projects) || user.projects.length === 0) return true;
211
272
  return user.projects.includes(project);
212
273
  }
213
274
 
275
+ /**
276
+ * Resolve the project a non-page artefact belongs to. Artefacts without a
277
+ * `meta.project` tag belong to the core project by exclusion. (Pages resolve
278
+ * via getProjectForPage instead — they carry a frontmatter `project` field
279
+ * and inherit by rootUrl prefix.)
280
+ *
281
+ * @param {object} artefact - Anything with optional `meta.project`.
282
+ * @returns {string} A project slug; never null.
283
+ */
284
+ export function resolveArtefactProject(artefact) {
285
+ return artefact?.meta?.project || CORE_PROJECT_SLUG;
286
+ }
287
+
214
288
  /**
215
289
  * Resolve the project a page belongs to using the inheritance rules:
216
- * 1. Explicit `project: null` in frontmatter → untagged
217
- * 2. Explicit `project: '<slug>'` → use it
290
+ * 1. Explicit `project: '<slug>'` use it
291
+ * 2. Explicit `project: null` → core (legacy opt-out, deprecated — core
292
+ * owns the floor, so "untagged" no longer exists as a state)
218
293
  * 3. Missing field → longest matching `rootUrl` prefix wins
219
- * 4. No match → untagged
294
+ * 4. No match → core
295
+ *
296
+ * The core project does not participate in prefix matching — it is the
297
+ * fallback when nothing else claims the page. If the core record has not
298
+ * been seeded yet, falls back to null (pre-seed behaviour).
220
299
  *
221
300
  * @param {string} urlPath
222
301
  * @param {string|null|undefined} explicitProject
223
302
  * @returns {Promise<string|null>}
224
303
  */
225
304
  export async function getProjectForPage(urlPath, explicitProject) {
226
- if (explicitProject === null) return null;
227
305
  if (typeof explicitProject === 'string' && explicitProject) return explicitProject;
228
306
  const projects = await listProjects();
307
+ const coreFallback = projects.some(p => p.slug === CORE_PROJECT_SLUG) ? CORE_PROJECT_SLUG : null;
308
+ if (explicitProject === null) return coreFallback;
229
309
  let best = null;
230
310
  let bestLen = -1;
231
311
  for (const p of projects) {
232
- if (!p.rootUrl) continue;
312
+ if (!p.rootUrl || p.slug === CORE_PROJECT_SLUG) continue;
233
313
  if (urlPath === p.rootUrl || urlPath.startsWith(p.rootUrl + '/')) {
234
314
  if (p.rootUrl.length > bestLen) {
235
315
  best = p.slug;
@@ -239,14 +319,18 @@ export async function getProjectForPage(urlPath, explicitProject) {
239
319
  }
240
320
  }
241
321
  }
242
- return best;
322
+ return best ?? coreFallback;
243
323
  }
244
324
 
245
325
  /**
246
- * Enumerate every artefact tagged with the given project. Returns a flat
326
+ * Enumerate every artefact belonging to the given project. Returns a flat
247
327
  * grouping by type — each value is an array of artefact summary objects.
248
328
  * Used by the project detail page (counts + per-type tabs).
249
329
  *
330
+ * Membership is by resolution, not tag equality: artefacts without a
331
+ * `meta.project` tag belong to the core project, so
332
+ * `getArtefactsForProject('core')` enumerates everything unclaimed.
333
+ *
250
334
  * @param {string} projectSlug
251
335
  * @returns {Promise<{pages:object[], collections:object[], forms:object[], actions:object[], menus:object[], blocks:object[], views:object[], roles:object[], users:object[]}>}
252
336
  */
@@ -260,7 +344,7 @@ export async function getArtefactsForProject(projectSlug) {
260
344
  const {listMenus, getMenu} = await import('./menus.js');
261
345
  for (const m of await listMenus()) {
262
346
  const full = await getMenu(m.slug);
263
- if (full?.meta?.project === projectSlug) out.menus.push(m);
347
+ if (resolveArtefactProject(full) === projectSlug) out.menus.push(m);
264
348
  }
265
349
  } catch { /* service unavailable */ }
266
350
 
@@ -268,7 +352,7 @@ export async function getArtefactsForProject(projectSlug) {
268
352
  const {listForms, readForm} = await import('./forms.js');
269
353
  for (const f of await listForms()) {
270
354
  const full = await readForm(f.slug);
271
- if (full?.meta?.project === projectSlug) out.forms.push(f);
355
+ if (resolveArtefactProject(full) === projectSlug) out.forms.push(f);
272
356
  }
273
357
  } catch { /* skip */ }
274
358
 
@@ -276,14 +360,14 @@ export async function getArtefactsForProject(projectSlug) {
276
360
  const {listCollections, getCollection} = await import('./collections.js');
277
361
  for (const c of await listCollections()) {
278
362
  const full = await getCollection(c.slug);
279
- if (full?.meta?.project === projectSlug) out.collections.push(c);
363
+ if (resolveArtefactProject(full) === projectSlug) out.collections.push(c);
280
364
  }
281
365
  } catch { /* skip */ }
282
366
 
283
367
  try {
284
368
  const {listActions} = await import('./actions.js');
285
369
  for (const a of await listActions()) {
286
- if (a?.meta?.project === projectSlug) out.actions.push(a);
370
+ if (resolveArtefactProject(a) === projectSlug) out.actions.push(a);
287
371
  }
288
372
  } catch { /* skip */ }
289
373
 
@@ -292,28 +376,28 @@ export async function getArtefactsForProject(projectSlug) {
292
376
  // per-block read is needed. (Blocks are keyed by `name`, not `slug`.)
293
377
  const {listBlocks} = await import('./blocks.js');
294
378
  for (const b of await listBlocks()) {
295
- if (b?.meta?.project === projectSlug) out.blocks.push(b);
379
+ if (resolveArtefactProject(b) === projectSlug) out.blocks.push(b);
296
380
  }
297
381
  } catch { /* skip */ }
298
382
 
299
383
  try {
300
384
  const {listViews} = await import('./views.js');
301
385
  for (const v of await listViews()) {
302
- if (v?.meta?.project === projectSlug) out.views.push(v);
386
+ if (resolveArtefactProject(v) === projectSlug) out.views.push(v);
303
387
  }
304
388
  } catch { /* skip */ }
305
389
 
306
390
  try {
307
391
  const {getRoleMap} = await import('./roles.js');
308
392
  for (const role of getRoleMap().values()) {
309
- if (role?.meta?.project === projectSlug) out.roles.push(role);
393
+ if (resolveArtefactProject(role) === projectSlug) out.roles.push(role);
310
394
  }
311
395
  } catch { /* skip */ }
312
396
 
313
397
  try {
314
398
  const {listUsers} = await import('./users.js');
315
399
  for (const u of await listUsers()) {
316
- if (u?.meta?.project === projectSlug) out.users.push(u);
400
+ if (resolveArtefactProject(u) === projectSlug) out.users.push(u);
317
401
  }
318
402
  } catch { /* skip */ }
319
403
 
@@ -339,10 +423,17 @@ export async function getArtefactsForProject(projectSlug) {
339
423
  * carry meta.project through their update path; without that plumbing, this
340
424
  * function simply returns zero counts for those types.
341
425
  *
426
+ * Refused for the core project — membership there is by resolution, so
427
+ * untagging would be a no-op that resolution immediately reverses.
428
+ *
342
429
  * @param {string} projectSlug
343
430
  * @returns {Promise<Record<string, number>>}
431
+ * @throws {Error} If projectSlug is the core project.
344
432
  */
345
433
  export async function untagAllForProject(projectSlug) {
434
+ if (projectSlug === CORE_PROJECT_SLUG) {
435
+ throw new Error('Cannot untag the core project — artefacts would immediately resolve back to it');
436
+ }
346
437
  const counts = {
347
438
  pages: 0, collections: 0, forms: 0, actions: 0,
348
439
  menus: 0, blocks: 0, views: 0, roles: 0, users: 0