domma-cms 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -53
- package/README.md +27 -27
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/index.html +2 -2
- package/admin/js/app.js +1 -1
- package/admin/js/lib/card-builder.js +2 -2
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/image-editor.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +2 -2
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/lib/project-quick-create.js +1 -1
- package/admin/js/lib/scribe-composer.js +1 -1
- package/admin/js/lib/server-restart.js +1 -1
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/simple-editor.js +1 -1
- package/admin/js/lib/themes.js +1 -1
- package/admin/js/lib/timeline-builder.js +1 -1
- package/admin/js/templates/action-editor.html +8 -8
- package/admin/js/templates/api-endpoint-editor.html +7 -7
- package/admin/js/templates/api-endpoints.html +1 -1
- package/admin/js/templates/api-reference.html +1 -1
- package/admin/js/templates/block-editor.html +6 -6
- package/admin/js/templates/collection-editor.html +1 -1
- package/admin/js/templates/component-editor.html +2 -2
- package/admin/js/templates/dashboard/cache.html +3 -3
- package/admin/js/templates/dashboard/journeys.html +3 -3
- package/admin/js/templates/dashboard/kpi-strip.html +3 -3
- package/admin/js/templates/dashboard/top-pages.html +1 -1
- package/admin/js/templates/dashboard/traffic-chart.html +1 -1
- package/admin/js/templates/docs/api-actions.html +1 -1
- package/admin/js/templates/docs/api-collections.html +4 -4
- package/admin/js/templates/docs/api-pages.html +2 -2
- package/admin/js/templates/docs/api-plugins.html +1 -1
- package/admin/js/templates/docs/api-settings.html +2 -2
- package/admin/js/templates/docs/api-users.html +1 -1
- package/admin/js/templates/docs/components-howto.html +4 -4
- package/admin/js/templates/docs/components-reference.html +21 -21
- package/admin/js/templates/docs/components-rules.html +14 -14
- package/admin/js/templates/docs/components-walkthrough.html +13 -13
- package/admin/js/templates/docs/tutorial-crud.html +49 -49
- package/admin/js/templates/docs/tutorial-forms.html +4 -4
- package/admin/js/templates/docs/tutorial-plugin.html +10 -10
- package/admin/js/templates/docs/usage-actions.html +6 -6
- package/admin/js/templates/docs/usage-cta-shortcode.html +9 -9
- package/admin/js/templates/docs/usage-dconfig.html +4 -4
- package/admin/js/templates/docs/usage-navigation.html +1 -1
- package/admin/js/templates/docs/usage-pages.html +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +24 -24
- package/admin/js/templates/docs/usage-users-roles.html +3 -3
- package/admin/js/templates/docs/usage-views.html +4 -4
- package/admin/js/templates/documentation.html +1 -1
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-editor.html +5 -5
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/login.html +5 -5
- package/admin/js/templates/menu-editor.html +10 -10
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/navigation.html +11 -11
- package/admin/js/templates/notifications.html +1 -1
- package/admin/js/templates/page-editor.html +4 -4
- package/admin/js/templates/pro-docs.html +14 -14
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/templates/settings.html +10 -10
- package/admin/js/templates/tutorials.html +3 -3
- package/admin/js/templates/view-editor.html +15 -15
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/api-endpoint-editor.js +2 -2
- package/admin/js/views/api-endpoints.js +2 -2
- package/admin/js/views/api-tokens.js +3 -3
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +1 -1
- package/admin/js/views/collection-entries.js +1 -1
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/component-editor.js +1 -1
- package/admin/js/views/components.js +4 -4
- package/admin/js/views/dashboard/widgets/cache.js +1 -1
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +1 -1
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/layouts.js +1 -1
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/menu-editor.js +3 -3
- package/admin/js/views/menu-locations.js +1 -1
- package/admin/js/views/menus.js +3 -3
- package/admin/js/views/navigation.js +8 -8
- package/admin/js/views/page-editor.js +40 -40
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/plugin-code.js +2 -2
- package/admin/js/views/plugins.js +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +1 -1
- package/admin/js/views/roles.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/view-preview.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/bin/cli.js +8 -8
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +16 -16
- package/config/plugins.json +1 -1
- package/package.json +2 -2
- package/plugins/_template/admin/views/index.js +1 -1
- package/plugins/_template/config.js +1 -1
- package/plugins/_template/plugin.js +1 -1
- package/plugins/analytics/admin/templates/analytics.html +3 -3
- package/plugins/analytics/admin/views/analytics.js +1 -1
- package/plugins/analytics/config.js +1 -1
- package/plugins/analytics/plugin.js +4 -4
- package/plugins/analytics/plugin.json +1 -1
- package/plugins/analytics/public/inject-body.html +2 -2
- package/plugins/analytics/public/inject-head.html +1 -1
- package/plugins/blog/admin/templates/blog.html +4 -4
- package/plugins/blog/admin/views/blog.js +3 -3
- package/plugins/blog/admin/views/categories.js +2 -2
- package/plugins/blog/admin/views/comments.js +4 -4
- package/plugins/blog/admin/views/post-editor.js +1 -1
- package/plugins/blog/plugin.js +2 -2
- package/plugins/blog/plugin.json +4 -4
- package/plugins/contacts/admin/views/contacts.js +8 -8
- package/plugins/contacts/plugin.js +4 -4
- package/plugins/contacts/plugin.json +1 -1
- package/plugins/invoice/admin/templates/editor.html +6 -6
- package/plugins/invoice/admin/templates/index.html +4 -4
- package/plugins/invoice/admin/views/editor.js +3 -3
- package/plugins/invoice/admin/views/index.js +4 -4
- package/plugins/invoice/admin/views/issuers.js +1 -1
- package/plugins/invoice/admin/views/party-view.js +3 -3
- package/plugins/invoice/admin/views/receivers.js +1 -1
- package/plugins/invoice/config.js +1 -1
- package/plugins/invoice/plugin.js +1 -1
- package/plugins/invoice/plugin.json +4 -4
- package/plugins/notes/admin/views/notes.js +4 -4
- package/plugins/notes/plugin.js +1 -1
- package/plugins/notes/plugin.json +1 -1
- package/plugins/site-search/admin/templates/site-search.html +3 -3
- package/plugins/site-search/admin/views/site-search.js +1 -1
- package/plugins/site-search/config.js +1 -1
- package/plugins/site-search/plugin.js +4 -4
- package/plugins/site-search/plugin.json +1 -1
- package/plugins/surveys/admin/templates/results.html +1 -1
- package/plugins/surveys/admin/templates/survey-editor.html +4 -4
- package/plugins/surveys/admin/views/audience.js +6 -6
- package/plugins/surveys/admin/views/results.js +3 -3
- package/plugins/surveys/admin/views/survey-editor.js +1 -1
- package/plugins/surveys/admin/views/surveys.js +2 -2
- package/plugins/surveys/plugin.js +1 -1
- package/plugins/surveys/plugin.json +4 -4
- package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
- package/plugins/theme-switcher/plugin.json +3 -3
- package/plugins/theme-switcher/public/inject-body.html +28 -28
- package/plugins/theme-switcher/public/inject-head.html +1 -1
- package/plugins/todo/plugin.js +1 -1
- package/public/css/forms.css +1 -1
- package/public/js/collection-browser.js +1 -1
- package/public/js/collection-context.js +1 -1
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -1
- package/public/js/site.js +1 -1
- package/scripts/build.js +14 -14
- package/scripts/create-plugin.js +2 -2
- package/scripts/fresh.js +2 -2
- package/scripts/gen-instance-secret.js +1 -1
- package/scripts/pro.js +12 -12
- package/scripts/reset.js +2 -2
- package/scripts/setup.js +15 -15
- package/scripts/users.js +5 -5
- package/scripts/verify-assets.mjs +6 -6
- package/server/config.js +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/middleware/managerAuth.js +3 -3
- package/server/routes/api/actions.js +4 -4
- package/server/routes/api/api-endpoints.js +7 -7
- package/server/routes/api/api-tokens.js +5 -5
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/blocks.js +3 -3
- package/server/routes/api/collections.js +18 -18
- package/server/routes/api/dashboard.js +5 -5
- package/server/routes/api/effects.js +2 -2
- package/server/routes/api/endpoints-public.js +5 -5
- package/server/routes/api/forms.js +47 -47
- package/server/routes/api/menu-locations.js +3 -3
- package/server/routes/api/menus.js +7 -7
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/notifications.js +2 -2
- package/server/routes/api/pages.js +14 -2
- package/server/routes/api/plugin-marketplace.js +3 -3
- package/server/routes/api/plugins.js +6 -6
- package/server/routes/api/projects.js +9 -9
- package/server/routes/api/scaffold.js +7 -7
- package/server/routes/api/settings.js +6 -6
- package/server/routes/api/sidebar.js +1 -1
- package/server/routes/api/users.js +5 -5
- package/server/routes/api/versions.js +1 -1
- package/server/routes/api/views.js +1 -1
- package/server/routes/docs-public.js +3 -3
- package/server/routes/public.js +14 -14
- package/server/server.js +34 -16
- package/server/services/actions.js +19 -19
- package/server/services/adapters/FileAdapter.js +2 -2
- package/server/services/adapters/MongoAdapter.js +5 -5
- package/server/services/apiEndpoints.js +15 -15
- package/server/services/apiTokens.js +7 -7
- package/server/services/blocks.js +13 -13
- package/server/services/cache/drivers/MemoryDriver.js +3 -3
- package/server/services/cache/drivers/NoneDriver.js +1 -1
- package/server/services/cache/index.js +5 -5
- package/server/services/collections.js +14 -14
- package/server/services/components.js +9 -9
- package/server/services/connectionManager.js +4 -4
- package/server/services/content.js +25 -2
- package/server/services/docs.js +14 -14
- package/server/services/email.js +167 -167
- package/server/services/filterEngine.js +6 -6
- package/server/services/forms.js +18 -10
- package/server/services/health.js +1 -1
- package/server/services/hooks.js +7 -7
- package/server/services/images.js +4 -4
- package/server/services/managerClient.js +1 -1
- package/server/services/markdown.js +79 -79
- package/server/services/menuRender.js +5 -5
- package/server/services/menus-migration.js +3 -3
- package/server/services/menus.js +31 -31
- package/server/services/permissionRegistry.js +1 -1
- package/server/services/pluginFiles.js +5 -5
- package/server/services/pluginInstaller.js +17 -17
- package/server/services/pluginScaffold.js +1 -1
- package/server/services/plugins.js +17 -17
- package/server/services/presetCollections.js +2 -2
- package/server/services/projects.js +35 -26
- package/server/services/recipes/contact-list.json +1 -1
- package/server/services/recipes/onboarding.json +8 -8
- package/server/services/references.js +8 -8
- package/server/services/renderer.js +9 -9
- package/server/services/roles.js +9 -9
- package/server/services/rowAccess.js +10 -10
- package/server/services/scaffolder.js +30 -30
- package/server/services/sidebar-migration.js +10 -10
- package/server/services/sitemap.js +1 -1
- package/server/services/userProfiles.js +199 -199
- package/server/services/userRoles.js +7 -7
- package/server/services/users.js +302 -302
- package/server/services/versions.js +3 -3
- package/server/services/viewPipeline.js +6 -6
- package/server/services/views.js +11 -11
- package/server/templates/page.html +5 -5
package/server/server.js
CHANGED
|
@@ -38,7 +38,7 @@ const DOMMA_DIST = path.join(dommaPackageDir, 'public', 'dist');
|
|
|
38
38
|
|
|
39
39
|
const { server: serverConfig, auth: authConfig } = config;
|
|
40
40
|
|
|
41
|
-
// Validate JWT_SECRET before starting
|
|
41
|
+
// Validate JWT_SECRET before starting - prevents silent auth failures
|
|
42
42
|
const JWT_SECRET = process.env.JWT_SECRET;
|
|
43
43
|
if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
|
|
44
44
|
console.error('');
|
|
@@ -49,7 +49,7 @@ if (!JWT_SECRET || JWT_SECRET === 'CHANGE_ME' || JWT_SECRET.length < 32) {
|
|
|
49
49
|
process.exit(1);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// MANAGER_SECRET is optional
|
|
52
|
+
// MANAGER_SECRET is optional - only needed when domma-cms-manager pushes notifications.
|
|
53
53
|
// Warn if set but insecure; silently accept if absent (manager push is disabled).
|
|
54
54
|
const MANAGER_SECRET = process.env.MANAGER_SECRET;
|
|
55
55
|
if (MANAGER_SECRET && MANAGER_SECRET.length < 32) {
|
|
@@ -92,7 +92,7 @@ await app.register(helmet, {
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
crossOriginEmbedderPolicy: false, // allow embedding images/resources
|
|
95
|
-
hsts: false, // disable HSTS
|
|
95
|
+
hsts: false, // disable HSTS - server runs HTTP only; HSTS would force browser to https
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
await app.register(jwt, { secret: process.env.JWT_SECRET });
|
|
@@ -102,7 +102,7 @@ await app.register(rateLimit, {
|
|
|
102
102
|
global: true, // apply default limit to all routes; stricter per-route limits override this
|
|
103
103
|
max: 500,
|
|
104
104
|
timeWindow: '1 minute',
|
|
105
|
-
// Loopback is always exempt
|
|
105
|
+
// Loopback is always exempt - local admin use should never be rate-limited.
|
|
106
106
|
// When behind a reverse proxy (TRUST_PROXY), the proxy's loopback is also exempt.
|
|
107
107
|
allowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
|
|
108
108
|
});
|
|
@@ -133,11 +133,11 @@ await app.register(staticPlugin, {
|
|
|
133
133
|
decorateReply: false
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
// Serve admin panel assets
|
|
136
|
+
// Serve admin panel assets - no-store on JS/CSS so ESM module URLs are never
|
|
137
137
|
// cached. `no-cache` revalidates but browsers may keep the parsed ESM module
|
|
138
138
|
// keyed by URL across reloads, which means a hard refresh can still load the
|
|
139
139
|
// stale code path. `no-store` skips the cache entirely. Other admin assets
|
|
140
|
-
// (images, html) keep no-cache. Local-only impact
|
|
140
|
+
// (images, html) keep no-cache. Local-only impact - admin is not public.
|
|
141
141
|
await app.register(staticPlugin, {
|
|
142
142
|
root: path.join(ROOT, 'admin'),
|
|
143
143
|
prefix: '/admin/',
|
|
@@ -162,7 +162,7 @@ await fs.mkdir(pluginsDir, { recursive: true });
|
|
|
162
162
|
await fs.mkdir(blocksDir, {recursive: true});
|
|
163
163
|
|
|
164
164
|
// ---------------------------------------------------------------------------
|
|
165
|
-
// Pro feature
|
|
165
|
+
// Pro feature - optional MongoDB connections
|
|
166
166
|
// ---------------------------------------------------------------------------
|
|
167
167
|
|
|
168
168
|
try {
|
|
@@ -173,11 +173,11 @@ try {
|
|
|
173
173
|
app.addHook('onClose', shutdown);
|
|
174
174
|
}
|
|
175
175
|
} catch {
|
|
176
|
-
// No connections.json or empty
|
|
176
|
+
// No connections.json or empty - pure file-based mode (free version).
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
// ---------------------------------------------------------------------------
|
|
180
|
-
// Roles
|
|
180
|
+
// Roles - seed preset collection + load into cache
|
|
181
181
|
// ---------------------------------------------------------------------------
|
|
182
182
|
|
|
183
183
|
await seedRoles();
|
|
@@ -186,13 +186,13 @@ await seedUserProfiles();
|
|
|
186
186
|
await ensureAllProfiles();
|
|
187
187
|
await seedPresetCollections();
|
|
188
188
|
await seedCoreProject();
|
|
189
|
-
await seedDocsProject(); // built-in Domma Docs project
|
|
189
|
+
await seedDocsProject(); // built-in Domma Docs project - seeded disabled by default
|
|
190
190
|
await seedDefaultBlocks();
|
|
191
191
|
await seedDefaultComponents();
|
|
192
192
|
await refreshComponentTagAllowlist();
|
|
193
193
|
|
|
194
194
|
// ---------------------------------------------------------------------------
|
|
195
|
-
// Menus
|
|
195
|
+
// Menus - one-shot migration from legacy navigation.json + site footer links
|
|
196
196
|
// ---------------------------------------------------------------------------
|
|
197
197
|
|
|
198
198
|
try {
|
|
@@ -229,7 +229,7 @@ console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disable
|
|
|
229
229
|
// stale pages. No-op on a cold memory cache; matters for the Redis driver.
|
|
230
230
|
await cache.invalidateTags(['docs']);
|
|
231
231
|
|
|
232
|
-
// Serve uploaded media files
|
|
232
|
+
// Serve uploaded media files - nosniff prevents browsers rendering spoofed content types
|
|
233
233
|
await app.register(staticPlugin, {
|
|
234
234
|
root: mediaDir,
|
|
235
235
|
prefix: '/media/',
|
|
@@ -239,7 +239,7 @@ await app.register(staticPlugin, {
|
|
|
239
239
|
}
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
// Serve plugin admin/ and public/ subdirs only
|
|
242
|
+
// Serve plugin admin/ and public/ subdirs only - block plugin.js, config.js, data/, etc.
|
|
243
243
|
await app.register(async function pluginStaticScope(instance) {
|
|
244
244
|
instance.addHook('onRequest', (request, reply, done) => {
|
|
245
245
|
const relative = request.url.replace(/^\/plugins\//, '').split('?')[0];
|
|
@@ -265,7 +265,7 @@ await app.register(async function pluginStaticScope(instance) {
|
|
|
265
265
|
});
|
|
266
266
|
|
|
267
267
|
// ---------------------------------------------------------------------------
|
|
268
|
-
// Global error handler
|
|
268
|
+
// Global error handler - prevents stack trace leaks in production
|
|
269
269
|
// ---------------------------------------------------------------------------
|
|
270
270
|
|
|
271
271
|
app.setErrorHandler((error, request, reply) => {
|
|
@@ -278,6 +278,24 @@ app.setErrorHandler((error, request, reply) => {
|
|
|
278
278
|
return reply.code(statusCode).send({ error: message });
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Admin API responses are never cacheable
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// The admin SPA must always see the live state of the site - a stale list is
|
|
285
|
+
// indistinguishable from a missing artefact, and the user is left arguing with
|
|
286
|
+
// their own CMS. Everything under /api is no-store EXCEPT the two externally
|
|
287
|
+
// consumable surfaces, which have their own caching contracts:
|
|
288
|
+
// /api/v1/* - collection access by API token
|
|
289
|
+
// /api/x/* - API Builder endpoints
|
|
290
|
+
app.addHook('onSend', async (request, reply) => {
|
|
291
|
+
const url = request.raw.url || '';
|
|
292
|
+
if (!url.startsWith('/api/')) return;
|
|
293
|
+
if (url.startsWith('/api/v1/') || url.startsWith('/api/x/')) return;
|
|
294
|
+
reply.header('Cache-Control', 'no-store, must-revalidate');
|
|
295
|
+
reply.header('Pragma', 'no-cache');
|
|
296
|
+
reply.header('Expires', '0');
|
|
297
|
+
});
|
|
298
|
+
|
|
281
299
|
// ---------------------------------------------------------------------------
|
|
282
300
|
// Auth API Routes (no authentication required on these endpoints themselves)
|
|
283
301
|
// ---------------------------------------------------------------------------
|
|
@@ -374,10 +392,10 @@ for (const [name, plugin] of Object.entries(getLoadedPlugins())) {
|
|
|
374
392
|
}
|
|
375
393
|
|
|
376
394
|
// ---------------------------------------------------------------------------
|
|
377
|
-
// Public Site (catch-all
|
|
395
|
+
// Public Site (catch-all - must be last)
|
|
378
396
|
// ---------------------------------------------------------------------------
|
|
379
397
|
|
|
380
|
-
// Domma Docs (request-time handbook)
|
|
398
|
+
// Domma Docs (request-time handbook) - dedicated /domma-docs(/*) prefix that
|
|
381
399
|
// out-prioritises the catch-all; must be registered before it.
|
|
382
400
|
const { docsPublicRoutes } = await import('./routes/docs-public.js');
|
|
383
401
|
await app.register(docsPublicRoutes);
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Actions Service (Pro
|
|
2
|
+
* Actions Service (Pro - requires MongoDB)
|
|
3
3
|
*
|
|
4
4
|
* Action configs are stored in MongoDB `cms__actions` on the 'default' connection.
|
|
5
5
|
* Executing an action runs a sequence of steps against a target collection entry.
|
|
6
6
|
*
|
|
7
7
|
* Supported step types:
|
|
8
|
-
* updateField
|
|
9
|
-
* deleteEntry
|
|
10
|
-
* moveToCollection
|
|
11
|
-
* createInCollection
|
|
8
|
+
* updateField - overwrite one field on the source entry
|
|
9
|
+
* deleteEntry - remove the source entry
|
|
10
|
+
* moveToCollection - copy source entry into target collection AND delete source
|
|
11
|
+
* createInCollection - create a new entry in another collection; source untouched
|
|
12
12
|
* (used for apply/follow/bookmark/upvote-style relations)
|
|
13
|
-
* webhook
|
|
14
|
-
* email
|
|
13
|
+
* webhook - POST/PUT/GET to an external URL with interpolated body
|
|
14
|
+
* email - send a transactional email via configured SMTP
|
|
15
15
|
*
|
|
16
16
|
* Template interpolation in step configs:
|
|
17
17
|
* {{entry.id}} → source entry id
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* {{user.role}} → executing user's role
|
|
24
24
|
* {{env.CMS_PUBLIC_*}} → environment variables with CMS_PUBLIC_ prefix only
|
|
25
25
|
*
|
|
26
|
-
* Note: execution is not transactional
|
|
26
|
+
* Note: execution is not transactional - partial results are returned on failure
|
|
27
27
|
* with a `stepsCompleted` count.
|
|
28
28
|
*/
|
|
29
29
|
import {v4 as uuidv4} from 'uuid';
|
|
@@ -151,16 +151,16 @@ async function executeStep(step, collection, entry, context) {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
/*
|
|
154
|
-
* createInCollection
|
|
154
|
+
* createInCollection - append an entry to another collection without
|
|
155
155
|
* touching the source. Designed for relational patterns like:
|
|
156
156
|
* - Apply for job → create entry in `applications` { jobId, candidateId, status }
|
|
157
157
|
* - Bookmark page → create entry in `bookmarks` { pageId, userId }
|
|
158
158
|
* - Upvote item → create entry in `votes` { itemId, userId, value: 1 }
|
|
159
159
|
*
|
|
160
160
|
* Config:
|
|
161
|
-
* targetCollection {string} required
|
|
162
|
-
* data {object} required
|
|
163
|
-
* createdBy {string} optional
|
|
161
|
+
* targetCollection {string} required - slug of the collection to append to
|
|
162
|
+
* data {object} required - field map; values are template-interpolated
|
|
163
|
+
* createdBy {string} optional - '{{user.id}}' is the conventional value;
|
|
164
164
|
* enables row-level "mine" filtering downstream
|
|
165
165
|
*
|
|
166
166
|
* Returns: { created: <new entry id>, in: <targetCollection> }
|
|
@@ -303,7 +303,7 @@ export async function createAction(data, userId = null) {
|
|
|
303
303
|
confirmMessage: trigger?.confirmMessage || null
|
|
304
304
|
},
|
|
305
305
|
steps,
|
|
306
|
-
// State-machine transition
|
|
306
|
+
// State-machine transition - when set, the action is gated by the
|
|
307
307
|
// entry's current value of `field` matching one of the `from` values,
|
|
308
308
|
// and is advertised as an "available transition" for that state.
|
|
309
309
|
// Optional `to` is informational only (steps still do the actual write).
|
|
@@ -399,12 +399,12 @@ export async function listActionsForCollection(collectionSlug) {
|
|
|
399
399
|
* 3. action.access.roles allows the user (compared by role level so
|
|
400
400
|
* higher-privileged roles inherit access automatically)
|
|
401
401
|
*
|
|
402
|
-
* Returns a stripped projection
|
|
402
|
+
* Returns a stripped projection - slug, title, trigger config, transition -
|
|
403
403
|
* so the client can render buttons without leaking step internals.
|
|
404
404
|
*
|
|
405
405
|
* @param {string} collectionSlug
|
|
406
406
|
* @param {object} entry - The entry being inspected (needed for field-value match)
|
|
407
|
-
* @param {object} [user] - { role }
|
|
407
|
+
* @param {object} [user] - { role } - anonymous if missing
|
|
408
408
|
* @returns {Promise<object[]>}
|
|
409
409
|
*/
|
|
410
410
|
export async function listTransitionsForEntry(collectionSlug, entry, user = null) {
|
|
@@ -414,7 +414,7 @@ export async function listTransitionsForEntry(collectionSlug, entry, user = null
|
|
|
414
414
|
const { getRoleLevel } = await import('./roles.js');
|
|
415
415
|
const { getEffectiveLevel } = await import('./userRoles.js');
|
|
416
416
|
// Use effective level so multi-role users see transitions any of their
|
|
417
|
-
// roles permit
|
|
417
|
+
// roles permit - e.g. a user who's primarily a candidate but also an
|
|
418
418
|
// admin sees BOTH the candidate-only "withdraw" AND the admin-only
|
|
419
419
|
// "approve" transitions on the same entry.
|
|
420
420
|
const userLevel = getEffectiveLevel(user);
|
|
@@ -446,7 +446,7 @@ export async function listTransitionsForEntry(collectionSlug, entry, user = null
|
|
|
446
446
|
|
|
447
447
|
/**
|
|
448
448
|
* Execute an action against a specific collection entry.
|
|
449
|
-
* Steps run sequentially
|
|
449
|
+
* Steps run sequentially - not transactional.
|
|
450
450
|
* Returns partial results with stepsCompleted count on failure.
|
|
451
451
|
*
|
|
452
452
|
* @param {string} slug - Action slug
|
|
@@ -469,7 +469,7 @@ export async function executeAction(slug, entryId, { user = null } = {}) {
|
|
|
469
469
|
throw err;
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
-
// State-machine guard
|
|
472
|
+
// State-machine guard - an action with a transition config only runs when
|
|
473
473
|
// the entry's current value of `field` is in `from`. Stops illegal moves
|
|
474
474
|
// like "withdraw" on an already-rejected application; the API returns 409
|
|
475
475
|
// so the client can present a meaningful "no longer available" message
|
|
@@ -480,7 +480,7 @@ export async function executeAction(slug, entryId, { user = null } = {}) {
|
|
|
480
480
|
const currentValue = entry.data?.[t.field];
|
|
481
481
|
const allowed = Array.isArray(t.from) ? t.from : (t.from ? [t.from] : []);
|
|
482
482
|
if (allowed.length && !allowed.includes(currentValue)) {
|
|
483
|
-
const err = new Error(`Cannot apply "${action.title}"
|
|
483
|
+
const err = new Error(`Cannot apply "${action.title}" - current ${t.field} is "${currentValue ?? '(none)'}", allowed: ${allowed.join(', ')}`);
|
|
484
484
|
err.statusCode = 409;
|
|
485
485
|
throw err;
|
|
486
486
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FileAdapter
|
|
2
|
+
* FileAdapter - file-based storage adapter for Collection entries.
|
|
3
3
|
*
|
|
4
4
|
* Stores entries in `data.json` inside the collection directory.
|
|
5
5
|
* This is the default adapter for all collections and the only adapter
|
|
@@ -56,7 +56,7 @@ export class FileAdapter {
|
|
|
56
56
|
*
|
|
57
57
|
* `search` is a simple substring match across all field values (legacy
|
|
58
58
|
* behaviour, kept for the admin search box). `filter` is the structured
|
|
59
|
-
* DSL
|
|
59
|
+
* DSL - see `filterEngine.js` for operator syntax. Both may be combined;
|
|
60
60
|
* search runs first, then filter.
|
|
61
61
|
*
|
|
62
62
|
* @param {string} slug
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MongoAdapter
|
|
2
|
+
* MongoAdapter - MongoDB storage adapter for Collection entries. (Pro feature)
|
|
3
3
|
*
|
|
4
4
|
* Each CMS collection maps to a MongoDB collection prefixed with `cms_`
|
|
5
5
|
* (e.g. slug "contacts" → MongoDB collection "cms_contacts").
|
|
6
6
|
*
|
|
7
|
-
* Entry format is preserved: { id, data, meta }
|
|
7
|
+
* Entry format is preserved: { id, data, meta } - identical to FileAdapter output.
|
|
8
8
|
* MongoDB's _id field is stripped from all returned documents.
|
|
9
9
|
*
|
|
10
10
|
* Adapter interface:
|
|
@@ -66,7 +66,7 @@ export class MongoAdapter {
|
|
|
66
66
|
* and structured filtering.
|
|
67
67
|
*
|
|
68
68
|
* `filter` is translated to native MongoDB query operators via
|
|
69
|
-
* `filterEngine.toMongoQuery()` and pushed down to the database
|
|
69
|
+
* `filterEngine.toMongoQuery()` and pushed down to the database - page
|
|
70
70
|
* authors writing `[collection where_salary_gte="50000"]` get index-aware
|
|
71
71
|
* queries automatically. When `search` is present, all documents are
|
|
72
72
|
* fetched and filtered in memory (same behaviour as FileAdapter, kept
|
|
@@ -200,7 +200,7 @@ export class MongoAdapter {
|
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
202
|
* Drop the backing MongoDB collection entirely (documents, indexes and
|
|
203
|
-
* the namespace). Used when the CMS collection itself is deleted
|
|
203
|
+
* the namespace). Used when the CMS collection itself is deleted -
|
|
204
204
|
* clear() only removes documents and would leave `cms_<slug>` behind.
|
|
205
205
|
*
|
|
206
206
|
* Deliberately bypasses _col(): that helper ensures the unique index,
|
|
@@ -213,7 +213,7 @@ export class MongoAdapter {
|
|
|
213
213
|
try {
|
|
214
214
|
await this._db.collection(`${PREFIX}${slug}`).drop();
|
|
215
215
|
} catch (err) {
|
|
216
|
-
// Already absent
|
|
216
|
+
// Already absent - nothing to drop.
|
|
217
217
|
if (err.codeName !== 'NamespaceNotFound') throw err;
|
|
218
218
|
}
|
|
219
219
|
this._ensured.delete(slug);
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Definitions are DATA, never code: entries in the file-based `api-endpoints`
|
|
7
7
|
* preset collection. A definition binds a URL path (with `:param` segments)
|
|
8
|
-
* to a collection query
|
|
8
|
+
* to a collection query - fixed filters whose values may carry
|
|
9
9
|
* `{{params.<name>}}` / `{{query.<name>}}` placeholders, sort/limit, a read
|
|
10
10
|
* field allowlist, and an auth mode (public / token / role) that reuses the
|
|
11
11
|
* exact machinery of the public collections API.
|
|
12
12
|
*
|
|
13
|
-
* Substituted values only ever feed the filterEngine
|
|
13
|
+
* Substituted values only ever feed the filterEngine - there is no eval and
|
|
14
14
|
* no way for a definition to execute code.
|
|
15
15
|
*/
|
|
16
16
|
import {createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from './collections.js';
|
|
@@ -42,7 +42,7 @@ function invalidateRegistry() {
|
|
|
42
42
|
registry = null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// Generic admin collection endpoints emit these for ALL collections
|
|
45
|
+
// Generic admin collection endpoints emit these for ALL collections - the
|
|
46
46
|
// service's own mutations invalidate directly, this catches edits made
|
|
47
47
|
// through the admin entries grid.
|
|
48
48
|
for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
|
|
@@ -79,7 +79,7 @@ function sanitise(entry) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* Normalise a path to its shape for duplicate detection
|
|
82
|
+
* Normalise a path to its shape for duplicate detection - every `:param`
|
|
83
83
|
* segment collapses to `:` so `/a/:x` and `/a/:y` are the same shape.
|
|
84
84
|
*
|
|
85
85
|
* @param {string} path
|
|
@@ -113,12 +113,12 @@ async function validateDefinition(data, {excludeId} = {}) {
|
|
|
113
113
|
if (schema.systemManaged || schema.preset) {
|
|
114
114
|
throw new Error(`Collection "${data.collection}" is system-managed and cannot be exposed`);
|
|
115
115
|
}
|
|
116
|
-
// An endpoint may only expose collections from its own project or core
|
|
116
|
+
// An endpoint may only expose collections from its own project or core -
|
|
117
117
|
// the URL namespace must match where the data lives, and scoped users
|
|
118
118
|
// must not be able to publish another project's data through their own.
|
|
119
119
|
const collectionProject = resolveArtefactProject(schema);
|
|
120
120
|
if (collectionProject !== data.project && collectionProject !== CORE_PROJECT_SLUG) {
|
|
121
|
-
throw new Error(`Collection "${data.collection}" belongs to project "${collectionProject}"
|
|
121
|
+
throw new Error(`Collection "${data.collection}" belongs to project "${collectionProject}" - an endpoint may only expose collections from its own project or core`);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
if (typeof data.path !== 'string' || !data.path.startsWith('/')) {
|
|
@@ -131,13 +131,13 @@ async function validateDefinition(data, {excludeId} = {}) {
|
|
|
131
131
|
if (PARAM_SEGMENT_RE.test(seg)) {
|
|
132
132
|
paramNames.add(seg.slice(1));
|
|
133
133
|
} else if (!STATIC_SEGMENT_RE.test(seg)) {
|
|
134
|
-
throw new Error(`Invalid path segment "${seg}"
|
|
134
|
+
throw new Error(`Invalid path segment "${seg}" - use lowercase letters, numbers, hyphens, or :param`);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
const auth = data.auth || 'public';
|
|
139
139
|
if (auth !== 'public' && auth !== 'token' && !getRoleMap().has(auth)) {
|
|
140
|
-
throw new Error(`Unknown auth mode "${auth}"
|
|
140
|
+
throw new Error(`Unknown auth mode "${auth}" - use public, token, or a role name`);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
if (data.mode != null && !MODES.includes(data.mode)) throw new Error('mode must be "list" or "single"');
|
|
@@ -170,7 +170,7 @@ async function validateDefinition(data, {excludeId} = {}) {
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
// Duplicate shape per project
|
|
173
|
+
// Duplicate shape per project - `/a/:x` and `/a/:y` collide.
|
|
174
174
|
const shape = pathShape(data.path);
|
|
175
175
|
const {entries} = await listEntries(API_ENDPOINTS_COLLECTION_SLUG, {limit: 0});
|
|
176
176
|
const clash = entries.find(e =>
|
|
@@ -216,7 +216,7 @@ export async function createEndpoint(input) {
|
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
218
|
* Update an endpoint. The project binding is immutable (it is the endpoint's
|
|
219
|
-
* URL namespace)
|
|
219
|
+
* URL namespace) - create a new endpoint to move one.
|
|
220
220
|
*
|
|
221
221
|
* @param {string} id
|
|
222
222
|
* @param {object} patch
|
|
@@ -387,14 +387,14 @@ function substituteFilter(filterTemplate, params, query) {
|
|
|
387
387
|
}
|
|
388
388
|
|
|
389
389
|
/**
|
|
390
|
-
* Query keys that control the query envelope, not a field to filter on
|
|
390
|
+
* Query keys that control the query envelope, not a field to filter on - they
|
|
391
391
|
* must never be interpreted as ad-hoc filters (otherwise `?limit=5` would
|
|
392
392
|
* filter a field called "limit").
|
|
393
393
|
*/
|
|
394
394
|
const RESERVED_QUERY_KEYS = new Set(['page', 'limit', 'sort', 'order', 'search']);
|
|
395
395
|
|
|
396
396
|
/**
|
|
397
|
-
* The fields an endpoint is allowed to expose
|
|
397
|
+
* The fields an endpoint is allowed to expose - and therefore the only fields
|
|
398
398
|
* an ad-hoc query filter may touch. A read-field allowlist, when set, is the
|
|
399
399
|
* boundary; otherwise every declared collection field (plus the always-safe
|
|
400
400
|
* `id` / `createdBy` pseudo-fields) is fair game. Restricting ad-hoc filters
|
|
@@ -415,7 +415,7 @@ async function exposedFieldSet(def) {
|
|
|
415
415
|
* Collect ad-hoc filters from the request query, layered UNDER the definition's
|
|
416
416
|
* declared filters: reserved envelope keys are skipped, values must target an
|
|
417
417
|
* exposed field, and a field already constrained by a declared filter is left
|
|
418
|
-
* to the declaration (the curated filter always wins
|
|
418
|
+
* to the declaration (the curated filter always wins - a caller cannot loosen
|
|
419
419
|
* or override it).
|
|
420
420
|
*
|
|
421
421
|
* @param {Record<string, unknown>} query
|
|
@@ -442,7 +442,7 @@ function collectAdHocFilter(query, exposed, declaredFields) {
|
|
|
442
442
|
*
|
|
443
443
|
* The final filter is the definition's declared filters (fixed literals,
|
|
444
444
|
* {{params.x}}, {{query.x}}) with ad-hoc query-string filters layered beneath
|
|
445
|
-
* them
|
|
445
|
+
* them - declared always wins on any overlap.
|
|
446
446
|
*
|
|
447
447
|
* @param {object} def
|
|
448
448
|
* @param {Record<string, string>} params - Decoded path params
|
|
@@ -458,7 +458,7 @@ export async function executeEndpoint(def, params, query) {
|
|
|
458
458
|
const adhoc = collectAdHocFilter(q, exposed, declaredFields);
|
|
459
459
|
const filter = {...adhoc, ...declared}; // declared wins on any key collision
|
|
460
460
|
|
|
461
|
-
// Envelope controls
|
|
461
|
+
// Envelope controls - the reserved query keys tune pagination and sort
|
|
462
462
|
// rather than filter (list mode only). The definition's own limit is the
|
|
463
463
|
// ceiling: a caller may page/shrink within it but never pull more than the
|
|
464
464
|
// endpoint intends; sort is restricted to exposed fields.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Project-scoped machine credentials for the external collections API.
|
|
4
4
|
*
|
|
5
5
|
* Tokens are entries in the file-based `api-tokens` preset collection.
|
|
6
|
-
* Only a SHA-256 hash is stored
|
|
6
|
+
* Only a SHA-256 hash is stored - the plaintext (`dcms_<64 hex>`) is returned
|
|
7
7
|
* exactly once, from createToken(). A token is accepted only on collection
|
|
8
8
|
* verbs configured with `api.<verb>.access === 'token'`, and only for
|
|
9
9
|
* collections whose resolved project matches the token's `project` binding.
|
|
@@ -26,7 +26,7 @@ let tokenCache = null;
|
|
|
26
26
|
* Serialises this service's fire-and-forget writes (the throttled lastUsedAt
|
|
27
27
|
* update) against its mutations. FileAdapter does unlocked read-modify-write,
|
|
28
28
|
* so an in-flight lastUsedAt update that read the data file BEFORE a revoke
|
|
29
|
-
* wrote it would write the revoked token straight back
|
|
29
|
+
* wrote it would write the revoked token straight back - resurrection.
|
|
30
30
|
* Mutations await the chain; lastUsedAt updates append to it.
|
|
31
31
|
*/
|
|
32
32
|
let writeChain = Promise.resolve();
|
|
@@ -35,7 +35,7 @@ function invalidateCache() {
|
|
|
35
35
|
tokenCache = null;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Generic admin collection endpoints emit these for ALL collections
|
|
38
|
+
// Generic admin collection endpoints emit these for ALL collections - the
|
|
39
39
|
// service's own mutations invalidate directly, this catches edits made
|
|
40
40
|
// through the admin entries grid.
|
|
41
41
|
for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
|
|
@@ -162,7 +162,7 @@ export async function findTokenByName(name, project) {
|
|
|
162
162
|
/**
|
|
163
163
|
* Validate a presented plaintext token.
|
|
164
164
|
* Returns the sanitised entry when the token exists, is enabled, and has not
|
|
165
|
-
* expired
|
|
165
|
+
* expired - otherwise null. Updates lastUsedAt at most once per minute
|
|
166
166
|
* (fire-and-forget; the cached copy is patched in place to avoid thrash).
|
|
167
167
|
*
|
|
168
168
|
* @param {string} plaintext
|
|
@@ -184,7 +184,7 @@ export async function validateToken(plaintext) {
|
|
|
184
184
|
|
|
185
185
|
const lastUsed = entry.data.lastUsedAt ? Date.parse(entry.data.lastUsedAt) : 0;
|
|
186
186
|
if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
|
|
187
|
-
// updateEntry replaces data wholesale
|
|
187
|
+
// updateEntry replaces data wholesale - pass the full object.
|
|
188
188
|
const data = {...entry.data, lastUsedAt: new Date().toISOString()};
|
|
189
189
|
entry.data = data; // keep the cached copy current without invalidating
|
|
190
190
|
// Fire-and-forget, but chained so revoke/update can await it.
|
|
@@ -222,7 +222,7 @@ export async function getTokenSanitised(id) {
|
|
|
222
222
|
|
|
223
223
|
/**
|
|
224
224
|
* Update mutable token fields. The project binding, hash, and hint are fixed
|
|
225
|
-
* for the token's lifetime
|
|
225
|
+
* for the token's lifetime - revoke and re-issue to rebind.
|
|
226
226
|
*
|
|
227
227
|
* @param {string} id
|
|
228
228
|
* @param {{name?: string, enabled?: boolean, scopes?: object[], expiresAt?: string|null}} patch
|
|
@@ -264,7 +264,7 @@ export async function updateToken(id, patch = {}) {
|
|
|
264
264
|
* @returns {Promise<boolean>}
|
|
265
265
|
*/
|
|
266
266
|
export async function revokeToken(id) {
|
|
267
|
-
await writeChain; // drain any in-flight lastUsedAt write
|
|
267
|
+
await writeChain; // drain any in-flight lastUsedAt write - revoke must be final
|
|
268
268
|
const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
269
269
|
if (!entry) return false;
|
|
270
270
|
await deleteEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Blocks Service
|
|
3
3
|
* CRUD operations and seeding for reusable HTML block templates in content/blocks/.
|
|
4
|
-
* Never overwrites existing files during seeding
|
|
4
|
+
* Never overwrites existing files during seeding - user customisations are preserved.
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'fs/promises';
|
|
7
7
|
import path from 'path';
|
|
@@ -21,7 +21,7 @@ async function seedBlock(name, content) {
|
|
|
21
21
|
const filePath = path.join(BLOCKS_DIR, `${name}.html`);
|
|
22
22
|
try {
|
|
23
23
|
await fs.access(filePath);
|
|
24
|
-
// File exists
|
|
24
|
+
// File exists - leave it alone
|
|
25
25
|
} catch {
|
|
26
26
|
await fs.writeFile(filePath, content.trim() + '\n', 'utf8');
|
|
27
27
|
}
|
|
@@ -34,7 +34,7 @@ async function seedBlock(name, content) {
|
|
|
34
34
|
const BLOCKS = {
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* contact-info
|
|
37
|
+
* contact-info - standalone contact details panel for landing pages.
|
|
38
38
|
* Fields: heading, intro, phone, phoneHref, email, address, hours,
|
|
39
39
|
* facebook, facebookLabel, instagram, instagramLabel,
|
|
40
40
|
* website, websiteLabel, footnote
|
|
@@ -99,7 +99,7 @@ const BLOCKS = {
|
|
|
99
99
|
`,
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
* contact-card
|
|
102
|
+
* contact-card - for the Contacts form
|
|
103
103
|
* Fields: full_name, phone_number, email_address
|
|
104
104
|
*/
|
|
105
105
|
'contact-card': `
|
|
@@ -120,7 +120,7 @@ const BLOCKS = {
|
|
|
120
120
|
`,
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
|
-
* enquiry-card
|
|
123
|
+
* enquiry-card - for the Enquiries form
|
|
124
124
|
* Fields: full_name, email, phone, subject, message
|
|
125
125
|
*/
|
|
126
126
|
'enquiry-card': `
|
|
@@ -143,7 +143,7 @@ const BLOCKS = {
|
|
|
143
143
|
`,
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* feedback-card
|
|
146
|
+
* feedback-card - for the Feedback form
|
|
147
147
|
* Fields: name, email, rating, category, subject, message, recommend
|
|
148
148
|
*/
|
|
149
149
|
'feedback-card': `
|
|
@@ -159,7 +159,7 @@ const BLOCKS = {
|
|
|
159
159
|
<div class="card-body">
|
|
160
160
|
<p style="margin:0 0 1rem;line-height:1.65;">{{message}}</p>
|
|
161
161
|
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);">
|
|
162
|
-
<span>{{name}}
|
|
162
|
+
<span>{{name}} - {{email}}</span>
|
|
163
163
|
<span>Would recommend: <strong>{{recommend}}</strong></span>
|
|
164
164
|
</div>
|
|
165
165
|
</div>
|
|
@@ -167,7 +167,7 @@ const BLOCKS = {
|
|
|
167
167
|
`,
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
|
-
* note-card
|
|
170
|
+
* note-card - for the Notes form
|
|
171
171
|
* Fields: title, content, category, tags
|
|
172
172
|
*/
|
|
173
173
|
'note-card': `
|
|
@@ -187,7 +187,7 @@ const BLOCKS = {
|
|
|
187
187
|
`,
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* todo-item
|
|
190
|
+
* todo-item - for the To-Do form
|
|
191
191
|
* Fields: title, description, status, priority, due_date, assigned_to
|
|
192
192
|
*/
|
|
193
193
|
'todo-item': `
|
|
@@ -238,7 +238,7 @@ function blockMetaPath(name) {
|
|
|
238
238
|
return path.join(BLOCKS_DIR, `${name}.meta.json`);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Per-block CSS size cap
|
|
241
|
+
/** Per-block CSS size cap - matches the 100 KB site-wide cap, scaled down. */
|
|
242
242
|
const MAX_CSS_SIZE = 50 * 1024;
|
|
243
243
|
|
|
244
244
|
// ---------------------------------------------------------------------------
|
|
@@ -300,7 +300,7 @@ export async function getBlock(name) {
|
|
|
300
300
|
let css = '';
|
|
301
301
|
try {
|
|
302
302
|
css = await fs.readFile(blockCssPath(name), 'utf8');
|
|
303
|
-
} catch { /* no CSS file
|
|
303
|
+
} catch { /* no CSS file - treat as empty */
|
|
304
304
|
}
|
|
305
305
|
return {name, content, css, bundled, ...(meta && {meta})};
|
|
306
306
|
} catch (err) {
|
|
@@ -334,7 +334,7 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
|
|
|
334
334
|
await fs.mkdir(BLOCKS_DIR, {recursive: true});
|
|
335
335
|
await fs.writeFile(blockFilePath(name), content, 'utf8');
|
|
336
336
|
const metaPath = blockMetaPath(name);
|
|
337
|
-
// Merge sidecar meta
|
|
337
|
+
// Merge sidecar meta - bundled flag plus any caller-supplied fields (eg meta.project).
|
|
338
338
|
const sidecar = {
|
|
339
339
|
...(meta && typeof meta === 'object' ? meta : {}),
|
|
340
340
|
...(bundled ? {bundled: true} : {})
|
|
@@ -345,7 +345,7 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
|
|
|
345
345
|
await fs.unlink(metaPath).catch(() => {
|
|
346
346
|
});
|
|
347
347
|
}
|
|
348
|
-
// CSS is optional
|
|
348
|
+
// CSS is optional - only touch the file when the caller passed the field explicitly.
|
|
349
349
|
if (typeof css === 'string') {
|
|
350
350
|
if (css.trim().length > 0) {
|
|
351
351
|
await fs.writeFile(blockCssPath(name), css, 'utf8');
|
|
@@ -3,7 +3,7 @@ import {LRU} from '../lru.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* In-memory cache driver with TTL and tag invalidation.
|
|
5
5
|
*
|
|
6
|
-
* Each entry is stored as {value, expiresAt, tags}. TTL is lazy
|
|
6
|
+
* Each entry is stored as {value, expiresAt, tags}. TTL is lazy - entries
|
|
7
7
|
* past their expiresAt are returned as undefined on the next get and dropped.
|
|
8
8
|
*
|
|
9
9
|
* Tag index: a Map<tag, Set<key>>. set() with {tags} adds the key to each
|
|
@@ -79,14 +79,14 @@ export class MemoryDriver {
|
|
|
79
79
|
this._tagIndex.clear();
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** Current entry count (sync
|
|
82
|
+
/** Current entry count (sync - for the dashboard widget). */
|
|
83
83
|
size() {
|
|
84
84
|
return this._lru.size;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Snapshot the currently cached entries as `{key, tags, expiresAt}`.
|
|
89
|
-
* No values
|
|
89
|
+
* No values - they can be megabytes of HTML. Uses `entries()` (non-mutating)
|
|
90
90
|
* so the LRU order is preserved through the iteration.
|
|
91
91
|
*/
|
|
92
92
|
listEntries() {
|