domma-cms 1.6.59 → 1.7.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 (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. package/server/templates/page.html +1 -1
@@ -0,0 +1,1667 @@
1
+ # API Reference
2
+
3
+ All API endpoints are served under the `/api` prefix. Unless noted otherwise:
4
+
5
+ - **Auth required** means the request must include `Authorization: Bearer <access_token>`
6
+ - Responses use `Content-Type: application/json`
7
+ - Error responses have the shape `{ "error": "message" }`
8
+ - Rate limiting applies to sensitive auth endpoints (noted per endpoint)
9
+
10
+ ---
11
+
12
+ ## Public SEO endpoints
13
+
14
+ These are unauthenticated, served at the root rather than under `/api`, and intended for search engines.
15
+
16
+ ### GET `/sitemap.xml`
17
+
18
+ Auto-generated XML sitemap of indexable pages.
19
+
20
+ **Auth required:** No
21
+ **Content-Type:** `application/xml`
22
+ **Caching:** Cached with tag `sitemap`; invalidated automatically on page create, update, rename, or delete.
23
+
24
+ Pages enter the sitemap based on the rules in `server/services/sitemap.js` → `shouldIncludeInSitemap()` - typically `status: published`, `visibility: public`, and `seo.noindex` not set. Entries include `<loc>` and `<lastmod>` (from the page's `updatedAt`). Absolute URLs are derived from the request's host and protocol automatically - set `TRUST_PROXY` when behind a reverse proxy so `X-Forwarded-*` headers are honoured - use `loopback` for a proxy on the same machine (or the proxy's addresses/CIDRs), not `true`, which trusts every hop and lets visitors spoof their IP address. `site.baseUrl` can be set in `config/site.json` to override this if your infrastructure doesn't forward Host correctly.
25
+
26
+ ### GET `/robots.txt`
27
+
28
+ Auto-generated robots policy referencing the sitemap.
29
+
30
+ **Auth required:** No
31
+ **Content-Type:** `text/plain`
32
+
33
+ Default body:
34
+
35
+ ```
36
+ User-agent: *
37
+ Allow: /
38
+ Disallow: /admin/
39
+ Disallow: /api/
40
+
41
+ Sitemap: <request-origin>/sitemap.xml
42
+ ```
43
+
44
+ The sitemap URL uses the same origin auto-derivation as `/sitemap.xml`. Append additional directives via `site.seo.robotsExtra` in `config/site.json`.
45
+
46
+ ---
47
+
48
+ ## Authentication (`/api/auth/*`)
49
+
50
+ ### GET `/api/auth/setup-status`
51
+
52
+ Check whether the initial admin account has been created.
53
+
54
+ **Auth required:** No
55
+
56
+ **Response**
57
+
58
+ ```json
59
+ { "needsSetup": true, "siteTitle": "My Site", "resetByEmail": true, "resetExpiresIn": "1 hour" }
60
+ ```
61
+
62
+ `resetByEmail` is `false` when no mail server is set up - "Forgot your password?" cannot send anything then.
63
+
64
+ ---
65
+
66
+ ### POST `/api/auth/setup`
67
+
68
+ Create the very first administrator account. Returns `403` if any users already exist.
69
+
70
+ **Auth required:** No
71
+
72
+ **Request body**
73
+
74
+ ```json
75
+ {
76
+ "name": "Admin User",
77
+ "email": "admin@example.com",
78
+ "password": "secret123"
79
+ }
80
+ ```
81
+
82
+ **Response** `201`
83
+
84
+ ```json
85
+ {
86
+ "token": "<access_token>",
87
+ "refreshToken": "<refresh_token>",
88
+ "user": { "id": "uuid", "name": "Admin User", "email": "admin@example.com", "role": "admin" }
89
+ }
90
+ ```
91
+
92
+ ---
93
+
94
+ ### POST `/api/auth/login`
95
+
96
+ Exchange credentials for tokens. Rate limited to **5 requests per minute**.
97
+
98
+ **Auth required:** No
99
+
100
+ **Request body**
101
+
102
+ ```json
103
+ { "email": "admin@example.com", "password": "secret123" }
104
+ ```
105
+
106
+ **Response** `200`
107
+
108
+ ```json
109
+ {
110
+ "token": "<access_token>",
111
+ "refreshToken": "<refresh_token>",
112
+ "user": { "id": "uuid", "name": "Admin User", "email": "admin@example.com", "role": "admin" }
113
+ }
114
+ ```
115
+
116
+ ---
117
+
118
+ ### POST `/api/auth/refresh`
119
+
120
+ Exchange a refresh token for a new access token.
121
+
122
+ **Auth required:** No
123
+
124
+ **Request body**
125
+
126
+ ```json
127
+ { "refreshToken": "<refresh_token>" }
128
+ ```
129
+
130
+ **Response** `200`
131
+
132
+ ```json
133
+ { "token": "<new_access_token>" }
134
+ ```
135
+
136
+ ---
137
+
138
+ ### POST `/api/auth/logout`
139
+
140
+ Blacklist a refresh token. Safe to call without authentication.
141
+
142
+ **Auth required:** No
143
+
144
+ **Request body**
145
+
146
+ ```json
147
+ { "refreshToken": "<refresh_token>" }
148
+ ```
149
+
150
+ **Response** `200`
151
+
152
+ ```json
153
+ { "ok": true }
154
+ ```
155
+
156
+ ---
157
+
158
+ ### GET `/api/auth/me`
159
+
160
+ Return the authenticated user's profile.
161
+
162
+ **Auth required:** Yes
163
+
164
+ **Response** `200`
165
+
166
+ ```json
167
+ {
168
+ "id": "uuid",
169
+ "name": "Admin User",
170
+ "email": "admin@example.com",
171
+ "role": "admin",
172
+ "isActive": true,
173
+ "lastLogin": "2026-01-01T00:00:00.000Z",
174
+ "profile": {}
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ### PUT `/api/auth/me`
181
+
182
+ Update the authenticated user's own name, email, password, or profile data.
183
+
184
+ **Auth required:** Yes
185
+
186
+ **Request body** (all fields optional)
187
+
188
+ ```json
189
+ {
190
+ "name": "New Name",
191
+ "email": "new@example.com",
192
+ "password": "newpassword",
193
+ "profile": { "bio": "Hello" }
194
+ }
195
+ ```
196
+
197
+ **Response** `200` - updated user object with `profile`
198
+
199
+ ---
200
+
201
+ ### GET `/api/auth/permissions`
202
+
203
+ Return the permissions for every role the authenticated user holds, with the admin scope and home.
204
+
205
+ **Auth required:** Yes
206
+
207
+ **Response** `200`
208
+
209
+ ```json
210
+ {
211
+ "permissions": ["pages.read", "pages.create", "..."],
212
+ "adminScope": null,
213
+ "adminHome": "/plugins/site-manager/dashboard"
214
+ }
215
+ ```
216
+
217
+ `adminScope` is `null` for the whole admin, or `{home, allow}` for a confined role. `adminHome` is the
218
+ route `#/` opens for this user: a confined role's home, else the site's `adminHome` if the user may use
219
+ it and the sidebar knows it, else `/`.
220
+
221
+ ---
222
+
223
+ ### GET `/api/auth/permissions-registry`
224
+
225
+ Return the full permissions registry (all resources and their role assignments).
226
+
227
+ **Auth required:** Yes
228
+
229
+ **Response** `200`
230
+
231
+ ```json
232
+ { "groups": [...], "resources": { ... } }
233
+ ```
234
+
235
+ ---
236
+
237
+ ### POST `/api/auth/forgot-password`
238
+
239
+ Trigger a password reset email. Answers at once and identically whether or not the address has an account - the lookup and the send happen after the reply, so neither the answer nor its timing says whether an account exists. Rate limited to **3 requests per hour**.
240
+
241
+ The link in the email points at `site.baseUrl` when it is set; otherwise at the address the request came from only if someone has signed in there, otherwise at the address signed in at most recently. With none of those (or no mail server) nothing is sent and the admins get a notification. Emits `auth:resetRequested` `{email, userId, ip, ua, sent, reason}`.
242
+
243
+ **Auth required:** No
244
+
245
+ **Request body**
246
+
247
+ ```json
248
+ { "email": "user@example.com" }
249
+ ```
250
+
251
+ **Response** `200`
252
+
253
+ ```json
254
+ { "ok": true, "expiresIn": "1 hour" }
255
+ ```
256
+
257
+ ---
258
+
259
+ ### POST `/api/auth/reset-password`
260
+
261
+ Consume a reset token and set a new password. Rate limited to **5 requests per minute**.
262
+
263
+ **Auth required:** No
264
+
265
+ **Request body**
266
+
267
+ ```json
268
+ { "token": "<reset_token>", "password": "newpassword" }
269
+ ```
270
+
271
+ **Response** `200`
272
+
273
+ ```json
274
+ { "ok": true }
275
+ ```
276
+
277
+ The link is used up before the password is set, the account is signed out everywhere, and the holder is emailed that
278
+ their password changed. A link for an account made inactive since it was sent no longer works. `400` with
279
+ `"expired": true` for a link that is unknown, used, withdrawn or out of time; with `problems` when the site's password
280
+ rules refuse the password.
281
+
282
+ ---
283
+
284
+ ### POST `/api/auth/reset-password/check`
285
+
286
+ Is a reset link still good - and, with a password, what the site's password rules make of it. The reset screen calls
287
+ it on opening (so a dead link says so before anything is typed) and as the new password is typed. Rate limited to
288
+ **60 requests per minute**.
289
+
290
+ **Auth required:** No (the token is the credential)
291
+
292
+ **Request body**
293
+
294
+ ```json
295
+ { "token": "<reset_token>", "password": "optional - to check against the rules" }
296
+ ```
297
+
298
+ **Response** `200`
299
+
300
+ ```json
301
+ { "email": "user@example.com", "expiresAt": "2026-09-27T09:00:00.000Z", "minutesLeft": 58, "problems": [] }
302
+ ```
303
+
304
+ `400` `{ "error": "Invalid or expired reset link", "expired": true }` otherwise.
305
+
306
+ ---
307
+
308
+ ## Pages (`/api/pages/*`)
309
+
310
+ All pages endpoints require the relevant `pages` permission.
311
+
312
+ ### GET `/api/pages`
313
+
314
+ List all pages (summary fields only).
315
+
316
+ **Auth required:** Yes (`pages:read`)
317
+
318
+ **Response** `200` - array of page summaries
319
+
320
+ ```json
321
+ [
322
+ {
323
+ "urlPath": "/about",
324
+ "title": "About",
325
+ "slug": "about",
326
+ "status": "published",
327
+ "layout": "default",
328
+ "showInNav": true,
329
+ "sortOrder": 1,
330
+ "category": null,
331
+ "visibility": "public",
332
+ "tags": [],
333
+ "updatedAt": "2026-01-01T00:00:00.000Z",
334
+ "createdAt": "2026-01-01T00:00:00.000Z"
335
+ }
336
+ ]
337
+ ```
338
+
339
+ ---
340
+
341
+ ### GET `/api/pages/tags`
342
+
343
+ Return a sorted, deduplicated list of all tags used across pages.
344
+
345
+ **Auth required:** Yes (`pages:read`)
346
+
347
+ **Response** `200`
348
+
349
+ ```json
350
+ { "tags": ["blog", "news", "tutorial"] }
351
+ ```
352
+
353
+ ---
354
+
355
+ ### GET `/api/pages/*`
356
+
357
+ Get a single page by URL path. The wildcard maps to the URL path, e.g. `GET /api/pages/about` retrieves `/about`.
358
+
359
+ **Auth required:** Yes (`pages:read`)
360
+
361
+ **Response** `200` - full page object including `frontmatter`, `body` (raw Markdown), and `html`
362
+
363
+ ---
364
+
365
+ ### POST `/api/pages`
366
+
367
+ Create a new page.
368
+
369
+ **Auth required:** Yes (`pages:create`)
370
+
371
+ **Request body**
372
+
373
+ ```json
374
+ {
375
+ "urlPath": "/about",
376
+ "frontmatter": {
377
+ "title": "About Us",
378
+ "layout": "default",
379
+ "status": "draft"
380
+ },
381
+ "body": "## Hello\n\nWelcome to our site."
382
+ }
383
+ ```
384
+
385
+ **Response** `201` - created page object
386
+
387
+ ---
388
+
389
+ ### POST `/api/pages/preview`
390
+
391
+ Render Markdown (with shortcodes) to HTML without persisting anything.
392
+
393
+ **Auth required:** Yes (`pages:read`)
394
+
395
+ **Request body**
396
+
397
+ ```json
398
+ { "markdown": "## Hello\n\n[card title=\"Example\"]Body[/card]" }
399
+ ```
400
+
401
+ **Response** `200`
402
+
403
+ ```json
404
+ { "html": "<h2>Hello</h2>..." }
405
+ ```
406
+
407
+ ---
408
+
409
+ ### PUT `/api/pages/*`
410
+
411
+ Update a page. Optionally rename it to a new URL path.
412
+
413
+ **Auth required:** Yes (`pages:update`)
414
+
415
+ **Request body**
416
+
417
+ ```json
418
+ {
419
+ "frontmatter": { "title": "Updated Title", "status": "published" },
420
+ "body": "Updated content.",
421
+ "newUrlPath": "/about-us"
422
+ }
423
+ ```
424
+
425
+ `newUrlPath` is optional. If supplied and different from the current path, the page file is renamed and any navigation items pointing to the old URL are updated automatically.
426
+
427
+ **Response** `200` - updated page object
428
+
429
+ ---
430
+
431
+ ### DELETE `/api/pages/*`
432
+
433
+ Delete a page.
434
+
435
+ **Auth required:** Yes (`pages:delete`)
436
+
437
+ **Response** `200`
438
+
439
+ ```json
440
+ { "success": true }
441
+ ```
442
+
443
+ ---
444
+
445
+ ## List tags (`/api/list-tags/:kind`)
446
+
447
+ Admin-only tags that link pages or blocks together, so the admin lists can be narrowed to one tag. `:kind` is `pages` or `blocks`. A page stores its tags as `adminTags` in its frontmatter, and a block in `meta.adminTags`. The names are kept in `config/list-tags.json`. Nothing on the public site reads them. Every change applies only to the items the caller's projects allow.
448
+
449
+ | Method | Path | Body | Auth |
450
+ |--------|------|------|------|
451
+ | GET | `/api/list-tags/:kind` | - | `pages:read` - `{tags: [{name, count}]}` |
452
+ | POST | `/api/list-tags/:kind` | `{name}` | `pages:update` - creates a tag |
453
+ | PUT | `/api/list-tags/:kind/item` | `{id, tags}` | `pages:update` - `id` is the page's urlPath or the block's name; adds no page revision |
454
+ | POST | `/api/list-tags/:kind/rename` | `{from, to}` | `pages:update` - `{name, changed}`; renaming onto an existing tag merges the two |
455
+ | POST | `/api/list-tags/:kind/delete` | `{name}` | `pages:update` - `{changed}`; the items stay |
456
+
457
+ ---
458
+
459
+ ## Media (`/api/media/*`)
460
+
461
+ ### GET `/api/media`
462
+
463
+ List all uploaded media files.
464
+
465
+ **Auth required:** Yes (`media:read`)
466
+
467
+ **Response** `200` - array of media objects with `name`, `url`, `size`, `mime`, `updatedAt`
468
+
469
+ ---
470
+
471
+ ### POST `/api/media`
472
+
473
+ Upload one or more files. Uses `multipart/form-data`. Allowed types: images (JPEG, PNG, GIF, WebP, SVG, BMP, TIFF, ICO), documents (PDF, TXT, CSV, DOC, DOCX, XLS, XLSX), video (MP4, WebM, OGG), audio (MP3, OGG, WAV, WebM).
474
+
475
+ **Auth required:** Yes (`media:create`)
476
+
477
+ **Response** `201` - single media object, or array if multiple files uploaded
478
+
479
+ ---
480
+
481
+ ### PATCH `/api/media/:name`
482
+
483
+ Rename a media file.
484
+
485
+ **Auth required:** Yes (`media:update`)
486
+
487
+ **Request body**
488
+
489
+ ```json
490
+ { "newName": "new-filename.jpg" }
491
+ ```
492
+
493
+ **Response** `200` - updated media object
494
+
495
+ ---
496
+
497
+ ### DELETE `/api/media/:name`
498
+
499
+ Delete a media file.
500
+
501
+ **Auth required:** Yes (`media:delete`)
502
+
503
+ **Response** `200`
504
+
505
+ ```json
506
+ { "success": true }
507
+ ```
508
+
509
+ ---
510
+
511
+ ### GET `/api/media/:name/info`
512
+
513
+ Get image metadata (dimensions, format, size) for editable image formats (JPEG, PNG, WebP, TIFF).
514
+
515
+ **Auth required:** Yes (`media:read`)
516
+
517
+ **Response** `200` - image metadata object
518
+
519
+ ---
520
+
521
+ ### POST `/api/media/:name/transform`
522
+
523
+ Apply image transformations (resize, crop, rotate, watermark, convert format) using Sharp.
524
+
525
+ **Auth required:** Yes (`media:update`)
526
+
527
+ **Request body**
528
+
529
+ ```json
530
+ {
531
+ "operations": {
532
+ "resize": { "width": 800, "height": 600, "fit": "cover" },
533
+ "rotate": 90,
534
+ "format": "webp",
535
+ "quality": 85
536
+ },
537
+ "saveAs": "resized-image.webp"
538
+ }
539
+ ```
540
+
541
+ `saveAs` is optional; omit it to overwrite the original.
542
+
543
+ **Response** `200` - updated media object
544
+
545
+ ---
546
+
547
+ ## Navigation (`/api/navigation`)
548
+
549
+ ### GET `/api/navigation`
550
+
551
+ Return the full navigation config object.
552
+
553
+ **Auth required:** Yes (`navigation:read`)
554
+
555
+ **Response** `200` - navigation config (see `config/navigation.json` in [Configuration](configuration.md))
556
+
557
+ ---
558
+
559
+ ### PUT `/api/navigation`
560
+
561
+ Replace the navigation config.
562
+
563
+ **Auth required:** Yes (`navigation:update`)
564
+
565
+ **Request body** - navigation config object (same shape as the file). The server normalises `children` keys to `items` before saving.
566
+
567
+ **Response** `200`
568
+
569
+ ```json
570
+ { "success": true }
571
+ ```
572
+
573
+ ---
574
+
575
+ ## Sidebar (`/api/sidebar/*`)
576
+
577
+ ### GET `/api/sidebar/registered-items`
578
+
579
+ Return the array of `{parent, folder, group?, item}` entries currently registered via `hooks.registerSidebarItem(...)` (and plugin manifests' `admin.sidebar`) by core and enabled plugins. `folder` is the Tool's default folder key (`tools` unless it names another); `group`, when present, is the sub-folder inside it (`Core` for the built-in Tools). Read-only snapshot of in-memory state - not persisted, regenerated on each server boot.
580
+
581
+ **Auth required:** Yes (`menus:read`)
582
+
583
+ **Response** `200`
584
+
585
+ ```json
586
+ {
587
+ "items": [
588
+ {
589
+ "parent": null,
590
+ "folder": "system",
591
+ "item": {
592
+ "text": "My Plugin",
593
+ "url": "#/plugins/my-plugin",
594
+ "icon": "puzzle",
595
+ "permission": "my-plugin"
596
+ }
597
+ }
598
+ ]
599
+ }
600
+ ```
601
+
602
+ ---
603
+
604
+ ## Settings (`/api/settings`)
605
+
606
+ ### GET `/api/settings`
607
+
608
+ Return the site config. The SMTP password is stripped from the response.
609
+
610
+ **Auth required:** Yes (`settings:read`)
611
+
612
+ **Response** `200` - site config object (see `config/site.json` in [Configuration](configuration.md))
613
+
614
+ ---
615
+
616
+ ### PUT `/api/settings`
617
+
618
+ Update site settings. Only the following keys are accepted: `title`, `tagline`, `description`, `logo`, `favicon`, `theme`, `smtp`, `footer`, `analytics`, `social`, `locale`, `layoutOptions`.
619
+
620
+ **Auth required:** Yes (`settings:update`)
621
+
622
+ **Request body** - partial or full site config object
623
+
624
+ **Response** `200`
625
+
626
+ ```json
627
+ { "success": true }
628
+ ```
629
+
630
+ ---
631
+
632
+ ### POST `/api/settings/test-email`
633
+
634
+ Send a test email using the stored SMTP configuration.
635
+
636
+ **Auth required:** Yes (`settings:read`)
637
+
638
+ **Request body** (optional)
639
+
640
+ ```json
641
+ { "to": "test@example.com" }
642
+ ```
643
+
644
+ If `to` is omitted, the `fromAddress` from SMTP config is used.
645
+
646
+ **Response** `200`
647
+
648
+ ```json
649
+ { "success": true, "message": "Test email sent to test@example.com" }
650
+ ```
651
+
652
+ ---
653
+
654
+ ### GET `/api/settings/custom-css`
655
+
656
+ Return the current custom CSS content.
657
+
658
+ **Auth required:** Yes (`settings:update`)
659
+
660
+ **Response** `200`
661
+
662
+ ```json
663
+ { "css": "/* custom styles */" }
664
+ ```
665
+
666
+ ---
667
+
668
+ ### PUT `/api/settings/custom-css`
669
+
670
+ Save custom CSS to `content/custom.css`. Maximum 100 KB.
671
+
672
+ **Auth required:** Yes (`settings:update`)
673
+
674
+ **Request body**
675
+
676
+ ```json
677
+ { "css": "body { background: red; }" }
678
+ ```
679
+
680
+ **Response** `200`
681
+
682
+ ```json
683
+ { "success": true }
684
+ ```
685
+
686
+ ---
687
+
688
+ ## Users (`/api/users/*`)
689
+
690
+ Role hierarchy enforced: a user cannot create, edit, or delete users with an equal or higher role level.
691
+
692
+ ### GET `/api/users`
693
+
694
+ List all users (includes profile data).
695
+
696
+ **Auth required:** Yes (`users:read`)
697
+
698
+ **Response** `200` - array of user objects with `profile` merged in
699
+
700
+ ---
701
+
702
+ ### GET `/api/users/:id`
703
+
704
+ Get a single user. Users with the `users` read permission (Super Admin and Admin by default) can view any user; all other users can only view themselves.
705
+
706
+ **Auth required:** Yes
707
+
708
+ **Response** `200` - user object with `profile`
709
+
710
+ ---
711
+
712
+ ### POST `/api/users`
713
+
714
+ Create a new user.
715
+
716
+ **Auth required:** Yes (`users:create`)
717
+
718
+ **Request body**
719
+
720
+ ```json
721
+ {
722
+ "name": "Jane Smith",
723
+ "email": "jane@example.com",
724
+ "password": "password123",
725
+ "role": "user"
726
+ }
727
+ ```
728
+
729
+ Optional: `additionalRoles` (array), `projects` (array of project slugs - the user's access scope; empty =
730
+ unrestricted), `meta` (object, e.g. `{"project": "shop"}` for the owning project), `isActive` (boolean,
731
+ default `true`) and `profile` (object of profile field values). Send them here rather than in a follow-up
732
+ PUT, so a failure cannot leave the account unrestricted and switched on.
733
+
734
+ `role` defaults to `user`. You can only create users whose role is less senior than your own.
735
+
736
+ **Response** `201` - created user object (password stripped)
737
+
738
+ ---
739
+
740
+ ### PUT `/api/users/:id`
741
+
742
+ Update a user's details. Cannot escalate a user to a role higher than the actor's own role.
743
+
744
+ **Auth required:** Yes (`users:update`)
745
+
746
+ **Request body** (all fields optional)
747
+
748
+ ```json
749
+ {
750
+ "name": "Jane Smith",
751
+ "email": "jane@example.com",
752
+ "role": "admin",
753
+ "isActive": true,
754
+ "profile": { "bio": "Site editor" }
755
+ }
756
+ ```
757
+
758
+ **Response** `200` - updated user object with `profile`
759
+
760
+ ---
761
+
762
+ ### DELETE `/api/users/:id`
763
+
764
+ Delete a user. Cannot delete your own account.
765
+
766
+ **Auth required:** Yes (`users:delete`)
767
+
768
+ **Response** `200`
769
+
770
+ ```json
771
+ { "success": true }
772
+ ```
773
+
774
+ ---
775
+
776
+ ### POST `/api/users/:id/password-reset`
777
+
778
+ An administrator's password reset: email the user a link to choose a new password, or get one to pass on yourself
779
+ (a site with no mail server, a mailbox that is not receiving). Their current password keeps working until the link is
780
+ used; a newer link replaces an older one. Not for your own account (use My Profile), and only for someone your role is
781
+ more senior to, on an active account.
782
+
783
+ **Auth required:** Yes (`users:update`)
784
+
785
+ **Request body**
786
+
787
+ ```json
788
+ { "via": "email" }
789
+ ```
790
+
791
+ **Response** `200` - `via: "email"`: `{ "sent": true, "expiresAt": "…", "expiresIn": "1 hour" }`;
792
+ `via: "link"`: `{ "link": "https://example.com/admin/#/reset-password?token=…", "expiresAt": "…", "expiresIn": "1 hour" }`.
793
+
794
+ `409` with `code`: `NO_SMTP` (emailing with no mail server), `INACTIVE`, `NO_ORIGIN`. `403` for yourself or someone
795
+ not junior to you. The list (`GET /api/users`) shows an open link as `resetLinkExpires` - the time only, never the token.
796
+
797
+ ---
798
+
799
+ ### DELETE `/api/users/:id/password-reset`
800
+
801
+ Withdraw an unused reset link: it stops working at once. Same permissions as above.
802
+
803
+ **Response** `200` `{ "ok": true }`
804
+
805
+ ---
806
+
807
+ ## Collections (`/api/collections/*`)
808
+
809
+ **Number fields are stored as numbers.** Every entry write - admin, `/api/v1`, form submissions,
810
+ import, Actions - converts a `number` field sent as plain decimal text (`"42"`, `" 9.5 "`) to a
811
+ number, so it comes back as `42`. Text that is not a number (`""`, `"abc"`) is stored as sent, not
812
+ refused; a required number field sent as `""` is still refused as missing.
813
+
814
+ ### Schema management
815
+
816
+ | Method | Path | Auth | Description |
817
+ |---|---|---|---|
818
+ | `GET` | `/api/collections` | `collections:read` | List all collections (schemas) |
819
+ | `POST` | `/api/collections` | `collections:create` | Create a collection |
820
+ | `GET` | `/api/collections/:slug` | `collections:read` | Get a collection schema |
821
+ | `PUT` | `/api/collections/:slug` | `collections:update` | Update a collection schema |
822
+ | `DELETE` | `/api/collections/:slug` | `collections:delete` | Delete a collection and all its data |
823
+
824
+ **Create collection request body**
825
+
826
+ ```json
827
+ {
828
+ "title": "Blog Posts",
829
+ "slug": "blog-posts",
830
+ "description": "Optional description",
831
+ "fields": [
832
+ { "name": "title", "type": "text", "required": true },
833
+ { "name": "body", "type": "textarea" }
834
+ ],
835
+ "api": {
836
+ "read": { "enabled": true, "access": "public" },
837
+ "create": { "enabled": false }
838
+ }
839
+ }
840
+ ```
841
+
842
+ ### Entry CRUD
843
+
844
+ | Method | Path | Auth | Description |
845
+ |---|---|---|---|
846
+ | `GET` | `/api/collections/:slug/entries` | `collections:read` | List entries (paginated, searchable) |
847
+ | `GET` | `/api/collections/:slug/entries/:id` | `collections:read` | Get a single entry |
848
+ | `POST` | `/api/collections/:slug/entries` | `collections:create` | Create an entry |
849
+ | `PUT` | `/api/collections/:slug/entries/:id` | `collections:update` | Update an entry |
850
+ | `DELETE` | `/api/collections/:slug/entries/:id` | `collections:delete` | Delete an entry |
851
+ | `DELETE` | `/api/collections/:slug/entries` | `collections:delete` | Clear all entries |
852
+
853
+ **List entries query parameters**
854
+
855
+ | Parameter | Default | Description |
856
+ |----------------------|--------------|----------------------------------------------------------------------|
857
+ | `page` | `1` | Page number |
858
+ | `limit` | `50` | Results per page (`0` falls back to the default - ask for a larger number instead) |
859
+ | `sort` | `"createdAt"`| Field to sort by |
860
+ | `order` | `"desc"` | Sort direction (`"asc"` or `"desc"`) |
861
+ | `search` | - | Full-text substring match across all field values |
862
+ | `filter[<field>]` | - | Structured filter - defaults to equality |
863
+ | `filter[<field>_op]` | - | Structured filter with explicit operator (see below) |
864
+
865
+ **Structured filter operators**
866
+
867
+ | Suffix | Meaning | Example |
868
+ |-------------|------------------------------------|--------------------------------------------------|
869
+ | (none) / `_eq` | equal | `?filter[status]=open` |
870
+ | `_ne` | not equal | `?filter[status_ne]=closed` |
871
+ | `_gt` | greater than | `?filter[salary_gt]=40000` |
872
+ | `_gte` | greater than or equal | `?filter[salary_gte]=50000` |
873
+ | `_lt` | less than | `?filter[salary_lt]=100000` |
874
+ | `_lte` | less than or equal | `?filter[postedAt_lte]=2026-06-01` |
875
+ | `_in` | value in comma-separated list | `?filter[type_in]=full-time,contract` |
876
+ | `_nin` | value not in list | `?filter[status_nin]=archived,withdrawn` |
877
+ | `_contains` | substring match (case-insensitive) | `?filter[title_contains]=engineer` |
878
+ | `_starts` | starts-with (case-insensitive) | `?filter[title_starts]=Senior` |
879
+ | `_ends` | ends-with (case-insensitive) | `?filter[email_ends]=@example.com` |
880
+ | `_exists` | field is present and non-null | `?filter[remote_exists]=true` |
881
+
882
+ Predicates AND together. Pseudo-fields: `createdBy` (matches `meta.createdBy`), `id` (top-level entry id).
883
+ Dot-paths into nested data are supported - `?filter[address.city]=London`.
884
+
885
+ For MongoDB-backed collections the filter is translated to a native query (`$gte`, `$in`, regex with `^…$/i`, etc.)
886
+ and pushed down to the database. File-mode collections apply the same operators in memory - semantics are identical.
887
+
888
+ **Create/update entry request body**
889
+
890
+ ```json
891
+ { "data": { "title": "My Post", "body": "Content here." } }
892
+ ```
893
+
894
+ ### Export / Import
895
+
896
+ | Method | Path | Auth | Description |
897
+ |---|---|---|---|
898
+ | `GET` | `/api/collections/:slug/export` | `collections:read` | Export entries (`?format=json` or `?format=csv`) |
899
+ | `POST` | `/api/collections/:slug/import` | `collections:create` | Import entries from JSON |
900
+
901
+ **Import request body**
902
+
903
+ ```json
904
+ { "entries": [{ "data": { "title": "Post 1" } }] }
905
+ ```
906
+
907
+ ### Public access endpoints
908
+
909
+ When a collection has its `api` settings configured, entries can be accessed without admin authentication:
910
+
911
+ | Method | Path | Access |
912
+ |----------|---------------------------------------|--------------------------------------------------|
913
+ | `GET` | `/api/collections/:slug/public` | Determined by `api.read.access` |
914
+ | `GET` | `/api/collections/:slug/public/:id` | Determined by `api.read.access` |
915
+ | `POST` | `/api/collections/:slug/public` | Determined by `api.create.access` |
916
+ | `PUT` | `/api/collections/:slug/public/:id` | Determined by `api.update.access` |
917
+ | `DELETE` | `/api/collections/:slug/public/:id` | Determined by `api.delete.access` |
918
+ | `POST` | `/api/collections/render-scope` | Authenticated; renders `[collection scope="mine"]` |
919
+
920
+ Access values: `"public"` (no auth), `"token"` (requires a project-scoped API token - see
921
+ [External API & API tokens](#external-api--api-tokens-apiv1)), or a role name (requires a JWT for that role or higher).
922
+
923
+ `api.read` additionally accepts a `fields` array - when non-empty, public/external read responses
924
+ include only the listed `data` fields (admin endpoints are unaffected).
925
+
926
+ The public `GET` list endpoint accepts all the same query parameters as the admin list endpoint
927
+ (`page`, `limit`, `sort`, `order`, `search`, `filter[…]`) plus:
928
+
929
+ | Parameter | Description |
930
+ |---|---|
931
+ | `scope=mine` | Restrict to the current user's own entries (`createdBy = <user.id>`). Requires a JWT. Bypasses the `api.read` gate - users can always view their own data. |
932
+
933
+ **`POST /api/collections/render-scope`** - renders a `[collection scope="mine"]` placeholder server-side with the
934
+ current user's id injected. Called by the public site JS when it encounters `.dm-collection-hydrate` placeholders.
935
+ An interactive block (`searchable`, `sortable`, `filterable` or `paginate`) comes back as a Collection Browser shell
936
+ with `scope: "mine"` in its config (so `transitions` works); anything else as the static fragment.
937
+
938
+ Request body:
939
+ ```json
940
+ { "attrs": "<base64-encoded JSON of the original shortcode attributes>" }
941
+ ```
942
+
943
+ Response:
944
+ ```json
945
+ { "html": "<div class=\"dm-collection-cards\">...</div>" }
946
+ ```
947
+
948
+ The endpoint never trusts the client's choice of user - the JWT is verified server-side and the `createdBy` filter
949
+ is appended before rendering.
950
+
951
+ ### Pro: Connections
952
+
953
+ | Method | Path | Auth | Description |
954
+ |---|---|---|---|
955
+ | `GET` | `/api/collections/pro-status` | `collections:read` | Check if Pro MongoDB mode is active |
956
+ | `GET` | `/api/collections/connections` | Admin only | List configured connections |
957
+ | `PUT` | `/api/collections/connections` | Admin only | Save connection config |
958
+
959
+ ---
960
+
961
+ ## External API & API tokens (`/api/v1/*`)
962
+
963
+ The external API is a stable, versioned alias of the public collection endpoints - the documented
964
+ surface for third-party integrations:
965
+
966
+ | Method | Path | Equivalent |
967
+ |----------|--------------------|------------------------------------------|
968
+ | `GET` | `/api/v1/:slug` | `GET /api/collections/:slug/public` |
969
+ | `GET` | `/api/v1/:slug/:id` | `GET /api/collections/:slug/public/:id` |
970
+ | `POST` | `/api/v1/:slug` | `POST /api/collections/:slug/public` |
971
+ | `PUT` | `/api/v1/:slug/:id` | `PUT /api/collections/:slug/public/:id` |
972
+ | `DELETE` | `/api/v1/:slug/:id` | `DELETE /api/collections/:slug/public/:id` |
973
+
974
+ Both paths share handlers, access rules, and query parameters (`page`, `limit`, `sort`, `order`,
975
+ `search`, `filter[…]`, `resolveRefs`).
976
+
977
+ ### API tokens
978
+
979
+ API tokens are machine credentials for external callers - created in the admin under
980
+ **System → API Tokens** (or by a scaffolder recipe). Format: `dcms_` + 64 hex characters.
981
+ Only a SHA-256 hash is stored; the plaintext is shown **once**, at creation.
982
+
983
+ A collection verb opts into token auth with `api.<verb>.access: "token"`. Callers then send:
984
+
985
+ ```
986
+ Authorization: Bearer dcms_<64 hex>
987
+ ```
988
+
989
+ **Strictness:** a token is accepted *only* where the access mode is `"token"` - it is never a
990
+ substitute for a role-name mode, and a JWT is never accepted in token mode.
991
+
992
+ **Project binding:** every token belongs to one project and only works on collections whose
993
+ resolved project matches (the collection's `meta.project`, or `core` when untagged).
994
+
995
+ **Scopes (optional):** a token may carry `scopes: [{ "collection": "jobs", "verbs": ["read"] }]`.
996
+ Empty scopes = every collection in the token's project. A scoped token is refused (403) outside
997
+ its scope list. The verb must still be enabled with `access: "token"` on the collection itself.
998
+ A scope may instead be a Content API scope, `{ "content": "pages" | "menus" | "site" | "search" }`
999
+ (`@pages` in the token editor) - see [Content API](#content-api-apicontentv1).
1000
+
1001
+ Failure responses: `401` (missing/invalid/disabled/expired token), `403` (wrong project or scope,
1002
+ or the verb is disabled).
1003
+
1004
+ Entries created via token auth are attributed `meta.createdBy: "token:<token-id>"`, `meta.source: "api"`.
1005
+
1006
+ ### Token management (`/api/api-tokens/*`)
1007
+
1008
+ | Method | Path | Auth | Description |
1009
+ |----------|-----------------------|---------------------|-------------|
1010
+ | `GET` | `/api/api-tokens` | `api-tokens:read` | List tokens visible to the caller (project scope applies). Never includes the hash. |
1011
+ | `POST` | `/api/api-tokens` | `api-tokens:create` | Create a token. The response's `plaintext` field is the **only** time the token value appears. |
1012
+ | `PUT` | `/api/api-tokens/:id` | `api-tokens:update` | Update `name`, `enabled`, `scopes`, `expiresAt`. The project binding and hash are immutable. |
1013
+ | `DELETE` | `/api/api-tokens/:id` | `api-tokens:delete` | Revoke - external callers lose access immediately. |
1014
+
1015
+ **Create request body**
1016
+
1017
+ ```json
1018
+ {
1019
+ "name": "mobile-app",
1020
+ "project": "core",
1021
+ "scopes": [{ "collection": "jobs", "verbs": ["read", "create"] }],
1022
+ "expiresAt": "2027-01-01T00:00:00.000Z"
1023
+ }
1024
+ ```
1025
+
1026
+ **Create response (201)**
1027
+
1028
+ ```json
1029
+ {
1030
+ "token": { "id": "…", "name": "mobile-app", "project": "core", "tokenHint": "ab12", "enabled": true },
1031
+ "plaintext": "dcms_<64 hex - shown once, store it now>"
1032
+ }
1033
+ ```
1034
+
1035
+ > Existing roles are **not** back-filled with the `api-tokens` permission family - grant it via the
1036
+ > role editor. The level-0 root role self-heals at boot.
1037
+
1038
+ ---
1039
+
1040
+ ## Content API (`/api/content/v1/*`)
1041
+
1042
+ Read-only, headless access to what the site publishes - pages, menus, the site settings public pages
1043
+ already show, and search - as JSON. Where `/api/v1` exposes collection **data**, the Content API exposes
1044
+ **content**. It has its own prefix because `/api/v1/:slug` matches any collection slug, and a
1045
+ collection called `pages` would otherwise be shadowed.
1046
+
1047
+ **Off by default.** Turn it on at **System → Content API** (stored in `config/content-api.json`); until
1048
+ then every address answers `404`.
1049
+
1050
+ | Method | Path | Returns |
1051
+ |--------|------------------------------------|---------|
1052
+ | `GET` | `/api/content/v1/pages` | `{pages, total, page, limit}` - page summaries, no bodies |
1053
+ | `GET` | `/api/content/v1/pages/<path>` | One page (`/pages/` is the home page; a directory path serves its `index`) |
1054
+ | `GET` | `/api/content/v1/menus/:location` | `{location, slug, name, items}` - the menu bound to a location |
1055
+ | `GET` | `/api/content/v1/site` | Title, brand, footer, social links, SEO defaults, theme |
1056
+ | `GET` | `/api/content/v1/search?q=` | `{results: [{urlPath, title, description, tags, snippet, score}]}` |
1057
+
1058
+ ### Which pages
1059
+
1060
+ A page the API serves is exactly a page the site would serve the same caller: both call
1061
+ `resolvePublishedPage()` (`server/services/pagePublishing.js`), and a test pins them together.
1062
+
1063
+ - Only `status: published` - drafts, scheduled and status-less pages are `404`.
1064
+ - Visibility follows `checkVisibility()`: a role-gated page is `403` to a caller who could not open it.
1065
+ - A page in a switched-off project is `404`.
1066
+ - Front matter **`api: false`** keeps a page off the API (`404`, not listed, not searched); it still
1067
+ shows on the site.
1068
+
1069
+ ### Page list - query parameters
1070
+
1071
+ | Parameter | Default | Description |
1072
+ |-----------|---------|-------------|
1073
+ | `page` | `1` | Page number |
1074
+ | `limit` | `50` | Page size, capped at the site's `maxLimit` (default 200). `0` or nonsense means the default |
1075
+ | `prefix` | - | Only pages whose path starts with this, e.g. `/services` |
1076
+ | `tag` / `category` | - | Only pages with this tag / in this category |
1077
+ | `project` | - | Only pages in this project |
1078
+ | `sort` / `order` | site order | `title`, `updatedAt` or `createdAt`; `order=desc` reverses |
1079
+
1080
+ Summaries carry `urlPath`, `title`, `description`, `tags`, `category`, `layout`, `updatedAt`. A site can
1081
+ turn the list off (`listable: false`) and keep single pages, so callers must know a path.
1082
+
1083
+ ### A page
1084
+
1085
+ ```
1086
+ GET /api/content/v1/pages/about?format=html|markdown|both
1087
+ ```
1088
+
1089
+ ```json
1090
+ {
1091
+ "urlPath": "/about", "title": "About", "description": "…", "tags": ["company"], "category": "general",
1092
+ "layout": "default", "createdAt": "…", "updatedAt": "…",
1093
+ "seo": {"title": "…", "description": "…", "image": "…"},
1094
+ "html": "<section>…</section>",
1095
+ "requires": {"domma": true, "components": ["tabs", "effects"]},
1096
+ "perViewer": false
1097
+ }
1098
+ ```
1099
+
1100
+ - `html` is the page **body** (no navbar or footer), shortcodes already rendered. `markdown` is the
1101
+ source, shortcodes included. A format the site does not offer answers `400`; HTML is always offered.
1102
+ - `requires.components` names the Domma behaviour the HTML relies on - the markup `site.js` and
1103
+ `effects.js` initialise (tabs, accordion, carousel, countdown, tooltip, reveal, effects, forms…) plus
1104
+ any custom `[component]`s. Without Domma's CSS and JS those parts are inert.
1105
+ - Front matter is **allowlisted**: only the keys above appear. `seo` keeps `title`, `description`,
1106
+ `image`, `canonical`, `noindex`.
1107
+ - `perViewer: true` means the body was rendered for this caller (a row-scoped `[view]`) and must not be
1108
+ shared between users.
1109
+ - **Drafts:** in role mode only, `?preview=1` from a caller holding `pages.read` returns unpublished pages
1110
+ (`Cache-Control: no-store`). Ignored in public and token mode.
1111
+
1112
+ ### Menus, site settings, search
1113
+
1114
+ - **Menus** - `?page=/about` resolves page-bound menus as they would be on that page. Items are
1115
+ allowlisted (`type`, `text`, `url`, `icon`, `target`, `size`, `badge`, `items`) and filtered for the
1116
+ caller; structural items (`separator`, `spacer`) are kept. The `admin-sidebar` and `overlay` slots, and
1117
+ any slot starting `admin`, are never served.
1118
+ - **Site** - `title`, `brand` (`text`, `logo`, `url`), `footer` (hidden links removed), `social`, `seo`
1119
+ (`defaultTitle`, `titleSeparator`, `defaultDescription`), `theme`. Never `smtp`, `url`, `tagline` or
1120
+ admin settings.
1121
+ - **Search** - the site's own search (`q` at least the site's minimum length, else `400`; `limit` up to its
1122
+ max results). `404` when search is switched off in System → Search.
1123
+
1124
+ ### Access
1125
+
1126
+ `access` in `config/content-api.json`, one mode for the whole API:
1127
+
1128
+ | `access` | Who may call | Rendered as |
1129
+ |----------|--------------|-------------|
1130
+ | `public` | Anyone, no credentials | Anonymous visitor |
1131
+ | `token` (default) | A project API token (`Authorization: Bearer dcms_…`). A JWT is refused | Anonymous visitor |
1132
+ | a role name | A Bearer **access** JWT holding that role or a more senior one. A role the site does not have admits level 0 only. The site's `dm_session` cookie is never accepted | That user |
1133
+
1134
+ **Tokens and projects.** A token reads only the pages of its own project - single pages, the list and
1135
+ search results; a page in another project is `404`. Menus and site settings are site-wide and readable by
1136
+ any valid token. Scopes narrow a token with content scopes - `{"content": "pages"}`, written `@pages` in
1137
+ the token editor (`@pages`, `@menus`, `@site`, `@search`; read-only, no verbs). A token with no scopes
1138
+ covers everything in its project; a token whose scopes name only collections gets **no** content.
1139
+
1140
+ ### Responses and caching
1141
+
1142
+ | Case | Status | `Cache-Control` |
1143
+ |------|--------|-----------------|
1144
+ | Public mode | `200` | `public, max-age=60` + weak `ETag` (page mtime) |
1145
+ | Token mode | `200` | `private, no-cache` |
1146
+ | Role mode, or a draft | `200` | `no-store` |
1147
+ | API or resource off, page missing / unpublished / disabled project / `api: false`, slot not served | `404` | `no-store` |
1148
+ | No or invalid credential, refresh token, JWT in token mode | `401` | `no-store` |
1149
+ | Role too junior, scope does not allow it, visibility refuses | `403` | `no-store` |
1150
+ | Format not offered, search query too short | `400` | `no-store` |
1151
+
1152
+ ### Calling from another domain
1153
+
1154
+ A browser on another origin can call the API only when that origin is allowed by `cors.origin` in
1155
+ `config/server.json` (see `docs/configuration.md`). Token and role modes send an `Authorization` header,
1156
+ so the browser preflights the request; `@fastify/cors` answers it for allowed origins. Server-to-server
1157
+ callers are unaffected.
1158
+
1159
+ ```bash
1160
+ curl 'https://example.com/api/content/v1/pages?prefix=/blog&limit=10'
1161
+ curl -H 'Authorization: Bearer dcms_0123…cdef' 'https://example.com/api/content/v1/pages/about?format=both'
1162
+ ```
1163
+
1164
+ ### Admin endpoints (`/api/content-api/*`)
1165
+
1166
+ | Method | Path | Auth | Description |
1167
+ |--------|------|------|-------------|
1168
+ | `GET` | `/api/content-api/settings` | `settings:read` | The normalised settings |
1169
+ | `PUT` | `/api/content-api/settings` | `settings:update` | Save; a role the site does not have is `400` |
1170
+ | `GET` | `/api/content-api/_count` | `settings:read` | Sidebar badge: pages served to an anonymous caller |
1171
+ | `GET` | `/api/content-api/preview?path=&format=&as=anon` | `settings:read` | What an anonymous caller would get, even while the API is off; `offered: false` flags a format the site does not offer |
1172
+
1173
+ ---
1174
+
1175
+ ## Custom endpoints (`/api/x/*`)
1176
+
1177
+ Named, curated REST endpoints over collection data, built in the admin (**Data → API Builder**)
1178
+ or by scaffolder recipes. A definition binds a clean URL to a fixed collection query - definitions
1179
+ are data, never code.
1180
+
1181
+ **URL anatomy:** `/api/x/<project><path>` - e.g. `/api/x/world-cup/fixtures-day/2026-06-11` for a
1182
+ definition in the `world-cup` project with `path: /fixtures-day/:date`.
1183
+
1184
+ **Definition:**
1185
+
1186
+ ```json
1187
+ {
1188
+ "name": "Fixtures by day",
1189
+ "project": "world-cup",
1190
+ "path": "/fixtures-day/:date",
1191
+ "collection": "wc-schedule",
1192
+ "auth": "public",
1193
+ "mode": "list",
1194
+ "filter": { "date": "{{params.date}}" },
1195
+ "sort": "seq", "order": "asc", "limit": 0,
1196
+ "fields": ["seq", "date", "title", "stage"],
1197
+ "enabled": true
1198
+ }
1199
+ ```
1200
+
1201
+ - **`path`** - lowercase/hyphen segments or `:param` placeholders. Two endpoints in one project
1202
+ cannot share a path *shape* (`/a/:x` and `/a/:y` collide). Static segments beat `:param`
1203
+ matches (`/m/latest` wins over `/m/:day`).
1204
+ - **`auth`** - `"public"`, `"token"` (a project API token whose project matches the endpoint's),
1205
+ or a role name (JWT). Same strictness rules as the collections API.
1206
+ - **`mode`** - `"list"` returns the standard `{entries, total, page, limit}` payload;
1207
+ `"single"` returns the first matching entry, or 404.
1208
+ - **`filter`** - `{field_op: value}` clauses using the standard 12 filter operators. Values may
1209
+ embed `{{params.<name>}}` (from a `:name` path segment - missing at request time → 400) or
1210
+ `{{query.<name>}}` (optional - the clause is dropped when the query param is absent, so query
1211
+ placeholders act as refinements). Substituted values only ever feed the filter engine.
1212
+ - **`fields`** - read allowlist; empty = all fields.
1213
+ - An endpoint grants access to exactly its own query - independent of the underlying collection's
1214
+ `api` settings. System-managed collections cannot be exposed.
1215
+
1216
+ Public-auth responses are cached and invalidated automatically when the collection's entries or
1217
+ the endpoint definition change.
1218
+
1219
+ **Example:**
1220
+
1221
+ ```bash
1222
+ curl https://example.com/api/x/world-cup/fixtures-day/Thu%2011%20Jun
1223
+ curl -H 'Authorization: Bearer dcms_…' https://example.com/api/x/world-cup/squad/:team
1224
+ ```
1225
+
1226
+ ### Endpoint management (`/api/api-endpoints/*`)
1227
+
1228
+ | Method | Path | Auth | Description |
1229
+ |----------|--------------------------|------------------------|-------------|
1230
+ | `GET` | `/api/api-endpoints` | `api-endpoints:read` | List definitions visible to the caller (project scope applies) |
1231
+ | `GET` | `/api/api-endpoints/:id` | `api-endpoints:read` | Single definition |
1232
+ | `POST` | `/api/api-endpoints` | `api-endpoints:create` | Create (400 validation; 409 duplicate path shape) |
1233
+ | `PUT` | `/api/api-endpoints/:id` | `api-endpoints:update` | Update - the `project` binding is immutable |
1234
+ | `DELETE` | `/api/api-endpoints/:id` | `api-endpoints:delete` | Delete - external callers get 404 immediately |
1235
+
1236
+ Known v1 limits: GET-only; no `resolveRefs`; endpoints survive project deletion (delete them
1237
+ explicitly); a project with endpoints refuses deletion until they are removed.
1238
+
1239
+ ---
1240
+
1241
+ ## Views (`/api/views/*`)
1242
+
1243
+ Saved Views - reusable queries over collection data, rendered in content with `[view slug="…" /]`.
1244
+ Works on every storage adapter (native aggregation on MongoDB-backed collections, built-in pipeline
1245
+ evaluator on file-backed ones). Admin endpoints require the `views` permission.
1246
+
1247
+ | Method | Path | Description |
1248
+ |---|---|---|
1249
+ | GET | `/api/views` | List all view configs |
1250
+ | POST | `/api/views` | Create a view config |
1251
+ | GET | `/api/views/:slug` | Get a view config |
1252
+ | PUT | `/api/views/:slug` | Update a view config |
1253
+ | DELETE | `/api/views/:slug` | Delete a view config |
1254
+ | GET | `/api/views/:slug/execute?page&limit` | Execute a view (admin) → `{results, total, page, limit}` |
1255
+ | GET | `/api/views/collection/:collectionSlug` | List views targeting a collection |
1256
+ | GET | `/api/views/:slug/public?page&limit` | Execute a view publicly (role-checked per the view's `access` config) |
1257
+
1258
+ View config shape: `{id, slug, title, description, connection, pipeline: {source, stages: [{type, config}]},
1259
+ display: {mode, columns, pageSize, block}, access: {roles, public, rowLevel}, meta}`. Configs are stored as
1260
+ JSON files in `content/views/`. Allowed stage types: `$match`, `$lookup`, `$sort`, `$project`, `$unwind`,
1261
+ `$limit`, `$skip`, `$count`, `$addFields`, `$group`.
1262
+
1263
+ ## Forms (`/api/forms/*`)
1264
+
1265
+ Form definitions are JSON files in `content/forms/`; every submission is stored as an entry in the form's
1266
+ target collection (its collection action's `slug` when enabled, else a collection named after the form).
1267
+ Admin routes use the `collections` permission family - there is no separate forms permission. The list, get
1268
+ and submissions routes also apply the caller's project scope.
1269
+
1270
+ | Method | Path | Auth | Description |
1271
+ |---|---|---|---|
1272
+ | `GET` | `/api/forms` | `collections:read` | List forms with `submissionCount`, `submissionsThisWeek`, `lastSubmissionAt` |
1273
+ | `POST` | `/api/forms` | `collections:create` | Create (`title` required; `201`, `409` slug taken). Also creates a same-slug collection if none exists |
1274
+ | `GET` | `/api/forms/:slug` | `collections:read` | Full definition, including `actions` |
1275
+ | `GET` | `/api/forms/:slug/public` | None | Definition without `actions` (what the `[form]` embed renders) |
1276
+ | `PUT` | `/api/forms/:slug` | `collections:update` | Shallow-merge the body over the definition; `slug`/`createdAt` fixed |
1277
+ | `DELETE` | `/api/forms/:slug` | `collections:delete` | Delete the definition (submissions stay in the collection) |
1278
+ | `POST` | `/api/forms/submit/:slug` | None (JWT optional) | Submit - see below |
1279
+ | `GET` | `/api/forms/:slug/submissions` | `collections:read` | This form's submissions, newest first |
1280
+ | `GET` | `/api/forms/:slug/submissions/export` | `collections:read` | CSV download |
1281
+ | `GET` | `/api/forms/:slug/submissions/export/json` | `collections:read` | JSON download |
1282
+ | `DELETE` | `/api/forms/:slug/submissions` | `collections:delete` | Delete all of this form's submissions |
1283
+ | `DELETE` | `/api/forms/:slug/submissions/:id` | `collections:delete` | Delete one |
1284
+ | `POST` | `/api/forms/:slug/submissions/:id/spam` | `collections:update` | `{ "spam": true \| false }` - flag or unflag |
1285
+ | `POST` | `/api/forms/test-email` | Admin (level 0-1) | Send a test email to `to` via the site SMTP settings |
1286
+
1287
+ Create and update responses carry `warnings: [...]` when the form and its target collection disagree
1288
+ (missing collection, required collection fields the form does not collect, form fields the collection lacks).
1289
+
1290
+ ### POST `/api/forms/submit/:slug`
1291
+
1292
+ JSON (field names as keys) or `multipart/form-data` for file fields. A valid access token, when sent,
1293
+ becomes the entry's `meta.createdBy` and the `{{user.*}}` of any Action; otherwise the submission is anonymous.
1294
+
1295
+ **Response** `200`
1296
+
1297
+ ```json
1298
+ { "ok": true, "entryId": "uuid", "ended": false, "message": "Thank you for your submission.", "redirect": null }
1299
+ ```
1300
+
1301
+ Order of checks:
1302
+
1303
+ 1. Honeypot (`settings.honeypot`, default on): a filled `_hp`, or `_t` (render time in ms) less than 2 s
1304
+ ago, gets an ordinary success and nothing is stored.
1305
+ 2. Triggers are resolved server-side from the same engine as the browser: `block-submit` → `400`;
1306
+ `hide-fields` / `disable-fields` / `end-form` masking excuse fields (their values are dropped);
1307
+ `require-fields` makes fields required.
1308
+ 3. Visible fields are checked for required/validation rules → `400` `{ "error": "Required fields missing: ..." }`.
1309
+ 4. Rate limit per form and IP (`settings.rateLimitPerMinute`, default 3 a minute, in memory) → `429`.
1310
+ 5. The entry is stored with `meta.source: "form:<slug>"` (a missing collection is provisioned from the form).
1311
+ An `end-form` with `record: false` stores nothing.
1312
+ 6. Email, webhook (`POST {form, data}`), `settings.actionSlug` and trigger events (`run-action`, `notify`,
1313
+ `redirect`) run after the store; their failures are logged and notified, never returned.
1314
+
1315
+ File parts are checked against the field's `file.maxSize` (default 5 MB) and `file.accept` (MIME list,
1316
+ `image/*` wildcards), saved to the media library and stored as `{url, name, size, mime}`. There is no CAPTCHA.
1317
+ `{{entryId}}` in `settings.successRedirect` (or a trigger redirect, which wins) is replaced with the new id.
1318
+
1319
+ ---
1320
+
1321
+ ## Actions (`/api/actions/*`)
1322
+
1323
+ Actions are configured step lists run against a collection entry. They need MongoDB (Pro): without a
1324
+ connection the admin routes answer `503` (`/api/actions/_count` answers a zero badge instead). Configs are
1325
+ stored in `cms__actions`.
1326
+
1327
+ | Method | Path | Auth | Description |
1328
+ |---|---|---|---|
1329
+ | `GET` | `/api/actions` | `actions:read` | List configs (project scope applies) |
1330
+ | `POST` | `/api/actions` | `actions:create` | Create (`201`; `409` slug exists) |
1331
+ | `GET` | `/api/actions/collection/:collectionSlug` | `actions:read` | Configs targeting one collection |
1332
+ | `GET` | `/api/actions/:slug` | `actions:read` | One config |
1333
+ | `PUT` | `/api/actions/:slug` | `actions:update` | Update |
1334
+ | `DELETE` | `/api/actions/:slug` | `actions:delete` | Delete |
1335
+ | `POST` | `/api/actions/:slug/execute` | `actions:read` | Run on `{ "entryId": "..." }` |
1336
+ | `POST` | `/api/actions/:slug/check-access` | `actions:read` | `{ "entryIds": [...] }` → `{ "allowed": [...] }` after row-level rules |
1337
+ | `POST` | `/api/actions/:slug/public` | JWT + role in `access.roles` (or more senior) | Run from the public site; optional `scope: "mine"` refuses rows the user did not create (`403`) |
1338
+ | `GET` | `/api/actions/transitions?collection=&entryId=` | JWT optional | Transitions this user may run on the entry (`{ "transitions": [...] }`; none when anonymous) |
1339
+
1340
+ **Config shape**
1341
+
1342
+ ```json
1343
+ {
1344
+ "title": "Approve", "slug": "approve-application", "collection": "applications",
1345
+ "trigger": { "type": "manual", "label": "Approve", "icon": "check-circle", "confirmMessage": "Approve?" },
1346
+ "transition": { "field": "status", "from": ["pending"], "to": "approved" },
1347
+ "steps": [
1348
+ { "type": "updateField", "config": { "field": "status", "value": "approved" } },
1349
+ { "type": "email", "config": { "to": "{{entry.data.email}}", "subject": "Approved" } }
1350
+ ],
1351
+ "access": { "roles": ["admin"], "rowLevel": null }
1352
+ }
1353
+ ```
1354
+
1355
+ **Execute response** `{ "success": true, "stepsCompleted": 2, "results": [...] }`. Errors: `400` bad input,
1356
+ `403` row-level denial or `scope: "mine"` on someone else's row, `404` missing action or entry, `409` the
1357
+ entry's `transition.field` is not in `from`.
1358
+
1359
+ ---
1360
+
1361
+ ## Scaffold (`/api/scaffold/*`)
1362
+
1363
+ Builds a working system from a bundled recipe (`server/services/recipes/*.json`) in one call. All routes need
1364
+ `collections:create` - and nothing else, although a recipe can also create roles, users, menus, API tokens and
1365
+ endpoints. Recipe format: `docs/scaffolding.md`.
1366
+
1367
+ | Method | Path | Auth | Description |
1368
+ |---|---|---|---|
1369
+ | `GET` | `/api/scaffold/recipes` | `collections:create` | `{ "recipes": [{slug, name, description, icon, options}] }` |
1370
+ | `GET` | `/api/scaffold/recipes/:slug` | `collections:create` | The full recipe, unresolved (`404` unknown) |
1371
+ | `POST` | `/api/scaffold/apply` | `collections:create` | Apply: `{ "recipe": "contact-list", "options": { "collectionSlug": "leads" } }` |
1372
+
1373
+ Option values sent in `options` are slugified. Apply creates, in order: the project (from the `namespace`
1374
+ option, if the recipe has a `project` block), then pre-flights - an existing collection, form, Action or menu
1375
+ answers `409` with `conflicts: [...]` - then roles, menus, collection, form, users, Actions (skipped with a
1376
+ warning without MongoDB), API tokens and API endpoints.
1377
+
1378
+ **Response** `200`
1379
+
1380
+ ```json
1381
+ {
1382
+ "created": { "collection": "leads", "form": "contact-quick-add", "actions": ["contact-followup"],
1383
+ "roles": [], "users": [], "menus": [], "apiTokens": [], "apiEndpoints": [] },
1384
+ "skipped": [],
1385
+ "warnings": [],
1386
+ "snippet": "[form name=\"contact-quick-add\" /]\n\n..."
1387
+ }
1388
+ ```
1389
+
1390
+ Apply is not transactional: after pre-flight, anything that fails becomes a `warnings` entry and the rest
1391
+ continues. `created.apiTokens[].token` is the only time a generated token's plaintext is shown; re-applying
1392
+ never re-issues a token or overwrites an endpoint with the same path shape.
1393
+
1394
+ ---
1395
+
1396
+ ## Plugins (`/api/plugins/*`)
1397
+
1398
+ ### GET `/api/plugins`
1399
+
1400
+ List all discovered plugins with their enabled state and settings.
1401
+
1402
+ **Auth required:** Yes (`plugins:read`)
1403
+
1404
+ **Response** `200` - array of plugin objects
1405
+
1406
+ ```json
1407
+ [
1408
+ {
1409
+ "name": "analytics",
1410
+ "displayName": "Analytics",
1411
+ "version": "1.0.0",
1412
+ "description": "...",
1413
+ "author": "...",
1414
+ "date": "2026-03-01",
1415
+ "icon": "chart-bar",
1416
+ "enabled": true,
1417
+ "settings": {}
1418
+ }
1419
+ ]
1420
+ ```
1421
+
1422
+ ---
1423
+
1424
+ ### PUT `/api/plugins/:name`
1425
+
1426
+ Enable/disable a plugin or update its settings.
1427
+
1428
+ **Auth required:** Yes (`plugins:update`)
1429
+
1430
+ **Request body**
1431
+
1432
+ ```json
1433
+ { "enabled": true, "settings": { "myOption": "value" } }
1434
+ ```
1435
+
1436
+ **Response** `200`
1437
+
1438
+ ```json
1439
+ { "success": true }
1440
+ ```
1441
+
1442
+ ---
1443
+
1444
+ ### GET `/api/plugins/admin-config`
1445
+
1446
+ Return the merged admin sidebar items, routes, and view registrations for all enabled plugins. Used by the admin SPA on startup.
1447
+
1448
+ **Auth required:** Yes
1449
+
1450
+ **Response** `200`
1451
+
1452
+ ```json
1453
+ {
1454
+ "sidebar": [...],
1455
+ "routes": [...],
1456
+ "views": { ... }
1457
+ }
1458
+ ```
1459
+
1460
+ ---
1461
+
1462
+ ## Projects (`/api/projects/*`)
1463
+
1464
+ Projects group related artefacts (Pages, Collections, Forms, Actions, Menus, Blocks, Views, Roles, Users) under a named slug. See `docs/configuration.md` → Projects for the data model and `meta.project` tag.
1465
+
1466
+ All endpoints require the relevant `projects` permission. List/get results are filtered by the requesting user's `projects: []` access scope (super-admins bypass).
1467
+
1468
+ ### GET `/api/projects`
1469
+
1470
+ List projects the user can see (scoped by `user.projects`).
1471
+
1472
+ **Auth required:** Yes (`projects:read`)
1473
+
1474
+ **Response** `200` - array of project records
1475
+
1476
+ ```json
1477
+ [
1478
+ {
1479
+ "slug": "members",
1480
+ "name": "Members Area",
1481
+ "description": "Curated members-only zone.",
1482
+ "icon": "users",
1483
+ "rootUrl": "/members",
1484
+ "sortOrder": 10,
1485
+ "createdAt": "2026-05-25T10:00:00.000Z",
1486
+ "updatedAt": "2026-05-25T10:00:00.000Z"
1487
+ }
1488
+ ]
1489
+ ```
1490
+
1491
+ ---
1492
+
1493
+ ### GET `/api/projects/:slug`
1494
+
1495
+ Return a single project record.
1496
+
1497
+ **Auth required:** Yes (`projects:read`)
1498
+
1499
+ **Response** `200` - full project record (404 if not found)
1500
+
1501
+ ---
1502
+
1503
+ ### POST `/api/projects`
1504
+
1505
+ Create a new project.
1506
+
1507
+ **Auth required:** Yes (`projects:create`)
1508
+
1509
+ **Request body**
1510
+
1511
+ ```json
1512
+ {
1513
+ "slug": "members",
1514
+ "name": "Members Area",
1515
+ "description": "Members-only zone",
1516
+ "icon": "users",
1517
+ "rootUrl": "/members",
1518
+ "sortOrder": 10
1519
+ }
1520
+ ```
1521
+
1522
+ **Response** `201` - created project record. `409` on slug collision.
1523
+
1524
+ ---
1525
+
1526
+ ### PUT `/api/projects/:slug`
1527
+
1528
+ Update a project. The slug is immutable.
1529
+
1530
+ **Auth required:** Yes (`projects:update`)
1531
+
1532
+ **Request body** - any of `name`, `description`, `icon`, `rootUrl`, `sortOrder`
1533
+
1534
+ **Response** `200` - updated project record
1535
+
1536
+ ---
1537
+
1538
+ ### DELETE `/api/projects/:slug`
1539
+
1540
+ Delete a project. Refuses with `409` if any artefacts still carry `meta.project: <slug>`.
1541
+
1542
+ **Auth required:** Yes (`projects:delete`)
1543
+
1544
+ **Response** `200`
1545
+
1546
+ ```json
1547
+ { "success": true }
1548
+ ```
1549
+
1550
+ **Response** `409` - conflict body includes a per-type artefact count breakdown:
1551
+
1552
+ ```json
1553
+ {
1554
+ "error": "Cannot delete project - tagged artefacts exist",
1555
+ "artefacts": {
1556
+ "pages": 3,
1557
+ "collections": 2,
1558
+ "forms": 1,
1559
+ "actions": 3,
1560
+ "menus": 1,
1561
+ "blocks": 0,
1562
+ "views": 0,
1563
+ "roles": 1,
1564
+ "users": 2
1565
+ }
1566
+ }
1567
+ ```
1568
+
1569
+ Clear the artefacts first (manually, or via `POST /api/projects/:slug/untag-all`).
1570
+
1571
+ ---
1572
+
1573
+ ### GET `/api/projects/:slug/artefacts`
1574
+
1575
+ Return all artefacts tagged with this project, grouped by type. Used by the sidebar and the project detail view.
1576
+
1577
+ **Auth required:** Yes (`projects:read`)
1578
+
1579
+ **Response** `200` - nine keys, each an array (empty arrays included):
1580
+
1581
+ ```json
1582
+ {
1583
+ "pages": [ ... ],
1584
+ "collections": [ ... ],
1585
+ "forms": [ ... ],
1586
+ "actions": [ ... ],
1587
+ "menus": [ ... ],
1588
+ "blocks": [ ... ],
1589
+ "views": [ ... ],
1590
+ "roles": [ ... ],
1591
+ "users": [ ... ]
1592
+ }
1593
+ ```
1594
+
1595
+ ---
1596
+
1597
+ ### POST `/api/projects/:slug/untag-all`
1598
+
1599
+ Escape hatch - sets `meta.project: null` on every artefact currently tagged with the slug. Used before deleting a project when the admin doesn't want to retag artefacts individually.
1600
+
1601
+ **Auth required:** Yes (`projects:delete`)
1602
+
1603
+ **Response** `200`
1604
+
1605
+ ```json
1606
+ { "success": true, "untagged": { "pages": 3, "collections": 2, "forms": 1, "actions": 3, "menus": 1, "blocks": 0, "views": 0, "roles": 1, "users": 2 } }
1607
+ ```
1608
+
1609
+ ---
1610
+
1611
+ ## Layouts (`/api/layouts`)
1612
+
1613
+ ### GET `/api/layouts`
1614
+
1615
+ Return all layout presets.
1616
+
1617
+ **Auth required:** Yes (`layouts:read`)
1618
+
1619
+ **Response** `200` - presets object (see `config/presets.json` in [Configuration](configuration.md))
1620
+
1621
+ ---
1622
+
1623
+ ### PUT `/api/layouts`
1624
+
1625
+ Replace layout presets. Each preset must be an object with at least a `label` field.
1626
+
1627
+ **Auth required:** Yes (`layouts:update`)
1628
+
1629
+ **Response** `200`
1630
+
1631
+ ```json
1632
+ { "success": true }
1633
+ ```
1634
+
1635
+ ---
1636
+
1637
+ ### GET `/api/layouts/options`
1638
+
1639
+ Return layout display options (e.g. spacer size).
1640
+
1641
+ **Auth required:** Yes (`layouts:read`)
1642
+
1643
+ **Response** `200`
1644
+
1645
+ ```json
1646
+ { "spacerSize": 40 }
1647
+ ```
1648
+
1649
+ ---
1650
+
1651
+ ### PUT `/api/layouts/options`
1652
+
1653
+ Update layout display options.
1654
+
1655
+ **Auth required:** Yes (`layouts:update`)
1656
+
1657
+ **Request body**
1658
+
1659
+ ```json
1660
+ { "spacerSize": 32 }
1661
+ ```
1662
+
1663
+ **Response** `200`
1664
+
1665
+ ```json
1666
+ { "success": true }
1667
+ ```