backend-manager 5.2.3 → 5.2.6

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 (50) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CLAUDE.md +3 -3
  3. package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +159 -0
  4. package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +15 -0
  5. package/TODO-WEBHOOK-KEY-UPGRADE.md +138 -0
  6. package/docs/consent.md +5 -10
  7. package/docs/sanitization.md +32 -24
  8. package/docs/schemas.md +1 -1
  9. package/docs/stripe-webhook-forwarding.md +2 -2
  10. package/docs/testing.md +8 -7
  11. package/package.json +1 -1
  12. package/scripts/test-helper-providers.js +162 -0
  13. package/src/cli/commands/base-command.js +5 -5
  14. package/src/cli/commands/emulator.js +201 -54
  15. package/src/cli/commands/test.js +80 -9
  16. package/src/manager/events/cron/daily/ghostii-auto-publisher.js +2 -2
  17. package/src/manager/events/firestore/payments-webhooks/analytics.js +2 -2
  18. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  19. package/src/manager/helpers/analytics.js +1 -1
  20. package/src/manager/helpers/middleware.js +7 -4
  21. package/src/manager/helpers/utilities.js +31 -0
  22. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  23. package/src/manager/libraries/email/providers/beehiiv.js +69 -27
  24. package/src/manager/libraries/email/providers/sendgrid.js +38 -12
  25. package/src/manager/libraries/email/validation.js +1 -1
  26. package/src/manager/libraries/infer-contact.js +1 -1
  27. package/src/manager/routes/general/email/post.js +4 -2
  28. package/src/manager/routes/marketing/email-preferences/post.js +2 -2
  29. package/src/manager/routes/payments/dispute-alert/post.js +3 -3
  30. package/src/manager/routes/payments/intent/processors/test.js +2 -2
  31. package/src/manager/routes/payments/webhook/post.js +2 -2
  32. package/src/manager/routes/user/delete.js +1 -1
  33. package/src/manager/routes/user/oauth2/providers/discord.js +1 -1
  34. package/src/manager/routes/user/oauth2/providers/google.js +1 -1
  35. package/src/test/runner.js +7 -31
  36. package/src/test/test-accounts.js +8 -63
  37. package/src/test/utils/http-client.js +1 -0
  38. package/test/events/payments/journey-payments-cancel.js +4 -4
  39. package/test/events/payments/journey-payments-failure.js +2 -2
  40. package/test/events/payments/journey-payments-legacy-product.js +1 -1
  41. package/test/events/payments/journey-payments-one-time-failure.js +1 -1
  42. package/test/events/payments/journey-payments-plan-change.js +1 -1
  43. package/test/events/payments/journey-payments-refund-webhook.js +4 -4
  44. package/test/events/payments/journey-payments-suspend.js +4 -4
  45. package/test/events/payments/journey-payments-trial.js +2 -2
  46. package/test/events/payments/journey-payments-uid-resolution.js +1 -1
  47. package/test/marketing/consent-lifecycle.js +255 -0
  48. package/test/routes/payments/dispute-alert.js +13 -13
  49. package/test/routes/payments/webhook.js +3 -3
  50. /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
@@ -225,7 +225,7 @@ function fireMeta({ resolved, currency, uid, processor, assistant, config }) {
225
225
  method: 'post',
226
226
  response: 'json',
227
227
  body: payload,
228
- timeout: 30000,
228
+ timeout: 60000,
229
229
  tries: 2,
230
230
  })
231
231
  .then(() => {
@@ -298,7 +298,7 @@ function fireTikTok({ resolved, currency, uid, processor, assistant, config }) {
298
298
  'Access-Token': accessToken,
299
299
  },
300
300
  body: { data: [payload] },
301
- timeout: 30000,
301
+ timeout: 60000,
302
302
  tries: 2,
303
303
  })
304
304
  .then(() => {
@@ -31,7 +31,7 @@ Module.prototype.main = function () {
31
31
  assistant.log(`Signout of all sessions...`);
32
32
  await fetch(`${self.Manager.project.apiUrl}/backend-manager`, {
33
33
  method: 'post',
34
- timeout: 30000,
34
+ timeout: 60000,
35
35
  response: 'json',
36
36
  tries: 2,
37
37
  log: true,
@@ -328,7 +328,7 @@ Analytics.prototype.event = function (payload, params) {
328
328
  method: 'post',
329
329
  response: 'text',
330
330
  tries: 2,
331
- timeout: 30000,
331
+ timeout: 60000,
332
332
  // headers: {
333
333
  // "Content-Type": "application/json"
334
334
  // },
@@ -35,7 +35,7 @@ Middleware.prototype.run = function (libPath, options) {
35
35
  options.setupAnalytics = typeof options.setupAnalytics === 'boolean' ? options.setupAnalytics : true;
36
36
  options.setupUsage = typeof options.setupUsage === 'boolean' ? options.setupUsage : true;
37
37
  options.setupSettings = typeof options.setupSettings === 'undefined' ? true : options.setupSettings;
38
- options.sanitize = typeof options.sanitize === 'undefined' ? true : options.sanitize;
38
+ options.sanitize = typeof options.sanitize === 'undefined' ? false : options.sanitize;
39
39
  options.includeNonSchemaSettings = typeof options.includeNonSchemaSettings === 'undefined' ? false : options.includeNonSchemaSettings;
40
40
  options.schema = typeof options.schema === 'undefined' ? libPath : options.schema;
41
41
  options.parseMultipartFormData = typeof options.parseMultipartFormData === 'undefined' ? true : options.parseMultipartFormData;
@@ -177,13 +177,16 @@ Middleware.prototype.run = function (libPath, options) {
177
177
  assistant.settings = data;
178
178
  }
179
179
 
180
- // Sanitize settings trim whitespace and strip HTML from all strings
181
- // Respects sanitize: false on individual schema fields
180
+ // Trim whitespace on all string settings (always on harmless and useful).
181
+ assistant.settings = Manager.Utilities().trim(assistant.settings);
182
+
183
+ // Optional HTML strip (off by default — opt in with `{ sanitize: true }`).
184
+ // Sanitize at the HTML-insertion site instead unless you need a belt-and-suspenders pass here.
185
+ // Respects sanitize: false on individual schema fields.
182
186
  if (options.sanitize) {
183
187
  const schema = options.setupSettings ? Manager.Settings().schema : null;
184
188
  const utilities = Manager.Utilities();
185
189
 
186
- // Walk settings, skipping fields the schema marks as sanitize: false
187
190
  assistant.settings = sanitizeWithSchema(utilities, assistant.settings, schema);
188
191
  }
189
192
 
@@ -528,4 +528,35 @@ Utilities.prototype.sanitize = function (input) {
528
528
  return input;
529
529
  };
530
530
 
531
+ /**
532
+ * Trim whitespace from all strings in input. Walks objects/arrays recursively.
533
+ * Does NOT strip HTML — use sanitize() for that.
534
+ *
535
+ * @param {*} input - The data to trim (string, object, array, or primitive)
536
+ * @returns {*} Trimmed copy (objects/arrays) or trimmed value (strings)
537
+ */
538
+ Utilities.prototype.trim = function (input) {
539
+ if (input == null) {
540
+ return input;
541
+ }
542
+
543
+ if (typeof input === 'string') {
544
+ return input.trim();
545
+ }
546
+
547
+ if (Array.isArray(input)) {
548
+ return input.map(item => this.trim(item));
549
+ }
550
+
551
+ if (typeof input === 'object') {
552
+ const result = {};
553
+ for (const [key, value] of Object.entries(input)) {
554
+ result[key] = this.trim(value);
555
+ }
556
+ return result;
557
+ }
558
+
559
+ return input;
560
+ };
561
+
531
562
  module.exports = Utilities;
@@ -517,7 +517,7 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
517
517
  const data = await fetch(`${parentUrl}/newsletter-sources`, {
518
518
  method: 'get',
519
519
  response: 'json',
520
- timeout: 15000,
520
+ timeout: 60000,
521
521
  query: {
522
522
  category,
523
523
  limit: 3,
@@ -546,7 +546,7 @@ async function claimSources(parentUrl, sources, brandId, assistant) {
546
546
  await fetch(`${parentUrl}/newsletter-sources`, {
547
547
  method: 'put',
548
548
  response: 'json',
549
- timeout: 10000,
549
+ timeout: 60000,
550
550
  body: {
551
551
  id: source.id,
552
552
  usedBy: brandId || 'unknown',
@@ -9,6 +9,11 @@ const { FIELDS, resolveFieldValues } = require('../constants.js');
9
9
 
10
10
  const BASE_URL = 'https://api.beehiiv.com/v2';
11
11
 
12
+ // Beehiiv API spikes past 10s during their hiccups, dropping signups silently.
13
+ // 60s is generous but harmless — caches are in place for metadata calls so a
14
+ // slow first call costs nothing in steady state.
15
+ const BEEHIIV_TIMEOUT_MS = 60000;
16
+
12
17
  // --- Internal helpers ---
13
18
 
14
19
  function headers() {
@@ -63,7 +68,7 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
63
68
  method: 'post',
64
69
  response: 'json',
65
70
  headers: headers(),
66
- timeout: 15000,
71
+ timeout: BEEHIIV_TIMEOUT_MS,
67
72
  body,
68
73
  });
69
74
 
@@ -79,39 +84,55 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
79
84
  }
80
85
 
81
86
  /**
82
- * Remove a subscriber from a Beehiiv publication by email.
87
+ * Look up a Beehiiv subscriber by email. Returns the subscription object
88
+ * (id, email, status, custom_fields, ...) or null if not found.
89
+ *
90
+ * Useful for tests that need to verify whether a subscriber landed in the
91
+ * publication after a marketing sync.
83
92
  *
84
93
  * @param {string} email
85
94
  * @param {string} publicationId
86
- * @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
95
+ * @returns {Promise<object|null>}
87
96
  */
88
- async function removeSubscriber(email, publicationId) {
97
+ async function findSubscriber(email, publicationId) {
89
98
  try {
90
99
  const encodedEmail = encodeURIComponent(email);
91
-
92
- // Step 1: Get subscription by email
93
- let searchData;
94
- try {
95
- searchData = await fetch(
96
- `${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
97
- {
98
- response: 'json',
99
- headers: headers(),
100
- timeout: 10000,
101
- }
102
- );
103
- } catch (e) {
104
- if (e.status === 404) {
105
- return { success: true, skipped: true, reason: 'Subscriber not found' };
100
+ const searchData = await fetch(
101
+ `${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
102
+ {
103
+ response: 'json',
104
+ headers: headers(),
105
+ timeout: BEEHIIV_TIMEOUT_MS,
106
106
  }
107
- throw e;
107
+ );
108
+
109
+ return searchData.data || null;
110
+ } catch (e) {
111
+ if (e.status === 404) {
112
+ return null;
108
113
  }
114
+ console.error('Beehiiv findSubscriber error:', e);
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Remove a subscriber from a Beehiiv publication by email.
121
+ *
122
+ * @param {string} email
123
+ * @param {string} publicationId
124
+ * @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
125
+ */
126
+ async function removeSubscriber(email, publicationId) {
127
+ try {
128
+ // Step 1: Look up the subscription
129
+ const subscription = await findSubscriber(email, publicationId);
109
130
 
110
- if (!searchData.data?.id) {
111
- return { success: true, skipped: true, reason: 'Subscription not found' };
131
+ if (!subscription?.id) {
132
+ return { success: true, skipped: true, reason: 'Subscriber not found' };
112
133
  }
113
134
 
114
- const subscriptionId = searchData.data.id;
135
+ const subscriptionId = subscription.id;
115
136
 
116
137
  // Step 2: Permanently delete the subscription
117
138
  await fetch(
@@ -119,7 +140,7 @@ async function removeSubscriber(email, publicationId) {
119
140
  {
120
141
  method: 'delete',
121
142
  headers: headers(),
122
- timeout: 10000,
143
+ timeout: BEEHIIV_TIMEOUT_MS,
123
144
  }
124
145
  );
125
146
 
@@ -186,7 +207,7 @@ function getPublicationId() {
186
207
  // const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
187
208
  // response: 'json',
188
209
  // headers: headers(),
189
- // timeout: 10000,
210
+ // timeout: BEEHIIV_TIMEOUT_MS,
190
211
  // });
191
212
  //
192
213
  // if (!data.data || data.data.length === 0) {
@@ -270,6 +291,25 @@ async function removeContact(email) {
270
291
  return removeSubscriber(email, publicationId);
271
292
  }
272
293
 
294
+ /**
295
+ * Look up a contact in this brand's Beehiiv publication. Resolves the
296
+ * publicationId from config and calls findSubscriber. Mirrors SendGrid's
297
+ * findContact() surface so tests can use the same pattern across both
298
+ * providers.
299
+ *
300
+ * @param {string} email
301
+ * @returns {Promise<object|null>}
302
+ */
303
+ async function findContact(email) {
304
+ const publicationId = getPublicationId();
305
+
306
+ if (!publicationId) {
307
+ return null;
308
+ }
309
+
310
+ return findSubscriber(email, publicationId);
311
+ }
312
+
273
313
  /**
274
314
  * Build Beehiiv custom_fields array from a user doc.
275
315
  * Resolves all field values, then maps to display names for Beehiiv.
@@ -316,7 +356,7 @@ async function resolveSegmentIds() {
316
356
  const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
317
357
  response: 'json',
318
358
  headers: headers(),
319
- timeout: 10000,
359
+ timeout: BEEHIIV_TIMEOUT_MS,
320
360
  });
321
361
 
322
362
  _segmentIdCache = {};
@@ -412,7 +452,7 @@ async function createPost(options) {
412
452
  method: 'post',
413
453
  response: 'json',
414
454
  headers: headers(),
415
- timeout: 15000,
455
+ timeout: BEEHIIV_TIMEOUT_MS,
416
456
  body,
417
457
  });
418
458
 
@@ -435,6 +475,8 @@ module.exports = {
435
475
 
436
476
  // Contacts
437
477
  addContact,
478
+ findContact,
479
+ findSubscriber,
438
480
  removeContact,
439
481
  buildFields,
440
482
 
@@ -115,7 +115,7 @@ async function upsertContacts({ contacts, listIds }) {
115
115
  method: 'put',
116
116
  response: 'json',
117
117
  headers: headers(),
118
- timeout: 15000,
118
+ timeout: SENDGRID_TIMEOUT_MS,
119
119
  body,
120
120
  });
121
121
 
@@ -131,14 +131,17 @@ async function upsertContacts({ contacts, listIds }) {
131
131
  }
132
132
 
133
133
  /**
134
- * Remove a contact from SendGrid by email address.
134
+ * Look up a SendGrid contact by email. Returns the contact object (id, email,
135
+ * list_ids, custom_fields, ...) or null if not found.
136
+ *
137
+ * Useful for tests that need to verify whether a contact landed in the list
138
+ * after a marketing sync.
135
139
  *
136
140
  * @param {string} email
137
- * @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
141
+ * @returns {Promise<object|null>}
138
142
  */
139
- async function removeContact(email) {
143
+ async function findContact(email) {
140
144
  try {
141
- // Step 1: Get contact ID by email
142
145
  const searchData = await fetch(`${BASE_URL}/marketing/contacts/search/emails`, {
143
146
  method: 'post',
144
147
  response: 'json',
@@ -147,11 +150,33 @@ async function removeContact(email) {
147
150
  body: { emails: [email] },
148
151
  });
149
152
 
150
- if (!searchData.result?.[email]?.contact?.id) {
153
+ return searchData.result?.[email]?.contact || null;
154
+ } catch (e) {
155
+ // 404 is the normal "not in contacts" response — return null silently.
156
+ if (e.status === 404) {
157
+ return null;
158
+ }
159
+ console.error('SendGrid findContact error:', e);
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Remove a contact from SendGrid by email address.
166
+ *
167
+ * @param {string} email
168
+ * @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
169
+ */
170
+ async function removeContact(email) {
171
+ try {
172
+ // Step 1: Get contact ID by email
173
+ const contact = await findContact(email);
174
+
175
+ if (!contact?.id) {
151
176
  return { success: true, skipped: true, reason: 'Contact not found' };
152
177
  }
153
178
 
154
- const contactId = searchData.result[email].contact.id;
179
+ const contactId = contact.id;
155
180
 
156
181
  // Step 2: Delete contact by ID
157
182
  const deleteData = await fetch(`${BASE_URL}/marketing/contacts?ids=${contactId}`, {
@@ -332,7 +357,7 @@ async function createSingleSend({ name, subject, preheader, templateId, from, se
332
357
  method: 'post',
333
358
  response: 'json',
334
359
  headers: headers(),
335
- timeout: 15000,
360
+ timeout: SENDGRID_TIMEOUT_MS,
336
361
  body,
337
362
  });
338
363
 
@@ -360,7 +385,7 @@ async function scheduleSingleSend(singleSendId, sendAt) {
360
385
  method: 'put',
361
386
  response: 'json',
362
387
  headers: headers(),
363
- timeout: 15000,
388
+ timeout: SENDGRID_TIMEOUT_MS,
364
389
  body: { send_at: sendAt },
365
390
  });
366
391
 
@@ -520,7 +545,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
520
545
  method: 'post',
521
546
  response: 'json',
522
547
  headers: headers(),
523
- timeout: 15000,
548
+ timeout: SENDGRID_TIMEOUT_MS,
524
549
  body: { segment_ids: [segmentId] },
525
550
  });
526
551
 
@@ -550,7 +575,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
550
575
  // Download CSV — disable cacheBreaker to preserve presigned S3 URL signature
551
576
  const csvText = await fetch(statusData.urls[0], {
552
577
  response: 'text',
553
- timeout: 30000,
578
+ timeout: 60000,
554
579
  cacheBreaker: false,
555
580
  });
556
581
 
@@ -614,7 +639,7 @@ async function bulkDeleteContacts(contactIds) {
614
639
  method: 'delete',
615
640
  response: 'json',
616
641
  headers: headers(),
617
- timeout: 15000,
642
+ timeout: SENDGRID_TIMEOUT_MS,
618
643
  });
619
644
 
620
645
  if (data.job_id) {
@@ -635,6 +660,7 @@ module.exports = {
635
660
 
636
661
  // Contacts
637
662
  addContact,
663
+ findContact,
638
664
  removeContact,
639
665
  getSegmentContacts,
640
666
  bulkDeleteContacts,
@@ -133,7 +133,7 @@ async function validate(email, options = {}) {
133
133
  try {
134
134
  const data = await fetch(
135
135
  `https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
136
- { response: 'json', timeout: 10000 }
136
+ { response: 'json', timeout: 60000 }
137
137
  );
138
138
 
139
139
  if (data.error) {
@@ -43,7 +43,7 @@ async function inferContactWithAI(email, assistant) {
43
43
  const ai = assistant.Manager.AI(assistant, process.env.BACKEND_MANAGER_OPENAI_API_KEY);
44
44
  const result = await ai.request({
45
45
  model: 'gpt-5-mini',
46
- timeout: 30000,
46
+ timeout: 60000,
47
47
  maxTokens: 1024,
48
48
  moderate: false,
49
49
  response: 'json',
@@ -22,10 +22,12 @@ module.exports = async ({ assistant, Manager, settings }) => {
22
22
  payload: {},
23
23
  };
24
24
 
25
- // Load email template
25
+ // Load email template — colons in id are converted to nested folders
26
+ // (e.g. "general:download-app-link" → templates/general/download-app-link.js)
27
+ const templatePath = settings.id.split(':').join('/');
26
28
  let emailPayload;
27
29
  try {
28
- const script = require(path.join(__dirname, 'templates', `${settings.id}.js`));
30
+ const script = require(path.join(__dirname, 'templates', `${templatePath}.js`));
29
31
  emailPayload = merge(
30
32
  {},
31
33
  DEFAULT,
@@ -166,7 +166,7 @@ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
166
166
  method: 'POST',
167
167
  response: 'json',
168
168
  headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
169
- timeout: 10000,
169
+ timeout: 60000,
170
170
  body: { recipient_emails: [email] },
171
171
  });
172
172
  } else {
@@ -176,7 +176,7 @@ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
176
176
  method: 'DELETE',
177
177
  response: 'text',
178
178
  headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
179
- timeout: 10000,
179
+ timeout: 60000,
180
180
  });
181
181
  }
182
182
  } catch (e) {
@@ -8,16 +8,16 @@ const powertools = require('node-powertools');
8
8
  *
9
9
  * Query params:
10
10
  * - provider: alert provider name (default: 'chargeblast')
11
- * - key: must match BACKEND_MANAGER_KEY
11
+ * - key: must match BACKEND_MANAGER_WEBHOOK_KEY (BACKEND_MANAGER_KEY accepted as legacy fallback)
12
12
  */
13
13
  module.exports = async ({ assistant, Manager, libraries }) => {
14
14
  const { admin } = libraries;
15
15
  const body = assistant.request.body;
16
16
  const query = assistant.request.query;
17
17
 
18
- // Validate key against BACKEND_MANAGER_KEY
18
+ // Validate key accept either BACKEND_MANAGER_WEBHOOK_KEY (preferred) or BACKEND_MANAGER_KEY (legacy)
19
19
  const key = query.key;
20
- if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
20
+ if (!key || (key !== process.env.BACKEND_MANAGER_WEBHOOK_KEY && key !== process.env.BACKEND_MANAGER_KEY)) {
21
21
  return assistant.respond('Invalid key', { code: 401 });
22
22
  }
23
23
 
@@ -155,12 +155,12 @@ async function createOneTimeIntent({ uid, orderId, product, productId, confirmat
155
155
  * Fire-and-forget webhook to trigger the full pipeline
156
156
  */
157
157
  function fireWebhook({ event, assistant }) {
158
- const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`;
158
+ const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`;
159
159
  fetch(webhookUrl, {
160
160
  method: 'POST',
161
161
  response: 'json',
162
162
  body: event,
163
- timeout: 15000,
163
+ timeout: 60000,
164
164
  }).catch((e) => {
165
165
  assistant.log(`Test processor auto-webhook failed: ${e.message}`);
166
166
  });
@@ -24,8 +24,8 @@ module.exports = async ({ assistant, Manager, libraries }) => {
24
24
  return assistant.respond('Missing processor parameter', { code: 400 });
25
25
  }
26
26
 
27
- // Validate key against BACKEND_MANAGER_KEY
28
- if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
27
+ // Validate key accept either BACKEND_MANAGER_WEBHOOK_KEY (preferred) or BACKEND_MANAGER_KEY (legacy)
28
+ if (!key || (key !== process.env.BACKEND_MANAGER_WEBHOOK_KEY && key !== process.env.BACKEND_MANAGER_KEY)) {
29
29
  return assistant.respond('Invalid key', { code: 401 });
30
30
  }
31
31
 
@@ -48,7 +48,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
48
48
 
49
49
  await fetch(`${Manager.project.apiUrl}/backend-manager/user/sessions`, {
50
50
  method: 'delete',
51
- timeout: 30000,
51
+ timeout: 60000,
52
52
  response: 'json',
53
53
  tries: 2,
54
54
  log: true,
@@ -22,7 +22,7 @@ module.exports = {
22
22
 
23
23
  const response = await fetch(this.urls.revoke, {
24
24
  method: 'POST',
25
- timeout: 30000,
25
+ timeout: 60000,
26
26
  body: new URLSearchParams({
27
27
  token,
28
28
  client_id: clientId,
@@ -27,7 +27,7 @@ module.exports = {
27
27
 
28
28
  const response = await fetch(this.urls.revoke, {
29
29
  method: 'POST',
30
- timeout: 30000,
30
+ timeout: 60000,
31
31
  body: new URLSearchParams({ token }),
32
32
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
33
33
  }).catch(e => e);
@@ -119,25 +119,6 @@ class TestRunner {
119
119
  await this.runTestsInDir(projectTestsDir, 'project');
120
120
  }
121
121
 
122
- // Post-run cleanup: scrub test accounts from third-party marketing providers
123
- // (SendGrid/Beehiiv) so each test run leaves the contact list in the same
124
- // state it found it. Pairs with the pre-run cleanup as defense in depth —
125
- // pre-run handles crashed previous runs, post-run handles the current run.
126
- // Only fires in extended mode (normal mode never touches real providers).
127
- if (process.env.TEST_EXTENDED_MODE) {
128
- process.stdout.write(chalk.gray('\n Cleaning up test accounts from marketing providers... '));
129
- try {
130
- const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
131
- apiUrl: this.options.apiUrl,
132
- backendManagerKey: this.options.backendManagerKey,
133
- });
134
- console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
135
- } catch (e) {
136
- // Post-run cleanup is best-effort — failures shouldn't change the test result
137
- console.log(chalk.yellow(`⚠ cleanup error: ${e.message}`));
138
- }
139
- }
140
-
141
122
  // Cleanup rules context
142
123
  if (this.rulesContext) {
143
124
  await this.rulesContext.cleanup();
@@ -166,6 +147,12 @@ class TestRunner {
166
147
  return false;
167
148
  }
168
149
 
150
+ if (!this.options.backendManagerWebhookKey) {
151
+ console.log(chalk.red(' ✗ Missing backendManagerWebhookKey'));
152
+ console.log(chalk.gray(' Set BEM_BACKEND_MANAGER_WEBHOOK_KEY environment variable or pass --webhook-key flag'));
153
+ return false;
154
+ }
155
+
169
156
  if (!this.options.brand?.id) {
170
157
  console.log(chalk.red(' ✗ Missing brand.id'));
171
158
  console.log(chalk.gray(' Could not determine brand ID from configuration'));
@@ -229,18 +216,6 @@ class TestRunner {
229
216
  const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
230
217
  console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
231
218
 
232
- // Clean any leftover test accounts from third-party marketing providers
233
- // (SendGrid/Beehiiv). Runs BEFORE we create fresh users so a previously
234
- // killed run doesn't leave the contact list polluted.
235
- if (process.env.TEST_EXTENDED_MODE) {
236
- process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
237
- const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
238
- apiUrl: this.options.apiUrl,
239
- backendManagerKey: this.options.backendManagerKey,
240
- });
241
- console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
242
- }
243
-
244
219
  process.stdout.write(chalk.gray(' Creating test accounts... '));
245
220
 
246
221
  // Create fresh test accounts
@@ -692,6 +667,7 @@ class TestRunner {
692
667
  timeout: this.options.timeout,
693
668
  accounts: this.accounts,
694
669
  backendManagerKey: this.options.backendManagerKey,
670
+ backendManagerWebhookKey: this.options.backendManagerWebhookKey,
695
671
  });
696
672
 
697
673
  // Set default auth