domma-cms 0.10.0 → 0.13.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 (121) hide show
  1. package/CLAUDE.md +248 -159
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +7 -3
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/http-interceptor.js +1 -0
  7. package/admin/js/lib/safe-html.js +1 -0
  8. package/admin/js/templates/documentation.html +611 -2
  9. package/admin/js/templates/layouts.html +5 -4
  10. package/admin/js/templates/notifications.html +14 -0
  11. package/admin/js/templates/plugin-marketplace.html +16 -0
  12. package/admin/js/templates/plugins.html +17 -5
  13. package/admin/js/views/index.js +1 -1
  14. package/admin/js/views/layouts.js +1 -16
  15. package/admin/js/views/notifications.js +1 -0
  16. package/admin/js/views/plugin-marketplace.js +1 -0
  17. package/admin/js/views/plugins.js +16 -16
  18. package/config/navigation.json +5 -72
  19. package/config/plugins.json +10 -14
  20. package/config/presets.json +50 -13
  21. package/config/site.json +11 -63
  22. package/package.json +2 -1
  23. package/plugins/_template/admin/templates/index.html +17 -0
  24. package/plugins/_template/admin/views/index.js +19 -0
  25. package/plugins/_template/config.js +8 -0
  26. package/plugins/_template/plugin.js +23 -0
  27. package/plugins/_template/plugin.json +34 -0
  28. package/plugins/analytics/plugin.json +41 -31
  29. package/plugins/blog/admin/templates/blog.html +22 -0
  30. package/plugins/blog/admin/templates/categories.html +7 -0
  31. package/plugins/blog/admin/templates/comments.html +11 -0
  32. package/plugins/blog/admin/templates/post-editor.html +97 -0
  33. package/plugins/blog/admin/templates/settings.html +11 -0
  34. package/plugins/blog/admin/views/blog.js +183 -0
  35. package/plugins/blog/admin/views/categories.js +235 -0
  36. package/plugins/blog/admin/views/comments.js +187 -0
  37. package/plugins/blog/admin/views/post-editor.js +291 -0
  38. package/plugins/blog/admin/views/settings.js +100 -0
  39. package/plugins/blog/collections/categories/schema.json +12 -0
  40. package/plugins/blog/collections/comments/schema.json +16 -0
  41. package/plugins/blog/collections/posts/schema.json +19 -0
  42. package/plugins/blog/config.js +8 -0
  43. package/plugins/blog/plugin.js +352 -0
  44. package/plugins/blog/plugin.json +96 -0
  45. package/plugins/blog/roles/blog-author.json +10 -0
  46. package/plugins/blog/roles/blog-editor.json +12 -0
  47. package/plugins/blog/templates/author.html +9 -0
  48. package/plugins/blog/templates/category.html +9 -0
  49. package/plugins/blog/templates/index.html +9 -0
  50. package/plugins/blog/templates/post.html +17 -0
  51. package/plugins/blog/templates/tag.html +9 -0
  52. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  53. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  54. package/plugins/contacts/plugin.js +4 -10
  55. package/plugins/contacts/plugin.json +13 -3
  56. package/plugins/notes/collections/user-notes/schema.json +1 -1
  57. package/plugins/notes/plugin.js +3 -9
  58. package/plugins/notes/plugin.json +13 -3
  59. package/plugins/site-search/plugin.json +5 -2
  60. package/plugins/theme-switcher/plugin.json +1 -1
  61. package/plugins/todo/collections/todos/schema.json +1 -1
  62. package/plugins/todo/plugin.js +3 -9
  63. package/plugins/todo/plugin.json +13 -3
  64. package/public/css/site.css +1 -1
  65. package/scripts/build.js +48 -0
  66. package/scripts/create-plugin.js +113 -0
  67. package/scripts/fresh.js +6 -7
  68. package/scripts/gen-instance-secret.js +46 -0
  69. package/scripts/reset.js +3 -3
  70. package/scripts/setup.js +31 -13
  71. package/server/middleware/auth.js +48 -0
  72. package/server/middleware/managerAuth.js +36 -0
  73. package/server/routes/api/actions.js +1 -1
  74. package/server/routes/api/auth.js +4 -3
  75. package/server/routes/api/layouts.js +173 -49
  76. package/server/routes/api/notifications.js +155 -0
  77. package/server/routes/api/plugin-marketplace.js +75 -0
  78. package/server/routes/api/users.js +1 -1
  79. package/server/routes/api/views.js +1 -1
  80. package/server/routes/public.js +4 -9
  81. package/server/server.js +32 -3
  82. package/server/services/actions.js +1 -1
  83. package/server/services/managerClient.js +182 -0
  84. package/server/services/markdown.js +52 -14
  85. package/server/services/permissionRegistry.js +245 -173
  86. package/server/services/pluginInstaller.js +301 -0
  87. package/server/services/plugins.js +117 -10
  88. package/server/services/presetCollections.js +66 -251
  89. package/server/services/renderer.js +99 -0
  90. package/server/services/roles.js +191 -39
  91. package/server/services/users.js +1 -1
  92. package/server/services/views.js +1 -1
  93. package/server/templates/page.html +2 -2
  94. package/plugins/docs/admin/templates/docs.html +0 -69
  95. package/plugins/docs/admin/views/docs.js +0 -276
  96. package/plugins/docs/config.js +0 -8
  97. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  98. package/plugins/docs/data/folders.json +0 -9
  99. package/plugins/docs/data/templates.json +0 -1
  100. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  101. package/plugins/docs/plugin.js +0 -375
  102. package/plugins/docs/plugin.json +0 -23
  103. package/plugins/form-builder/data/forms/contacts.json +0 -66
  104. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  105. package/plugins/form-builder/data/forms/feedback.json +0 -131
  106. package/plugins/form-builder/data/forms/notes.json +0 -79
  107. package/plugins/form-builder/data/forms/to-do.json +0 -100
  108. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  109. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  110. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  111. package/plugins/form-builder/data/submissions/notes.json +0 -1
  112. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  113. package/plugins/garage/admin/templates/garage.html +0 -111
  114. package/plugins/garage/admin/views/garage.js +0 -622
  115. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  116. package/plugins/garage/config.js +0 -18
  117. package/plugins/garage/data/vehicles.json +0 -70
  118. package/plugins/garage/plugin.js +0 -398
  119. package/plugins/garage/plugin.json +0 -33
  120. package/scripts/seed.js +0 -1996
  121. package/server/services/userTypes.js +0 -227
@@ -125,6 +125,54 @@ export function canManageUser(actorRole, targetRole) {
125
125
  return getRoleLevel(actorRole) < getRoleLevel(targetRole);
126
126
  }
127
127
 
128
+ /**
129
+ * Check whether a user role satisfies a visibility requirement.
130
+ * Used by both requireVisibility() and the public page renderer.
131
+ *
132
+ * @param {string|null} userRole - The visitor's role, or null if unauthenticated
133
+ * @param {string} visibility - Required visibility ('public', 'private', or a role name)
134
+ * @returns {boolean} true if access is granted
135
+ */
136
+ export function checkVisibility(userRole, visibility) {
137
+ if (!visibility || visibility === 'public') return true;
138
+ if (!userRole) return false; // unauthenticated, non-public page
139
+ const userLevel = getRoleLevel(userRole);
140
+ const requiredLevel = getRoleLevel(visibility);
141
+ const threshold = requiredLevel === Infinity ? 0 : requiredLevel;
142
+ return userLevel <= threshold;
143
+ }
144
+
145
+ /**
146
+ * Fastify preHandler factory — gates a route by visibility level.
147
+ * Works identically to the content-page visibility system.
148
+ * Returns a no-op for 'public' so it is safe to apply unconditionally.
149
+ *
150
+ * @param {string} visibility - 'public' | 'private' | any role name
151
+ * @returns {Function} Fastify preHandler
152
+ */
153
+ export function requireVisibility(visibility) {
154
+ if (!visibility || visibility === 'public') {
155
+ return (_request, _reply, done) => { if (done) done(); };
156
+ }
157
+
158
+ return async (request, reply) => {
159
+ let userRole = null;
160
+ try {
161
+ const decoded = await request.jwtVerify();
162
+ if (decoded.type === 'access') userRole = decoded.role;
163
+ } catch { /* unauthenticated */ }
164
+
165
+ if (!checkVisibility(userRole, visibility)) {
166
+ const code = userRole ? 403 : 401;
167
+ return reply.code(code).send({
168
+ statusCode: code,
169
+ error: code === 403 ? 'Forbidden' : 'Unauthorised',
170
+ message: code === 403 ? 'Insufficient role for this resource' : 'Authentication required'
171
+ });
172
+ }
173
+ };
174
+ }
175
+
128
176
  /**
129
177
  * Return role names ordered from most to least privileged.
130
178
  * Computed from the roles cache.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Manager auth middleware — shared-secret guard for inbound requests from domma-cms-manager.
3
+ * Completely separate from JWT authenticate. Do NOT mix these two auth surfaces.
4
+ */
5
+ import { timingSafeEqual, createHmac } from 'crypto';
6
+
7
+ /**
8
+ * Fastify preHandler — validates the X-Manager-Token or Authorization: Bearer header
9
+ * against the MANAGER_SECRET environment variable using timing-safe comparison.
10
+ *
11
+ * @param {import('fastify').FastifyRequest} request
12
+ * @param {import('fastify').FastifyReply} reply
13
+ * @param {Function} done
14
+ */
15
+ export function requireManager(request, reply, done) {
16
+ const secret = process.env.MANAGER_SECRET;
17
+
18
+ if (!secret || secret.length < 32) {
19
+ return reply.code(503).send({ error: 'Manager push notifications are not configured on this server.' });
20
+ }
21
+
22
+ const authHeader = request.headers['x-manager-token'] || request.headers['authorization'] || '';
23
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader;
24
+
25
+ if (!token) {
26
+ return reply.code(401).send({ error: 'Manager token required.' });
27
+ }
28
+
29
+ // Compare HMAC digests — always 32 bytes regardless of input length, preventing timing oracle
30
+ const digest = (s) => createHmac('sha256', 'domma-cms').update(s).digest();
31
+ if (!timingSafeEqual(digest(token), digest(secret))) {
32
+ return reply.code(403).send({ error: 'Invalid manager token.' });
33
+ }
34
+
35
+ done();
36
+ }
@@ -178,7 +178,7 @@ export async function actionsRoutes(fastify) {
178
178
  }
179
179
 
180
180
  const user = request.user;
181
- const allowedRoles = action.access?.roles || ['admin'];
181
+ const allowedRoles = action.access?.roles || ['admin', 'super-admin'];
182
182
  const userLevel = getRoleLevel(user?.role);
183
183
  const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
184
184
 
@@ -11,6 +11,7 @@ import crypto from 'node:crypto';
11
11
  import {config, getConfig} from '../../config.js';
12
12
  import {hooks} from '../../services/hooks.js';
13
13
  import {authenticate, getPermissionsForRole} from '../../middleware/auth.js';
14
+ import {getRoleLevel} from '../../services/roles.js';
14
15
  import {GROUP_ORDER, REGISTRY} from '../../services/permissionRegistry.js';
15
16
  import {
16
17
  clearResetToken,
@@ -80,7 +81,7 @@ export async function authRoutes(fastify) {
80
81
 
81
82
  setupInProgress = true;
82
83
  try {
83
- const user = await createUser({name, email, password, role: 'admin'});
84
+ const user = await createUser({name, email, password, role: 'super-admin'});
84
85
  const {token, refreshToken} = signTokens(fastify, user);
85
86
  return reply.status(201).send({token, refreshToken, user});
86
87
  } finally {
@@ -107,7 +108,7 @@ export async function authRoutes(fastify) {
107
108
 
108
109
  await touchLastLogin(user.id);
109
110
 
110
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
111
+ const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, level: getRoleLevel(user.role) };
111
112
  hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
112
113
  const { token, refreshToken } = signTokens(fastify, safeUser);
113
114
  return { token, refreshToken, user: safeUser };
@@ -270,7 +271,7 @@ export async function authRoutes(fastify) {
270
271
  return reply.status(401).send({ error: 'User not found or inactive' });
271
272
  }
272
273
 
273
- const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role };
274
+ const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, level: getRoleLevel(user.role) };
274
275
  const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
275
276
  return { token };
276
277
  });
@@ -1,49 +1,173 @@
1
- /**
2
- * Layouts (Presets) API
3
- * GET /api/layouts - get all layout presets
4
- * PUT /api/layouts - save layout presets
5
- */
6
- import {getConfig, saveConfig} from '../../config.js';
7
- import {authenticate, requirePermission} from '../../middleware/auth.js';
8
-
9
- export async function layoutsRoutes(fastify) {
10
- const canRead = {preHandler: [authenticate, requirePermission('layouts', 'read')]};
11
- const canUpdate = {preHandler: [authenticate, requirePermission('layouts', 'update')]};
12
-
13
- fastify.get('/layouts', canRead, async () => {
14
- return getConfig('presets');
15
- });
16
-
17
- fastify.put('/layouts', canUpdate, async (request, reply) => {
18
- const data = request.body;
19
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
20
- return reply.status(400).send({ error: 'Invalid presets data: expected an object' });
21
- }
22
- // Each preset value must be an object with at least a name field
23
- for (const [key, preset] of Object.entries(data)) {
24
- if (!preset || typeof preset !== 'object' || Array.isArray(preset)) {
25
- return reply.status(400).send({ error: `Invalid preset "${key}": expected an object` });
26
- }
27
- if (typeof preset.name !== 'string' || !preset.name.trim()) {
28
- return reply.status(400).send({ error: `Invalid preset "${key}": missing or empty "name" field` });
29
- }
30
- }
31
- saveConfig('presets', data);
32
- return { success: true };
33
- });
34
-
35
- fastify.get('/layouts/options', canRead, async () => {
36
- return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
37
- });
38
-
39
- fastify.put('/layouts/options', canUpdate, async (request, reply) => {
40
- const data = request.body;
41
- if (!data || typeof data !== 'object') {
42
- return reply.status(400).send({error: 'Invalid layout options'});
43
- }
44
- const site = getConfig('site');
45
- site.layoutOptions = {...site.layoutOptions, ...data};
46
- saveConfig('site', site);
47
- return {success: true};
48
- });
49
- }
1
+ /**
2
+ * Layouts (Presets) API
3
+ * GET /api/layouts - get all layout presets
4
+ * PUT /api/layouts - bulk-save layout presets (legacy, kept for compatibility)
5
+ * POST /api/layouts - create a new preset
6
+ * PUT /api/layouts/:key - update a single preset
7
+ * DELETE /api/layouts/:key - delete a preset (builtin presets cannot be deleted)
8
+ * GET /api/layouts/options - get layout options
9
+ * PUT /api/layouts/options - save layout options
10
+ */
11
+ import {getConfig, saveConfig} from '../../config.js';
12
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
13
+
14
+ const VALID_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
15
+ const BG_COLOR_RE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
16
+ const CLASS_RE = /[^a-zA-Z0-9\s_-]/g;
17
+
18
+ const BUILTIN_PRESETS = {
19
+ default: {
20
+ key: 'default', label: 'Default',
21
+ description: 'Standard page with navbar and footer.',
22
+ builtin: true, navbar: true, footer: true, sidebar: false,
23
+ width: 'normal', bgColor: '', bgImage: '', class: ''
24
+ },
25
+ landing: {
26
+ key: 'landing', label: 'Landing Page',
27
+ description: 'Full-width landing page with navbar, no footer.',
28
+ builtin: true, navbar: true, footer: false, sidebar: false,
29
+ width: 'full', bgColor: '', bgImage: '', class: ''
30
+ },
31
+ blank: {
32
+ key: 'blank', label: 'Blank',
33
+ description: 'Minimal page with no navbar or footer.',
34
+ builtin: true, navbar: false, footer: false, sidebar: false,
35
+ width: 'normal', bgColor: '', bgImage: '', class: ''
36
+ }
37
+ };
38
+
39
+ function slugify(str) {
40
+ return str.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
41
+ }
42
+
43
+ function sanitisePreset(data) {
44
+ return {
45
+ navbar: data.navbar !== false,
46
+ footer: data.footer !== false,
47
+ sidebar: data.sidebar === true,
48
+ width: VALID_WIDTHS.has(data.width) ? data.width : 'normal',
49
+ bgColor: data.bgColor && BG_COLOR_RE.test(data.bgColor) ? data.bgColor : '',
50
+ bgImage: typeof data.bgImage === 'string' ? data.bgImage.trim() : '',
51
+ class: typeof data.class === 'string' ? data.class.replace(CLASS_RE, '').trim() : '',
52
+ label: String(data.label || '').slice(0, 60).trim(),
53
+ description: String(data.description || '').slice(0, 200).trim()
54
+ };
55
+ }
56
+
57
+ export async function layoutsRoutes(fastify) {
58
+ // ── Merge-in seeding: ensure built-in presets exist ──────────────────────
59
+ const existing = getConfig('presets') || {};
60
+ let seeded = false;
61
+ for (const [key, preset] of Object.entries(BUILTIN_PRESETS)) {
62
+ if (!existing[key]) {
63
+ existing[key] = preset;
64
+ seeded = true;
65
+ }
66
+ }
67
+ if (seeded) saveConfig('presets', existing);
68
+
69
+ const canRead = {preHandler: [authenticate, requirePermission('layouts', 'read')]};
70
+ const canUpdate = {preHandler: [authenticate, requirePermission('layouts', 'update')]};
71
+
72
+ // ── Existing routes ───────────────────────────────────────────────────────
73
+ fastify.get('/layouts', canRead, async () => {
74
+ return getConfig('presets');
75
+ });
76
+
77
+ fastify.put('/layouts', canUpdate, async (request, reply) => {
78
+ const data = request.body;
79
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
80
+ return reply.status(400).send({error: 'Invalid presets data: expected an object'});
81
+ }
82
+ for (const [key, preset] of Object.entries(data)) {
83
+ if (!preset || typeof preset !== 'object' || Array.isArray(preset)) {
84
+ return reply.status(400).send({error: `Invalid preset "${key}": expected an object`});
85
+ }
86
+ if (typeof preset.label !== 'string' || !preset.label.trim()) {
87
+ return reply.status(400).send({error: `Invalid preset "${key}": missing or empty "label" field`});
88
+ }
89
+ }
90
+ saveConfig('presets', data);
91
+ return {success: true};
92
+ });
93
+
94
+ // ── New CRUD routes ───────────────────────────────────────────────────────
95
+ fastify.post('/layouts', canUpdate, async (request, reply) => {
96
+ const {label, description, ...rest} = request.body || {};
97
+ if (!label || !String(label).trim()) {
98
+ return reply.status(400).send({error: 'label is required'});
99
+ }
100
+ const key = slugify(label);
101
+ if (!key) {
102
+ return reply.status(400).send({error: 'label must contain at least one alphanumeric character'});
103
+ }
104
+ const presets = getConfig('presets') || {};
105
+ if (presets[key]) {
106
+ return reply.status(409).send({error: 'A preset with this key already exists'});
107
+ }
108
+ presets[key] = {
109
+ key,
110
+ builtin: false,
111
+ ...sanitisePreset({label, description, ...rest})
112
+ };
113
+ saveConfig('presets', presets);
114
+ return {success: true, key, preset: presets[key]};
115
+ });
116
+
117
+ fastify.put('/layouts/:key', canUpdate, async (request, reply) => {
118
+ const {key} = request.params;
119
+ const presets = getConfig('presets') || {};
120
+ if (!presets[key]) {
121
+ return reply.status(404).send({error: `Preset "${key}" not found`});
122
+ }
123
+ const {label, description, ...rest} = request.body || {};
124
+ if (!label || !String(label).trim()) {
125
+ return reply.status(400).send({error: 'label is required'});
126
+ }
127
+ presets[key] = {
128
+ ...presets[key],
129
+ label: String(label).slice(0, 60).trim(),
130
+ description: String(description || '').slice(0, 200).trim(),
131
+ ...sanitisePreset({label, description, ...rest}),
132
+ key,
133
+ builtin: presets[key].builtin // never overwrite builtin flag
134
+ };
135
+ saveConfig('presets', presets);
136
+ return {success: true, preset: presets[key]};
137
+ });
138
+
139
+ fastify.delete('/layouts/:key', canUpdate, async (request, reply) => {
140
+ const {key} = request.params;
141
+ const presets = getConfig('presets') || {};
142
+ if (!presets[key]) {
143
+ return reply.status(404).send({error: `Preset "${key}" not found`});
144
+ }
145
+ if (presets[key].builtin) {
146
+ return reply.status(400).send({error: 'Built-in presets cannot be deleted'});
147
+ }
148
+ delete presets[key];
149
+ saveConfig('presets', presets);
150
+ return {success: true};
151
+ });
152
+
153
+ // ── Layout options ────────────────────────────────────────────────────────
154
+ fastify.get('/layouts/options', canRead, async () => {
155
+ return getConfig('site')?.layoutOptions ?? {spacerSize: 8};
156
+ });
157
+
158
+ fastify.put('/layouts/options', canUpdate, async (request, reply) => {
159
+ const data = request.body;
160
+ if (!data || typeof data !== 'object') {
161
+ return reply.status(400).send({error: 'Invalid layout options'});
162
+ }
163
+ const site = getConfig('site');
164
+ const {spacerSize, spacerClass} = data;
165
+ site.layoutOptions = {
166
+ ...site.layoutOptions,
167
+ ...(spacerSize !== undefined ? {spacerSize: Math.max(0, Math.min(500, parseInt(spacerSize, 10) || 0))} : {}),
168
+ ...(spacerClass !== undefined ? {spacerClass: String(spacerClass).replace(/[^a-zA-Z0-9\s_-]/g, '').trim()} : {})
169
+ };
170
+ saveConfig('site', site);
171
+ return {success: true};
172
+ });
173
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Notifications API
3
+ * POST /api/system/notifications - Manager push (requireManager)
4
+ * GET /api/system/notifications - List active notifications (admin/manager)
5
+ * GET /api/system/notifications/unread-count - Unread count for bell badge
6
+ * POST /api/system/notifications/:id/read - Mark read (idempotent)
7
+ * POST /api/system/notifications/:id/dismiss - Mark dismissed
8
+ * DELETE /api/system/notifications/:id - Hard delete (admin only)
9
+ */
10
+ import { authenticate, requirePermission } from '../../middleware/auth.js';
11
+ import { requireManager } from '../../middleware/managerAuth.js';
12
+ import { getAdapter } from '../../services/adapterRegistry.js';
13
+
14
+ const SLUG = 'notifications';
15
+
16
+ export async function notificationsRoutes(fastify) {
17
+ const canRead = { preHandler: [authenticate, requirePermission('notifications', 'read')] };
18
+ const canDelete = { preHandler: [authenticate, requirePermission('notifications', 'delete')] };
19
+
20
+ // --- Manager inbound push ---
21
+ fastify.post('/system/notifications', { preHandler: [requireManager] }, async (request, reply) => {
22
+ const { title, body, severity = 'info', link, expiresAt } = request.body || {};
23
+
24
+ if (!title || typeof title !== 'string' || !title.trim()) {
25
+ return reply.code(400).send({ error: 'title is required.' });
26
+ }
27
+ if (!body || typeof body !== 'string' || !body.trim()) {
28
+ return reply.code(400).send({ error: 'body is required.' });
29
+ }
30
+ const VALID_SEVERITIES = ['info', 'success', 'warning', 'critical'];
31
+ if (!VALID_SEVERITIES.includes(severity)) {
32
+ return reply.code(400).send({ error: `severity must be one of: ${VALID_SEVERITIES.join(', ')}.` });
33
+ }
34
+
35
+ if (link !== undefined && link !== null && link !== '') {
36
+ if (typeof link !== 'string' || !/^https?:\/\//i.test(link)) {
37
+ return reply.code(400).send({ error: 'link must be an absolute https:// or http:// URL.' });
38
+ }
39
+ }
40
+
41
+ if (expiresAt !== undefined && expiresAt !== null && expiresAt !== '') {
42
+ if (typeof expiresAt !== 'string' || isNaN(Date.parse(expiresAt))) {
43
+ return reply.code(400).send({ error: 'expiresAt must be a valid ISO 8601 datetime string.' });
44
+ }
45
+ }
46
+
47
+ const adapter = await getAdapter(SLUG);
48
+ const entry = await adapter.insert(SLUG, {
49
+ title: title.trim(),
50
+ body: body.trim(),
51
+ severity,
52
+ source: 'manager',
53
+ link: link || null,
54
+ createdAt: new Date().toISOString(),
55
+ expiresAt: expiresAt || null,
56
+ readBy: [],
57
+ dismissedBy: []
58
+ });
59
+
60
+ return reply.code(201).send(entry);
61
+ });
62
+
63
+ // --- List active notifications for current user ---
64
+ fastify.get('/system/notifications', canRead, async (request) => {
65
+ const userId = request.user.id;
66
+ const now = new Date().toISOString();
67
+ const adapter = await getAdapter(SLUG);
68
+ const all = await adapter.all(SLUG);
69
+
70
+ return all
71
+ .filter(entry => {
72
+ const d = entry.data;
73
+ // Exclude expired
74
+ if (d.expiresAt && d.expiresAt < now) return false;
75
+ // Exclude dismissed by this user
76
+ if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
77
+ return true;
78
+ })
79
+ .sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))
80
+ .map(entry => ({
81
+ ...entry,
82
+ unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)
83
+ }));
84
+ });
85
+
86
+ // --- Unread count (lightweight — for bell badge) ---
87
+ fastify.get('/system/notifications/unread-count', canRead, async (request) => {
88
+ const userId = request.user.id;
89
+ const now = new Date().toISOString();
90
+ const adapter = await getAdapter(SLUG);
91
+ const all = await adapter.all(SLUG);
92
+
93
+ const count = all.filter(entry => {
94
+ const d = entry.data;
95
+ if (d.expiresAt && d.expiresAt < now) return false;
96
+ if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
97
+ return !Array.isArray(d.readBy) || !d.readBy.includes(userId);
98
+ }).length;
99
+
100
+ return { count };
101
+ });
102
+
103
+ // --- Mark read (idempotent) ---
104
+ fastify.post('/system/notifications/:id/read', canRead, async (request, reply) => {
105
+ const { id } = request.params;
106
+ const userId = request.user.id;
107
+ const adapter = await getAdapter(SLUG);
108
+ const entry = await adapter.get(SLUG, id);
109
+
110
+ if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
111
+
112
+ const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
113
+ if (readBy.includes(userId)) return { ok: true }; // already read — idempotent
114
+
115
+ const updated = await adapter.update(SLUG, id, {
116
+ ...entry.data,
117
+ readBy: [...readBy, userId]
118
+ });
119
+
120
+ return { ok: true, entry: updated };
121
+ });
122
+
123
+ // --- Dismiss ---
124
+ fastify.post('/system/notifications/:id/dismiss', canRead, async (request, reply) => {
125
+ const { id } = request.params;
126
+ const userId = request.user.id;
127
+ const adapter = await getAdapter(SLUG);
128
+ const entry = await adapter.get(SLUG, id);
129
+
130
+ if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
131
+
132
+ const dismissedBy = Array.isArray(entry.data.dismissedBy) ? entry.data.dismissedBy : [];
133
+ const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
134
+
135
+ const updated = await adapter.update(SLUG, id, {
136
+ ...entry.data,
137
+ dismissedBy: dismissedBy.includes(userId) ? dismissedBy : [...dismissedBy, userId],
138
+ readBy: readBy.includes(userId) ? readBy : [...readBy, userId] // dismissing also marks read
139
+ });
140
+
141
+ return { ok: true, entry: updated };
142
+ });
143
+
144
+ // --- Hard delete (admin only) ---
145
+ fastify.delete('/system/notifications/:id', canDelete, async (request, reply) => {
146
+ const { id } = request.params;
147
+ const adapter = await getAdapter(SLUG);
148
+ const entry = await adapter.get(SLUG, id);
149
+
150
+ if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
151
+
152
+ await adapter.remove(SLUG, id);
153
+ return { ok: true };
154
+ });
155
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Plugin Marketplace API
3
+ * GET /api/plugins/marketplace — fetch catalogue from manager (requires MANAGER_URL)
4
+ * POST /api/plugins/marketplace/install — download + install a plugin bundle
5
+ * DELETE /api/plugins/marketplace/:slug — uninstall an installed plugin
6
+ *
7
+ * All routes require authentication + admin role.
8
+ */
9
+ import {authenticate, requireAdmin} from '../../middleware/auth.js';
10
+ import {fetchCatalogue, fetchPluginBundle} from '../../services/managerClient.js';
11
+ import {installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
12
+
13
+ export async function pluginMarketplaceRoutes(fastify) {
14
+ const adminOnly = { preHandler: [authenticate, requireAdmin] };
15
+
16
+ // GET /plugins/marketplace
17
+ fastify.get('/plugins/marketplace', adminOnly, async (_request, reply) => {
18
+ if (!process.env.MANAGER_URL) {
19
+ return reply.send({ available: false, catalogue: [] });
20
+ }
21
+
22
+ const licenceToken = process.env.LICENCE_TOKEN;
23
+ const catalogue = await fetchCatalogue(licenceToken);
24
+ return reply.send({ available: true, catalogue });
25
+ });
26
+
27
+ // POST /plugins/marketplace/install
28
+ fastify.post('/plugins/marketplace/install', adminOnly, async (request, reply) => {
29
+ const { slug, version } = request.body || {};
30
+
31
+ if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
32
+ return reply.code(400).send({ error: 'Invalid plugin slug.' });
33
+ }
34
+ if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
35
+ return reply.code(400).send({ error: 'Invalid version format. Expected semver (e.g. 1.0.0).' });
36
+ }
37
+
38
+ const licenceToken = process.env.LICENCE_TOKEN;
39
+ const bundle = await fetchPluginBundle(slug, version, licenceToken);
40
+
41
+ if (!bundle) {
42
+ return reply.code(503).send({ error: 'Manager unavailable' });
43
+ }
44
+
45
+ try {
46
+ await installPlugin(slug, bundle);
47
+ } catch (err) {
48
+ fastify.log.error({ err }, '[marketplace] install failed');
49
+ // Known safe error messages from pluginInstaller (for admin clarity)
50
+ const safeMessages = ['Plugin already installed', 'Plugin signature invalid', 'Unknown public key', 'Invalid plugin slug'];
51
+ const safe = safeMessages.some(m => err.message.startsWith(m));
52
+ return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
53
+ }
54
+
55
+ return reply.send({ success: true });
56
+ });
57
+
58
+ // DELETE /plugins/marketplace/:slug
59
+ fastify.delete('/plugins/marketplace/:slug', adminOnly, async (request, reply) => {
60
+ const { slug } = request.params;
61
+
62
+ if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
63
+ return reply.code(400).send({ error: 'Invalid plugin slug.' });
64
+ }
65
+
66
+ try {
67
+ await uninstallPlugin(slug);
68
+ } catch (err) {
69
+ fastify.log.error({ err }, '[marketplace] uninstall failed');
70
+ return reply.code(400).send({ error: 'Uninstall failed.' });
71
+ }
72
+
73
+ return reply.send({ success: true });
74
+ });
75
+ }
@@ -54,7 +54,7 @@ export async function usersRoutes(fastify) {
54
54
  return reply.status(400).send({ error: 'Password must be at least 8 characters' });
55
55
  }
56
56
 
57
- const targetRole = role || 'editor';
57
+ const targetRole = role || 'user';
58
58
  if (!canManageUser(request.user.role, targetRole)) {
59
59
  return reply.code(403).send({ error: 'You cannot create a user with that role' });
60
60
  }
@@ -125,7 +125,7 @@ export async function viewsRoutes(fastify) {
125
125
  }
126
126
 
127
127
  const user = request.user;
128
- const allowedRoles = view.access?.roles || ['admin'];
128
+ const allowedRoles = view.access?.roles || ['admin', 'super-admin'];
129
129
  const userLevel = getRoleLevel(user?.role);
130
130
  const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
131
131
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import {getPage} from '../services/content.js';
8
8
  import {renderPage} from '../services/renderer.js';
9
- import {getRoleLevel} from '../services/roles.js';
9
+ import {checkVisibility} from '../middleware/auth.js';
10
10
  import {hooks} from '../services/hooks.js';
11
11
 
12
12
  /**
@@ -69,15 +69,10 @@ export async function publicRoutes(fastify) {
69
69
  let userRole = null;
70
70
  try {
71
71
  const decoded = await request.jwtVerify();
72
- userRole = decoded.role;
73
- } catch { /* no token — treat as unauthenticated */
74
- }
75
-
76
- const userLevel = getRoleLevel(userRole);
77
- const visibilityLevel = getRoleLevel(page.visibility);
78
- const requiredLevel = visibilityLevel === Infinity ? 0 : visibilityLevel;
72
+ if (decoded.type === 'access') userRole = decoded.role;
73
+ } catch { /* no token — treat as unauthenticated */ }
79
74
 
80
- if (userLevel > requiredLevel) {
75
+ if (!checkVisibility(userRole, page.visibility)) {
81
76
  reply.status(403);
82
77
  return reply.type('text/html').send(accessDeniedHtml(urlPath));
83
78
  }