domma-cms 1.8.46 → 1.9.1

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 (53) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/admin/js/lib/shortcode-registry.js +1 -1
  3. package/admin/js/templates/docs/usage-shortcodes.html +1 -1
  4. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  5. package/bin/lib/left-free-tier.js +83 -0
  6. package/bin/update.js +24 -0
  7. package/docs/DommaFeatures.md +47 -7
  8. package/docs/markdown-shortcodes.md +4 -0
  9. package/docs/plugin-development.md +7 -0
  10. package/package.json +1 -1
  11. package/plugins/_lib/games/names.js +6 -0
  12. package/plugins/_lib/public/games/motion.css +1 -0
  13. package/plugins/_lib/public/games/motion.js +28 -0
  14. package/plugins/free-tier.lock.json +1 -43
  15. package/server/routes/api/api-endpoints.js +5 -1
  16. package/server/routes/api/collections.js +4 -3
  17. package/server/routes/api/forms.js +22 -0
  18. package/server/routes/api/views.js +18 -0
  19. package/server/services/collectionAccess.js +97 -15
  20. package/server/services/markdown.js +7 -1
  21. package/server/services/permissionRegistry.js +39 -11
  22. package/plugins/crossword/CLAUDE.md +0 -37
  23. package/plugins/crossword/admin/css/index.css +0 -18
  24. package/plugins/crossword/admin/templates/crossword.html +0 -1
  25. package/plugins/crossword/admin/views/crossword.js +0 -449
  26. package/plugins/crossword/collections/crossword-plays.json +0 -20
  27. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  28. package/plugins/crossword/config.js +0 -6
  29. package/plugins/crossword/docs/guide.md +0 -53
  30. package/plugins/crossword/inject/body.html +0 -1
  31. package/plugins/crossword/lib/crossword.js +0 -170
  32. package/plugins/crossword/lib/store.js +0 -56
  33. package/plugins/crossword/plugin.js +0 -274
  34. package/plugins/crossword/plugin.json +0 -79
  35. package/plugins/crossword/public/crossword.css +0 -36
  36. package/plugins/crossword/public/crossword.js +0 -519
  37. package/plugins/crossword/public/lib/layout.js +0 -199
  38. package/plugins/quiz/CLAUDE.md +0 -43
  39. package/plugins/quiz/admin/css/index.css +0 -13
  40. package/plugins/quiz/admin/templates/quiz.html +0 -1
  41. package/plugins/quiz/admin/views/quiz.js +0 -530
  42. package/plugins/quiz/collections/quiz-plays.json +0 -20
  43. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  44. package/plugins/quiz/config.js +0 -6
  45. package/plugins/quiz/docs/guide.md +0 -61
  46. package/plugins/quiz/inject/body.html +0 -1
  47. package/plugins/quiz/lib/engine.js +0 -115
  48. package/plugins/quiz/lib/quiz.js +0 -225
  49. package/plugins/quiz/lib/store.js +0 -56
  50. package/plugins/quiz/plugin.js +0 -275
  51. package/plugins/quiz/plugin.json +0 -79
  52. package/plugins/quiz/public/quiz.css +0 -36
  53. package/plugins/quiz/public/quiz.js +0 -372
@@ -42,6 +42,7 @@ import {
42
42
  import {getConfig} from '../../config.js';
43
43
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
44
44
  import {hooks} from '../../services/hooks.js';
45
+ import {pluginDenialFor} from '../../services/collectionAccess.js';
45
46
  import {saveMedia} from '../../services/content.js';
46
47
  import {v4 as uuidv4} from 'uuid';
47
48
  import {ago, clip, plural} from '../../services/sidebarBadges.js';
@@ -265,6 +266,19 @@ export function isSubmissionOf(form, entry) {
265
266
  return entry?.meta?.source === `form:${form?.slug}`;
266
267
  }
267
268
 
269
+ /**
270
+ * Sends 403 and answers true when the form's submissions collection belongs
271
+ * to a plugin the caller may not read through the Collections API
272
+ * (collectionAccess.js) - otherwise a form named after a game's collection,
273
+ * or pointing its collection action at one, would list the game's answers.
274
+ */
275
+ async function refusedPluginTarget(request, reply, form) {
276
+ const denial = await pluginDenialFor([submissionsCollectionFor(form)], request.user);
277
+ if (!denial) return false;
278
+ reply.status(403).send({error: denial});
279
+ return true;
280
+ }
281
+
268
282
  async function canSeeForm(user, form) {
269
283
  const {canSeeArtefact} = await import('../../services/projects.js');
270
284
  return canSeeArtefact(user, form);
@@ -460,6 +474,7 @@ export async function formsRoutes(fastify) {
460
474
  updatedAt: now
461
475
  };
462
476
 
477
+ if (await refusedPluginTarget(request, reply, form)) return;
463
478
  await writeForm(slug, form);
464
479
 
465
480
  // Auto-create a matching collection for this form (skip if one already exists)
@@ -543,6 +558,7 @@ export async function formsRoutes(fastify) {
543
558
  createdAt: existing.createdAt,
544
559
  updatedAt: new Date().toISOString()
545
560
  };
561
+ if (await refusedPluginTarget(request, reply, updated)) return;
546
562
  await writeForm(slug, updated);
547
563
 
548
564
  // Non-blocking warnings: check the form's fields against its target
@@ -581,6 +597,7 @@ export async function formsRoutes(fastify) {
581
597
  // The form's project scope, as the form routes check it: a user confined to
582
598
  // other projects must not read or change this form's submissions.
583
599
  if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
600
+ if (await refusedPluginTarget(request, reply, form)) return;
584
601
  const targetSlug = submissionsCollectionFor(form);
585
602
 
586
603
  // A form whose backing collection has not been provisioned yet simply
@@ -609,6 +626,7 @@ export async function formsRoutes(fastify) {
609
626
  // The form's project scope, as the form routes check it: a user confined to
610
627
  // other projects must not read or change this form's submissions.
611
628
  if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
629
+ if (await refusedPluginTarget(request, reply, form)) return;
612
630
  // Where the list reads them: a form whose collection action writes elsewhere
613
631
  // exported (and cleared, and deleted from) its own-slug collection instead.
614
632
  let entries = [];
@@ -637,6 +655,7 @@ export async function formsRoutes(fastify) {
637
655
  // The form's project scope, as the form routes check it: a user confined to
638
656
  // other projects must not read or change this form's submissions.
639
657
  if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
658
+ if (await refusedPluginTarget(request, reply, form)) return;
640
659
  let entries = [];
641
660
  try {
642
661
  entries = await submissionsOf(form);
@@ -662,6 +681,7 @@ export async function formsRoutes(fastify) {
662
681
  // The form's project scope, as the form routes check it: a user confined to
663
682
  // other projects must not read or change this form's submissions.
664
683
  if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
684
+ if (await refusedPluginTarget(request, reply, form)) return;
665
685
  const target = submissionsCollectionFor(form);
666
686
  try {
667
687
  // Its own collection: clear it. A shared one: delete only this form's
@@ -692,6 +712,7 @@ export async function formsRoutes(fastify) {
692
712
  // The form's project scope, as the form routes check it: a user confined to
693
713
  // other projects must not read or change this form's submissions.
694
714
  if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
715
+ if (await refusedPluginTarget(request, reply, form)) return;
695
716
  const target = submissionsCollectionFor(form);
696
717
  try {
697
718
  if (!isSubmissionOf(form, await getEntry(target, id))) return reply.status(404).send({ error: 'Submission not found.' });
@@ -719,6 +740,7 @@ export async function formsRoutes(fastify) {
719
740
  // The form's project scope, as the form routes check it: a user confined to
720
741
  // other projects must not read or change this form's submissions.
721
742
  if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
743
+ if (await refusedPluginTarget(request, reply, form)) return;
722
744
  const target = submissionsCollectionFor(form);
723
745
  const value = request.body?.spam !== false;
724
746
  try {
@@ -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
  /**
@@ -2302,7 +2302,13 @@ function processGridBlocks(markdown) {
2302
2302
  const COL_INJECTABLE = ['reveal', 'breathe', 'animate', 'ripple'];
2303
2303
  const injected = extractInjectedEffects(attrs, COL_INJECTABLE);
2304
2304
  const baseCls = attrs.span ? `col-span-${attrs.span}` : 'col';
2305
- const allClasses = [baseCls, ...injected.effectClasses].join(' ');
2305
+ // Explicit alignment takes precedence over the shorthand flags.
2306
+ const alignment = typeof attrs.align === 'string'
2307
+ ? attrs.align.trim().toLowerCase()
2308
+ : ['center', 'centre', 'left', 'right'].find(value => attrs[value] === true);
2309
+ const align = alignment === 'centre' ? 'center' : alignment;
2310
+ const alignClasses = ['left', 'center', 'right'].includes(align) ? [`text-${align}`] : [];
2311
+ const allClasses = [baseCls, ...alignClasses, ...injected.effectClasses].join(' ');
2306
2312
  const cls = ` class="${allClasses}"`;
2307
2313
  const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
2308
2314
  // Restore the col body before passing it downstream - otherwise the
@@ -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>