domma-cms 0.84.1 → 0.86.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 (70) hide show
  1. package/README.md +4 -4
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/js/lib/sidebar-renderer.js +3 -3
  5. package/admin/js/lib/themes.js +1 -1
  6. package/config/menus/admin-sidebar.json +21 -8
  7. package/config/plugins.json +0 -18
  8. package/package.json +2 -2
  9. package/plugins/_lib/mail/diagnostics.js +3 -2
  10. package/plugins/blog/CLAUDE.md +33 -0
  11. package/plugins/blog/lib/access.js +64 -0
  12. package/plugins/blog/lib/notices.js +38 -0
  13. package/plugins/blog/plugin.js +84 -26
  14. package/plugins/blog/plugin.json +173 -20
  15. package/plugins/blog/roles/blog-author.json +15 -2
  16. package/plugins/blog/tests/access.test.js +32 -0
  17. package/plugins/blog/tests/comment-notices.test.js +140 -0
  18. package/plugins/blog/tests/notices.test.js +20 -0
  19. package/plugins/blog/tests/public.test.js +3 -1
  20. package/plugins/free-tier.lock.json +63 -10
  21. package/plugins/mail-reader/plugin.js +12 -1
  22. package/plugins/mail-reader/plugin.json +23 -4
  23. package/plugins/shopping-cart/CLAUDE.md +93 -0
  24. package/plugins/shopping-cart/admin/css/index.css +224 -0
  25. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  26. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  27. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  28. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  29. package/plugins/shopping-cart/admin/views/products.js +547 -0
  30. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  31. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  32. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  33. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  34. package/plugins/shopping-cart/config.js +52 -0
  35. package/plugins/shopping-cart/inject/body.html +2 -0
  36. package/plugins/shopping-cart/lib/extensions.js +96 -0
  37. package/plugins/shopping-cart/lib/payments.js +281 -0
  38. package/plugins/shopping-cart/lib/pricing.js +232 -0
  39. package/plugins/shopping-cart/lib/render.js +282 -0
  40. package/plugins/shopping-cart/lib/samples.js +34 -0
  41. package/plugins/shopping-cart/lib/shop.js +436 -0
  42. package/plugins/shopping-cart/lib/summary.js +127 -0
  43. package/plugins/shopping-cart/plugin.js +559 -0
  44. package/plugins/shopping-cart/plugin.json +52 -0
  45. package/plugins/shopping-cart/plugin.public.js +144 -0
  46. package/plugins/shopping-cart/public/cart.js +715 -0
  47. package/plugins/shopping-cart/public/lib/money.js +105 -0
  48. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  49. package/plugins/shopping-cart/public/lib/products.js +235 -0
  50. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  51. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  52. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  53. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  54. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  55. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  56. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  57. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  58. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  59. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  60. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  61. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  62. package/plugins/shopping-cart/public/shop.css +415 -0
  63. package/plugins/shopping-cart/templates/shop.html +4 -0
  64. package/plugins/shopping-cart/tests/api.test.js +144 -0
  65. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  66. package/scripts/setup.js +1 -0
  67. package/server/routes/api/notifications.js +4 -3
  68. package/server/services/notify.js +4 -2
  69. package/server/services/pluginScope.js +85 -0
  70. package/server/services/plugins.js +7 -2
@@ -0,0 +1,140 @@
1
+ /**
2
+ * blog: a comment held for approval raises a notice for the site's staff, and
3
+ * moderating or deleting it resolves that notice. The plugin runs in Fastify
4
+ * against the domma-cms checkout's collections; the blog collections'
5
+ * data files and config/plugins.json are put back byte for byte.
6
+ */
7
+ import {test, before, after} from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import Fastify from 'fastify';
12
+ import plugin from '../plugin.js';
13
+ import {commentKey} from '../lib/notices.js';
14
+
15
+ const SLUGS = {posts: 'blog-posts', comments: 'blog-comments', categories: 'blog-categories'};
16
+ // The checkout's content/ carries an old demo blog-comments schema (post_slug, author_name...), not the
17
+ // plugin's: the plugin's own schemas stand in for the run, and every file goes back as it was.
18
+ const KEEP = ['config/plugins.json', ...Object.values(SLUGS).flatMap(c => [`content/collections/${c}/data.json`, `content/collections/${c}/schema.json`])]
19
+ .map(f => path.resolve(f));
20
+ const HERE = path.dirname(new URL(import.meta.url).pathname);
21
+ const saved = new Map();
22
+ // Registering the plugin seeds its layout blocks into content/blocks: whatever it adds goes again.
23
+ const BLOCKS = path.resolve('content/blocks');
24
+ let blocksBefore = new Set();
25
+ const MIGRATIONS = path.resolve('content/.migrations.json');
26
+ let hadMigrations = true;
27
+ const notes = [];
28
+ const resolved = [];
29
+ const sources = [];
30
+ // Who holds what - as a site that shipped this release would have it.
31
+ const WRITE = ['super-admin', 'admin', 'blog-editor', 'blog-author'];
32
+ const EDIT = ['super-admin', 'admin', 'blog-editor'];
33
+ const HOLDERS = {
34
+ 'blog-posts.read': WRITE, 'blog-posts.create': WRITE, 'blog-posts.update': WRITE, 'blog-categories.read': WRITE,
35
+ 'blog-posts.delete': EDIT, 'blog-posts.publish': EDIT, 'blog-categories.create': EDIT, 'blog-categories.update': EDIT,
36
+ 'blog-categories.delete': EDIT, 'blog-comments.read': EDIT, 'blog-comments.update': EDIT, 'blog-comments.delete': EDIT
37
+ };
38
+ let app;
39
+
40
+ before(async () => {
41
+ for (const f of KEEP) saved.set(f, await fs.readFile(f, 'utf8').catch(() => null));
42
+ blocksBefore = new Set(await fs.readdir(BLOCKS).catch(() => []));
43
+ const {invalidate} = await import('../../../server/services/adapterRegistry.js').catch(() => ({}));
44
+ for (const [dir, slug] of Object.entries(SLUGS)) {
45
+ const target = path.resolve('content/collections', slug);
46
+ await fs.mkdir(target, {recursive: true});
47
+ await fs.copyFile(path.join(HERE, '..', 'collections', dir, 'schema.json'), path.join(target, 'schema.json'));
48
+ await fs.writeFile(path.join(target, 'data.json'), '[]\n');
49
+ invalidate?.(slug);
50
+ }
51
+ hadMigrations = await fs.access(MIGRATIONS).then(() => true, () => false);
52
+ app = Fastify();
53
+ const pass = async () => {};
54
+ await app.register(plugin, {prefix: '/b', settings: {},
55
+ // Who is asking: the x-as header (a role), super-admin by default.
56
+ getPermissionsFor: (key) => HOLDERS[key] || ['super-admin'],
57
+ auth: {authenticate: async (req) => { const r = req.headers['x-as'] || 'super-admin'; req.user = {id: `u-${r}`, role: r, name: r,
58
+ ...(req.headers['x-also'] ? {additionalRoles: [req.headers['x-also']]} : {})}; }, requireAdmin: pass,
59
+ requirePermission: () => pass, requireRole: () => pass, requireVisibility: () => pass},
60
+ hooks: {notify: (p) => { notes.push(p); }, resolveNotification: (k, s) => { resolved.push(`${s}|${k}`); },
61
+ registerNotificationSource: (d) => { sources.push(d.id); }}});
62
+ await app.ready();
63
+ });
64
+
65
+ after(async () => {
66
+ await app?.close();
67
+ for (const name of await fs.readdir(BLOCKS).catch(() => [])) {
68
+ if (!blocksBefore.has(name)) await fs.rm(path.join(BLOCKS, name), {force: true});
69
+ }
70
+ if (!hadMigrations) await fs.rm(MIGRATIONS, {force: true});
71
+ for (const [f, text] of saved) {
72
+ if (text === null) await fs.rm(f, {force: true});
73
+ else await fs.writeFile(f, text);
74
+ }
75
+ });
76
+
77
+ const call = async (method, url, body, as, also) => {
78
+ const headers = {...(as ? {'x-as': as} : {}), ...(also ? {'x-also': also} : {})};
79
+ const res = await app.inject({method, url: `/b${url}`, payload: body, headers});
80
+ return {status: res.statusCode, body: res.json()};
81
+ };
82
+
83
+ test('a held comment notifies; approving it, or deleting one, resolves it', async () => {
84
+ assert.ok(sources.includes('comments'));
85
+ assert.equal((await call('PUT', '/settings', {commentsEnabled: true, commentModeration: true})).status, 200, 'comments on, held for approval');
86
+ const post = (await call('POST', '/posts', {title: 'Spring opening hours', slug: `zz-notice-${Date.now()}`, status: 'published',
87
+ content: 'Open late.', body: 'Open late.'})).body;
88
+ assert.ok(post.id, JSON.stringify(post));
89
+ await call('POST', `/posts/${post.id}/publish`).catch(() => {});
90
+
91
+ const say = (text) => call('POST', '/comments', {postId: post.id, authorName: 'Jo', authorEmail: 'jo@example.test', body: text});
92
+ const first = await say('<b>Great</b> news');
93
+ assert.equal(first.status, 201, JSON.stringify(first.body));
94
+ const n = notes.find(x => x.dedupeKey === commentKey(first.body.id));
95
+ assert.equal(n.title, 'New comment on "Spring opening hours"');
96
+ assert.match(n.body, /^Jo: <b>Great<\/b> news/, 'kept as text - the screen escapes it');
97
+ assert.equal(n.source, 'comments');
98
+ assert.equal(n.link, '#/plugins/blog/comments');
99
+
100
+ await call('PUT', `/comments/${first.body.id}`, {status: 'approved'});
101
+ assert.ok(resolved.includes(`comments|${commentKey(first.body.id)}`));
102
+
103
+ const second = await say('Spam?');
104
+ resolved.length = 0;
105
+ await call('PUT', `/comments/${second.body.id}`, {status: 'pending'});
106
+ assert.equal(resolved.length, 0, 'still waiting: nothing to resolve');
107
+ await call('DELETE', `/comments/${second.body.id}`);
108
+ assert.ok(resolved.includes(`comments|${commentKey(second.body.id)}`));
109
+
110
+ // The honeypot makes nothing, and says nothing.
111
+ notes.length = 0;
112
+ await call('POST', '/comments', {postId: post.id, authorName: 'Bot', authorEmail: 'b@x.test', body: 'buy', website: 'http://spam'});
113
+ assert.equal(notes.length, 0);
114
+ });
115
+
116
+ test('by permission: a plain account gets nothing; an author only their own; an editor held as an additional role moderates', async () => {
117
+ const draft = (await call('POST', '/posts', {title: 'Staff draft', slug: `zz-perm-${Date.now()}`})).body;
118
+ const say = (await call('POST', '/comments', {postId: draft.id, authorName: 'Jo', authorEmail: 'jo@example.test', body: 'x'}));
119
+ // A plain signed-in account: no drafts, no commenters, no writing, no moderating.
120
+ for (const [m, u, b] of [['GET', '/posts'], ['GET', `/posts/${draft.id}`], ['POST', '/posts', {title: 'x'}], ['GET', '/comments'],
121
+ ['GET', '/comments/pending-count'], ['GET', '/settings'], ['GET', '/categories']]) {
122
+ assert.equal((await call(m, u, b, 'user')).status, 403, `${m} ${u}`);
123
+ }
124
+ if (say.body?.id) {
125
+ assert.equal((await call('PUT', `/comments/${say.body.id}`, {status: 'approved'}, 'user')).status, 403);
126
+ assert.equal((await call('DELETE', `/comments/${say.body.id}`, null, 'user')).status, 403);
127
+ }
128
+ // An author: writes, sees only their own, cannot publish, delete or read comments.
129
+ const mine = (await call('POST', '/posts', {title: 'Mine', slug: `zz-mine-${Date.now()}`}, 'blog-author')).body;
130
+ assert.ok(mine.id);
131
+ const listed = (await call('GET', '/posts', null, 'blog-author')).body.map(p => p.id);
132
+ assert.ok(listed.includes(mine.id) && !listed.includes(draft.id), 'own posts only');
133
+ assert.equal((await call('GET', `/posts/${draft.id}`, null, 'blog-author')).status, 404, "someone else's draft is not there");
134
+ assert.equal((await call('POST', `/posts/${mine.id}/publish`, null, 'blog-author')).status, 403);
135
+ assert.equal((await call('DELETE', `/posts/${mine.id}`, null, 'blog-author')).status, 403);
136
+ assert.equal((await call('GET', '/comments', null, 'blog-author')).status, 403);
137
+ // An editor held as an ADDITIONAL role: role names are not what counts.
138
+ assert.equal((await call('GET', '/comments', null, 'user', 'blog-editor')).status, 200);
139
+ if (say.body?.id) assert.equal((await call('PUT', `/comments/${say.body.id}`, {status: 'approved'}, 'user', 'blog-editor')).status, 200);
140
+ });
@@ -0,0 +1,20 @@
1
+ /** The moderation notice (blog/lib/notices.js). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {commentKey, commentNotice} from '../lib/notices.js';
5
+
6
+ test('a held comment makes a notice; an approved one does not', () => {
7
+ const n = commentNotice({id: 'c1', status: 'pending', authorName: 'Sam', body: 'Lovely post,\nthanks'}, 'Spring hours', '#/plugins/blog/comments');
8
+ assert.deepEqual(n, {title: 'New comment on "Spring hours"', body: 'Sam: Lovely post, thanks\n\nWaiting for approval.',
9
+ severity: 'info', link: '#/plugins/blog/comments', source: 'comments', dedupeKey: commentKey('c1')});
10
+ assert.equal(commentNotice({id: 'c2', status: 'approved', body: 'x'}, 'P', '#/x'), null);
11
+ assert.equal(commentNotice(null, 'P', '#/x'), null);
12
+ });
13
+
14
+ test('a long comment is one line, cut short; a missing name is "Someone"', () => {
15
+ const n = commentNotice({id: 'c3', status: 'pending', body: 'word '.repeat(200)}, 'T'.repeat(100), '#/x');
16
+ assert.ok(n.title.length <= 100);
17
+ assert.ok(n.body.startsWith('Someone: word word'));
18
+ assert.ok(n.body.split('\n')[0].length <= 212);
19
+ assert.ok(n.body.includes('…'));
20
+ });
@@ -16,7 +16,7 @@ import zlib from 'zlib';
16
16
  import Fastify from 'fastify';
17
17
  import compress from '@fastify/compress';
18
18
  import blogPublicPlugin from '../plugin.public.js';
19
- import {postFields} from '../plugin.js';
19
+ import {postFields, usePermissionLookup} from '../plugin.js';
20
20
 
21
21
  async function buildPublic() {
22
22
  const app = Fastify();
@@ -54,6 +54,8 @@ test('a display can be asked for, and an unknown one falls back', async () => {
54
54
  });
55
55
 
56
56
  test('an author cannot publish by writing the status - that is what the publish route is for', () => {
57
+ // Who may publish is a permission: editors and admins hold blog-posts.publish.
58
+ usePermissionLookup((k) => (k === 'blog-posts.publish' ? ['super-admin', 'admin', 'blog-editor'] : []));
57
59
  const author = {id: 'a', role: 'blog-author'};
58
60
  const editor = {id: 'e', role: 'blog-editor'};
59
61
  assert.equal(postFields({status: 'published'}, author).status, undefined);
@@ -1,11 +1,11 @@
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@46444b1",
3
+ "source": "dcms-marketplace@0335bfa",
4
4
  "plugins": {
5
5
  "blog": {
6
- "version": "1.6.3",
6
+ "version": "1.8.0",
7
7
  "files": {
8
- "CLAUDE.md": "63b11d562db0e0003b929320f55f2a6dedf6ba98a20ed6f5c768fa3177286de9",
8
+ "CLAUDE.md": "026438d4d93f5a637be16ac22d5131e8248e93d2df1bd9b30d5f1cfe357ba2cc",
9
9
  "admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
10
10
  "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
11
  "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
@@ -41,13 +41,15 @@
41
41
  "collections/comments/schema.json": "29cbd289cbfcfc4158c9357a218db4ce06eddc3370bfbf9afb7d7f52065a849e",
42
42
  "collections/posts/schema.json": "19a5a770d3eefea73f4117454bd1312a7a7e3d727cfcdfde141139fbc81cabd7",
43
43
  "config.js": "ee67396262762f5c31269d258affd75f7392a3e364f72f7792c2f1cdafd94bba",
44
+ "lib/access.js": "af47440c7233a56000641a3231d7e9ff81b12f40dfb9528aa6dd4e2dd9bca8df",
44
45
  "lib/layouts.js": "bbb61ac7ec2e0cecd3d59700e9f3bd1064cd53b2e9f639d28b45ffc2ad80ab27",
45
46
  "lib/nav-link.js": "6a8f9368bb8aee1c21e90fb335a691859cb9a9bc250c2b9167a33ca9189fa2ff",
47
+ "lib/notices.js": "a9a2918ee6f5f7e0bbaeced0d3539584279bdbe68d15472a8615bdddb4ef905b",
46
48
  "lib/page.js": "464face32871185368de096ed9a6aa635523bf319af6459308cfceb77d66f82c",
47
49
  "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
48
50
  "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
49
- "plugin.js": "ccfd8735838bb6c8c5d45c3c3589d6adcf114a89b560f66e93a1077e9bea3bfb",
50
- "plugin.json": "5654242b0fd5d4f6039a87dc429bd69a24355a59c3409be10998410fbe6d019b",
51
+ "plugin.js": "db93fd831f6ff6762c6768d47e658830b6284b6f4a9efd2bbffaab5966ba0361",
52
+ "plugin.json": "d6a254e9ec114cbc469e8e2b5a9ca33376c3f88e49c54d42ea589ac883e3dac3",
51
53
  "plugin.public.js": "8efc1ff3a2ead68d36fbd545f8ce590f600b293896ba6f4947418b1efcdf2947",
52
54
  "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
53
55
  "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
@@ -57,23 +59,26 @@
57
59
  "public/samples/harbour.svg": "a5f09eafa1406da3c2d017e9bee308a98543df15090ed27227cbbf8341aec460",
58
60
  "public/samples/meadow.svg": "90efae21e209b7d48901eed457f47d0a347c58a6b6e1ea81ba98c131c4c559cb",
59
61
  "public/samples/rosewood.svg": "0f963fe5633585786271255b7c9df36be67748ae751f9cf7b0bbda6f5b93449d",
60
- "roles/blog-author.json": "ccdd4d99296c157ddc839af22ef6e79c47c381dd9d937069f7090a4fb76bb41d",
62
+ "roles/blog-author.json": "2784a98dd31667bd3aefdf3f8ad3895e6fb200e26a9f1bb7c873f9e9993d4a2b",
61
63
  "roles/blog-editor.json": "bea0b673cf9e3da04c5882d51dd33bdcaf035b047c1cfe87624a9f16e6514ffc",
62
64
  "templates/index.html": "3c2cf8a4ffde8599d451f6d00b736aeba42fd073b726ada3b0ffe4d56e1ef0f4",
63
65
  "templates/post.html": "28e9f06071115ecc4fbba007b35782732bf12ef72528525893d424e3c4f0313f",
66
+ "tests/access.test.js": "65b760910117c3a44195543acdcff91074f39b4e5567911a2bd8676204152cb2",
67
+ "tests/comment-notices.test.js": "34026cc9a2a4746cf87bc41df8be849fadaa43091a72ac2aa1d06c1f437bf618",
64
68
  "tests/layouts.test.js": "5325aad79a153f1d17f49b27223f50be182a2acce8ee8be7da658ae92ae36fe8",
65
69
  "tests/nav-link.test.js": "d36e33ad7483abca27ae6e2098ad2f75bafa18d91f3beba70372ebe6b9b88994",
66
- "tests/public.test.js": "0049a6be16c6a8acb44d53ea885909c80563a0afaa3e1e89d3c54d5429feed98"
70
+ "tests/notices.test.js": "2b02aa84e780dc4c82e6c503fc8fe17c64a63bee0709627866ef97c99cb4e131",
71
+ "tests/public.test.js": "a6fcdc4ede242aa050ffe5a52f970d6bec1f4a70ae78967eb8a7bdeec73df19d"
67
72
  }
68
73
  },
69
74
  "mail-reader": {
70
- "version": "1.1.0",
75
+ "version": "1.2.0",
71
76
  "files": {
72
77
  "CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
73
78
  "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
74
79
  "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
75
- "plugin.js": "ebe4c41bdd8836fb3128af3a0e789d47f89b00e9e37dc9378bb308aaae8d41b2",
76
- "plugin.json": "ed1f02d9405f6ae1257313ea4155ea49e307fb2210650fd247b92e6988aa78d1"
80
+ "plugin.js": "7f602a60c4eca7d3637659d03d5de9e242584666846b16c9e5a1b758e4856c9f",
81
+ "plugin.json": "60111616d915be07c8bb53a54b8aeae906e167d157e54295bb440546128acd55"
77
82
  }
78
83
  },
79
84
  "security": {
@@ -99,6 +104,54 @@
99
104
  "server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
100
105
  "tests/security.test.js": "951636a0e42ac8dbf78b69ca7e7e2adcd4e29d94a51998e738c3afa742c00866"
101
106
  }
107
+ },
108
+ "shopping-cart": {
109
+ "version": "1.0.0",
110
+ "files": {
111
+ "CLAUDE.md": "0f89bab23796eba425e26792c952eb00eddb09fb2c74a4a7a1fb3722437dae0b",
112
+ "admin/css/index.css": "a6bab1d0072e364ee1d94c2d7d7b99c3660ba91c5eba537e269a579f5b17f924",
113
+ "admin/lib/kit.js": "3e721a53a97f136833975f99d9f985fcec4018b328b4d355267fe11702234fa0",
114
+ "admin/templates/shop.html": "c5bb03f1f7cbabfbd8202f3533783eb1848103755b1ba25fdcf1fd8e9478cb8e",
115
+ "admin/views/orders.js": "8255b75a4a0eeccec7cf76982a5f64469ab7a2ad5f742a54afe55bca61ace713",
116
+ "admin/views/overview.js": "3901dfb9527fed0689bb31098488136471ff081b9fe28979339f39b2d53e3bd3",
117
+ "admin/views/products.js": "4947de61aa57da59fd0e819ac0f5380bbdee6b73855bd91d3872445fdd6c3e2d",
118
+ "admin/views/settings.js": "28cb0b5e8d45e3723ffe77e46299bfc13fbad02a2ae8103843c6894dc232387a",
119
+ "admin/views/shop.js": "a53a1793251b962a87c5d01685f934b6476bcfcdd51e3dea3a319eb8c03a4137",
120
+ "collections/shop-orders.json": "0bca8eef2d5f9108651f73d273480265c167dc983aa115ce59613d13015ca255",
121
+ "collections/shop-products.json": "4d4613ecec7b8a5a0744c4f0a57b17a4429b461e060206a4bbdea5ad7c8c75dc",
122
+ "config.js": "dcb91db2839c1ab9e40bb1811397024fad9e37e3753d17e2baa2b9b31d4a7d7c",
123
+ "inject/body.html": "de20f92c83229545d20e5186fd37ef0046a8820881bff34e7df5391cd7a2c75d",
124
+ "lib/extensions.js": "2cba90efef5b437ace557c7265fe7363ac7923d155681087a63e31e1168f6601",
125
+ "lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
126
+ "lib/pricing.js": "94953075062e9b36cdbd6ea149662d013d97ee12359a9ff7459669beff99ab99",
127
+ "lib/render.js": "3eacb4f7bf62376873e6fe59e2fe55e72a97fda8ed6bff73b93791f568cceffd",
128
+ "lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
129
+ "lib/shop.js": "36e1cea342e23f1a53b9fac63b993f860afc1bcb74b25bef9f7bf8eaf07d2d85",
130
+ "lib/summary.js": "a510386e28eb19d9617f488551d1057819ae9f76fc85c744f2e5dbce492607fe",
131
+ "plugin.js": "594c2174e36566f90ac1e9ff11cb76656ad111dc392dc8e9556d731b7dd0a41a",
132
+ "plugin.json": "d5a3aeebb422cde97ac30605e8706acfb39e69601e7e6ae5d626c3744d49093c",
133
+ "plugin.public.js": "176383dfa7af913c433e0397584c46a67a21ab3307b80a08f0aaf50da2634f9e",
134
+ "public/cart.js": "fcc706773a36e4bfd02ea235bcdad629137e996218e0a0e23892e33c689f2a6e",
135
+ "public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
136
+ "public/lib/orders.js": "09478e20d238adc8e387b3d9efcb13c441898fab18387ce6a1a8a92304ac4c5b",
137
+ "public/lib/products.js": "c698dcb509dfc09a135e4039ec085dda8a3606e7f9f9586ea0fcf068f9147438",
138
+ "public/samples/candle-2.svg": "aca5691b9168467caf6feb7622ab154ee92504be8b887ac6c818db22476e7bed",
139
+ "public/samples/candle.svg": "ef75e95a53ce13e88928004c6f1290c45a1094765e2ba741ae1fc55d404b881f",
140
+ "public/samples/mug-2.svg": "5f37d23e90b7e79e42d38009a56b3ea26adae03586830a0cd418565491bd4532",
141
+ "public/samples/mug.svg": "e29e268fae300b95e38021443aa6df57e6a4dcb14c4058e5ef7106eb9bc1be6c",
142
+ "public/samples/notebook-2.svg": "21fbeaaab182485b3b9dde4e3b9d83cf3f9cadd5825225632cda14f0453c9538",
143
+ "public/samples/notebook.svg": "ff659345f465324199858f992e779aa7e0c4308e3cc39f15f7d5a0d4618b21d0",
144
+ "public/samples/print-2.svg": "09385dc66f5902caa399c6a4cc11ae320a779cbd355b7965d573483af9ccb818",
145
+ "public/samples/print.svg": "47e0f0960231b81b2d03f205c9b86e03f7c22011a2f243cfa915d08a73de377d",
146
+ "public/samples/tee-2.svg": "aaec073d128b3ea90e58225dd34daca8cbc39aa0f3112d2bc65119284718b5e4",
147
+ "public/samples/tee.svg": "287a82d7fc00acb1543b3209927ae20dcbe3d4b2a2b3de03574ab0c463385f3c",
148
+ "public/samples/tote-2.svg": "9c7f41cc57866e71973e413a6c87f46be48f7488d3490ceea58e24559c334813",
149
+ "public/samples/tote.svg": "01de817a32c8d3d8ea47ff491049f439ca3e4d4fce943dbdc62a4187139e016c",
150
+ "public/shop.css": "277904382e491f0ecc76aad916126970b87e161ef06ac6deeff193358b4e4c31",
151
+ "templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
152
+ "tests/api.test.js": "a806df62981574a98d435eefc98143b2b03071b5f58a4e7db3cab93850c0e8ae",
153
+ "tests/pure.test.js": "dbe35ed7e94cea2f96d42d5abe7d41173393180ff5764c0539799329aa61bfa0"
154
+ }
102
155
  }
103
156
  }
104
157
  }
@@ -25,8 +25,19 @@ export default async function mailReaderPlugin(fastify, options) {
25
25
  // rather than leave the mail server holding dead sessions.
26
26
  fastify.addHook('onClose', async () => pool.closeAll());
27
27
 
28
+ // The Email permission (1.2), shared with Email Pro: every route needs it.
29
+ // Granted to admin only - a mailbox here opens connections from this server.
30
+ // Give it to other roles under Roles. An older core keeps the old rule.
31
+ const {authenticate: signedIn, requirePermission} = options.auth;
32
+ const mayUse = typeof requirePermission === 'function' ? requirePermission('email', 'read') : async () => {};
33
+ async function authenticate(request, reply) {
34
+ const out = await signedIn(request, reply);
35
+ if (reply.sent) return out;
36
+ return mayUse(request, reply);
37
+ }
38
+
28
39
  registerReadRoutes(fastify, {
29
- auth: options.auth,
40
+ auth: {...options.auth, authenticate},
30
41
  hooks: options.hooks,
31
42
  config,
32
43
  pool,
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mail-reader",
3
3
  "displayName": "Mail Reader",
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
5
  "tier": "free",
6
6
  "description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
7
7
  "author": "Darryl Waterhouse",
8
- "date": "2026-09-25",
8
+ "date": "2026-09-26",
9
9
  "icon": "mail",
10
10
  "admin": {
11
11
  "sidebar": [
@@ -15,7 +15,8 @@
15
15
  "icon": "mail",
16
16
  "url": "#/plugins/mail-reader",
17
17
  "section": "#/plugins/mail-reader",
18
- "countUrl": "/api/plugins/mail-reader/unread"
18
+ "countUrl": "/api/plugins/mail-reader/unread",
19
+ "permission": "email"
19
20
  }
20
21
  ],
21
22
  "routes": [
@@ -31,5 +32,23 @@
31
32
  "exportName": "mailView"
32
33
  }
33
34
  }
34
- }
35
+ },
36
+ "permissions": [
37
+ {
38
+ "key": "email",
39
+ "label": "Email",
40
+ "group": "Plugins",
41
+ "description": "Your own mailboxes in the admin: read, and with Email Pro send, from this server.",
42
+ "actions": [
43
+ {
44
+ "key": "read",
45
+ "label": "Use",
46
+ "description": "Add your own mailboxes, read and (Email Pro) send mail - from this server's address"
47
+ }
48
+ ],
49
+ "grant": [
50
+ "admin"
51
+ ]
52
+ }
53
+ ]
35
54
  }
@@ -0,0 +1,93 @@
1
+ # shopping-cart - AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Sell from a Domma CMS site. 1.0.0 (2026-09-26): products with options (size, colour) each with its own price,
6
+ SKU and stock; a storefront at a configurable address (`/shop`) with category chips, search and sort; product
7
+ pages with a gallery; a basket kept in the shopper's browser that follows them round the site (floating
8
+ button + drawer on every page); one-page checkout; Stripe (hosted Checkout), PayPal (Orders v2) or payment
9
+ offline; a receipt page that waits for the payment to be confirmed; orders in the admin with fulfil (tracking),
10
+ refund (through the provider), cancel, packing slips; an Overview with takings and what needs doing; a sidebar
11
+ badge for paid orders to fulfil; sample products with bundled pictures.
12
+
13
+ `tier: free`. Shopping Cart Pro (plugins/shopping-cart-pro) is an ADD-ON that plugs into this one - it does not
14
+ supersede it (see Extensions).
15
+
16
+ ## The one rule about money
17
+
18
+ Every amount is an integer in MINOR units (pence) - products, orders, settings (`shippingFlat: 395` = £3.95).
19
+ Only the edges convert: the admin's inputs (`kit.moneyIn/moneyOut`), display (`formatMoney`), PayPal's decimal
20
+ string (`fromMinor`). The browser never sends a price: `POST /quote` and `POST /checkout` take product ids,
21
+ option ids and quantities, and `lib/pricing.js` `priceBasket` is the ONE place a total is worked out. Checkout
22
+ refuses (409, with the new quote) when the basket changed (sold out, fewer left, a code refused) until the
23
+ shopper resubmits with `accept`, and when `expectTotal` differs from what it would charge.
24
+
25
+ ## Files
26
+
27
+ - `plugin.json`, `config.js` (every setting, documented), `plugin.js` (API, shortcodes, payments' return routes)
28
+ - `plugin.public.js` - storefront pages from ONE onRequest hook on the LIVE shop path (like the Blog), so
29
+ moving the shop needs no restart. `matchShopPath` is exported and tested.
30
+ - `public/lib/money.js`, `products.js`, `orders.js` - PURE, shared by server, admin and storefront (public/ is
31
+ served; lib/ is not). Product cleaning, stock (`available`, `totalStock`), order statuses and moves (`NEXT`).
32
+ - `lib/pricing.js` - the basket's price: lines (capped at stock), then stages discount -> shipping -> tax.
33
+ - `lib/extensions.js` - the registry extensions register into (globalThis, Symbol.for('domma.shopping-cart')).
34
+ - `lib/shop.js` - collections I/O, live settings (masking, cleaning), order numbers, `markPaid` / `moveOrder` /
35
+ refunds with stock, all order writes serialised by `withLock`. `shelfNow`/`settingsNow` for the shortcodes.
36
+ - `lib/payments.js` - Stripe and PayPal over fetch (no SDK). `stripeVerify` checks webhook signatures.
37
+ - `lib/render.js` - the storefront's HTML (server-rendered; everything escaped; descriptions via parseMarkdown).
38
+ - `lib/summary.js` - Overview figures and the badge. `lib/samples.js` + `public/samples/*.svg` - sample products.
39
+ - `public/cart.js`, `public/shop.css` - the browser half, injected on every page (`inject/body.html`).
40
+ - `templates/shop.html` - the fragment every storefront page renders in (`renderBlogPage`).
41
+ - `admin/views/shop.js` - the Tool: tab host (Overview, Orders, Products + extension tabs), banner cog/visit/+.
42
+ `overview.js`, `orders.js`, `products.js`, `settings.js` (the cog's slideover). `admin/lib/kit.js` - shared.
43
+ - `tests/pure.test.js` (money, products, pricing, registry, payments, paths, settings, summary),
44
+ `tests/api.test.js` (a whole sale against the real collection service; restores the checkout).
45
+
46
+ ## Data
47
+
48
+ Collections `shop-products` and `shop-orders` (made at every boot if missing; API access all off - orders hold
49
+ shoppers' details). Settings: `config/plugins.json` `shopping-cart.settings`; the order counter is plugin state
50
+ `shopping-cart.orderSeq` (never below the highest order number, starting 1001).
51
+
52
+ Order statuses: pending (checkout started) -> awaiting (offline) -> paid -> fulfilled; cancelled; refunded.
53
+ Stock is taken when an order becomes PAID (`stockTaken`), never at checkout, and can go back on a refund.
54
+ A paid or refunded order cannot be deleted. Each order has a random `token`: the receipt URL is
55
+ `<shop>/order/<id>?t=<token>`; without the token it is a 404.
56
+
57
+ ## Payments
58
+
59
+ - Stripe: `POST /checkout` makes a Checkout Session (itemised; ONE "Order #n" line when there is a discount,
60
+ since Stripe discounts only through its coupons), success_url = the receipt + `session_id`. The receipt page
61
+ (and `GET /order/:id`) asks Stripe and marks the order paid when `stripeSessionPaid` agrees on order id,
62
+ amount and currency. The webhook (`/stripe/webhook`, raw body, `stripeWebhookSecret`) catches a closed tab.
63
+ - PayPal: create order -> approve -> `GET /paypal/return` CAPTURES (ORDER_ALREADY_CAPTURED reads it instead)
64
+ and checks `paypalPaid`. Sandbox/live by setting.
65
+ - Offline: the order is `awaiting`; the receipt shows `offlineInstructions`. Mark paid from the admin.
66
+ - Refunds: `POST /orders/:id/refund` refunds through Stripe (payment_intent) or PayPal (capture id) FIRST,
67
+ and only then marks the order refunded. Full refunds only.
68
+ - Secrets never leave the server: GET /settings masks them (`••••••••`); saving the mask keeps the value.
69
+
70
+ ## Extensions (Shopping Cart Pro)
71
+
72
+ `cartRegistry().register(owner, {stages, on, tabs, checkout})` - see lib/extensions.js. A shipping handler may also
73
+ return `choices: [{id, label, amount}]` - the quote carries them as `shippingChoices` (+ `shippingChoice`, the
74
+ picked one's `ref`) and checkout offers them when there are two or more, sending `service` back. A stage handler that
75
+ throws falls back to the built-in rule (and an internal note), so an extension cannot take checkout down.
76
+ `emit()` never throws or waits. `registry.services` gives extensions the Cart's data functions. Extension tabs
77
+ are dynamic-imported by shop.js from `/plugins/<owner>/...` only, and get the same ctx as the built-in tabs.
78
+ An extension registers from `onReady`; a lapsed licence means it never registers and nothing else changes.
79
+
80
+ ## Shortcodes
81
+
82
+ `[shop-products category= tag= featured=true limit=8 columns=3 /]`, `[shop-product slug= /]`,
83
+ `[buy-button slug= label= /]`. Rendered from `shelfNow()` (products as last read - shortcodes are synchronous).
84
+ A cached page can show a stale stock flag until it is re-rendered; checkout re-prices regardless.
85
+
86
+ ## Gotchas
87
+
88
+ - Storefront CSS tokens are defined on `body`, not `:root`: themes set --dm-* on body's dm-theme-* class.
89
+ - The site's own input/link styles reach the storefront: see the "reach in here" block at the end of shop.css.
90
+ - `siteBase()` uses `request.host` (with the port); `request.hostname` drops it under Fastify 5.
91
+ - A test site on a non-standard port needs `site.baseUrl` for Stripe/PayPal return addresses behind a proxy.
92
+ - Admin views: item templates live in JS (a templateUrl goes through _.render); checkboxes are set after render
93
+ and listened to; slideovers via kit.openPanel (disposed and removed on close).