domma-cms 1.6.34 → 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 (106) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/lib/effects-builder.js +1 -1
  3. package/admin/js/lib/pages-arrange.js +1 -1
  4. package/admin/js/lib/scribe-composer.js +1 -1
  5. package/admin/js/lib/scribe-model.js +1 -1
  6. package/admin/js/lib/shortcode-preview.js +1 -1
  7. package/admin/js/lib/shortcode-registry.js +2 -2
  8. package/admin/js/templates/docs/usage-site-settings.html +2 -1
  9. package/admin/js/templates/page-editor.html +2 -1
  10. package/admin/js/templates/pages.html +1 -1
  11. package/admin/js/templates/theme.html +1 -1
  12. package/admin/js/views/pages.js +3 -3
  13. package/admin/js/views/theme.js +5 -5
  14. package/package.json +1 -1
  15. package/plugins/_lib/call/ice.js +5 -2
  16. package/plugins/_lib/call/rooms.js +9 -3
  17. package/plugins/_lib/call/routes.js +7 -4
  18. package/plugins/_lib/mail/defaults.js +12 -0
  19. package/plugins/_lib/mail/hostGuard.js +86 -0
  20. package/plugins/_lib/mail/readRoutes.js +20 -1
  21. package/plugins/_lib/outreach/audience.js +76 -0
  22. package/plugins/_lib/outreach/collections/outreach-contacts.schema.json +12 -0
  23. package/plugins/_lib/outreach/collections/outreach-groups.schema.json +10 -0
  24. package/plugins/_lib/outreach/collections/outreach-unsubscribes.schema.json +11 -0
  25. package/plugins/_lib/outreach/expiry.js +46 -0
  26. package/plugins/_lib/outreach/index.js +15 -0
  27. package/plugins/_lib/outreach/ledger.js +127 -0
  28. package/plugins/_lib/outreach/sending.js +121 -0
  29. package/plugins/_lib/outreach/suppression.js +62 -0
  30. package/plugins/_lib/outreach/tokens.js +35 -0
  31. package/plugins/_lib/outreach/unsubscribe.js +46 -0
  32. package/plugins/_lib/public/call/call.js +3 -1
  33. package/plugins/blog/CLAUDE.md +11 -1
  34. package/plugins/blog/docs/guide.md +1 -1
  35. package/plugins/blog/lib/page.js +32 -0
  36. package/plugins/blog/plugin.js +58 -16
  37. package/plugins/blog/plugin.json +2 -2
  38. package/plugins/blog/plugin.public.js +5 -4
  39. package/plugins/blog/tests/public.test.js +30 -1
  40. package/plugins/booking/CLAUDE.md +4 -1
  41. package/plugins/booking/docs/guide.md +1 -1
  42. package/plugins/booking/plugin.js +16 -21
  43. package/plugins/booking/plugin.json +2 -2
  44. package/plugins/booking/tests/api.test.js +26 -1
  45. package/plugins/feedback/CLAUDE.md +7 -2
  46. package/plugins/feedback/admin/views/feedback.js +1 -1
  47. package/plugins/feedback/lib/receiver.js +1 -1
  48. package/plugins/feedback/plugin.json +3 -3
  49. package/plugins/free-tier.lock.json +63 -51
  50. package/plugins/mail-reader/CLAUDE.md +6 -0
  51. package/plugins/mail-reader/docs/guide.md +1 -0
  52. package/plugins/mail-reader/plugin.json +2 -2
  53. package/plugins/shopping-cart/CLAUDE.md +62 -1
  54. package/plugins/shopping-cart/admin/css/index.css +16 -0
  55. package/plugins/shopping-cart/admin/lib/chart.js +64 -0
  56. package/plugins/shopping-cart/admin/lib/kit.js +15 -0
  57. package/plugins/shopping-cart/admin/views/orders.js +1 -0
  58. package/plugins/shopping-cart/admin/views/overview.js +4 -53
  59. package/plugins/shopping-cart/admin/views/sales.js +133 -0
  60. package/plugins/shopping-cart/admin/views/settings.js +3 -1
  61. package/plugins/shopping-cart/admin/views/shop.js +2 -1
  62. package/plugins/shopping-cart/config.js +1 -0
  63. package/plugins/shopping-cart/docs/guide.md +26 -1
  64. package/plugins/shopping-cart/inject/body.html +2 -2
  65. package/plugins/shopping-cart/lib/accounts.js +185 -0
  66. package/plugins/shopping-cart/lib/commission.js +90 -0
  67. package/plugins/shopping-cart/lib/extensions.js +12 -4
  68. package/plugins/shopping-cart/lib/payments.js +7 -4
  69. package/plugins/shopping-cart/lib/render.js +13 -1
  70. package/plugins/shopping-cart/lib/sales.js +193 -0
  71. package/plugins/shopping-cart/lib/shop.js +21 -3
  72. package/plugins/shopping-cart/plugin.js +180 -35
  73. package/plugins/shopping-cart/plugin.json +3 -3
  74. package/plugins/shopping-cart/plugin.public.js +17 -1
  75. package/plugins/shopping-cart/public/account.js +277 -0
  76. package/plugins/shopping-cart/public/cart.js +16 -0
  77. package/plugins/shopping-cart/public/lib/session.js +82 -0
  78. package/plugins/shopping-cart/public/shop.css +27 -0
  79. package/plugins/shopping-cart/roles/shop-customer.json +8 -0
  80. package/plugins/shopping-cart/tests/accounts.test.js +136 -0
  81. package/plugins/shopping-cart/tests/api.test.js +34 -0
  82. package/plugins/shopping-cart/tests/commission.test.js +90 -0
  83. package/plugins/shopping-cart/tests/pure.test.js +16 -1
  84. package/plugins/shopping-cart/tests/sales.test.js +73 -0
  85. package/plugins/support/CLAUDE.md +6 -0
  86. package/plugins/support/admin/views/support.js +1 -1
  87. package/plugins/support/docs/guide.md +90 -0
  88. package/plugins/support/lib/receiver.js +1 -1
  89. package/plugins/support/plugin.json +3 -3
  90. package/public/css/site.css +1 -1
  91. package/public/js/effects.js +1 -1
  92. package/public/js/scribe-style.mjs +1 -1
  93. package/public/js/site.js +1 -1
  94. package/public/js/text-style.mjs +1 -1
  95. package/server/routes/api/analytics.js +32 -0
  96. package/server/routes/api/collections.js +148 -15
  97. package/server/routes/api/theme.js +12 -2
  98. package/server/routes/outreach-public.js +68 -0
  99. package/server/server.js +10 -1
  100. package/server/services/authChecks.js +8 -0
  101. package/server/services/collectionAccess.js +191 -0
  102. package/server/services/collections.js +8 -2
  103. package/server/services/markdown.js +7 -2
  104. package/server/services/outreachUnsubscribe.js +54 -0
  105. package/server/services/request-log.js +70 -0
  106. package/server/templates/page.html +3 -3
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared expiry state: a thing that closes by a DATE or by a USAGE TALLY,
3
+ * whichever is reached first. The vocabulary is deliberately the same as
4
+ * Cart Pro's discount codes (shopping-cart-pro/lib/codes.js):
5
+ *
6
+ * {active, startsAt, endsAt, limit, uses}
7
+ *
8
+ * Dates are 'YYYY-MM-DD', inclusive, '' for open; `limit` is null for
9
+ * unlimited; `uses` is the server's counter. The two modules must never
10
+ * disagree about what "expired" means - change one, change both.
11
+ *
12
+ * One deliberate difference: an entry that never carried `active` (a
13
+ * campaign offer, say) is treated as active; Cart Pro's cleanCode always
14
+ * writes the field, so its rows never hit that branch.
15
+ *
16
+ * @module _lib/outreach/expiry
17
+ */
18
+
19
+ /** 'YYYY-MM-DD' for a time, in the server's zone. */
20
+ export const dayOf = (t) => {
21
+ const d = new Date(t);
22
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
23
+ };
24
+
25
+ /**
26
+ * Live, scheduled, ended, used up or paused - for state chips and refusals.
27
+ *
28
+ * @param {{active?: boolean, startsAt?: string, endsAt?: string, limit?: number|null, uses?: number}} c
29
+ * @param {number} [now]
30
+ * @returns {'paused'|'scheduled'|'ended'|'used'|'live'}
31
+ */
32
+ export function codeState(c, now = Date.now()) {
33
+ const today = dayOf(now);
34
+ const active = c.active === undefined ? true : Boolean(c.active);
35
+ if (!active) return 'paused';
36
+ if (c.startsAt && today < c.startsAt) return 'scheduled';
37
+ if (c.endsAt && today > c.endsAt) return 'ended';
38
+ if (c.limit !== null && c.limit !== undefined && (Number(c.uses) || 0) >= c.limit) return 'used';
39
+ return 'live';
40
+ }
41
+
42
+ /** Why a thing is not live now, or null when it is. */
43
+ export function closedReason(c, now = Date.now()) {
44
+ const state = codeState(c, now);
45
+ return state === 'live' ? null : state;
46
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The outreach library: the audience, token, ledger, sending, suppression
3
+ * and expiry plumbing shared by every Tool that emails people and follows
4
+ * up (Surveys, Campaigns, Onboard). See each module's header; collection
5
+ * schemas the consuming plugins copy are in ./collections.
6
+ *
7
+ * @module _lib/outreach
8
+ */
9
+ export {generateToken, hashToken, formatCode, normaliseCode} from './tokens.js';
10
+ export {createAudience} from './audience.js';
11
+ export {createLedger} from './ledger.js';
12
+ export {createSuppression, normaliseEmail} from './suppression.js';
13
+ export {escapeHtml, escapeAttr, outreachBaseUrl, renderInvite, sendBatch} from './sending.js';
14
+ export {dayOf, codeState, closedReason} from './expiry.js';
15
+ export {UNSUBSCRIBE_PATH, mintSecret, signEmail, unsubscribeUrl, verifyUnsubscribe} from './unsubscribe.js';
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The outreach send ledger: one row per (thing, contact) with a hashed
3
+ * per-recipient token and a status - the record that makes sends resumable
4
+ * (the row is written before the email goes) and reminders targetable.
5
+ *
6
+ * Generic over what is being sent: `refField` names the column tying a row
7
+ * to its campaign/survey/journey. Surveys passes
8
+ * `{slug: 'surveys-invites', refField: 'surveyId', source: 'surveys'}` so its stored data is
9
+ * unchanged. Lifted from Surveys 1.4 (lib/ledger.js).
10
+ *
11
+ * Statuses: pending -> sent -> completed, or bounced (any send failure,
12
+ * including a suppressed address - deliberately excluded from reminders).
13
+ *
14
+ * @module _lib/outreach/ledger
15
+ */
16
+ import {generateToken, hashToken, normaliseCode} from './tokens.js';
17
+
18
+ /**
19
+ * @param {{createEntry,listEntries,getEntry,updateEntry}} col collections service
20
+ * @param {{slug?: string, refField?: string}} [opts]
21
+ */
22
+ export function createLedger(col, {slug = 'outreach-invites', refField = 'ref', source = 'outreach'} = {}) {
23
+ async function allInvites() {
24
+ const {entries} = await col.listEntries(slug, {limit: 100000});
25
+ return entries;
26
+ }
27
+
28
+ async function invitesFor(refId) {
29
+ return (await allInvites()).filter(e => e.data[refField] === refId);
30
+ }
31
+
32
+ /** Issue pending invites for contacts that don't already have one. Returns [{id, contactId, token}]. */
33
+ async function issueInvites(refId, contactIds) {
34
+ const existing = await invitesFor(refId);
35
+ const seen = new Set(existing.map(e => e.data.contactId));
36
+ const out = [];
37
+ for (const contactId of contactIds) {
38
+ if (seen.has(contactId)) continue;
39
+ const token = generateToken();
40
+ const entry = await col.createEntry(slug, {
41
+ [refField]: refId, contactId,
42
+ tokenHash: hashToken(normaliseCode(token)),
43
+ status: 'pending', sentAt: null, completedAt: null, responseId: null
44
+ }, {source});
45
+ out.push({id: entry.id, contactId, token});
46
+ seen.add(contactId);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * Ensure every listed contact has a FRESH token to email. New contacts get
53
+ * a created invite; existing non-completed invites are re-issued with a
54
+ * new token (so re-sends and reminders carry a working personalised link);
55
+ * contacts who already completed are skipped. The previous token for a
56
+ * re-issued invite stops working - latest link wins.
57
+ *
58
+ * Returns [{id, contactId, token}] for the invites to email.
59
+ */
60
+ async function issueOrReissue(refId, contactIds) {
61
+ const existing = await invitesFor(refId);
62
+ const byContact = new Map(existing.map(e => [e.data.contactId, e]));
63
+ const out = [];
64
+ for (const contactId of contactIds) {
65
+ const ex = byContact.get(contactId);
66
+ if (ex && ex.data.status === 'completed') continue;
67
+ const token = generateToken();
68
+ const tokenHash = hashToken(normaliseCode(token));
69
+ if (ex) {
70
+ await col.updateEntry(slug, ex.id, {...ex.data, tokenHash});
71
+ out.push({id: ex.id, contactId, token});
72
+ } else {
73
+ const entry = await col.createEntry(slug, {
74
+ [refField]: refId, contactId, tokenHash,
75
+ status: 'pending', sentAt: null, completedAt: null, responseId: null
76
+ }, {source});
77
+ out.push({id: entry.id, contactId, token});
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /** Find an invite by submitted code (any format). Null if unknown. */
84
+ async function findByCode(refId, code) {
85
+ const hash = hashToken(normaliseCode(code));
86
+ const invites = await invitesFor(refId);
87
+ return invites.find(e => e.data.tokenHash === hash) ?? null;
88
+ }
89
+
90
+ /** Mark sent / bounced. */
91
+ async function markSent(inviteId, ok, nowIso) {
92
+ const entry = await col.getEntry(slug, inviteId);
93
+ if (!entry) return null;
94
+ return col.updateEntry(slug, inviteId, {
95
+ ...entry.data,
96
+ status: ok ? 'sent' : 'bounced',
97
+ sentAt: ok ? nowIso : entry.data.sentAt
98
+ });
99
+ }
100
+
101
+ /** Mark completed with the stored response id. */
102
+ async function markCompleted(inviteId, responseId, nowIso) {
103
+ const entry = await col.getEntry(slug, inviteId);
104
+ if (!entry) return null;
105
+ return col.updateEntry(slug, inviteId, {
106
+ ...entry.data, status: 'completed', completedAt: nowIso, responseId: responseId ?? null
107
+ });
108
+ }
109
+
110
+ /** Invites that are neither completed nor bounced. */
111
+ async function outstanding(refId) {
112
+ const invites = await invitesFor(refId);
113
+ return invites.filter(e => e.data.status !== 'completed' && e.data.status !== 'bounced');
114
+ }
115
+
116
+ /** Sent invites, not yet completed, whose sentAt is older than afterDays before now. */
117
+ async function dueForReminder(refId, afterDays, now) {
118
+ const cutoff = now.getTime() - Number(afterDays) * 86400000;
119
+ const invites = await invitesFor(refId);
120
+ return invites.filter(e =>
121
+ e.data.status === 'sent' &&
122
+ e.data.sentAt && new Date(e.data.sentAt).getTime() <= cutoff
123
+ );
124
+ }
125
+
126
+ return {allInvites, invitesFor, issueInvites, issueOrReissue, findByCode, markSent, markCompleted, outstanding, dueForReminder, slug, refField};
127
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Outreach email rendering and batch sending. Lifted from Surveys 1.4
3
+ * (lib/sending.js), generalised: the per-recipient link and the button label
4
+ * are the caller's, and a suppression check can sit in front of every send.
5
+ *
6
+ * @module _lib/outreach/sending
7
+ */
8
+ import {normaliseEmail} from './suppression.js';
9
+
10
+ export function escapeHtml(s) {
11
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
12
+ }
13
+
14
+ /** Escape a value for use inside a double-quoted HTML attribute (e.g. href). */
15
+ export function escapeAttr(s) {
16
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
17
+ }
18
+
19
+ /** 'https://example.com' from a configured address, or '' when it is not one. */
20
+ function origin(value) {
21
+ const v = String(value ?? '').trim().replace(/\/+$/, '');
22
+ return /^https?:\/\/[^\s/?#]+$/i.test(v) ? v : '';
23
+ }
24
+
25
+ /**
26
+ * Where the links in outreach emails point: the Site URL setting
27
+ * (site.baseUrl, the address core builds password-reset, canonical and
28
+ * sitemap links on), else the old `site.url`, else the address this request
29
+ * came in on.
30
+ *
31
+ * @param {object} site - getConfig('site')
32
+ * @param {{protocol?: string, host?: string, headers?: object}} [req]
33
+ * @returns {string} origin, no trailing slash
34
+ */
35
+ export function outreachBaseUrl(site, req) {
36
+ const configured = origin(site?.baseUrl) || origin(site?.url);
37
+ if (configured) return configured;
38
+ const host = req?.host || req?.headers?.host || '';
39
+ return host ? `${req?.protocol || 'http'}://${host}` : '';
40
+ }
41
+
42
+ /**
43
+ * Render one message's subject + plain-text + HTML from a template and a link.
44
+ *
45
+ * The HTML version turns each {{link}} into a clickable button; all non-link
46
+ * copy is escaped so user-authored bodies can't inject markup. The plain-text
47
+ * version keeps the raw URL. An optional `footerHtml` (already-safe markup -
48
+ * an unsubscribe line) is appended inside the wrapper.
49
+ *
50
+ * @param {{subject?: string, body?: string}} template
51
+ * @param {{name?: string}} contact
52
+ * @param {string} link
53
+ * @param {{buttonLabel?: string, defaultSubject?: string, defaultBody?: string, footerHtml?: string, footerText?: string}} [opts]
54
+ */
55
+ export function renderInvite(template, contact, link, opts = {}) {
56
+ const {
57
+ buttonLabel = 'Open the link',
58
+ defaultSubject = 'A message for you',
59
+ defaultBody = "Hi {{name}},\n\n{{link}}",
60
+ footerHtml = '',
61
+ footerText = ''
62
+ } = opts;
63
+ const name = contact.name || 'there';
64
+ const subjectTpl = (template.subject && template.subject.trim()) || defaultSubject;
65
+ const bodyTpl = (template.body && template.body.trim()) || defaultBody;
66
+
67
+ const subject = subjectTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link);
68
+ const text = bodyTpl.replaceAll('{{name}}', name).replaceAll('{{link}}', link)
69
+ + (footerText ? `\n\n${footerText}` : '');
70
+
71
+ const button = `<p style="margin:24px 0;"><a href="${escapeAttr(link)}" `
72
+ + `style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;`
73
+ + `padding:12px 24px;border-radius:6px;font-weight:600;font-size:15px;">${escapeHtml(buttonLabel)}</a></p>`;
74
+
75
+ const bodyHtml = bodyTpl
76
+ .split('{{link}}')
77
+ .map((seg) => escapeHtml(seg.replaceAll('{{name}}', name)).replace(/\n/g, '<br>'))
78
+ .join(button);
79
+
80
+ const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;`
81
+ + `max-width:560px;margin:0 auto;padding:24px;color:#1f2937;line-height:1.6;font-size:15px;">`
82
+ + bodyHtml
83
+ + `<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">`
84
+ + `<p style="font-size:12px;color:#6b7280;">If the button doesn't work, paste this link into your browser:<br>`
85
+ + `<a href="${escapeAttr(link)}" style="color:#2563eb;">${escapeHtml(link)}</a></p>`
86
+ + (footerHtml || '')
87
+ + `</div>`;
88
+
89
+ return {subject, text, html};
90
+ }
91
+
92
+ /**
93
+ * Send to each recipient; never fails wholesale. A recipient on the
94
+ * suppression set is not sent to at all and comes back
95
+ * `{inviteId, ok: false, suppressed: true}` - the caller's ledger treats
96
+ * that like a bounce, which keeps them out of reminders.
97
+ *
98
+ * @param {Array<{invite: {id: string}, contact: {name?: string, email: string}, token: string}>} recipients
99
+ * @param {{subject?: string, body?: string}} template
100
+ * @param {object} deps {sendEmail, transport, from, fromName, linkFor(recipient), suppressed?: Set<string>, render?: opts for renderInvite}
101
+ * @returns {Promise<Array<{inviteId: string, ok: boolean, suppressed?: true}>>}
102
+ */
103
+ export async function sendBatch(recipients, template, deps) {
104
+ const {sendEmail, transport, from, fromName, linkFor, suppressed = null, render = {}} = deps;
105
+ const results = [];
106
+ for (const r of recipients) {
107
+ if (suppressed && suppressed.has(normaliseEmail(r.contact.email))) {
108
+ results.push({inviteId: r.invite.id, ok: false, suppressed: true});
109
+ continue;
110
+ }
111
+ const link = linkFor(r);
112
+ const msg = renderInvite(template, r.contact, link, render);
113
+ try {
114
+ await sendEmail(transport, {from, fromName, to: r.contact.email, subject: msg.subject, html: msg.html, text: msg.text});
115
+ results.push({inviteId: r.invite.id, ok: true});
116
+ } catch {
117
+ results.push({inviteId: r.invite.id, ok: false});
118
+ }
119
+ }
120
+ return results;
121
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The shared suppression list: addresses that must not be emailed by any
3
+ * outreach Tool. One opt-out silences them all - marketing email without a
4
+ * working unsubscribe is not shippable (PECR), and a surveys opt-out should
5
+ * silence campaigns too. Rows live in the shared `outreach-unsubscribes`
6
+ * collection ({email, at, source}; schema in ./collections).
7
+ *
8
+ * Addresses are compared lower-cased and trimmed. Suppressed recipients are
9
+ * not sent to and are marked `bounced` in the ledger, which keeps them out
10
+ * of reminders for good.
11
+ *
12
+ * @module _lib/outreach/suppression
13
+ */
14
+
15
+ /** Canonical form for comparison and storage. */
16
+ export const normaliseEmail = (email) => String(email ?? '').trim().toLowerCase();
17
+
18
+ /**
19
+ * @param {{createEntry,listEntries,deleteEntry?}} col collections service
20
+ * @param {{slug?: string}} [opts]
21
+ */
22
+ export function createSuppression(col, {slug = 'outreach-unsubscribes'} = {}) {
23
+ async function rows() {
24
+ const {entries} = await col.listEntries(slug, {limit: 100000}).catch(() => ({entries: []}));
25
+ return entries;
26
+ }
27
+
28
+ /** Every suppressed address as a Set - take one per send batch. */
29
+ async function suppressedSet() {
30
+ return new Set((await rows()).map(e => normaliseEmail(e.data.email)).filter(Boolean));
31
+ }
32
+
33
+ async function isSuppressed(email) {
34
+ const want = normaliseEmail(email);
35
+ if (!want) return false;
36
+ return (await suppressedSet()).has(want);
37
+ }
38
+
39
+ /** Add an address (idempotent). `source` says which Tool took the opt-out. */
40
+ async function add(email, source = 'outreach') {
41
+ const want = normaliseEmail(email);
42
+ if (!want) return null;
43
+ const existing = (await rows()).find(e => normaliseEmail(e.data.email) === want);
44
+ if (existing) return existing;
45
+ return col.createEntry(slug, {email: want, at: new Date().toISOString(), source}, {source});
46
+ }
47
+
48
+ /** Take an address off the list (they asked to hear from us again). */
49
+ async function remove(email) {
50
+ if (typeof col.deleteEntry !== 'function') return false;
51
+ const want = normaliseEmail(email);
52
+ const hits = (await rows()).filter(e => normaliseEmail(e.data.email) === want);
53
+ for (const e of hits) await col.deleteEntry(slug, e.id).catch(() => {});
54
+ return hits.length > 0;
55
+ }
56
+
57
+ async function list() {
58
+ return (await rows()).map(e => ({id: e.id, email: e.data.email, at: e.data.at ?? null, source: e.data.source ?? ''}));
59
+ }
60
+
61
+ return {isSuppressed, suppressedSet, add, remove, list, slug};
62
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Outreach tokens: short unambiguous codes for per-recipient links. Only the
3
+ * SHA-256 hash is ever stored - the plaintext travels in the emailed URL and
4
+ * nowhere else. Lifted from Surveys 1.4 (lib/tokens.js) unchanged.
5
+ *
6
+ * @module _lib/outreach/tokens
7
+ */
8
+ import crypto from 'crypto';
9
+
10
+ const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no O,0,I,1,L
11
+ const LENGTH = 8;
12
+
13
+ /** Generate an 8-char unambiguous uppercase token. */
14
+ export function generateToken() {
15
+ const bytes = crypto.randomBytes(LENGTH);
16
+ let out = '';
17
+ for (let i = 0; i < LENGTH; i++) out += ALPHABET[bytes[i] % ALPHABET.length];
18
+ return out;
19
+ }
20
+
21
+ /** SHA-256 hex of a normalised code. The plaintext is never stored. */
22
+ export function hashToken(code) {
23
+ return crypto.createHash('sha256').update(String(code)).digest('hex');
24
+ }
25
+
26
+ /** Display form: ABCD-EFGH. */
27
+ export function formatCode(token) {
28
+ const t = String(token).toUpperCase();
29
+ return t.length === 8 ? `${t.slice(0, 4)}-${t.slice(4)}` : t;
30
+ }
31
+
32
+ /** Canonical form for hashing/lookup: strip non-alphanumerics, uppercase. */
33
+ export function normaliseCode(input) {
34
+ return String(input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
35
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Unsubscribe links that keep working forever: an HMAC of the address under a
3
+ * per-site secret, not a ledger token (a re-send re-issues ledger tokens, and
4
+ * the unsubscribe link in an OLD email must still work - PECR). The secret is
5
+ * the engine's (server/services/outreachUnsubscribe.js) and so is the
6
+ * `/campaign-unsubscribe` route (server/routes/outreach-public.js), so a link
7
+ * keeps working after the Tool that sent it lapses or is removed.
8
+ *
9
+ * Campaigns Pro carries a byte-identical copy for engines older than this
10
+ * file - change the signature here and the links already sent stop verifying.
11
+ *
12
+ * @module _lib/outreach/unsubscribe
13
+ */
14
+ import crypto from 'crypto';
15
+ import {normaliseEmail} from './suppression.js';
16
+
17
+ /** Where every unsubscribe link points; links already sent carry this path. */
18
+ export const UNSUBSCRIBE_PATH = '/campaign-unsubscribe';
19
+
20
+ export function mintSecret() {
21
+ return crypto.randomBytes(24).toString('hex');
22
+ }
23
+
24
+ export function signEmail(secret, email) {
25
+ return crypto.createHmac('sha256', String(secret)).update(normaliseEmail(email)).digest('hex');
26
+ }
27
+
28
+ /** The address an emailed unsubscribe link points at. */
29
+ export function unsubscribeUrl(baseUrl, secret, email) {
30
+ const e = Buffer.from(normaliseEmail(email), 'utf8').toString('base64url');
31
+ return `${baseUrl}${UNSUBSCRIBE_PATH}?e=${e}&k=${signEmail(secret, email)}`;
32
+ }
33
+
34
+ /** The verified address from ?e=&k=, or null. Constant-time on the signature. */
35
+ export function verifyUnsubscribe(secret, e, k) {
36
+ let email = '';
37
+ try { email = Buffer.from(String(e ?? ''), 'base64url').toString('utf8'); } catch { return null; }
38
+ email = normaliseEmail(email);
39
+ if (!email || !k) return null;
40
+ const want = signEmail(secret, email);
41
+ const got = String(k);
42
+ if (got.length !== want.length) return null;
43
+ try {
44
+ return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(want, 'hex')) ? email : null;
45
+ } catch { return null; }
46
+ }
@@ -569,6 +569,8 @@ function mount(el) {
569
569
  switch (e.type) {
570
570
  case 'knocking': stage.value = 'waiting'; break;
571
571
  case 'admitted':
572
+ // The ICE servers arrive with admission (not the join reply) - before any peer is connected.
573
+ if (Array.isArray(e.iceServers)) iceServers = e.iceServers;
572
574
  stage.value = 'in';
573
575
  recording.value = e.recording === true;
574
576
  (e.chat || []).forEach(item => addItem(item, {live: false}));
@@ -868,7 +870,7 @@ function mount(el) {
868
870
  if (r.status === 425 && body.opensAt) { error.value = `This call opens at ${new Date(body.opensAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}. Come back then.`; return; }
869
871
  if (!r.ok) { error.value = body.error || 'Could not join the call.'; return; }
870
872
  me = {id: body.id, secret: body.secret, who: body.who || body.id};
871
- iceServers = body.iceServers || [];
873
+ iceServers = body.iceServers || []; // an older engine's join reply; the admitted event replaces it
872
874
  host.value = body.host === true;
873
875
  if (body.name) name.value = body.name;
874
876
  max.value = body.max || 6;
@@ -20,7 +20,7 @@ Public-facing blog for the CMS. Supports posts with categories, tags, and option
20
20
  - `blocks/*.html|css` - the shipped layouts, copied into content/blocks/ once
21
21
  - `public/blog.js`, `public/blog.css` - the browser half (listing, right-click menu, post extras)
22
22
  - `public/samples/*.svg` - sample covers
23
- - `tests/layouts.test.js`, `tests/public.test.js`, `tests/nav-link.test.js`
23
+ - `tests/access.test.js`, `tests/comment-notices.test.js`, `tests/layouts.test.js`, `tests/nav-link.test.js`, `tests/notices.test.js`, `tests/public.test.js`
24
24
  - `admin/views/blog.js` - post list (KPIs + status/category/search filters)
25
25
  - `admin/views/post-editor.js` - create / edit a post
26
26
  - `admin/views/categories.js`, `admin/views/comments.js`, `admin/views/settings.js`
@@ -319,3 +319,13 @@ Declared in plugin.json `permissions` (granted once to `admin`), checked on EVER
319
319
  - `usePermissionLookup()` points the checks at a lookup: the plugin passes core's `getPermissionsFor` (or
320
320
  `options.getPermissionsFor` in tests).
321
321
 
322
+ ## 1.9.6 (2026-10-13 Tool sweep)
323
+
324
+ - **One slug, one post.** `POST /posts` and `PUT /posts/:id` answer 409 when another post (draft or not; a Pro-era post
325
+ in another blog does not count) holds the slug - `slugHolder()` in lib/page.js. The public post lookup is
326
+ `publicPost()`: the LIVE post with that slug, not whichever is newest. Before, an author's draft saved with a live
327
+ post's slug sat first in the newest-first list and the live address answered 404.
328
+ - **POST /comments** has its own `config.rateLimit` (5/min/IP), caps every field (`commentFields()`, exported and
329
+ tested), needs a plausible email, and refuses a post that does not exist or is not visible (404) - each of those
330
+ used to raise a staff notification.
331
+ - `ASSET_V` / the preview `assetV` are 1.9.6 (they were 1.6.x while the plugin was 1.9.5 - keep them with the version).
@@ -39,7 +39,7 @@ Writers without the Publish permission see only their own posts.
39
39
  | Field | What it does |
40
40
  |---|---|
41
41
  | Title | Required. |
42
- | Slug | The last part of the post's address, for example `my-first-post`. With **Auto** ticked it follows the title. Changing it on a published post breaks links to the old address. |
42
+ | Slug | The last part of the post's address, for example `my-first-post`. With **Auto** ticked it follows the title. Changing it on a published post breaks links to the old address. Two posts cannot share a slug: saving one with a slug another post already has (a draft included) is refused with a message. |
43
43
  | Excerpt | A sentence or two shown in listings. Also used as the search-engine description when **SEO Description** is empty. |
44
44
  | Content | Markdown. Every shortcode the site supports works here too. |
45
45
  | SEO Title / SEO Description | What search engines and link previews show. Leave empty to use the title and excerpt. |
@@ -26,6 +26,38 @@ export function isVisible(post) {
26
26
  (post.status === 'scheduled' && post.publishedAt && new Date(post.publishedAt) <= new Date());
27
27
  }
28
28
 
29
+ /** Whether a post is this blog's (a Pro-era post in another blog is not), whatever its status. */
30
+ function inThisBlog(post) {
31
+ return !post.blogId || post.blogId === 'main';
32
+ }
33
+
34
+ /**
35
+ * The post readers get for an address: a LIVE one. The list is newest first,
36
+ * so `find(slug)` alone picked an author's fresh draft over the published
37
+ * post with the same slug and answered 404 for the live one.
38
+ *
39
+ * @param {object[]} posts
40
+ * @param {string} slug
41
+ * @returns {object|null}
42
+ */
43
+ export function publicPost(posts, slug) {
44
+ return posts.find((p) => p.slug === slug && isVisible(p)) ?? null;
45
+ }
46
+
47
+ /**
48
+ * The post, other than `exceptId`, that already holds a slug - a save that
49
+ * would make two posts answer one address. Drafts count: the one that is live
50
+ * today is not always the one that is live tomorrow.
51
+ *
52
+ * @param {object[]} posts
53
+ * @param {string} slug
54
+ * @param {string|null} [exceptId] - the post being saved
55
+ * @returns {object|null}
56
+ */
57
+ export function slugHolder(posts, slug, exceptId = null) {
58
+ return posts.find((p) => p.slug === slug && p.id !== exceptId && inThisBlog(p)) ?? null;
59
+ }
60
+
29
61
  /** Settings as they are NOW - display choices apply without a restart. */
30
62
  export function liveSettings(registered) {
31
63
  return {...defaultConfig, ...DISPLAY_DEFAULTS, ...registered, ...(getPluginStates()?.blog?.settings ?? {})};