domma-cms 0.9.1 → 0.9.6

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 (43) hide show
  1. package/admin/js/templates/block-editor.html +163 -163
  2. package/admin/js/templates/form-editor.html +245 -245
  3. package/admin/js/views/action-editor.js +1 -1
  4. package/admin/js/views/block-editor.js +8 -8
  5. package/admin/js/views/collection-editor.js +4 -4
  6. package/admin/js/views/collections.js +1 -1
  7. package/admin/js/views/form-editor.js +7 -7
  8. package/admin/js/views/forms.js +1 -1
  9. package/admin/js/views/navigation.js +14 -14
  10. package/admin/js/views/page-editor.js +35 -35
  11. package/admin/js/views/pages.js +5 -5
  12. package/admin/js/views/plugins.js +13 -10
  13. package/admin/js/views/view-editor.js +1 -1
  14. package/config/plugins.json +25 -0
  15. package/package.json +1 -1
  16. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +4 -4
  17. package/plugins/docs/data/folders.json +3 -3
  18. package/plugins/docs/data/versions/57e003f0-68f2-47dc-9c36-ed4b10ed3deb/1.json +5 -0
  19. package/plugins/garage/admin/templates/garage.html +30 -0
  20. package/plugins/garage/admin/views/garage.js +62 -1
  21. package/plugins/garage/plugin.json +1 -1
  22. package/plugins/notes/admin/templates/notes.html +2 -11
  23. package/plugins/notes/admin/views/notes.js +108 -129
  24. package/plugins/notes/collections/user-notes/schema.json +2 -1
  25. package/plugins/notes/plugin.json +1 -1
  26. package/plugins/site-search/admin/templates/site-search.html +174 -46
  27. package/plugins/site-search/admin/views/site-search.js +72 -1
  28. package/plugins/site-search/config.js +6 -1
  29. package/plugins/site-search/plugin.json +1 -1
  30. package/plugins/site-search/public/inject-head.html +1 -1
  31. package/plugins/site-search/public/search.css +1 -1
  32. package/plugins/site-search/public/search.js +1 -1
  33. package/plugins/todo/admin/templates/todo.html +2 -8
  34. package/plugins/todo/admin/views/todo.js +123 -106
  35. package/plugins/todo/collections/todos/schema.json +2 -1
  36. package/plugins/todo/plugin.json +1 -1
  37. package/server/routes/api/media.js +127 -118
  38. package/server/routes/api/plugins.js +15 -4
  39. package/server/server.js +288 -285
  40. package/server/services/collections.js +17 -10
  41. package/server/services/plugins.js +77 -67
  42. package/server/services/renderer.js +3 -3
  43. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +0 -11
package/server/server.js CHANGED
@@ -1,285 +1,288 @@
1
- /**
2
- * Domma CMS - Fastify Server
3
- * Serves the admin SPA, public site, REST API, and static assets.
4
- * Domma dist files are served directly from node_modules/domma-js/public/dist.
5
- * Updated: blobs + z-index fixes.
6
- */
7
- import 'dotenv/config';
8
- import Fastify from 'fastify';
9
- import jwt from '@fastify/jwt';
10
- import staticPlugin from '@fastify/static';
11
- import cors from '@fastify/cors';
12
- import multipart from '@fastify/multipart';
13
- import rateLimit from '@fastify/rate-limit';
14
- import helmet from '@fastify/helmet';
15
- import path from 'path';
16
- import fs from 'fs/promises';
17
- import {fileURLToPath} from 'url';
18
- import {createRequire} from 'module';
19
- import {config, getConfig} from './config.js';
20
- import {registerPlugins} from './services/plugins.js';
21
- import {load as loadRoles, seed as seedRoles} from './services/roles.js';
22
- import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
23
- import {seedAll as seedPresetCollections} from './services/presetCollections.js';
24
- import {seedDefaultBlocks} from './services/blocks.js';
25
-
26
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
- const ROOT = path.resolve(__dirname, '..');
28
-
29
- // Resolve domma-js package location via require resolution
30
- const require = createRequire(import.meta.url);
31
- const dommaPackageDir = path.dirname(require.resolve('domma-js/package.json'));
32
- const DOMMA_DIST = path.join(dommaPackageDir, 'public', 'dist');
33
-
34
- const { server: serverConfig, auth: authConfig } = config;
35
-
36
- // Validate JWT_SECRET before starting — prevents silent auth failures
37
- const JWT_SECRET = process.env.JWT_SECRET;
38
- if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
39
- console.error('');
40
- console.error(' ERROR: JWT_SECRET is not set or is insecure.');
41
- console.error(' Run `npm run setup` or set a secure JWT_SECRET in .env');
42
- console.error(' (minimum 32 characters, not "CHANGE_ME")');
43
- console.error('');
44
- process.exit(1);
45
- }
46
-
47
- const app = Fastify({
48
- logger: {level: process.env.NODE_ENV === 'development' ? 'info' : 'warn'},
49
- // When running behind a reverse proxy (e.g. domma-cms-manager), trust the
50
- // X-Forwarded-For header so @fastify/rate-limit keys on the real client IP
51
- // rather than the proxy's loopback address.
52
- trustProxy: !!process.env.TRUST_PROXY,
53
- });
54
-
55
- // Register process error handlers immediately so startup errors are captured
56
- process.on('uncaughtException', (err) => {
57
- app.log.error({ err }, 'Uncaught exception');
58
- process.exit(1);
59
- });
60
-
61
- process.on('unhandledRejection', (reason) => {
62
- app.log.error({ reason }, 'Unhandled rejection');
63
- process.exit(1);
64
- });
65
-
66
- // ---------------------------------------------------------------------------
67
- // Core Plugins
68
- // ---------------------------------------------------------------------------
69
-
70
- await app.register(helmet, {
71
- contentSecurityPolicy: {
72
- directives: {
73
- defaultSrc: ["'self'"],
74
- scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
75
- styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://fonts.googleapis.com'],
76
- imgSrc: ["'self'", 'data:', 'blob:'],
77
- fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
78
- connectSrc: ["'self'", 'https://cdn.jsdelivr.net'],
79
- upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
80
- }
81
- },
82
- crossOriginEmbedderPolicy: false, // allow embedding images/resources
83
- hsts: false, // disable HSTS — server runs HTTP only; HSTS would force browser to https
84
- });
85
-
86
- await app.register(jwt, { secret: process.env.JWT_SECRET });
87
- await app.register(cors, serverConfig.cors);
88
- await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
89
- await app.register(rateLimit, {
90
- global: true, // apply default limit to all routes; stricter per-route limits override this
91
- max: 500,
92
- timeWindow: '1 minute',
93
- // Loopback is always exempt — local admin use should never be rate-limited.
94
- // When behind a reverse proxy (TRUST_PROXY), the proxy's loopback is also exempt.
95
- allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
96
- });
97
-
98
- // ---------------------------------------------------------------------------
99
- // Static Assets
100
- // ---------------------------------------------------------------------------
101
-
102
- // Serve Domma dist files from npm package (shared by admin + public)
103
- await app.register(staticPlugin, {
104
- root: DOMMA_DIST,
105
- prefix: '/dist/domma/',
106
- decorateReply: false
107
- });
108
-
109
- // Serve public site assets (CSS, JS, images, etc.)
110
- await app.register(staticPlugin, {
111
- root: path.join(ROOT, 'public'),
112
- prefix: '/public/',
113
- decorateReply: false
114
- });
115
-
116
- // Serve admin panel assets — no-cache so JS/CSS changes are always picked up
117
- await app.register(staticPlugin, {
118
- root: path.join(ROOT, 'admin'),
119
- prefix: '/admin/',
120
- decorateReply: false,
121
- setHeaders: (res) => {
122
- res.setHeader('Cache-Control', 'no-cache');
123
- }
124
- });
125
-
126
- // Ensure required directories exist
127
- const mediaDir = path.join(ROOT, config.content.mediaDir);
128
- const usersDir = path.join(ROOT, config.content.usersDir);
129
- const collectionsDir = path.join(ROOT, config.content.collectionsDir);
130
- const pluginsDir = path.join(ROOT, 'plugins');
131
- const blocksDir = path.join(ROOT, 'content', 'blocks');
132
- await fs.mkdir(mediaDir, { recursive: true });
133
- await fs.mkdir(usersDir, { recursive: true });
134
- await fs.mkdir(collectionsDir, { recursive: true });
135
- await fs.mkdir(pluginsDir, { recursive: true });
136
- await fs.mkdir(blocksDir, {recursive: true});
137
-
138
- // ---------------------------------------------------------------------------
139
- // Pro feature — optional MongoDB connections
140
- // ---------------------------------------------------------------------------
141
-
142
- try {
143
- const connections = getConfig('connections');
144
- if (connections && Object.keys(connections).length > 0) {
145
- const { initialise, shutdown } = await import('./services/connectionManager.js');
146
- await initialise(connections);
147
- app.addHook('onClose', shutdown);
148
- }
149
- } catch {
150
- // No connections.json or empty — pure file-based mode (free version).
151
- }
152
-
153
- // ---------------------------------------------------------------------------
154
- // Roles — seed preset collection + load into cache
155
- // ---------------------------------------------------------------------------
156
-
157
- await seedRoles();
158
- await loadRoles();
159
- await seedUserProfiles();
160
- await ensureAllProfiles();
161
- await seedPresetCollections();
162
- await seedDefaultBlocks();
163
-
164
- // Serve uploaded media files
165
- await app.register(staticPlugin, {
166
- root: mediaDir,
167
- prefix: '/media/',
168
- decorateReply: false
169
- });
170
-
171
- // Serve plugin admin/ and public/ subdirs only — block plugin.js, config.js, data/, etc.
172
- await app.register(async function pluginStaticScope(instance) {
173
- instance.addHook('onRequest', (request, reply, done) => {
174
- const relative = request.url.replace(/^\/plugins\//, '').split('?')[0];
175
- const segments = relative.split('/');
176
-
177
- if (relative.includes('..')) {
178
- reply.code(403).send({error: 'Forbidden'});
179
- return;
180
- }
181
- // Must be: {pluginName}/{admin|public}/{file...}
182
- if (segments.length < 3 || !['admin', 'public'].includes(segments[1])) {
183
- reply.code(404).send({error: 'Not found'});
184
- return;
185
- }
186
- done();
187
- });
188
-
189
- await instance.register(staticPlugin, {
190
- root: pluginsDir,
191
- prefix: '/plugins/',
192
- decorateReply: false
193
- });
194
- });
195
-
196
- // ---------------------------------------------------------------------------
197
- // Global error handler — prevents stack trace leaks in production
198
- // ---------------------------------------------------------------------------
199
-
200
- app.setErrorHandler((error, request, reply) => {
201
- app.log.error({ err: error, url: request.url }, 'Request error');
202
- const statusCode = error.statusCode || 500;
203
- // Don't leak stack traces in production
204
- const message = process.env.NODE_ENV === 'production' && statusCode === 500
205
- ? 'Internal server error'
206
- : error.message;
207
- return reply.code(statusCode).send({ error: message });
208
- });
209
-
210
- // ---------------------------------------------------------------------------
211
- // Auth API Routes (no authentication required on these endpoints themselves)
212
- // ---------------------------------------------------------------------------
213
-
214
- const { authRoutes } = await import('./routes/api/auth.js');
215
- await app.register(authRoutes, { prefix: '/api' });
216
-
217
- // ---------------------------------------------------------------------------
218
- // Protected API Routes
219
- // ---------------------------------------------------------------------------
220
-
221
- const { pagesRoutes } = await import('./routes/api/pages.js');
222
- const { settingsRoutes } = await import('./routes/api/settings.js');
223
- const { layoutsRoutes } = await import('./routes/api/layouts.js');
224
- const { navigationRoutes } = await import('./routes/api/navigation.js');
225
- const { mediaRoutes } = await import('./routes/api/media.js');
226
- const { usersRoutes } = await import('./routes/api/users.js');
227
- const { pluginsRoutes } = await import('./routes/api/plugins.js');
228
- const { collectionsRoutes } = await import('./routes/api/collections.js');
229
- const { formsRoutes } = await import('./routes/api/forms.js');
230
- const { viewsRoutes } = await import('./routes/api/views.js');
231
- const { actionsRoutes } = await import('./routes/api/actions.js');
232
- const {blocksRoutes} = await import('./routes/api/blocks.js');
233
- const {versionsRoutes} = await import('./routes/api/versions.js');
234
- const {effectsRoutes} = await import('./routes/api/effects.js');
235
-
236
- await app.register(pagesRoutes, { prefix: '/api' });
237
- await app.register(settingsRoutes, { prefix: '/api' });
238
- await app.register(layoutsRoutes, { prefix: '/api' });
239
- await app.register(navigationRoutes, { prefix: '/api' });
240
- await app.register(mediaRoutes, { prefix: '/api' });
241
- await app.register(usersRoutes, { prefix: '/api' });
242
- await app.register(pluginsRoutes, { prefix: '/api' });
243
- await app.register(collectionsRoutes, { prefix: '/api' });
244
- await app.register(formsRoutes, { prefix: '/api' });
245
- await app.register(viewsRoutes, { prefix: '/api' });
246
- await app.register(actionsRoutes, { prefix: '/api' });
247
- await app.register(blocksRoutes, {prefix: '/api'});
248
- await app.register(versionsRoutes, {prefix: '/api'});
249
- await app.register(effectsRoutes, {prefix: '/api'});
250
-
251
- // ---------------------------------------------------------------------------
252
- // CMS Plugins (server-side Fastify plugins from plugins/ directory)
253
- // ---------------------------------------------------------------------------
254
-
255
- await registerPlugins(app);
256
-
257
- // ---------------------------------------------------------------------------
258
- // Public Site (catch-all — must be last)
259
- // ---------------------------------------------------------------------------
260
-
261
- const { publicRoutes } = await import('./routes/public.js');
262
- await app.register(publicRoutes);
263
-
264
- // ---------------------------------------------------------------------------
265
- // Graceful shutdown and unhandled error guards
266
- // ---------------------------------------------------------------------------
267
-
268
- const shutdown = async (signal) => {
269
- app.log.info(`Received ${signal}, shutting down gracefully...`);
270
- await app.close();
271
- process.exit(0);
272
- };
273
-
274
- process.on('SIGTERM', () => shutdown('SIGTERM'));
275
- process.on('SIGINT', () => shutdown('SIGINT'));
276
-
277
- try {
278
- await app.listen({ port: serverConfig.port, host: serverConfig.host });
279
- console.log(`Domma CMS running at http://localhost:${serverConfig.port}`);
280
- console.log(` Admin: http://localhost:${serverConfig.port}/admin/`);
281
- console.log(` Public: http://localhost:${serverConfig.port}/`);
282
- } catch (err) {
283
- app.log.error(err);
284
- process.exit(1);
285
- }
1
+ /**
2
+ * Domma CMS - Fastify Server
3
+ * Serves the admin SPA, public site, REST API, and static assets.
4
+ * Domma dist files are served directly from node_modules/domma-js/public/dist.
5
+ * Updated: blobs + z-index fixes.
6
+ */
7
+ import 'dotenv/config';
8
+ import Fastify from 'fastify';
9
+ import jwt from '@fastify/jwt';
10
+ import staticPlugin from '@fastify/static';
11
+ import cors from '@fastify/cors';
12
+ import multipart from '@fastify/multipart';
13
+ import rateLimit from '@fastify/rate-limit';
14
+ import helmet from '@fastify/helmet';
15
+ import path from 'path';
16
+ import fs from 'fs/promises';
17
+ import {fileURLToPath} from 'url';
18
+ import {createRequire} from 'module';
19
+ import {config, getConfig} from './config.js';
20
+ import {registerPlugins} from './services/plugins.js';
21
+ import {load as loadRoles, seed as seedRoles} from './services/roles.js';
22
+ import {ensureAllProfiles, seed as seedUserProfiles} from './services/userProfiles.js';
23
+ import {seedAll as seedPresetCollections} from './services/presetCollections.js';
24
+ import {seedDefaultBlocks} from './services/blocks.js';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+ const ROOT = path.resolve(__dirname, '..');
28
+
29
+ // Resolve domma-js package location via require resolution
30
+ const require = createRequire(import.meta.url);
31
+ const dommaPackageDir = path.dirname(require.resolve('domma-js/package.json'));
32
+ const DOMMA_DIST = path.join(dommaPackageDir, 'public', 'dist');
33
+
34
+ const { server: serverConfig, auth: authConfig } = config;
35
+
36
+ // Validate JWT_SECRET before starting — prevents silent auth failures
37
+ const JWT_SECRET = process.env.JWT_SECRET;
38
+ if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
39
+ console.error('');
40
+ console.error(' ERROR: JWT_SECRET is not set or is insecure.');
41
+ console.error(' Run `npm run setup` or set a secure JWT_SECRET in .env');
42
+ console.error(' (minimum 32 characters, not "CHANGE_ME")');
43
+ console.error('');
44
+ process.exit(1);
45
+ }
46
+
47
+ const app = Fastify({
48
+ logger: {level: process.env.NODE_ENV === 'development' ? 'info' : 'warn'},
49
+ // When running behind a reverse proxy (e.g. domma-cms-manager), trust the
50
+ // X-Forwarded-For header so @fastify/rate-limit keys on the real client IP
51
+ // rather than the proxy's loopback address.
52
+ trustProxy: !!process.env.TRUST_PROXY,
53
+ });
54
+
55
+ // Register process error handlers immediately so startup errors are captured
56
+ process.on('uncaughtException', (err) => {
57
+ app.log.error({ err }, 'Uncaught exception');
58
+ process.exit(1);
59
+ });
60
+
61
+ process.on('unhandledRejection', (reason) => {
62
+ app.log.error({ reason }, 'Unhandled rejection');
63
+ process.exit(1);
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Core Plugins
68
+ // ---------------------------------------------------------------------------
69
+
70
+ await app.register(helmet, {
71
+ contentSecurityPolicy: {
72
+ directives: {
73
+ defaultSrc: ["'self'"],
74
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net'],
75
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://fonts.googleapis.com'],
76
+ imgSrc: ["'self'", 'data:', 'blob:'],
77
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
78
+ connectSrc: ["'self'", 'https://cdn.jsdelivr.net'],
79
+ upgradeInsecureRequests: null, // prevent browsers forcing HTTPS on HTTP deployments
80
+ }
81
+ },
82
+ crossOriginEmbedderPolicy: false, // allow embedding images/resources
83
+ hsts: false, // disable HSTS — server runs HTTP only; HSTS would force browser to https
84
+ });
85
+
86
+ await app.register(jwt, { secret: process.env.JWT_SECRET });
87
+ await app.register(cors, serverConfig.cors);
88
+ await app.register(multipart, { limits: { fileSize: serverConfig.uploads.maxFileSize } });
89
+ await app.register(rateLimit, {
90
+ global: true, // apply default limit to all routes; stricter per-route limits override this
91
+ max: 500,
92
+ timeWindow: '1 minute',
93
+ // Loopback is always exempt — local admin use should never be rate-limited.
94
+ // When behind a reverse proxy (TRUST_PROXY), the proxy's loopback is also exempt.
95
+ allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Static Assets
100
+ // ---------------------------------------------------------------------------
101
+
102
+ // Serve Domma dist files from npm package (shared by admin + public)
103
+ await app.register(staticPlugin, {
104
+ root: DOMMA_DIST,
105
+ prefix: '/dist/domma/',
106
+ decorateReply: false
107
+ });
108
+
109
+ // Serve public site assets (CSS, JS, images, etc.)
110
+ await app.register(staticPlugin, {
111
+ root: path.join(ROOT, 'public'),
112
+ prefix: '/public/',
113
+ decorateReply: false
114
+ });
115
+
116
+ // Serve admin panel assets — no-cache so JS/CSS changes are always picked up
117
+ await app.register(staticPlugin, {
118
+ root: path.join(ROOT, 'admin'),
119
+ prefix: '/admin/',
120
+ decorateReply: false,
121
+ setHeaders: (res) => {
122
+ res.setHeader('Cache-Control', 'no-cache');
123
+ }
124
+ });
125
+
126
+ // Ensure required directories exist
127
+ const mediaDir = path.join(ROOT, config.content.mediaDir);
128
+ const usersDir = path.join(ROOT, config.content.usersDir);
129
+ const collectionsDir = path.join(ROOT, config.content.collectionsDir);
130
+ const pluginsDir = path.join(ROOT, 'plugins');
131
+ const blocksDir = path.join(ROOT, 'content', 'blocks');
132
+ await fs.mkdir(mediaDir, { recursive: true });
133
+ await fs.mkdir(usersDir, { recursive: true });
134
+ await fs.mkdir(collectionsDir, { recursive: true });
135
+ await fs.mkdir(pluginsDir, { recursive: true });
136
+ await fs.mkdir(blocksDir, {recursive: true});
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Pro feature — optional MongoDB connections
140
+ // ---------------------------------------------------------------------------
141
+
142
+ try {
143
+ const connections = getConfig('connections');
144
+ if (connections && Object.keys(connections).length > 0) {
145
+ const { initialise, shutdown } = await import('./services/connectionManager.js');
146
+ await initialise(connections);
147
+ app.addHook('onClose', shutdown);
148
+ }
149
+ } catch {
150
+ // No connections.json or empty — pure file-based mode (free version).
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Roles — seed preset collection + load into cache
155
+ // ---------------------------------------------------------------------------
156
+
157
+ await seedRoles();
158
+ await loadRoles();
159
+ await seedUserProfiles();
160
+ await ensureAllProfiles();
161
+ await seedPresetCollections();
162
+ await seedDefaultBlocks();
163
+
164
+ // Serve uploaded media files — nosniff prevents browsers rendering spoofed content types
165
+ await app.register(staticPlugin, {
166
+ root: mediaDir,
167
+ prefix: '/media/',
168
+ decorateReply: false,
169
+ setHeaders: (res) => {
170
+ res.setHeader('X-Content-Type-Options', 'nosniff');
171
+ }
172
+ });
173
+
174
+ // Serve plugin admin/ and public/ subdirs only — block plugin.js, config.js, data/, etc.
175
+ await app.register(async function pluginStaticScope(instance) {
176
+ instance.addHook('onRequest', (request, reply, done) => {
177
+ const relative = request.url.replace(/^\/plugins\//, '').split('?')[0];
178
+ const segments = relative.split('/');
179
+
180
+ if (relative.includes('..')) {
181
+ reply.code(403).send({error: 'Forbidden'});
182
+ return;
183
+ }
184
+ // Must be: {pluginName}/{admin|public}/{file...}
185
+ if (segments.length < 3 || !['admin', 'public'].includes(segments[1])) {
186
+ reply.code(404).send({error: 'Not found'});
187
+ return;
188
+ }
189
+ done();
190
+ });
191
+
192
+ await instance.register(staticPlugin, {
193
+ root: pluginsDir,
194
+ prefix: '/plugins/',
195
+ decorateReply: false
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Global error handler prevents stack trace leaks in production
201
+ // ---------------------------------------------------------------------------
202
+
203
+ app.setErrorHandler((error, request, reply) => {
204
+ app.log.error({ err: error, url: request.url }, 'Request error');
205
+ const statusCode = error.statusCode || 500;
206
+ // Don't leak stack traces in production
207
+ const message = process.env.NODE_ENV === 'production' && statusCode === 500
208
+ ? 'Internal server error'
209
+ : error.message;
210
+ return reply.code(statusCode).send({ error: message });
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Auth API Routes (no authentication required on these endpoints themselves)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ const { authRoutes } = await import('./routes/api/auth.js');
218
+ await app.register(authRoutes, { prefix: '/api' });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Protected API Routes
222
+ // ---------------------------------------------------------------------------
223
+
224
+ const { pagesRoutes } = await import('./routes/api/pages.js');
225
+ const { settingsRoutes } = await import('./routes/api/settings.js');
226
+ const { layoutsRoutes } = await import('./routes/api/layouts.js');
227
+ const { navigationRoutes } = await import('./routes/api/navigation.js');
228
+ const { mediaRoutes } = await import('./routes/api/media.js');
229
+ const { usersRoutes } = await import('./routes/api/users.js');
230
+ const { pluginsRoutes } = await import('./routes/api/plugins.js');
231
+ const { collectionsRoutes } = await import('./routes/api/collections.js');
232
+ const { formsRoutes } = await import('./routes/api/forms.js');
233
+ const { viewsRoutes } = await import('./routes/api/views.js');
234
+ const { actionsRoutes } = await import('./routes/api/actions.js');
235
+ const {blocksRoutes} = await import('./routes/api/blocks.js');
236
+ const {versionsRoutes} = await import('./routes/api/versions.js');
237
+ const {effectsRoutes} = await import('./routes/api/effects.js');
238
+
239
+ await app.register(pagesRoutes, { prefix: '/api' });
240
+ await app.register(settingsRoutes, { prefix: '/api' });
241
+ await app.register(layoutsRoutes, { prefix: '/api' });
242
+ await app.register(navigationRoutes, { prefix: '/api' });
243
+ await app.register(mediaRoutes, { prefix: '/api' });
244
+ await app.register(usersRoutes, { prefix: '/api' });
245
+ await app.register(pluginsRoutes, { prefix: '/api' });
246
+ await app.register(collectionsRoutes, { prefix: '/api' });
247
+ await app.register(formsRoutes, { prefix: '/api' });
248
+ await app.register(viewsRoutes, { prefix: '/api' });
249
+ await app.register(actionsRoutes, { prefix: '/api' });
250
+ await app.register(blocksRoutes, {prefix: '/api'});
251
+ await app.register(versionsRoutes, {prefix: '/api'});
252
+ await app.register(effectsRoutes, {prefix: '/api'});
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // CMS Plugins (server-side Fastify plugins from plugins/ directory)
256
+ // ---------------------------------------------------------------------------
257
+
258
+ await registerPlugins(app);
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Public Site (catch-all must be last)
262
+ // ---------------------------------------------------------------------------
263
+
264
+ const { publicRoutes } = await import('./routes/public.js');
265
+ await app.register(publicRoutes);
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Graceful shutdown and unhandled error guards
269
+ // ---------------------------------------------------------------------------
270
+
271
+ const shutdown = async (signal) => {
272
+ app.log.info(`Received ${signal}, shutting down gracefully...`);
273
+ await app.close();
274
+ process.exit(0);
275
+ };
276
+
277
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
278
+ process.on('SIGINT', () => shutdown('SIGINT'));
279
+
280
+ try {
281
+ await app.listen({ port: serverConfig.port, host: serverConfig.host });
282
+ console.log(`Domma CMS running at http://localhost:${serverConfig.port}`);
283
+ console.log(` Admin: http://localhost:${serverConfig.port}/admin/`);
284
+ console.log(` Public: http://localhost:${serverConfig.port}/`);
285
+ } catch (err) {
286
+ app.log.error(err);
287
+ process.exit(1);
288
+ }
@@ -37,7 +37,6 @@ function slugify(str) {
37
37
  async function readSchema(slug) {
38
38
  const raw = await fs.readFile(schemaPath(slug), 'utf8');
39
39
  const schema = JSON.parse(raw);
40
- // Normalise legacy 'preset' flag — both mean "bundled with fresh installs"
41
40
  if (schema.preset && !schema.bundled) {
42
41
  schema.bundled = true;
43
42
  delete schema.preset;
@@ -139,11 +138,11 @@ export async function createCollection({title, slug, description = '', fields =
139
138
  slug: finalSlug,
140
139
  title: title.trim(),
141
140
  description: description.trim(),
142
- ...(bundled ? {bundled: true} : {}),
143
- ...(plugin ? {plugin} : {}),
144
141
  fields,
145
142
  api: { ...defaultApiAccess(), ...api },
146
143
  storage: storage || {adapter: 'file'},
144
+ ...(bundled ? {bundled: true} : {}),
145
+ ...(plugin ? {plugin} : {}),
147
146
  createdAt: now,
148
147
  updatedAt: now
149
148
  };
@@ -165,20 +164,28 @@ export async function updateCollection(slug, updates) {
165
164
  const schema = await getCollection(slug);
166
165
  if (!schema) throw new Error(`Collection "${slug}" not found`);
167
166
 
168
- const { slug: _ignore, createdAt, plugin: _plugin, bundled: _bundled, ...rest } = updates;
167
+ const {slug: _ignore, createdAt, plugin: _stripPlugin, ...rest} = updates;
169
168
  const updated = {
170
169
  ...schema,
171
170
  ...rest,
172
- // bundled is user-editable — set from update, omit if falsy
173
- ...(updates.bundled ? {bundled: true} : {}),
174
- // plugin is ownership metadata — never overwrite from updates
175
- ...(schema.plugin ? {plugin: schema.plugin} : {}),
176
171
  slug,
177
172
  createdAt: schema.createdAt,
178
173
  updatedAt: new Date().toISOString()
179
174
  };
180
- // Clear bundled from schema if it was unchecked (schema spread may have preserved old value)
181
- if (!updates.bundled) delete updated.bundled;
175
+
176
+ // Preserve the original plugin field (never overwrite)
177
+ if (schema.plugin) {
178
+ updated.plugin = schema.plugin;
179
+ } else {
180
+ delete updated.plugin;
181
+ }
182
+
183
+ // Allow bundled to be set or cleared
184
+ if (updates.bundled) {
185
+ updated.bundled = true;
186
+ } else {
187
+ delete updated.bundled;
188
+ }
182
189
 
183
190
  await writeSchema(updated);
184
191