domma-cms 1.6.35 → 1.6.37

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 (65) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/compose-window.js +4 -0
  3. package/plugins/_lib/admin/mail/mail.css +1 -1
  4. package/plugins/_lib/admin/mail/reader-view.js +15 -6
  5. package/plugins/_lib/call/ice.js +5 -2
  6. package/plugins/_lib/call/rooms.js +9 -3
  7. package/plugins/_lib/call/routes.js +7 -4
  8. package/plugins/_lib/mail/compose.js +7 -2
  9. package/plugins/_lib/mail/defaults.js +12 -0
  10. package/plugins/_lib/mail/hostGuard.js +86 -0
  11. package/plugins/_lib/mail/message-action.js +56 -0
  12. package/plugins/_lib/mail/readRoutes.js +20 -1
  13. package/plugins/_lib/mail/scheduler.js +2 -0
  14. package/plugins/_lib/mail/send.js +12 -3
  15. package/plugins/_lib/mail/store.js +16 -6
  16. package/plugins/_lib/outreach/index.js +1 -1
  17. package/plugins/_lib/outreach/ledger.js +10 -3
  18. package/plugins/_lib/outreach/sending.js +9 -3
  19. package/plugins/_lib/outreach/suppression.js +14 -3
  20. package/plugins/_lib/outreach/unsubscribe.js +13 -0
  21. package/plugins/_lib/public/call/call.js +3 -1
  22. package/plugins/blog/CLAUDE.md +11 -1
  23. package/plugins/blog/docs/guide.md +1 -1
  24. package/plugins/blog/lib/page.js +32 -0
  25. package/plugins/blog/plugin.js +58 -16
  26. package/plugins/blog/plugin.json +2 -2
  27. package/plugins/blog/plugin.public.js +5 -4
  28. package/plugins/blog/tests/public.test.js +30 -1
  29. package/plugins/booking/CLAUDE.md +4 -1
  30. package/plugins/booking/docs/guide.md +1 -1
  31. package/plugins/booking/plugin.js +16 -21
  32. package/plugins/booking/plugin.json +2 -2
  33. package/plugins/booking/tests/api.test.js +26 -1
  34. package/plugins/feedback/CLAUDE.md +7 -2
  35. package/plugins/feedback/admin/views/feedback.js +1 -1
  36. package/plugins/feedback/lib/receiver.js +1 -1
  37. package/plugins/feedback/plugin.json +3 -3
  38. package/plugins/free-tier.lock.json +42 -41
  39. package/plugins/mail-reader/CLAUDE.md +6 -0
  40. package/plugins/mail-reader/docs/guide.md +1 -0
  41. package/plugins/mail-reader/plugin.json +2 -2
  42. package/plugins/shopping-cart/CLAUDE.md +17 -1
  43. package/plugins/shopping-cart/docs/guide.md +1 -1
  44. package/plugins/shopping-cart/lib/accounts.js +8 -3
  45. package/plugins/shopping-cart/lib/payments.js +7 -4
  46. package/plugins/shopping-cart/lib/shop.js +8 -3
  47. package/plugins/shopping-cart/plugin.js +77 -41
  48. package/plugins/shopping-cart/plugin.json +2 -2
  49. package/plugins/shopping-cart/public/account.js +2 -2
  50. package/plugins/shopping-cart/tests/accounts.test.js +10 -1
  51. package/plugins/shopping-cart/tests/api.test.js +23 -0
  52. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  53. package/plugins/support/CLAUDE.md +6 -0
  54. package/plugins/support/admin/views/support.js +1 -1
  55. package/plugins/support/docs/guide.md +90 -0
  56. package/plugins/support/lib/receiver.js +1 -1
  57. package/plugins/support/plugin.json +3 -3
  58. package/server/routes/api/analytics.js +32 -0
  59. package/server/routes/api/collections.js +148 -15
  60. package/server/routes/outreach-public.js +54 -10
  61. package/server/server.js +5 -1
  62. package/server/services/authChecks.js +8 -0
  63. package/server/services/collectionAccess.js +191 -0
  64. package/server/services/collections.js +8 -2
  65. package/server/services/request-log.js +70 -0
@@ -15,8 +15,10 @@ import {bookingRegistry} from '../lib/extensions.js';
15
15
  import {addDays, atWallClock, wallClock} from '../public/lib/time.js';
16
16
 
17
17
  const PLUGINS = path.resolve('config/plugins.json');
18
+ const SITE = path.resolve('config/site.json');
18
19
  const CONTACTS = path.resolve('content/collections/contacts-contacts/data.json');
19
20
  let savedContacts;
21
+ let savedSite;
20
22
  const DIRS = ['booking-bookables', 'booking-services', 'booking-bookings'].map(c => path.resolve('content/collections', c));
21
23
  const TZ = 'Europe/London';
22
24
  let savedPlugins;
@@ -29,6 +31,9 @@ const related = {};
29
31
  before(async () => {
30
32
  savedPlugins = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
31
33
  savedContacts = await fs.readFile(CONTACTS, 'utf8').catch(() => null);
34
+ // Links in emails come from the Site URL alone (1.6.3) - the checkout's site.json has none.
35
+ savedSite = await fs.readFile(SITE, 'utf8');
36
+ await fs.writeFile(SITE, JSON.stringify({...JSON.parse(savedSite), baseUrl: 'https://site.test'}, null, 2) + '\n');
32
37
  for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
33
38
  setMailer(async (msg) => { sent.push(msg); });
34
39
  app = Fastify();
@@ -54,6 +59,7 @@ after(async () => {
54
59
  if (savedPlugins === null) await fs.rm(PLUGINS, {force: true});
55
60
  else await fs.writeFile(PLUGINS, savedPlugins);
56
61
  if (savedContacts !== null) await fs.writeFile(CONTACTS, savedContacts);
62
+ if (savedSite) await fs.writeFile(SITE, savedSite);
57
63
  if (dataDir) await fs.rm(dataDir, {recursive: true, force: true});
58
64
  });
59
65
 
@@ -353,8 +359,27 @@ test('online: a service an extension makes online gives a call - the customer an
353
359
  assert.match(r.body.booking.callUrl, /\/booking\/call\//);
354
360
  await new Promise(res => setTimeout(res, 50));
355
361
  const toHost = sent.find(m => m.to === 'vic@example.com');
356
- assert.match(toHost?.text || '', /Start the call: \S+&h=/, 'the person booked gets the host link');
362
+ assert.match(toHost?.text || '', /Start the call: https:\/\/site\.test\/\S+&h=/, 'the person booked gets the host link, on the Site URL');
357
363
  assert.doesNotMatch(sent.find(m => m.to === 'web@example.com').text, /&h=/);
364
+
365
+ // 1.6.3: a booker's Host header never decides where the owner's link points - with no Site URL there is no link at all.
366
+ sent.length = 0;
367
+ await fs.writeFile(SITE, savedSite);
368
+ try {
369
+ const slot2 = (await call('GET', `/public/slots?service=${svc.id}&from=${day}&to=${day}`)).body.days[0].slots[5];
370
+ const forged = await app.inject({method: 'POST', url: '/public/book', headers: {host: 'evil.example'},
371
+ payload: {serviceId: svc.id, startAt: slot2.start, customer: {name: 'Forger', email: 'forger@example.com'}}});
372
+ assert.equal(forged.statusCode, 201, forged.body);
373
+ await new Promise(res => setTimeout(res, 50));
374
+ const owner = sent.find(m => m.to === 'vic@example.com');
375
+ assert.ok(owner, 'the owner is still told');
376
+ assert.doesNotMatch(owner.text, /evil\.example/);
377
+ assert.doesNotMatch(owner.text, /&h=/, 'the host key goes in no link whose base is not the configured one');
378
+ assert.doesNotMatch(sent.find(m => m.to === 'forger@example.com').text, /evil\.example/);
379
+ await call('DELETE', `/bookings/${forged.json().booking.id}`);
380
+ } finally {
381
+ await fs.writeFile(SITE, JSON.stringify({...JSON.parse(savedSite), baseUrl: 'https://site.test'}, null, 2) + '\n');
382
+ }
358
383
  const early = await call('POST', `/call/${r.body.booking.callUrl.split('/call/')[1].split('?')[0]}/join`,
359
384
  {b: r.body.booking.id, t: new URL(`http://x${r.body.booking.callUrl}`).searchParams.get('t')});
360
385
  assert.equal(early.status, 425, 'not open days ahead');
@@ -98,8 +98,8 @@ Replying in the inbox to a `_manager` report you filed yourself marks it seen fo
98
98
  - **Who sent it is never the browser's word.** The sender builds the payload from `request.user`; the
99
99
  manager attributes a report to the slug the fleet TOKEN resolves to and ignores any `site` in the body.
100
100
  A site can still vouch for any of its own admins - it cannot speak for another site.
101
- - **Tokens rotate whenever either end restarts** (fleetTokens.js is memory-only), so a send can meet a
102
- 401 or ECONNREFUSED for a few seconds. That is why the outbox exists: a report is "sent or waiting",
101
+ - **Tokens survive a manager restart** (since 2026-10-04 fleetTokens.js keeps them in `site-manager/data/fleet-tokens.json`,
102
+ mode 0600) but a site being restarted still meets a 401 or ECONNREFUSED for a few seconds. That is why the outbox exists: a report is "sent or waiting",
103
103
  never lost. Waiting ones go on the next `/sent` and on the timer (`syncMinutes`, default 60, which also
104
104
  notices new replies and raises a `feedback:reply` notification). A retry carries the same `ref`, and the
105
105
  manager answers the existing report (200) instead of making a second.
@@ -113,3 +113,8 @@ Replying in the inbox to a `_manager` report you filed yourself marks it seen fo
113
113
  (`.fb-note` is flex) beats the browser's. `index.css` carries `.fb [hidden] {display: none !important}`.
114
114
  - Only this machine's sites can reach dcm (the fleet token lives in this manager's memory). Sites on other
115
115
  machines would need their own manager to relay with an installation key; not built.
116
+
117
+ ## 1.1.4 (2026-10-13 Tool sweep hygiene)
118
+
119
+ - Cache-busters follow the manifest: `feedback.js?v=1.1.4` in plugin.json and `V = '1.1.4'` for the sub-views - bump
120
+ both with the version. Fleet-token note above corrected (tokens are on disk since 2026-10-04).
@@ -16,7 +16,7 @@
16
16
  import {api, loadKit, store} from '../lib/kit.js';
17
17
 
18
18
  const ROUTE = /^#\/plugins\/feedback(\/|$)/;
19
- const V = '1.1.0';
19
+ const V = '1.1.4';
20
20
  const TABS = [
21
21
  {key: 'inbox', label: 'Inbox', icon: 'inbox'},
22
22
  {key: 'mine', label: 'My feedback', icon: 'send'}
@@ -7,7 +7,7 @@
7
7
  * admins' names and emails.
8
8
  *
9
9
  * A site is who its fleet token says it is (site-manager/services/fleetTokens.js,
10
- * memory only, minted when the manager forked it). The module is imported at
10
+ * minted when the manager forked it and kept in site-manager/data/fleet-tokens.json). The module is imported at
11
11
  * call time and only exists where Site Manager does, which is also how this
12
12
  * plugin knows it is on the manager.
13
13
  *
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "feedback",
3
3
  "displayName": "Feedback",
4
- "version": "1.1.3",
4
+ "version": "1.1.4",
5
5
  "tier": "free",
6
6
  "description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-03",
8
+ "date": "2026-10-13",
9
9
  "icon": "message-square",
10
10
  "permissions": [
11
11
  {
@@ -40,7 +40,7 @@
40
40
  ],
41
41
  "views": {
42
42
  "plugin-feedback": {
43
- "entry": "feedback/admin/views/feedback.js?v=1.1.1",
43
+ "entry": "feedback/admin/views/feedback.js?v=1.1.4",
44
44
  "exportName": "feedbackView"
45
45
  }
46
46
  }
@@ -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@1978d48",
3
+ "source": "dcms-marketplace@b1edb76",
4
4
  "plugins": {
5
5
  "blog": {
6
- "version": "1.9.5",
6
+ "version": "1.9.6",
7
7
  "files": {
8
- "CLAUDE.md": "926730d510cc38f6764ffcb9afbed1c3ae3a547117c6c0243b14f899271350fb",
8
+ "CLAUDE.md": "5164b58785b979b828a707ac94088d80eb1a1655fc48a23404aeca8cef316e70",
9
9
  "admin/css/index.css": "6142a6b756e1218157842528830b1e84f592f0b84b7d5ce6449db421b6f4b039",
10
10
  "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
11
  "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
@@ -41,17 +41,17 @@
41
41
  "collections/comments/schema.json": "29cbd289cbfcfc4158c9357a218db4ce06eddc3370bfbf9afb7d7f52065a849e",
42
42
  "collections/posts/schema.json": "19a5a770d3eefea73f4117454bd1312a7a7e3d727cfcdfde141139fbc81cabd7",
43
43
  "config.js": "ee67396262762f5c31269d258affd75f7392a3e364f72f7792c2f1cdafd94bba",
44
- "docs/guide.md": "6a7466881851797789a18e0d32c4fcb515f2259b110948c55be03923c38e0de5",
44
+ "docs/guide.md": "d9f70afe4612a2464fc537a0348e987873d09cc30ce44692a22cf79c40a4c0b4",
45
45
  "lib/access.js": "af47440c7233a56000641a3231d7e9ff81b12f40dfb9528aa6dd4e2dd9bca8df",
46
46
  "lib/layouts.js": "dbb6ae9e514ccaf600a732702925b905131dea166d90fe759a62b6652e78e1de",
47
47
  "lib/nav-link.js": "6a8f9368bb8aee1c21e90fb335a691859cb9a9bc250c2b9167a33ca9189fa2ff",
48
48
  "lib/notices.js": "a9a2918ee6f5f7e0bbaeced0d3539584279bdbe68d15472a8615bdddb4ef905b",
49
- "lib/page.js": "bfb6a355a027feaf775d6d8ac871fc6d999354774e018f8e16caec551e99f40a",
49
+ "lib/page.js": "ca84803672c3815c9606402bd24e9c6bdef167f7a342eb6d3d51a8b9a9c692ba",
50
50
  "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
51
51
  "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
52
- "plugin.js": "0aa46a01b5298065497f39729b88e34a9f56ac684b0a14f8d94ab6007f0221d0",
53
- "plugin.json": "b4d7637ecb053df7ab182aaed7962c52e47fbee0eedcd041964585fbc85ea7da",
54
- "plugin.public.js": "67e109da613237d9ec52e7cc2f3442bb61ca5a51cb184448b9f0d993e50f7ae3",
52
+ "plugin.js": "278d269eb8fb6df2f64f23ae26b5e5b5fe96e7f9d07241539d261d66d4ccd7f7",
53
+ "plugin.json": "ff7466d42c41dd64117861d4d88914be1d367854ade67cb4ca42d8b42068bcf4",
54
+ "plugin.public.js": "33387fa7636b877dec3881b0698918bc1a7379d5690d010c826aedc4091284d8",
55
55
  "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
56
56
  "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
57
57
  "public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
@@ -69,13 +69,13 @@
69
69
  "tests/layouts.test.js": "554cff8194bd650cfb5f2a83c1f51c25f427c07f7dbfdc2ad9ad69bb38fca6a0",
70
70
  "tests/nav-link.test.js": "d36e33ad7483abca27ae6e2098ad2f75bafa18d91f3beba70372ebe6b9b88994",
71
71
  "tests/notices.test.js": "2b02aa84e780dc4c82e6c503fc8fe17c64a63bee0709627866ef97c99cb4e131",
72
- "tests/public.test.js": "a6fcdc4ede242aa050ffe5a52f970d6bec1f4a70ae78967eb8a7bdeec73df19d"
72
+ "tests/public.test.js": "cd4cc53da9709d400d082ef6df01b3f49e6fc419e4b020b588464306a5d907e9"
73
73
  }
74
74
  },
75
75
  "booking": {
76
- "version": "1.6.2",
76
+ "version": "1.6.3",
77
77
  "files": {
78
- "CLAUDE.md": "9a862d4f9ee4676c67af9699820015d7e0ab98b14b62deee6d51cefe0cc28471",
78
+ "CLAUDE.md": "1709e58b31a3ac7c1cbadbe6d4f8b0a01cf696b72981ccfa14cd1861133fac85",
79
79
  "admin/css/index.css": "4f7731921c0c5038cb1566a6ab2d43ffc35bf9b161e58f87a0cff423e81b47fa",
80
80
  "admin/lib/details.js": "8b37536bfa9ccfcc5265cc957ba8266d3f5f777133e7c0946ae49540f1c80e3c",
81
81
  "admin/lib/diary-layout.js": "d818c79001fb9a9ecc15c96239ba961b0a1d98b9e8fce903eefb57d85adf2a73",
@@ -93,14 +93,14 @@
93
93
  "collections/booking-bookings.json": "fa0b15cc81b4a2114734a0bc8adc4b4d25049d16cf62549b83634cdbeb543b52",
94
94
  "collections/booking-services.json": "cecb500a9215a0eba894e6b4c9ae78f5fbeb209830d07d47050c56c75143f66a",
95
95
  "config.js": "5565fd627481cf809322257cc300a7cbb7ccbf6cae076b4cf8a7453fcf554bd3",
96
- "docs/guide.md": "5dbe9a4e0cabdc85a3c6a0b40ea4e1bc02976ad465891d28001c1485b0b1cd50",
96
+ "docs/guide.md": "8f5898627abb1b21f90809b4f2e4c29f80d478bd11c0f1aec5319cddc40d14f9",
97
97
  "inject/body.html": "ab55ce945eec2302eb0c836a4626cffa5e091d050c6a59c6f2af332b05570777",
98
98
  "lib/call.js": "87c11b8531e1a6d247d52d4e03ea8e5aa06a1af49cbd7cbd0af97e2586237f7b",
99
99
  "lib/extensions.js": "8cec28ff8685daad8083b23f034f1c3bf82fd2322943dd19d46570ccdc311105",
100
100
  "lib/mail.js": "cad8f409df62d6e42f0f4024ec364bbe018a62b842fa3f7c38786fd4e7f92408",
101
101
  "lib/store.js": "7aa7967e4694e5a3b85378f60302e5a47394b1d90c7dd86e09578156f1c13e03",
102
- "plugin.js": "948cf76ee8495d9a6bce85c4f5549107385c8a947bc7690ff08d6d2b870f458d",
103
- "plugin.json": "6032a3e333e3ae492a2484fbb0ab93cfffe20c9d01e85f67ef7369c55e5c6f10",
102
+ "plugin.js": "4a303855a5a7f746e07e45a937dfeac8984dc9254c2f9b1a60b6f6306912ed84",
103
+ "plugin.json": "d099b136335028b2bca2bd21065c6ae343099f71635f9c793c95567a6e8a63db",
104
104
  "plugin.public.js": "4d5b4e8ad6943182ee3b6c6e6f6762a2d3f676d8d7ee363fcb07bd47d3d6532e",
105
105
  "public/booking.css": "8b88989fdd04e50efc1688a2f32fe2e422299d7d13b81bb8fce0cc162fa99df6",
106
106
  "public/booking.js": "7dfc3d205696967013886567f67a867443c2e370bac29019d69911d695fdbade",
@@ -110,42 +110,42 @@
110
110
  "public/lib/time.js": "48e13a23e3d7a4916e2097e8f87c03a87d37b71c4ef037a5a16143c6a4185c41",
111
111
  "templates/booking.html": "a4995de46dfdab7e692b56f21347f29c76320cf3b0477889dc3c5d14e0b6dcd7",
112
112
  "tests/admin-diary.test.js": "4e34b447d62b41272ceec49bad9d491ae00dd04f4760d8a7a3046be1912f4abe",
113
- "tests/api.test.js": "b9076e6a5fb7d43cf13cd937751aa20a05e17fa13c55fc3fc52caa1f206d5d10",
113
+ "tests/api.test.js": "efb3767f7efde08c11c8edcb5753889773b421188798f725aff0e4e533860aff",
114
114
  "tests/public.test.js": "0b7b626d60df5a3d73d7c0077b73f59b62a9fded98ce84a8d93cd32cd0333159",
115
115
  "tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
116
116
  }
117
117
  },
118
118
  "feedback": {
119
- "version": "1.1.3",
119
+ "version": "1.1.4",
120
120
  "files": {
121
- "CLAUDE.md": "7d6cbce582050a8e0cb74b7e177cbee0051b940369cd9a438546a66eb53051c2",
121
+ "CLAUDE.md": "af921af5c262cac7ac784fa86b780e4f31dac1baa510b4b3fd32a52521d4cccf",
122
122
  "admin/css/index.css": "f2e934c8b94f883340148aba17945dfa725eeb2b0ddb6cabb8a8c17e2487c1de",
123
123
  "admin/lib/kit.js": "913761a2cc19b69bed3f05267d45fdb1c08338b1edb081b9272231733d9b5790",
124
124
  "admin/templates/feedback.html": "a366dce78f6c3e84a7cb0ac63eba53e1980a4eb44154496a0536f5214c5bb36f",
125
- "admin/views/feedback.js": "ac33bf7de7445722dd055dcd9b6737a545b70379863328bc0d112abe52ce29c3",
125
+ "admin/views/feedback.js": "768d76da473dc4905e8e8e9133a715c1db8c3cb53d91756b5c7d067a92971463",
126
126
  "admin/views/inbox.js": "649b86844230be73939d82aa7c97432252c0b119baa102ebf53808e1d5f23737",
127
127
  "admin/views/send.js": "5868038a4020158746c5705d2a0ab5f552ac5021328a1dcf64a2def890495427",
128
128
  "collections/feedback-reports.json": "a072291b52463f7785a77f5f77d36b8238ba8f89cfc2672c6642eccdb3144486",
129
129
  "config.js": "29d9999d1028c2c11931f67fb9abdf35132430b7cf58c161681e734c0b45143a",
130
130
  "docs/guide.md": "e006cc3f3c114583285964bc1b5d22b2a01285c2d2015d132434bfed363917d4",
131
- "lib/receiver.js": "c6ab2858dc7bfff6de19b41c84295c0c918ba50ddc57f6d3d83f1352662ebd91",
131
+ "lib/receiver.js": "d77962c895617f9adf2fb16d29992fd83b75a3419a4aa71ac3e3bbe8c2089839",
132
132
  "lib/sender.js": "037f9b4469a34ffe1d302a633e664860d625f7fb3be4dfadd01b8a3d094849b7",
133
133
  "lib/shape.js": "67acf62f812c2c9a01b52776641ed7ae1601c59ebab8ea278a07d1087a9f299b",
134
134
  "plugin.js": "c388bbc2a49b5902773b15a74379c40eeaea58396058136e7901e405b3a396f8",
135
- "plugin.json": "a2e627cc6c126878a5756080782cc7f29a4024b50b1c485211ecdd0ce6399d94",
135
+ "plugin.json": "612357f1b3166f5bad11a563d0e7759c262a2328eba9890c5937dc98d7752363",
136
136
  "tests/api.test.js": "3fd27c561af593168ff0c2d717900feda89da4e306f8e0ecac0a6ad68c6d3359",
137
137
  "tests/shape.test.js": "5133578142a79cc1a100b3215436372129f01fb0de8c757fd743ff99e1d7e37f"
138
138
  }
139
139
  },
140
140
  "mail-reader": {
141
- "version": "1.2.6",
141
+ "version": "1.2.7",
142
142
  "files": {
143
- "CLAUDE.md": "40b25e33b9f14636bf766b68d1af5be24dcf69ccf6d770b9d080c1ed81664abb",
143
+ "CLAUDE.md": "6c1fe7956f0d2cc637bff3441061d083e6d938ccf58045473ec49c8fb38f41c1",
144
144
  "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
145
145
  "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
146
- "docs/guide.md": "a8febdc46bf60af35c6d980ac37629baa93da94e0970473f6fbe2d3f242ceb63",
146
+ "docs/guide.md": "d6b5944740ea1bbd689034ef726d8bc0f9bfeb28ea62b63c258292962d45cf18",
147
147
  "plugin.js": "7f602a60c4eca7d3637659d03d5de9e242584666846b16c9e5a1b758e4856c9f",
148
- "plugin.json": "af84350ede3edcb8b07ca752f8799245519a475c2242e89919a7e3e9b74cc8ff"
148
+ "plugin.json": "05a5fa1e0ff69eb2cfe0c07a1abbe2cb4a1cd57a75511d0ae6fb1a74524a65e4"
149
149
  }
150
150
  },
151
151
  "security": {
@@ -175,9 +175,9 @@
175
175
  }
176
176
  },
177
177
  "shopping-cart": {
178
- "version": "1.6.0",
178
+ "version": "1.6.1",
179
179
  "files": {
180
- "CLAUDE.md": "31785e2236ff0469d3653426e7b84b21ce8ab6ad5e310e043b5a7a74aa8c0bb3",
180
+ "CLAUDE.md": "2759884523ebf0f9323cdefdd220fc9c0ad952f9964c065f19b97530e61f7533",
181
181
  "admin/css/index.css": "dfe1ffc05b9161af3d8a74e82bd36c46991cb3b0409223b213eb8eca6fba9d55",
182
182
  "admin/lib/chart.js": "b01ce5cfac773c8df8efccab50b67d3dfa99fbead0037d817553db4a6688d4d6",
183
183
  "admin/lib/kit.js": "d9aa90469b3f589beaff0c3fb6ad3d983ab3fd6d94c06965be639a665bfe052f",
@@ -191,22 +191,22 @@
191
191
  "collections/shop-orders.json": "0bca8eef2d5f9108651f73d273480265c167dc983aa115ce59613d13015ca255",
192
192
  "collections/shop-products.json": "4d4613ecec7b8a5a0744c4f0a57b17a4429b461e060206a4bbdea5ad7c8c75dc",
193
193
  "config.js": "31b336560d21a802d799ed487d5c3116de898446ba3521068b10d192f4ebbb2d",
194
- "docs/guide.md": "7d5e48e2a9820898017debeb7b864c8ff1d91094725be49ece65b0460608857a",
194
+ "docs/guide.md": "5b63bd03b015e273c2880ac431a9faccdcc389e8c5f367697aeb4502be8e280b",
195
195
  "inject/body.html": "7e7e5b408f47abb8650e0a7d5e277daf8a634f4c6877c5259f837b1bf3d6c8e8",
196
- "lib/accounts.js": "d61e83e09c69993270ccca8da8df71e779c72ec7d17947b457aadcfe6f57dd4c",
196
+ "lib/accounts.js": "5115cec6a145da18c106dfdbf5fd0b2a1bad0c68e8fac4ef623287e85db75e5c",
197
197
  "lib/commission.js": "5b10475d98cff2aaf4c76b9950f8d698d6262e36754a8963669690a8e3e9bd52",
198
198
  "lib/extensions.js": "73d3515b52c066e3e616c425d91a7a48a6aab153e7d14ae7278b38ee32649352",
199
- "lib/payments.js": "84ad14813688de1f38448b4935180a786cda8af2f7f4b8890a8f1d426982d1aa",
199
+ "lib/payments.js": "de093035ce732cd4f7f569fcd32cb5a86e68596a3ae54ec7d00e949b3024f7a7",
200
200
  "lib/pricing.js": "5644f2dd55fc3cfd7f40b0169dea5fb547e8b302239b8c17f23cfa177e42eab5",
201
201
  "lib/render.js": "2a2b9ab4a24728186e1e45e6429c7406a6d5ca3d4b43db1758bbd54405d105b7",
202
202
  "lib/sales.js": "16804f0ba4e61e8a2887a7c4172da37533850a1cd975a177b754f3350268e946",
203
203
  "lib/samples.js": "912b3d5bd543dffa5f870caf3c2b873f642fd55e3ebf3f97e1b31eeaaac77d3b",
204
- "lib/shop.js": "116b0a2cc1513895ce67d2aa6fa216a16e90cafab67394ed85ef0f34e88c50e3",
204
+ "lib/shop.js": "190ad53acffb88ca9462900091918ea5d3e40fb666e89b9c060832035284115c",
205
205
  "lib/summary.js": "cdb13ce3d1799d429059fa03f179281711dc00427ccf2eb6b8f8aa52fef26295",
206
- "plugin.js": "d210a27a96251f3a5f358d9184d194e807ceb1da55f5a505f7b967aa003f29c0",
207
- "plugin.json": "fff190cc0dface77302191f4bee512192556cbb64e7e8f940db90d37f87ccef4",
206
+ "plugin.js": "f776e6bbdd98bf0b487e8247bc7334e78a1f8601a54b26b8c30df817619c2ebc",
207
+ "plugin.json": "abc448993e39b008d2f8d69deb55176bd090f5272003164a0931059dca4a5fc5",
208
208
  "plugin.public.js": "2f92e8340c9da653401c11dd99dab033dc6772d63fa2114489192c48e9e0be00",
209
- "public/account.js": "f1a497b70c230eee464781926e36c30936820abcdebf389982eb1d8646a51cb0",
209
+ "public/account.js": "5f644016c098c7879c59948d662f5997cb61680996204ecb7897baab8945d434",
210
210
  "public/cart.js": "7338141392ec653ff616eb75688d2a2ad08cff8427d7020d381f540be0037b73",
211
211
  "public/lib/money.js": "d22b2b6f80dde6f7887b69e24188c05ea93c02217aba639b5331aa3bd616f4fc",
212
212
  "public/lib/orders.js": "09478e20d238adc8e387b3d9efcb13c441898fab18387ce6a1a8a92304ac4c5b",
@@ -227,31 +227,32 @@
227
227
  "public/shop.css": "c01603589727c91ff4204222e594b3f52e3ca165c1ebf9e6cef6c9f006f6e57b",
228
228
  "roles/shop-customer.json": "3f3366c536c9fbf2b2ce146bb3090b008d5fbfe22752e764eb8e5a1de9cd611c",
229
229
  "templates/shop.html": "4649e57d23458833a157799dc80141824eb0f658d96734bd1859c68e0ba9a8b9",
230
- "tests/accounts.test.js": "99566264ab5be8f8602bd7f7777e89c12d07563372843845ca9a11cc15ce061e",
231
- "tests/api.test.js": "cb929e08be430064ceaa72a3a97b00ef5bce0f6af7eb5ed10ac905bf8ed05c21",
230
+ "tests/accounts.test.js": "e64adc2a855d4ca017682bba7f75b052534f826556d8d35d825fb79cbc66596a",
231
+ "tests/api.test.js": "8bccf43e520bca7bb73837a74b3c3cf1fdb3a8db8fb5026a27bc0e5ed9d5ca80",
232
232
  "tests/commission.test.js": "1dfb26a90d3dfcf9f57f2174ac5d9b2310570b21f82c79bf9a2011e7f4911e85",
233
- "tests/pure.test.js": "7fd6ad3b1955dc2c3d9a9ef175b458e2150f63cdc526c1bc0c1acba83c4e01e2",
233
+ "tests/pure.test.js": "13620f708f21e2590979e8579e2e7788d6109e1682bf73d7b1eb64838da35196",
234
234
  "tests/sales.test.js": "9025bc608b04bcc14f5bb2c98b4f7c35de99f3ddab4ffa59772d045795bf2cb7"
235
235
  }
236
236
  },
237
237
  "support": {
238
- "version": "1.1.1",
238
+ "version": "1.1.2",
239
239
  "files": {
240
- "CLAUDE.md": "ff42515f210dfdbeb1b14992012177ad8513323a340a0d4fe69a6ec13773db5a",
240
+ "CLAUDE.md": "9b35a7df9335e69278c261f3e68944a4fea87c495c2ff500c14990dc8a6c7337",
241
241
  "admin/css/index.css": "bad02a487ebb131b64ee55e2a4533c8b79d6ada80fffd34b0952bf3b68204a36",
242
242
  "admin/lib/kit.js": "22f02768625bcad9d9c8e64bf0a8fe95f7c5ff6dcf8abb189df8203362a7a4e1",
243
243
  "admin/templates/support.html": "2bb69d0443a367a402e6160fe68f4af3e8983b578cfff9575b4df9a534fcb480",
244
244
  "admin/views/desk.js": "ac043630ad08c8f2053f3f908be18586d9a4798976d20427cf5215ee042c807c",
245
245
  "admin/views/inbox.js": "f570595d7b2c831d151e0aaf85ff903e6e2b7066124b3f9d23c2c24f5b1734cf",
246
- "admin/views/support.js": "360a50ff54b81f93fcd6fb6949710e0cff5d342bb8573408415ef3faf3fd29ff",
246
+ "admin/views/support.js": "4be6cce46067ee7af140f43253da36e060add2a9190e91f013fd1aad668fcd7b",
247
247
  "collections/support-tickets.json": "55a0856bacd66c2e8f1e3c85bdf3638e5ff826560943cda854a8f2d336a88905",
248
248
  "config.js": "eb7ccc1169fa8b2be52a213536825492fedaec5fa73fd749cba9f498dd54b503",
249
+ "docs/guide.md": "d0d38dacbdf68bfaba1ed4e62c8d182372a683d255a8ff75ca57bd0be2a0b082",
249
250
  "lib/mail.js": "d14aae3a59aad6af61451863cdaaf94fc4a87810b16e1dcb9c655dda6c803b3b",
250
- "lib/receiver.js": "b95c569932c261a48849f0a6b7005f649d9c2a50d319b098a47d02da9363050c",
251
+ "lib/receiver.js": "64c292281bde5a942de4d04a16911d0730a4da7e80ce6d91a9da864b9f886b59",
251
252
  "lib/sender.js": "b20aaee34ade89814a897f68467f9902693843fa6b5c8eb0090c4059193c8227",
252
253
  "lib/shape.js": "04d840b589d20700ccb2cfa4c72072f8d9f8e03f0573e2ea1177a7a83a383bea",
253
254
  "plugin.js": "e3c50a05fa99cb24375782bbb3ca8c58cf5ddc3036937e6115f4d963715c120e",
254
- "plugin.json": "85ea5009e63d12ddbcf2be9d0df330827e3bf3babd35deddd47fce1da807049e",
255
+ "plugin.json": "c45819016588e7e26426830ce30fd6d7561b61ffcac2a8ad48bb4c178c138188",
255
256
  "tests/api.test.js": "1fa87f6ae12c48fc34a5fb2b5bbeed6cfaa36329610674d4fae05d64734dbb15",
256
257
  "tests/shape.test.js": "364c6b7680699f59fcd449cd448184486488838300f155135daea4c05387cf74"
257
258
  }
@@ -108,3 +108,9 @@ Right-click on the open message is engine code (`_lib/admin/mail/body-menu.js`,
108
108
  The page-size picker under the list is engine code (`listPageSize()`/`pageAfterResize()` in
109
109
  `_lib/admin/mail/settings-section.js`, sent as `limit` by `reader-view.js`, capped by `maxListLimit`);
110
110
  this version only documents it in docs/guide.md. 0.116.8 is already released, so it can ship now.
111
+
112
+ ## Private hosts refused (guide only, 1.2.7, 2026-10-13; engine `_lib/mail/hostGuard.js`)
113
+
114
+ Saving or testing a mailbox on a private, loopback or link-local address is refused by the shared read routes
115
+ unless it is the site's own outgoing server or `allowPrivateMailHosts` is set; `/accounts/test` is limited to
116
+ 10 a minute. Engine code - this version only documents the option in docs/guide.md. Ship with the matching core.
@@ -99,6 +99,7 @@ Whoever runs the site can change these in the plugin's configuration file (`conf
99
99
  | `listLimit` | 50 | Messages per page until you pick another size under the list |
100
100
  | `allowRemoteImages` | false | Load remote images without asking |
101
101
  | `allowInsecureTLS` | false | Accept mail server certificates that do not verify (see Tips) |
102
+ | `allowPrivateMailHosts` | false | Let a mailbox be on the site's own private network (a LAN or loopback address). Off, a mailbox on such an address is refused; the site's own outgoing server is always allowed |
102
103
  | `connectionTimeoutMs` | 20000 | How long to wait for a mail server, in milliseconds |
103
104
  | `maxConnections` | 20 | Most mail server connections kept open at once |
104
105
  | `maxMessageBytes` | 25000000 | Largest message that will be opened, in bytes |
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mail-reader",
3
3
  "displayName": "Mail Reader",
4
- "version": "1.2.6",
4
+ "version": "1.2.7",
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-27",
8
+ "date": "2026-10-13",
9
9
  "icon": "mail",
10
10
  "uses": {
11
11
  "contacts": "Add sender to contacts"
@@ -94,7 +94,8 @@ A cached page can show a stale stock flag until it is re-rendered; checkout re-p
94
94
  - Storefront CSS tokens are defined on `body`, not `:root`: themes set --dm-* on body's dm-theme-* class.
95
95
  - The site's own input/link styles reach the storefront: see the "reach in here" block at the end of shop.css.
96
96
  - `siteBase()` uses `request.host` (with the port); `request.hostname` drops it under Fastify 5.
97
- - A test site on a non-standard port needs `site.baseUrl` for Stripe/PayPal return addresses behind a proxy.
97
+ - A test site on a non-standard port needs `site.baseUrl` for Stripe/PayPal return addresses behind a proxy, and
98
+ emails have no links to the order until it (or a real sign-in) gives the site a trusted address (1.6.1).
98
99
  - Admin views: item templates live in JS (a templateUrl goes through _.render); checkboxes are set after render
99
100
  and listened to; slideovers via kit.openPanel (disposed and removed on close).
100
101
 
@@ -201,3 +202,18 @@ Cart only READS it (`lib/commission.js currentCommission`, re-read when the file
201
202
  - Never shown to the customer (`publicOrder` leaves it out). Shown to the shop: Sales (commission, returned, after
202
203
  commission; a CSV column), the order, Shop settings › Payment ("set by your provider" - read-only).
203
204
  - The manager's statements add up what the orders carry - nothing is recalculated there.
205
+
206
+ ## 1.6.1 (2026-10-13): no Host headers in email links; confirm says nothing; refunds under the lock
207
+
208
+ - `siteBase(request)` is for a payment provider's return addresses only (Site URL, else the shopper's own origin) and
209
+ remembers nothing: a forged Host on one `/quote` used to become the address - with the receipt tokens - in every email
210
+ until the next request. Email links use `quietBase()` (async: Site URL, else core's `trustedOrigin` - an address someone
211
+ has really signed in on, as password resets do), exposed to extensions as `services.linkBase()`; `services.siteBase()`
212
+ stays sync and is the Site URL or ''. `receiptUrl` is '' when there is none and the log says so once; `startCheckout`
213
+ prefers the Site URL to the extension's `base`. Account confirm and forgotten-password links use the same rule for the
214
+ request (`linkBase(request)`); with no trusted address, register answers 409 and forgot sends nothing.
215
+ - `POST /account/confirm` needs the token (shape-checked) and a waiting account, answers `{confirmed: true}` only, and
216
+ is off when accounts are: user ids are public (`createdBy`), so "already confirmed -> here is the email" leaked emails.
217
+ - `refundOrder` runs under `withLock` (provider call and `moveOrderNow` in one), and Stripe refunds carry
218
+ `Idempotency-Key: shopping-cart-refund-<orderId>` (sessions: `shop-session-<orderId>`) so a retry after a timeout
219
+ returns the first result.
@@ -23,7 +23,7 @@ The free Shopping Cart sends no emails at all - not to the shopper and not to yo
23
23
  2. Click **Add a product**, or **or try samples** to load six sample products with pictures, options and stock. You can edit or delete the samples later.
24
24
  3. Click **Payment settings** and turn on at least one way to pay (see Settings below). Pay offline is on by default.
25
25
  4. Click **See the shop** to check it, then link to `/shop` from your menu or put `[shop-products /]` on any page.
26
- 5. If your site sits behind a proxy or on an unusual port, set your site's address first: [Site Settings](#/settings) > General > **Site URL** (for example `https://example.com`). Stripe and PayPal send shoppers back to this address after they pay. Without it the Shop guesses the address from the request, which can be wrong behind a proxy.
26
+ 5. If your site sits behind a proxy or on an unusual port, set your site's address first: [Site Settings](#/settings) > General > **Site URL** (for example `https://example.com`). Stripe and PayPal send shoppers back to this address after they pay, and the links in order and account emails are built on it. Without it the Shop guesses the return address from the request, which can be wrong behind a proxy, and emails go out without their links until it is set.
27
27
 
28
28
  ## Screens
29
29
 
@@ -118,12 +118,17 @@ export async function register({name, email, password, phone = ''}, {link, shopT
118
118
  return {sent: true};
119
119
  }
120
120
 
121
- /** Click the link: the account is active. Returns the user, or throws. */
121
+ /**
122
+ * Click the link: the account is active. Returns the user, or throws. Only a waiting account with the right
123
+ * token is answered - never an already-active user: user ids are public (createdBy on entries, author fields),
124
+ * so answering "confirmed" for an active id would hand anyone that person's email.
125
+ */
122
126
  export async function confirm(userId, token) {
123
127
  const user = /^[0-9a-f-]{36}$/i.test(String(userId || '')) ? await getUserById(String(userId)) : null;
124
128
  const pending = user?.meta?.shop?.pending;
125
- if (user?.isActive && !pending) return user; // already confirmed - a second click is fine
126
- if (!user || !pending) throw bad(404, 'That link is not one we know - it may have been used already.');
129
+ if (!user || user.isActive || !pending || !/^[0-9a-f]{48}$/i.test(String(token || ''))) {
130
+ throw bad(404, 'That link is not one we know - it may have been used already.');
131
+ }
127
132
  const a = Buffer.from(sha(token));
128
133
  const b = Buffer.from(String(pending.hash || ''));
129
134
  if (a.length !== b.length || !timingSafeEqual(a, b)) throw bad(404, 'That link is not one we know - it may have been used already.');
@@ -64,12 +64,14 @@ export function stripeForm(obj, prefix = '', out = new URLSearchParams()) {
64
64
  return out;
65
65
  }
66
66
 
67
- function stripeInit(secretKey, method = 'GET', form = null) {
67
+ /** `idempotencyKey`: Stripe answers a repeat of the same request (a retry after a timeout) with the first result. */
68
+ function stripeInit(secretKey, method = 'GET', form = null, idempotencyKey = '') {
68
69
  return {
69
70
  method,
70
71
  headers: {
71
72
  Authorization: `Bearer ${secretKey}`,
72
73
  ...(form ? {'Content-Type': 'application/x-www-form-urlencoded'} : {}),
74
+ ...(idempotencyKey ? {'Idempotency-Key': String(idempotencyKey).slice(0, 255)} : {}),
73
75
  'Stripe-Version': '2024-06-20'
74
76
  },
75
77
  ...(form ? {body: form.toString()} : {})
@@ -128,7 +130,8 @@ export async function stripeCreateSession({secretKey, order, successUrl, cancelU
128
130
  description: `${shopName ? `${shopName} ` : ''}order #${order.number}`.slice(0, 1000)},
129
131
  line_items: Object.fromEntries(items.map((x, i) => [i, x]))
130
132
  });
131
- const s = await call(`${STRIPE}/checkout/sessions`, stripeInit(secretKey, 'POST', form), 'Stripe');
133
+ // One session per order: a retry of the same checkout gets the same page back, not a second session.
134
+ const s = await call(`${STRIPE}/checkout/sessions`, stripeInit(secretKey, 'POST', form, `shop-session-${order.id}`), 'Stripe');
132
135
  if (!s?.url) throw new PaymentError('Stripe did not return a payment page.', {provider: 'Stripe'});
133
136
  return {id: s.id, url: s.url};
134
137
  }
@@ -150,9 +153,9 @@ export function stripeSessionPaid(session, order) {
150
153
  return {ref: session.id, payment: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id || ''};
151
154
  }
152
155
 
153
- export async function stripeRefund({secretKey, paymentIntent, amount}) {
156
+ export async function stripeRefund({secretKey, paymentIntent, amount, idempotencyKey = ''}) {
154
157
  const form = stripeForm({payment_intent: paymentIntent, ...(amount ? {amount} : {})});
155
- return call(`${STRIPE}/refunds`, stripeInit(secretKey, 'POST', form), 'Stripe');
158
+ return call(`${STRIPE}/refunds`, stripeInit(secretKey, 'POST', form, idempotencyKey), 'Stripe');
156
159
  }
157
160
 
158
161
  /** Can this key talk to Stripe? */
@@ -382,8 +382,13 @@ export function markPaid(id, info = {}) {
382
382
  *
383
383
  * @returns {Promise<{order: object, from: string}>}
384
384
  */
385
- export function moveOrder(id, to, {by = 'admin', note = '', restock = false, tracking = null} = {}) {
386
- return withLock(async () => {
385
+ export function moveOrder(id, to, opts = {}) {
386
+ return withLock(() => moveOrderNow(id, to, opts));
387
+ }
388
+
389
+ /** moveOrder's body - for a caller that already holds the lock (a refund wraps the provider call and the move in one). */
390
+ export async function moveOrderNow(id, to, {by = 'admin', note = '', restock = false, tracking = null} = {}) {
391
+ {
387
392
  const had = await getOrder(id);
388
393
  if (!had) throw Object.assign(new Error('No such order.'), {status: 404});
389
394
  const d = had.data;
@@ -408,7 +413,7 @@ export function moveOrder(id, to, {by = 'admin', note = '', restock = false, tra
408
413
  }
409
414
  const order = await updateEntry(ORDERS, id, {...d, ...patch});
410
415
  return {order, from: d.status};
411
- });
416
+ }
412
417
  }
413
418
 
414
419
  /** The admin's own note and the tracking details - no status change. */