domma-cms 1.8.46 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  3. package/bin/lib/left-free-tier.js +83 -0
  4. package/bin/update.js +24 -0
  5. package/docs/DommaFeatures.md +47 -7
  6. package/docs/plugin-development.md +7 -0
  7. package/package.json +1 -1
  8. package/plugins/_lib/games/names.js +6 -0
  9. package/plugins/_lib/public/games/motion.css +1 -0
  10. package/plugins/_lib/public/games/motion.js +28 -0
  11. package/plugins/free-tier.lock.json +1 -43
  12. package/server/routes/api/api-endpoints.js +5 -1
  13. package/server/routes/api/collections.js +4 -3
  14. package/server/routes/api/forms.js +22 -0
  15. package/server/routes/api/views.js +18 -0
  16. package/server/services/collectionAccess.js +97 -15
  17. package/server/services/permissionRegistry.js +39 -11
  18. package/plugins/crossword/CLAUDE.md +0 -37
  19. package/plugins/crossword/admin/css/index.css +0 -18
  20. package/plugins/crossword/admin/templates/crossword.html +0 -1
  21. package/plugins/crossword/admin/views/crossword.js +0 -449
  22. package/plugins/crossword/collections/crossword-plays.json +0 -20
  23. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  24. package/plugins/crossword/config.js +0 -6
  25. package/plugins/crossword/docs/guide.md +0 -53
  26. package/plugins/crossword/inject/body.html +0 -1
  27. package/plugins/crossword/lib/crossword.js +0 -170
  28. package/plugins/crossword/lib/store.js +0 -56
  29. package/plugins/crossword/plugin.js +0 -274
  30. package/plugins/crossword/plugin.json +0 -79
  31. package/plugins/crossword/public/crossword.css +0 -36
  32. package/plugins/crossword/public/crossword.js +0 -519
  33. package/plugins/crossword/public/lib/layout.js +0 -199
  34. package/plugins/quiz/CLAUDE.md +0 -43
  35. package/plugins/quiz/admin/css/index.css +0 -13
  36. package/plugins/quiz/admin/templates/quiz.html +0 -1
  37. package/plugins/quiz/admin/views/quiz.js +0 -530
  38. package/plugins/quiz/collections/quiz-plays.json +0 -20
  39. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  40. package/plugins/quiz/config.js +0 -6
  41. package/plugins/quiz/docs/guide.md +0 -61
  42. package/plugins/quiz/inject/body.html +0 -1
  43. package/plugins/quiz/lib/engine.js +0 -115
  44. package/plugins/quiz/lib/quiz.js +0 -225
  45. package/plugins/quiz/lib/store.js +0 -56
  46. package/plugins/quiz/plugin.js +0 -275
  47. package/plugins/quiz/plugin.json +0 -79
  48. package/plugins/quiz/public/quiz.css +0 -36
  49. package/plugins/quiz/public/quiz.js +0 -372
@@ -17,6 +17,19 @@
17
17
  */
18
18
  import {canReadView, createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
19
19
  import {authenticate, requirePermission} from '../../middleware/auth.js';
20
+ import {pluginDenialFor} from '../../services/collectionAccess.js';
21
+
22
+ /**
23
+ * Sends 403 and answers true when a pipeline reads (as its source or through a
24
+ * $lookup) a plugin's collection the caller may not read through the
25
+ * Collections API (collectionAccess.js).
26
+ */
27
+ async function refusedPluginSources(request, reply, pipeline) {
28
+ const denial = await pluginDenialFor([pipeline?.source, ...lookupSources(pipeline?.stages || [])], request.user);
29
+ if (!denial) return false;
30
+ reply.status(403).send({error: denial});
31
+ return true;
32
+ }
20
33
 
21
34
  /**
22
35
  * Every collection a pipeline's $lookup stages read, as slugs (`cms_` stripped - Mongo-authored views
@@ -78,6 +91,7 @@ export async function viewsRoutes(fastify) {
78
91
  });
79
92
 
80
93
  fastify.post('/views', canCreate, async (request, reply) => {
94
+ if (await refusedPluginSources(request, reply, request.body?.pipeline)) return;
81
95
  try {
82
96
  const view = await createView(request.body || {}, request.user?.id || null);
83
97
  return reply.status(201).send(view);
@@ -119,6 +133,7 @@ export async function viewsRoutes(fastify) {
119
133
  });
120
134
 
121
135
  fastify.put('/views/:slug', canUpdate, async (request, reply) => {
136
+ if (await refusedPluginSources(request, reply, request.body?.pipeline)) return;
122
137
  try {
123
138
  return await updateView(request.params.slug, request.body || {});
124
139
  } catch (err) {
@@ -143,6 +158,8 @@ export async function viewsRoutes(fastify) {
143
158
 
144
159
  fastify.get('/views/:slug/execute', canRead, async (request, reply) => {
145
160
  const { page, limit } = request.query;
161
+ const view = await getView(request.params.slug).catch(() => null);
162
+ if (view && await refusedPluginSources(request, reply, view.pipeline)) return;
146
163
  try {
147
164
  return await executeView(request.params.slug, {
148
165
  page: parseInt(page, 10) || 1,
@@ -178,6 +195,7 @@ export async function viewsRoutes(fastify) {
178
195
  const joined = await getCollection(from).catch(() => null);
179
196
  if (joined && !canSeeArtefact(request.user, joined)) return reply.status(403).send({error: 'Forbidden'});
180
197
  }
198
+ if (await refusedPluginSources(request, reply, {source, stages})) return;
181
199
  const n = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50);
182
200
  try {
183
201
  return await executeViewConfig({connection, pipeline: {source, stages}, access: {rowLevel: null}},
@@ -13,15 +13,29 @@
13
13
  *
14
14
  * - A collection a plugin created (`schema.plugin`) belongs to that plugin.
15
15
  * When the plugin registers permission resources (plugin.json
16
- * `permissions`, or `resources` in its role files) the caller must hold one
17
- * of them; a plugin that registers none falls back to `collections` alone.
16
+ * `permissions`, or `resources` in its role files) the caller must have
17
+ * full use of one of them: the action the resource names as its
18
+ * `collectionAction` (a game's `manage`), or else EVERY action it has. A
19
+ * read-only holder is kept out - the generic API shows whole rows, and a
20
+ * plugin's rows can hold what its own screens never show a reader (a
21
+ * quiz's answers, a puzzle's solution, a player's play token). A plugin
22
+ * that registers no resource falls back to `collections` alone. A plugin
23
+ * that is switched off (or failed to load) has nothing in the registry, so
24
+ * the permissions its plugin.json and role files declare still guard its
25
+ * collections - switching a game off must not open its answers.
26
+ *
27
+ * The same check guards every other way into a plugin's rows: a form whose
28
+ * submissions collection is the plugin's, a view reading or joining it, and
29
+ * an API Builder endpoint exposing it.
18
30
  *
19
31
  * A level-0 user keeps everything - the same bypass rowAccess.js gives them.
20
32
  */
21
33
  import {USER_DATA_PRESET_SLUGS} from './presetCollections.js';
22
34
  import {getPermissionsFor} from './roles.js';
23
35
  import {getEffectiveLevel, getEffectiveRoles} from './userRoles.js';
24
- import {getEffectiveRegistry} from './permissionRegistry.js';
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import {getEffectiveRegistry, normalisePluginResource} from './permissionRegistry.js';
25
39
 
26
40
  /** Where each user-data preset keeps its owner - the field its Tool routes filter on. */
27
41
  const OWNER_FIELD = Object.freeze({
@@ -81,35 +95,103 @@ export function holds(user, resource, action) {
81
95
  return getEffectiveRoles(user).some(r => allowed.includes(r));
82
96
  }
83
97
 
98
+ /** Plugin names as directories - anything else is not read from disk. */
99
+ const PLUGIN_NAME = /^[a-z0-9][a-z0-9_-]*$/i;
100
+
84
101
  /**
85
- * The permission resources a plugin registered at boot (plugin.json
86
- * `permissions` plus `resources` in its role files).
102
+ * The permission resources a plugin declares on disk (plugin.json
103
+ * `permissions` and `resources` in its role files), shaped like registry
104
+ * entries. Used when the registry holds none for it - the plugin is switched
105
+ * off or failed to load - so its collections keep their gate.
87
106
  *
88
107
  * @param {string} plugin
89
- * @param {Array<{key: string, plugin?: string}>} [registry] - injectable for tests
90
- * @returns {string[]}
108
+ * @param {string} [pluginsDir]
109
+ * @returns {Array<{key: string, plugin: string, actions: Array<{key: string}>, collectionAction?: string}>}
91
110
  */
92
- export function pluginResources(plugin, registry = getEffectiveRegistry()) {
93
- return registry.filter(r => r.plugin === plugin).map(r => r.key);
111
+ export function declaredPluginResources(plugin, pluginsDir = path.resolve('plugins')) {
112
+ if (typeof plugin !== 'string' || !PLUGIN_NAME.test(plugin)) return [];
113
+ const dir = path.join(pluginsDir, plugin);
114
+ const readJson = file => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
115
+ const declared = [];
116
+ const manifest = readJson(path.join(dir, 'plugin.json'));
117
+ if (Array.isArray(manifest?.permissions)) declared.push(...manifest.permissions);
118
+ let roleFiles = [];
119
+ try { roleFiles = fs.readdirSync(path.join(dir, 'roles')).filter(f => f.endsWith('.json')); } catch { /* none */ }
120
+ for (const file of roleFiles) {
121
+ const def = readJson(path.join(dir, 'roles', file));
122
+ if (Array.isArray(def?.resources)) declared.push(...def.resources);
123
+ }
124
+ const seen = new Set();
125
+ return declared.filter(r => typeof r?.key === 'string' && r.key && !seen.has(r.key) && seen.add(r.key))
126
+ .map(r => {
127
+ const {actions, collectionAction} = normalisePluginResource(r);
128
+ return {key: r.key, plugin, actions, ...(collectionAction && {collectionAction})};
129
+ });
130
+ }
131
+
132
+ /**
133
+ * The permissions that admit a user to a plugin's collections through the
134
+ * generic API, one list per resource the plugin registered (or, switched off,
135
+ * declares on disk): `[resource.action]` when the resource declares a
136
+ * `collectionAction`, otherwise every action it has. Holding every entry of
137
+ * any one list admits. A resource with no actions asks for one nobody but
138
+ * root holds, rather than admitting any action.
139
+ *
140
+ * @param {string} plugin
141
+ * @param {Array<{key: string, plugin?: string, actions?: Array<{key: string}>, collectionAction?: string}>} [registry] - injectable for tests
142
+ * @param {(plugin: string) => Array<object>} [onDisk] - injectable for tests
143
+ * @returns {Array<Array<[string, string]>>} [resource, action] pairs
144
+ */
145
+ export function pluginCollectionGates(plugin, registry = getEffectiveRegistry(), onDisk = declaredPluginResources) {
146
+ let own = registry.filter(r => r.plugin === plugin);
147
+ if (!own.length) own = onDisk(plugin);
148
+ return own.map(r => {
149
+ if (r.collectionAction) return [[r.key, r.collectionAction]];
150
+ const actions = (r.actions || []).map(a => a?.key).filter(Boolean);
151
+ return actions.length ? actions.map(action => [r.key, action]) : [[r.key, '(root only)']];
152
+ });
94
153
  }
95
154
 
96
155
  /**
97
156
  * Why `user` may not touch a plugin's collection through the generic API, or
98
157
  * null when they may: the collection is nobody's, the plugin registered no
99
- * permission, the user holds one of them, or the user is root.
158
+ * permission, the user has full use of one of them (see pluginCollectionGates),
159
+ * or the user is root.
100
160
  *
101
161
  * @param {object} schema
102
162
  * @param {object|null} user
103
163
  * @param {Array<{key: string, plugin?: string}>} [registry] - injectable for tests
164
+ * @param {(plugin: string) => Array<object>} [onDisk] - injectable for tests
104
165
  * @returns {string|null}
105
166
  */
106
- export function pluginDenial(schema, user, registry) {
167
+ export function pluginDenial(schema, user, registry, onDisk) {
107
168
  const plugin = schema?.plugin;
108
169
  if (!plugin || isRoot(user)) return null;
109
- const resources = pluginResources(plugin, registry);
110
- if (!resources.length) return null;
111
- if (resources.some(resource => holds(user, resource))) return null;
112
- return `This collection belongs to the ${plugin} plugin - its permission is required`;
170
+ const gates = pluginCollectionGates(plugin, registry, onDisk);
171
+ if (!gates.length) return null;
172
+ if (gates.some(gate => gate.every(([resource, action]) => holds(user, resource, action)))) return null;
173
+ const needs = gates.map(gate => gate.map(([r, a]) => `${r}.${a}`).join(' + ')).join(' or ');
174
+ return `This collection belongs to the ${plugin} plugin - it needs ${needs}`;
175
+ }
176
+
177
+ /**
178
+ * pluginDenial for collections named by slug - a form's submissions
179
+ * collection, a view's source and joins, an endpoint's collection. A slug
180
+ * with no collection is nobody's.
181
+ *
182
+ * @param {Iterable<string>} slugs
183
+ * @param {object|null} user
184
+ * @returns {Promise<string|null>} the first denial, or null
185
+ */
186
+ export async function pluginDenialFor(slugs, user) {
187
+ if (isRoot(user)) return null;
188
+ const {getCollection} = await import('./collections.js');
189
+ for (const slug of new Set(slugs)) {
190
+ if (typeof slug !== 'string' || !slug) continue;
191
+ const denial = pluginDenial(await getCollection(slug).catch(() => null), user);
192
+ if (denial) return denial;
193
+ }
194
+ return null;
113
195
  }
114
196
 
115
197
  /**
@@ -353,7 +353,7 @@ export const REGISTRY = [
353
353
  * Plugin-contributed resources registered at runtime via registerPluginResource().
354
354
  * Cleared on teardown via unregisterPluginResourcesByPlugin().
355
355
  *
356
- * @type {Array<{key:string,label:string,description:string,icon:string,group:string,plugin:string,actions:{key:string,label:string,description:string}[]}>}
356
+ * @type {Array<{key:string,label:string,description:string,icon:string,group:string,plugin:string,actions:{key:string,label:string,description:string}[],collectionAction?:string}>}
357
357
  */
358
358
  const _pluginContributed = [];
359
359
 
@@ -361,13 +361,23 @@ const _pluginContributed = [];
361
361
  * Register a resource contributed by a plugin.
362
362
  * Idempotent - if the key already exists in the effective registry it is skipped.
363
363
  *
364
- * @param {{key:string,label:string,description?:string,icon?:string,group?:string,actions?:{key:string,label:string,description?:string}[]}} resource
364
+ * @param {{key:string,label:string,description?:string,icon?:string,group?:string,actions?:{key:string,label:string,description?:string}[],collectionAction?:string}} resource
365
365
  * @param {string} plugin - Plugin name that owns this resource
366
366
  */
367
367
  export function registerPluginResource(resource, plugin) {
368
368
  if (!resource || typeof resource.key !== 'string' || !resource.key) return;
369
- const effective = getEffectiveRegistry();
370
- if (effective.some(r => r.key === resource.key)) return;
369
+ const {actions, collectionAction} = normalisePluginResource(resource);
370
+ const existing = getEffectiveRegistry().find(r => r.key === resource.key);
371
+ if (existing) {
372
+ // The same plugin registering the key again (a role file or
373
+ // hooks.registerRole() before the manifest) must not lose the
374
+ // manifest's collectionAction - it is a security setting.
375
+ if (existing.plugin === plugin && collectionAction && !existing.collectionAction
376
+ && existing.actions.some(a => a.key === collectionAction)) {
377
+ existing.collectionAction = collectionAction;
378
+ }
379
+ return;
380
+ }
371
381
 
372
382
  _pluginContributed.push({
373
383
  key: resource.key,
@@ -376,16 +386,34 @@ export function registerPluginResource(resource, plugin) {
376
386
  icon: resource.icon || 'box',
377
387
  group: resource.group || 'Plugins',
378
388
  plugin,
379
- // Actions may be objects or bare keys (`["read", "create"]`) - the
380
- // short form is what plugin role files have always shipped.
381
- actions: Array.isArray(resource.actions) && resource.actions.length
382
- ? resource.actions.map(a => typeof a === 'string'
383
- ? {key: a, label: a, description: a}
384
- : {key: a.key, label: a.label || a.key, description: a.description || a.label || a.key})
385
- : ACTIONS.map(key => ({key, label: key, description: key}))
389
+ actions,
390
+ ...(collectionAction && {collectionAction})
386
391
  });
387
392
  }
388
393
 
394
+ /**
395
+ * A plugin resource's actions and `collectionAction`, as they are registered.
396
+ * Actions may be objects or bare keys (`["read", "create"]`) - the short form
397
+ * is what plugin role files have always shipped; an action without a key is
398
+ * dropped, and none left means the default CRUD set. `collectionAction` is the
399
+ * action the generic Collections API asks for on the plugin's collections
400
+ * (collectionAccess.js), kept only when it names one of those actions.
401
+ *
402
+ * @param {{actions?: Array<string|{key: string, label?: string, description?: string}>, collectionAction?: string}} resource
403
+ * @returns {{actions: {key: string, label: string, description: string}[], collectionAction: string|null}}
404
+ */
405
+ export function normalisePluginResource(resource) {
406
+ const listed = (Array.isArray(resource?.actions) ? resource.actions : [])
407
+ .map(a => typeof a === 'string'
408
+ ? {key: a, label: a, description: a}
409
+ : {key: a?.key, label: a?.label || a?.key, description: a?.description || a?.label || a?.key})
410
+ .filter(a => typeof a.key === 'string' && a.key);
411
+ const actions = listed.length ? listed : ACTIONS.map(key => ({key, label: key, description: key}));
412
+ const collectionAction = typeof resource?.collectionAction === 'string'
413
+ && actions.some(a => a.key === resource.collectionAction) ? resource.collectionAction : null;
414
+ return {actions, collectionAction};
415
+ }
416
+
389
417
  /**
390
418
  * Remove all resources registered by a given plugin.
391
419
  *
@@ -1,37 +0,0 @@
1
- # crossword - AI Assistant Guide
2
-
3
- ## Purpose
4
-
5
- Crosswords on the public site, built from a word + clue list, timed by the server, with a fastest-solver
6
- leaderboard. 1.1.0 (2026-10-21), `tier: free`, disabled on arrival. Sibling of `quiz`; shares `plugins/_lib/games`.
7
-
8
- ## Files
9
-
10
- - `public/lib/layout.js` - PURE, isomorphic, SERVED: `generate(words, {seed})` (greedy, deterministic from the
11
- seed, clues never affect the layout), `cleanAnswer` (letters + enumeration), `solutionGrid`, `shapeOf`. The admin
12
- builder imports it over HTTP for the live preview; the server runs the same function on save, so a layout is
13
- never taken from the browser. It holds no puzzle, which is why it may live under `public/`.
14
- - `lib/crossword.js` - PURE: `cleanPuzzle`, `isOpen`, `publicPuzzle` (shape + clues, no letters), `readCells`,
15
- `compare`, `catchUp`, `finish`, `view`.
16
- - `lib/store.js` - `crossword-puzzles` + `plays = new PlayStore('crossword-plays', {order: 'time'})`.
17
- - `plugin.js` - routes (header comment), shortcodes, the `fastest` notification source.
18
- - `public/crossword.js` - the player: the screens are domma-reactive; the grid is a `Board` class over real
19
- `<input>`s (keyboard rules in its header - all focus and keystrokes, which bindings do not reach), letters
20
- remembered per play in storage. Exports `mount(el, {test})`, which the admin's Test screen uses.
21
- - `admin/views/crossword.js` - list, editor with live grid, test and plays screens, all domma-reactive through the
22
- admin kit (`_lib/admin/games/kit.js`). The preview grid is a data-each over cells.
23
-
24
- ## Contracts
25
-
26
- - **No letters before solving.** `/public/crossword/:slug` gives only the size; the clues come with the play (so
27
- nobody studies them off the clock); `solution` is in `/result` only when `status === 'finished'`. The tests
28
- assert no answer string appears in the play payload - keep that test.
29
- - **Time = server elapsed + `state.penaltyMs`.** Check adds `checkPenalty` seconds (403 when `checks` is off); a
30
- FULL but wrong submit adds `wrongPenalty`; a grid with empties only reports the count.
31
- - Cells travel as `height` strings of `width` chars, `A-Z` or space; anything else is 400.
32
- - The grid uses fixed ink/paper colours (`--cw-ink`, `--cw-paper`) whatever the theme - a themed grid inverted
33
- under dark themes.
34
- - Same shortcode prefix trap as quiz: `crossword-leaderboard` is registered with `{priority: 5}`.
35
- - **Test plays** (1.1.0): `GET/POST /puzzles/:id/test` (manage), as in quiz's CLAUDE.md - the saved crossword
36
- whatever its status, never on a board, not in the plays list, no notification, cleared by the next test.
37
- `/result` answers `test: true` and `wouldPlace`.
@@ -1,18 +0,0 @@
1
- /* Crossword admin - the word list and the live grid. Shared pieces are in /plugins/_lib/admin/games/games.css. */
2
- .cwa-words { display: flex; flex-direction: column; gap: .4rem; }
3
- .cwa-word { display: grid; grid-template-columns: 1.6rem minmax(7rem, 12rem) 3.5rem minmax(0, 1fr) auto; gap: .4rem; align-items: center; }
4
- .cwa-n { color: var(--dm-text-muted); font-size: var(--dm-text-sm); text-align: right; }
5
- .cwa-answer { text-transform: uppercase; font-family: var(--dm-font-mono, monospace); letter-spacing: .04em; }
6
- .cwa-enum { color: var(--dm-text-muted); font-size: var(--dm-text-xs); white-space: nowrap; }
7
- @media (max-width: 700px) { .cwa-word { grid-template-columns: 1.4rem 1fr auto; } .cwa-enum { display: none; } .cwa-clue { grid-column: 2 / 4; } }
8
-
9
- .cwa-side { position: sticky; top: .5rem; }
10
- .cwa-preview { overflow: auto; max-height: 70vh; }
11
- /* Fixed ink and paper, as on the public grid: a themed grid came out inverted under dark themes. */
12
- .cwa-grid { --cwa-ink: #111827; --cwa-paper: #ffffff; display: grid; gap: 0; width: max-content; border: 2px solid var(--cwa-ink); background: var(--cwa-ink); }
13
- .cwa-cell { position: relative; width: 1.7rem; height: 1.7rem; display: grid; place-items: center; font-weight: 700; font-size: .85rem;
14
- background: var(--cwa-paper); color: var(--cwa-ink); border: 1px solid color-mix(in srgb, var(--cwa-ink) 35%, transparent); box-sizing: border-box; }
15
- .cwa-cell.is-block { background: var(--cwa-ink); border-color: var(--cwa-ink); }
16
- .cwa-num { position: absolute; top: 1px; left: 2px; font-size: .5rem; font-weight: 600; line-height: 1; color: #4b5563; }
17
- .cwa-unplaced { margin: 0; padding: .5rem .75rem; border-radius: 6px; font-size: var(--dm-text-sm);
18
- background: color-mix(in srgb, var(--dm-warning) 15%, transparent); color: color-mix(in srgb, var(--dm-warning) 55%, var(--dm-text)); }
@@ -1 +0,0 @@
1
- <div class="gma cwa" data-crossword-admin></div>