domma-cms 1.6.35 → 1.6.36

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 (51) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/call/ice.js +5 -2
  3. package/plugins/_lib/call/rooms.js +9 -3
  4. package/plugins/_lib/call/routes.js +7 -4
  5. package/plugins/_lib/mail/defaults.js +12 -0
  6. package/plugins/_lib/mail/hostGuard.js +86 -0
  7. package/plugins/_lib/mail/readRoutes.js +20 -1
  8. package/plugins/_lib/public/call/call.js +3 -1
  9. package/plugins/blog/CLAUDE.md +11 -1
  10. package/plugins/blog/docs/guide.md +1 -1
  11. package/plugins/blog/lib/page.js +32 -0
  12. package/plugins/blog/plugin.js +58 -16
  13. package/plugins/blog/plugin.json +2 -2
  14. package/plugins/blog/plugin.public.js +5 -4
  15. package/plugins/blog/tests/public.test.js +30 -1
  16. package/plugins/booking/CLAUDE.md +4 -1
  17. package/plugins/booking/docs/guide.md +1 -1
  18. package/plugins/booking/plugin.js +16 -21
  19. package/plugins/booking/plugin.json +2 -2
  20. package/plugins/booking/tests/api.test.js +26 -1
  21. package/plugins/feedback/CLAUDE.md +7 -2
  22. package/plugins/feedback/admin/views/feedback.js +1 -1
  23. package/plugins/feedback/lib/receiver.js +1 -1
  24. package/plugins/feedback/plugin.json +3 -3
  25. package/plugins/free-tier.lock.json +42 -41
  26. package/plugins/mail-reader/CLAUDE.md +6 -0
  27. package/plugins/mail-reader/docs/guide.md +1 -0
  28. package/plugins/mail-reader/plugin.json +2 -2
  29. package/plugins/shopping-cart/CLAUDE.md +17 -1
  30. package/plugins/shopping-cart/docs/guide.md +1 -1
  31. package/plugins/shopping-cart/lib/accounts.js +8 -3
  32. package/plugins/shopping-cart/lib/payments.js +7 -4
  33. package/plugins/shopping-cart/lib/shop.js +8 -3
  34. package/plugins/shopping-cart/plugin.js +77 -41
  35. package/plugins/shopping-cart/plugin.json +2 -2
  36. package/plugins/shopping-cart/public/account.js +2 -2
  37. package/plugins/shopping-cart/tests/accounts.test.js +10 -1
  38. package/plugins/shopping-cart/tests/api.test.js +23 -0
  39. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  40. package/plugins/support/CLAUDE.md +6 -0
  41. package/plugins/support/admin/views/support.js +1 -1
  42. package/plugins/support/docs/guide.md +90 -0
  43. package/plugins/support/lib/receiver.js +1 -1
  44. package/plugins/support/plugin.json +3 -3
  45. package/server/routes/api/analytics.js +32 -0
  46. package/server/routes/api/collections.js +148 -15
  47. package/server/server.js +5 -1
  48. package/server/services/authChecks.js +8 -0
  49. package/server/services/collectionAccess.js +191 -0
  50. package/server/services/collections.js +8 -2
  51. package/server/services/request-log.js +70 -0
@@ -48,21 +48,23 @@ import {
48
48
  } from './lib/store.js';
49
49
 
50
50
  /**
51
- * The origin staff last used the admin on - for emails sent with no request
52
- * (reminders) when `site.baseUrl` is not set. Only signed-in requests set it:
53
- * a customer's Host header is theirs to choose, and must not decide where
54
- * everyone else's reminder links point.
51
+ * The site's address for links in emails and the admin: `site.baseUrl` (Settings > Site), else '' - a link is then
52
+ * left out and the log says so once. Until 1.6.3 a request's Host header stood in when the setting was empty, so a
53
+ * booker who could reach the server with a Host of their choosing (default vhost, direct IP, misconfigured proxy)
54
+ * got the owner an email whose "Start the call" button went to their domain carrying the host key. A Host header
55
+ * is the caller's to choose; it never decides where anyone's link points. `request` is accepted for the registry's
56
+ * callers (Pro) and ignored.
55
57
  */
56
- let lastOrigin = '';
57
-
58
- function siteBase(request) {
58
+ let warnedNoBase = false;
59
+ function siteBase(request = null, log = null) {
59
60
  let base = '';
60
61
  try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
61
62
  if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
62
- if (!request) return lastOrigin;
63
- const origin = `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
64
- if (request.user) lastOrigin = origin;
65
- return origin;
63
+ if (log && !warnedNoBase) {
64
+ warnedNoBase = true;
65
+ log.warn?.('[booking] Site URL is not set (Settings > Site): booking emails go out without links until it is.');
66
+ }
67
+ return '';
66
68
  }
67
69
 
68
70
  const manageUrl = (s, b, base) => `${base}${s.bookingPath}/manage/${b.id}?t=${b.token}`;
@@ -209,7 +211,7 @@ export default async function booking(fastify, options) {
209
211
  async function tell(b, what, {request = null, toCustomer = true, toOwner = true} = {}) {
210
212
  try {
211
213
  const s = await liveSettings();
212
- const base = siteBase(request);
214
+ const base = siteBase(request, log);
213
215
  const title = siteTitle();
214
216
  const link = base && b.token ? manageUrl(s, b, base) : '';
215
217
  const may = customerMayChange(b, s) && (s.allowCancel || s.allowMove);
@@ -282,7 +284,7 @@ export default async function booking(fastify, options) {
282
284
  registry.services = {
283
285
  name: NAME, liveSettings, allBookings, allServices, allBookables, getBooking, getService, getBookable,
284
286
  changeBooking, sendReminders,
285
- /** The site's address: `site.baseUrl`, else the request's origin (a signed-in one), else the last admin's. */
287
+ /** The site's address: `site.baseUrl`, else '' (never a request's Host). */
286
288
  siteBase: (request = null) => siteBase(request),
287
289
  /** A booking as the admin may see it (no customer token; an online one's host key as `hostUrl`). */
288
290
  adminBooking,
@@ -352,7 +354,6 @@ export default async function booking(fastify, options) {
352
354
  const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
353
355
  if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
354
356
  }
355
- if (request) siteBase(request);
356
357
  if (notify) tell(b, 'made', {request, toOwner: source !== 'admin'});
357
358
  registry.emit('made', b);
358
359
  return b;
@@ -411,7 +412,7 @@ export default async function booking(fastify, options) {
411
412
  };
412
413
  }
413
414
 
414
- fastify.get('/bootstrap', can('read'), async (request) => { siteBase(request); return bootstrap(); });
415
+ fastify.get('/bootstrap', can('read'), async () => bootstrap());
415
416
 
416
417
  fastify.get('/count', can('read'), async () => {
417
418
  const s = await liveSettings();
@@ -513,7 +514,6 @@ export default async function booking(fastify, options) {
513
514
  const id = await linkContact(customer, owner, contactsOn()).catch(() => '');
514
515
  if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
515
516
  }
516
- siteBase(request);
517
517
  if (body.notify !== false) tell(b, 'made', {request, toOwner: false});
518
518
  registry.emit('made', b);
519
519
  return reply.code(201).send(adminBooking(b));
@@ -536,7 +536,6 @@ export default async function booking(fastify, options) {
536
536
  try {
537
537
  const b = await placeBooking({service, candidates, rules, startAt: startAt.toISOString(), tz: s.timeZone, moveId: current.id,
538
538
  force: body.force === true, fields: {by: request.user?.name || ''}});
539
- siteBase(request);
540
539
  if (body.notify !== false) tell(b, 'moved', {request, toOwner: false});
541
540
  registry.emit('moved', b, current);
542
541
  endCall(current).catch(() => {});
@@ -564,7 +563,6 @@ export default async function booking(fastify, options) {
564
563
  if (!res) return fail(reply, 404, 'No such booking.');
565
564
  const {booking: b, from} = res;
566
565
  if (b.status !== from) {
567
- siteBase(request);
568
566
  if (b.status === 'cancelled' && body.notify !== false) tell(b, 'cancelled', {request, toOwner: false});
569
567
  registry.emit(b.status === 'cancelled' ? 'cancelled' : 'status', b, from);
570
568
  if (b.status === 'cancelled') endCall(b).catch(() => {});
@@ -754,7 +752,6 @@ export default async function booking(fastify, options) {
754
752
  const id = await linkContact(customer, bk?.userId || s.contactsOwnerId, contactsOn()).catch(() => '');
755
753
  if (id) Object.assign(b, (await changeBooking(b.id, {contactId: id})).booking);
756
754
  }
757
- siteBase(request);
758
755
  tell(b, 'made', {request});
759
756
  notice(b, 'made');
760
757
  registry.emit('made', b);
@@ -793,7 +790,6 @@ export default async function booking(fastify, options) {
793
790
  ? `Bookings can be cancelled here up to ${s.changeNoticeHours} hours before. Please contact us instead.`
794
791
  : 'This booking cannot be cancelled here. Please contact us instead.');
795
792
  const {booking: done} = await changeBooking(b.id, {status: 'cancelled', reason: String(request.body?.reason || '').slice(0, 300)}, b.name);
796
- siteBase(request);
797
793
  tell(done, 'cancelled', {request});
798
794
  notice(done, 'cancelled');
799
795
  registry.emit('cancelled', done, 'confirmed');
@@ -817,7 +813,6 @@ export default async function booking(fastify, options) {
817
813
  const {service, candidates, rules} = await candidatesFor(b.serviceId, b.bookableId);
818
814
  try {
819
815
  const moved = await placeBooking({service, candidates, rules, startAt: offered.start, tz: s.timeZone, moveId: b.id, fields: {by: b.name}});
820
- siteBase(request);
821
816
  tell(moved, 'moved', {request});
822
817
  notice(moved, 'moved');
823
818
  registry.emit('moved', moved, b);
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "booking",
3
3
  "displayName": "Booking",
4
- "version": "1.6.2",
4
+ "version": "1.6.3",
5
5
  "tier": "free",
6
6
  "description": "Let people book appointments with you or your team, and rooms or equipment, from a booking page on your site. Opening hours, days off and buffers; confirmation and reminder emails with a link to change or cancel; a diary in the admin. Book straight from a contact or an email.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-03",
8
+ "date": "2026-10-13",
9
9
  "icon": "clipboard-check",
10
10
  "uses": {
11
11
  "contacts": "Customers become contacts; Book… on a contact and a Bookings panel on the contact card",
@@ -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? */