backend-manager 5.1.4 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +23 -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/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  40. package/test/events/payments/journey-payments-cancel.js +4 -5
  41. package/test/events/payments/journey-payments-failure.js +0 -1
  42. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  43. package/test/events/payments/journey-payments-one-time.js +6 -3
  44. package/test/events/payments/journey-payments-plan-change.js +5 -5
  45. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  46. package/test/events/payments/journey-payments-suspend.js +4 -5
  47. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  48. package/test/events/payments/journey-payments-trial.js +2 -3
  49. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  50. package/test/functions/admin/database-read.js +0 -14
  51. package/test/functions/admin/database-write.js +0 -14
  52. package/test/functions/admin/firestore-query.js +0 -14
  53. package/test/functions/admin/firestore-read.js +0 -15
  54. package/test/functions/admin/firestore-write.js +0 -11
  55. package/test/functions/general/add-marketing-contact.js +16 -14
  56. package/test/helpers/email.js +1 -1
  57. package/test/helpers/infer-contact.js +3 -3
  58. package/test/helpers/user.js +241 -2
  59. package/test/helpers/webhook-forward.js +392 -0
  60. package/test/marketing/newsletter-generate.js +17 -7
  61. package/test/routes/admin/database.js +0 -13
  62. package/test/routes/admin/firestore-query.js +0 -13
  63. package/test/routes/admin/firestore.js +0 -14
  64. package/test/routes/admin/infer-contact.js +6 -3
  65. package/test/routes/admin/post.js +4 -2
  66. package/test/routes/marketing/contact.js +60 -26
  67. package/test/routes/marketing/email-preferences.js +145 -69
  68. package/test/routes/marketing/webhook-forward.js +54 -0
  69. package/test/routes/marketing/webhook.js +582 -0
  70. package/test/routes/payments/cancel.js +2 -7
  71. package/test/routes/payments/dispute-alert.js +0 -39
  72. package/test/routes/payments/refund.js +3 -1
  73. package/test/routes/payments/webhook.js +5 -26
  74. package/test/routes/test/usage.js +2 -2
  75. package/test/routes/user/signup.js +114 -0
@@ -25,11 +25,63 @@ class ServeCommand extends BaseCommand {
25
25
  // Start Stripe webhook forwarding in background
26
26
  this.startStripeWebhookForwarding();
27
27
 
28
- // Set up log file in the project directory
28
+ // Set up log file in the project directory.
29
+ // Mirrors the emulator.js pattern: the file is truncated on boot and on every
30
+ // hot reload. Two reset signals are honored:
31
+ // 1. Sentinel file (serve.log.reset) — used by the BEM watcher when source
32
+ // changes in backend-manager itself trigger a reload.
33
+ // 2. Reload marker on stdout (`Using node@22 from host.`) — catches reloads
34
+ // triggered by firebase serve's own internal watcher (any change inside
35
+ // the consumer's functions/ directory). MUST be the line firebase-tools
36
+ // prints at the START of each reload cycle, not somewhere in the middle.
37
+ // If we rolled mid-cycle (e.g. on "Loaded functions definitions"), the
38
+ // tail of the reload sequence still gets captured into the fresh log;
39
+ // but firebase serve sometimes only emits the trailing function-initialized
40
+ // lines on the first cycle (subsequent cycles route those elsewhere), so
41
+ // we'd end up with a near-empty log. Rolling at the START of the cycle
42
+ // lets us capture whatever firebase-tools does emit, complete-or-not.
29
43
  const logPath = path.join(projectDir, 'functions', 'serve.log');
30
- const logStream = fs.createWriteStream(logPath, { flags: 'w' });
44
+ const resetSentinelPath = `${logPath}.reset`;
45
+ // Match any node version: "Using node@22 from host.", "Using node@20 from host.", etc.
46
+ const RELOAD_MARKER = /Using node@\d+ from host\./;
31
47
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
32
48
 
49
+ let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
50
+ let reloadCount = 0; // skip rolling on the first marker (initial boot, not a reload)
51
+
52
+ function rollLog() {
53
+ try {
54
+ const oldStream = currentStream;
55
+ currentStream = fs.createWriteStream(logPath, { flags: 'w' });
56
+ oldStream.end();
57
+ } catch (e) {
58
+ // Best-effort. If roll fails, serve keeps running with the existing stream.
59
+ }
60
+ }
61
+
62
+ function writeToLog(data) {
63
+ if (currentStream && !currentStream.destroyed) {
64
+ currentStream.write(stripAnsi(data.toString()));
65
+ }
66
+ }
67
+
68
+ // Clean up any stale sentinel from a prior crashed serve run
69
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
70
+
71
+ // Poll every 500ms for the reset sentinel — cheap, no fs.watch quirks
72
+ const resetWatcher = setInterval(() => {
73
+ if (!fs.existsSync(resetSentinelPath)) {
74
+ return;
75
+ }
76
+
77
+ try {
78
+ rollLog();
79
+ fs.unlinkSync(resetSentinelPath);
80
+ } catch (e) {
81
+ // Best-effort.
82
+ }
83
+ }, 500);
84
+
33
85
  this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
34
86
 
35
87
  // Execute with tee to log file
@@ -42,21 +94,35 @@ class ServeCommand extends BaseCommand {
42
94
  env: { ...process.env, FORCE_COLOR: '1' },
43
95
  },
44
96
  }, (child) => {
45
- // Tee stdout to both console and log file (strip ANSI codes for clean log)
97
+ // Tee stdout to both console and log file (strip ANSI codes for clean log).
98
+ // Watch each chunk for the reload marker — when seen (after the initial boot),
99
+ // roll the log BEFORE writing this chunk so the marker becomes the first
100
+ // line of the fresh file.
46
101
  child.stdout.on('data', (data) => {
47
102
  process.stdout.write(data);
48
- logStream.write(stripAnsi(data.toString()));
103
+ const text = data.toString();
104
+ if (RELOAD_MARKER.test(text)) {
105
+ reloadCount++;
106
+ if (reloadCount > 1) {
107
+ rollLog();
108
+ }
109
+ }
110
+ writeToLog(data);
49
111
  });
50
112
 
51
113
  // Tee stderr to both console and log file (strip ANSI codes for clean log)
52
114
  child.stderr.on('data', (data) => {
53
115
  process.stderr.write(data);
54
- logStream.write(stripAnsi(data.toString()));
116
+ writeToLog(data);
55
117
  });
56
118
 
57
- // Clean up log stream when child exits
119
+ // Clean up log stream + watcher when child exits
58
120
  child.on('close', () => {
59
- logStream.end();
121
+ clearInterval(resetWatcher);
122
+ if (currentStream && !currentStream.destroyed) {
123
+ currentStream.end();
124
+ }
125
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
60
126
  });
61
127
  });
62
128
  } catch (error) {
@@ -51,7 +51,7 @@ class TestCommand extends BaseCommand {
51
51
  // Use hosting URL for all API requests (rewrites to bm_api function)
52
52
  const testConfig = {
53
53
  ...projectConfig,
54
- hostingUrl: `http://127.0.0.1:${emulatorPorts.hosting}`,
54
+ apiUrl: `http://127.0.0.1:${emulatorPorts.hosting}`,
55
55
  projectDir,
56
56
  testPaths,
57
57
  emulatorPorts,
@@ -185,12 +185,58 @@ class TestCommand extends BaseCommand {
185
185
  return this.isPortInUse(emulatorPorts.functions);
186
186
  }
187
187
 
188
+ /**
189
+ * Signal the running emulator process to roll emulator.log.
190
+ *
191
+ * Mechanism: write a sentinel file at emulator.log.reset. The emulator command
192
+ * (src/cli/commands/emulator.js) polls for it and, on detection, closes its
193
+ * current write stream and reopens with flags: 'w' (truncating cleanly from its
194
+ * own perspective — avoids the sparse-file problem caused by external truncation).
195
+ *
196
+ * Waits up to 2s for the sentinel to be consumed. If it's still there after 2s
197
+ * the emulator isn't watching (probably running an older BEM or started outside
198
+ * `npx mgr emulator`); we delete the sentinel and proceed — tests still run, the
199
+ * log just won't be reset for this run.
200
+ */
201
+ async requestEmulatorLogReset(projectDir) {
202
+ const logPath = path.join(projectDir, 'functions', 'emulator.log');
203
+ const sentinelPath = `${logPath}.reset`;
204
+
205
+ try {
206
+ fs.writeFileSync(sentinelPath, '');
207
+ } catch (e) {
208
+ return; // Can't write — skip, not fatal
209
+ }
210
+
211
+ // Poll for the emulator to consume the sentinel (it deletes the file when done)
212
+ const maxWaitMs = 2000;
213
+ const pollIntervalMs = 100;
214
+ const start = Date.now();
215
+
216
+ while (Date.now() - start < maxWaitMs) {
217
+ if (!fs.existsSync(sentinelPath)) {
218
+ return; // Emulator picked it up and rolled the log
219
+ }
220
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
221
+ }
222
+
223
+ // Timed out — emulator didn't see the sentinel. Clean up so we don't leave it behind.
224
+ try { fs.unlinkSync(sentinelPath); } catch (e) { /* ok */ }
225
+ }
226
+
188
227
  /**
189
228
  * Run tests directly (emulator already running)
190
229
  */
191
230
  async runTestsDirectly(testCommand, functionsDir, emulatorPorts) {
192
231
  const projectDir = this.main.firebaseProjectPath;
193
232
 
233
+ // Ask the running emulator process to roll emulator.log so this test run gets a
234
+ // clean slate. We touch a sentinel file the emulator polls for (every ~500ms) and
235
+ // wait briefly for it to be consumed. If the emulator isn't watching (older BEM
236
+ // version, or not started via `npx mgr emulator`), we time out silently — the log
237
+ // just won't be fresh, tests still run normally.
238
+ await this.requestEmulatorLogReset(projectDir);
239
+
194
240
  this.log(chalk.gray(` Hosting: http://127.0.0.1:${emulatorPorts.hosting}`));
195
241
  this.log(chalk.gray(` Firestore: 127.0.0.1:${emulatorPorts.firestore}`));
196
242
  this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
@@ -53,13 +53,21 @@ class WatchCommand extends BaseCommand {
53
53
  // So we must: 1) ensure file exists, 2) wait for FS to settle, 3) write new content
54
54
  // --on-change-only: only run exec when files change, not on initial startup
55
55
  // --delay 1: debounce multiple rapid changes into one trigger
56
+ //
57
+ // The exec also drops <log>.reset sentinels so the parent serve/emulator command
58
+ // rolls its log file cleanly on every hot reload (mirrors the emulator log-roll
59
+ // pattern used by the test runner). Sentinels are best-effort — if a parent
60
+ // command isn't watching, the file is harmless and gets cleaned up by the next
61
+ // boot's stale-sentinel sweep.
56
62
  const triggerFile = config.triggerFile;
63
+ const serveLogResetPath = path.join(config.functionsDir, 'serve.log.reset');
64
+ const emulatorLogResetPath = path.join(config.functionsDir, 'emulator.log.reset');
57
65
  const nodemon = spawn(nodemonPath, [
58
66
  '--on-change-only',
59
67
  '--delay', '1',
60
68
  '--watch', config.bemSrcDir,
61
69
  '--ext', 'js,json',
62
- '--exec', `node -e "var f='${triggerFile}',fs=require('fs');if(!fs.existsSync(f)){fs.writeFileSync(f,'// init');require('child_process').execSync('sleep 0.1');}fs.writeFileSync(f,'// '+Date.now())" && echo " [BEM] Triggered hot reload"`,
70
+ '--exec', `node -e "var f='${triggerFile}',fs=require('fs');if(!fs.existsSync(f)){fs.writeFileSync(f,'// init');require('child_process').execSync('sleep 0.1');}fs.writeFileSync(f,'// '+Date.now());try{fs.writeFileSync('${serveLogResetPath}','');}catch(e){}try{fs.writeFileSync('${emulatorLogResetPath}','');}catch(e){}" && echo " [BEM] Triggered hot reload"`,
63
71
  ], {
64
72
  stdio: 'inherit',
65
73
  detached: false,
@@ -93,11 +101,15 @@ class WatchCommand extends BaseCommand {
93
101
  jetpack.write(config.triggerFile, `// BEM reload trigger\n`);
94
102
  }
95
103
 
96
- // Use nodemon to watch the BEM src directory and touch the trigger file on changes
104
+ // Use nodemon to watch the BEM src directory and touch the trigger file on changes.
105
+ // Also drop <log>.reset sentinels so any sibling serve/emulator command rolls its
106
+ // log file on each reload (mirrors the test runner's log-roll pattern).
107
+ const serveLogResetPath = path.join(config.functionsDir, 'serve.log.reset');
108
+ const emulatorLogResetPath = path.join(config.functionsDir, 'emulator.log.reset');
97
109
  const nodemon = spawn(nodemonPath, [
98
110
  '--watch', config.bemSrcDir,
99
111
  '--ext', 'js,json',
100
- '--exec', `touch "${config.triggerFile}" && echo " → Triggered hot reload"`,
112
+ '--exec', `touch "${config.triggerFile}" "${serveLogResetPath}" "${emulatorLogResetPath}" && echo " → Triggered hot reload"`,
101
113
  ], {
102
114
  stdio: 'inherit',
103
115
  cwd: config.bemDir,
@@ -147,6 +147,35 @@ const SCHEMA = {
147
147
  page: { type: 'string', default: null, nullable: true },
148
148
  },
149
149
  },
150
+ consent: {
151
+ legal: {
152
+ status: { type: 'string', default: 'revoked' },
153
+ grantedAt: {
154
+ timestamp: { type: 'string', default: null, nullable: true },
155
+ timestampUNIX: { type: 'number', default: null, nullable: true },
156
+ source: { type: 'string', default: null, nullable: true },
157
+ ip: { type: 'string', default: null, nullable: true },
158
+ text: { type: 'string', default: null, nullable: true },
159
+ },
160
+ },
161
+ marketing: {
162
+ status: { type: 'string', default: 'revoked' },
163
+ grantedAt: {
164
+ timestamp: { type: 'string', default: null, nullable: true },
165
+ timestampUNIX: { type: 'number', default: null, nullable: true },
166
+ source: { type: 'string', default: null, nullable: true },
167
+ ip: { type: 'string', default: null, nullable: true },
168
+ text: { type: 'string', default: null, nullable: true },
169
+ },
170
+ revokedAt: {
171
+ timestamp: { type: 'string', default: null, nullable: true },
172
+ timestampUNIX: { type: 'number', default: null, nullable: true },
173
+ source: { type: 'string', default: null, nullable: true },
174
+ ip: { type: 'string', default: null, nullable: true },
175
+ text: { type: 'string', default: null, nullable: true },
176
+ },
177
+ },
178
+ },
150
179
  };
151
180
 
152
181
  /**
@@ -210,6 +210,35 @@ Manager.prototype.init = function (exporter, options) {
210
210
  : self.config.brand?.url || '';
211
211
  };
212
212
 
213
+ // Resolve the parent BEM's website URL (the parent's brand domain, NO `api.` subdomain).
214
+ // - If config.parent === 'self', THIS BEM is the parent — returns this brand's own URL.
215
+ // - If config.parent is a URL, returns it as-is.
216
+ // - Returns '' if neither is configured.
217
+ // Use getParentApiUrl() for the API URL (with `api.` subdomain inserted).
218
+ self.getParentUrl = function() {
219
+ const parent = self.config.parent;
220
+ if (parent === 'self') {
221
+ return self.config.brand?.url || '';
222
+ }
223
+ return parent || '';
224
+ };
225
+
226
+ // Resolve the parent BEM's API URL (`https://api.{parent-host}`).
227
+ // ALWAYS returns the live production URL — even when THIS brand is running
228
+ // in dev/test mode. The parent's API is a real remote server (no localhost
229
+ // equivalent), so dev-mode does NOT redirect to localhost the way getApiUrl()
230
+ // does. Use this when you need to call the parent's API from any environment.
231
+ self.getParentApiUrl = function() {
232
+ const base = self.getParentUrl().replace(/^https?:\/\//, '');
233
+ return base ? `https://api.${base}` : '';
234
+ };
235
+
236
+ // Returns true when this BEM IS the parent (config.parent === 'self').
237
+ // Gates parent-only routes like /marketing/webhook/forward.
238
+ self.isParent = function() {
239
+ return self.config.parent === 'self';
240
+ };
241
+
213
242
  // Set more properties (need to wait for assistant to determine if DEV)
214
243
  self.project.functionsUrl = self.getFunctionsUrl();
215
244
 
@@ -242,6 +242,7 @@
242
242
  "ac20mail.in",
243
243
  "academiccommunity.com",
244
244
  "academywe.us",
245
+ "acanok.com",
245
246
  "acc1s.com",
246
247
  "acc1s.net",
247
248
  "accclone.com",
@@ -653,6 +654,7 @@
653
654
  "biojuris.com",
654
655
  "bione.co",
655
656
  "bipochub.com",
657
+ "bitmah.com",
656
658
  "bitmens.com",
657
659
  "bitwhites.top",
658
660
  "bitymails.us",
@@ -1072,6 +1074,7 @@
1072
1074
  "dao.pp.ua",
1073
1075
  "daouse.com",
1074
1076
  "dapurx.me",
1077
+ "dardr.com",
1075
1078
  "darkharvestfilms.com",
1076
1079
  "daryxfox.net",
1077
1080
  "dasdasdascyka.tk",
@@ -1937,6 +1940,7 @@
1937
1940
  "getairmail.gq",
1938
1941
  "getairmail.ml",
1939
1942
  "getairmail.tk",
1943
+ "getasail.com",
1940
1944
  "geteit.com",
1941
1945
  "getfun.men",
1942
1946
  "getmail1.com",
@@ -2164,6 +2168,7 @@
2164
2168
  "hidemail.pro",
2165
2169
  "hidemail.us",
2166
2170
  "hidesmail.net",
2171
+ "hidevak.com",
2167
2172
  "hidingmail.net",
2168
2173
  "hidmail.org",
2169
2174
  "hidzz.com",
@@ -2173,6 +2178,7 @@
2173
2178
  "highstar.shop",
2174
2179
  "hihi.lol",
2175
2180
  "hikuhu.com",
2181
+ "hilostar.com",
2176
2182
  "hiltonvr.com",
2177
2183
  "him6.com",
2178
2184
  "himail.infos.st",
@@ -2190,6 +2196,7 @@
2190
2196
  "holio.day",
2191
2197
  "holl.ga",
2192
2198
  "homecut.pro",
2199
+ "homvela.com",
2193
2200
  "honesthirianinda.net",
2194
2201
  "honeys.be",
2195
2202
  "honor-8.com",
@@ -2434,6 +2441,7 @@
2434
2441
  "itcompu.com",
2435
2442
  "itfast.net",
2436
2443
  "itmo.edu.pl",
2444
+ "itquoted.com",
2437
2445
  "itsbds.com",
2438
2446
  "itsedit.click",
2439
2447
  "itsjiff.com",
@@ -77,7 +77,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
77
77
  return null;
78
78
  }
79
79
 
80
- const parentUrl = Manager.config?.parent;
80
+ const parentUrl = Manager.getParentApiUrl();
81
81
 
82
82
  if (!parentUrl) {
83
83
  assistant.log('Newsletter generator: no parent URL configured');
@@ -311,7 +311,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
311
311
 
312
312
  // 4. Mark sources as used on parent server (unless caller opted out)
313
313
  if (!opts.skipClaim) {
314
- const parentUrl = Manager.config?.parent;
314
+ const parentUrl = Manager.getParentApiUrl();
315
315
 
316
316
  if (parentUrl) {
317
317
  await claimSources(parentUrl, sources, brand?.id, assistant);
@@ -415,6 +415,7 @@ async function createPost(options) {
415
415
  module.exports = {
416
416
  // Resolution
417
417
  resolveSegmentIds,
418
+ getPublicationId,
418
419
 
419
420
  // Contacts
420
421
  addContact,
@@ -452,6 +452,18 @@ function resolveProduct(raw, config) {
452
452
  return { id: 'basic', name: 'Basic' };
453
453
  }
454
454
 
455
+ // Test-mode sentinel: the test processor synthesizes "_test_<id>" when no real
456
+ // Stripe product is configured. Map it back to the matching BEM product so the
457
+ // pipeline can be exercised end-to-end without real Stripe credentials.
458
+ if (typeof stripeProductId === 'string' && stripeProductId.startsWith('_test_')) {
459
+ const bemId = stripeProductId.slice('_test_'.length);
460
+ const product = config.payment.products.find((p) => p.id === bemId);
461
+ if (product) {
462
+ return { id: product.id, name: product.name || product.id };
463
+ }
464
+ return { id: 'basic', name: 'Basic' };
465
+ }
466
+
455
467
  for (const product of config.payment.products) {
456
468
  // Match current product ID
457
469
  if (product.stripe?.productId === stripeProductId) {
@@ -174,5 +174,12 @@ function resolveStripeProductId(productId, config) {
174
174
 
175
175
  const product = config.payment.products.find(p => p.id === productId);
176
176
 
177
- return product?.stripe?.productId || null;
177
+ if (!product) {
178
+ return null;
179
+ }
180
+
181
+ // Real Stripe product ID if configured, otherwise the "_test_<id>" sentinel that the
182
+ // Stripe resolver recognizes and maps back to the BEM product. Lets reconstruction
183
+ // work in brands without real Stripe (Somiibo uses PayPal, Chargebee, etc.).
184
+ return product.stripe?.productId || `_test_${product.id}`;
178
185
  }
@@ -16,8 +16,9 @@ module.exports = async ({ assistant, user, settings }) => {
16
16
  return assistant.respond('Admin required.', { code: 403 });
17
17
  }
18
18
 
19
- // Accept single email or array of emails
20
- const emails = Array.isArray(settings.emails)
19
+ // Accept single email or array of emails. Schema defaults `emails` to [], so check length:
20
+ // an empty default should NOT shadow a provided single `email`.
21
+ const emails = Array.isArray(settings.emails) && settings.emails.length > 0
21
22
  ? settings.emails
22
23
  : [settings.email];
23
24