domma-cms 0.9.10 → 0.12.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 (125) 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/card-builder.js +2 -2
  8. package/admin/js/lib/markdown-toolbar.js +5 -5
  9. package/admin/js/lib/safe-html.js +1 -0
  10. package/admin/js/lib/shortcode-modal.js +1 -0
  11. package/admin/js/templates/layouts.html +5 -4
  12. package/admin/js/templates/notifications.html +14 -0
  13. package/admin/js/templates/plugin-marketplace.html +16 -0
  14. package/admin/js/templates/plugins.html +17 -5
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/layouts.js +1 -16
  17. package/admin/js/views/notifications.js +1 -0
  18. package/admin/js/views/page-editor.js +37 -33
  19. package/admin/js/views/plugin-marketplace.js +1 -0
  20. package/admin/js/views/plugins.js +16 -16
  21. package/config/navigation.json +5 -72
  22. package/config/plugins.json +10 -14
  23. package/config/presets.json +50 -13
  24. package/config/site.json +11 -63
  25. package/package.json +2 -1
  26. package/plugins/_template/admin/templates/index.html +17 -0
  27. package/plugins/_template/admin/views/index.js +19 -0
  28. package/plugins/_template/config.js +8 -0
  29. package/plugins/_template/plugin.js +23 -0
  30. package/plugins/_template/plugin.json +34 -0
  31. package/plugins/analytics/plugin.json +41 -31
  32. package/plugins/blog/admin/templates/blog.html +22 -0
  33. package/plugins/blog/admin/templates/categories.html +7 -0
  34. package/plugins/blog/admin/templates/comments.html +11 -0
  35. package/plugins/blog/admin/templates/post-editor.html +97 -0
  36. package/plugins/blog/admin/templates/settings.html +11 -0
  37. package/plugins/blog/admin/views/blog.js +183 -0
  38. package/plugins/blog/admin/views/categories.js +235 -0
  39. package/plugins/blog/admin/views/comments.js +187 -0
  40. package/plugins/blog/admin/views/post-editor.js +291 -0
  41. package/plugins/blog/admin/views/settings.js +100 -0
  42. package/plugins/blog/collections/categories/schema.json +12 -0
  43. package/plugins/blog/collections/comments/schema.json +16 -0
  44. package/plugins/blog/collections/posts/schema.json +19 -0
  45. package/plugins/blog/config.js +8 -0
  46. package/plugins/blog/plugin.js +352 -0
  47. package/plugins/blog/plugin.json +96 -0
  48. package/plugins/blog/roles/blog-author.json +10 -0
  49. package/plugins/blog/roles/blog-editor.json +12 -0
  50. package/plugins/blog/templates/author.html +9 -0
  51. package/plugins/blog/templates/category.html +9 -0
  52. package/plugins/blog/templates/index.html +9 -0
  53. package/plugins/blog/templates/post.html +17 -0
  54. package/plugins/blog/templates/tag.html +9 -0
  55. package/plugins/contacts/collections/user-contact-groups/schema.json +1 -1
  56. package/plugins/contacts/collections/user-contacts/schema.json +1 -1
  57. package/plugins/contacts/plugin.js +4 -10
  58. package/plugins/contacts/plugin.json +13 -3
  59. package/plugins/notes/collections/user-notes/schema.json +1 -1
  60. package/plugins/notes/plugin.js +3 -9
  61. package/plugins/notes/plugin.json +13 -3
  62. package/plugins/site-search/plugin.json +5 -2
  63. package/plugins/theme-switcher/plugin.json +1 -1
  64. package/plugins/todo/collections/todos/schema.json +1 -1
  65. package/plugins/todo/plugin.js +3 -9
  66. package/plugins/todo/plugin.json +13 -3
  67. package/public/css/site.css +1 -1
  68. package/public/js/site.js +1 -1
  69. package/scripts/build.js +48 -0
  70. package/scripts/create-plugin.js +113 -0
  71. package/scripts/fresh.js +6 -7
  72. package/scripts/gen-instance-secret.js +46 -0
  73. package/scripts/reset.js +3 -3
  74. package/scripts/setup.js +31 -13
  75. package/server/middleware/auth.js +48 -0
  76. package/server/middleware/managerAuth.js +36 -0
  77. package/server/routes/api/actions.js +1 -1
  78. package/server/routes/api/auth.js +4 -3
  79. package/server/routes/api/layouts.js +173 -49
  80. package/server/routes/api/notifications.js +155 -0
  81. package/server/routes/api/plugin-marketplace.js +75 -0
  82. package/server/routes/api/users.js +1 -1
  83. package/server/routes/api/views.js +1 -1
  84. package/server/routes/public.js +4 -9
  85. package/server/server.js +32 -3
  86. package/server/services/actions.js +1 -1
  87. package/server/services/managerClient.js +182 -0
  88. package/server/services/markdown.js +76 -9
  89. package/server/services/permissionRegistry.js +245 -173
  90. package/server/services/pluginInstaller.js +301 -0
  91. package/server/services/plugins.js +117 -10
  92. package/server/services/presetCollections.js +66 -251
  93. package/server/services/renderer.js +99 -0
  94. package/server/services/roles.js +191 -39
  95. package/server/services/users.js +1 -1
  96. package/server/services/views.js +1 -1
  97. package/server/templates/page.html +2 -2
  98. package/plugins/docs/admin/templates/docs.html +0 -69
  99. package/plugins/docs/admin/views/docs.js +0 -276
  100. package/plugins/docs/config.js +0 -8
  101. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +0 -11
  102. package/plugins/docs/data/folders.json +0 -9
  103. package/plugins/docs/data/templates.json +0 -1
  104. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +0 -5
  105. package/plugins/docs/plugin.js +0 -375
  106. package/plugins/docs/plugin.json +0 -23
  107. package/plugins/form-builder/data/forms/contacts.json +0 -66
  108. package/plugins/form-builder/data/forms/enquiries.json +0 -103
  109. package/plugins/form-builder/data/forms/feedback.json +0 -131
  110. package/plugins/form-builder/data/forms/notes.json +0 -79
  111. package/plugins/form-builder/data/forms/to-do.json +0 -100
  112. package/plugins/form-builder/data/submissions/contacts.json +0 -1
  113. package/plugins/form-builder/data/submissions/enquiries.json +0 -1
  114. package/plugins/form-builder/data/submissions/feedback.json +0 -1
  115. package/plugins/form-builder/data/submissions/notes.json +0 -1
  116. package/plugins/form-builder/data/submissions/to-do.json +0 -1
  117. package/plugins/garage/admin/templates/garage.html +0 -111
  118. package/plugins/garage/admin/views/garage.js +0 -622
  119. package/plugins/garage/collections/garage-vehicles/schema.json +0 -101
  120. package/plugins/garage/config.js +0 -18
  121. package/plugins/garage/data/vehicles.json +0 -70
  122. package/plugins/garage/plugin.js +0 -398
  123. package/plugins/garage/plugin.json +0 -33
  124. package/scripts/seed.js +0 -1996
  125. package/server/services/userTypes.js +0 -227
package/scripts/setup.js CHANGED
@@ -7,10 +7,11 @@
7
7
  *
8
8
  * Steps:
9
9
  * 1. Generate JWT_SECRET (skips if already set)
10
- * 2. Create admin account (skips if users already exist)
11
- * 3. Set site title and tagline
12
- * 4. Pick a theme
13
- * 5. Set server port
10
+ * 2. Generate INSTANCE_SECRET (skips if already set)
11
+ * 3. Create admin account (skips if users already exist)
12
+ * 4. Set site title and tagline
13
+ * 5. Pick a theme
14
+ * 6. Set server port
14
15
  */
15
16
  import {createInterface} from 'node:readline/promises';
16
17
  import {randomBytes} from 'node:crypto';
@@ -154,9 +155,26 @@ if (!isPlaceholder) {
154
155
  }
155
156
 
156
157
  // ---------------------------------------------------------------------------
157
- // Step 2 — Admin Account
158
+ // Step 2 — Instance Secret
158
159
  // ---------------------------------------------------------------------------
159
- section('2. Admin Account');
160
+ section('2. Instance Secret');
161
+
162
+ const existingSecret = env.INSTANCE_SECRET ?? '';
163
+ const isInstanceSecretSet = existingSecret && existingSecret.length >= 32;
164
+
165
+ if (isInstanceSecretSet) {
166
+ console.log(' ✓ INSTANCE_SECRET already configured — skipping.');
167
+ } else {
168
+ const secret = randomBytes(32).toString('hex');
169
+ env.INSTANCE_SECRET = secret;
170
+ await writeEnv(env);
171
+ console.log(' ✓ INSTANCE_SECRET generated and written to .env');
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Step 3 — Admin Account
176
+ // ---------------------------------------------------------------------------
177
+ section('3. Admin Account');
160
178
 
161
179
  const userCount = await countUsers();
162
180
  if (userCount > 0) {
@@ -189,7 +207,7 @@ if (userCount > 0) {
189
207
  email: email.toLowerCase(),
190
208
  name,
191
209
  password: hash,
192
- role: 'admin',
210
+ role: 'super-admin',
193
211
  isActive: true,
194
212
  createdAt: now,
195
213
  updatedAt: now,
@@ -207,9 +225,9 @@ if (userCount > 0) {
207
225
  }
208
226
 
209
227
  // ---------------------------------------------------------------------------
210
- // Step 3 — Site Identity
228
+ // Step 4 — Site Identity
211
229
  // ---------------------------------------------------------------------------
212
- section('3. Site Identity');
230
+ section('4. Site Identity');
213
231
 
214
232
  {
215
233
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -235,9 +253,9 @@ section('3. Site Identity');
235
253
  }
236
254
 
237
255
  // ---------------------------------------------------------------------------
238
- // Step 4 — Theme
256
+ // Step 5 — Theme
239
257
  // ---------------------------------------------------------------------------
240
- section('4. Theme');
258
+ section('5. Theme');
241
259
 
242
260
  THEMES.forEach((t, i) => {
243
261
  const num = String(i + 1).padStart(2);
@@ -261,9 +279,9 @@ THEMES.forEach((t, i) => {
261
279
  }
262
280
 
263
281
  // ---------------------------------------------------------------------------
264
- // Step 5 — Port
282
+ // Step 6 — Port
265
283
  // ---------------------------------------------------------------------------
266
- section('5. Server Port');
284
+ section('6. Server Port');
267
285
 
268
286
  {
269
287
  const rl = createInterface({input: process.stdin, output: process.stdout});
@@ -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
+ }