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.
@@ -5,9 +5,9 @@
5
5
  * GET /api/projects/:slug — full project
6
6
  * POST /api/projects — create
7
7
  * PUT /api/projects/:slug — edit (slug immutable)
8
- * DELETE /api/projects/:slug — delete (409 if any artefacts tagged)
8
+ * DELETE /api/projects/:slug — delete (403 for core; 409 if any artefacts tagged)
9
9
  * GET /api/projects/:slug/artefacts — grouped artefacts
10
- * POST /api/projects/:slug/untag-all — escape hatch before delete
10
+ * POST /api/projects/:slug/untag-all — escape hatch before delete (403 for core)
11
11
  *
12
12
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
13
13
  */
@@ -16,6 +16,7 @@ import {
16
16
  requirePermission as defaultRequirePermission
17
17
  } from '../../middleware/auth.js';
18
18
  import {
19
+ CORE_PROJECT_SLUG,
19
20
  createProject,
20
21
  deleteProject,
21
22
  getArtefactsForProject,
@@ -76,6 +77,9 @@ export async function projectsRoutes(fastify, opts = {}) {
76
77
  });
77
78
 
78
79
  fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
80
+ if (request.params.slug === CORE_PROJECT_SLUG) {
81
+ return reply.status(403).send({error: 'Cannot delete the core project'});
82
+ }
79
83
  const grouped = await getArtefactsForProject(request.params.slug);
80
84
  const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
81
85
  if (total > 0) {
@@ -98,6 +102,9 @@ export async function projectsRoutes(fastify, opts = {}) {
98
102
  });
99
103
 
100
104
  fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
105
+ if (request.params.slug === CORE_PROJECT_SLUG) {
106
+ return reply.status(403).send({error: 'Cannot untag the core project — artefacts would immediately resolve back to it'});
107
+ }
101
108
  if (!(await getProject(request.params.slug))) {
102
109
  return reply.status(404).send({error: 'Project not found'});
103
110
  }
package/server/server.js CHANGED
@@ -22,6 +22,7 @@ import {getLoadedPlugins, getPluginSettings, registerPlugins} from './services/p
22
22
  import {load as loadRoles, seed as seedRoles} from './services/roles.js';
23
23
  import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
24
24
  import {seedAll as seedPresetCollections} from './services/presetCollections.js';
25
+ import {seedCoreProject} from './services/projects.js';
25
26
  import {seedDefaultBlocks} from './services/blocks.js';
26
27
  import {seedDefaultComponents} from './services/components.js';
27
28
  import {refreshComponentTagAllowlist} from './services/markdown.js';
@@ -184,6 +185,7 @@ await loadRoles();
184
185
  await seedUserProfiles();
185
186
  await ensureAllProfiles();
186
187
  await seedPresetCollections();
188
+ await seedCoreProject();
187
189
  await seedDefaultBlocks();
188
190
  await seedDefaultComponents();
189
191
  await refreshComponentTagAllowlist();
@@ -1,255 +1,345 @@
1
- /**
2
- * Core Forms Service
3
- * CRUD operations for form definitions stored in content/forms/.
4
- * Submissions are stored exclusively in Collections (slug matching form slug).
5
- */
6
- import fs from 'fs/promises';
7
- import path from 'path';
8
- import {fileURLToPath} from 'url';
9
- import * as cache from './cache/index.js';
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
- const ROOT = path.resolve(__dirname, '..', '..');
13
- export const FORMS_DIR = path.join(ROOT, 'content', 'forms');
14
-
15
- /**
16
- * Ensure the forms directory exists.
17
- *
18
- * @returns {Promise<void>}
19
- */
20
- export async function ensureFormsDir() {
21
- await fs.mkdir(FORMS_DIR, { recursive: true });
22
- }
23
-
24
- /**
25
- * Read a single form definition by slug.
26
- *
27
- * @param {string} slug
28
- * @returns {Promise<object>}
29
- * @throws {Error} If the form file does not exist or cannot be parsed.
30
- */
31
- export async function readForm(slug) {
32
- const file = path.join(FORMS_DIR, `${slug}.json`);
33
- return JSON.parse(await fs.readFile(file, 'utf8'));
34
- }
35
-
36
- /**
37
- * Write a form definition to disk.
38
- *
39
- * @param {string} slug
40
- * @param {object} data
41
- * @returns {Promise<void>}
42
- */
43
- export async function writeForm(slug, data) {
44
- await ensureFormsDir();
45
- const file = path.join(FORMS_DIR, `${slug}.json`);
46
- await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
47
- await cache.invalidateTags([`form:${slug}`]);
48
- }
49
-
50
- /**
51
- * List all form definitions.
52
- *
53
- * @returns {Promise<object[]>}
54
- */
55
- export async function listForms() {
56
- let entries;
57
- try {
58
- entries = await fs.readdir(FORMS_DIR);
59
- } catch {
60
- return [];
61
- }
62
- const forms = [];
63
- for (const entry of entries.filter(e => e.endsWith('.json'))) {
64
- try {
65
- const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
66
- forms.push(data);
67
- } catch {
68
- // skip malformed
69
- }
70
- }
71
- return forms;
72
- }
73
-
74
- /**
75
- * Delete a form definition by slug.
76
- *
77
- * @param {string} slug
78
- * @returns {Promise<void>}
79
- * @throws {Error} If the form file does not exist.
80
- */
81
- export async function deleteForm(slug) {
82
- await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
83
- await cache.invalidateTags([`form:${slug}`]);
84
- }
85
-
86
- /**
87
- * Convert a string to a URL-friendly slug.
88
- *
89
- * @param {string} str
90
- * @returns {string}
91
- */
92
- export function slugify(str) {
93
- return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
94
- }
95
-
96
- /**
97
- * Get a single form definition by slug (alias for readForm).
98
- *
99
- * @param {string} slug
100
- * @returns {Promise<object>}
101
- * @throws {Error} If the form file does not exist or cannot be parsed.
102
- */
103
- export async function getForm(slug) {
104
- return readForm(slug);
105
- }
106
-
107
- /**
108
- * Create a new form definition and write it to disk.
109
- *
110
- * @param {object} data - Form definition data
111
- * @param {string} data.title - Form title (required if no slug)
112
- * @param {string} [data.slug] - Explicit slug (derived from title if omitted)
113
- * @param {string} [data.description]
114
- * @param {Array} [data.fields]
115
- * @param {object} [data.settings]
116
- * @param {object} [data.actions]
117
- * @returns {Promise<object>} The created form object
118
- * @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
119
- */
120
- export async function createForm(data) {
121
- const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
122
-
123
- if (!title?.trim() && !rawSlug?.trim()) {
124
- throw new Error('A title or slug is required to create a form.');
125
- }
126
-
127
- const slug = rawSlug ? slugify(rawSlug) : slugify(title);
128
- if (!slug) {
129
- throw new Error('Could not derive a valid slug from the provided title or slug.');
130
- }
131
-
132
- // Throw if a form with this slug already exists
133
- try {
134
- await readForm(slug);
135
- const err = new Error(`Form with slug "${slug}" already exists`);
136
- err.code = 'FORM_ALREADY_EXISTS';
137
- throw err;
138
- } catch (err) {
139
- if (err.code === 'FORM_ALREADY_EXISTS') throw err;
140
- // File not found — safe to proceed
141
- }
142
-
143
- const now = new Date().toISOString();
144
- const trimmedTitle = title ? title.trim() : slug;
145
-
146
- const form = {
147
- slug,
148
- title: trimmedTitle,
149
- description,
150
- ...(plugin ? {plugin} : {}),
151
- fields: Array.isArray(fields) ? fields : [],
152
- settings: {
153
- submitText: 'Submit',
154
- successMessage: 'Thank you for your submission.',
155
- layout: 'stacked',
156
- honeypot: true,
157
- rateLimitPerMinute: 3,
158
- ...settings
159
- },
160
- actions: {
161
- email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
162
- webhook: { enabled: false, url: '', method: 'POST' },
163
- collection: { enabled: true, slug },
164
- ...actions
165
- },
166
- createdAt: now,
167
- updatedAt: now
168
- };
169
-
170
- await writeForm(slug, form);
171
- return form;
172
- }
173
-
174
- /** System collections that should never have an auto-generated public form. */
175
- const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
176
-
177
- /**
178
- * Collection field type → form field type mapping.
179
- *
180
- * @param {string} type
181
- * @returns {string}
182
- */
183
- function toFormFieldType(type) {
184
- const map = {
185
- string: 'string', text: 'string', email: 'email', tel: 'tel',
186
- number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
187
- checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
188
- date: 'date', time: 'time', url: 'url', hidden: 'hidden'
189
- };
190
- return map[type] || 'string';
191
- }
192
-
193
- /**
194
- * Build a form definition object from a collection schema.
195
- *
196
- * @param {object} schema - Collection schema
197
- * @returns {object} Form definition
198
- */
199
- function buildFormFromCollection(schema) {
200
- const now = new Date().toISOString();
201
- const fields = (schema.fields || []).map(f => {
202
- const field = {
203
- name: f.name,
204
- type: toFormFieldType(f.type),
205
- label: f.label || f.name,
206
- required: !!f.required,
207
- placeholder: f.placeholder || '',
208
- helper: f.helper || '',
209
- tooltip: f.tooltip || ''
210
- };
211
- if (f.options) field.options = f.options;
212
- if (f.validation) field.validation = f.validation;
213
- return field;
214
- });
215
-
216
- return {
217
- slug: schema.slug,
218
- title: schema.title,
219
- description: schema.description || '',
220
- fields,
221
- settings: {
222
- submitText: 'Submit',
223
- successMessage: 'Thank you for your submission.',
224
- layout: 'stacked',
225
- honeypot: true,
226
- rateLimitPerMinute: 5
227
- },
228
- actions: {
229
- email: {enabled: false, recipients: '', subjectPrefix: `[${schema.slug}]`},
230
- webhook: {enabled: false, url: '', method: 'POST'},
231
- collection: {enabled: true, slug: schema.slug}
232
- },
233
- createdAt: now,
234
- updatedAt: now
235
- };
236
- }
237
-
238
- /**
239
- * Ensure a form exists for the given collection schema.
240
- * Creates the form only if absent — never overwrites an existing one.
241
- * Skips system collections (roles, user-profiles).
242
- *
243
- * @param {object} schema - Collection schema
244
- * @returns {Promise<void>}
245
- */
246
- export async function ensureFormForCollection(schema) {
247
- if (NO_FORM_SLUGS.has(schema.slug)) return;
248
- await ensureFormsDir();
249
- const filePath = path.join(FORMS_DIR, `${schema.slug}.json`);
250
- try {
251
- await fs.access(filePath);
252
- } catch {
253
- await writeForm(schema.slug, buildFormFromCollection(schema));
254
- }
255
- }
1
+ /**
2
+ * Core Forms Service
3
+ * CRUD operations for form definitions stored in content/forms/.
4
+ * Submissions are stored exclusively in Collections (slug matching form slug).
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {fileURLToPath} from 'url';
9
+ import * as cache from './cache/index.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const ROOT = path.resolve(__dirname, '..', '..');
13
+ export const FORMS_DIR = path.join(ROOT, 'content', 'forms');
14
+
15
+ /**
16
+ * Ensure the forms directory exists.
17
+ *
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function ensureFormsDir() {
21
+ await fs.mkdir(FORMS_DIR, { recursive: true });
22
+ }
23
+
24
+ /**
25
+ * Read a single form definition by slug.
26
+ *
27
+ * @param {string} slug
28
+ * @returns {Promise<object>}
29
+ * @throws {Error} If the form file does not exist or cannot be parsed.
30
+ */
31
+ export async function readForm(slug) {
32
+ const file = path.join(FORMS_DIR, `${slug}.json`);
33
+ return JSON.parse(await fs.readFile(file, 'utf8'));
34
+ }
35
+
36
+ /**
37
+ * Write a form definition to disk.
38
+ *
39
+ * @param {string} slug
40
+ * @param {object} data
41
+ * @returns {Promise<void>}
42
+ */
43
+ export async function writeForm(slug, data) {
44
+ await ensureFormsDir();
45
+ const file = path.join(FORMS_DIR, `${slug}.json`);
46
+ await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
47
+ await cache.invalidateTags([`form:${slug}`]);
48
+ }
49
+
50
+ /**
51
+ * List all form definitions.
52
+ *
53
+ * @returns {Promise<object[]>}
54
+ */
55
+ export async function listForms() {
56
+ let entries;
57
+ try {
58
+ entries = await fs.readdir(FORMS_DIR);
59
+ } catch {
60
+ return [];
61
+ }
62
+ const forms = [];
63
+ for (const entry of entries.filter(e => e.endsWith('.json'))) {
64
+ try {
65
+ const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
66
+ forms.push(data);
67
+ } catch {
68
+ // skip malformed
69
+ }
70
+ }
71
+ return forms;
72
+ }
73
+
74
+ /**
75
+ * Delete a form definition by slug.
76
+ *
77
+ * @param {string} slug
78
+ * @returns {Promise<void>}
79
+ * @throws {Error} If the form file does not exist.
80
+ */
81
+ export async function deleteForm(slug) {
82
+ await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
83
+ await cache.invalidateTags([`form:${slug}`]);
84
+ }
85
+
86
+ /**
87
+ * Convert a string to a URL-friendly slug.
88
+ *
89
+ * @param {string} str
90
+ * @returns {string}
91
+ */
92
+ export function slugify(str) {
93
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
94
+ }
95
+
96
+ /**
97
+ * Get a single form definition by slug (alias for readForm).
98
+ *
99
+ * @param {string} slug
100
+ * @returns {Promise<object>}
101
+ * @throws {Error} If the form file does not exist or cannot be parsed.
102
+ */
103
+ export async function getForm(slug) {
104
+ return readForm(slug);
105
+ }
106
+
107
+ /**
108
+ * Create a new form definition and write it to disk.
109
+ *
110
+ * @param {object} data - Form definition data
111
+ * @param {string} data.title - Form title (required if no slug)
112
+ * @param {string} [data.slug] - Explicit slug (derived from title if omitted)
113
+ * @param {string} [data.description]
114
+ * @param {Array} [data.fields]
115
+ * @param {object} [data.settings]
116
+ * @param {object} [data.actions]
117
+ * @returns {Promise<object>} The created form object
118
+ * @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
119
+ */
120
+ export async function createForm(data) {
121
+ const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
122
+
123
+ if (!title?.trim() && !rawSlug?.trim()) {
124
+ throw new Error('A title or slug is required to create a form.');
125
+ }
126
+
127
+ const slug = rawSlug ? slugify(rawSlug) : slugify(title);
128
+ if (!slug) {
129
+ throw new Error('Could not derive a valid slug from the provided title or slug.');
130
+ }
131
+
132
+ // Throw if a form with this slug already exists
133
+ try {
134
+ await readForm(slug);
135
+ const err = new Error(`Form with slug "${slug}" already exists`);
136
+ err.code = 'FORM_ALREADY_EXISTS';
137
+ throw err;
138
+ } catch (err) {
139
+ if (err.code === 'FORM_ALREADY_EXISTS') throw err;
140
+ // File not found — safe to proceed
141
+ }
142
+
143
+ const now = new Date().toISOString();
144
+ const trimmedTitle = title ? title.trim() : slug;
145
+
146
+ const form = {
147
+ slug,
148
+ title: trimmedTitle,
149
+ description,
150
+ ...(plugin ? {plugin} : {}),
151
+ fields: Array.isArray(fields) ? fields : [],
152
+ settings: {
153
+ submitText: 'Submit',
154
+ successMessage: 'Thank you for your submission.',
155
+ layout: 'stacked',
156
+ honeypot: true,
157
+ rateLimitPerMinute: 3,
158
+ ...settings
159
+ },
160
+ actions: {
161
+ email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
162
+ webhook: { enabled: false, url: '', method: 'POST' },
163
+ collection: { enabled: true, slug },
164
+ ...actions
165
+ },
166
+ createdAt: now,
167
+ updatedAt: now
168
+ };
169
+
170
+ await writeForm(slug, form);
171
+ return form;
172
+ }
173
+
174
+ /** System collections that should never have an auto-generated public form. */
175
+ const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
176
+
177
+ /**
178
+ * Collection field type → form field type mapping.
179
+ *
180
+ * @param {string} type
181
+ * @returns {string}
182
+ */
183
+ function toFormFieldType(type) {
184
+ const map = {
185
+ string: 'string', text: 'string', email: 'email', tel: 'tel',
186
+ number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
187
+ checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
188
+ date: 'date', time: 'time', url: 'url', hidden: 'hidden'
189
+ };
190
+ return map[type] || 'string';
191
+ }
192
+
193
+ /**
194
+ * Build a form definition object from a collection schema.
195
+ *
196
+ * @param {object} schema - Collection schema
197
+ * @returns {object} Form definition
198
+ */
199
+ function buildFormFromCollection(schema) {
200
+ const now = new Date().toISOString();
201
+ const fields = (schema.fields || []).map(f => {
202
+ const field = {
203
+ name: f.name,
204
+ type: toFormFieldType(f.type),
205
+ label: f.label || f.name,
206
+ required: !!f.required,
207
+ placeholder: f.placeholder || '',
208
+ helper: f.helper || '',
209
+ tooltip: f.tooltip || ''
210
+ };
211
+ if (f.options) field.options = f.options;
212
+ if (f.validation) field.validation = f.validation;
213
+ return field;
214
+ });
215
+
216
+ return {
217
+ slug: schema.slug,
218
+ title: schema.title,
219
+ description: schema.description || '',
220
+ fields,
221
+ settings: {
222
+ submitText: 'Submit',
223
+ successMessage: 'Thank you for your submission.',
224
+ layout: 'stacked',
225
+ honeypot: true,
226
+ rateLimitPerMinute: 5
227
+ },
228
+ actions: {
229
+ email: {enabled: false, recipients: '', subjectPrefix: `[${schema.slug}]`},
230
+ webhook: {enabled: false, url: '', method: 'POST'},
231
+ collection: {enabled: true, slug: schema.slug}
232
+ },
233
+ createdAt: now,
234
+ updatedAt: now
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Ensure a form exists for the given collection schema.
240
+ * Creates the form only if absent — never overwrites an existing one.
241
+ * Skips system collections (roles, user-profiles).
242
+ *
243
+ * @param {object} schema - Collection schema
244
+ * @returns {Promise<void>}
245
+ */
246
+ export async function ensureFormForCollection(schema) {
247
+ if (NO_FORM_SLUGS.has(schema.slug)) return;
248
+ await ensureFormsDir();
249
+ const filePath = path.join(FORMS_DIR, `${schema.slug}.json`);
250
+ try {
251
+ await fs.access(filePath);
252
+ } catch {
253
+ await writeForm(schema.slug, buildFormFromCollection(schema));
254
+ }
255
+ }
256
+
257
+ /** Form field types that are presentational only and store no value. */
258
+ const NON_DATA_FIELD_TYPES = new Set(['page-break', 'spacer', 'heading', 'paragraph', 'html']);
259
+
260
+ /**
261
+ * Form field type → collection field type. Mostly an identity mapping; a form's
262
+ * field types are a superset of (and align with) collection field types.
263
+ *
264
+ * @param {string} type
265
+ * @returns {string}
266
+ */
267
+ function toCollectionFieldType(type) {
268
+ const map = {
269
+ string: 'string', email: 'email', tel: 'tel', number: 'number',
270
+ textarea: 'textarea', select: 'select', radio: 'radio',
271
+ checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
272
+ date: 'date', time: 'time', url: 'url', hidden: 'hidden'
273
+ };
274
+ return map[type] || 'string';
275
+ }
276
+
277
+ /**
278
+ * Ensure the collection that backs a form's submissions exists.
279
+ *
280
+ * This is the inverse of {@link ensureFormForCollection}. A form whose
281
+ * `collection` action points at a slug that was never created is permanently
282
+ * broken: listing submissions 404s and — worse — every public submission is
283
+ * rejected and lost. Provisioning the collection from the form's own fields
284
+ * makes such forms self-heal instead of silently dropping data.
285
+ *
286
+ * Creates the collection only if absent — never overwrites an existing one.
287
+ * No-op when the form disables collection storage.
288
+ *
289
+ * @param {object} form - Form definition
290
+ * @returns {Promise<object|null>} The existing or newly created collection schema, or null when not applicable.
291
+ */
292
+ export async function ensureCollectionForForm(form) {
293
+ if (!form || typeof form !== 'object') return null;
294
+
295
+ const action = form.actions?.collection;
296
+ // Respect an explicitly disabled collection store — nothing to provision.
297
+ if (action && action.enabled === false) return null;
298
+
299
+ const targetSlug = (action && action.slug) ? action.slug : form.slug;
300
+ if (!targetSlug) return null;
301
+
302
+ // Dynamic import avoids a forms ↔ collections module cycle at load time.
303
+ const { getCollection, createCollection } = await import('./collections.js');
304
+
305
+ const existing = await getCollection(targetSlug);
306
+ if (existing) return existing;
307
+
308
+ const fields = (form.fields || [])
309
+ .filter(f => f && f.name && !NON_DATA_FIELD_TYPES.has(f.type))
310
+ .map(f => {
311
+ const field = {
312
+ name: f.name,
313
+ type: toCollectionFieldType(f.type),
314
+ label: f.label || f.name,
315
+ required: !!f.required
316
+ };
317
+ if (f.options) field.options = f.options;
318
+ if (f.validation) field.validation = f.validation;
319
+ return field;
320
+ });
321
+
322
+ try {
323
+ return await createCollection({
324
+ title: form.title || targetSlug,
325
+ slug: targetSlug,
326
+ description: form.description || `Submissions for the "${form.title || targetSlug}" form.`,
327
+ fields,
328
+ // Submissions can contain PII — lock the backing collection to admin
329
+ // access so it is never exposed through the public collections API.
330
+ // (Mirrors the access config the POST /forms create path applies.)
331
+ api: {
332
+ create: { enabled: false, access: 'admin' },
333
+ read: { enabled: true, access: 'admin' },
334
+ update: { enabled: false, access: 'admin' },
335
+ delete: { enabled: false, access: 'admin' }
336
+ },
337
+ meta: { generatedFromForm: form.slug }
338
+ });
339
+ } catch (err) {
340
+ // Lost a race with a concurrent provision (EEXIST) — re-read and use it.
341
+ const now = await getCollection(targetSlug);
342
+ if (now) return now;
343
+ throw err;
344
+ }
345
+ }
@@ -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'},