domma-cms 0.22.5 → 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.
- package/CLAUDE.md +7 -5
- package/admin/js/lib/sidebar-grouping.js +1 -0
- package/admin/js/lib/sidebar-grouping.test.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/project-settings.html +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +3 -3
- package/config/menus/admin-sidebar.json +0 -6
- package/package.json +3 -2
- package/plugins/analytics/admin/templates/analytics.html +1 -1
- package/server/routes/api/forms.js +765 -746
- package/server/routes/api/projects.js +9 -2
- package/server/server.js +2 -0
- package/server/services/forms.js +345 -255
- package/server/services/plugins.js +5 -2
- package/server/services/presetCollections.js +2 -1
- package/server/services/projects.js +115 -24
- package/server/services/roles.js +1 -1
|
@@ -50,7 +50,8 @@ const PRESETS = [
|
|
|
50
50
|
{name: 'description', label: 'Description', type: 'textarea'},
|
|
51
51
|
{name: 'icon', label: 'Icon', type: 'text', default: 'folder'},
|
|
52
52
|
{name: 'rootUrl', label: 'Root URL', type: 'text'},
|
|
53
|
-
{name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0}
|
|
53
|
+
{name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0},
|
|
54
|
+
{name: 'protected', label: 'Protected', type: 'boolean', default: false}
|
|
54
55
|
],
|
|
55
56
|
api: {
|
|
56
57
|
create: {enabled: false, access: 'admin'},
|
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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 "/" (
|
|
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
|
|
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
|
-
* -
|
|
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:
|
|
217
|
-
* 2. Explicit `project:
|
|
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 →
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/server/services/roles.js
CHANGED
|
@@ -73,7 +73,7 @@ const SEED_ENTRIES = [
|
|
|
73
73
|
permissions: [
|
|
74
74
|
'pages', 'media', 'blocks', 'navigation', 'layouts',
|
|
75
75
|
'collections', 'views', 'actions',
|
|
76
|
-
'users', 'settings', 'notifications'
|
|
76
|
+
'users', 'settings', 'notifications', 'plugins'
|
|
77
77
|
],
|
|
78
78
|
badgeClass: 'badge-warning'
|
|
79
79
|
},
|