domma-cms 0.17.0 → 0.21.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 (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Menu locations API
3
+ *
4
+ * GET /api/menu-locations — current slot → menu-slug map
5
+ * PUT /api/menu-locations — replace the map (validates against registry + menus)
6
+ * GET /api/menu-locations/registry — list registered slots (core + plugin-contributed)
7
+ *
8
+ * Auth middlewares are accepted as options so tests can supply no-ops without
9
+ * needing to register `@fastify/jwt` + seed the roles cache. In production the
10
+ * defaults are the real `authenticate` / `requirePermission` from
11
+ * middleware/auth.js.
12
+ */
13
+ import {
14
+ authenticate as defaultAuthenticate,
15
+ requirePermission as defaultRequirePermission
16
+ } from '../../middleware/auth.js';
17
+ import {getLocationRegistry, getLocations, setLocations} from '../../services/menus.js';
18
+ import * as cache from '../../services/cache/index.js';
19
+
20
+ /**
21
+ * Register the menu-locations routes.
22
+ *
23
+ * @param {import('fastify').FastifyInstance} fastify
24
+ * @param {{authenticate?: Function, requirePermission?: Function}} [opts]
25
+ * @returns {Promise<void>}
26
+ */
27
+ export async function menuLocationsRoutes(fastify, opts = {}) {
28
+ const authenticate = opts.authenticate || defaultAuthenticate;
29
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
30
+
31
+ const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
32
+ const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
33
+
34
+ fastify.get('/menu-locations', canRead, async () => getLocations());
35
+ fastify.get('/menu-locations/registry', canRead, async () => getLocationRegistry());
36
+
37
+ fastify.put('/menu-locations', canUpdate, async (request, reply) => {
38
+ try {
39
+ await setLocations(request.body || {});
40
+ await cache.invalidateTags(['menu-locations', 'nav']);
41
+ return {success: true};
42
+ } catch (err) {
43
+ return reply.status(400).send({error: err.message});
44
+ }
45
+ });
46
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Menus API
3
+ *
4
+ * GET /api/menus — list (metadata only)
5
+ * GET /api/menus/:slug — full menu
6
+ * POST /api/menus — create
7
+ * PUT /api/menus/:slug — replace
8
+ * DELETE /api/menus/:slug — delete (409 if mapped to a slot)
9
+ * POST /api/menus/:slug/duplicate — copy as <slug>-copy
10
+ *
11
+ * Auth middlewares are accepted as options so tests can supply no-ops without
12
+ * needing to register `@fastify/jwt` + seed the roles cache. In production the
13
+ * defaults are the real `authenticate` / `requirePermission` from
14
+ * middleware/auth.js.
15
+ */
16
+ import {
17
+ authenticate as defaultAuthenticate,
18
+ requirePermission as defaultRequirePermission
19
+ } from '../../middleware/auth.js';
20
+ import {
21
+ createMenu,
22
+ deleteMenu,
23
+ duplicateMenu,
24
+ getLocations,
25
+ getMenu,
26
+ listMenus,
27
+ updateMenu
28
+ } from '../../services/menus.js';
29
+ import * as cache from '../../services/cache/index.js';
30
+
31
+ /**
32
+ * Register the menus routes.
33
+ *
34
+ * @param {import('fastify').FastifyInstance} fastify
35
+ * @param {{authenticate?: Function, requirePermission?: Function}} [opts]
36
+ * @returns {Promise<void>}
37
+ */
38
+ export async function menusRoutes(fastify, opts = {}) {
39
+ const authenticate = opts.authenticate || defaultAuthenticate;
40
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
41
+
42
+ const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
43
+ const canCreate = {preHandler: [authenticate, requirePermission('menus', 'create')]};
44
+ const canUpdate = {preHandler: [authenticate, requirePermission('menus', 'update')]};
45
+ const canDelete = {preHandler: [authenticate, requirePermission('menus', 'delete')]};
46
+
47
+ fastify.get('/menus', canRead, async (request) => {
48
+ const {canSeeArtefact} = await import('../../services/projects.js');
49
+ const all = await listMenus();
50
+ const filtered = [];
51
+ for (const m of all) {
52
+ // listMenus returns metadata only; getMenu returns full record with meta
53
+ const full = await getMenu(m.slug);
54
+ if (canSeeArtefact(request.user, full)) filtered.push(m);
55
+ }
56
+ return filtered;
57
+ });
58
+
59
+ fastify.get('/menus/:slug', canRead, async (request, reply) => {
60
+ const menu = await getMenu(request.params.slug);
61
+ if (!menu) return reply.status(404).send({error: 'Menu not found'});
62
+ const {canSeeArtefact} = await import('../../services/projects.js');
63
+ if (!canSeeArtefact(request.user, menu)) {
64
+ return reply.status(403).send({error: 'Access denied for this project'});
65
+ }
66
+ return menu;
67
+ });
68
+
69
+ fastify.post('/menus', canCreate, async (request, reply) => {
70
+ try {
71
+ const menu = await createMenu(request.body || {});
72
+ await cache.invalidateTags([`menu:${menu.slug}`, 'nav']);
73
+ return reply.status(201).send(menu);
74
+ } catch (err) {
75
+ const status = /already exists/.test(err.message) ? 409 : 400;
76
+ return reply.status(status).send({error: err.message});
77
+ }
78
+ });
79
+
80
+ fastify.put('/menus/:slug', canUpdate, async (request, reply) => {
81
+ try {
82
+ const menu = await updateMenu(request.params.slug, request.body || {});
83
+ await cache.invalidateTags([`menu:${menu.slug}`, 'nav']);
84
+ return menu;
85
+ } catch (err) {
86
+ const status = /not found/.test(err.message) ? 404 : 400;
87
+ return reply.status(status).send({error: err.message});
88
+ }
89
+ });
90
+
91
+ fastify.delete('/menus/:slug', canDelete, async (request, reply) => {
92
+ const map = await getLocations();
93
+ const slot = Object.entries(map).find(([, slug]) => slug === request.params.slug)?.[0];
94
+ if (slot) {
95
+ return reply.status(409).send({
96
+ error: `Cannot delete — menu is mapped to slot "${slot}". Unmap it first.`
97
+ });
98
+ }
99
+ const ok = await deleteMenu(request.params.slug);
100
+ if (!ok) return reply.status(404).send({error: 'Menu not found'});
101
+ await cache.invalidateTags([`menu:${request.params.slug}`, 'nav']);
102
+ return {success: true};
103
+ });
104
+
105
+ fastify.post('/menus/:slug/duplicate', canCreate, async (request, reply) => {
106
+ try {
107
+ const copy = await duplicateMenu(request.params.slug);
108
+ await cache.invalidateTags([`menu:${copy.slug}`]);
109
+ return reply.status(201).send(copy);
110
+ } catch (err) {
111
+ const status = /not found/.test(err.message) ? 404 : 400;
112
+ return reply.status(status).send({error: err.message});
113
+ }
114
+ });
115
+ }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import {getConfig, saveConfig} from '../../config.js';
7
7
  import {authenticate, requirePermission} from '../../middleware/auth.js';
8
+ import * as cache from '../../services/cache/index.js';
8
9
 
9
10
  export async function navigationRoutes(fastify) {
10
11
  const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
@@ -35,6 +36,7 @@ export async function navigationRoutes(fastify) {
35
36
  });
36
37
  }
37
38
  saveConfig('navigation', data);
39
+ await cache.invalidateTags(['nav']);
38
40
  return { success: true };
39
41
  });
40
42
  }
@@ -22,7 +22,7 @@ export async function pagesRoutes(fastify) {
22
22
  fastify.post('/pages/preview', canRead, async (request, reply) => {
23
23
  const {markdown} = request.body;
24
24
  if (typeof markdown !== 'string') return reply.status(400).send({error: 'markdown must be a string'});
25
- const {html} = await parseMarkdown(`---\ntitle: preview\n---\n${markdown}`);
25
+ const {html} = await parseMarkdown(`---\ntitle: preview\n---\n${markdown}`, {user: request.user || null});
26
26
  return {html};
27
27
  });
28
28
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Projects API
3
+ *
4
+ * GET /api/projects — list visible projects
5
+ * GET /api/projects/:slug — full project
6
+ * POST /api/projects — create
7
+ * PUT /api/projects/:slug — edit (slug immutable)
8
+ * DELETE /api/projects/:slug — delete (409 if any artefacts tagged)
9
+ * GET /api/projects/:slug/artefacts — grouped artefacts
10
+ * POST /api/projects/:slug/untag-all — escape hatch before delete
11
+ *
12
+ * Auth middlewares are accepted as DI options so tests can supply no-ops.
13
+ */
14
+ import {
15
+ authenticate as defaultAuthenticate,
16
+ requirePermission as defaultRequirePermission
17
+ } from '../../middleware/auth.js';
18
+ import {
19
+ createProject,
20
+ deleteProject,
21
+ getArtefactsForProject,
22
+ getProject,
23
+ listProjectsForUser,
24
+ untagAllForProject,
25
+ updateProject
26
+ } from '../../services/projects.js';
27
+ import * as cache from '../../services/cache/index.js';
28
+
29
+ /**
30
+ * Register the projects routes.
31
+ *
32
+ * @param {import('fastify').FastifyInstance} fastify
33
+ * @param {{authenticate?: Function, requirePermission?: Function}} [opts]
34
+ * @returns {Promise<void>}
35
+ */
36
+ export async function projectsRoutes(fastify, opts = {}) {
37
+ const authenticate = opts.authenticate || defaultAuthenticate;
38
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
39
+
40
+ const canRead = {preHandler: [authenticate, requirePermission('projects', 'read')]};
41
+ const canCreate = {preHandler: [authenticate, requirePermission('projects', 'create')]};
42
+ const canUpdate = {preHandler: [authenticate, requirePermission('projects', 'update')]};
43
+ const canDelete = {preHandler: [authenticate, requirePermission('projects', 'delete')]};
44
+
45
+ fastify.get('/projects', canRead, async (request) => {
46
+ return listProjectsForUser(request.user);
47
+ });
48
+
49
+ fastify.get('/projects/:slug', canRead, async (request, reply) => {
50
+ const project = await getProject(request.params.slug);
51
+ if (!project) return reply.status(404).send({error: 'Project not found'});
52
+ return project;
53
+ });
54
+
55
+ fastify.post('/projects', canCreate, async (request, reply) => {
56
+ try {
57
+ const project = await createProject(request.body || {});
58
+ await cache.invalidateTags(['projects']);
59
+ return reply.status(201).send(project);
60
+ } catch (err) {
61
+ const status = /already exists/.test(err.message) ? 409 : 400;
62
+ return reply.status(status).send({error: err.message});
63
+ }
64
+ });
65
+
66
+ fastify.put('/projects/:slug', canUpdate, async (request, reply) => {
67
+ try {
68
+ const project = await updateProject(request.params.slug, request.body || {});
69
+ await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
70
+ return project;
71
+ } catch (err) {
72
+ const status = /not found/.test(err.message) ? 404 : 400;
73
+ return reply.status(status).send({error: err.message});
74
+ }
75
+ });
76
+
77
+ fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
78
+ const grouped = await getArtefactsForProject(request.params.slug);
79
+ const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
80
+ if (total > 0) {
81
+ return reply.status(409).send({
82
+ error: `Cannot delete — ${total} artefacts still tagged with this project`,
83
+ artefacts: Object.fromEntries(Object.entries(grouped).map(([k, v]) => [k, v.length]))
84
+ });
85
+ }
86
+ const ok = await deleteProject(request.params.slug);
87
+ if (!ok) return reply.status(404).send({error: 'Project not found'});
88
+ await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
89
+ return {success: true};
90
+ });
91
+
92
+ fastify.get('/projects/:slug/artefacts', canRead, async (request, reply) => {
93
+ if (!(await getProject(request.params.slug))) {
94
+ return reply.status(404).send({error: 'Project not found'});
95
+ }
96
+ return getArtefactsForProject(request.params.slug);
97
+ });
98
+
99
+ fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
100
+ if (!(await getProject(request.params.slug))) {
101
+ return reply.status(404).send({error: 'Project not found'});
102
+ }
103
+ const counts = await untagAllForProject(request.params.slug);
104
+ await cache.invalidateTags(['projects', `project:${request.params.slug}`]);
105
+ return {success: true, untagged: counts};
106
+ });
107
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Scaffold API — list bundled recipes and apply one to create a working CRUD
3
+ * system (Collection + Form + Actions) in a single call.
4
+ *
5
+ * Admin-only — these endpoints can create resources, so they require the same
6
+ * permission tier as collection/form management. The `apply` endpoint is
7
+ * non-atomic across recipe pieces but returns a clear summary of what was
8
+ * created vs. skipped so the caller can clean up if needed.
9
+ *
10
+ * Endpoints:
11
+ * GET /api/scaffold/recipes — list bundled recipes (name, description, options)
12
+ * POST /api/scaffold/apply — apply one recipe with caller-provided option overrides
13
+ */
14
+ import {applyRecipe, getRecipe, listRecipes} from '../../services/scaffolder.js';
15
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
16
+
17
+ export async function scaffoldRoutes(fastify) {
18
+ // Scaffolding creates collections + forms + actions, so we require both
19
+ // collections.create and forms.create permissions. The cheap check is to
20
+ // gate on collections.create — anyone with that should already be a
21
+ // privileged role; we add forms.create on top as a belt-and-braces.
22
+ const canScaffold = {preHandler: [authenticate, requirePermission('collections', 'create')]};
23
+
24
+ /*
25
+ * GET /api/scaffold/recipes
26
+ *
27
+ * Returns the list of bundled recipes available to scaffold. Each entry
28
+ * exposes just enough metadata for the picker UI to render: name,
29
+ * description, icon, and the options schema (with defaults and hints).
30
+ *
31
+ * Response: { recipes: [{slug, name, description, icon, options}, ...] }
32
+ */
33
+ fastify.get('/scaffold/recipes', canScaffold, async () => {
34
+ const recipes = await listRecipes();
35
+ return { recipes };
36
+ });
37
+
38
+ /*
39
+ * GET /api/scaffold/recipes/:slug
40
+ *
41
+ * Returns the full recipe document — useful for a preview view that wants
42
+ * to show the user exactly what will be created before they click Apply.
43
+ */
44
+ fastify.get('/scaffold/recipes/:slug', canScaffold, async (request, reply) => {
45
+ const recipe = await getRecipe(request.params.slug);
46
+ if (!recipe) return reply.status(404).send({ error: 'Recipe not found' });
47
+ return recipe;
48
+ });
49
+
50
+ /*
51
+ * POST /api/scaffold/apply
52
+ *
53
+ * Body: { recipe: <slug>, options?: { collectionSlug, formSlug, actionPrefix, ... } }
54
+ *
55
+ * Pre-flight: refuses if any target slug already exists (HTTP 409 with the
56
+ * conflict list in `conflicts`). On success, returns a summary describing
57
+ * what got created, what was skipped (e.g. Mongo-gated actions in file
58
+ * mode), and any non-fatal warnings.
59
+ *
60
+ * Response:
61
+ * { created: {collection, form, actions: [...]}, skipped: [...], warnings: [...], snippet: string|null }
62
+ *
63
+ * The `snippet` is a Markdown fragment the caller can insert at the
64
+ * cursor position in the page editor — typically the [form] + [collection]
65
+ * combination needed to see the new system on a page immediately.
66
+ */
67
+ fastify.post('/scaffold/apply', canScaffold, async (request, reply) => {
68
+ const {recipe, options} = request.body || {};
69
+ if (!recipe || typeof recipe !== 'string') {
70
+ return reply.status(400).send({ error: 'recipe slug is required' });
71
+ }
72
+
73
+ try {
74
+ const result = await applyRecipe(recipe, {
75
+ options: options || {},
76
+ createdBy: request.user?.id || null
77
+ });
78
+ return result;
79
+ } catch (err) {
80
+ if (err.statusCode === 409 && Array.isArray(err.conflicts)) {
81
+ return reply.status(409).send({ error: err.message, conflicts: err.conflicts });
82
+ }
83
+ return reply.status(400).send({ error: err.message });
84
+ }
85
+ });
86
+ }
@@ -10,6 +10,7 @@ import nodemailer from 'nodemailer';
10
10
  import fs from 'fs/promises';
11
11
  import path from 'path';
12
12
  import {fileURLToPath} from 'url';
13
+ import * as cache from '../../services/cache/index.js';
13
14
 
14
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
16
  const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
@@ -64,6 +65,7 @@ export async function settingsRoutes(fastify) {
64
65
  merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
65
66
  }
66
67
  saveConfig('site', merged);
68
+ await cache.invalidateTags(['site']);
67
69
  return { success: true };
68
70
  });
69
71
 
@@ -133,6 +135,7 @@ export async function settingsRoutes(fastify) {
133
135
  return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
134
136
  }
135
137
  await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
138
+ await cache.invalidateTags(['site']);
136
139
  return { success: true };
137
140
  });
138
141
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Admin sidebar API
3
+ *
4
+ * GET /api/sidebar/registered-items — plugin-registered sidebar items
5
+ *
6
+ * Auth middlewares are accepted as DI options so tests can supply no-ops.
7
+ */
8
+ import {
9
+ authenticate as defaultAuthenticate,
10
+ requirePermission as defaultRequirePermission
11
+ } from '../../middleware/auth.js';
12
+ import {getRegisteredSidebarItems} from '../../services/hooks.js';
13
+
14
+ export async function sidebarRoutes(fastify, opts = {}) {
15
+ const authenticate = opts.authenticate || defaultAuthenticate;
16
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
17
+
18
+ const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
19
+
20
+ fastify.get('/sidebar/registered-items', canRead, async () => {
21
+ return getRegisteredSidebarItems();
22
+ });
23
+ }
@@ -18,9 +18,13 @@ export async function usersRoutes(fastify) {
18
18
  const canDelete = [authenticate, requirePermission('users', 'delete')];
19
19
 
20
20
  // List all users
21
- fastify.get('/users', {preHandler: canRead}, async () => {
21
+ fastify.get('/users', {preHandler: canRead}, async (request) => {
22
+ const {canSeeArtefact} = await import('../../services/projects.js');
22
23
  const [users, profiles] = await Promise.all([listUsers(), listProfiles()]);
23
- return users.map(u => ({...u, profile: profiles.get(u.id) || {}}));
24
+ // User records carry meta.project directly; filter against the raw record
25
+ return users
26
+ .filter(u => canSeeArtefact(request.user, u))
27
+ .map(u => ({...u, profile: profiles.get(u.id) || {}}));
24
28
  });
25
29
 
26
30
  // Get single user (admin, manager, or the user themselves)
@@ -55,12 +59,22 @@ export async function usersRoutes(fastify) {
55
59
  }
56
60
 
57
61
  const targetRole = role || 'user';
58
- if (!canManageUser(request.user.role, targetRole)) {
62
+ // Pass the full actor object so multi-role privilege is evaluated;
63
+ // for the prospective target we only have a primary role here.
64
+ if (!canManageUser(request.user, targetRole)) {
59
65
  return reply.code(403).send({ error: 'You cannot create a user with that role' });
60
66
  }
67
+ // Additional-roles privilege guard — actor must be able to manage EVERY role
68
+ // they're trying to assign, not just the primary.
69
+ const requestedAdditional = Array.isArray(request.body?.additionalRoles) ? request.body.additionalRoles : [];
70
+ for (const r of requestedAdditional) {
71
+ if (!canManageUser(request.user, r)) {
72
+ return reply.code(403).send({ error: `You cannot assign the additional role "${r}"` });
73
+ }
74
+ }
61
75
 
62
76
  try {
63
- const user = await createUser({ name, email, password, role: targetRole });
77
+ const user = await createUser({ name, email, password, role: targetRole, additionalRoles: requestedAdditional });
64
78
  return reply.status(201).send(user);
65
79
  } catch (err) {
66
80
  return reply.status(409).send({ error: err.message });
@@ -75,15 +89,26 @@ export async function usersRoutes(fastify) {
75
89
  const target = await getUserById(id);
76
90
  if (!target) return reply.status(404).send({ error: 'User not found' });
77
91
 
78
- if (!canManageUser(actor.role, target.role)) {
92
+ if (!canManageUser(actor, target)) {
79
93
  return reply.code(403).send({ error: 'You cannot edit a user with that role' });
80
94
  }
81
95
 
82
96
  const updates = request.body || {};
83
97
  // Prevent role escalation beyond what the actor can manage
84
- if (updates.role && !canManageUser(actor.role, updates.role)) {
98
+ if (updates.role && !canManageUser(actor, updates.role)) {
85
99
  return reply.code(403).send({ error: 'You cannot assign that role' });
86
100
  }
101
+ // Same check for any additionalRoles being assigned
102
+ if (Array.isArray(updates.additionalRoles)) {
103
+ for (const r of updates.additionalRoles) {
104
+ if (!canManageUser(actor, r)) {
105
+ return reply.code(403).send({ error: `You cannot assign the additional role "${r}"` });
106
+ }
107
+ }
108
+ // Strip duplicates / primary collisions before persisting
109
+ const primary = updates.role || target.role;
110
+ updates.additionalRoles = updates.additionalRoles.filter(r => r && r !== primary);
111
+ }
87
112
 
88
113
  // Strip profile from core updates and handle separately
89
114
  const {profile, ...coreUpdates} = updates;
@@ -109,7 +134,7 @@ export async function usersRoutes(fastify) {
109
134
  const target = await getUserById(id);
110
135
  if (!target) return reply.status(404).send({ error: 'User not found' });
111
136
 
112
- if (!canManageUser(actor.role, target.role)) {
137
+ if (!canManageUser(actor, target)) {
113
138
  return reply.code(403).send({ error: 'You cannot delete a user with that role' });
114
139
  }
115
140
 
@@ -16,6 +16,7 @@
16
16
  import {createView, deleteView, executeView, getView, listViews, updateView} from '../../services/views.js';
17
17
  import {authenticate, requirePermission} from '../../middleware/auth.js';
18
18
  import {getRoleLevel} from '../../services/roles.js';
19
+ import {getEffectiveLevel} from '../../services/userRoles.js';
19
20
 
20
21
  export async function viewsRoutes(fastify) {
21
22
  const canRead = {preHandler: [authenticate, requirePermission('views', 'read')]};
@@ -29,7 +30,10 @@ export async function viewsRoutes(fastify) {
29
30
 
30
31
  fastify.get('/views', canRead, async (request, reply) => {
31
32
  try {
32
- return await listViews();
33
+ const {canSeeArtefact} = await import('../../services/projects.js');
34
+ const all = await listViews();
35
+ // listViews returns full records with meta inline — filter directly
36
+ return all.filter(v => canSeeArtefact(request.user, v));
33
37
  } catch (err) {
34
38
  return reply.status(503).send({ error: err.message });
35
39
  }
@@ -60,6 +64,10 @@ export async function viewsRoutes(fastify) {
60
64
  try {
61
65
  const view = await getView(request.params.slug);
62
66
  if (!view) return reply.status(404).send({ error: 'View not found' });
67
+ const {canSeeArtefact} = await import('../../services/projects.js');
68
+ if (!canSeeArtefact(request.user, view)) {
69
+ return reply.status(403).send({ error: 'Access denied for this project' });
70
+ }
63
71
  return view;
64
72
  } catch (err) {
65
73
  return reply.status(503).send({ error: err.message });
@@ -126,7 +134,7 @@ export async function viewsRoutes(fastify) {
126
134
 
127
135
  const user = request.user;
128
136
  const allowedRoles = view.access?.roles || ['admin', 'super-admin'];
129
- const userLevel = getRoleLevel(user?.role);
137
+ const userLevel = getEffectiveLevel(user);
130
138
  const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
131
139
 
132
140
  if (userLevel > minAllowed) {