domma-cms 0.84.1 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +4 -4
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/js/lib/sidebar-renderer.js +3 -3
  5. package/admin/js/lib/themes.js +1 -1
  6. package/config/menus/admin-sidebar.json +21 -8
  7. package/config/plugins.json +0 -18
  8. package/package.json +2 -2
  9. package/plugins/_lib/mail/diagnostics.js +3 -2
  10. package/plugins/blog/CLAUDE.md +33 -0
  11. package/plugins/blog/lib/access.js +64 -0
  12. package/plugins/blog/lib/notices.js +38 -0
  13. package/plugins/blog/plugin.js +84 -26
  14. package/plugins/blog/plugin.json +173 -20
  15. package/plugins/blog/roles/blog-author.json +15 -2
  16. package/plugins/blog/tests/access.test.js +32 -0
  17. package/plugins/blog/tests/comment-notices.test.js +140 -0
  18. package/plugins/blog/tests/notices.test.js +20 -0
  19. package/plugins/blog/tests/public.test.js +3 -1
  20. package/plugins/free-tier.lock.json +63 -10
  21. package/plugins/mail-reader/plugin.js +12 -1
  22. package/plugins/mail-reader/plugin.json +23 -4
  23. package/plugins/shopping-cart/CLAUDE.md +93 -0
  24. package/plugins/shopping-cart/admin/css/index.css +224 -0
  25. package/plugins/shopping-cart/admin/lib/kit.js +190 -0
  26. package/plugins/shopping-cart/admin/templates/shop.html +1 -0
  27. package/plugins/shopping-cart/admin/views/orders.js +365 -0
  28. package/plugins/shopping-cart/admin/views/overview.js +214 -0
  29. package/plugins/shopping-cart/admin/views/products.js +547 -0
  30. package/plugins/shopping-cart/admin/views/settings.js +247 -0
  31. package/plugins/shopping-cart/admin/views/shop.js +130 -0
  32. package/plugins/shopping-cart/collections/shop-orders.json +20 -0
  33. package/plugins/shopping-cart/collections/shop-products.json +24 -0
  34. package/plugins/shopping-cart/config.js +52 -0
  35. package/plugins/shopping-cart/inject/body.html +2 -0
  36. package/plugins/shopping-cart/lib/extensions.js +96 -0
  37. package/plugins/shopping-cart/lib/payments.js +281 -0
  38. package/plugins/shopping-cart/lib/pricing.js +232 -0
  39. package/plugins/shopping-cart/lib/render.js +282 -0
  40. package/plugins/shopping-cart/lib/samples.js +34 -0
  41. package/plugins/shopping-cart/lib/shop.js +436 -0
  42. package/plugins/shopping-cart/lib/summary.js +127 -0
  43. package/plugins/shopping-cart/plugin.js +559 -0
  44. package/plugins/shopping-cart/plugin.json +52 -0
  45. package/plugins/shopping-cart/plugin.public.js +144 -0
  46. package/plugins/shopping-cart/public/cart.js +715 -0
  47. package/plugins/shopping-cart/public/lib/money.js +105 -0
  48. package/plugins/shopping-cart/public/lib/orders.js +122 -0
  49. package/plugins/shopping-cart/public/lib/products.js +235 -0
  50. package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
  51. package/plugins/shopping-cart/public/samples/candle.svg +16 -0
  52. package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
  53. package/plugins/shopping-cart/public/samples/mug.svg +14 -0
  54. package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
  55. package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
  56. package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
  57. package/plugins/shopping-cart/public/samples/print.svg +14 -0
  58. package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
  59. package/plugins/shopping-cart/public/samples/tee.svg +12 -0
  60. package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
  61. package/plugins/shopping-cart/public/samples/tote.svg +13 -0
  62. package/plugins/shopping-cart/public/shop.css +415 -0
  63. package/plugins/shopping-cart/templates/shop.html +4 -0
  64. package/plugins/shopping-cart/tests/api.test.js +144 -0
  65. package/plugins/shopping-cart/tests/pure.test.js +210 -0
  66. package/scripts/setup.js +1 -0
  67. package/server/routes/api/notifications.js +4 -3
  68. package/server/services/notify.js +4 -2
  69. package/server/services/pluginScope.js +85 -0
  70. package/server/services/plugins.js +7 -2
@@ -0,0 +1,210 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {createHmac} from 'node:crypto';
4
+ import {formatMoney, fromMinor, includedTax, percentOf, toMinor} from '../public/lib/money.js';
5
+ import {available, cleanProduct, priceRange, publicProduct, slugify, unitPrice} from '../public/lib/products.js';
6
+ import {canMove, cleanCheckout} from '../public/lib/orders.js';
7
+ import {cleanLines, priceBasket} from '../lib/pricing.js';
8
+ import {paypalPaid, stripeForm, stripeLineItems, stripeSessionPaid, stripeVerify} from '../lib/payments.js';
9
+ import {badge, summarise} from '../lib/summary.js';
10
+ import {cartRegistry} from '../lib/extensions.js';
11
+ import {matchShopPath} from '../plugin.public.js';
12
+ import {cleanPath, cleanSettings, MASK, maskSettings} from '../lib/shop.js';
13
+ import defaults from '../config.js';
14
+
15
+ test('money: minor units in, formatted out', () => {
16
+ assert.equal(toMinor('19.99'), 1999);
17
+ assert.equal(toMinor('£1,299.50'), 129950);
18
+ assert.equal(toMinor(12), 1200);
19
+ assert.equal(toMinor('1000', 'JPY'), 1000);
20
+ for (const bad of ['', 'abc', '-3', null, '1.2.3']) assert.equal(toMinor(bad), null, String(bad));
21
+ assert.equal(fromMinor(1999), '19.99');
22
+ assert.equal(formatMoney(1999, 'GBP'), '£19.99');
23
+ assert.equal(formatMoney(2000, 'GBP', {compact: true}), '£20');
24
+ assert.equal(percentOf(1000, 20), 200);
25
+ assert.equal(includedTax(12000, 20), 2000);
26
+ });
27
+
28
+ test('products: cleaned, slugged, variants and stock', () => {
29
+ assert.equal(slugify('Blue Mug (Large) & Saucer!'), 'blue-mug-large-and-saucer');
30
+ const {data, errors} = cleanProduct({name: 'Tee', price: 2000, trackStock: true,
31
+ variants: [{name: 'S', stock: 2}, {name: 'M', stock: 0, price: 2200}, {name: 's'}]});
32
+ assert.ok(errors.variant2, 'a duplicate option name is refused');
33
+ assert.equal(data.slug, 'tee');
34
+ assert.equal(data.variants.length, 2);
35
+ const p = {...data};
36
+ assert.equal(unitPrice(p, p.variants[1].id), 2200);
37
+ assert.equal(unitPrice(p, p.variants[0].id), 2000);
38
+ assert.equal(available(p, p.variants[0].id), 2);
39
+ assert.equal(available(p, p.variants[1].id), 0);
40
+ assert.deepEqual(priceRange(p), {min: 2000, max: 2200});
41
+ assert.equal(available({trackStock: false}), Infinity);
42
+ const pub = publicProduct({id: 'x', data: p});
43
+ assert.equal(pub.variants[0].available, 2);
44
+ assert.equal(cleanProduct({name: '', price: 'x'}, {minor: false}).errors.price, 'Not a price.');
45
+ assert.equal(cleanProduct({name: 'A', price: '12.50'}, {minor: false}).data.price, 1250);
46
+ assert.equal(cleanProduct({name: 'A', price: 1000, compareAt: 900}).data.compareAt, null, 'a "was" price below the price is dropped');
47
+ assert.deepEqual(cleanProduct({name: 'A', price: 1, images: ['javascript:alert(1)', '/media/a.jpg']}).data.images, ['/media/a.jpg']);
48
+ });
49
+
50
+ test('orders: moves, checkout cleaning', () => {
51
+ assert.ok(canMove('paid', 'fulfilled'));
52
+ assert.ok(!canMove('cancelled', 'paid'));
53
+ assert.ok(!canMove('pending', 'refunded'));
54
+ const bad = cleanCheckout({customer: {name: '', email: 'nope'}, address: {country: 'FR'}}, {countries: ['GB']});
55
+ assert.ok(bad.errors.name && bad.errors.email && bad.errors.line1 && bad.errors.country);
56
+ const ok = cleanCheckout({customer: {name: 'Ann', email: 'Ann@Example.com'}}, {needsAddress: false});
57
+ assert.deepEqual(ok.errors, {});
58
+ assert.equal(ok.customer.email, 'ann@example.com');
59
+ assert.equal(ok.address, null);
60
+ });
61
+
62
+ const catalogue = new Map([
63
+ ['p1', {id: 'p1', data: {name: 'Mug', status: 'active', price: 1000, trackStock: true, stock: 3, variants: [], shippable: true}}],
64
+ ['p2', {id: 'p2', data: {name: 'Ebook', status: 'active', price: 500, trackStock: false, variants: [], shippable: false}}],
65
+ ['p3', {id: 'p3', data: {name: 'Old', status: 'archived', price: 100, variants: []}}]
66
+ ]);
67
+ const S = {...defaults, currency: 'GBP'};
68
+
69
+ test('pricing: stock caps quantities, archived products drop out', async () => {
70
+ const lines = cleanLines([{productId: 'p1', qty: 2}, {productId: 'p1', qty: 3}, {productId: 'p3', qty: 1}, {productId: '../x', qty: 1}, {productId: 'p2', qty: 0}]);
71
+ assert.deepEqual(lines.map(l => [l.productId, l.qty]), [['p1', 5], ['p3', 1]]);
72
+ const q = await priceBasket({lines, products: catalogue, settings: S});
73
+ assert.equal(q.lines.length, 1);
74
+ assert.equal(q.lines[0].qty, 3, 'only three left');
75
+ assert.equal(q.subtotal, 3000);
76
+ assert.equal(q.problems.length, 2);
77
+ });
78
+
79
+ test('pricing: flat delivery, free over a threshold, collection, nothing to ship', async () => {
80
+ const s = {...S, shippingFlat: 395, freeShippingOver: 2500, allowCollection: true};
81
+ let q = await priceBasket({lines: [{productId: 'p1', variantId: '', qty: 1}], products: catalogue, settings: s});
82
+ assert.equal(q.shipping, 395);
83
+ assert.equal(q.total, 1395);
84
+ q = await priceBasket({lines: [{productId: 'p1', variantId: '', qty: 3}], products: catalogue, settings: s});
85
+ assert.equal(q.shipping, 0);
86
+ assert.equal(q.adjustments[0].label, 'Free delivery');
87
+ q = await priceBasket({lines: [{productId: 'p1', variantId: '', qty: 1}], products: catalogue, settings: s, input: {delivery: 'collect'}});
88
+ assert.equal(q.shipping, 0);
89
+ q = await priceBasket({lines: [{productId: 'p2', variantId: '', qty: 1}], products: catalogue, settings: s});
90
+ assert.equal(q.requiresShipping, false);
91
+ assert.equal(q.adjustments.length, 0);
92
+ });
93
+
94
+ test('pricing: tax inclusive shows, exclusive adds', async () => {
95
+ const lines = [{productId: 'p1', variantId: '', qty: 1}];
96
+ let q = await priceBasket({lines, products: catalogue, settings: {...S, taxRate: 20, pricesIncludeTax: true, shippingFlat: 200}});
97
+ assert.equal(q.total, 1200);
98
+ assert.equal(q.tax, 200, 'the VAT inside £12');
99
+ assert.ok(q.taxIncluded);
100
+ q = await priceBasket({lines, products: catalogue, settings: {...S, taxRate: 10, pricesIncludeTax: false, taxShipping: false, shippingFlat: 200}});
101
+ assert.equal(q.tax, 100);
102
+ assert.equal(q.total, 1300);
103
+ });
104
+
105
+ test('pricing: an extension replaces a stage; a discount never goes below nothing; a failure falls back', async () => {
106
+ const lines = [{productId: 'p1', variantId: '', qty: 1}];
107
+ let q = await priceBasket({lines, products: catalogue, settings: S, handlers: {discount: () => [{label: 'Huge', amount: -99999, code: 'X'}]}});
108
+ assert.equal(q.discount, -1000);
109
+ assert.equal(q.total, 0);
110
+ q = await priceBasket({lines, products: catalogue, settings: {...S, shippingFlat: 300}, handlers: {shipping: () => { throw new Error('boom'); }}});
111
+ assert.equal(q.shipping, 300, 'the built-in rate stands in');
112
+ assert.equal(q.internalNotes.length, 1);
113
+ assert.equal(q.notes.length, 0, 'the shopper is not told about our fault');
114
+ });
115
+
116
+ test('registry: stages, tabs, and emit that never throws', async () => {
117
+ const reg = cartRegistry();
118
+ reg.register('test-ext', {stages: {discount: () => []}, tabs: [{key: 'x', label: 'X', module: '/plugins/test-ext/admin/x.js'}, {key: 'bad', module: 'https://evil/x.js'}],
119
+ on: {paid: () => { throw new Error('nope'); }}, checkout: {codes: true}});
120
+ assert.equal(typeof reg.handlers().discount, 'function');
121
+ assert.deepEqual(reg.tabs().map(t => t.key), ['x']);
122
+ assert.deepEqual(reg.checkoutFeatures(), {codes: true});
123
+ reg.emit('paid', {});
124
+ await new Promise(r => setTimeout(r, 5));
125
+ reg.unregister('test-ext');
126
+ assert.equal(reg.list().length, 0);
127
+ });
128
+
129
+ test('stripe: form encoding, itemising, signature, paid check', () => {
130
+ assert.equal(stripeForm({a: {b: [{c: 1}]}, d: 'x', e: null}).toString(), 'a%5Bb%5D%5B0%5D%5Bc%5D=1&d=x');
131
+ const order = {id: 'o1', number: 1001, currency: 'GBP', total: 1395,
132
+ lines: [{name: 'Mug', variantName: '', unitPrice: 1000, qty: 1, image: ''}],
133
+ adjustments: [{stage: 'shipping', label: 'Delivery', amount: 395}, {stage: 'tax', label: 'Incl VAT', amount: 232, included: true}]};
134
+ assert.equal(stripeLineItems(order).length, 2, 'the mug and delivery; included tax is not a line');
135
+ assert.equal(stripeLineItems({...order, adjustments: [{stage: 'discount', label: 'X', amount: -100}]}).length, 1, 'discounted: one line');
136
+ const body = '{"x":1}';
137
+ const t = 1_700_000_000;
138
+ const sig = createHmac('sha256', 'whsec_test').update(`${t}.${body}`).digest('hex');
139
+ assert.ok(stripeVerify(Buffer.from(body), `t=${t},v1=${sig}`, 'whsec_test', 300, t + 10));
140
+ assert.ok(!stripeVerify(Buffer.from(body), `t=${t},v1=${sig}`, 'whsec_test', 300, t + 1000), 'too old');
141
+ assert.ok(!stripeVerify(Buffer.from('{"x":2}'), `t=${t},v1=${sig}`, 'whsec_test', 300, t));
142
+ const session = {payment_status: 'paid', client_reference_id: 'o1', currency: 'gbp', amount_total: 1395, id: 'cs_1', payment_intent: 'pi_1'};
143
+ assert.deepEqual(stripeSessionPaid(session, order), {ref: 'cs_1', payment: 'pi_1'});
144
+ assert.equal(stripeSessionPaid({...session, amount_total: 1}, order), null, 'the wrong amount is not a payment');
145
+ });
146
+
147
+ test('paypal: only a completed capture of the right amount counts', () => {
148
+ const order = {id: 'o1', currency: 'GBP', total: 1395};
149
+ const pp = {id: 'PP1', status: 'COMPLETED', purchase_units: [{reference_id: 'o1', payments: {captures: [{id: 'CAP1', status: 'COMPLETED', amount: {currency_code: 'GBP', value: '13.95'}}]}}]};
150
+ assert.deepEqual(paypalPaid(pp, order), {ref: 'PP1', payment: 'CAP1'});
151
+ assert.equal(paypalPaid({...pp, status: 'APPROVED'}, order), null);
152
+ assert.equal(paypalPaid(pp, {...order, total: 2000}), null);
153
+ });
154
+
155
+ test('storefront addresses', () => {
156
+ assert.deepEqual(matchShopPath('/shop', '/shop'), {name: 'index', params: {}});
157
+ assert.equal(matchShopPath('/shop/cart', '/shop').name, 'cart');
158
+ assert.deepEqual(matchShopPath('/shop/blue-mug', '/shop'), {name: 'product', params: {slug: 'blue-mug'}});
159
+ assert.deepEqual(matchShopPath('/shop/order/0f8c3a2e-1111', '/shop'), {name: 'order', params: {id: '0f8c3a2e-1111'}});
160
+ assert.equal(matchShopPath('/shopping', '/shop'), null);
161
+ assert.equal(matchShopPath('/shop/a/b/c', '/shop'), null);
162
+ assert.equal(cleanPath('Store/'), '/store');
163
+ assert.equal(cleanPath('/admin'), '/shop', 'never over the admin');
164
+ });
165
+
166
+ test('settings: secrets masked, a mask keeps the stored value, bad keys refused', () => {
167
+ const current = {...defaults, stripeSecretKey: 'sk_test_abcdefghijklmnop'};
168
+ assert.equal(maskSettings(current).stripeSecretKey, MASK);
169
+ assert.equal(maskSettings(current).paypalSecret, '');
170
+ let r = cleanSettings({stripeSecretKey: MASK, taxRate: '20'}, current);
171
+ assert.equal(r.settings.stripeSecretKey, current.stripeSecretKey);
172
+ assert.equal(r.settings.taxRate, 20);
173
+ r = cleanSettings({stripeSecretKey: 'pk_test_wrong', stripeEnabled: true}, current);
174
+ assert.ok(r.errors.stripeSecretKey);
175
+ r = cleanSettings({paypalEnabled: true}, current);
176
+ assert.ok(r.errors.paypalEnabled);
177
+ });
178
+
179
+ test('summary and badge', () => {
180
+ const now = Date.parse('2026-09-26T12:00:00Z');
181
+ const o = (status, total, daysAgo, extra = {}) => ({id: `${status}${total}`, meta: {createdAt: new Date(now - daysAgo * 864e5).toISOString()},
182
+ data: {status, total, number: 1000 + total, paidAt: ['paid', 'fulfilled'].includes(status) ? new Date(now - daysAgo * 864e5).toISOString() : undefined,
183
+ lines: [{productId: 'p1', name: 'Mug', qty: 1, lineTotal: total}], customer: {name: 'Ann'}, ...extra}});
184
+ const orders = [o('paid', 500, 0), o('fulfilled', 1500, 3), o('pending', 900, 1), o('refunded', 700, 2), o('paid', 100, 40)];
185
+ const s = summarise(orders, [{id: 'p1', data: {status: 'active', trackStock: true, stock: 1, name: 'Mug'}}], {...S, lowStockAt: 3}, now);
186
+ assert.equal(s.days30.revenue, 2000);
187
+ assert.equal(s.days30.orders, 2);
188
+ assert.equal(s.toFulfil, 2);
189
+ assert.equal(s.abandoned, 1);
190
+ assert.equal(s.lowStockCount, 1);
191
+ assert.equal(s.series.length, 30);
192
+ const b = badge(orders, S, now);
193
+ assert.equal(b.count, 2);
194
+ assert.equal(b.tone, 'danger', 'one has waited 40 days');
195
+ });
196
+
197
+ test('pricing: a shipping handler can offer services to choose between', async () => {
198
+ const lines = [{productId: 'p1', variantId: '', qty: 1}];
199
+ const shipping = ({input}) => {
200
+ const choices = [{id: 'std', label: 'Standard', amount: 300}, {id: 'exp', label: 'Express', amount: 900}];
201
+ const pick = choices.find(c => c.id === input.service) || choices[0];
202
+ return {adjustments: [{label: pick.label, amount: pick.amount, ref: pick.id}], choices};
203
+ };
204
+ let q = await priceBasket({lines, products: catalogue, settings: S, handlers: {shipping}});
205
+ assert.deepEqual(q.shippingChoices.map(c => c.id), ['std', 'exp']);
206
+ assert.equal(q.shippingChoice, 'std');
207
+ q = await priceBasket({lines, products: catalogue, settings: S, input: {service: 'exp'}, handlers: {shipping}});
208
+ assert.equal(q.shipping, 900);
209
+ assert.equal(q.shippingChoice, 'exp');
210
+ });
package/scripts/setup.js CHANGED
@@ -46,6 +46,7 @@ const THEMES = [
46
46
  'unicorn-dark', 'unicorn-light',
47
47
  'dreamy-dark', 'dreamy-light',
48
48
  'wedding-dark', 'wedding-light',
49
+ 'corporate-dark', 'corporate-light',
49
50
  ];
50
51
 
51
52
  const SERVER_CONFIG = path.join(ROOT, 'config', 'server.json');
@@ -116,9 +116,10 @@ export function notificationsBadge(active = []) {
116
116
  *
117
117
  * @param {Date} now
118
118
  * @param {string} origin - the site's own address, for the one sample with a link
119
+ * @param {string|null} [forUser] - the id of whoever asked: only they see them
119
120
  * @returns {object[]} entries, `{id, data, meta}`
120
121
  */
121
- export function sampleNotifications(now = new Date(), origin = '') {
122
+ export function sampleNotifications(now = new Date(), origin = '', forUser = null) {
122
123
  const expiresAt = new Date(now.getTime() + 7 * 86_400_000).toISOString();
123
124
  const note = `\n\n(A sample - it goes by itself on ${expiresAt.slice(0, 10)}.)`;
124
125
  const samples = [
@@ -138,7 +139,7 @@ export function sampleNotifications(now = new Date(), origin = '') {
138
139
  return {
139
140
  id: randomUUID(),
140
141
  data: {title: x.title, body: x.body + note, severity: x.severity, source: 'sample', link: x.link || null,
141
- createdAt: at, expiresAt, readBy: [], dismissedBy: []},
142
+ createdAt: at, expiresAt, readBy: [], dismissedBy: [], audience: forUser ? {userIds: [String(forUser)]} : null},
142
143
  meta: {createdAt: at, updatedAt: at, source: 'sample'}
143
144
  };
144
145
  });
@@ -354,7 +355,7 @@ export async function notificationsRoutes(fastify, opts = {}) {
354
355
  fastify.post('/system/notifications/samples', canDelete, async (request, reply) => {
355
356
  const adapter = await getAdapter(SLUG);
356
357
  const origin = `${request.protocol}://${request.host || request.hostname}`;
357
- const made = sampleNotifications(new Date(), origin);
358
+ const made = sampleNotifications(new Date(), origin, request.user?.id);
358
359
  // A second click replaces the samples rather than stacking another set.
359
360
  for (const e of await adapter.all(SLUG)) if (e?.data?.source === 'sample') await adapter.remove(SLUG, e.id);
360
361
  for (const entry of made) await adapter.insert(SLUG, entry);
@@ -64,13 +64,15 @@ export function cleanLink(link) {
64
64
 
65
65
  /**
66
66
  * May this user see a notification with this audience. None (manager pushes,
67
- * everything before 0.80) is everyone who can read notifications, as before.
67
+ * everything before 0.80) is admins and up: roles a plugin gives the bell to
68
+ * (Job Board's company, agent and candidate - people from outside, who may have
69
+ * signed themselves up) must never see what was meant for the site's staff.
68
70
  *
69
71
  * @param {object|null|undefined} audience - {roles: string[]} | {userIds: string[]}
70
72
  * @param {object} user - request.user: {id, role, additionalRoles}
71
73
  */
72
74
  export function inAudience(audience, user) {
73
- if (!audience || typeof audience !== 'object') return true;
75
+ if (!audience || typeof audience !== 'object') audience = AUDIENCES.admins;
74
76
  if (Array.isArray(audience.userIds)) return audience.userIds.includes(user?.id);
75
77
  if (Array.isArray(audience.roles)) {
76
78
  const held = getEffectiveRoles(user || {});
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Plugin APIs and the admin scope.
3
+ *
4
+ * A role can confine its holders to a few admin screens (`role.admin =
5
+ * {home, allow}` - Job Board's company, agent and candidate roles, say, which
6
+ * people outside the business can sign themselves up for). The admin's router
7
+ * keeps them on those screens (admin/js/lib/admin-scope.js), but that is the
8
+ * browser: every plugin's API was open to ANY signed-in user whose route only
9
+ * asked `authenticate`, and most plugins' write routes asked nothing more. So
10
+ * a job seeker could publish, edit or delete blog posts through the API.
11
+ *
12
+ * The rule here: a user whose roles confine them may call a plugin's
13
+ * authenticated routes only if their scope admits at least one of that
14
+ * plugin's admin screens. An unconfined user (every staff role, the level-0
15
+ * role) is untouched, and so is every route that does not authenticate (the
16
+ * public side). Core's own APIs are guarded by permissions, as before.
17
+ *
18
+ * Pure except for the role lookup, which is passed in.
19
+ *
20
+ * @module services/pluginScope
21
+ */
22
+
23
+ /** Does an allowed prefix cover a path? The admin's own rule (admin-scope.js). */
24
+ export function covers(allowed, path) {
25
+ if (allowed === '/') return path === '/';
26
+ return path === allowed || path.startsWith(`${allowed}/`);
27
+ }
28
+
29
+ /**
30
+ * The admin screens a plugin owns: its routes, and where its sidebar entries
31
+ * point (`#/plugins/blog` -> `/plugins/blog`).
32
+ *
33
+ * @param {object} manifest - plugin.json
34
+ * @returns {string[]}
35
+ */
36
+ export function pluginAdminPaths(manifest) {
37
+ const admin = manifest?.admin || {};
38
+ const paths = [];
39
+ for (const r of Array.isArray(admin.routes) ? admin.routes : []) if (typeof r?.path === 'string') paths.push(r.path);
40
+ const walk = (items) => {
41
+ for (const it of Array.isArray(items) ? items : []) {
42
+ for (const u of [it?.url, it?.section]) if (typeof u === 'string' && u.startsWith('#/')) paths.push(u.slice(1));
43
+ walk(it?.children);
44
+ }
45
+ };
46
+ walk(admin.sidebar);
47
+ return [...new Set(paths.map(p => p.split('?')[0].replace(/\/:[^/]+/g, '').replace(/\/+$/, '') || '/'))];
48
+ }
49
+
50
+ /**
51
+ * May a user with this admin scope call this plugin's authenticated API?
52
+ *
53
+ * @param {{home: string, allow: string[]}|null} scope - null = the whole admin
54
+ * @param {object} manifest
55
+ * @returns {boolean}
56
+ */
57
+ export function pluginInScope(scope, manifest) {
58
+ if (!scope) return true;
59
+ const allow = Array.isArray(scope.allow) ? scope.allow : [];
60
+ return pluginAdminPaths(manifest).some(p => allow.some(a => covers(a, p)));
61
+ }
62
+
63
+ /**
64
+ * The `authenticate` a plugin is handed: core's, then the scope rule.
65
+ *
66
+ * @param {Function} authenticate - core's preHandler
67
+ * @param {object} manifest
68
+ * @param {(user: object) => ({home: string, allow: string[]}|null)} scopeOf - the user's admin scope
69
+ * @returns {Function} a preHandler
70
+ */
71
+ export function authenticateFor(authenticate, manifest, scopeOf) {
72
+ return async function authenticateInScope(request, reply) {
73
+ const out = await authenticate(request, reply);
74
+ if (reply.sent) return out;
75
+ let scope = null;
76
+ try { scope = scopeOf(request.user); } catch { scope = null; }
77
+ if (!pluginInScope(scope, manifest)) {
78
+ return reply.code(403).send({
79
+ statusCode: 403, error: 'Forbidden',
80
+ message: `Your account cannot use ${manifest?.displayName || manifest?.name || 'this plugin'}.`
81
+ });
82
+ }
83
+ return out;
84
+ };
85
+ }
@@ -33,7 +33,9 @@ import {classifyEntitlement, mayLoad} from './pluginEntitlement.js';
33
33
  import {notifierFor} from './notify.js';
34
34
  import {createCollection, getCollection} from './collections.js';
35
35
  import * as defaultRolesService from './roles.js';
36
- import {normaliseAdminScope} from './roles.js';
36
+ import {getAdminScope, normaliseAdminScope} from './roles.js';
37
+ import {authenticateFor} from './pluginScope.js';
38
+ import {getEffectiveRoles} from './userRoles.js';
37
39
  import * as defaultUsersService from './users.js';
38
40
  import {registerLoginCheck, registerPasswordCheck} from './authChecks.js';
39
41
 
@@ -473,7 +475,10 @@ export async function registerPlugins(fastify) {
473
475
  const prefix = `/api/plugins/${manifest.name}`;
474
476
  await fastify.register(plugin, {
475
477
  prefix,
476
- auth: {authenticate, requireRole, requireAdmin, requireVisibility, requirePermission},
478
+ // A user confined to some admin screens (role.admin) may call this plugin's
479
+ // authenticated routes only if one of its screens is among them - see pluginScope.js.
480
+ auth: {authenticate: authenticateFor(authenticate, manifest, (user) => getAdminScope(getEffectiveRoles(user || {}))),
481
+ requireRole, requireAdmin, requireVisibility, requirePermission},
477
482
  hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation,
478
483
  // Stamped with the plugin's name, so the Menus editor can say where a Tool came from.
479
484
  registerSidebarItem: (opts) => registerSidebarItem({...opts, item: {source: manifest.displayName || manifest.name, ...opts?.item}}),