domma-cms 1.8.12 → 1.8.14

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.
@@ -112,8 +112,7 @@ export async function pluginMarketplaceRoutes(fastify) {
112
112
  // bare "Installation failed." while Install from file showed it.
113
113
  const safe = err.expose === true || [
114
114
  'Plugin already installed', 'Plugin signature invalid', 'Unknown public key',
115
- 'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade',
116
- 'This site accepts signed plugins only'
115
+ 'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade'
117
116
  ].some(m => err.message.startsWith(m));
118
117
  return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
119
118
  }
@@ -2,6 +2,7 @@
2
2
  * Plugins API
3
3
  * GET /api/plugins - list all discovered plugins + state (admin only)
4
4
  * GET /api/plugins/_count - the sidebar badge (`_` is outside the slug alphabet)
5
+ * GET /api/plugins/_policy - {signedOnly}: what the screens may offer (services/codePolicy.js)
5
6
  * POST /api/plugins/scaffold - create a new plugin from plugins/_template (admin only)
6
7
  * PUT /api/plugins/:name - enable/disable, update settings (admin only)
7
8
  * GET /api/plugins/admin-config - sidebar/routes/views for enabled plugins (authenticated)
@@ -27,6 +28,7 @@ import {getToolStates, planSwitch, requiresOf, switchableUnits, usesOf} from '..
27
28
  import {applyCascade} from './tools.js';
28
29
  import {scaffoldPlugin} from '../../services/pluginScaffold.js';
29
30
  import {deletePluginFile, listPluginFiles, readPluginFile, writePluginFile} from '../../services/pluginFiles.js';
31
+ import {signedOnly, signedOnlyError} from '../../services/codePolicy.js';
30
32
  import {getConfig} from '../../config.js';
31
33
  import path from 'path';
32
34
  import {existsSync, readFileSync} from 'node:fs';
@@ -217,6 +219,10 @@ export async function pluginsRoutes(fastify, opts = {}) {
217
219
  const canUpdate = { preHandler: [authenticate, requirePermission('plugins', 'update')] };
218
220
  const canDevelop = { preHandler: [authenticate, requirePermission('plugins', 'develop')] };
219
221
 
222
+ // A signed-only refusal carries its code so a screen can tell it from a failure.
223
+ const mapErr = (reply, err) => reply.status(err.statusCode || 500)
224
+ .send({ error: err.message, ...(err.code === 'signed-only' && {code: err.code}) });
225
+
220
226
  // List all plugins with their current state
221
227
  fastify.get('/plugins', canRead, async (request) => {
222
228
  const manifests = await discoverPlugins();
@@ -286,6 +292,12 @@ export async function pluginsRoutes(fastify, opts = {}) {
286
292
  return pluginsBadge(plugins, await latestVersions());
287
293
  });
288
294
 
295
+ // What this site lets its admin do with plugin code. A signed-only site
296
+ // (services/codePolicy.js) refuses New plugin, unsigned uploads and code
297
+ // edits - the screens ask so they can leave those off rather than offer
298
+ // them and show the refusal. The refusal is the guard, not this.
299
+ fastify.get('/plugins/_policy', canRead, async () => ({signedOnly: signedOnly()}));
300
+
289
301
  // Scaffold a new plugin from plugins/_template. The plugin is created
290
302
  // pre-enabled so it goes live on the next server restart (its routes and
291
303
  // admin views only register at boot - same restart rule as enable/disable).
@@ -296,7 +308,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
296
308
  savePluginState(result.name, { enabled: true });
297
309
  return { success: true, name: result.name, files: result.files, restartRequired: true };
298
310
  } catch (err) {
299
- return reply.status(err.statusCode || 500).send({ error: err.message });
311
+ return mapErr(reply, err);
300
312
  }
301
313
  });
302
314
 
@@ -371,13 +383,12 @@ export async function pluginsRoutes(fastify, opts = {}) {
371
383
  });
372
384
 
373
385
  // Return merged admin config (sidebar, routes, views) for enabled plugins
374
- fastify.get('/plugins/admin-config', { preHandler: [authenticate] }, async () => {
375
- return getAdminPluginConfig();
386
+ fastify.get('/plugins/admin-config', { preHandler: [authenticate] }, async (request) => {
387
+ return getAdminPluginConfig(request.user);
376
388
  });
377
389
 
378
390
  // ── In-admin code editor (plugins.develop - code execution grade) ──────
379
391
 
380
- const mapErr = (reply, err) => reply.status(err.statusCode || 500).send({ error: err.message });
381
392
 
382
393
  /*
383
394
  * May this request read a LICENSED plugin's source?
@@ -388,7 +399,9 @@ export async function pluginsRoutes(fastify, opts = {}) {
388
399
  * without opening it on the sites they are distributed to. Level rather
389
400
  * than role name, the same way requireAdmin and canManageUser decide.
390
401
  */
391
- const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0;
402
+ // A signed-only site is a hosted one, where level 0 is the customer - the
403
+ // assumption above does not hold there, so nobody reads licensed source.
404
+ const mayEditLicensed = (request) => !!request.user && getEffectiveLevel(request.user) === 0 && !signedOnly();
392
405
  const licensed = (request) => ({allowLicensed: mayEditLicensed(request)});
393
406
 
394
407
  // List a plugin's editable files
@@ -465,6 +478,14 @@ export async function pluginsRoutes(fastify, opts = {}) {
465
478
  return reply.code(400).send({error: err.message});
466
479
  }
467
480
 
481
+ // A signed-only site refuses an unsigned file outright - before the
482
+ // "already installed, update it?" question, which would otherwise be
483
+ // asked about something that can never be installed.
484
+ if (!parts.signature && signedOnly()) {
485
+ const err = signedOnlyError();
486
+ return reply.code(err.statusCode).send({error: err.message, code: err.code});
487
+ }
488
+
468
489
  // The slug comes from the manifest inside, never from the filename: a
469
490
  // file can be renamed by anyone, a manifest is what the installer then
470
491
  // validates against what was actually extracted.
@@ -496,6 +517,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
496
517
  });
497
518
  } catch (err) {
498
519
  fastify.log.warn({err}, '[plugins] upload install failed');
520
+ if (err.code === 'signed-only') return mapErr(reply, err);
499
521
  return reply.code(400).send({error: err.message});
500
522
  }
501
523
 
@@ -17,6 +17,7 @@
17
17
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
18
18
  */
19
19
  import {authenticate as defaultAuthenticate, checkVisibility} from '../../middleware/auth.js';
20
+ import {isDcmStaff} from '../../services/staffAccess.js';
20
21
  import {getRegisteredSidebarItems} from '../../services/hooks.js';
21
22
  import {filterItemsForUser, getMenu} from '../../services/menus.js';
22
23
  import {isDashboardPro} from '../../services/widgets.js';
@@ -60,6 +61,8 @@ export async function sidebarRoutes(fastify, opts = {}) {
60
61
  ? {...entry, item: {...entry.item, text: boardLabel}}
61
62
  : entry))
62
63
  .filter(entry => !entry?.item?.hidden && checkVisibility(request.user, entry?.item?.visibility))
64
+ // A staff-only Tool's entry (services/tools.js `staff`): the Manager's staff sessions only.
65
+ .filter(entry => !entry?.item?.staffOnly || isDcmStaff(request.user))
63
66
  .map(entry => Array.isArray(entry.item?.items)
64
67
  ? {...entry, item: {...entry.item, items: filterItemsForUser(entry.item.items, request.user)}}
65
68
  : entry);
@@ -2,7 +2,8 @@
2
2
  * Built-in Tool switches - Contacts, Notes, Todo, Analytics (services/tools.js).
3
3
  *
4
4
  * GET /api/tools - each built-in Tool, on/off, locked, and the plugins that depend on it (plugins.read)
5
- * GET /api/tools/enabled - {name: boolean}, for any signed-in user (plugin screens hide what needs a Tool that is off)
5
+ * GET /api/tools/enabled - {name: boolean}, for any signed-in user (plugin screens hide what needs a Tool that is off);
6
+ * a staff-only Tool (`staff: true`, services/tools.js) is true for a staff session only
6
7
  * PUT /api/tools/:name - {enabled, cascade?} (plugins.update). Answers 423 when the site's manager has
7
8
  * locked it, and 409 with the plan when the switch moves other Tools too - repeat
8
9
  * with `cascade: true` to apply. Restarts the site like a plugin switch does.
@@ -12,7 +13,10 @@ import {
12
13
  requirePermission as defaultRequirePermission
13
14
  } from '../../middleware/auth.js';
14
15
  import {discoverPlugins, getPluginStates, runLifecycleHook, savePluginState} from '../../services/plugins.js';
15
- import {CORE_TOOLS, getToolStates, isCoreTool, isToolEnabled, planSwitch, saveToolState, switchableUnits} from '../../services/tools.js';
16
+ import {
17
+ CORE_TOOLS, getToolStates, isCoreTool, isToolEnabled, isToolStaffOnly, planSwitch, saveToolState, switchableUnits, toolOpenAt
18
+ } from '../../services/tools.js';
19
+ import {isDcmStaff, isStaffRequest} from '../../services/staffAccess.js';
16
20
  import {autoRestartEnabled, hasSupervisor, scheduleRestart} from './plugins.js';
17
21
 
18
22
  /**
@@ -39,15 +43,22 @@ export async function applyCascade(cascaded, fastify) {
39
43
  * the Tool's prefix; the inner routes must NOT be given it again, or every
40
44
  * route lands at /api/notes/api/notes/... (0.88.0 did exactly that on dcm).
41
45
  *
46
+ * Off with `staff: true` (services/tools.js) lets a staff session through
47
+ * (services/staffAccess.js) and nobody else - not the site's own top role;
48
+ * the routes' own authenticate/permission checks still run.
49
+ *
42
50
  * @param {string} name the Tool (services/tools.js CORE_TOOLS)
43
51
  * @param {Function} routes its Fastify plugin
44
52
  * @param {(name: string) => boolean} [isEnabled]
53
+ * @param {{isStaffOnly?: (name: string) => boolean, staffPass?: (request: object) => Promise<boolean>}} [opts]
45
54
  */
46
- export function toolGate(name, routes, isEnabled = isToolEnabled) {
55
+ export function toolGate(name, routes, isEnabled = isToolEnabled, {isStaffOnly = isToolStaffOnly, staffPass = isStaffRequest} = {}) {
47
56
  const displayName = CORE_TOOLS.find(t => t.name === name)?.displayName || name;
48
57
  return async (scope) => {
49
58
  scope.addHook('onRequest', async (request, reply) => {
50
- if (!isEnabled(name)) return reply.code(404).send({error: `${displayName} is switched off on this site.`});
59
+ if (isEnabled(name)) return;
60
+ if (isStaffOnly(name) && await staffPass(request)) return;
61
+ return reply.code(404).send({error: `${displayName} is switched off on this site.`});
51
62
  });
52
63
  await scope.register(routes);
53
64
  };
@@ -74,6 +85,8 @@ export async function toolsRoutes(fastify, opts = {}) {
74
85
  ...t,
75
86
  enabled: u.enabled,
76
87
  locked: u.locked,
88
+ // Off for the site, kept open to the Manager's staff sessions.
89
+ staff: u.staff === true,
77
90
  // Enabled plugins that stop, or lose something, if this goes off.
78
91
  requiredBy: plugins.filter(p => p.enabled && p.requires.includes(t.name)).map(p => ({name: p.name, displayName: p.displayName})),
79
92
  usedBy: plugins.filter(p => p.enabled && t.name in p.uses).map(p => ({name: p.name, displayName: p.displayName, reason: p.uses[t.name]}))
@@ -81,9 +94,10 @@ export async function toolsRoutes(fastify, opts = {}) {
81
94
  });
82
95
  });
83
96
 
84
- fastify.get('/tools/enabled', {preHandler: [authenticate]}, async () => {
97
+ fastify.get('/tools/enabled', {preHandler: [authenticate]}, async (request) => {
85
98
  const states = getToolStates();
86
- return Object.fromEntries(CORE_TOOLS.map(t => [t.name, states[t.name]?.enabled !== false]));
99
+ const staff = isDcmStaff(request.user);
100
+ return Object.fromEntries(CORE_TOOLS.map(t => [t.name, toolOpenAt(states[t.name], staff)]));
87
101
  });
88
102
 
89
103
  fastify.put('/tools/:name', canUpdate, async (request, reply) => {
package/server/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Domma dist files are served directly from node_modules/domma-js/public/dist.
5
5
  * Updated: blobs + z-index fixes.
6
6
  */
7
- import 'dotenv/config';
7
+ import './env.js';
8
8
  import Fastify from 'fastify';
9
9
  import jwt from '@fastify/jwt';
10
10
  import staticPlugin from '@fastify/static';
@@ -578,7 +578,7 @@ await app.register(scaffoldRoutes, {prefix: '/api'});
578
578
  // Each can be switched off (config/tools.json, services/tools.js): the gate is
579
579
  // read per request, so an API stops answering the moment the switch is saved,
580
580
  // before the restart that takes its sidebar entry and sources away.
581
- const {isToolEnabled} = await import('./services/tools.js');
581
+ const {isToolEnabled, isToolStaffOnly} = await import('./services/tools.js');
582
582
  const {toolGate, toolsRoutes} = await import('./routes/api/tools.js');
583
583
  await app.register(toolGate('notes', notesRoutes), {prefix: '/api/notes'});
584
584
  await app.register(toolGate('todo', todoRoutes), {prefix: '/api/todo'});
@@ -635,7 +635,13 @@ await app.register(relatedRoutes, {prefix: '/api/related'});
635
635
  const {registerSidebarItem} = await import('./services/hooks.js');
636
636
  // `countUrl` is the badge: the sidebar fetches it and shows {count, label, tone}
637
637
  // (see fetchCounts in admin/js/lib/sidebar-renderer.js).
638
- // A built-in Tool switched off gets no entry (its switch restarts the site).
638
+ // A built-in Tool switched off gets no entry (its switch restarts the site) - unless it is
639
+ // off with `staff: true` (the Manager's lock on hosted sites): then the entry is there for
640
+ // the Manager's staff sessions only (`staffOnly`, checked per viewer by /api/sidebar/registered-items).
641
+ const toolEntry = (name, item) => {
642
+ if (isToolEnabled(name)) registerSidebarItem({item: {source: 'Built in', ...item}});
643
+ else if (isToolStaffOnly(name)) registerSidebarItem({item: {source: 'Built in', ...item, staffOnly: true}});
644
+ };
639
645
  const coreToolItems = [
640
646
  {text: 'Notes', url: '#/notes', icon: 'edit-3', permission: 'notes', countUrl: '/api/notes/count'},
641
647
  {text: 'Todo', url: '#/todo', icon: 'check-square', permission: 'todo', countUrl: '/api/todo/count'},
@@ -644,12 +650,8 @@ const coreToolItems = [
644
650
  {text: 'Contacts', url: '#/contacts', icon: 'users', permission: 'contacts', countUrl: '/api/contacts/count', inventory: true},
645
651
  {text: 'SEO', url: '#/seo', icon: 'search', permission: 'seo', countUrl: '/api/seo/count'}
646
652
  ];
647
- for (const item of coreToolItems) {
648
- if (isToolEnabled(item.permission)) registerSidebarItem({item: {source: 'Built in', ...item}});
649
- }
650
- if (isToolEnabled('wordpress-import')) {
651
- registerSidebarItem({item: {source: 'Built in', text: 'Import from WordPress', url: '#/wordpress-import', icon: 'download', permission: 'import', countUrl: '/api/import/wordpress/count', inventory: true}});
652
- }
653
+ for (const item of coreToolItems) toolEntry(item.permission, item);
654
+ toolEntry('wordpress-import', {text: 'Import from WordPress', url: '#/wordpress-import', icon: 'download', permission: 'import', countUrl: '/api/import/wordpress/count', inventory: true});
653
655
  // Todo's to-dos on the calendar (registered when routes/api/todo.js loads).
654
656
  if (!isToolEnabled('todo')) {
655
657
  const {unregisterCalendarSource} = await import('./services/hooks.js');
@@ -747,6 +749,10 @@ await app.register(docsPublicRoutes);
747
749
  // An instant call's page, /_call/<id> (a reserved prefix, like /_preview) - before the catch-all.
748
750
  const { callPublicRoutes } = await import('./routes/call-public.js');
749
751
  await app.register(callPublicRoutes);
752
+ // A staff session from the site's Manager (/_staff/open?ticket=…, services/staffAccess.js) - the one way
753
+ // into a Tool switched off with `staff: true`. A ticket only works on a site its Manager started.
754
+ const { staffAccessRoutes } = await import('./services/staffAccess.js');
755
+ await app.register(staffAccessRoutes);
750
756
 
751
757
  // The outreach unsubscribe link (/campaign-unsubscribe) - core's, so it outlives the Tool that
752
758
  // sent the email. After the plugins: an older Campaigns Pro that registers it itself wins.
@@ -23,6 +23,7 @@ import path from 'path';
23
23
  import { config } from '../../config.js';
24
24
  import { applyFilter } from '../filterEngine.js';
25
25
  import { withFileLock, writeFileAtomic } from './fileLock.js';
26
+ import {fileIn} from '../safePath.js';
26
27
 
27
28
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
28
29
 
@@ -31,7 +32,7 @@ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
31
32
  // ---------------------------------------------------------------------------
32
33
 
33
34
  function dataPath(slug) {
34
- return path.join(COLLECTIONS_DIR, slug, 'data.json');
35
+ return path.join(fileIn(COLLECTIONS_DIR, slug), 'data.json');
35
36
  }
36
37
 
37
38
  async function read(slug) {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Signed-only sites: who may put server-side code on this site.
3
+ *
4
+ * `config/server.json` `requireSignedPlugins: true` means the only plugin code
5
+ * this site runs is code the Marketplace signed. A hosted site (one a Domma CMS
6
+ * Manager runs) has it on, because its owner holds the top role there and a
7
+ * level-0 user who can run their own code can read the site's secrets - and
8
+ * with JWT_SECRET forge a Manager staff session (services/staffAccess.js).
9
+ * Self-hosted sites leave it off: there the owner IS the host.
10
+ *
11
+ * With it on, every way the admin had of getting its own JavaScript onto the
12
+ * server is refused here, at the service, so a second route or a plugin calling
13
+ * the same service cannot step round it:
14
+ *
15
+ * - installing an unsigned .dcmsplugin (pluginInstaller.installPlugin)
16
+ * - New plugin (pluginScaffold.scaffoldPlugin)
17
+ * - writing or deleting a plugin's files in the code editor (pluginFiles)
18
+ * - reading a LICENSED plugin's source as level 0 - on a hosted site level 0
19
+ * is the customer, not the people who publish the plugin (routes/api/plugins.js)
20
+ *
21
+ * Three things say "on", and any one is enough - none can be undone from the
22
+ * site's own admin:
23
+ *
24
+ * 1. `DOMMA_SIGNED_ONLY=1` in the environment. The Manager sets it when it
25
+ * forks a site it hosts (after the site's .env, so .env cannot unset it).
26
+ * This is the one a file-write bug cannot touch.
27
+ * 2. server.json as it was at boot (config.server). Deleting or rewriting
28
+ * the file while the site runs changes nothing until a restart.
29
+ * 3. server.json now, read fresh - so the Manager can switch a running site
30
+ * on without restarting it.
31
+ *
32
+ * Nothing in the admin or the API writes server.json - it is the host's file.
33
+ * Keep it that way: a route that ever writes it must leave
34
+ * `requireSignedPlugins` exactly as it found it.
35
+ *
36
+ * @module services/codePolicy
37
+ */
38
+ import {config, getConfig} from '../config.js';
39
+
40
+ /** What the admin is told, whatever it tried. Plain words: it is the whole answer. */
41
+ export const SIGNED_ONLY_MESSAGE = 'This site only runs plugins signed by the Marketplace, so plugin code cannot be uploaded, created or edited here. Install plugins from Browse.';
42
+
43
+ /**
44
+ * Does this site refuse any plugin code the Marketplace has not signed?
45
+ *
46
+ * @param {NodeJS.ProcessEnv} [env]
47
+ * @returns {boolean}
48
+ */
49
+ export function signedOnly(env = process.env) {
50
+ if (env.DOMMA_SIGNED_ONLY === '1') return true;
51
+ if (config.server?.requireSignedPlugins === true) return true;
52
+ try {
53
+ return getConfig('server')?.requireSignedPlugins === true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The refusal, as an Error the routes map straight to a reply
61
+ * (`statusCode` 403, `code` 'signed-only', `expose` for the marketplace route).
62
+ *
63
+ * @returns {Error}
64
+ */
65
+ export function signedOnlyError() {
66
+ return Object.assign(new Error(SIGNED_ONLY_MESSAGE), {statusCode: 403, code: 'signed-only', expose: true});
67
+ }
68
+
69
+ /** Throw the refusal when the site is signed-only. */
70
+ export function assertMayChangeCode() {
71
+ if (signedOnly()) throw signedOnlyError();
72
+ }
73
+
74
+ /**
75
+ * MongoDB client options that reach outside the database: start a program
76
+ * (`autoEncryption.extraOptions.mongocryptdSpawnPath` / `...SpawnArgs`), load a
77
+ * native library (`cryptSharedLibPath`) or read a file off this server
78
+ * (`tlsCAFile`, `tlsCertificateKeyFile`, the old `ssl*` names). The admin
79
+ * writes connections.json (PUT /api/collections/connections) and the site -
80
+ * and the Manager's Data Manager - hand its `options` to `new MongoClient`.
81
+ * Today the spawn needs `mongodb-client-encryption`, which is not installed;
82
+ * this keeps it that way on a signed-only site whatever gets installed later.
83
+ */
84
+ const RISKY_MONGO_OPTION = /^(autoEncryption|extraOptions|.*File|.*Path|.*SpawnArgs|ssl(CA|Cert|Key|CRL|PEMKeyFile|PEMKeyPassword)|cryptShared.*|mongocryptd.*)$/i;
85
+
86
+ /**
87
+ * The risky option names in one stored connection (its `options`, at any depth,
88
+ * and its URI's query string). Empty when it is fine.
89
+ *
90
+ * @param {{uri?: string, options?: object}} conn
91
+ * @returns {string[]}
92
+ */
93
+ export function riskyConnectionOptions(conn) {
94
+ const found = [];
95
+ const walk = (obj, at) => {
96
+ if (!obj || typeof obj !== 'object') return;
97
+ for (const [k, v] of Object.entries(obj)) {
98
+ const here = at ? `${at}.${k}` : k;
99
+ if (RISKY_MONGO_OPTION.test(k)) found.push(here);
100
+ else walk(v, here);
101
+ }
102
+ };
103
+ walk(conn?.options, 'options');
104
+ const query = String(conn?.uri || '').split('?')[1] || '';
105
+ for (const pair of query.split('&')) {
106
+ let key = pair.split('=')[0];
107
+ try { key = decodeURIComponent(key); } catch { /* keep it raw */ }
108
+ if (key && RISKY_MONGO_OPTION.test(key)) found.push(`uri ${key}`);
109
+ }
110
+ return found;
111
+ }
@@ -14,6 +14,7 @@ import {adapterForStorage, describeAdapter, getAdapter, invalidate, isFixedToFil
14
14
  import * as cache from './cache/index.js';
15
15
  import {typedEntryData} from './entryValues.js';
16
16
  import {toCsv} from '../../public/js/collection-export.mjs';
17
+ import {fileIn} from './safePath.js';
17
18
 
18
19
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
19
20
  const TOOL_TRASH_SLUGS = new Set(['notes-notes', 'todo-items', 'contacts-contacts']);
@@ -27,7 +28,7 @@ async function ensureDir() {
27
28
  }
28
29
 
29
30
  function collectionDir(slug) {
30
- return path.join(COLLECTIONS_DIR, slug);
31
+ return fileIn(COLLECTIONS_DIR, slug);
31
32
  }
32
33
 
33
34
  function schemaPath(slug) {
@@ -7,6 +7,7 @@ import fs from 'fs/promises';
7
7
  import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
9
  import {getPluginComponents} from './hooks.js';
10
+ import {fileIn} from './safePath.js';
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
13
  const COMPONENTS_DIR = path.resolve(__dirname, '../../content/components');
@@ -28,7 +29,7 @@ function assertValidName(name) {
28
29
  * @param {string} name - Component name (without extension).
29
30
  * @returns {string}
30
31
  */
31
- function componentFilePath(name) { return path.join(COMPONENTS_DIR, `${name}.dmc`); }
32
+ function componentFilePath(name) { return fileIn(COMPONENTS_DIR, name, '.dmc'); }
32
33
 
33
34
  /**
34
35
  * Absolute filesystem path to a component's companion .meta.json file.
@@ -36,7 +37,7 @@ function componentFilePath(name) { return path.join(COMPONENTS_DIR, `${name}.dmc
36
37
  * @param {string} name - Component name (without extension).
37
38
  * @returns {string}
38
39
  */
39
- function componentMetaPath(name) { return path.join(COMPONENTS_DIR, `${name}.meta.json`); }
40
+ function componentMetaPath(name) { return fileIn(COMPONENTS_DIR, name, '.meta.json'); }
40
41
 
41
42
  /**
42
43
  * Split a .dmc source string into its four SFC blocks.
@@ -35,6 +35,14 @@ export async function initialise(connectionsConfig) {
35
35
  continue;
36
36
  }
37
37
 
38
+ // Written before the site went signed-only, or by hand: not used there.
39
+ const {riskyConnectionOptions, signedOnly} = await import('./codePolicy.js');
40
+ const risky = signedOnly() ? riskyConnectionOptions(cfg) : [];
41
+ if (risky.length) {
42
+ console.error(`[connectionManager] Skipping connection "${name}": ${risky.join(', ')} not allowed on a signed-only site`);
43
+ continue;
44
+ }
45
+
38
46
  try {
39
47
  const client = new MongoClient(cfg.uri, cfg.options || {});
40
48
  await client.connect();
@@ -501,7 +501,7 @@ function urlPathToFilePath(urlPath) {
501
501
  const normalised = urlPath === '/' || !urlPath ? 'index' : urlPath.replace(/^\//, '').replace(/\/$/, '');
502
502
  const result = path.join(PAGES_DIR, normalised + '.md');
503
503
  const resolved = path.resolve(result);
504
- if (!resolved.startsWith(path.resolve(PAGES_DIR))) {
504
+ if (!resolved.startsWith(path.resolve(PAGES_DIR) + path.sep)) {
505
505
  throw new Error('Invalid path: outside content directory');
506
506
  }
507
507
  return result;
@@ -20,6 +20,7 @@ import path from 'path';
20
20
  import {fileURLToPath} from 'url';
21
21
  import * as cache from './cache/index.js';
22
22
  import {cssClassProblems} from '../../public/js/css-classes.mjs';
23
+ import {fileIn} from './safePath.js';
23
24
 
24
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
26
  const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
@@ -66,7 +67,7 @@ export async function ensureContextMenusDir() {
66
67
  }
67
68
 
68
69
  function ctxPath(slug) {
69
- return path.join(CTX_DIR, `${slug}.json`);
70
+ return fileIn(CTX_DIR, slug, '.json');
70
71
  }
71
72
 
72
73
  async function readCtxFile(slug) {
@@ -8,6 +8,7 @@ import path from 'path';
8
8
  import {fileURLToPath} from 'url';
9
9
  import * as cache from './cache/index.js';
10
10
  import {buttonSettingsProblems, formClassProblems} from '../../public/js/form-submit.mjs';
11
+ import {fileIn} from './safePath.js';
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  const ROOT = path.resolve(__dirname, '..', '..');
@@ -30,7 +31,7 @@ export async function ensureFormsDir() {
30
31
  * @throws {Error} If the form file does not exist or cannot be parsed.
31
32
  */
32
33
  export async function readForm(slug) {
33
- const file = path.join(FORMS_DIR, `${slug}.json`);
34
+ const file = fileIn(FORMS_DIR, slug, '.json');
34
35
  return JSON.parse(await fs.readFile(file, 'utf8'));
35
36
  }
36
37
 
@@ -43,7 +44,7 @@ export async function readForm(slug) {
43
44
  */
44
45
  export async function writeForm(slug, data) {
45
46
  await ensureFormsDir();
46
- const file = path.join(FORMS_DIR, `${slug}.json`);
47
+ const file = fileIn(FORMS_DIR, slug, '.json');
47
48
  await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
48
49
  await cache.invalidateTags([`form:${slug}`]);
49
50
  }
@@ -83,7 +84,7 @@ export async function listForms() {
83
84
  * @throws {Error} If the form file does not exist.
84
85
  */
85
86
  export async function deleteForm(slug) {
86
- await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
87
+ await fs.unlink(fileIn(FORMS_DIR, slug, '.json'));
87
88
  await cache.invalidateTags([`form:${slug}`]);
88
89
  }
89
90
 
@@ -21,6 +21,7 @@ import {
21
21
  LEGACY_STYLE_HOVER, LEGACY_STYLE_ACTIVE, legacyStyleHighlight,
22
22
  PRESET_COLOUR_VARS, PRESET_COLOUR_FALLBACKS, colourToCss, cssClassProblems
23
23
  } from '../../public/js/menu-decor.mjs';
24
+ import {fileIn} from './safePath.js';
24
25
 
25
26
  /**
26
27
  * Bust the response-cache tags that menu changes affect. Public pages are
@@ -472,7 +473,7 @@ export async function ensureMenusDir() {
472
473
  }
473
474
 
474
475
  function menuPath(slug) {
475
- return path.join(MENUS_DIR, `${slug}.json`);
476
+ return fileIn(MENUS_DIR, slug, '.json');
476
477
  }
477
478
 
478
479
  async function readMenuFile(slug) {
@@ -13,13 +13,16 @@
13
13
  * exception is a SUPER-ADMIN (role level 0), which on the fleet exists only on
14
14
  * the manager: the people who publish the paid plugins are the people who need
15
15
  * to read and edit them. Customer sites are all level 1, so nothing changes
16
- * there - see the `allowLicensed` argument, set from the request's user.
16
+ * there - see the `allowLicensed` argument, set from the request's user. (Hosted
17
+ * owners are in fact level 0 on their own site: on a signed-only site the route
18
+ * never allows licensed source - services/codePolicy.js.)
17
19
  *
18
20
  * Thrown errors carry `statusCode` (400 invalid, 403 licensed, 404 missing,
19
21
  * 413 too large) for direct mapping in the route layer.
20
22
  */
21
23
  import fs from 'fs/promises';
22
24
  import path from 'path';
25
+ import {assertMayChangeCode} from './codePolicy.js';
23
26
 
24
27
  const PLUGINS_DIR = path.resolve('plugins');
25
28
 
@@ -200,6 +203,8 @@ export async function readPluginFile(name, relPath, {allowLicensed = false} = {}
200
203
  * @returns {Promise<{path: string, restartRequired: boolean}>}
201
204
  */
202
205
  export async function writePluginFile(name, relPath, content, {allowLicensed = false} = {}) {
206
+ // A signed-only site runs the Marketplace's code as signed: nothing here may change it.
207
+ assertMayChangeCode();
203
208
  const pluginDir = await resolvePluginDir(name, allowLicensed);
204
209
  const abs = await resolveFilePath(pluginDir, relPath);
205
210
 
@@ -240,6 +245,8 @@ export async function writePluginFile(name, relPath, content, {allowLicensed = f
240
245
  * @returns {Promise<{path: string}>}
241
246
  */
242
247
  export async function deletePluginFile(name, relPath, {allowLicensed = false} = {}) {
248
+ // A signed-only site runs the Marketplace's code as signed: nothing here may change it.
249
+ assertMayChangeCode();
243
250
  const pluginDir = await resolvePluginDir(name, allowLicensed);
244
251
  const abs = await resolveFilePath(pluginDir, relPath);
245
252
  const rel = path.relative(pluginDir, abs).split(path.sep).join('/');
@@ -16,9 +16,9 @@ import {readFileSync} from 'fs';
16
16
  import path from 'path';
17
17
  import zlib from 'zlib';
18
18
  import {fileURLToPath} from 'url';
19
- import {getConfig} from '../config.js';
20
19
  import {discoverPlugins, pluginRoleHooks, unregisterAllRolesForPlugin, validatePluginDir} from './plugins.js';
21
20
  import {allowInGitignore, manifestContentPaths, pluginContentRules} from './siteGitignore.js';
21
+ import {signedOnly, signedOnlyError} from './codePolicy.js';
22
22
 
23
23
  // ---------------------------------------------------------------------------
24
24
  // Paths
@@ -74,18 +74,14 @@ MCowBQYDK2VwAyEAqAlx1lfHeCaNiwJmoXZMDY56yafU2GlpNG3VInw9Q14=
74
74
  * Does this site refuse anything the marketplace has not signed?
75
75
  *
76
76
  * Default false, because a CMS whose own scaffolder produces unsigned plugins
77
- * cannot refuse unsigned plugins without refusing its user's own work. A fleet
78
- * site should set it true: everything it receives arrives by push-copy, and
79
- * nothing legitimate is ever uploaded there.
77
+ * cannot refuse unsigned plugins without refusing its user's own work. A hosted
78
+ * site has it on (services/codePolicy.js - the one place that decides, and the
79
+ * other doors it closes).
80
80
  *
81
81
  * @returns {boolean}
82
82
  */
83
83
  function requireSignedPlugins() {
84
- try {
85
- return getConfig('server')?.requireSignedPlugins === true;
86
- } catch {
87
- return false;
88
- }
84
+ return signedOnly();
89
85
  }
90
86
 
91
87
  /**
@@ -348,12 +344,12 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
348
344
  // Step 1 - Verify signature, or establish that there is deliberately none.
349
345
  const unsigned = !signature;
350
346
  if (unsigned) {
347
+ // Signed-only first: asking the admin to confirm something the site will
348
+ // refuse anyway would be a question with no right answer.
349
+ if (requireSignedPlugins()) throw signedOnlyError();
351
350
  if (!allowUnsigned) {
352
351
  throw new Error('This plugin is unsigned. Installing it runs its code on this server, so it has to be confirmed.');
353
352
  }
354
- if (requireSignedPlugins()) {
355
- throw new Error('This site accepts signed plugins only (server.json: requireSignedPlugins).');
356
- }
357
353
  }
358
354
 
359
355
  const pubKeyPem = unsigned ? null : trustedKey(publicKeyId, 'bundle');
@@ -396,7 +392,14 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
396
392
  throw err;
397
393
  }
398
394
 
399
- // Step 4 - Validate manifest
395
+ // Step 4 - Validate manifest. discoverPlugins() names a plugin after its
396
+ // folder, so it cannot tell a bundle installed under someone else's slug -
397
+ // the manifest inside must name this slug itself (as an upgrade checks).
398
+ const own = await validatePluginDir(destDir, slug);
399
+ if (!own.ok) {
400
+ await fs.rm(destDir, { recursive: true, force: true });
401
+ throw new Error(`Plugin manifest validation failed after extraction: ${slug} (${own.reason})`);
402
+ }
400
403
  const manifests = await discoverPlugins();
401
404
  const manifest = manifests.find(m => m.name === slug);
402
405
  if (!manifest) {
@@ -12,6 +12,7 @@
12
12
  import fs from 'fs/promises';
13
13
  import path from 'path';
14
14
  import {allowInGitignore, sitePluginRules} from './siteGitignore.js';
15
+ import {assertMayChangeCode} from './codePolicy.js';
15
16
 
16
17
  const PLUGINS_DIR = path.resolve('plugins');
17
18
  const TEMPLATE_DIR = path.join(PLUGINS_DIR, '_template');
@@ -71,6 +72,8 @@ export function validateScaffoldInput(input = {}) {
71
72
  * @returns {Promise<{name: string, files: string[]}>} Created plugin name and relative file list
72
73
  */
73
74
  export async function scaffoldPlugin(input) {
75
+ // A new plugin is new server code: a signed-only site makes none (services/codePolicy.js).
76
+ assertMayChangeCode();
74
77
  const { slug, displayName, description, author, icon } = validateScaffoldInput(input);
75
78
 
76
79
  try {