domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -578,6 +578,24 @@ export async function formsRoutes(fastify) {
578
578
  formValues[field.name] = val !== undefined ? (typeof val === 'string' ? val.trim() : val) : '';
579
579
  }
580
580
 
581
+ // Triggers, from the same engine the browser ran. A block-submit that
582
+ // only existed in the browser would be a suggestion: anyone can re-enable
583
+ // a button. Resolved BEFORE the field pass because a trigger decides
584
+ // which fields are in play at all.
585
+ const triggerState = FormLogicEngine.resolveTriggerState(form.fields || [], formValues);
586
+
587
+ if (triggerState.blocks.length) {
588
+ return reply.status(400).send({
589
+ error: triggerState.blocks.map(b => b.message).join(' ')
590
+ });
591
+ }
592
+
593
+ // Hidden and disabled are the same thing to a submission: the visitor
594
+ // was given no way to answer, so the answer is not theirs and is not
595
+ // required. A value posted for one anyway is dropped below.
596
+ const triggerOff = new Set([...triggerState.hidden, ...triggerState.disabled]);
597
+ const triggerRequired = new Set(triggerState.required);
598
+
581
599
  // Evaluate conditional logic
582
600
  const missingFields = [];
583
601
  const validationErrors = [];
@@ -587,11 +605,13 @@ export async function formsRoutes(fastify) {
587
605
  if (field.type === 'page-break' || field.type === 'spacer') continue;
588
606
  const vis = FormLogicEngine.evaluateFieldVisibility(field, formValues);
589
607
  if (vis === 'hidden') continue;
608
+ if (triggerOff.has(field.name)) continue;
590
609
  visibleFieldNames.add(field.name);
591
610
 
592
611
  const value = formValues[field.name];
593
612
  const isEmpty = !value?.toString().trim();
594
- const required = FormLogicEngine.evaluateFieldRequirement(field, formValues);
613
+ const required = FormLogicEngine.evaluateFieldRequirement(field, formValues)
614
+ || triggerRequired.has(field.name);
595
615
  if (required && isEmpty) {
596
616
  missingFields.push(field.label || field.name);
597
617
  }
@@ -719,10 +739,29 @@ export async function formsRoutes(fastify) {
719
739
  }
720
740
  }
721
741
 
742
+ // Trigger events the server owns. Fired after the entry is stored, for
743
+ // the same reason the other actions are: a failed side effect must not
744
+ // cost the visitor their submission.
745
+ let triggerRedirect = null;
746
+ for (const event of triggerState.events) {
747
+ if (event.type === 'run-action' && event.action.slug && entry) {
748
+ try {
749
+ await executeAction(event.action.slug, entry.id, {user: submittingUser});
750
+ } catch (err) {
751
+ fastify.log.warn(`[forms] Trigger action "${event.action.slug}" failed for form "${slug}": ${err.message}`);
752
+ }
753
+ } else if (event.type === 'redirect' && event.action.url && !triggerRedirect) {
754
+ // First one wins - two triggers redirecting at once is an
755
+ // authoring mistake, not a case to resolve cleverly.
756
+ triggerRedirect = String(event.action.url);
757
+ }
758
+ }
759
+
722
760
  hooks.emit('form:submitted', {slug, entryId: entry?.id || null, data, formTitle: form.title});
723
761
 
724
- // Template interpolation for successRedirect
725
- let redirect = settings.successRedirect || null;
762
+ // Template interpolation for successRedirect. A trigger's redirect is
763
+ // the more specific instruction, so it wins over the form's default.
764
+ let redirect = triggerRedirect || settings.successRedirect || null;
726
765
  if (redirect && entry?.id) {
727
766
  redirect = redirect.replace(/\{\{entryId\}\}/g, entry.id);
728
767
  }
@@ -1,5 +1,23 @@
1
- import defaultConfig from './config.js';
2
- import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../server/services/collections.js';
1
+ /**
2
+ * Notes API - /api/notes
3
+ *
4
+ * Was the `notes` plugin until 0.67.0. The logic is unchanged; what moved is
5
+ * where it lives and how it is gated.
6
+ *
7
+ * **Per-user scope.** Entries carry a `userId` and every read is filtered to the
8
+ * caller's own. The plugin made this configurable (`scope: 'user' | 'site'`) and
9
+ * no install ever set it to anything else, so it is a constant here. Note this
10
+ * is hand-filtering rather than `rowAccess.js`, which is the mechanism core has
11
+ * for exactly this - moving onto it is a behaviour change and belongs in its own
12
+ * task, not smuggled into a relocation.
13
+ *
14
+ * **The collection slug is `notes-notes`, not the directory it used to sit in.**
15
+ * `setupPlugin()` read `schema.slug`, so the live collection has always been
16
+ * called this, and existing entries are found only if it stays exactly so.
17
+ */
18
+ import {ago, clip, plural} from '../../services/sidebarBadges.js';
19
+ import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
20
+ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../services/collections.js';
3
21
 
4
22
  const SLUG = 'notes-notes';
5
23
 
@@ -14,13 +32,20 @@ function toNote(entry) {
14
32
  };
15
33
  }
16
34
 
17
- export default async function notesPlugin(fastify, options) {
18
- const { authenticate } = options.auth;
19
- const config = {...defaultConfig, ...(options.settings || {})};
20
- const scope = config.scope ?? 'user';
35
+ /**
36
+ * Auth is injectable so a test can drive these routes without registering
37
+ * @fastify/jwt or a role store - the same shape dashboard.js and plugins.js use.
38
+ *
39
+ * @param {import('fastify').FastifyInstance} fastify
40
+ * @param {object} [opts]
41
+ */
42
+ export async function notesRoutes(fastify, opts = {}) {
43
+ const authenticate = opts.authenticate || defaultAuthenticate;
44
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
45
+
21
46
 
22
47
  function userId(request) {
23
- return scope === 'user' ? (request.user?.id ?? request.user?.sub ?? null) : null;
48
+ return request.user?.id ?? request.user?.sub ?? null;
24
49
  }
25
50
 
26
51
  async function loadAll(uid) {
@@ -30,8 +55,27 @@ export default async function notesPlugin(fastify, options) {
30
55
  return items;
31
56
  }
32
57
 
33
- /** GET /api/plugins/notes/items - supports ?q= and ?category= */
34
- fastify.get('/items', { preHandler: [authenticate] }, async (request, reply) => {
58
+ /** GET /api/notes/count - the sidebar badge: how many notes this user has. */
59
+ fastify.get('/count', {preHandler: [authenticate, requirePermission('notes', 'read')]}, async (request) => {
60
+ const notes = await loadAll(userId(request)); // newest edit first
61
+ const categories = new Set(notes.flatMap(n => (Array.isArray(n.categories) ? n.categories : []).map(c => String(c).toLowerCase())));
62
+ return {
63
+ count: notes.length, label: plural(notes.length, 'note'), tone: 'info',
64
+ details: {
65
+ title: 'Notes',
66
+ rows: [
67
+ {label: 'Notes', value: String(notes.length)},
68
+ {label: 'Categories', value: String(categories.size)},
69
+ {label: 'Edited this week', value: String(notes.filter(n => Date.now() - new Date(n.updatedAt).getTime() < 7 * 86_400_000).length)}
70
+ ],
71
+ items: notes.slice(0, 4).map(n => ({text: clip(n.title || n.content || 'Untitled'), meta: `edited ${ago(n.updatedAt)}`})),
72
+ empty: 'No notes yet.'
73
+ }
74
+ };
75
+ });
76
+
77
+ /** GET /api/notes/items - supports ?q= and ?category= */
78
+ fastify.get('/items', {preHandler: [authenticate, requirePermission('notes', 'read')]}, async (request, reply) => {
35
79
  const uid = userId(request);
36
80
  const { q, category } = request.query;
37
81
 
@@ -55,8 +99,8 @@ export default async function notesPlugin(fastify, options) {
55
99
  return reply.send(items);
56
100
  });
57
101
 
58
- /** POST /api/plugins/notes/items */
59
- fastify.post('/items', { preHandler: [authenticate] }, async (request, reply) => {
102
+ /** POST /api/notes/items */
103
+ fastify.post('/items', {preHandler: [authenticate, requirePermission('notes', 'create')]}, async (request, reply) => {
60
104
  const uid = userId(request);
61
105
  const { title, content, categories } = request.body ?? {};
62
106
 
@@ -72,8 +116,8 @@ export default async function notesPlugin(fastify, options) {
72
116
  return reply.code(201).send(toNote(entry));
73
117
  });
74
118
 
75
- /** PUT /api/plugins/notes/items/:id */
76
- fastify.put('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
119
+ /** PUT /api/notes/items/:id */
120
+ fastify.put('/items/:id', {preHandler: [authenticate, requirePermission('notes', 'update')]}, async (request, reply) => {
77
121
  const uid = userId(request);
78
122
  const { id } = request.params;
79
123
  const { title, content, categories } = request.body ?? {};
@@ -95,8 +139,8 @@ export default async function notesPlugin(fastify, options) {
95
139
  return reply.send(toNote(updated));
96
140
  });
97
141
 
98
- /** DELETE /api/plugins/notes/items/:id */
99
- fastify.delete('/items/:id', { preHandler: [authenticate] }, async (request, reply) => {
142
+ /** DELETE /api/notes/items/:id */
143
+ fastify.delete('/items/:id', {preHandler: [authenticate, requirePermission('notes', 'delete')]}, async (request, reply) => {
100
144
  const uid = userId(request);
101
145
  const { id } = request.params;
102
146
 
@@ -108,8 +152,8 @@ export default async function notesPlugin(fastify, options) {
108
152
  return reply.send({ success: true });
109
153
  });
110
154
 
111
- /** GET /api/plugins/notes/categories */
112
- fastify.get('/categories', { preHandler: [authenticate] }, async (request, reply) => {
155
+ /** GET /api/notes/categories */
156
+ fastify.get('/categories', {preHandler: [authenticate, requirePermission('notes', 'read')]}, async (request, reply) => {
113
157
  const uid = userId(request);
114
158
  const items = await loadAll(uid);
115
159
  const cats = new Set();
@@ -7,13 +7,63 @@
7
7
  * POST /api/system/notifications/:id/dismiss - Mark dismissed
8
8
  * DELETE /api/system/notifications/:id - Hard delete (admin only)
9
9
  */
10
- import { authenticate, requirePermission } from '../../middleware/auth.js';
10
+ import {
11
+ authenticate as defaultAuthenticate,
12
+ requirePermission as defaultRequirePermission
13
+ } from '../../middleware/auth.js';
11
14
  import { requireManager } from '../../middleware/managerAuth.js';
12
15
  import { getAdapter } from '../../services/adapterRegistry.js';
13
16
 
14
17
  const SLUG = 'notifications';
15
18
 
16
- export async function notificationsRoutes(fastify) {
19
+ /** Ids already warned about, so one bad record does not fill the log. */
20
+ const warnedMalformed = new Set();
21
+
22
+ /**
23
+ * A notification's payload, or null when the record is not a notification.
24
+ *
25
+ * Entries are `{id, data, meta}`, but this collection has been written to
26
+ * directly before now - a form once pointed its submissions at `notifications`
27
+ * and left flat records behind with no `data` at all. Reading `entry.data.x`
28
+ * off one of those threw, and because the bell polls on every admin page load,
29
+ * a single stray record turned into a 500 on every screen in the admin. One
30
+ * unreadable row is skipped; it is not the rest of the inbox's problem.
31
+ *
32
+ * @param {object} entry
33
+ * @param {object} [log] - Fastify logger, when there is one to warn through.
34
+ * @returns {object|null}
35
+ */
36
+ function notificationData(entry, log) {
37
+ const data = entry && entry.data;
38
+ if (data && typeof data === 'object') return data;
39
+ const id = entry?.id || '(no id)';
40
+ if (log && !warnedMalformed.has(id)) {
41
+ warnedMalformed.add(id);
42
+ log.warn(`[notifications] Skipping malformed entry "${id}" - no data object. `
43
+ + 'Something wrote to the notifications collection directly.');
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Is this notification live for this user right now: not expired, not dismissed.
50
+ *
51
+ * @param {object} data - A notification payload.
52
+ * @param {string} userId
53
+ * @param {string} now - ISO timestamp.
54
+ * @returns {boolean}
55
+ */
56
+ function isActiveFor(data, userId, now) {
57
+ if (data.expiresAt && data.expiresAt < now) return false;
58
+ return !(Array.isArray(data.dismissedBy) && data.dismissedBy.includes(userId));
59
+ }
60
+
61
+ export async function notificationsRoutes(fastify, opts = {}) {
62
+ // Injectable so tests can supply no-ops rather than a seeded roles cache -
63
+ // the same DI pattern as menus.js, projects.js and context-menus.js.
64
+ const authenticate = opts.authenticate || defaultAuthenticate;
65
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
66
+
17
67
  const canRead = { preHandler: [authenticate, requirePermission('notifications', 'read')] };
18
68
  const canDelete = { preHandler: [authenticate, requirePermission('notifications', 'delete')] };
19
69
 
@@ -69,14 +119,12 @@ export async function notificationsRoutes(fastify) {
69
119
 
70
120
  return all
71
121
  .filter(entry => {
72
- const d = entry.data;
73
- // Exclude expired
74
- if (d.expiresAt && d.expiresAt < now) return false;
75
- // Exclude dismissed by this user
76
- if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
77
- return true;
122
+ const d = notificationData(entry, fastify.log);
123
+ return !!d && isActiveFor(d, userId, now);
78
124
  })
79
- .sort((a, b) => b.data.createdAt.localeCompare(a.data.createdAt))
125
+ // A record with no createdAt sorts last rather than taking the
126
+ // whole list down with it.
127
+ .sort((a, b) => String(b.data.createdAt || '').localeCompare(String(a.data.createdAt || '')))
80
128
  .map(entry => ({
81
129
  ...entry,
82
130
  unread: !Array.isArray(entry.data.readBy) || !entry.data.readBy.includes(userId)
@@ -91,9 +139,8 @@ export async function notificationsRoutes(fastify) {
91
139
  const all = await adapter.all(SLUG);
92
140
 
93
141
  const count = all.filter(entry => {
94
- const d = entry.data;
95
- if (d.expiresAt && d.expiresAt < now) return false;
96
- if (Array.isArray(d.dismissedBy) && d.dismissedBy.includes(userId)) return false;
142
+ const d = notificationData(entry, fastify.log);
143
+ if (!d || !isActiveFor(d, userId, now)) return false;
97
144
  return !Array.isArray(d.readBy) || !d.readBy.includes(userId);
98
145
  }).length;
99
146
 
@@ -107,13 +154,15 @@ export async function notificationsRoutes(fastify) {
107
154
  const adapter = await getAdapter(SLUG);
108
155
  const entry = await adapter.get(SLUG, id);
109
156
 
110
- if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
157
+ const data = notificationData(entry, fastify.log);
158
+ // A record the list never showed is not one this route can mark read.
159
+ if (!data) return reply.code(404).send({ error: 'Notification not found.' });
111
160
 
112
- const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
161
+ const readBy = Array.isArray(data.readBy) ? data.readBy : [];
113
162
  if (readBy.includes(userId)) return { ok: true }; // already read - idempotent
114
163
 
115
164
  const updated = await adapter.update(SLUG, id, {
116
- ...entry.data,
165
+ ...data,
117
166
  readBy: [...readBy, userId]
118
167
  });
119
168
 
@@ -127,13 +176,14 @@ export async function notificationsRoutes(fastify) {
127
176
  const adapter = await getAdapter(SLUG);
128
177
  const entry = await adapter.get(SLUG, id);
129
178
 
130
- if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
179
+ const data = notificationData(entry, fastify.log);
180
+ if (!data) return reply.code(404).send({ error: 'Notification not found.' });
131
181
 
132
- const dismissedBy = Array.isArray(entry.data.dismissedBy) ? entry.data.dismissedBy : [];
133
- const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
182
+ const dismissedBy = Array.isArray(data.dismissedBy) ? data.dismissedBy : [];
183
+ const readBy = Array.isArray(data.readBy) ? data.readBy : [];
134
184
 
135
185
  const updated = await adapter.update(SLUG, id, {
136
- ...entry.data,
186
+ ...data,
137
187
  dismissedBy: dismissedBy.includes(userId) ? dismissedBy : [...dismissedBy, userId],
138
188
  readBy: readBy.includes(userId) ? readBy : [...readBy, userId] // dismissing also marks read
139
189
  });
@@ -1,32 +1,57 @@
1
1
  /**
2
- * Plugin Marketplace API
3
- * GET /api/plugins/marketplace - fetch catalogue from manager (requires MANAGER_URL)
4
- * POST /api/plugins/marketplace/install - download + install a plugin bundle
5
- * DELETE /api/plugins/marketplace/:slug - uninstall an installed plugin
2
+ * Plugin Marketplace API - the managed (fleet) path.
3
+ *
4
+ * GET /api/plugins/marketplace - the catalogue as this site sees it
5
+ * POST /api/plugins/marketplace/install - fetch a bundle and install it
6
+ * DELETE /api/plugins/marketplace/:slug - uninstall
7
+ *
8
+ * This is delivery path A: a site the manager forked, buying from the fleet's
9
+ * own catalogue over loopback. The standalone path - buy, download one file,
10
+ * Install from File - goes through `POST /plugins/install-upload` instead and
11
+ * shares nothing with this but the installer.
12
+ *
13
+ * An unmanaged site has no MANAGER_URL, every route here answers "not
14
+ * available", and the screen says so in terms that fit that customer.
6
15
  *
7
16
  * All routes require authentication + admin role.
8
17
  */
9
18
  import {authenticate, requireAdmin} from '../../middleware/auth.js';
10
- import {fetchCatalogue, fetchPluginBundle} from '../../services/managerClient.js';
11
- import {installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
19
+ import {fetchCatalogue, fetchPluginBundle, isManaged, siteSlug} from '../../services/managerClient.js';
20
+ import {compareVersions, installPlugin, uninstallPlugin} from '../../services/pluginInstaller.js';
21
+ import {discoverPlugins} from '../../services/plugins.js';
12
22
 
13
23
  export async function pluginMarketplaceRoutes(fastify) {
14
24
  const adminOnly = { preHandler: [authenticate, requireAdmin] };
15
25
 
16
26
  // GET /plugins/marketplace
17
27
  fastify.get('/plugins/marketplace', adminOnly, async (_request, reply) => {
18
- if (!process.env.MANAGER_URL) {
19
- return reply.send({ available: false, catalogue: [] });
20
- }
28
+ if (!isManaged()) return reply.send({ available: false, catalogue: [] });
29
+
30
+ const catalogue = await fetchCatalogue();
21
31
 
22
- const licenceToken = process.env.LICENCE_TOKEN;
23
- const catalogue = await fetchCatalogue(licenceToken);
24
- return reply.send({ available: true, catalogue });
32
+ // What is on THIS disk is a local fact - the manager's ledger knows
33
+ // what a site is licensed for, never what it actually has. Joined here
34
+ // so the card can offer Update rather than an Install that would come
35
+ // back 409.
36
+ const installed = new Map((await discoverPlugins()).map(m => [m.name, m.version]));
37
+ return reply.send({
38
+ available: true,
39
+ site: siteSlug(),
40
+ catalogue: catalogue.map(entry => {
41
+ const have = installed.get(entry.slug);
42
+ return {
43
+ ...entry,
44
+ installed: have !== undefined,
45
+ installedVersion: have ?? null,
46
+ updatable: have !== undefined && compareVersions(entry.version, have) > 0
47
+ };
48
+ })
49
+ });
25
50
  });
26
51
 
27
52
  // POST /plugins/marketplace/install
28
53
  fastify.post('/plugins/marketplace/install', adminOnly, async (request, reply) => {
29
- const { slug, version } = request.body || {};
54
+ const { slug, version, update } = request.body || {};
30
55
 
31
56
  if (!slug || !/^[a-z0-9][a-z0-9-_]{0,63}$/.test(slug)) {
32
57
  return reply.code(400).send({ error: 'Invalid plugin slug.' });
@@ -34,25 +59,55 @@ export async function pluginMarketplaceRoutes(fastify) {
34
59
  if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
35
60
  return reply.code(400).send({ error: 'Invalid version format. Expected semver (e.g. 1.0.0).' });
36
61
  }
62
+ if (!isManaged()) {
63
+ return reply.code(503).send({ error: 'This site is not managed - install from a file instead.' });
64
+ }
37
65
 
38
- const licenceToken = process.env.LICENCE_TOKEN;
39
- const bundle = await fetchPluginBundle(slug, version, licenceToken);
40
-
66
+ let bundle;
67
+ try {
68
+ bundle = await fetchPluginBundle(slug, version);
69
+ } catch (err) {
70
+ // A refusal from the manager is the answer, not a failure: "this
71
+ // site has no licence for invoice" is what the admin needs to read.
72
+ return reply.code(403).send({ error: err.message });
73
+ }
41
74
  if (!bundle) {
42
- return reply.code(503).send({ error: 'Manager unavailable' });
75
+ return reply.code(503).send({ error: 'The manager is not reachable right now.' });
43
76
  }
44
77
 
45
78
  try {
46
- await installPlugin(slug, bundle);
79
+ const result = await installPlugin(slug, {
80
+ tarball: bundle.tarball,
81
+ signature: bundle.signature,
82
+ publicKeyId: bundle.publicKeyId,
83
+ // Minted for this site by the manager. Without it a paid plugin
84
+ // installs and then refuses to load, which reads as a broken
85
+ // install rather than a licensing answer.
86
+ entitlement: bundle.entitlement,
87
+ // Same plugin already here means update, which the admin asked
88
+ // for by pressing Update rather than Install.
89
+ allowUpgrade: update === true
90
+ });
91
+ return reply.send({
92
+ success: true,
93
+ name: slug,
94
+ action: result?.action ?? 'install',
95
+ from: result?.from ?? null,
96
+ version: result?.version ?? bundle.version,
97
+ restartRequired: true
98
+ });
47
99
  } catch (err) {
48
100
  fastify.log.error({ err }, '[marketplace] install failed');
49
- // Known safe error messages from pluginInstaller (for admin clarity)
50
- const safeMessages = ['Plugin already installed', 'Plugin signature invalid', 'Unknown public key', 'Invalid plugin slug'];
51
- const safe = safeMessages.some(m => err.message.startsWith(m));
101
+ // Messages the installer raises deliberately, all of them things an
102
+ // admin can act on. Anything else is kept vague on purpose - an
103
+ // unexpected error can carry a path from this server.
104
+ const safe = [
105
+ 'Plugin already installed', 'Plugin signature invalid', 'Unknown public key',
106
+ 'Invalid plugin slug', 'Refusing to downgrade', 'Refusing to upgrade',
107
+ 'This site accepts signed plugins only'
108
+ ].some(m => err.message.startsWith(m));
52
109
  return reply.code(400).send({ error: safe ? err.message : 'Installation failed.' });
53
110
  }
54
-
55
- return reply.send({ success: true });
56
111
  });
57
112
 
58
113
  // DELETE /plugins/marketplace/:slug