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.
- package/README.md +4 -4
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/themes.js +1 -1
- package/config/menus/admin-sidebar.json +21 -8
- package/config/plugins.json +0 -18
- package/package.json +2 -2
- package/plugins/_lib/mail/diagnostics.js +3 -2
- package/plugins/blog/CLAUDE.md +33 -0
- package/plugins/blog/lib/access.js +64 -0
- package/plugins/blog/lib/notices.js +38 -0
- package/plugins/blog/plugin.js +84 -26
- package/plugins/blog/plugin.json +173 -20
- package/plugins/blog/roles/blog-author.json +15 -2
- package/plugins/blog/tests/access.test.js +32 -0
- package/plugins/blog/tests/comment-notices.test.js +140 -0
- package/plugins/blog/tests/notices.test.js +20 -0
- package/plugins/blog/tests/public.test.js +3 -1
- package/plugins/free-tier.lock.json +63 -10
- package/plugins/mail-reader/plugin.js +12 -1
- package/plugins/mail-reader/plugin.json +23 -4
- package/plugins/shopping-cart/CLAUDE.md +93 -0
- package/plugins/shopping-cart/admin/css/index.css +224 -0
- package/plugins/shopping-cart/admin/lib/kit.js +190 -0
- package/plugins/shopping-cart/admin/templates/shop.html +1 -0
- package/plugins/shopping-cart/admin/views/orders.js +365 -0
- package/plugins/shopping-cart/admin/views/overview.js +214 -0
- package/plugins/shopping-cart/admin/views/products.js +547 -0
- package/plugins/shopping-cart/admin/views/settings.js +247 -0
- package/plugins/shopping-cart/admin/views/shop.js +130 -0
- package/plugins/shopping-cart/collections/shop-orders.json +20 -0
- package/plugins/shopping-cart/collections/shop-products.json +24 -0
- package/plugins/shopping-cart/config.js +52 -0
- package/plugins/shopping-cart/inject/body.html +2 -0
- package/plugins/shopping-cart/lib/extensions.js +96 -0
- package/plugins/shopping-cart/lib/payments.js +281 -0
- package/plugins/shopping-cart/lib/pricing.js +232 -0
- package/plugins/shopping-cart/lib/render.js +282 -0
- package/plugins/shopping-cart/lib/samples.js +34 -0
- package/plugins/shopping-cart/lib/shop.js +436 -0
- package/plugins/shopping-cart/lib/summary.js +127 -0
- package/plugins/shopping-cart/plugin.js +559 -0
- package/plugins/shopping-cart/plugin.json +52 -0
- package/plugins/shopping-cart/plugin.public.js +144 -0
- package/plugins/shopping-cart/public/cart.js +715 -0
- package/plugins/shopping-cart/public/lib/money.js +105 -0
- package/plugins/shopping-cart/public/lib/orders.js +122 -0
- package/plugins/shopping-cart/public/lib/products.js +235 -0
- package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
- package/plugins/shopping-cart/public/samples/candle.svg +16 -0
- package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/mug.svg +14 -0
- package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
- package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
- package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/print.svg +14 -0
- package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
- package/plugins/shopping-cart/public/samples/tee.svg +12 -0
- package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
- package/plugins/shopping-cart/public/samples/tote.svg +13 -0
- package/plugins/shopping-cart/public/shop.css +415 -0
- package/plugins/shopping-cart/templates/shop.html +4 -0
- package/plugins/shopping-cart/tests/api.test.js +144 -0
- package/plugins/shopping-cart/tests/pure.test.js +210 -0
- package/scripts/setup.js +1 -0
- package/server/routes/api/notifications.js +4 -3
- package/server/services/notify.js +4 -2
- package/server/services/pluginScope.js +85 -0
- 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
|
@@ -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
|
|
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')
|
|
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
|
-
|
|
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}}),
|