domma-cms 1.8.45 → 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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/admin/js/lib/code-format.js +50 -40
  3. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  4. package/admin/js/views/page-editor.js +10 -8
  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/configuration.md +19 -7
  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/permissionRegistry.js +39 -11
  21. package/plugins/crossword/CLAUDE.md +0 -37
  22. package/plugins/crossword/admin/css/index.css +0 -18
  23. package/plugins/crossword/admin/templates/crossword.html +0 -1
  24. package/plugins/crossword/admin/views/crossword.js +0 -449
  25. package/plugins/crossword/collections/crossword-plays.json +0 -20
  26. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  27. package/plugins/crossword/config.js +0 -6
  28. package/plugins/crossword/docs/guide.md +0 -53
  29. package/plugins/crossword/inject/body.html +0 -1
  30. package/plugins/crossword/lib/crossword.js +0 -170
  31. package/plugins/crossword/lib/store.js +0 -56
  32. package/plugins/crossword/plugin.js +0 -274
  33. package/plugins/crossword/plugin.json +0 -79
  34. package/plugins/crossword/public/crossword.css +0 -36
  35. package/plugins/crossword/public/crossword.js +0 -519
  36. package/plugins/crossword/public/lib/layout.js +0 -199
  37. package/plugins/quiz/CLAUDE.md +0 -43
  38. package/plugins/quiz/admin/css/index.css +0 -13
  39. package/plugins/quiz/admin/templates/quiz.html +0 -1
  40. package/plugins/quiz/admin/views/quiz.js +0 -530
  41. package/plugins/quiz/collections/quiz-plays.json +0 -20
  42. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  43. package/plugins/quiz/config.js +0 -6
  44. package/plugins/quiz/docs/guide.md +0 -61
  45. package/plugins/quiz/inject/body.html +0 -1
  46. package/plugins/quiz/lib/engine.js +0 -115
  47. package/plugins/quiz/lib/quiz.js +0 -225
  48. package/plugins/quiz/lib/store.js +0 -56
  49. package/plugins/quiz/plugin.js +0 -275
  50. package/plugins/quiz/plugin.json +0 -79
  51. package/plugins/quiz/public/quiz.css +0 -36
  52. package/plugins/quiz/public/quiz.js +0 -372
@@ -23,7 +23,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
23
23
  - **Operations** - Node 24, `npx` scaffolder and CLI, an updater that merges config, fleet management through
24
24
  domma-cms-manager, response cache, built-in security hardening.
25
25
  - **Marketplace** - signed `.dcmsplugin` packages, licensed paid plugins, safe upgrades with rollback,
26
- `minCmsVersion` checks, user guides in the admin's Documentation folder, and the 25 plugins below.
26
+ `minCmsVersion` checks, user guides in the admin's Documentation folder, and the plugins below.
27
27
 
28
28
  ## Marketplace catalogue
29
29
 
@@ -38,7 +38,8 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
38
38
  | `calendar` | paid | 1.15.4 | Events calendar - month, day and list views, online meetings as a video call on your site (with the call's chat kept, and Add people during the call), call anyone now from a contact or an email, all-day and recurring events, sharing with people and roles, RSVPs and an iCal feed. |
39
39
  | `campaigns-pro` | paid | 1.0.2 | Marketing campaigns, one paid Tool: compose a message, send it to an audience imported from Contacts (shared with Surveys), schedule sends, attach an offer that closes by a date or a claim limit. Opens and clicks (cookieless), links tagged for Analytics, a linked Shop discount code, send a survey as a campaign, follow-up sequences, resend to non-openers, A/B subject tests, and a version-1 stats feed other Tools read. |
40
40
  | `contacts-pro` | paid | 0.12.0 | Contacts, with more to them: your own custom fields, birthdays and ages with the ones coming up and a reminder in the sidebar, and groups with their own emblem and colours. Share a contact, several, or a whole group with people or roles - to view, or to edit. Call a contact now on a video call on your site. Works over the same contacts as the built-in Tool, so nothing moves and nothing is lost. |
41
- | `crossword` | free | 1.1.0 | Crosswords on your site: type the answers and clues and the grid lays itself out (live preview, Another layout). Players type a name and race the server's clock, checks and wrong guesses cost time, fastest solvers on a leaderboard. Test it from the Tool - a test go never reaches the board. |
41
+ | `crossword` | paid (giftable) | 1.4.0 | Crosswords on your site: type the answers and clues and the grid lays itself out (live preview, Another layout). Players type a name and race the server's clock, checks and wrong guesses cost time, fastest solvers on a leaderboard. Hints for a time penalty, personal best and Share, animations, export and import. Test it from the Tool - a test go never reaches the board. |
42
+ | `daily-word` | paid (giftable) | 1.2.0 | One hidden word a day, the same for everyone: six guesses, each letter marked right place, in the word or not; real words only (a bundled dictionary, switchable), an archive of past days, streaks, Share as coloured squares, a calendar of what is coming. Fewest guesses tops the day's board, then the fastest. |
42
43
  | `data-transfer` | paid | 1.3.3 | Move collections between file and MongoDB storage, back them up and restore them (all or some, merged or replaced) - keeping every entry's id - and clone them (cloned entries get new ids, so references stay unambiguous). Every restore and transfer takes a safety snapshot first. |
43
44
  | `docs` | paid | 2.13.1 | Document editor: Home and editor screens, sharing with Viewers/Editors, public links, import/export, send to page/blog/email, edit lock, Trash, version history, templates. PDF documents (2.13): a PDF kept as it is, shown in the browser's viewer, searchable inside, versioned on replace. |
44
45
  | `docs-pro` | paid | 0.11.0 | Domma Spaces (publish a Docs folder at `/spaces` with a Kindle-style reader), Cloud copies (Google Drive, OneDrive, Dropbox, S3), Signatures (emailed signing links, typed or drawn, signed copy with a certificate page; 0.10: boxes placed on a PDF document's pages, the signed PDF stamped with pdf-lib), PDF tools (0.11: fill the PDF's own form, annotate, pages) and protected public links (expiry date, password). Adds to Docs. |
@@ -51,7 +52,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
51
52
  | `messages` | paid | 1.7.2 | Internal user-to-user and group messaging with real-time delivery and video calls (what is typed in a call lands in the conversation; ring another team member into a call) - conversations you can archive, mark unread and search, drafts kept per conversation, attachments, and an unread badge in the sidebar. |
52
53
  | `mongodb-adapter` | paid | 1.0.2 | Provision a dedicated MongoDB database per site. Credentials are managed centrally by the manager. |
53
54
  | `onboard-pro` | paid | 1.0.1 | Onboarding journeys, one paid Tool: read/form/details/manual steps, a personal link per person, signature steps through Docs Pro, admin-approved account steps (never an administrator), due dates, automatic nudges and auto-enrolment from a contact group, countersigned steps, waive-with-a-reason, and a funnel showing where people stop. |
54
- | `quiz` | free | 1.1.0 | Timed quizzes on your site: one or several right answers, true or false, or a typed answer, marked by the server so answers never reach the browser early. Name-only players, leaderboards by day, week, month and all time. Test it from the Tool - a test go never reaches the board. |
55
+ | `quiz` | paid (giftable) | 1.4.0 | Timed quizzes on your site: one or several right answers, true or false, or a typed answer, marked by the server so answers never reach the browser early. Name-only players, leaderboards by day, week, month and all time. Hints for a time penalty, personal best and Share, animations, export and import. Test it from the Tool - a test go never reaches the board. |
55
56
  | `security` | free | 1.1.3 | Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like. |
56
57
  | `security-pro` | paid | 1.3.2 | Everything in Security, plus two-factor sign-in (authenticator apps, recovery codes, required per role), a firewall that bans scanners for longer each time they return, per-route rate limits, country rules for the admin, breached-password checks and every signed-in session in one place. |
57
58
  | `seo-pro` | paid | 0.6.2 | More for SEO: Google Search Console in the admin (with your own Google client) - searches, clicks, positions and where to win more; structured data for rich results (FAQ, how-to, product, event, local business); links worth adding between pages, pages competing for one search, readability, near duplicates and image checks; every title and description in one grid, and moving an old site's addresses across as redirects; a not-found log with one-click redirects; a scheduled check of every link; SEO history and notices. Adds to SEO, which is built into Domma CMS (0.90 and later). |
@@ -62,6 +63,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
62
63
  | `surveys` | paid | 1.5.1 | Targeted surveys over the Forms engine: shared audiences, tokenised invites, reminders, and per-survey statistics. |
63
64
  | `theme-roller` | paid | 1.1.3 | Make your own themes: start from any built-in theme, change its colours, fonts, spacing and borders with a live preview, and save it. Saved themes join the built-in ones everywhere a theme is chosen - the site, the admin, day and night, a region of a page. Needs Domma CMS 0.73 or later to use them. |
64
65
  | `waypoint-pro` | paid | 1.3.1 | Plan and track work: projects with their own workflows, issues on a board or in a list, a backlog planned into sprints with a burndown, and comments, mentions and watchers that tell the right people. |
66
+ | `word-search` | paid (giftable) | 1.1.0 | Word searches on your site: type up to 40 words and the server lays out the letter grid (across, down, diagonal, backwards too if you like). Players drag, tap or use the keyboard to mark each word against the clock; wrong marks cost time, fastest full solve wins. Hints, personal best, Share, animations, export and import. |
65
67
 
66
68
  Versions are read from `dcms-marketplace/plugins/*/plugin.json` (every plugin folder except `_lib`).
67
69
 
@@ -1211,6 +1213,8 @@ The four Tools below were plugins until 0.67.0 and are now part of the CMS. They
1211
1213
  Where: Role editor > Admin area; `normaliseAdminScope` / `getAdminScope` in `roles.js`; `admin/js/lib/admin-scope.js` - Since: 0.71.0.
1212
1214
  - **Plugin roles and permissions** - Plugins declare `permissions[]` in `plugin.json`, with `grant` to give them to existing roles, and ship `roles/*.json`. They can also create and remove roles from code with `hooks.registerRole()` and `unregisterRole()`. A plugin role is created once and then belongs to the admin. Disabling the plugin keeps the role, so user assignments survive, and uninstalling removes it.
1213
1215
  Since: 0.71.0.
1216
+ - **Plugin collections in the generic Collections API** - A collection a plugin created is reachable through `/api/collections/<slug>/...` only by someone with full use of one of the plugin's permissions: the action that permission names as `collectionAction` in `plugin.json` (Quiz, Crossword, Word Search and Daily Word name `manage`), or every action it has. A read-only holder is refused, so answers, solutions and play tokens are not readable through the Collections API, a form's submissions, Views or API Builder. A switched-off plugin's collections keep the gate its `plugin.json` declares. Super Admin keeps everything; a plugin with no permissions needs only `collections`.
1217
+ Where: `pluginCollectionGates` / `pluginDenial` / `pluginDenialFor` in `server/services/collectionAccess.js`; forms, views and api-endpoints routes - Since: Unreleased.
1214
1218
  - **Role-aware public site** - A signed-in visitor sees the navbar, footer and menu items meant for their role. Visibility supports both "this role and above" and `=role` ("this role only").
1215
1219
  Since: 0.71.0.
1216
1220
 
@@ -1316,7 +1320,7 @@ The four Tools below were plugins until 0.67.0 and are now part of the CMS. They
1316
1320
 
1317
1321
  - **Managed sites** - A site forked by the manager gets `MANAGER_URL`, `SITE_SLUG` and a per-fork `FLEET_TOKEN` in its environment. It then:
1318
1322
  - browses the fleet catalogue under Marketplace > Browse, with prices, licence state and updates;
1319
- - installs paid plugins with an entitlement issued for that site;
1323
+ - installs paid plugins with an entitlement issued for that site, sold or gifted (a giftable Tool can be gifted for good or for a number of days - see [Tiers, licences and entitlements](#tiers-licences-and-entitlements));
1320
1324
  - receives pushed notifications (`MANAGER_SECRET`).
1321
1325
 
1322
1326
  The manager also writes `tools.json` and `plugins.json` directly to set switches and locks. Every call to the manager fails gracefully, so a manager that is down never stops the site serving pages. Unmanaged sites install plugins from a `.dcmsplugin` file instead.
@@ -1413,6 +1417,7 @@ Domma CMS is extended by plugins: self-contained folders under `plugins/<slug>/`
1413
1417
  - **Current register (marketplace, 24 plugins)** - versions in the [catalogue table](#marketplace-catalogue).
1414
1418
  - Free (6, shipped with the engine): `blog`, `booking`, `feedback`, `mail-reader`, `security`, `shopping-cart`.
1415
1419
  - Paid (21): `analytics-pro`, `blog-pro`, `booking-pro`, `calendar`, `contacts-pro`, `data-transfer`, `docs`, `docs-pro`, `email-pro`, `garage`, `invoice`, `job-board`, `messages`, `mongodb-adapter`, `security-pro`, `seo-pro`, `shopping-cart-pro`, `social-media-pro`, `surveys`, `theme-roller`, `waypoint-pro`.
1420
+ - Paid and giftable (4, the Games): `quiz`, `crossword`, `word-search`, `daily-word`.
1416
1421
  - **Free-and-Pro pairs** - A Pro edition declares `supersedes` on its free sibling, so only one runs: Blog Pro → Blog, Email Pro → Mail Reader, Security Pro → Security, Contacts Pro → Contacts (built-in Tool takeover). Shopping Cart Pro and SEO Pro instead `requires` their free base (`shopping-cart`, the `seo` Tool).
1417
1422
  - **MongoDB-dependent plugins** - Email Pro ("Requires MongoDB" in its description), Data Transfer (moves collections between file and MongoDB storage) and MongoDB Adapter (managed-only: reads a `config/connections.json` the manager writes during provisioning). There is no manifest field for a Mongo requirement - it is stated only in the description text. Core already supports Mongo storage for free via `storage: {adapter: "mongodb"}` in a schema.
1418
1423
  - Where: design spec "Managed-only plugins"
@@ -1421,6 +1426,8 @@ Domma CMS is extended by plugins: self-contained folders under `plugins/<slug>/`
1421
1426
  - `perpetual` (self-hosted, bought outright): never stops. After `supportExpiresAt` it is `support-ended` - still runs, updates lapse.
1422
1427
  - Absent, wrong slug, unknown key or bad signature → `unlicensed` - does not load.
1423
1428
  - Where: `server/services/pluginEntitlement.js` · Since: 0.68.0
1429
+ - **Giftable Tools and gifts** - A paid manifest may add `giftable: true` (the catalogue carries it; refused on a free Tool). The Manager can then gift that Tool to a site at no charge, from the site's Plugins tab or the Plugin Store, **for good** or **for a number of days**. A gift is an ordinary licence with `type: 'gift'`, `giftedBy` and `giftedAt`, no grace: for good is minted perpetual (no expiry), a timed gift is a rental that tells the site's admins three days before and switches the Tool off on its last day. **Extend gift** adds days or makes it for good without a restart; **End gift** is the rescind (disable only, files and data stay). A gift is refused over a live paid licence and never shortens one; buying a gifted Tool later grants over it (the licence becomes `generated`). Only a gift can be for good - a sale's licence edited to no expiry is refused. The Licences ledger has a Gifts filter; the licence expiry check runs hourly.
1430
+ - Where: domma-cms-manager `plugins/site-manager/services/entitlements.js`, `licenceManager.js`; `dcms-marketplace/scripts/build-catalogue.js` (`giftable`) · Since: Manager 2026-10-23 (Games)
1424
1431
  - **Only our paid plugins are ever checked** - The gate keys on `closedSource: true`; a plugin you wrote yourself is never asked for an entitlement.
1425
1432
  - **Where entitlements live** - `plugins/<slug>/.entitlement.json` (arrives in the envelope, survives upgrades), or instance-wide in `config/entitlements/*.json` (e.g. an owner wildcard `slug: "*"` covering every plugin, used by the manager and cms.dommajs.org). Per-plugin wins.
1426
1433
  - **Offline, no phone-home** - Verification is Ed25519 over canonical JSON (keys sorted, no whitespace) against keys compiled into the CMS release.
@@ -1517,7 +1524,7 @@ plugins/my-plugin/
1517
1524
  | `admin.routes[]`, `admin.views{}` | SPA routes and view modules `{entry, exportName}`. |
1518
1525
  | `admin.css[]` | Admin stylesheets (may include `../_lib/admin/ui/help.css`). |
1519
1526
  | `inject.head`, `inject.headLate`, `inject.bodyEnd` | HTML snippets added to every public page (paths confined to the plugin folder). |
1520
- | `permissions[]` | Permission resources `{key, label, actions?, group?, icon?, grant?: ["admin"]}` - `grant` given once. |
1527
+ | `permissions[]` | Permission resources `{key, label, actions?, group?, icon?, grant?: ["admin"], collectionAction?}` - `grant` given once; `collectionAction` is the action the generic Collections API needs on the plugin's collections (default: every action). |
1521
1528
  | `settingsSchema[]` | Configure form: `{key, label, type: text|number|select|boolean, options?, min?, max?, help?, secret?}`. |
1522
1529
  | `requires[]` / `uses{}` | Hard / soft dependencies on Tools or plugins (0.88.0). |
1523
1530
  | `supersedes[]` | Plugins or built-in Tools this one replaces. |
@@ -2410,16 +2417,47 @@ Tier: paid (closed source) · Version: 0.4.0 · Requires: nothing; no MongoDB (w
2410
2417
 
2411
2418
  **Where:** Admin sidebar "Sheets" (`#/plugins/sheets`).
2412
2419
 
2413
- ### Quiz (`quiz`) and Crossword (`crossword`)
2420
+ ### Games: Quiz (`quiz`), Crossword (`crossword`), Word Search (`word-search`) and Daily Word (`daily-word`)
2414
2421
 
2415
- Tier: free, bundled in the engine, off on arrival · Version: 1.1.0 each (needs Domma CMS 1.8.33 for the games library) · Sidebar: Tools > Games
2422
+ Tier: paid and giftable (the Manager can gift them for good or for a number of days) · Versions: Quiz 1.4.0, Crossword 1.4.0, Word Search 1.1.0, Daily Word 1.2.0 (need Domma CMS 1.9.0 for the shared games library and its motion files) · Sidebar: Tools > Games · Permissions: a Games group in the role editor
2423
+
2424
+ All four share the engine's games library (`plugins/_lib/games`: plays, tokens, names, ranking, timing; `plugins/_lib/public/games`: player parts and motion; `plugins/_lib/admin/games/kit.js`: list, editor, test and plays screens). The server keeps the clock and marks every move; nothing that gives an answer reaches the browser before the play is finished. Name-only players, honeypot and rate limits, test plays from the Tool, a sidebar badge, notifications. The generic Collections API reaches a game's collections only with the game's `manage` action (`collectionAction` in plugin.json).
2425
+
2426
+ #### Quiz and Crossword
2416
2427
 
2417
2428
  **1.0.0 (CMS 1.8.32)** - see the 1.8.32 changelog: server-kept time and marking, name-only players with a play token, leaderboards by period (best go per name), Plays screen (hide, delete, reset), sidebar badge, top-spot notifications, `[quiz]`/`[crossword]` and `-leaderboard` shortcodes.
2418
2429
 
2419
2430
  **1.1.0 - Test from the Tool, domma-reactive admin.** **Test** (editor, list rows, Plays screen; `#/plugins/quiz/test/<id>`, `#/plugins/crossword/test/<id>`) mounts the real public player in the admin and plays the SAVED game whatever its status or dates ("Save and test" when there are unsaved changes). The play is stored with `test: true` and played through the ordinary `/public/play` routes, but `PlayStore` leaves it out of boards, places, goes-per-name, the admin's plays list and counts and the sidebar badge; it never notifies; the next test of the game clears it. The result says where it would have come (`wouldPlace`). Routes: `GET/POST /api/plugins/quiz/quizzes/:id/test`, `GET/POST /api/plugins/crossword/puzzles/:id/test` (manage permission). Both admin screens are rebuilt on domma-reactive through the shared kit (`plugins/_lib/admin/games/kit.js`: `mountScreen`, `wireRows`, `sortableRows`, `saveKey`, `playsScreen`, `testScreen`): list rows with a context menu (right-click, ⋮, Shift+F10), questions dragged by a grip to reorder, Ctrl+S, help popovers; the crossword preview grid uses fixed ink and paper colours.
2420
2431
 
2432
+ **1.2.0 - export and import.** `GET /export` (all) and `GET /quizzes/:id/export` / `GET /puzzles/:id/export` (one) give a file `{format: 'domma-quiz'|'domma-crossword', version: 1, exportedAt, quizzes|puzzles}` - games without ids, timestamps or crossword layouts, never plays. `POST /import` (manage, 8 MB, 50 a file) cleans each item as on save and adds it as a draft with a unique short name; invalid items are skipped with their reason; a file of the other game or a newer version is refused by name. Crossword grids are re-laid from words + seed, so they match. Admin: Import… and Export all on the list, Export in each row's menu and the editor's banner.
2433
+
2434
+ **1.3.0 - paid and giftable.** `tier: paid`, `closedSource`, `giftable`: they reach a site through the catalogue and a licence, or a gift from the Manager (for good or for a number of days), no longer inside the engine (`plugins/free-tier.lock.json` no longer lists them). The engine update removes a site's old free copy (`bin/lib/left-free-tier.js`): never a copy with `.entitlement.json` beside it, never one whose plugin.json says paid, never `data/` or `backups/`. Their permissions sit in a Games group in the role editor. Their tests live with them in dcms-marketplace; the shared `_lib/games` tests stay in `tests/plugins/games-pure.test.js`.
2435
+
2436
+ **1.4.0 - hints, boards, personal best, share, animations.** See "Shared by all four" below.
2437
+
2421
2438
  **Where:** Tools > Games > Quiz (`#/plugins/quiz`) and Crossword (`#/plugins/crossword`).
2422
2439
 
2440
+ #### Word Search (`word-search`)
2441
+
2442
+ **1.0.0** - The admin types up to 40 words (3-15 letters; spaces and hyphens dropped, accents folded) and an optional theme; the server lays out the grid (`public/lib/layout.js`, pure and seeded: 8x8 to 20x20 or Auto; across, down, diagonal, each a switch, plus "backwards too"; Shuffle for another layout; words that would not fit come back as `unplaced`). The fill never makes a second copy of a listed word or a blocked word (`BLOCKED_WORDS` from `_lib/games/names.js`, exported for this) in any direction. Players mark a word by its first and last letter - drag, tap-tap or the keyboard; the server reads the letters along the line and accepts an unfound word either way round; a wrong mark costs seconds (default 5); the last word finishes the play, fastest full solve wins. The word list can be shown or hidden. Word positions never reach the browser before the play finishes. Changing the words of an open word search ends plays in progress. Shortcodes `[word-search id="..." /]`, `[word-search-leaderboard id="..." /]`; notification source `fastest`. **1.1.0** - the extras below.
2443
+
2444
+ **Where:** Tools > Games > Word Search (`#/plugins/word-search`).
2445
+
2446
+ #### Daily Word (`daily-word`)
2447
+
2448
+ **1.0.0** - One hidden word a day, the same for everyone. A site makes word sets (length 4-8, guesses 4-8, default 6, a start date, answers in a seeded order fixed at save, Shuffle order); day N uses answer N; a Calendar view shows what is coming (manage permission only); a set running out warns a week ahead (notification `running-low`) and the public side shows "No word today" (`no-word`). Guesses must be real words (a bundled public-domain English list, server side only, switchable per set to "any letters") or one of the set's answers, else "Not in the word list" and no guess is used. Marking follows the duplicate-letter rule (`mark()`, pure). The site's day decides the word; one go per name per day by default. An **Archive** of past days is playable but never on a board, in a streak or counted for goes. Score = guesses allowed + 1 - guesses used, then the fastest time. Share copies the coloured squares only. Changing today's or a past day's answer is refused once played. Export holds future answers, so it needs manage. **1.1.0 - streaks**: the finished screen shows the name's streak (days in a row with a solved real play) or that a spent day ended it; `order="streak"` on `[daily-word-leaderboard]` ranks current streaks; the goes check and the new play are one turn per set, day and name (no double go in a race). **1.2.0** - the extras below. Shortcodes `[daily-word id="..." /]`, `[daily-word-leaderboard id="..." period="today" /]`.
2449
+
2450
+ **Where:** Tools > Games > Daily Word (`#/plugins/daily-word`).
2451
+
2452
+ #### Shared by all four
2453
+
2454
+ - **Giftable** - paid Tools that reach a site through the catalogue and a licence, or a gift from the Manager for good or for a number of days ([Tiers, licences and entitlements](#tiers-licences-and-entitlements)). Quiz and Crossword left the free tier at 1.3.0; the engine update (1.9.0) removes a site's old free copy unless it is licensed, keeping `data/` and `backups/`.
2455
+ - **Export and import** - every game exports one or all as a file and imports a file as drafts beside what is there, with no plays; layouts and schedules are worked out afresh on the server.
2456
+ - **Hints** - per game: on or off, hints a play, seconds each. Quiz takes two wrong answers away or gives a typed answer's first letter; Crossword fills one letter of the chosen word; Word Search shows where one unfound word starts; Daily Word shows one letter in its place. The server decides and records each (`POST /public/play/:id/hint`); results, the Plays screen and boards (an "H2" chip) show hints used.
2457
+ - **Boards** - `board="show|hide|only"` on every game shortcode; a per-game **Leaderboard shows** period (`boardPeriod`) used when no period is asked for; `[games-leaderboards games="..." period="..." limit="..."]` puts every Game's boards on one page (each game registers the same handler, so it works with any of them installed). Board shortcodes on the page refresh when a game finishes.
2458
+ - **Personal best** on every finished screen; **Share** (no answers) on Quiz, Crossword and Word Search, coloured squares on Daily Word.
2459
+ - **Animations** - a per-game setting, on unless switched off: answers, letters and tiles pop or flip in, right and wrong answers pulse, found words pop along their line, finished screens and board rows pop in. Never under `prefers-reduced-motion` or `html.dm-reduced-motion`. Shared CSS and helpers: `plugins/_lib/public/games/motion.css` and `motion.js` (engine 1.9.0).
2460
+
2423
2461
  ### Other plugin directories
2424
2462
 
2425
2463
  No other directories exist in `dcms-marketplace/plugins/` beyond the plugins above and `_lib`. `_lib` is not a plugin (skipped by plugin discovery): it holds the shared mail core used by Mail Reader and Email Pro, plus shared `dataStore.js` and `schemaSync.js`.
@@ -2516,6 +2554,8 @@ Later changes to these features are in the CHANGELOG (for example 0.92.1: row-le
2516
2554
 
2517
2555
  ### Plugins & the Marketplace platform
2518
2556
 
2557
+ - A games page: a Daily Word set with a month of answers, a Word Search and a Quiz on one page, with `[games-leaderboards]` below them; hints with a penalty, and moving a game to another site with Export and Import.
2558
+ - Gifting a Tool from the Manager: gift a Game for 30 days, extend it, make it for good, and end it.
2519
2559
  - Build your first plugin in 10 minutes: New plugin in the Marketplace, add a route and a screen in the code editor, restart, see it in the sidebar.
2520
2560
  - Add a custom shortcode and let its HTML through the sanitiser (`registerShortcode` + `registerSanitizeRules`).
2521
2561
  - Give your plugin a Configure form with `settingsSchema`, including a masked secret read at request time.
@@ -1544,10 +1544,21 @@ step Ctrl+Z undoes. The toolbar's format button does the same.
1544
1544
  code keeps its own shape within that. The renderer takes the indent off before
1545
1545
  anything else (`unindentShortTags()` in `markdown.js`: a tag line loses all of
1546
1546
  it, a container's content the indent its lines share), so it never reaches the
1547
- page - and a page written flat is read exactly as before.
1548
- - **Block and component HTML, JS**: re-indented by nesting only; the code itself
1549
- is never rewritten. `<pre>` and `<textarea>` keep their lines; a `<script>` or
1550
- `<style>` inside HTML is indented as JS or CSS.
1547
+ page - and a page written flat is read exactly as before. HTML blocks in the
1548
+ body (a run of lines starting with a block tag such as `<div>` or `<p>`, up to
1549
+ the next blank line or short tag line) are laid out as block HTML is, below; a
1550
+ run holding `<pre>`, `<textarea>`, a script, a style or a fence is left as written.
1551
+ - **Block and component HTML**: every block element (`div`, `p`, `li`, `h2`,
1552
+ `section`, a custom element…) on a line of its own, its content one level in,
1553
+ and `{{#if}}` / `{{else}}` / `{{/if}}` written on their own lines the same.
1554
+ Inline content - text, `<a>`, `<strong>`, `<span>` - is never split or joined:
1555
+ it keeps the lines it was written on (a line inside an open inline tag goes one
1556
+ deeper), so no space appears or goes where the page would show it. An element
1557
+ holding only inline content stays on one line if it was written on one. Tags get
1558
+ one space between attributes, and a tag wider than 120 columns an attribute a line.
1559
+ `<pre>` and `<textarea>` keep every character; a `<script>` or `<style>` is
1560
+ formatted as JS or CSS. A stray closing tag stays where it was; nothing is dropped.
1561
+ - **JS**: re-indented by nesting only; the code itself is never rewritten.
1551
1562
  - **CSS** (blocks, components, the page's custom CSS): one declaration a line,
1552
1563
  `prop: value;`, a blank line between rules.
1553
1564
  - **JSON** (component props): parsed and printed; invalid JSON is reported, not changed.
@@ -1557,14 +1568,15 @@ browser with the rest of the editor's settings: indent with spaces or tabs, the
1557
1568
  indent size (2, 4 or 8 - also how wide a tab shows), *Keep a file's own
1558
1569
  indentation* (on: a block, component or CSS already in tabs or fours keeps them;
1559
1570
  the page body always takes the setting), *Auto-indent*, *Carry lists and quotes
1560
- on*, *Indent inside short tags*, *Line up tables when formatting*, and
1561
- *Format on save* (off by default: Save formats the tab on show first; text that
1571
+ on*, *Indent inside short tags*, *Line up tables when formatting*,
1572
+ *Lay out HTML in the page body*, and *Format on save* (off by default: Save formats the tab on show first; text that
1562
1573
  cannot be formatted is saved as it is).
1563
1574
 
1564
1575
  While typing, **Enter** keeps the line's indentation. In the body it carries a
1565
1576
  list or quote on (numbers count up, a task box comes back empty) and Enter on an
1566
1577
  empty item ends the list; Shift+Enter is a plain new line. After an opening short
1567
- tag the next line goes one deeper; after a closing one it stays level. In code it goes one
1578
+ tag the next line goes one deeper; after a closing one it stays level; after an opening
1579
+ block HTML tag (`<div>`, `<ul>`…) one deeper too. In code it goes one
1568
1580
  deeper after `{`, `(`, `[`, an opening tag or `{{#if}}`, and with the caret
1569
1581
  between a pair the closer moves to its own line. **Tab / Shift+Tab** indent or
1570
1582
  outdent every selected line when the selection spans lines.
@@ -211,6 +211,13 @@ each role sees.
211
211
  - `group` (default `Plugins`) and `icon` place it in the role editor.
212
212
  - `grant` gives those existing roles the permission **once**. The grant is recorded on the role, so if the admin later
213
213
  removes it, it stays removed. The super-admin (level 0) holds every plugin permission without a grant.
214
+ - `collectionAction` names the action someone needs to reach your plugin's collections through core's generic
215
+ routes - the Collections API and screen, a form's submissions, Views and API Builder endpoints - `"manage"`, say.
216
+ It must be one of the resource's `actions` or it is ignored. Without it, every action of the resource is needed.
217
+ Holding the gate of ANY one of your resources opens all your collections, so if a resource's only action is
218
+ `read`, give the others a `collectionAction` and keep row secrets (answers, solutions, tokens) in a plugin whose
219
+ every resource is gated. A plugin with no permissions at all leaves its collections to `collections` holders.
220
+ Switched off, your collections keep the gate your `plugin.json` and role files declare.
214
221
 
215
222
  Guard your routes with them:
216
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.45",
3
+ "version": "1.9.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -16,6 +16,12 @@ export const NAME_MAX = 24;
16
16
  /** Folded to a-z first, so leetspeak and accents do not slip past. Kept short on purpose. */
17
17
  const BLOCKED = ['fuck', 'shit', 'cunt', 'wank', 'twat', 'bitch', 'bastard', 'nigg', 'fag', 'slut', 'whore', 'rape', 'nazi', 'hitler', 'penis', 'vagina', 'dick', 'cock', 'pussy', 'arse', 'asshole', 'admin', 'moderator'];
18
18
 
19
+ /**
20
+ * The same list, for games that put letters in front of the public (Word Search fills its grid with
21
+ * random letters and rerolls any fill that spells one of these). Read-only.
22
+ */
23
+ export const BLOCKED_WORDS = Object.freeze([...BLOCKED]);
24
+
19
25
  const LEET = {0: 'o', 1: 'i', 3: 'e', 4: 'a', 5: 's', 7: 't', '@': 'a', $: 's'};
20
26
 
21
27
  /** Lower case, accents gone, spacing collapsed - the identity of a name. */
@@ -0,0 +1 @@
1
+ .gm-motion{--gm-step: 60ms;--gm-dur: .4s}.gm-motion :is(.gm-pop,.gm-pop-list>*,.gm-flip,.gm-bounce){animation-duration:var(--gm-dur);animation-timing-function:cubic-bezier(.22,.61,.36,1);animation-fill-mode:backwards;animation-delay:calc(var(--gm-i, 0) * var(--gm-step))}.gm-motion :is(.gm-pop,.gm-pop-list>*){animation-name:dm-game-pop}.gm-motion .gm-flip{animation-name:dm-game-flip}.gm-motion .gm-bounce{animation-name:dm-game-bounce;animation-fill-mode:none}.gm-motion .gm-shake{animation:dm-game-shake .45s ease}.gm-motion .gm-pulse-good{animation:dm-game-pulse-good .5s ease}.gm-motion .gm-pulse-bad{animation:dm-game-pulse-bad .5s ease}.gm-motion .gm-hint-pop{animation:dm-game-hint .5s cubic-bezier(.22,.61,.36,1)}.gm-motion .gm-pop-list>:nth-child(2){--gm-i: 1}.gm-motion .gm-pop-list>:nth-child(3){--gm-i: 2}.gm-motion .gm-pop-list>:nth-child(4){--gm-i: 3}.gm-motion .gm-pop-list>:nth-child(5){--gm-i: 4}.gm-motion .gm-pop-list>:nth-child(6){--gm-i: 5}.gm-motion .gm-pop-list>:nth-child(7){--gm-i: 6}.gm-motion .gm-pop-list>:nth-child(8){--gm-i: 7}.gm-motion .gm-pop-list>:nth-child(9){--gm-i: 8}.gm-motion .gm-pop-list>:nth-child(10){--gm-i: 9}.gm-motion .gm-pop-list>:nth-child(11){--gm-i: 10}.gm-motion .gm-pop-list>:nth-child(12){--gm-i: 11}.gm-motion .gm-pop-list>:nth-child(13){--gm-i: 12}.gm-motion .gm-pop-list>:nth-child(14){--gm-i: 13}.gm-motion .gm-pop-list>:nth-child(15){--gm-i: 14}.gm-motion .gm-pop-list>:nth-child(16){--gm-i: 15}.gm-motion .gm-pop-list>:nth-child(17){--gm-i: 16}.gm-motion .gm-pop-list>:nth-child(18){--gm-i: 17}.gm-motion .gm-pop-list>:nth-child(19){--gm-i: 18}.gm-motion .gm-pop-list>:nth-child(20){--gm-i: 19}.gm-motion .gm-pop-list>:nth-child(n+21){--gm-i: 20}@keyframes dm-game-pop{0%{opacity:0;transform:scale(.5)}70%{opacity:1;transform:scale(1.08)}to{opacity:1;transform:scale(1)}}@keyframes dm-game-flip{0%{transform:perspective(300px) rotateX(-90deg)}to{transform:perspective(300px) rotateX(0)}}@keyframes dm-game-bounce{0%,to{transform:translateY(0)}40%{transform:translateY(-22%)}70%{transform:translateY(4%)}}@keyframes dm-game-shake{0%,to{translate:0}20%,60%{translate:-6px}40%,80%{translate:6px}}@keyframes dm-game-pulse-good{0%{transform:scale(1)}40%{transform:scale(1.06);box-shadow:0 0 0 6px color-mix(in srgb,#2f7d32 35%,transparent)}to{transform:scale(1)}}@keyframes dm-game-pulse-bad{0%{transform:scale(1)}40%{transform:scale(.97);box-shadow:0 0 0 6px color-mix(in srgb,#b3261e 35%,transparent)}to{transform:scale(1)}}@keyframes dm-game-hint{0%{transform:scale(.6)}60%{transform:scale(1.2)}to{transform:scale(1)}}@media(prefers-reduced-motion:reduce){.gm-motion,.gm-motion *{animation:none!important}}html.dm-reduced-motion .gm-motion,html.dm-reduced-motion .gm-motion *{animation:none!important}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Games - motion helpers for the public players (the stylesheet is motion.css
3
+ * beside this file; see its header for the classes).
4
+ *
5
+ * MOTION_CSS the stylesheet, for addStyles([...])
6
+ * motionOff() true when the visitor asked for less motion (OS setting or the footer switch)
7
+ * replay(el, cls) run a one-shot animation class again on an element already drawn
8
+ *
9
+ * Decoration only: none of these waits for an animation before anything else happens.
10
+ *
11
+ * @module _lib/public/games/motion
12
+ */
13
+
14
+ export const MOTION_CSS = '/plugins/_lib/public/games/motion.css';
15
+
16
+ export function motionOff() {
17
+ if (document.documentElement.classList.contains('dm-reduced-motion')) return true;
18
+ try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
19
+ }
20
+
21
+ /** Run `cls` again on `el` (removed, a reflow, added back); cleared once it ends. */
22
+ export function replay(el, cls) {
23
+ if (!el || motionOff()) return;
24
+ el.classList.remove(cls);
25
+ void el.offsetWidth;
26
+ el.classList.add(cls);
27
+ el.addEventListener('animationend', () => el.classList.remove(cls), {once: true});
28
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@b9f41e5",
3
+ "source": "dcms-marketplace@4201b7a",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.10.1",
@@ -126,27 +126,6 @@
126
126
  "tests/seo.test.js": "8d935319eb21fbb81feb663220384b3a4d52d0fe8597047994652daa8c981489"
127
127
  }
128
128
  },
129
- "crossword": {
130
- "version": "1.1.0",
131
- "files": {
132
- "CLAUDE.md": "598736eec2dfa833f7bb84ca9376071d1143d98a5c01106d0ff453749af18e43",
133
- "admin/css/index.css": "d8d32268504d5aefa9c5fbf48a0c67b978cdd01d3be0bc747205a001972e05db",
134
- "admin/templates/crossword.html": "336b4a3b7efff1ab8004c0516d55f11ae2efdc98cb3b18133ea00ca07311e2aa",
135
- "admin/views/crossword.js": "42f3aabd09483eddf34d25dae243a853c562a13348eafe4fc1977361c1b03f4e",
136
- "collections/crossword-plays.json": "20577250f00ebe0f6503cc2aae83dba93d88381b76b0b1dbc76c7b856d4295ff",
137
- "collections/crossword-puzzles.json": "1b70950c96e50ede4d6a0c8e59ca7767b3c684c1a7c68588dc6462e69dad596e",
138
- "config.js": "7a15db33506a432f5c43a095366c2e82a3bd20ceded585fb4fb78b93ad6dff29",
139
- "docs/guide.md": "1189300bbe20f032e9040fe027e84a6137ab560e2fe9b88219aba66ea336ec4d",
140
- "inject/body.html": "9dad7927d5afbd5e53f1edaa4a420403cd2325ce908624f9643a087388c090ab",
141
- "lib/crossword.js": "f8149db65fb3a7f82b2fbae6a9f8d3ceeeec95730e426cf6fc36ea058527df34",
142
- "lib/store.js": "284b3b8abe9fdaa64a660d5ab0120856b893d166b28e8bda6cf0ef038baa74bc",
143
- "plugin.js": "5c1885955d122200c7ff9629d7fb9fcb54c232edb4cec4658574ca90d73730e5",
144
- "plugin.json": "c5f9e3d934423161af6ef5fbf52c0c3394e737245a4de21da72da6d75a790f44",
145
- "public/crossword.css": "656f369ea6587b000ff56b8ab80010f4f76af10966537c180950074ec5169f61",
146
- "public/crossword.js": "8418a2c52a3d351a95e271d3c9f564b7336243ccde75106a9998995575802173",
147
- "public/lib/layout.js": "0026afd8b42277838c55e1c031fa07cfea1c640c47aaf385e461cc998c4619c0"
148
- }
149
- },
150
129
  "docs": {
151
130
  "version": "2.14.0",
152
131
  "files": {
@@ -270,27 +249,6 @@
270
249
  "tests/messages.test.js": "5b724e53fcf7f3b5a035c47c0a4ca98dcd7de42b65f4a4ea195a9409fd53e8af"
271
250
  }
272
251
  },
273
- "quiz": {
274
- "version": "1.1.0",
275
- "files": {
276
- "CLAUDE.md": "9d037c11b0de115bf17a6c53387116be037c0d4dcd1b6bf3dafa373539143e16",
277
- "admin/css/index.css": "6598225cfb8b58c486eb390ac41c7f359c78f48e0c97bc8b354d3832f618afa8",
278
- "admin/templates/quiz.html": "f0b65c406b0b6325a815906ca8da7d884d5d24f2504e05631e46c34a5ca0edb9",
279
- "admin/views/quiz.js": "c58ba86ec989df8f29e8acff2266fb3463e301c5516bf9281193de539b7c1f44",
280
- "collections/quiz-plays.json": "331d665a90b6dddf069245a2faf8dd454a36d387702343a7e36d1fe6dd5015f8",
281
- "collections/quiz-quizzes.json": "ce9bacf6c05fb5c22f9a5c5105f624c7a9c6d78c2bd343072d9cb7587e920b1d",
282
- "config.js": "24db3727a804678ef12290ae258c7cf52afbc7a425bb452d87b7b2467ff7c559",
283
- "docs/guide.md": "a7f8f449b94ecc726dc82fc7a0ae7c335431fa757f9493859bdf16e633689c3f",
284
- "inject/body.html": "07727ead4a04fdeb2759a19e8cc358ba2692b807fb741a6daafaf02463fe8233",
285
- "lib/engine.js": "a554c7abfa357288bbd73f47c9bcbf8eab7d8222ff63b1fd119a3ca5f9fb79f4",
286
- "lib/quiz.js": "d697bbd1af41575a9ecee49781695f2dfb4a585bf0230cc39ac5d174dc9c2762",
287
- "lib/store.js": "a740d879185e20c036ac17ab5cbca74d1f71097f4af3bc121fcddc826d8686c6",
288
- "plugin.js": "0ec6f96fc4922c7ef41b36bd757c02926ee99d50e78fe85f7f30b9fbd9605b25",
289
- "plugin.json": "788960b6a31c7c1c2d525fb7cae9ab9b7c9310def5d778c275f22302ecaadf78",
290
- "public/quiz.css": "e8869fb30e22763357464e9aa7a353306b6631b8a7418ba4e483331e66aada67",
291
- "public/quiz.js": "dedc96469e440c979b20d7d95655fbb25062b00b27be569b2a55ec5a8ec0cbb5"
292
- }
293
- },
294
252
  "security": {
295
253
  "version": "1.2.1",
296
254
  "files": {
@@ -25,6 +25,7 @@ import {
25
25
  } from '../../services/apiEndpoints.js';
26
26
  import {canSeeArtefact} from '../../services/projects.js';
27
27
  import {getCollection} from '../../services/collections.js';
28
+ import {pluginDenial} from '../../services/collectionAccess.js';
28
29
 
29
30
  /**
30
31
  * Scoped users must not expose data they cannot see: the collection an
@@ -40,7 +41,10 @@ async function canUseCollection(user, slug) {
40
41
  if (!slug) return true;
41
42
  const schema = await getCollection(slug).catch(() => null);
42
43
  if (!schema) return true;
43
- return canSeeArtefact(user, schema);
44
+ // A plugin's collection needs what the Collections API asks for it - an
45
+ // endpoint would otherwise publish rows (a game's answers) its author
46
+ // could not read.
47
+ return canSeeArtefact(user, schema) && !pluginDenial(schema, user);
44
48
  }
45
49
 
46
50
  /**
@@ -81,9 +81,10 @@ const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION
81
81
  /**
82
82
  * The generic routes are open to anyone holding `collections`, which every
83
83
  * level-1 admin does. Two kinds of collection need more than that (see
84
- * services/collectionAccess.js): a plugin's collection needs that plugin's own
85
- * permission, and the Tools' user-data presets (notes, tasks, contacts) are
86
- * kept to the caller's own rows. Answers the schema, or sends 404/403 and
84
+ * services/collectionAccess.js): a plugin's collection needs full use of one of
85
+ * the plugin's own permissions (its declared `collectionAction`, or every
86
+ * action), and the Tools' user-data presets (notes, tasks, contacts) are kept
87
+ * to the caller's own rows. Answers the schema, or sends 404/403 and
87
88
  * answers null - the caller returns at once.
88
89
  *
89
90
  * @param {object} request
@@ -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}},