domma-cms 0.43.1 → 0.45.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 (251) hide show
  1. package/CLAUDE.md +53 -53
  2. package/README.md +27 -27
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/index.html +2 -2
  7. package/admin/js/app.js +1 -1
  8. package/admin/js/lib/card-builder.js +2 -2
  9. package/admin/js/lib/crud-tutorial.js +1 -1
  10. package/admin/js/lib/effect-defs.js +1 -1
  11. package/admin/js/lib/effects-builder.js +1 -1
  12. package/admin/js/lib/image-editor.js +1 -1
  13. package/admin/js/lib/markdown-toolbar.js +2 -2
  14. package/admin/js/lib/project-context.js +1 -1
  15. package/admin/js/lib/project-quick-create.js +1 -1
  16. package/admin/js/lib/scribe-composer.js +1 -1
  17. package/admin/js/lib/server-restart.js +1 -1
  18. package/admin/js/lib/shortcode-context-menu.js +2 -2
  19. package/admin/js/lib/simple-editor.js +1 -1
  20. package/admin/js/lib/themes.js +1 -1
  21. package/admin/js/lib/timeline-builder.js +1 -1
  22. package/admin/js/templates/action-editor.html +8 -8
  23. package/admin/js/templates/api-endpoint-editor.html +7 -7
  24. package/admin/js/templates/api-endpoints.html +1 -1
  25. package/admin/js/templates/api-reference.html +1 -1
  26. package/admin/js/templates/block-editor.html +6 -6
  27. package/admin/js/templates/collection-editor.html +1 -1
  28. package/admin/js/templates/component-editor.html +2 -2
  29. package/admin/js/templates/dashboard/cache.html +3 -3
  30. package/admin/js/templates/dashboard/journeys.html +3 -3
  31. package/admin/js/templates/dashboard/kpi-strip.html +3 -3
  32. package/admin/js/templates/dashboard/top-pages.html +1 -1
  33. package/admin/js/templates/dashboard/traffic-chart.html +1 -1
  34. package/admin/js/templates/docs/api-actions.html +1 -1
  35. package/admin/js/templates/docs/api-collections.html +4 -4
  36. package/admin/js/templates/docs/api-pages.html +2 -2
  37. package/admin/js/templates/docs/api-plugins.html +1 -1
  38. package/admin/js/templates/docs/api-settings.html +2 -2
  39. package/admin/js/templates/docs/api-users.html +1 -1
  40. package/admin/js/templates/docs/components-howto.html +4 -4
  41. package/admin/js/templates/docs/components-reference.html +21 -21
  42. package/admin/js/templates/docs/components-rules.html +14 -14
  43. package/admin/js/templates/docs/components-walkthrough.html +13 -13
  44. package/admin/js/templates/docs/tutorial-crud.html +49 -49
  45. package/admin/js/templates/docs/tutorial-forms.html +4 -4
  46. package/admin/js/templates/docs/tutorial-plugin.html +10 -10
  47. package/admin/js/templates/docs/usage-actions.html +6 -6
  48. package/admin/js/templates/docs/usage-cta-shortcode.html +9 -9
  49. package/admin/js/templates/docs/usage-dconfig.html +4 -4
  50. package/admin/js/templates/docs/usage-navigation.html +1 -1
  51. package/admin/js/templates/docs/usage-pages.html +1 -1
  52. package/admin/js/templates/docs/usage-shortcodes.html +24 -24
  53. package/admin/js/templates/docs/usage-users-roles.html +3 -3
  54. package/admin/js/templates/docs/usage-views.html +4 -4
  55. package/admin/js/templates/documentation.html +1 -1
  56. package/admin/js/templates/effects.html +25 -25
  57. package/admin/js/templates/form-editor.html +5 -5
  58. package/admin/js/templates/login.html +5 -5
  59. package/admin/js/templates/menu-editor.html +10 -10
  60. package/admin/js/templates/navigation.html +11 -11
  61. package/admin/js/templates/notifications.html +1 -1
  62. package/admin/js/templates/page-editor.html +4 -4
  63. package/admin/js/templates/pro-docs.html +14 -14
  64. package/admin/js/templates/role-editor.html +1 -1
  65. package/admin/js/templates/settings.html +10 -10
  66. package/admin/js/templates/tutorials.html +3 -3
  67. package/admin/js/templates/view-editor.html +15 -15
  68. package/admin/js/views/action-editor.js +1 -1
  69. package/admin/js/views/actions-list.js +1 -1
  70. package/admin/js/views/api-endpoint-editor.js +2 -2
  71. package/admin/js/views/api-endpoints.js +2 -2
  72. package/admin/js/views/api-tokens.js +3 -3
  73. package/admin/js/views/blocks.js +2 -2
  74. package/admin/js/views/collection-editor.js +1 -1
  75. package/admin/js/views/collection-entries.js +1 -1
  76. package/admin/js/views/collections.js +1 -1
  77. package/admin/js/views/component-editor.js +1 -1
  78. package/admin/js/views/components.js +4 -4
  79. package/admin/js/views/dashboard/widgets/cache.js +1 -1
  80. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  81. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
  82. package/admin/js/views/form-editor.js +7 -7
  83. package/admin/js/views/form-submissions.js +1 -1
  84. package/admin/js/views/forms.js +1 -1
  85. package/admin/js/views/layouts.js +1 -1
  86. package/admin/js/views/login.js +2 -2
  87. package/admin/js/views/menu-editor.js +3 -3
  88. package/admin/js/views/menu-locations.js +1 -1
  89. package/admin/js/views/menus.js +3 -3
  90. package/admin/js/views/navigation.js +8 -8
  91. package/admin/js/views/page-editor.js +40 -40
  92. package/admin/js/views/pages.js +3 -3
  93. package/admin/js/views/plugin-code.js +2 -2
  94. package/admin/js/views/plugins.js +1 -1
  95. package/admin/js/views/project-settings.js +1 -1
  96. package/admin/js/views/projects.js +1 -1
  97. package/admin/js/views/roles.js +1 -1
  98. package/admin/js/views/user-editor.js +1 -1
  99. package/admin/js/views/users.js +3 -3
  100. package/admin/js/views/view-editor.js +1 -1
  101. package/admin/js/views/view-preview.js +1 -1
  102. package/admin/js/views/views-list.js +1 -1
  103. package/bin/cli.js +8 -8
  104. package/bin/lib/config-merge.js +5 -5
  105. package/bin/update.js +16 -16
  106. package/config/site.json +86 -86
  107. package/package.json +2 -2
  108. package/plugins/_template/admin/views/index.js +1 -1
  109. package/plugins/_template/config.js +1 -1
  110. package/plugins/_template/plugin.js +1 -1
  111. package/plugins/analytics/admin/templates/analytics.html +3 -3
  112. package/plugins/analytics/admin/views/analytics.js +1 -1
  113. package/plugins/analytics/config.js +1 -1
  114. package/plugins/analytics/plugin.js +4 -4
  115. package/plugins/analytics/plugin.json +1 -1
  116. package/plugins/analytics/public/inject-body.html +2 -2
  117. package/plugins/analytics/public/inject-head.html +1 -1
  118. package/plugins/blog/admin/templates/blog.html +4 -4
  119. package/plugins/blog/admin/views/blog.js +3 -3
  120. package/plugins/blog/admin/views/categories.js +2 -2
  121. package/plugins/blog/admin/views/comments.js +4 -4
  122. package/plugins/blog/admin/views/post-editor.js +1 -1
  123. package/plugins/blog/plugin.js +2 -2
  124. package/plugins/blog/plugin.json +4 -4
  125. package/plugins/contacts/admin/views/contacts.js +8 -8
  126. package/plugins/contacts/plugin.js +4 -4
  127. package/plugins/contacts/plugin.json +1 -1
  128. package/plugins/invoice/admin/templates/editor.html +6 -6
  129. package/plugins/invoice/admin/templates/index.html +4 -4
  130. package/plugins/invoice/admin/views/editor.js +3 -3
  131. package/plugins/invoice/admin/views/index.js +4 -4
  132. package/plugins/invoice/admin/views/issuers.js +1 -1
  133. package/plugins/invoice/admin/views/party-view.js +3 -3
  134. package/plugins/invoice/admin/views/receivers.js +1 -1
  135. package/plugins/invoice/config.js +1 -1
  136. package/plugins/invoice/plugin.js +1 -1
  137. package/plugins/invoice/plugin.json +4 -4
  138. package/plugins/notes/admin/views/notes.js +4 -4
  139. package/plugins/notes/plugin.js +1 -1
  140. package/plugins/notes/plugin.json +1 -1
  141. package/plugins/site-search/admin/templates/site-search.html +3 -3
  142. package/plugins/site-search/admin/views/site-search.js +1 -1
  143. package/plugins/site-search/config.js +1 -1
  144. package/plugins/site-search/plugin.js +4 -4
  145. package/plugins/site-search/plugin.json +1 -1
  146. package/plugins/surveys/admin/templates/results.html +1 -1
  147. package/plugins/surveys/admin/templates/survey-editor.html +4 -4
  148. package/plugins/surveys/admin/views/audience.js +6 -6
  149. package/plugins/surveys/admin/views/results.js +3 -3
  150. package/plugins/surveys/admin/views/survey-editor.js +1 -1
  151. package/plugins/surveys/admin/views/surveys.js +2 -2
  152. package/plugins/surveys/plugin.js +1 -1
  153. package/plugins/surveys/plugin.json +4 -4
  154. package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
  155. package/plugins/theme-switcher/plugin.json +3 -3
  156. package/plugins/theme-switcher/public/inject-body.html +28 -28
  157. package/plugins/theme-switcher/public/inject-head.html +1 -1
  158. package/plugins/todo/plugin.js +1 -1
  159. package/public/css/forms.css +1 -1
  160. package/public/js/collection-browser.js +1 -1
  161. package/public/js/collection-context.js +1 -1
  162. package/public/js/form-logic-engine.js +1 -1
  163. package/public/js/forms.js +2 -1
  164. package/public/js/site.js +1 -1
  165. package/scripts/build.js +100 -6
  166. package/scripts/create-plugin.js +2 -2
  167. package/scripts/fresh.js +2 -2
  168. package/scripts/gen-instance-secret.js +1 -1
  169. package/scripts/pro.js +12 -12
  170. package/scripts/reset.js +2 -2
  171. package/scripts/setup.js +15 -15
  172. package/scripts/users.js +5 -5
  173. package/scripts/verify-assets.mjs +6 -6
  174. package/server/config.js +1 -1
  175. package/server/middleware/auth.js +11 -11
  176. package/server/middleware/managerAuth.js +3 -3
  177. package/server/routes/api/actions.js +4 -4
  178. package/server/routes/api/api-endpoints.js +7 -7
  179. package/server/routes/api/api-tokens.js +5 -5
  180. package/server/routes/api/auth.js +10 -10
  181. package/server/routes/api/blocks.js +3 -3
  182. package/server/routes/api/collections.js +18 -18
  183. package/server/routes/api/dashboard.js +5 -5
  184. package/server/routes/api/effects.js +2 -2
  185. package/server/routes/api/endpoints-public.js +5 -5
  186. package/server/routes/api/forms.js +47 -47
  187. package/server/routes/api/menu-locations.js +3 -3
  188. package/server/routes/api/menus.js +7 -7
  189. package/server/routes/api/notifications.js +2 -2
  190. package/server/routes/api/pages.js +14 -2
  191. package/server/routes/api/plugin-marketplace.js +3 -3
  192. package/server/routes/api/plugins.js +6 -6
  193. package/server/routes/api/projects.js +9 -9
  194. package/server/routes/api/scaffold.js +7 -7
  195. package/server/routes/api/settings.js +6 -6
  196. package/server/routes/api/sidebar.js +1 -1
  197. package/server/routes/api/users.js +5 -5
  198. package/server/routes/api/versions.js +1 -1
  199. package/server/routes/api/views.js +1 -1
  200. package/server/routes/docs-public.js +3 -3
  201. package/server/routes/public.js +14 -14
  202. package/server/server.js +34 -16
  203. package/server/services/actions.js +19 -19
  204. package/server/services/adapters/FileAdapter.js +2 -2
  205. package/server/services/adapters/MongoAdapter.js +5 -5
  206. package/server/services/apiEndpoints.js +15 -15
  207. package/server/services/apiTokens.js +7 -7
  208. package/server/services/blocks.js +13 -13
  209. package/server/services/cache/drivers/MemoryDriver.js +3 -3
  210. package/server/services/cache/drivers/NoneDriver.js +1 -1
  211. package/server/services/cache/index.js +5 -5
  212. package/server/services/collections.js +14 -14
  213. package/server/services/components.js +9 -9
  214. package/server/services/connectionManager.js +4 -4
  215. package/server/services/content.js +25 -2
  216. package/server/services/docs.js +14 -14
  217. package/server/services/email.js +1 -1
  218. package/server/services/filterEngine.js +6 -6
  219. package/server/services/forms.js +18 -10
  220. package/server/services/health.js +1 -1
  221. package/server/services/hooks.js +7 -7
  222. package/server/services/images.js +4 -4
  223. package/server/services/managerClient.js +1 -1
  224. package/server/services/markdown.js +79 -79
  225. package/server/services/menuRender.js +5 -5
  226. package/server/services/menus-migration.js +3 -3
  227. package/server/services/menus.js +31 -31
  228. package/server/services/permissionRegistry.js +1 -1
  229. package/server/services/pluginFiles.js +5 -5
  230. package/server/services/pluginInstaller.js +17 -17
  231. package/server/services/pluginScaffold.js +1 -1
  232. package/server/services/plugins.js +17 -17
  233. package/server/services/presetCollections.js +2 -2
  234. package/server/services/projects.js +35 -26
  235. package/server/services/recipes/contact-list.json +1 -1
  236. package/server/services/recipes/onboarding.json +8 -8
  237. package/server/services/references.js +8 -8
  238. package/server/services/renderer.js +9 -9
  239. package/server/services/roles.js +9 -9
  240. package/server/services/rowAccess.js +10 -10
  241. package/server/services/scaffolder.js +30 -30
  242. package/server/services/sidebar-migration.js +10 -10
  243. package/server/services/sitemap.js +1 -1
  244. package/server/services/userProfiles.js +4 -4
  245. package/server/services/userRoles.js +7 -7
  246. package/server/services/users.js +5 -5
  247. package/server/services/versions.js +3 -3
  248. package/server/services/viewPipeline.js +6 -6
  249. package/server/services/views.js +11 -11
  250. package/server/templates/page.html +5 -5
  251. package/config/navigation.json.bak +0 -31
@@ -2,9 +2,9 @@
2
2
  * Users API
3
3
  * GET /api/users - list all users (admin, manager)
4
4
  * GET /api/users/:id - single user (admin, manager, or self)
5
- * POST /api/users - create user (admin, manager — manager cannot create admin)
6
- * PUT /api/users/:id - update user (admin, manager — manager cannot edit admin)
7
- * DELETE /api/users/:id - delete user (admin, manager — manager cannot delete admin, no self-delete)
5
+ * POST /api/users - create user (admin, manager - manager cannot create admin)
6
+ * PUT /api/users/:id - update user (admin, manager - manager cannot edit admin)
7
+ * DELETE /api/users/:id - delete user (admin, manager - manager cannot delete admin, no self-delete)
8
8
  */
9
9
  import {authenticate, canManageUser, requirePermission} from '../../middleware/auth.js';
10
10
  import {getPermissionsFor} from '../../services/roles.js';
@@ -64,7 +64,7 @@ export async function usersRoutes(fastify) {
64
64
  if (!canManageUser(request.user, targetRole)) {
65
65
  return reply.code(403).send({ error: 'You cannot create a user with that role' });
66
66
  }
67
- // Additional-roles privilege guard — actor must be able to manage EVERY role
67
+ // Additional-roles privilege guard - actor must be able to manage EVERY role
68
68
  // they're trying to assign, not just the primary.
69
69
  const requestedAdditional = Array.isArray(request.body?.additionalRoles) ? request.body.additionalRoles : [];
70
70
  for (const r of requestedAdditional) {
@@ -89,7 +89,7 @@ export async function usersRoutes(fastify) {
89
89
  const target = await getUserById(id);
90
90
  if (!target) return reply.status(404).send({ error: 'User not found' });
91
91
 
92
- // Self-edit is always allowed — the hierarchy guard only applies to
92
+ // Self-edit is always allowed - the hierarchy guard only applies to
93
93
  // OTHER users, otherwise nobody can edit a peer-level account (or
94
94
  // even their own, since canManageUser is a strict level comparison).
95
95
  // Role-escalation guards below still apply to self-edits.
@@ -83,7 +83,7 @@ export async function versionsRoutes(fastify) {
83
83
  }
84
84
  });
85
85
 
86
- // Prune versions — keep the most recent N (policy decided in pruneVersions)
86
+ // Prune versions - keep the most recent N (policy decided in pruneVersions)
87
87
  fastify.post('/versions/prune/*', canDelete, async (request, reply) => {
88
88
  const urlPath = '/' + request.params['*'];
89
89
  const keep = Number.parseInt(request.body?.keep, 10);
@@ -32,7 +32,7 @@ export async function viewsRoutes(fastify) {
32
32
  try {
33
33
  const {canSeeArtefact} = await import('../../services/projects.js');
34
34
  const all = await listViews();
35
- // listViews returns full records with meta inline — filter directly
35
+ // listViews returns full records with meta inline - filter directly
36
36
  return all.filter(v => canSeeArtefact(request.user, v));
37
37
  } catch (err) {
38
38
  return reply.status(503).send({ error: err.message });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Domma Docs — public surface
2
+ * Domma Docs - public surface
3
3
  *
4
4
  * Serves the handbook at request time from the admin doc templates:
5
5
  * GET /domma-docs → home landing
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * This dedicated /domma-docs(/*) prefix out-prioritises public.js's `/*`
13
13
  * catch-all (Fastify ranks static segments above wildcards), exactly as
14
- * routes/api/endpoints-public.js `/x/*` coexists with it — so no per-path
14
+ * routes/api/endpoints-public.js `/x/*` coexists with it - so no per-path
15
15
  * special-casing is needed in public.js. Registered at app root (not behind a
16
16
  * prefixed plugin) so the specificity ranking shares one router scope.
17
17
  */
@@ -38,6 +38,6 @@ export async function docsPublicRoutes(fastify) {
38
38
  return reply.type('text/html').send(html);
39
39
  };
40
40
 
41
- fastify.get('/domma-docs', handle); // exact — params['*'] === '' (home landing)
41
+ fastify.get('/domma-docs', handle); // exact - params['*'] === '' (home landing)
42
42
  fastify.get('/domma-docs/*', handle); // sections + leaves
43
43
  }
@@ -18,7 +18,7 @@ import * as cache from '../services/cache/index.js';
18
18
  *
19
19
  * Honours `site.baseUrl` as an explicit override (useful when the CMS sits
20
20
  * behind infrastructure that doesn't forward Host correctly). Otherwise
21
- * builds it from `request.protocol` + `request.host` — both of which Fastify
21
+ * builds it from `request.protocol` + `request.host` - both of which Fastify
22
22
  * resolves from `X-Forwarded-*` headers when `trustProxy` is on. `request.host`
23
23
  * includes the port for non-default ports (e.g. `localhost:4096` in dev) and
24
24
  * omits it for the standard 80/443.
@@ -58,7 +58,7 @@ export async function publicRoutes(fastify) {
58
58
  // Health check
59
59
  fastify.get('/api/health', async () => ({ status: 'ok' }));
60
60
 
61
- // SEO: sitemap.xml — cached per origin (so a single CMS responding on
61
+ // SEO: sitemap.xml - cached per origin (so a single CMS responding on
62
62
  // multiple domains gets the right absolute URLs); invalidated by page
63
63
  // CRUD via the 'sitemap' tag.
64
64
  fastify.get('/sitemap.xml', async (request, reply) => {
@@ -71,7 +71,7 @@ export async function publicRoutes(fastify) {
71
71
  return reply.type('application/xml').send(xml);
72
72
  });
73
73
 
74
- // SEO: robots.txt — cheap to build, no cache wrap needed.
74
+ // SEO: robots.txt - cheap to build, no cache wrap needed.
75
75
  fastify.get('/robots.txt', async (request, reply) => {
76
76
  return reply.type('text/plain').send(buildRobotsTxt(getBaseUrl(request)));
77
77
  });
@@ -87,7 +87,7 @@ export async function publicRoutes(fastify) {
87
87
 
88
88
  const urlPath = '/' + (rawPath || '');
89
89
 
90
- // First-pass page lookup — used only for the visibility decision
90
+ // First-pass page lookup - used only for the visibility decision
91
91
  // (metadata-only is sufficient). We re-fetch below with `user` once
92
92
  // it is resolved so the body's [menu] shortcode renders against the
93
93
  // correct role.
@@ -110,28 +110,28 @@ export async function publicRoutes(fastify) {
110
110
  }
111
111
 
112
112
  // Hard kill-switch: a disabled project takes its pages off the public
113
- // site entirely — 404 (no oracle), regardless of page visibility.
113
+ // site entirely - 404 (no oracle), regardless of page visibility.
114
114
  const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
115
115
  if (!(await isProjectEnabled(pageProject))) {
116
116
  reply.status(404);
117
117
  return reply.type('text/html').send(await render404(urlPath));
118
118
  }
119
119
 
120
- // Enforce page visibility — role only resolved for gated pages,
120
+ // Enforce page visibility - role only resolved for gated pages,
121
121
  // so public pages share a single cache entry keyed `roleanon`.
122
122
  //
123
123
  // `visibility` may be a string ('public' | 'private' | role name) or
124
- // an array of role names — see checkVisibility() for full semantics.
124
+ // an array of role names - see checkVisibility() for full semantics.
125
125
  // Effectively-public values (missing, 'public', or an array containing
126
126
  // 'public') skip JWT verification entirely so anonymous traffic hits
127
127
  // the shared cache without auth cost.
128
- // For per-role cache keying we use the primary role only — multi-role
128
+ // For per-role cache keying we use the primary role only - multi-role
129
129
  // users still see correctly-gated content (checkVisibility consults
130
130
  // additionalRoles too) but the cache key stays bounded to one entry
131
131
  // per primary role rather than 2^N per role combination. The fallback
132
132
  // is correctness: any user whose access depends on an additional role
133
133
  // is granted, but their served HTML is the variant cached for their
134
- // primary role's tier — fine for the public-site use case.
134
+ // primary role's tier - fine for the public-site use case.
135
135
  let userRole = null;
136
136
  let userObj = null;
137
137
  const vis = page.visibility;
@@ -146,7 +146,7 @@ export async function publicRoutes(fastify) {
146
146
  userRole = decoded.role;
147
147
  userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
148
148
  }
149
- } catch { /* no token — treat as unauthenticated */ }
149
+ } catch { /* no token - treat as unauthenticated */ }
150
150
 
151
151
  if (!checkVisibility(userObj, vis)) {
152
152
  reply.status(403);
@@ -155,12 +155,12 @@ export async function publicRoutes(fastify) {
155
155
  }
156
156
 
157
157
  const baseUrl = getBaseUrl(request);
158
- // mtime in the key: any change to the backing file — editor save,
159
- // script, git pull — yields a fresh entry even if no invalidation
158
+ // mtime in the key: any change to the backing file - editor save,
159
+ // script, git pull - yields a fresh entry even if no invalidation
160
160
  // hook fired. Stale-mtime entries age out via TTL/LRU.
161
161
  const pageMtime = await getPageMtime(page.urlPath || urlPath);
162
162
  const cacheKey = `page:${urlPath}:m${pageMtime}:role${userRole ?? 'anon'}:o${baseUrl}`;
163
- const cacheTags = [`page:${urlPath}`, ...(page.tags || []), 'nav', 'site'];
163
+ const cacheTags = [`page:${urlPath}`, ...(page.cacheTags || []), 'nav', 'site'];
164
164
  const html = await cache.wrap(
165
165
  cacheKey,
166
166
  async () => {
@@ -179,7 +179,7 @@ export async function publicRoutes(fastify) {
179
179
  }
180
180
 
181
181
  /**
182
- * Render a 404 response — tries content/pages/404.md first, falls back to
182
+ * Render a 404 response - tries content/pages/404.md first, falls back to
183
183
  * a minimal inline page so the site theme is applied when possible.
184
184
  */
185
185
  export async function render404(urlPath) {
package/server/server.js CHANGED
@@ -38,7 +38,7 @@ const DOMMA_DIST = path.join(dommaPackageDir, 'public', 'dist');
38
38
 
39
39
  const { server: serverConfig, auth: authConfig } = config;
40
40
 
41
- // Validate JWT_SECRET before starting — prevents silent auth failures
41
+ // Validate JWT_SECRET before starting - prevents silent auth failures
42
42
  const JWT_SECRET = process.env.JWT_SECRET;
43
43
  if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
44
44
  console.error('');
@@ -49,7 +49,7 @@ if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
49
49
  process.exit(1);
50
50
  }
51
51
 
52
- // MANAGER_SECRET is optional — only needed when domma-cms-manager pushes notifications.
52
+ // MANAGER_SECRET is optional - only needed when domma-cms-manager pushes notifications.
53
53
  // Warn if set but insecure; silently accept if absent (manager push is disabled).
54
54
  const MANAGER_SECRET = process.env.MANAGER_SECRET;
55
55
  if (MANAGER_SECRET && MANAGER_SECRET.length < 32) {
@@ -92,7 +92,7 @@ await app.register(helmet, {
92
92
  }
93
93
  },
94
94
  crossOriginEmbedderPolicy: false, // allow embedding images/resources
95
- hsts: false, // disable HSTS — server runs HTTP only; HSTS would force browser to https
95
+ hsts: false, // disable HSTS - server runs HTTP only; HSTS would force browser to https
96
96
  });
97
97
 
98
98
  await app.register(jwt, { secret: process.env.JWT_SECRET });
@@ -102,7 +102,7 @@ await app.register(rateLimit, {
102
102
  global: true, // apply default limit to all routes; stricter per-route limits override this
103
103
  max: 500,
104
104
  timeWindow: '1 minute',
105
- // Loopback is always exempt — local admin use should never be rate-limited.
105
+ // Loopback is always exempt - local admin use should never be rate-limited.
106
106
  // When behind a reverse proxy (TRUST_PROXY), the proxy's loopback is also exempt.
107
107
  allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
108
108
  });
@@ -133,11 +133,11 @@ await app.register(staticPlugin, {
133
133
  decorateReply: false
134
134
  });
135
135
 
136
- // Serve admin panel assets — no-store on JS/CSS so ESM module URLs are never
136
+ // Serve admin panel assets - no-store on JS/CSS so ESM module URLs are never
137
137
  // cached. `no-cache` revalidates but browsers may keep the parsed ESM module
138
138
  // keyed by URL across reloads, which means a hard refresh can still load the
139
139
  // stale code path. `no-store` skips the cache entirely. Other admin assets
140
- // (images, html) keep no-cache. Local-only impact — admin is not public.
140
+ // (images, html) keep no-cache. Local-only impact - admin is not public.
141
141
  await app.register(staticPlugin, {
142
142
  root: path.join(ROOT, 'admin'),
143
143
  prefix: '/admin/',
@@ -162,7 +162,7 @@ await fs.mkdir(pluginsDir, { recursive: true });
162
162
  await fs.mkdir(blocksDir, {recursive: true});
163
163
 
164
164
  // ---------------------------------------------------------------------------
165
- // Pro feature — optional MongoDB connections
165
+ // Pro feature - optional MongoDB connections
166
166
  // ---------------------------------------------------------------------------
167
167
 
168
168
  try {
@@ -173,11 +173,11 @@ try {
173
173
  app.addHook('onClose', shutdown);
174
174
  }
175
175
  } catch {
176
- // No connections.json or empty — pure file-based mode (free version).
176
+ // No connections.json or empty - pure file-based mode (free version).
177
177
  }
178
178
 
179
179
  // ---------------------------------------------------------------------------
180
- // Roles — seed preset collection + load into cache
180
+ // Roles - seed preset collection + load into cache
181
181
  // ---------------------------------------------------------------------------
182
182
 
183
183
  await seedRoles();
@@ -186,13 +186,13 @@ await seedUserProfiles();
186
186
  await ensureAllProfiles();
187
187
  await seedPresetCollections();
188
188
  await seedCoreProject();
189
- await seedDocsProject(); // built-in Domma Docs project — seeded disabled by default
189
+ await seedDocsProject(); // built-in Domma Docs project - seeded disabled by default
190
190
  await seedDefaultBlocks();
191
191
  await seedDefaultComponents();
192
192
  await refreshComponentTagAllowlist();
193
193
 
194
194
  // ---------------------------------------------------------------------------
195
- // Menus — one-shot migration from legacy navigation.json + site footer links
195
+ // Menus - one-shot migration from legacy navigation.json + site footer links
196
196
  // ---------------------------------------------------------------------------
197
197
 
198
198
  try {
@@ -229,7 +229,7 @@ console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disable
229
229
  // stale pages. No-op on a cold memory cache; matters for the Redis driver.
230
230
  await cache.invalidateTags(['docs']);
231
231
 
232
- // Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
232
+ // Serve uploaded media files - nosniff prevents browsers rendering spoofed content types
233
233
  await app.register(staticPlugin, {
234
234
  root: mediaDir,
235
235
  prefix: '/media/',
@@ -239,7 +239,7 @@ await app.register(staticPlugin, {
239
239
  }
240
240
  });
241
241
 
242
- // Serve plugin admin/ and public/ subdirs only — block plugin.js, config.js, data/, etc.
242
+ // Serve plugin admin/ and public/ subdirs only - block plugin.js, config.js, data/, etc.
243
243
  await app.register(async function pluginStaticScope(instance) {
244
244
  instance.addHook('onRequest', (request, reply, done) => {
245
245
  const relative = request.url.replace(/^\/plugins\//, '').split('?')[0];
@@ -265,7 +265,7 @@ await app.register(async function pluginStaticScope(instance) {
265
265
  });
266
266
 
267
267
  // ---------------------------------------------------------------------------
268
- // Global error handler — prevents stack trace leaks in production
268
+ // Global error handler - prevents stack trace leaks in production
269
269
  // ---------------------------------------------------------------------------
270
270
 
271
271
  app.setErrorHandler((error, request, reply) => {
@@ -278,6 +278,24 @@ app.setErrorHandler((error, request, reply) => {
278
278
  return reply.code(statusCode).send({ error: message });
279
279
  });
280
280
 
281
+ // ---------------------------------------------------------------------------
282
+ // Admin API responses are never cacheable
283
+ // ---------------------------------------------------------------------------
284
+ // The admin SPA must always see the live state of the site - a stale list is
285
+ // indistinguishable from a missing artefact, and the user is left arguing with
286
+ // their own CMS. Everything under /api is no-store EXCEPT the two externally
287
+ // consumable surfaces, which have their own caching contracts:
288
+ // /api/v1/* - collection access by API token
289
+ // /api/x/* - API Builder endpoints
290
+ app.addHook('onSend', async (request, reply) => {
291
+ const url = request.raw.url || '';
292
+ if (!url.startsWith('/api/')) return;
293
+ if (url.startsWith('/api/v1/') || url.startsWith('/api/x/')) return;
294
+ reply.header('Cache-Control', 'no-store, must-revalidate');
295
+ reply.header('Pragma', 'no-cache');
296
+ reply.header('Expires', '0');
297
+ });
298
+
281
299
  // ---------------------------------------------------------------------------
282
300
  // Auth API Routes (no authentication required on these endpoints themselves)
283
301
  // ---------------------------------------------------------------------------
@@ -374,10 +392,10 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
374
392
  }
375
393
 
376
394
  // ---------------------------------------------------------------------------
377
- // Public Site (catch-all — must be last)
395
+ // Public Site (catch-all - must be last)
378
396
  // ---------------------------------------------------------------------------
379
397
 
380
- // Domma Docs (request-time handbook) — dedicated /domma-docs(/*) prefix that
398
+ // Domma Docs (request-time handbook) - dedicated /domma-docs(/*) prefix that
381
399
  // out-prioritises the catch-all; must be registered before it.
382
400
  const { docsPublicRoutes } = await import('./routes/docs-public.js');
383
401
  await app.register(docsPublicRoutes);
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Actions Service (Pro — requires MongoDB)
2
+ * Actions Service (Pro - requires MongoDB)
3
3
  *
4
4
  * Action configs are stored in MongoDB `cms__actions` on the 'default' connection.
5
5
  * Executing an action runs a sequence of steps against a target collection entry.
6
6
  *
7
7
  * Supported step types:
8
- * updateField — overwrite one field on the source entry
9
- * deleteEntry — remove the source entry
10
- * moveToCollection — copy source entry into target collection AND delete source
11
- * createInCollection — create a new entry in another collection; source untouched
8
+ * updateField - overwrite one field on the source entry
9
+ * deleteEntry - remove the source entry
10
+ * moveToCollection - copy source entry into target collection AND delete source
11
+ * createInCollection - create a new entry in another collection; source untouched
12
12
  * (used for apply/follow/bookmark/upvote-style relations)
13
- * webhook — POST/PUT/GET to an external URL with interpolated body
14
- * email — send a transactional email via configured SMTP
13
+ * webhook - POST/PUT/GET to an external URL with interpolated body
14
+ * email - send a transactional email via configured SMTP
15
15
  *
16
16
  * Template interpolation in step configs:
17
17
  * {{entry.id}} → source entry id
@@ -23,7 +23,7 @@
23
23
  * {{user.role}} → executing user's role
24
24
  * {{env.CMS_PUBLIC_*}} → environment variables with CMS_PUBLIC_ prefix only
25
25
  *
26
- * Note: execution is not transactional — partial results are returned on failure
26
+ * Note: execution is not transactional - partial results are returned on failure
27
27
  * with a `stepsCompleted` count.
28
28
  */
29
29
  import {v4 as uuidv4} from 'uuid';
@@ -151,16 +151,16 @@ async function executeStep(step, collection, entry, context) {
151
151
  }
152
152
 
153
153
  /*
154
- * createInCollection — append an entry to another collection without
154
+ * createInCollection - append an entry to another collection without
155
155
  * touching the source. Designed for relational patterns like:
156
156
  * - Apply for job → create entry in `applications` { jobId, candidateId, status }
157
157
  * - Bookmark page → create entry in `bookmarks` { pageId, userId }
158
158
  * - Upvote item → create entry in `votes` { itemId, userId, value: 1 }
159
159
  *
160
160
  * Config:
161
- * targetCollection {string} required — slug of the collection to append to
162
- * data {object} required — field map; values are template-interpolated
163
- * createdBy {string} optional — '{{user.id}}' is the conventional value;
161
+ * targetCollection {string} required - slug of the collection to append to
162
+ * data {object} required - field map; values are template-interpolated
163
+ * createdBy {string} optional - '{{user.id}}' is the conventional value;
164
164
  * enables row-level "mine" filtering downstream
165
165
  *
166
166
  * Returns: { created: <new entry id>, in: <targetCollection> }
@@ -303,7 +303,7 @@ export async function createAction(data, userId = null) {
303
303
  confirmMessage: trigger?.confirmMessage || null
304
304
  },
305
305
  steps,
306
- // State-machine transition — when set, the action is gated by the
306
+ // State-machine transition - when set, the action is gated by the
307
307
  // entry's current value of `field` matching one of the `from` values,
308
308
  // and is advertised as an "available transition" for that state.
309
309
  // Optional `to` is informational only (steps still do the actual write).
@@ -399,12 +399,12 @@ export async function listActionsForCollection(collectionSlug) {
399
399
  * 3. action.access.roles allows the user (compared by role level so
400
400
  * higher-privileged roles inherit access automatically)
401
401
  *
402
- * Returns a stripped projection — slug, title, trigger config, transition —
402
+ * Returns a stripped projection - slug, title, trigger config, transition -
403
403
  * so the client can render buttons without leaking step internals.
404
404
  *
405
405
  * @param {string} collectionSlug
406
406
  * @param {object} entry - The entry being inspected (needed for field-value match)
407
- * @param {object} [user] - { role } — anonymous if missing
407
+ * @param {object} [user] - { role } - anonymous if missing
408
408
  * @returns {Promise<object[]>}
409
409
  */
410
410
  export async function listTransitionsForEntry(collectionSlug, entry, user = null) {
@@ -414,7 +414,7 @@ export async function listTransitionsForEntry(collectionSlug, entry, user = null
414
414
  const { getRoleLevel } = await import('./roles.js');
415
415
  const { getEffectiveLevel } = await import('./userRoles.js');
416
416
  // Use effective level so multi-role users see transitions any of their
417
- // roles permit — e.g. a user who's primarily a candidate but also an
417
+ // roles permit - e.g. a user who's primarily a candidate but also an
418
418
  // admin sees BOTH the candidate-only "withdraw" AND the admin-only
419
419
  // "approve" transitions on the same entry.
420
420
  const userLevel = getEffectiveLevel(user);
@@ -446,7 +446,7 @@ export async function listTransitionsForEntry(collectionSlug, entry, user = null
446
446
 
447
447
  /**
448
448
  * Execute an action against a specific collection entry.
449
- * Steps run sequentially — not transactional.
449
+ * Steps run sequentially - not transactional.
450
450
  * Returns partial results with stepsCompleted count on failure.
451
451
  *
452
452
  * @param {string} slug - Action slug
@@ -469,7 +469,7 @@ export async function executeAction(slug, entryId, { user = null } = {}) {
469
469
  throw err;
470
470
  }
471
471
 
472
- // State-machine guard — an action with a transition config only runs when
472
+ // State-machine guard - an action with a transition config only runs when
473
473
  // the entry's current value of `field` is in `from`. Stops illegal moves
474
474
  // like "withdraw" on an already-rejected application; the API returns 409
475
475
  // so the client can present a meaningful "no longer available" message
@@ -480,7 +480,7 @@ export async function executeAction(slug, entryId, { user = null } = {}) {
480
480
  const currentValue = entry.data?.[t.field];
481
481
  const allowed = Array.isArray(t.from) ? t.from : (t.from ? [t.from] : []);
482
482
  if (allowed.length && !allowed.includes(currentValue)) {
483
- const err = new Error(`Cannot apply "${action.title}" — current ${t.field} is "${currentValue ?? '(none)'}", allowed: ${allowed.join(', ')}`);
483
+ const err = new Error(`Cannot apply "${action.title}" - current ${t.field} is "${currentValue ?? '(none)'}", allowed: ${allowed.join(', ')}`);
484
484
  err.statusCode = 409;
485
485
  throw err;
486
486
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * FileAdapter — file-based storage adapter for Collection entries.
2
+ * FileAdapter - file-based storage adapter for Collection entries.
3
3
  *
4
4
  * Stores entries in `data.json` inside the collection directory.
5
5
  * This is the default adapter for all collections and the only adapter
@@ -56,7 +56,7 @@ export class FileAdapter {
56
56
  *
57
57
  * `search` is a simple substring match across all field values (legacy
58
58
  * behaviour, kept for the admin search box). `filter` is the structured
59
- * DSL — see `filterEngine.js` for operator syntax. Both may be combined;
59
+ * DSL - see `filterEngine.js` for operator syntax. Both may be combined;
60
60
  * search runs first, then filter.
61
61
  *
62
62
  * @param {string} slug
@@ -1,10 +1,10 @@
1
1
  /**
2
- * MongoAdapter — MongoDB storage adapter for Collection entries. (Pro feature)
2
+ * MongoAdapter - MongoDB storage adapter for Collection entries. (Pro feature)
3
3
  *
4
4
  * Each CMS collection maps to a MongoDB collection prefixed with `cms_`
5
5
  * (e.g. slug "contacts" → MongoDB collection "cms_contacts").
6
6
  *
7
- * Entry format is preserved: { id, data, meta } — identical to FileAdapter output.
7
+ * Entry format is preserved: { id, data, meta } - identical to FileAdapter output.
8
8
  * MongoDB's _id field is stripped from all returned documents.
9
9
  *
10
10
  * Adapter interface:
@@ -66,7 +66,7 @@ export class MongoAdapter {
66
66
  * and structured filtering.
67
67
  *
68
68
  * `filter` is translated to native MongoDB query operators via
69
- * `filterEngine.toMongoQuery()` and pushed down to the database — page
69
+ * `filterEngine.toMongoQuery()` and pushed down to the database - page
70
70
  * authors writing `[collection where_salary_gte="50000"]` get index-aware
71
71
  * queries automatically. When `search` is present, all documents are
72
72
  * fetched and filtered in memory (same behaviour as FileAdapter, kept
@@ -200,7 +200,7 @@ export class MongoAdapter {
200
200
 
201
201
  /**
202
202
  * Drop the backing MongoDB collection entirely (documents, indexes and
203
- * the namespace). Used when the CMS collection itself is deleted —
203
+ * the namespace). Used when the CMS collection itself is deleted -
204
204
  * clear() only removes documents and would leave `cms_<slug>` behind.
205
205
  *
206
206
  * Deliberately bypasses _col(): that helper ensures the unique index,
@@ -213,7 +213,7 @@ export class MongoAdapter {
213
213
  try {
214
214
  await this._db.collection(`${PREFIX}${slug}`).drop();
215
215
  } catch (err) {
216
- // Already absent — nothing to drop.
216
+ // Already absent - nothing to drop.
217
217
  if (err.codeName !== 'NamespaceNotFound') throw err;
218
218
  }
219
219
  this._ensured.delete(slug);
@@ -5,12 +5,12 @@
5
5
  *
6
6
  * Definitions are DATA, never code: entries in the file-based `api-endpoints`
7
7
  * preset collection. A definition binds a URL path (with `:param` segments)
8
- * to a collection query — fixed filters whose values may carry
8
+ * to a collection query - fixed filters whose values may carry
9
9
  * `{{params.<name>}}` / `{{query.<name>}}` placeholders, sort/limit, a read
10
10
  * field allowlist, and an auth mode (public / token / role) that reuses the
11
11
  * exact machinery of the public collections API.
12
12
  *
13
- * Substituted values only ever feed the filterEngine — there is no eval and
13
+ * Substituted values only ever feed the filterEngine - there is no eval and
14
14
  * no way for a definition to execute code.
15
15
  */
16
16
  import {createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from './collections.js';
@@ -42,7 +42,7 @@ function invalidateRegistry() {
42
42
  registry = null;
43
43
  }
44
44
 
45
- // Generic admin collection endpoints emit these for ALL collections — the
45
+ // Generic admin collection endpoints emit these for ALL collections - the
46
46
  // service's own mutations invalidate directly, this catches edits made
47
47
  // through the admin entries grid.
48
48
  for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
@@ -79,7 +79,7 @@ function sanitise(entry) {
79
79
  }
80
80
 
81
81
  /**
82
- * Normalise a path to its shape for duplicate detection — every `:param`
82
+ * Normalise a path to its shape for duplicate detection - every `:param`
83
83
  * segment collapses to `:` so `/a/:x` and `/a/:y` are the same shape.
84
84
  *
85
85
  * @param {string} path
@@ -113,12 +113,12 @@ async function validateDefinition(data, {excludeId} = {}) {
113
113
  if (schema.systemManaged || schema.preset) {
114
114
  throw new Error(`Collection "${data.collection}" is system-managed and cannot be exposed`);
115
115
  }
116
- // An endpoint may only expose collections from its own project or core —
116
+ // An endpoint may only expose collections from its own project or core -
117
117
  // the URL namespace must match where the data lives, and scoped users
118
118
  // must not be able to publish another project's data through their own.
119
119
  const collectionProject = resolveArtefactProject(schema);
120
120
  if (collectionProject !== data.project && collectionProject !== CORE_PROJECT_SLUG) {
121
- throw new Error(`Collection "${data.collection}" belongs to project "${collectionProject}" — an endpoint may only expose collections from its own project or core`);
121
+ throw new Error(`Collection "${data.collection}" belongs to project "${collectionProject}" - an endpoint may only expose collections from its own project or core`);
122
122
  }
123
123
 
124
124
  if (typeof data.path !== 'string' || !data.path.startsWith('/')) {
@@ -131,13 +131,13 @@ async function validateDefinition(data, {excludeId} = {}) {
131
131
  if (PARAM_SEGMENT_RE.test(seg)) {
132
132
  paramNames.add(seg.slice(1));
133
133
  } else if (!STATIC_SEGMENT_RE.test(seg)) {
134
- throw new Error(`Invalid path segment "${seg}" — use lowercase letters, numbers, hyphens, or :param`);
134
+ throw new Error(`Invalid path segment "${seg}" - use lowercase letters, numbers, hyphens, or :param`);
135
135
  }
136
136
  }
137
137
 
138
138
  const auth = data.auth || 'public';
139
139
  if (auth !== 'public' && auth !== 'token' && !getRoleMap().has(auth)) {
140
- throw new Error(`Unknown auth mode "${auth}" — use public, token, or a role name`);
140
+ throw new Error(`Unknown auth mode "${auth}" - use public, token, or a role name`);
141
141
  }
142
142
 
143
143
  if (data.mode != null && !MODES.includes(data.mode)) throw new Error('mode must be "list" or "single"');
@@ -170,7 +170,7 @@ async function validateDefinition(data, {excludeId} = {}) {
170
170
  }
171
171
  }
172
172
 
173
- // Duplicate shape per project — `/a/:x` and `/a/:y` collide.
173
+ // Duplicate shape per project - `/a/:x` and `/a/:y` collide.
174
174
  const shape = pathShape(data.path);
175
175
  const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
176
176
  const clash = entries.find(e =>
@@ -216,7 +216,7 @@ export async function createEndpoint(input) {
216
216
 
217
217
  /**
218
218
  * Update an endpoint. The project binding is immutable (it is the endpoint's
219
- * URL namespace) — create a new endpoint to move one.
219
+ * URL namespace) - create a new endpoint to move one.
220
220
  *
221
221
  * @param {string} id
222
222
  * @param {object} patch
@@ -387,14 +387,14 @@ function substituteFilter(filterTemplate, params, query) {
387
387
  }
388
388
 
389
389
  /**
390
- * Query keys that control the query envelope, not a field to filter on — they
390
+ * Query keys that control the query envelope, not a field to filter on - they
391
391
  * must never be interpreted as ad-hoc filters (otherwise `?limit=5` would
392
392
  * filter a field called "limit").
393
393
  */
394
394
  const RESERVED_QUERY_KEYS = new Set(['page', 'limit', 'sort', 'order', 'search']);
395
395
 
396
396
  /**
397
- * The fields an endpoint is allowed to expose — and therefore the only fields
397
+ * The fields an endpoint is allowed to expose - and therefore the only fields
398
398
  * an ad-hoc query filter may touch. A read-field allowlist, when set, is the
399
399
  * boundary; otherwise every declared collection field (plus the always-safe
400
400
  * `id` / `createdBy` pseudo-fields) is fair game. Restricting ad-hoc filters
@@ -415,7 +415,7 @@ async function exposedFieldSet(def) {
415
415
  * Collect ad-hoc filters from the request query, layered UNDER the definition's
416
416
  * declared filters: reserved envelope keys are skipped, values must target an
417
417
  * exposed field, and a field already constrained by a declared filter is left
418
- * to the declaration (the curated filter always wins — a caller cannot loosen
418
+ * to the declaration (the curated filter always wins - a caller cannot loosen
419
419
  * or override it).
420
420
  *
421
421
  * @param {Record<string, unknown>} query
@@ -442,7 +442,7 @@ function collectAdHocFilter(query, exposed, declaredFields) {
442
442
  *
443
443
  * The final filter is the definition's declared filters (fixed literals,
444
444
  * {{params.x}}, {{query.x}}) with ad-hoc query-string filters layered beneath
445
- * them — declared always wins on any overlap.
445
+ * them - declared always wins on any overlap.
446
446
  *
447
447
  * @param {object} def
448
448
  * @param {Record<string, string>} params - Decoded path params
@@ -458,7 +458,7 @@ export async function executeEndpoint(def, params, query) {
458
458
  const adhoc = collectAdHocFilter(q, exposed, declaredFields);
459
459
  const filter = {...adhoc, ...declared}; // declared wins on any key collision
460
460
 
461
- // Envelope controls — the reserved query keys tune pagination and sort
461
+ // Envelope controls - the reserved query keys tune pagination and sort
462
462
  // rather than filter (list mode only). The definition's own limit is the
463
463
  // ceiling: a caller may page/shrink within it but never pull more than the
464
464
  // endpoint intends; sort is restricted to exposed fields.