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
@@ -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. */
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import {getConfig} from '../../server/config.js';
26
+ import {originOf, trustedOrigin} from '../../server/services/passwordReset.js';
26
27
  import {cleanCheckout, orderLabel} from './public/lib/orders.js';
27
28
  import {formatMoney} from './public/lib/money.js';
28
29
  import {publicProduct, byShelf} from './public/lib/products.js';
@@ -37,24 +38,45 @@ import {badge, summarise} from './lib/summary.js';
37
38
  import * as pay from './lib/payments.js';
38
39
  import {
39
40
  NAME, allOrders, allProducts, annotateOrder, cleanSettings, cleanTracking, createOrder, ensureCollections, extendOrder,
40
- getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, publicOrder, removeOrder,
41
- refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt
41
+ getOrder, getProduct, liveSettings, markPaid, maskSettings, methodsOf, moveOrder, moveOrderNow, publicOrder, removeOrder,
42
+ refreshShelf, removeProduct, saveProduct, saveSettings, setProviderRef, settingsNow, shelfNow, tokenOk, updateProductExt, withLock
42
43
  } from './lib/shop.js';
43
44
  import {cardHtml, esc, gridHtml} from './lib/render.js';
44
45
 
45
- /** The origin shoppers last reached us on - for emails, which have no request. */
46
- let lastOrigin = '';
47
-
48
- /** Where this site lives, for the addresses a payment provider sends shoppers back to. */
49
- function siteBase(request) {
46
+ /** The Site URL setting as an origin, or ''. */
47
+ function configuredBase() {
50
48
  let base = '';
51
49
  try { base = String(getConfig('site')?.baseUrl || ''); } catch { /* no site config */ }
52
- if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
53
- if (!request) return lastOrigin;
54
- const origin = `${request.protocol}://${request.host || request.headers.host || request.hostname}`;
55
- lastOrigin = origin;
56
- return origin;
50
+ return /^https?:\/\/[^/\s]+/i.test(base) ? base.replace(/\/+$/, '') : '';
51
+ }
52
+
53
+ /**
54
+ * Where this site lives, for the addresses a payment provider sends shoppers back to: the Site URL setting,
55
+ * else the origin of the shopper's own request (they land back where they came from). Never remembered for
56
+ * later: any request can claim any Host, and a kept one would put a stranger's address - and the receipt
57
+ * tokens - in every customer email until the next request.
58
+ */
59
+ function siteBase(request) {
60
+ return configuredBase() || (request ? `${request.protocol}://${request.host || request.headers.host || request.hostname}` : '');
61
+ }
62
+
63
+ let warnedNoBase = false;
64
+ let warn = (msg) => console.warn(msg);
65
+ /**
66
+ * The address for links with no shopper's request behind them (emails, links handed to other Tools): core's
67
+ * rule as for password resets - the Site URL, else an address someone has really signed in on. '' when the
68
+ * site knows none; the email then goes without its link, and the log says why once.
69
+ */
70
+ async function quietBase() {
71
+ const base = configuredBase() || await trustedOrigin('').catch(() => '');
72
+ if (!base && !warnedNoBase) {
73
+ warnedNoBase = true;
74
+ warn('[shopping-cart] Site URL is not set (Site Settings): emails go out without links to the order until it is.');
75
+ }
76
+ return base;
57
77
  }
78
+ /** The same rule for an email answering a request (account confirm, forgotten password). */
79
+ const linkBase = (request) => configuredBase() || trustedOrigin(originOf(request)).catch(() => '');
58
80
 
59
81
  const receiptPath = (s, order) => `${s.shopPath}/order/${order.id}?t=${order.data.token}`;
60
82
 
@@ -138,6 +160,7 @@ function registerShortcodes(hooks) {
138
160
  }
139
161
 
140
162
  export default async function shoppingCart(fastify, options) {
163
+ warn = (msg) => fastify.log.warn(msg);
141
164
  const {authenticate, requirePermission} = options.auth;
142
165
  const hooks = options.hooks || {};
143
166
  const can = (action) => ({preHandler: [authenticate, requirePermission('shop', action)]});
@@ -181,7 +204,10 @@ export default async function shoppingCart(fastify, options) {
181
204
  name: NAME,
182
205
  liveSettings, allOrders, getOrder, allProducts, getProduct, extendOrder, publicOrder,
183
206
  formatMoney, orderLabel,
207
+ /** The Site URL as an origin, or '' - sync, for the shopper-facing paths. Email links: `linkBase`. */
184
208
  siteBase: () => siteBase(null),
209
+ /** The address for links in emails (async): the Site URL, else an address someone has really signed in on, else ''. */
210
+ linkBase: quietBase,
185
211
  /** Change only an extension's part of a product, under the order lock (lib/shop.js updateProductExt). */
186
212
  updateProductExt,
187
213
  /** Move an order on (Shopping Cart Pro fulfils an order with nothing to post), telling every extension. */
@@ -190,7 +216,7 @@ export default async function shoppingCart(fastify, options) {
190
216
  announceMove(order, from, order.data.status);
191
217
  return order;
192
218
  },
193
- receiptUrl: (order) => liveSettings().then(s => `${siteBase(null)}${receiptPath(s, order)}`)
219
+ receiptUrl: async (order) => { const [s, base] = await Promise.all([liveSettings(), quietBase()]); return base ? `${base}${receiptPath(s, order)}` : ''; }
194
220
  };
195
221
 
196
222
  // ---- Admin: overview, badge, extensions ----------------------------------------
@@ -366,26 +392,32 @@ export default async function shoppingCart(fastify, options) {
366
392
  /** Refund an order in full (see above). Throws {status, message}. */
367
393
  async function refundOrder(id, {restock = false, note = '', viaProvider = true, by = 'admin'} = {}) {
368
394
  const bad = (status, message) => Object.assign(new Error(message), {status});
369
- const o = await getOrder(id);
370
- if (!o) throw bad(404, 'No such order.');
371
- const d = o.data;
372
- if (!['paid', 'fulfilled'].includes(d.status)) throw bad(409, 'Only a paid order can be refunded.');
373
- const s = await liveSettings();
374
- let how = 'marked refunded';
375
- try {
376
- if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
377
- if (!s.stripeSecretKey) throw bad(409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
378
- await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef});
379
- how = 'refunded through Stripe';
380
- } else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
381
- if (!s.paypalClientId || !s.paypalSecret) throw bad(409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
382
- await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
383
- how = 'refunded through PayPal';
395
+ // The whole refund runs under the order lock: two refunds at once would both reach the provider, and a
396
+ // write landing between the provider's yes and ours would be lost. The provider call carries an
397
+ // Idempotency-Key from the order id, so a retry after a timeout returns the first refund, not a second.
398
+ const {order, from, how} = await withLock(async () => {
399
+ const o = await getOrder(id);
400
+ if (!o) throw bad(404, 'No such order.');
401
+ const d = o.data;
402
+ if (!['paid', 'fulfilled'].includes(d.status)) throw bad(409, 'Only a paid order can be refunded.');
403
+ const s = await liveSettings();
404
+ let via = 'marked refunded';
405
+ try {
406
+ if (viaProvider && d.provider === 'stripe' && d.paymentRef) {
407
+ if (!s.stripeSecretKey) throw bad(409, 'Stripe is not set up any more - refund it in Stripe, then mark it refunded here.');
408
+ await pay.stripeRefund({secretKey: s.stripeSecretKey, paymentIntent: d.paymentRef, idempotencyKey: `${NAME}-refund-${o.id}`});
409
+ via = 'refunded through Stripe';
410
+ } else if (viaProvider && d.provider === 'paypal' && d.paymentRef) {
411
+ if (!s.paypalClientId || !s.paypalSecret) throw bad(409, 'PayPal is not set up any more - refund it in PayPal, then mark it refunded here.');
412
+ await pay.paypalRefund({creds: {clientId: s.paypalClientId, secret: s.paypalSecret, mode: s.paypalMode}, captureId: d.paymentRef});
413
+ via = 'refunded through PayPal';
414
+ }
415
+ } catch (err) {
416
+ throw bad(err.status || 502, err.status === 409 ? err.message : `Not refunded: ${err.message}`);
384
417
  }
385
- } catch (err) {
386
- throw bad(err.status || 502, err.status === 409 ? err.message : `Not refunded: ${err.message}`);
387
- }
388
- const {order, from} = await moveOrder(o.id, 'refunded', {by, note: [how, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
418
+ const moved = await moveOrderNow(o.id, 'refunded', {by, note: [via, String(note || '')].filter(Boolean).join(' - '), restock: Boolean(restock)});
419
+ return {...moved, how: via};
420
+ });
389
421
  announceMove(order, from, 'refunded');
390
422
  return {order, how};
391
423
  }
@@ -465,30 +497,35 @@ export default async function shoppingCart(fastify, options) {
465
497
  /** `rest` -> an absolute address under the shop, for emails. */
466
498
  const shopLink = async (request) => {
467
499
  const s = await liveSettings();
468
- const base = siteBase(request);
469
- return {s, link: (rest) => `${base}${s.shopPath}/${rest}`};
500
+ const base = await linkBase(request);
501
+ return {s, base, link: (rest) => `${base}${s.shopPath}/${rest}`};
470
502
  };
471
503
  const accountFail = (reply, err) => fail(reply, err.status || 500, err.message, err.errors ? {errors: err.errors} : {});
472
504
 
473
505
  fastify.post('/account/register', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
474
506
  if (await accountsOff(reply)) return undefined;
475
- const {s, link} = await shopLink(request);
507
+ const {s, base, link} = await shopLink(request);
508
+ // A confirm link needs an address the site trusts; the Host header is not one.
509
+ if (!base) return fail(reply, 409, 'Accounts cannot be made just now - please check out as a guest.');
476
510
  try {
477
511
  return await accounts.register(request.body || {}, {link, shopTitle: s.shopTitle || 'the shop', log: request.log});
478
512
  } catch (err) { return accountFail(reply, err); }
479
513
  });
480
514
 
481
515
  fastify.post('/account/confirm', {config: {rateLimit: {max: 20, timeWindow: '10 minutes'}}}, async (request, reply) => {
516
+ if (await accountsOff(reply)) return undefined;
482
517
  try {
483
- const user = await accounts.confirm(request.body?.u, request.body?.t);
484
- return {confirmed: true, email: user.email};
518
+ // Nothing about the account comes back - not even its email: `u` is a user id, and those are public.
519
+ await accounts.confirm(request.body?.u, request.body?.t);
520
+ return {confirmed: true};
485
521
  } catch (err) { return accountFail(reply, err); }
486
522
  });
487
523
 
488
524
  fastify.post('/account/forgot', {config: {rateLimit: {max: 5, timeWindow: '10 minutes'}}}, async (request, reply) => {
489
525
  if (await accountsOff(reply)) return undefined;
490
- const {s, link} = await shopLink(request);
491
- await accounts.forgot(request.body?.email, {link, shopTitle: s.shopTitle || 'the shop', log: request.log}).catch(() => null);
526
+ const {s, base, link} = await shopLink(request);
527
+ if (base) await accounts.forgot(request.body?.email, {link, shopTitle: s.shopTitle || 'the shop', log: request.log}).catch(() => null);
528
+ else request.log.warn('[shopping-cart] password reset not sent: the site has no trusted address (set the Site URL)');
492
529
  // The same answer whoever asked: the form must not tell anyone who has an account.
493
530
  return {ok: true};
494
531
  });
@@ -531,7 +568,6 @@ export default async function shoppingCart(fastify, options) {
531
568
  });
532
569
 
533
570
  fastify.post('/quote', async (request) => {
534
- siteBase(request);
535
571
  const s = await liveSettings();
536
572
  const {quote} = await quoteFor(request.body, s);
537
573
  return shopperQuote(quote, s);
@@ -690,7 +726,7 @@ export default async function shoppingCart(fastify, options) {
690
726
  const methods = methodsOf(s).map(m => m.id);
691
727
  const via = quote.total === 0 ? 'free' : (provider || methods[0] || '');
692
728
  if (via !== 'free' && !methods.includes(via)) throw bad(409, 'The Shop has no way to take payment set up yet.');
693
- const origin = /^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') : siteBase(null);
729
+ const origin = configuredBase() || (/^https?:\/\/[^/\s]+$/i.test(base) ? base.replace(/\/+$/, '') : await quietBase());
694
730
  const back = /^\/(?!\/)/.test(String(returnTo)) ? `${origin}${returnTo}` : '';
695
731
  const order = await createOrder({quote, checkout, provider: via, delivery: deliver ? 'ship' : delivery === 'collect' ? 'collect' : 'none',
696
732
  code: String(code || '').slice(0, 60), ref, returnTo: back, ownEmails: ownEmails === true, payNote});
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "shopping-cart",
3
3
  "displayName": "Shopping Cart",
4
- "version": "1.6.0",
4
+ "version": "1.6.1",
5
5
  "tier": "free",
6
6
  "description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-12",
8
+ "date": "2026-10-13",
9
9
  "icon": "shopping-bag",
10
10
  "permissions": [
11
11
  {
@@ -208,9 +208,9 @@ function signedInView(body, me) {
208
208
 
209
209
  async function confirmView(body) {
210
210
  try {
211
- const r = await account.confirm(params.get('u'), params.get('t'));
211
+ await account.confirm(params.get('u'), params.get('t'));
212
212
  document.querySelector('.shop-account h1').textContent = 'Your account';
213
- signedOutView(body, {email: r.email, notice: 'Thank you - your account is confirmed. Sign in to carry on.'});
213
+ signedOutView(body, {notice: 'Thank you - your account is confirmed. Sign in to carry on.'});
214
214
  } catch (err) {
215
215
  document.querySelector('.shop-account h1').textContent = 'That link did not work';
216
216
  body.innerHTML = `<p class="shop-co-error">${esc(err.message)}</p><p><a class="shop-linkbtn" href="${esc(shopPath)}/account">Sign in or create an account</a></p>`;
@@ -62,6 +62,7 @@ const linkIn = (m) => /https?:\/\/\S+/.exec(m.text)?.[0] || '';
62
62
 
63
63
  test('accounts: off by default; create, confirm by email, details, addresses, orders by email, reset, delete', async () => {
64
64
  assert.equal((await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'})).status, 404, 'off until switched on');
65
+ assert.equal((await call('POST', '/account/confirm', {u: '00000000-0000-4000-8000-000000000000', t: '0'.repeat(48)})).status, 404, 'confirm is off too');
65
66
  const put = await call('PUT', '/settings', {accounts: true, offlineEnabled: true, countries: ['GB']});
66
67
  assert.equal(put.status, 200, JSON.stringify(put.body));
67
68
  assert.equal(put.body.settings?.accounts ?? put.body.accounts, true, JSON.stringify(put.body).slice(0, 300));
@@ -81,11 +82,19 @@ test('accounts: off by default; create, confirm by email, details, addresses, or
81
82
  assert.equal(mail.length, 1, 'still pending: no second email, no second account');
82
83
 
83
84
  assert.equal((await call('POST', '/account/confirm', {u: user.id, t: '0'.repeat(48)})).status, 404);
85
+ assert.equal((await call('POST', '/account/confirm', {u: user.id})).status, 404, 'no token, no answer');
84
86
  r = await call('POST', '/account/confirm', {u: confirmLink.searchParams.get('u'), t: confirmLink.searchParams.get('t')});
85
- assert.deepEqual(r.body, {confirmed: true, email: EMAILS[0]});
87
+ assert.deepEqual(r.body, {confirmed: true}, 'confirms, and says nothing about the account - not even its email');
86
88
  user = await getUserById(user.id);
87
89
  assert.equal(user.isActive, true);
88
90
  assert.ok(!user.meta.shop.pending);
91
+ // The link is spent: a second click, or any guess at an active user's id, learns nothing (ids are public).
92
+ r = await call('POST', '/account/confirm', {u: confirmLink.searchParams.get('u'), t: confirmLink.searchParams.get('t')});
93
+ assert.equal(r.status, 404);
94
+ assert.equal(JSON.stringify(r.body).includes(EMAILS[0]), false);
95
+ r = await call('POST', '/account/confirm', {u: user.id, t: 'x'});
96
+ assert.equal(r.status, 404);
97
+ assert.equal(JSON.stringify(r.body).includes(EMAILS[0]), false);
89
98
  r = await call('POST', '/account/register', {name: 'Ann', email: EMAILS[0], password: 'a-long-password'});
90
99
  assert.match(mail.at(-1).subject, /already have an account/);
91
100
 
@@ -302,3 +302,26 @@ test('sales: the report and its CSV over the API', async () => {
302
302
  assert.match(csv.headers['content-disposition'], /attachment; filename="sales-2020-01-01-to-2030-12-31\.csv"/);
303
303
  assert.ok(csv.body.startsWith('Order,Paid,Status'));
304
304
  });
305
+
306
+ test('review: links for emails never come from a request\'s Host header; two refunds at once have one winner', async () => {
307
+ const reg = globalThis[Symbol.for('domma.shopping-cart')];
308
+ await call('PUT', '/settings', {offlineEnabled: true, countries: ['GB'], taxRate: 0, shippingFlat: 0});
309
+ const pen = (await call('POST', '/products', {name: 'Pen', price: 500, status: 'active', shippable: false})).body;
310
+ // A forged Host on a public request must not become the address in anyone's email.
311
+ const forged = await app.inject({method: 'POST', url: '/quote', headers: {host: 'evil.example'}, payload: {lines: [{productId: pen.id, qty: 1}]}});
312
+ assert.equal(forged.statusCode, 200);
313
+ assert.equal(String(reg.services.siteBase()).includes('evil.example'), false);
314
+ const placed = await call('POST', '/checkout', {lines: [{productId: pen.id, qty: 1}], provider: 'offline', customer: {name: 'Eve', email: 'eve@example.com'}});
315
+ assert.equal(placed.status, 200, JSON.stringify(placed.body));
316
+ const order = await reg.services.getOrder(placed.body.orderId);
317
+ const receipt = await reg.services.receiptUrl(order);
318
+ assert.equal(receipt.includes('evil.example'), false, `receipt link: ${receipt}`);
319
+ assert.equal((await reg.services.linkBase()).includes('evil.example'), false);
320
+ if (receipt) assert.match(receipt, /^https?:\/\/[^/]+\/shop\/order\//);
321
+
322
+ // Paid, then refunded twice in the same instant: one refund, one "only a paid order" refusal.
323
+ await call('PUT', `/orders/${order.id}/status`, {status: 'paid'});
324
+ const both = await Promise.all([call('POST', `/orders/${order.id}/refund`, {}), call('POST', `/orders/${order.id}/refund`, {})]);
325
+ assert.deepEqual(both.map(r => r.status).sort(), [200, 409], JSON.stringify(both.map(r => r.body)));
326
+ assert.equal((await reg.services.getOrder(order.id)).data.history.filter(h => h.status === 'refunded').length, 1);
327
+ });
@@ -5,7 +5,7 @@ import {formatMoney, fromMinor, includedTax, percentOf, toMinor} from '../public
5
5
  import {available, cleanProduct, priceRange, publicProduct, slugify, unitPrice} from '../public/lib/products.js';
6
6
  import {canMove, cleanCheckout} from '../public/lib/orders.js';
7
7
  import {cleanCharges, cleanCustomLines, cleanLines, priceBasket} from '../lib/pricing.js';
8
- import {paypalPaid, stripeForm, stripeLineItems, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
8
+ import {paypalPaid, stripeCreateSession, stripeForm, stripeLineItems, stripeRefund, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
9
9
  import {badge, summarise} from '../lib/summary.js';
10
10
  import {cartRegistry} from '../lib/extensions.js';
11
11
  import {matchShopPath} from '../plugin.public.js';
@@ -298,3 +298,18 @@ test('pricing: a shipping handler can offer services to choose between', async (
298
298
  assert.equal(q.shipping, 900);
299
299
  assert.equal(q.shippingChoice, 'exp');
300
300
  });
301
+
302
+ test('payments: Stripe refunds and sessions carry an Idempotency-Key, so a retry after a timeout is not a second one', async () => {
303
+ const seen = [];
304
+ const realFetch = globalThis.fetch;
305
+ globalThis.fetch = async (url, init) => { seen.push({url, init}); return {ok: true, json: async () => ({id: 'cs_test_1', url: 'https://pay.test/x'})}; };
306
+ try {
307
+ await stripeRefund({secretKey: 'sk_test_x', paymentIntent: 'pi_1', idempotencyKey: 'shopping-cart-refund-o1'});
308
+ assert.equal(seen[0].init.headers['Idempotency-Key'], 'shopping-cart-refund-o1');
309
+ await stripeRefund({secretKey: 'sk_test_x', paymentIntent: 'pi_1'});
310
+ assert.equal('Idempotency-Key' in seen[1].init.headers, false, 'none asked for, none sent');
311
+ await stripeCreateSession({secretKey: 'sk_test_x', order: {id: 'o1', number: 1001, currency: 'GBP', total: 100, lines: [{name: 'Mug', qty: 1, unitPrice: 100, lineTotal: 100}], adjustments: []},
312
+ successUrl: 'https://s.test/ok', cancelUrl: 'https://s.test/no'});
313
+ assert.equal(seen[2].init.headers['Idempotency-Key'], 'shop-session-o1');
314
+ } finally { globalThis.fetch = realFetch; }
315
+ });
@@ -76,3 +76,9 @@ manager also "Quote in reply").
76
76
 
77
77
  Site: new replies (`info`), else open requests as an inventory. Manager: requests waiting on Domma,
78
78
  `danger` while any is urgent, `attention` otherwise; nothing waiting = every request, inventory.
79
+
80
+ ## 1.1.2 (2026-10-13 Tool sweep hygiene)
81
+
82
+ - `docs/guide.md` added (the Documentation > Plugins page Feedback already had). The manifest entry carries
83
+ `?v=1.1.2` and `V = '1.1.2'` loads the sub-views - bump both with the version. lib/receiver.js header corrected
84
+ (fleet tokens are on disk since 2026-10-04).
@@ -14,7 +14,7 @@
14
14
  import {api, loadKit} from '../lib/kit.js';
15
15
 
16
16
  const ROUTE = /^#\/plugins\/support(\/|$)/;
17
- const V = '1.1.0';
17
+ const V = '1.1.2';
18
18
  let lifetime = null;
19
19
 
20
20
  export const supportView = {
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: Guide
3
+ order: 1
4
+ ---
5
+
6
+ Support gets you help from Domma without leaving your admin: our email, phone and hours are always to hand, and a request is a conversation with the Domma team. It is free, on every site.
7
+
8
+ ## What it does
9
+
10
+ - Shows the **contact card**: Domma's email, phone, hours and how quickly to expect an answer.
11
+ - Sends a request to the Domma team with your name, your site and its CMS version attached, and shows Domma's replies underneath it, newest at the bottom.
12
+ - Tells you when Domma replies, and lets you write back in the same request.
13
+ - Keeps a request safe if Domma cannot be reached, and sends it as soon as it can.
14
+
15
+ ## Getting started
16
+
17
+ 1. Open [Support](#/plugins/support) from the admin sidebar.
18
+ 2. Click **Ask for help**.
19
+ 3. Pick **What is it about?**: I need help with something, Something is not working, Billing or my account, or Something else.
20
+ 4. Type a **Subject** (one line) and your **Message**.
21
+ 5. Choose **Normal** (we answer in order, usually within a working day) or **Urgent** (your site is down, or you cannot take orders, bookings or payments).
22
+ 6. Optional: fill in **Where** - the screen or page it is about, for example "Pages" or `/contact`.
23
+ 7. Click **Send**.
24
+
25
+ For something that is not working, say what you did, what you expected and what happened instead.
26
+
27
+ ## Screens
28
+
29
+ ### Requests
30
+
31
+ Each row shows the subject, when it was last written to, and a status:
32
+
33
+ | Status | Meaning |
34
+ |---|---|
35
+ | Waiting on Domma | Domma has it and has not replied yet |
36
+ | Domma replied | There is a reply - it is your turn |
37
+ | Resolved | Finished. Writing again reopens it |
38
+ | Waiting to send | Domma could not be reached yet; it goes automatically |
39
+
40
+ A dot on a row means a new reply. Click a row to read the conversation and **Reply**. Right-click a row (or use the row's menu) to **Archive**, **Resolve** or **Remove** it:
41
+
42
+ - **Archive** tidies a request away under **Archived**. It comes back by itself if Domma writes to it.
43
+ - **Resolve** closes it. You can still write to it, which reopens it.
44
+ - **Remove** takes it off your screen. Domma keeps its copy. Removing a request that has not reached Domma yet cancels it.
45
+
46
+ ### Not connected
47
+
48
+ If the screen offers the contact card only and says to email us, your site was not started by a Domma server, so there is nowhere to send a request to. Email or phone us instead.
49
+
50
+ ## Settings
51
+
52
+ There is no settings screen. Whoever runs the site can change one option in the plugin's configuration file:
53
+
54
+ | Option | Default | Meaning |
55
+ |---|---|---|
56
+ | `syncMinutes` | 5 | How often the site sends waiting requests and checks for replies |
57
+
58
+ Opening the Support screen also sends anything waiting and checks for replies straight away. The contact card is set by Domma and arrives with the first sync.
59
+
60
+ ## Notifications
61
+
62
+ When Domma replies, you get an admin notification with the request's subject. The sidebar badge counts replies you have not opened; opening the request clears it. With nothing new, the badge shows how many requests are waiting on Domma.
63
+
64
+ ## Permissions and roles
65
+
66
+ In System > Roles, Support appears in the **Plugins** group:
67
+
68
+ | Action | Label | Allows |
69
+ |---|---|---|
70
+ | `support.send` | Send | Ask Domma for help and see this site's requests |
71
+ | `support.manage` | Manage | Only used on the Domma server (see below) |
72
+
73
+ Only the **admin** role has them by default. Everyone with **Send** sees every request the site has sent, not just their own.
74
+
75
+ ## On the Domma server
76
+
77
+ On the Domma server itself (the Manager), the same screen is the **Inbox** of every site's requests, for holders of **Support > Manage**:
78
+
79
+ - Filter by **Open**, **Answered**, **Resolved**, **Archived** or **All**, urgent first; search subject, text, person and site.
80
+ - Open a request to read the conversation, **Reply** (the site sees it and is notified, and the person is emailed), **Resolve**, change its priority, and keep **Internal notes** (never sent to the site).
81
+ - Right-click a row to archive, resolve or **Delete** it. Archiving is Domma's own filing: the site does not see it, and a request comes out of the archive when the customer writes again.
82
+ - A new request or a customer's reply is emailed to the Domma support address, with the customer as Reply-To and `[URGENT]` in the subject when it is urgent.
83
+
84
+ ## Limitations
85
+
86
+ - Only sites started by a Domma server can send. A standalone install gets the contact card only.
87
+ - No attachments or screenshots. Describe the problem in words, and use **Where** for the page.
88
+ - Subjects are limited to 140 characters and messages to 8,000.
89
+ - The Domma server accepts up to 20 requests and replies from one site in 10 minutes. Any more show as **Waiting to send** and go later.
90
+ - You cannot edit or withdraw a message once sent. Write a follow-up instead.
@@ -6,7 +6,7 @@
6
6
  * names, emails and words.
7
7
  *
8
8
  * A site is who its fleet token says it is (site-manager/services/fleetTokens.js,
9
- * memory only, minted when the manager forked it). That module only exists where
9
+ * minted when the manager forked it and kept in site-manager/data/fleet-tokens.json). That module only exists where
10
10
  * Site Manager does, which is also how this plugin knows it is on the manager.
11
11
  *
12
12
  * @module support/lib/receiver
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "support",
3
3
  "displayName": "Support",
4
- "version": "1.1.1",
4
+ "version": "1.1.2",
5
5
  "tier": "free",
6
6
  "description": "Get help from Domma straight from your admin: our email, phone and hours always to hand, and a conversation with the Domma team for anything you need - urgent requests first. Right-click a request to archive, resolve or remove it.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-04",
8
+ "date": "2026-10-13",
9
9
  "icon": "headphones",
10
10
  "permissions": [
11
11
  {
@@ -61,7 +61,7 @@
61
61
  ],
62
62
  "views": {
63
63
  "plugin-support": {
64
- "entry": "support/admin/views/support.js",
64
+ "entry": "support/admin/views/support.js?v=1.1.2",
65
65
  "exportName": "supportView"
66
66
  }
67
67
  }
@@ -56,8 +56,33 @@ const JOURNEYS_FILE = path.join(DATA_DIR, 'journeys.json');
56
56
  const EXCLUDE_ADMIN = true;
57
57
  const EXCLUDE_PATHS = ['/api'];
58
58
  const JOURNEY_DAILY_CAP = 5000;
59
+ const JOURNEY_RETENTION_DAYS = 90; // the longest range the screens offer; older days are never read
59
60
  const REALTIME_WINDOW_MS = 5 * 60 * 1000;
60
61
 
62
+ /**
63
+ * The beacon's `url` becomes an object key in lifetime.json and daily.json, so
64
+ * it must be a site path: starting with '/', no whitespace, at most 2 KB
65
+ * (anything a browser posts is far shorter; a 1 MB key would otherwise persist
66
+ * for ever). Prototype names are refused explicitly as well - the '/' rule
67
+ * already excludes them, but `lifetime["constructor"] + 1` turning every total
68
+ * into a string is exactly the bug a relaxed rule must not bring back.
69
+ */
70
+ const URL_MAX = 2048;
71
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
72
+ function isBeaconPath(url) {
73
+ return typeof url === 'string' && url.length <= URL_MAX && url.startsWith('/') && !/\s/.test(url);
74
+ }
75
+ function isSafeKey(key) {
76
+ return !UNSAFE_KEYS.has(key) && !UNSAFE_KEYS.has(key.replace(/^\/+/, ''));
77
+ }
78
+
79
+ /** Drop journey days older than the retention - nothing reads them, and the file is rewritten on every hit. */
80
+ function pruneJourneys(journeys, today, days = JOURNEY_RETENTION_DAYS) {
81
+ const cutoff = todayKey(new Date(Date.parse(today) - days * 86400000));
82
+ for (const day of Object.keys(journeys)) if (day < cutoff) delete journeys[day];
83
+ return journeys;
84
+ }
85
+
61
86
  async function readJson(file, fallback) {
62
87
  try {
63
88
  return JSON.parse(await fs.readFile(file, 'utf8'));
@@ -401,11 +426,17 @@ export async function analyticsRoutes(fastify, opts = {}) {
401
426
  if (!url || typeof url !== 'string') {
402
427
  return reply.status(400).send({ error: 'url is required' });
403
428
  }
429
+ if (!isBeaconPath(url)) {
430
+ return reply.status(400).send({ error: 'url must be a site path of 2 KB or less' });
431
+ }
404
432
  if (dnt === true || dnt === '1') {
405
433
  return { ok: true, ignored: 'dnt' };
406
434
  }
407
435
 
408
436
  const normalised = url.split('?')[0].split('#')[0].replace(/\/+$/, '') || '/';
437
+ if (!isSafeKey(normalised)) {
438
+ return reply.status(400).send({ error: 'url must be a site path of 2 KB or less' });
439
+ }
409
440
  if (EXCLUDE_ADMIN && normalised.startsWith('/admin')) {
410
441
  return { ok: true, ignored: 'admin' };
411
442
  }
@@ -432,6 +463,7 @@ export async function analyticsRoutes(fastify, opts = {}) {
432
463
  // Journey event - only if sid is a string of reasonable shape, and under cap
433
464
  const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
434
465
  const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
466
+ pruneJourneys(journeys, today);
435
467
  if (safeSid) {
436
468
  if (!journeys[today]) journeys[today] = [];
437
469
  if (shouldRecordJourney(journeys[today])) {