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.
- package/CHANGELOG.md +11 -0
- package/admin/js/lib/shortcode-registry.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +1 -1
- package/admin/js/templates/docs/usage-users-roles.html +4 -0
- package/bin/lib/left-free-tier.js +83 -0
- package/bin/update.js +24 -0
- package/docs/DommaFeatures.md +47 -7
- package/docs/markdown-shortcodes.md +4 -0
- package/docs/plugin-development.md +7 -0
- package/package.json +1 -1
- package/plugins/_lib/games/names.js +6 -0
- package/plugins/_lib/public/games/motion.css +1 -0
- package/plugins/_lib/public/games/motion.js +28 -0
- package/plugins/free-tier.lock.json +1 -43
- package/server/routes/api/api-endpoints.js +5 -1
- package/server/routes/api/collections.js +4 -3
- package/server/routes/api/forms.js +22 -0
- package/server/routes/api/views.js +18 -0
- package/server/services/collectionAccess.js +97 -15
- package/server/services/markdown.js +7 -1
- package/server/services/permissionRegistry.js +39 -11
- package/plugins/crossword/CLAUDE.md +0 -37
- package/plugins/crossword/admin/css/index.css +0 -18
- package/plugins/crossword/admin/templates/crossword.html +0 -1
- package/plugins/crossword/admin/views/crossword.js +0 -449
- package/plugins/crossword/collections/crossword-plays.json +0 -20
- package/plugins/crossword/collections/crossword-puzzles.json +0 -17
- package/plugins/crossword/config.js +0 -6
- package/plugins/crossword/docs/guide.md +0 -53
- package/plugins/crossword/inject/body.html +0 -1
- package/plugins/crossword/lib/crossword.js +0 -170
- package/plugins/crossword/lib/store.js +0 -56
- package/plugins/crossword/plugin.js +0 -274
- package/plugins/crossword/plugin.json +0 -79
- package/plugins/crossword/public/crossword.css +0 -36
- package/plugins/crossword/public/crossword.js +0 -519
- package/plugins/crossword/public/lib/layout.js +0 -199
- package/plugins/quiz/CLAUDE.md +0 -43
- package/plugins/quiz/admin/css/index.css +0 -13
- package/plugins/quiz/admin/templates/quiz.html +0 -1
- package/plugins/quiz/admin/views/quiz.js +0 -530
- package/plugins/quiz/collections/quiz-plays.json +0 -20
- package/plugins/quiz/collections/quiz-quizzes.json +0 -17
- package/plugins/quiz/config.js +0 -6
- package/plugins/quiz/docs/guide.md +0 -61
- package/plugins/quiz/inject/body.html +0 -1
- package/plugins/quiz/lib/engine.js +0 -115
- package/plugins/quiz/lib/quiz.js +0 -225
- package/plugins/quiz/lib/store.js +0 -56
- package/plugins/quiz/plugin.js +0 -275
- package/plugins/quiz/plugin.json +0 -79
- package/plugins/quiz/public/quiz.css +0 -36
- 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
|
|
17
|
-
* of
|
|
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
|
|
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
|
|
86
|
-
* `permissions`
|
|
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 {
|
|
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
|
|
93
|
-
|
|
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
|
|
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
|
|
110
|
-
if (!
|
|
111
|
-
if (
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
370
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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>
|