backend-manager 5.1.4 → 5.2.1

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 (76) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +29 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +15 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/testing.md +36 -0
  8. package/package.json +1 -1
  9. package/src/cli/commands/emulator.js +44 -8
  10. package/src/cli/commands/serve.js +73 -7
  11. package/src/cli/commands/test.js +47 -1
  12. package/src/cli/commands/watch.js +15 -3
  13. package/src/manager/helpers/user.js +29 -0
  14. package/src/manager/index.js +29 -0
  15. package/src/manager/libraries/email/data/disposable-domains.json +8 -0
  16. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  17. package/src/manager/libraries/email/providers/beehiiv.js +1 -0
  18. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  19. package/src/manager/libraries/payment/processors/test.js +8 -1
  20. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  21. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  22. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  23. package/src/manager/routes/marketing/webhook/post.js +180 -0
  24. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  25. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  26. package/src/manager/routes/payments/cancel/post.js +2 -2
  27. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  28. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  29. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  30. package/src/manager/routes/user/signup/post.js +65 -1
  31. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  32. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  33. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  34. package/src/manager/schemas/payments/cancel/post.js +5 -0
  35. package/src/manager/schemas/user/signup/post.js +5 -0
  36. package/src/test/runner.js +61 -18
  37. package/src/test/test-accounts.js +94 -12
  38. package/src/test/utils/http-client.js +4 -3
  39. package/templates/_.env +1 -0
  40. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  41. package/test/events/payments/journey-payments-cancel.js +4 -5
  42. package/test/events/payments/journey-payments-failure.js +0 -1
  43. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  44. package/test/events/payments/journey-payments-one-time.js +6 -3
  45. package/test/events/payments/journey-payments-plan-change.js +5 -5
  46. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  47. package/test/events/payments/journey-payments-suspend.js +4 -5
  48. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  49. package/test/events/payments/journey-payments-trial.js +2 -3
  50. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  51. package/test/functions/admin/database-read.js +0 -14
  52. package/test/functions/admin/database-write.js +0 -14
  53. package/test/functions/admin/firestore-query.js +0 -14
  54. package/test/functions/admin/firestore-read.js +0 -15
  55. package/test/functions/admin/firestore-write.js +0 -11
  56. package/test/functions/general/add-marketing-contact.js +16 -14
  57. package/test/helpers/email.js +1 -1
  58. package/test/helpers/infer-contact.js +3 -3
  59. package/test/helpers/user.js +241 -2
  60. package/test/helpers/webhook-forward.js +392 -0
  61. package/test/marketing/newsletter-generate.js +17 -7
  62. package/test/routes/admin/database.js +0 -13
  63. package/test/routes/admin/firestore-query.js +0 -13
  64. package/test/routes/admin/firestore.js +0 -14
  65. package/test/routes/admin/infer-contact.js +6 -3
  66. package/test/routes/admin/post.js +4 -2
  67. package/test/routes/marketing/contact.js +60 -26
  68. package/test/routes/marketing/email-preferences.js +145 -69
  69. package/test/routes/marketing/webhook-forward.js +54 -0
  70. package/test/routes/marketing/webhook.js +582 -0
  71. package/test/routes/payments/cancel.js +2 -7
  72. package/test/routes/payments/dispute-alert.js +0 -39
  73. package/test/routes/payments/refund.js +3 -1
  74. package/test/routes/payments/webhook.js +5 -26
  75. package/test/routes/test/usage.js +2 -2
  76. package/test/routes/user/signup.js +114 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Beehiiv webhook processor
3
+ *
4
+ * Beehiiv sends one event per POST (unlike SendGrid's batched array).
5
+ * Each event includes a `publication_id` so the processor can decide whether
6
+ * the event belongs to THIS brand or a sibling brand sharing the same parent.
7
+ *
8
+ * Supported event types (anything else is silently ignored):
9
+ * - 'subscription.unsubscribed' — user clicked unsubscribe in a Beehiiv email
10
+ * - 'subscription.deleted' — admin or API removed the subscriber
11
+ * - 'subscription.paused' — user paused delivery (treat as revoke; we
12
+ * can't differentiate "pause" semantics)
13
+ *
14
+ * Publication routing:
15
+ * - If the event's publication_id doesn't match this brand's configured
16
+ * publication, silent skip. This is how the shared-devbeans publication
17
+ * case works: the parent fans the event to every child, and only the
18
+ * brand(s) sharing that publication actually process it.
19
+ * - getPublicationId() reads from config.marketing.beehiiv.publicationId
20
+ * or fuzzy-matches by brand name against the Beehiiv API.
21
+ *
22
+ * Idempotency is enforced by the dispatcher via marketing-webhooks/{eventId} doc.
23
+ */
24
+
25
+ const REVOKE_EVENT_TYPES = new Set([
26
+ 'subscription.unsubscribed',
27
+ 'subscription.deleted',
28
+ 'subscription.paused',
29
+ ]);
30
+
31
+ /**
32
+ * Parse the raw webhook request into a normalized array (single-element).
33
+ * Beehiiv sends one event per POST. Some HTTP clients serialize objects with
34
+ * extra keys merged in (e.g. our test client merges query params into the body) —
35
+ * we tolerate that by pulling fields explicitly.
36
+ */
37
+ function parseWebhook(req) {
38
+ const body = req.body;
39
+
40
+ if (!body || typeof body !== 'object') {
41
+ throw new Error('Empty or non-object webhook body');
42
+ }
43
+
44
+ // Beehiiv's event ID lives at `data.id` (per their docs) but their actual
45
+ // delivery shape can vary across endpoints. Try common locations.
46
+ const eventId = body.id
47
+ || body.event_id
48
+ || body.data?.id
49
+ || null;
50
+
51
+ const eventType = body.event || body.type || null;
52
+
53
+ // Email may be at top-level or nested under `data`
54
+ const rawEmail = body.email || body.data?.email || null;
55
+ const email = typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : null;
56
+
57
+ // Publication ID — required to decide whether THIS brand should handle it
58
+ const publicationId = body.publication_id || body.data?.publication_id || null;
59
+
60
+ // Timestamp — Beehiiv uses ISO 8601. Fall back to data.created_at variants.
61
+ const timestampISO = body.created_at
62
+ || body.timestamp
63
+ || body.data?.created_at
64
+ || null;
65
+
66
+ const timestampUNIX = timestampISO
67
+ ? Math.floor(new Date(timestampISO).getTime() / 1000) || null
68
+ : null;
69
+
70
+ return [{
71
+ eventId,
72
+ eventType,
73
+ email,
74
+ timestamp: timestampUNIX,
75
+ publicationId,
76
+ raw: body,
77
+ }];
78
+ }
79
+
80
+ /**
81
+ * Returns true if this event type represents a revocation we should act on.
82
+ */
83
+ function isSupported(eventType) {
84
+ return REVOKE_EVENT_TYPES.has(eventType);
85
+ }
86
+
87
+ /**
88
+ * Process a single parsed event. Called by the dispatcher AFTER idempotency check passes.
89
+ * Returns a result object summarizing what happened.
90
+ */
91
+ async function handleEvent({ Manager, assistant, parsed }) {
92
+ const { admin } = Manager.libraries;
93
+ const { eventId, eventType, email, timestamp, publicationId } = parsed;
94
+
95
+ if (!email) {
96
+ assistant.log(`beehiiv webhook: event ${eventId} (${eventType}) missing email, skipping`);
97
+ return { handled: false, reason: 'missing-email' };
98
+ }
99
+
100
+ // Publication filter — silent skip if the event isn't for our publication.
101
+ // This is THE mechanism that routes shared-publication events (e.g. devbeans
102
+ // across 6 brands) to only the brands that share that publication.
103
+ // beehiivProvider.getPublicationId() reads Manager.config.marketing.beehiiv.publicationId
104
+ // first, then falls back to fuzzy-match against the Beehiiv API by brand name.
105
+ if (publicationId) {
106
+ let ourPublicationId = null;
107
+ try {
108
+ const beehiivProvider = require('../../../../libraries/email/providers/beehiiv.js');
109
+ if (typeof beehiivProvider.getPublicationId === 'function') {
110
+ ourPublicationId = await beehiivProvider.getPublicationId();
111
+ }
112
+ } catch (e) {
113
+ assistant.error('beehiiv webhook: failed to resolve our publication ID:', e);
114
+ return { handled: false, reason: 'publication-resolve-failed' };
115
+ }
116
+
117
+ if (!ourPublicationId) {
118
+ assistant.log(`beehiiv webhook: no publication configured for this brand, skipping event ${eventId}`);
119
+ return { handled: false, reason: 'no-local-publication' };
120
+ }
121
+
122
+ if (ourPublicationId !== publicationId) {
123
+ assistant.log(`beehiiv webhook: publication mismatch (event=${publicationId}, ours=${ourPublicationId}), skipping`);
124
+ return { handled: false, reason: 'publication-mismatch' };
125
+ }
126
+ }
127
+
128
+ // Build the canonical revokedAt timestamp from the event (or server time if missing)
129
+ const startTime = assistant.meta.startTime;
130
+ const eventUNIX = typeof timestamp === 'number' ? timestamp : startTime.timestampUNIX;
131
+ const eventISO = new Date(eventUNIX * 1000).toISOString();
132
+
133
+ // Look up the user by email
134
+ const snapshot = await admin.firestore().collection('users')
135
+ .where('auth.email', '==', email)
136
+ .limit(1)
137
+ .get()
138
+ .catch((e) => {
139
+ assistant.error(`beehiiv webhook: user lookup failed for ${email}:`, e);
140
+ return null;
141
+ });
142
+
143
+ if (!snapshot || snapshot.empty) {
144
+ // Silent skip — this email may not map to a customer of THIS brand even if
145
+ // the publication matched (legitimate for shared-devbeans where 6 brands
146
+ // process every event but only one has the user).
147
+ assistant.log(`beehiiv webhook: no user found for ${email}, skipping doc update`);
148
+ return { handled: false, reason: 'user-not-found', email };
149
+ }
150
+
151
+ const userDoc = snapshot.docs[0];
152
+ const uid = userDoc.id;
153
+
154
+ // Write consent.marketing.status = 'revoked' (preserve grantedAt — informational audit trail)
155
+ await admin.firestore().doc(`users/${uid}`).set({
156
+ consent: {
157
+ marketing: {
158
+ status: 'revoked',
159
+ revokedAt: {
160
+ timestamp: eventISO,
161
+ timestampUNIX: eventUNIX,
162
+ source: 'beehiiv',
163
+ ip: null,
164
+ text: null,
165
+ },
166
+ },
167
+ },
168
+ metadata: Manager.Metadata().set({ tag: 'marketing/webhook:beehiiv' }),
169
+ }, { merge: true });
170
+
171
+ assistant.log(`beehiiv webhook: revoked consent.marketing for ${uid} (${email}) — eventType=${eventType}`);
172
+
173
+ // Cross-provider sync: also remove from SendGrid (best-effort, idempotent on 404)
174
+ const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
175
+
176
+ if (shouldCallExternalAPIs) {
177
+ try {
178
+ const mailer = Manager.Email(assistant);
179
+ await mailer.remove(email);
180
+ assistant.log(`beehiiv webhook: cross-provider sync complete for ${email}`);
181
+ } catch (e) {
182
+ // Best-effort — user doc is already updated. Log + continue.
183
+ assistant.error(`beehiiv webhook: cross-provider sync failed for ${email}:`, e);
184
+ }
185
+ } else {
186
+ assistant.log('beehiiv webhook: skipping cross-provider sync (BEM_TESTING=true)');
187
+ }
188
+
189
+ return { handled: true, uid, email, eventType };
190
+ }
191
+
192
+ module.exports = {
193
+ parseWebhook,
194
+ isSupported,
195
+ handleEvent,
196
+ };
@@ -0,0 +1,171 @@
1
+ /**
2
+ * SendGrid webhook processor
3
+ *
4
+ * SendGrid sends an array of events per POST. This module parses the array,
5
+ * decides which events represent an unsubscribe (revocation of marketing consent),
6
+ * and exposes a per-event handler that:
7
+ * 1. Looks up the user by email in this brand's Firestore (silent skip if not found)
8
+ * 2. Writes consent.marketing.status = 'revoked' to the user doc, source: 'sendgrid'
9
+ * 3. Calls Beehiiv to remove the contact too (cross-provider sync — idempotent on 404)
10
+ *
11
+ * Supported event types (anything else is silently ignored):
12
+ * - 'unsubscribe' — user clicked the unified unsubscribe link
13
+ * - 'group_unsubscribe' — user unsubscribed from a specific ASM group
14
+ * - 'spamreport' — user marked email as spam
15
+ * - 'bounce' — hard bounce (treat as revoke to avoid future sends)
16
+ * - 'dropped' — SendGrid dropped the message (often due to suppression)
17
+ *
18
+ * Note: 'group_unsubscribe' is the most common one (matches our ASM-link flow), and since
19
+ * GROUPS.marketing (25928) is account-global across all brands, an unsub from group 25928
20
+ * legitimately removes the user from marketing across the entire SendGrid account.
21
+ *
22
+ * Idempotency is enforced by the dispatcher via marketing-webhooks/{eventId} doc.
23
+ */
24
+
25
+ const REVOKE_EVENT_TYPES = new Set([
26
+ 'unsubscribe',
27
+ 'group_unsubscribe',
28
+ 'spamreport',
29
+ 'bounce',
30
+ 'dropped',
31
+ ]);
32
+
33
+ /**
34
+ * Parse the raw webhook request into a normalized array of events.
35
+ * SendGrid sends an array of events as the body. Some HTTP clients (including
36
+ * BEM's own test client) JSON-encode arrays as objects with numeric keys —
37
+ * we tolerate both shapes plus the rare single-event object form.
38
+ */
39
+ function parseWebhook(req) {
40
+ const body = req.body;
41
+
42
+ if (!body) {
43
+ throw new Error('Empty webhook body');
44
+ }
45
+
46
+ let events;
47
+ if (Array.isArray(body)) {
48
+ // Real SendGrid: array body
49
+ events = body;
50
+ } else if (body && typeof body === 'object' && typeof body['0'] === 'object' && body['0'] !== null) {
51
+ // Array serialized as object-with-numeric-keys (test clients, some proxies)
52
+ events = [];
53
+ let i = 0;
54
+ while (body[String(i)]) {
55
+ events.push(body[String(i)]);
56
+ i += 1;
57
+ }
58
+ } else {
59
+ // Single event sent as a bare object
60
+ events = [body];
61
+ }
62
+
63
+ return events.map((event) => {
64
+ // sg_event_id is SendGrid's per-event unique ID, used for idempotency.
65
+ // smtp-id is another stable identifier we fall back to.
66
+ const eventId = event.sg_event_id || event['smtp-id'] || event.smtpId || null;
67
+ const eventType = event.event;
68
+ const email = typeof event.email === 'string' ? event.email.trim().toLowerCase() : null;
69
+ const timestamp = typeof event.timestamp === 'number' ? event.timestamp : null;
70
+ const asmGroupId = event.asm_group_id || null;
71
+
72
+ return {
73
+ eventId,
74
+ eventType,
75
+ email,
76
+ timestamp,
77
+ asmGroupId,
78
+ raw: event,
79
+ };
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Returns true if this event type represents a revocation we should act on.
85
+ */
86
+ function isSupported(eventType) {
87
+ return REVOKE_EVENT_TYPES.has(eventType);
88
+ }
89
+
90
+ /**
91
+ * Process a single parsed event. Called by the dispatcher AFTER idempotency check passes.
92
+ *
93
+ * Returns a result object summarizing what happened (for logging/response).
94
+ */
95
+ async function handleEvent({ Manager, assistant, parsed }) {
96
+ const { admin } = Manager.libraries;
97
+ const { eventId, eventType, email, timestamp } = parsed;
98
+
99
+ if (!email) {
100
+ assistant.log(`sendgrid webhook: event ${eventId} (${eventType}) missing email, skipping`);
101
+ return { handled: false, reason: 'missing-email' };
102
+ }
103
+
104
+ // Convert SendGrid's UNIX timestamp to our canonical { timestamp, timestampUNIX } shape.
105
+ // Fall back to server time if missing.
106
+ const startTime = assistant.meta.startTime;
107
+ const eventUNIX = typeof timestamp === 'number' ? timestamp : startTime.timestampUNIX;
108
+ const eventISO = new Date(eventUNIX * 1000).toISOString();
109
+
110
+ // Look up the user by email
111
+ const snapshot = await admin.firestore().collection('users')
112
+ .where('auth.email', '==', email)
113
+ .limit(1)
114
+ .get()
115
+ .catch((e) => {
116
+ assistant.error(`sendgrid webhook: user lookup failed for ${email}:`, e);
117
+ return null;
118
+ });
119
+
120
+ if (!snapshot || snapshot.empty) {
121
+ // Silent skip — this email may not map to a customer of THIS brand (shared SendGrid account).
122
+ assistant.log(`sendgrid webhook: no user found for ${email}, skipping doc update`);
123
+ return { handled: false, reason: 'user-not-found', email };
124
+ }
125
+
126
+ const userDoc = snapshot.docs[0];
127
+ const uid = userDoc.id;
128
+
129
+ // Write consent.marketing.status = 'revoked' (preserve grantedAt — informational audit trail)
130
+ await admin.firestore().doc(`users/${uid}`).set({
131
+ consent: {
132
+ marketing: {
133
+ status: 'revoked',
134
+ revokedAt: {
135
+ timestamp: eventISO,
136
+ timestampUNIX: eventUNIX,
137
+ source: 'sendgrid',
138
+ ip: null,
139
+ text: null,
140
+ },
141
+ },
142
+ },
143
+ metadata: Manager.Metadata().set({ tag: 'marketing/webhook:sendgrid' }),
144
+ }, { merge: true });
145
+
146
+ assistant.log(`sendgrid webhook: revoked consent.marketing for ${uid} (${email}) — eventType=${eventType}`);
147
+
148
+ // Cross-provider sync: also remove from Beehiiv (best-effort, idempotent on 404)
149
+ const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
150
+
151
+ if (shouldCallExternalAPIs) {
152
+ try {
153
+ const mailer = Manager.Email(assistant);
154
+ await mailer.remove(email);
155
+ assistant.log(`sendgrid webhook: cross-provider sync complete for ${email}`);
156
+ } catch (e) {
157
+ // Best-effort — user doc is already updated. Log + continue.
158
+ assistant.error(`sendgrid webhook: cross-provider sync failed for ${email}:`, e);
159
+ }
160
+ } else {
161
+ assistant.log('sendgrid webhook: skipping cross-provider sync (BEM_TESTING=true)');
162
+ }
163
+
164
+ return { handled: true, uid, email, eventType };
165
+ }
166
+
167
+ module.exports = {
168
+ parseWebhook,
169
+ isSupported,
170
+ handleEvent,
171
+ };
@@ -32,9 +32,9 @@ module.exports = async ({ assistant, user, settings }) => {
32
32
  return assistant.respond('No active paid subscription found', { code: 400 });
33
33
  }
34
34
 
35
- // Guard: subscription younger than 24 hours
35
+ // Guard: subscription younger than 24 hours (callers may bypass via skipGuards)
36
36
  const startDateUNIX = subscription.payment?.startDate?.timestampUNIX;
37
- if (startDateUNIX) {
37
+ if (!settings.skipGuards && startDateUNIX) {
38
38
  const ageMs = Date.now() - (startDateUNIX * 1000);
39
39
  const twentyFourHoursMs = 24 * 60 * 60 * 1000;
40
40
  if (ageMs < twentyFourHoursMs) {
@@ -23,7 +23,8 @@ module.exports = {
23
23
  const now = Math.floor(timestamp / 1000);
24
24
  const periodEnd = now + (30 * 86400);
25
25
 
26
- // Look up the Stripe product ID from the existing order so resolveProduct() can match
26
+ // Look up the Stripe product ID from the existing order so resolveProduct() can match.
27
+ // Falls back to the "_test_<id>" sentinel when no real Stripe product is configured.
27
28
  const orderId = subscription?.payment?.orderId;
28
29
  let stripeProductId = null;
29
30
 
@@ -34,7 +35,9 @@ module.exports = {
34
35
  const productId = orderData.unified?.product?.id;
35
36
  const products = assistant.Manager.config.payment?.products || [];
36
37
  const product = products.find(p => p.id === productId);
37
- stripeProductId = product?.stripe?.productId || null;
38
+ if (product) {
39
+ stripeProductId = product.stripe?.productId || `_test_${product.id}`;
40
+ }
38
41
  }
39
42
  }
40
43
 
@@ -57,14 +57,18 @@ async function createSubscriptionIntent({ uid, orderId, product, frequency, tria
57
57
  const now = Math.floor(timestamp / 1000);
58
58
  const periodEnd = now + (FREQUENCY_TO_PERIOD[frequency] || 30 * 86400);
59
59
 
60
- // Build Stripe-shaped subscription object
61
- // Uses product's Stripe product ID so resolveProduct() can match it
60
+ // Build Stripe-shaped subscription object.
61
+ // Prefer the real Stripe product ID if configured; otherwise use a sentinel of the
62
+ // form "_test_<id>" that the Stripe resolver recognizes and maps back to product.id.
63
+ // This lets the test processor work in brands that haven't wired up Stripe yet.
64
+ const planProductId = product.stripe?.productId || `_test_${product.id}`;
65
+
62
66
  const subscription = {
63
67
  id: subscriptionId,
64
68
  object: 'subscription',
65
69
  status: trial && product.trial?.days ? 'trialing' : 'active',
66
70
  metadata: { uid, orderId },
67
- plan: { product: product.stripe?.productId || null, interval },
71
+ plan: { product: planProductId, interval },
68
72
  current_period_end: periodEnd,
69
73
  current_period_start: now,
70
74
  start_date: now,
@@ -22,6 +22,7 @@ module.exports = {
22
22
 
23
23
  // Look up the Stripe product ID from the existing order so resolveProduct() can match
24
24
  const orderId = subscription?.payment?.orderId;
25
+ // Falls back to the "_test_<id>" sentinel when no real Stripe product is configured.
25
26
  let stripeProductId = null;
26
27
 
27
28
  if (orderId) {
@@ -31,7 +32,9 @@ module.exports = {
31
32
  const productId = orderData.unified?.product?.id;
32
33
  const products = assistant.Manager.config.payment?.products || [];
33
34
  const product = products.find(p => p.id === productId);
34
- stripeProductId = product?.stripe?.productId || null;
35
+ if (product) {
36
+ stripeProductId = product.stripe?.productId || `_test_${product.id}`;
37
+ }
35
38
  }
36
39
  }
37
40
 
@@ -76,7 +76,12 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
76
76
  await processAffiliate(assistant, uid, email, settings);
77
77
 
78
78
  // 6. Send emails + marketing (non-blocking, fire-and-forget)
79
- syncMarketingContact(assistant, uid, email);
79
+ // Gate marketing sync on explicit consent — never add a user to marketing lists without it
80
+ if (userRecord.consent?.marketing?.status === 'granted') {
81
+ syncMarketingContact(assistant, uid, email);
82
+ } else {
83
+ assistant.log(`signup(): Skipping marketing sync — consent.marketing.status is "${userRecord.consent?.marketing?.status}"`);
84
+ }
80
85
  sendWelcomeEmails(assistant, uid, inferred?.firstName);
81
86
 
82
87
  return assistant.respond({ signedUp: true });
@@ -137,6 +142,7 @@ function buildUserRecord(assistant, settings, inferred) {
137
142
  },
138
143
  },
139
144
  attribution: attribution || {},
145
+ consent: buildConsentRecord(assistant, settings.consent),
140
146
  metadata: Manager.Metadata().set({ tag: 'user/signup' }),
141
147
  };
142
148
 
@@ -156,6 +162,64 @@ function buildUserRecord(assistant, settings, inferred) {
156
162
  return record;
157
163
  }
158
164
 
165
+ /**
166
+ * Translate the client's lightweight consent payload into the canonical user-doc shape.
167
+ *
168
+ * Client sends: { legal: { granted, text }, marketing: { granted, text } }
169
+ * Server writes: { legal: { status, grantedAt: {...} }, marketing: { status, grantedAt: {...}, revokedAt: {...} } }
170
+ *
171
+ * Server time (not client-supplied) is authoritative — defends against clock manipulation.
172
+ * IP is captured from request geolocation.
173
+ *
174
+ * Legal is REQUIRED — the client must send legal.granted=true. If missing/false we still
175
+ * record what the client sent, but the route will not have reached this point in practice
176
+ * (the signup-form HTML5-requires the legal checkbox).
177
+ */
178
+ function buildConsentRecord(assistant, clientConsent) {
179
+ const consent = clientConsent || {};
180
+ const ip = assistant.request.geolocation?.ip || null;
181
+ const timestamp = assistant.meta.startTime.timestamp;
182
+ const timestampUNIX = assistant.meta.startTime.timestampUNIX;
183
+
184
+ // Build empty leaf shape — used wherever grantedAt or revokedAt is "not set"
185
+ const emptyMeta = { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null };
186
+
187
+ // --- Legal ---
188
+ const legalGranted = consent.legal?.granted === true;
189
+ const legalText = typeof consent.legal?.text === 'string' ? consent.legal.text : null;
190
+
191
+ const legal = legalGranted
192
+ ? {
193
+ status: 'granted',
194
+ grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: legalText },
195
+ }
196
+ : {
197
+ status: 'revoked',
198
+ grantedAt: { ...emptyMeta },
199
+ };
200
+
201
+ // --- Marketing ---
202
+ const marketingGranted = consent.marketing?.granted === true;
203
+ const marketingText = typeof consent.marketing?.text === 'string' ? consent.marketing.text : null;
204
+
205
+ const marketing = marketingGranted
206
+ ? {
207
+ status: 'granted',
208
+ grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: marketingText },
209
+ revokedAt: { ...emptyMeta },
210
+ }
211
+ : {
212
+ status: 'revoked',
213
+ grantedAt: { ...emptyMeta },
214
+ // Record the decline with source=signup-form-declined. text=null (no message shown).
215
+ revokedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: null },
216
+ };
217
+
218
+ assistant.log(`buildConsentRecord: legal=${legal.status}, marketing=${marketing.status} (raw input legal.granted=${consent.legal?.granted}, marketing.granted=${consent.marketing?.granted})`);
219
+
220
+ return { legal, marketing };
221
+ }
222
+
159
223
  /**
160
224
  * Infer name/company from email using AI (or regex fallback)
161
225
  * Returns the inferred contact info, or null on failure
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * Schema for POST /marketing/email-preferences
3
+ *
4
+ * Two supported modes (route decides based on user.authenticated):
5
+ * - Authenticated (account page toggle): action ('subscribe' | 'unsubscribe'). Other fields ignored.
6
+ * - Anonymous (HMAC link from email footer): email + asmId + sig + action ('subscribe' | 'unsubscribe').
3
7
  */
4
8
  module.exports = () => ({
5
- email: { types: ['string'], default: undefined, required: true },
6
- asmId: { types: ['string', 'number'], default: undefined, required: true },
7
- action: { types: ['string'], default: 'unsubscribe' },
8
- sig: { types: ['string'], default: undefined, required: true },
9
+ email: { types: ['string'], default: undefined, required: false },
10
+ asmId: { types: ['string', 'number'], default: undefined, required: false },
11
+ action: { types: ['string'], default: 'unsubscribe', required: true },
12
+ sig: { types: ['string'], default: undefined, required: false },
9
13
  });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Schema: POST /marketing/webhook/forward
3
+ *
4
+ * Empty by design — the body is the raw provider webhook payload that gets
5
+ * forwarded to every child BEM unchanged. Validation happens in the dispatcher
6
+ * (provider + key query params) and at each child's receiver.
7
+ */
8
+ module.exports = () => ({});
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Schema: POST /marketing/webhook
3
+ *
4
+ * Empty by design — webhook payloads are provider-defined and validated inside
5
+ * each processor module. Auth + provider come from query params, not the body.
6
+ */
7
+ module.exports = () => ({});
@@ -15,4 +15,9 @@ module.exports = () => ({
15
15
  types: ['boolean'],
16
16
  required: true,
17
17
  },
18
+ // Bypass route-level guards (e.g. 24-hour subscription age). Used by tests and internal callers.
19
+ skipGuards: {
20
+ types: ['boolean'],
21
+ default: false,
22
+ },
18
23
  });
@@ -19,4 +19,9 @@ module.exports = ({ user }) => ({
19
19
  default: {},
20
20
  required: false,
21
21
  },
22
+ consent: {
23
+ types: ['object'],
24
+ default: {},
25
+ required: false,
26
+ },
22
27
  });