backend-manager 5.2.5 → 5.2.7

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 (43) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +159 -0
  3. package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +15 -0
  4. package/TODO-WEBHOOK-KEY-UPGRADE.md +138 -0
  5. package/docs/stripe-webhook-forwarding.md +2 -2
  6. package/package.json +1 -1
  7. package/scripts/test-helper-providers.js +162 -0
  8. package/src/cli/commands/base-command.js +5 -5
  9. package/src/cli/commands/emulator.js +201 -54
  10. package/src/cli/commands/test.js +80 -9
  11. package/src/manager/events/cron/daily/ghostii-auto-publisher.js +2 -2
  12. package/src/manager/events/firestore/payments-webhooks/analytics.js +2 -2
  13. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  14. package/src/manager/helpers/analytics.js +1 -1
  15. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  16. package/src/manager/libraries/email/providers/beehiiv.js +11 -6
  17. package/src/manager/libraries/email/providers/sendgrid.js +6 -6
  18. package/src/manager/libraries/email/validation.js +1 -1
  19. package/src/manager/libraries/infer-contact.js +12 -5
  20. package/src/manager/routes/general/email/post.js +4 -2
  21. package/src/manager/routes/marketing/email-preferences/post.js +2 -2
  22. package/src/manager/routes/payments/dispute-alert/post.js +3 -3
  23. package/src/manager/routes/payments/intent/processors/test.js +2 -2
  24. package/src/manager/routes/payments/webhook/post.js +2 -2
  25. package/src/manager/routes/user/delete.js +1 -1
  26. package/src/manager/routes/user/oauth2/providers/discord.js +1 -1
  27. package/src/manager/routes/user/oauth2/providers/google.js +1 -1
  28. package/src/manager/routes/user/signup/post.js +1 -0
  29. package/src/test/runner.js +7 -0
  30. package/src/test/utils/http-client.js +1 -0
  31. package/test/events/payments/journey-payments-cancel.js +4 -4
  32. package/test/events/payments/journey-payments-failure.js +2 -2
  33. package/test/events/payments/journey-payments-legacy-product.js +1 -1
  34. package/test/events/payments/journey-payments-one-time-failure.js +1 -1
  35. package/test/events/payments/journey-payments-plan-change.js +1 -1
  36. package/test/events/payments/journey-payments-refund-webhook.js +4 -4
  37. package/test/events/payments/journey-payments-suspend.js +4 -4
  38. package/test/events/payments/journey-payments-trial.js +2 -2
  39. package/test/events/payments/journey-payments-uid-resolution.js +1 -1
  40. package/test/routes/payments/dispute-alert.js +13 -13
  41. package/test/routes/payments/webhook.js +3 -3
  42. package/test/routes/test/usage.js +12 -3
  43. /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
@@ -1,15 +1,20 @@
1
1
  const BaseCommand = require('./base-command');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const { spawn } = require('child_process');
4
5
  const chalk = require('chalk').default;
5
6
  const jetpack = require('fs-jetpack');
6
7
  const JSON5 = require('json5');
7
- const powertools = require('node-powertools');
8
8
  const WatchCommand = require('./watch');
9
9
  const { DEFAULT_EMULATOR_PORTS } = require('./setup-tests/emulator-config');
10
10
  const { EXTENDED_MODE_WARNING } = require('../../test/utils/extended-mode-warning');
11
11
  const { writeTestMode, captureSyncedEnv } = require('../../test/utils/test-mode-file');
12
12
 
13
+ // Used by both `npx mgr emulator` and `npx mgr test` auto-start path.
14
+ // Note: `emulators:start` enables the UI by default (controlled by firebase.json's
15
+ // `emulators.ui.enabled`), so no `--ui` flag here — that flag only exists on `:exec`.
16
+ const EMULATOR_FLAGS = '--only functions,firestore,auth,database,hosting,pubsub';
17
+
13
18
  class EmulatorCommand extends BaseCommand {
14
19
  async execute() {
15
20
  this.log(chalk.cyan('\n Starting Firebase emulator (keep-alive mode)...\n'));
@@ -45,23 +50,40 @@ class EmulatorCommand extends BaseCommand {
45
50
  // Start Stripe webhook forwarding in background
46
51
  this.startStripeWebhookForwarding();
47
52
 
48
- // Run emulator with keep-alive command (use single quotes since runWithEmulator wraps in double quotes)
49
- const keepAliveCommand = "echo ''; echo 'Emulator ready. Press Ctrl+C to shut down...'; sleep 86400";
50
-
53
+ // Keep-alive: boot emulators and wait for Ctrl+C. No "command" subprocess
54
+ // the emulator child IS the foreground process from the user's perspective.
51
55
  try {
52
- await this.runWithEmulator(keepAliveCommand);
53
- } catch (error) {
54
- // User pressed Ctrl+C - this is expected
56
+ const { shutdown, exitPromise } = await this.startEmulators();
57
+
58
+ this.log(chalk.gray('\n Emulator ready. Press Ctrl+C to shut down...\n'));
59
+
60
+ const onSigint = async () => {
61
+ this.log(chalk.gray('\n Shutting down emulator...'));
62
+ await shutdown();
63
+ this.log(chalk.gray(' Emulator stopped.\n'));
64
+ process.exit(0);
65
+ };
66
+ process.once('SIGINT', onSigint);
67
+
68
+ // Resolve if the emulator dies on its own (crash, port conflict, etc.)
69
+ await exitPromise;
70
+ process.removeListener('SIGINT', onSigint);
55
71
  this.log(chalk.gray('\n Emulator stopped.\n'));
72
+ } catch (error) {
73
+ this.logError(`Emulator error: ${error.message || error}`);
74
+ process.exit(1);
56
75
  }
57
76
  }
58
77
 
59
78
  /**
60
- * Run a command with Firebase emulator
61
- * @param {string} command - The command to execute inside the Firebase emulator
62
- * @returns {Promise<void>}
79
+ * Boot Firebase emulators as a long-running child process.
80
+ * Stdout/stderr are teed to console + emulator.log.
81
+ * Resolves once the emulator hub is listening (i.e., emulators are ready).
82
+ * Caller is responsible for calling shutdown() to send SIGTERM and wait for exit.
83
+ *
84
+ * @returns {Promise<{ child: ChildProcess, shutdown: () => Promise<void>, emulatorPorts: object }>}
63
85
  */
64
- async runWithEmulator(command) {
86
+ async startEmulators() {
65
87
  const projectDir = this.main.firebaseProjectPath;
66
88
 
67
89
  // Load emulator ports from firebase.json
@@ -73,24 +95,13 @@ class EmulatorCommand extends BaseCommand {
73
95
  throw new Error('Port conflicts could not be resolved');
74
96
  }
75
97
 
76
- // Wipe stale firebase-tools debug logs + any leftover BEM logs from older
77
- // versions. Keeps the project tree clean across runs.
98
+ // Wipe stale firebase-tools debug logs + any leftover BEM logs from older versions.
78
99
  this.sweepStaleLogs();
79
100
 
80
- // BEM_TESTING=true is passed so Functions skip external API calls (emails, SendGrid)
81
- // hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
82
- // pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
83
- // Use double quotes for command wrapper since the command may contain single quotes (JSON strings)
84
- const envPrefix = process.env.TEST_EXTENDED_MODE
85
- ? 'BEM_TESTING=true TEST_EXTENDED_MODE=true'
86
- : 'BEM_TESTING=true';
87
- const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
88
-
89
- // Set up log file in the project directory.
90
- // We use a mutable `currentStream` so the test command can request a fresh log
91
- // by touching emulator.log.reset — the watcher below detects it, closes the
92
- // current stream, reopens with flags: 'w' (truncating cleanly from our process'
93
- // perspective, no sparse-file issue), and deletes the sentinel.
101
+ // Set up log file + reset-sentinel watcher.
102
+ // Mutable `currentStream` so the test command can request a fresh log by touching
103
+ // emulator.log.reset the watcher detects it, closes the current stream, and
104
+ // reopens with flags: 'w' (truncating cleanly from our process' perspective).
94
105
  const logPath = this.getLogsPath('emulator.log');
95
106
  const resetSentinelPath = this.getTempPath('emulator.log.reset');
96
107
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
@@ -103,11 +114,9 @@ class EmulatorCommand extends BaseCommand {
103
114
  }
104
115
  }
105
116
 
106
- // Clean up any stale sentinel from a prior crashed emulator run
117
+ // Clean up any stale sentinel from a prior crashed run
107
118
  try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
108
119
 
109
- // Watch for the test command's request to roll the log.
110
- // Poll every 500ms — cheap, no fs.watch quirks across platforms.
111
120
  const resetWatcher = setInterval(() => {
112
121
  if (!fs.existsSync(resetSentinelPath)) {
113
122
  return;
@@ -129,37 +138,175 @@ class EmulatorCommand extends BaseCommand {
129
138
  writeToLog('\n');
130
139
  }
131
140
 
132
- this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
141
+ this.log(chalk.gray(` Logs saving to: ${logPath}`));
142
+
143
+ // BEM_TESTING=true is passed so Functions skip external API calls (emails, SendGrid)
144
+ // hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
145
+ // pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
146
+ const env = {
147
+ ...process.env,
148
+ FORCE_COLOR: '1',
149
+ BEM_TESTING: 'true',
150
+ };
133
151
 
134
- await powertools.execute(emulatorCommand, {
135
- log: false,
152
+ // Spawn `firebase emulators:start` as a background child. Use `sh -c` so the
153
+ // user's shell PATH resolves `firebase` consistently with the interactive shell.
154
+ //
155
+ // `detached: true` puts the child into its own process group. We need this so that
156
+ // shutdown() can kill the entire group (sh → firebase → java emulators) by
157
+ // signalling the negative pgid. Without it, SIGTERM to the shell doesn't propagate
158
+ // to firebase or its java grandchildren, leaving orphan firestore/pubsub processes.
159
+ const child = spawn('sh', ['-c', `firebase emulators:start ${EMULATOR_FLAGS}`], {
136
160
  cwd: projectDir,
137
- config: {
138
- stdio: ['inherit', 'pipe', 'pipe'],
161
+ env,
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ detached: true,
164
+ });
165
+
166
+ // Wire readiness detection into the stdout/stderr handlers.
167
+ //
168
+ // We watch for firebase-tools' explicit "All emulators ready!" line — that's the
169
+ // signal that function discovery + load is complete and the runtime can serve HTTP.
170
+ // Port-listening alone isn't enough: firebase-tools binds the functions socket
171
+ // ~5-10s before user functions are actually loadable, so HTTP requests fail with
172
+ // ECONNREFUSED / "fetch failed" if we proceed when only the port is open.
173
+ let readyResolve;
174
+ let readyReject;
175
+ const readyPromise = new Promise((resolve, reject) => {
176
+ readyResolve = resolve;
177
+ readyReject = reject;
178
+ });
179
+ let ready = false;
180
+ const READY_MARKER = /All emulators ready/i;
181
+
182
+ child.stdout.on('data', (data) => {
183
+ process.stdout.write(data);
184
+ writeToLog(data);
185
+ if (!ready && READY_MARKER.test(data.toString())) {
186
+ ready = true;
187
+ readyResolve();
188
+ }
189
+ });
190
+
191
+ child.stderr.on('data', (data) => {
192
+ process.stderr.write(data);
193
+ writeToLog(data);
194
+ // firebase-tools prints the ready line to stderr sometimes — watch both.
195
+ if (!ready && READY_MARKER.test(data.toString())) {
196
+ ready = true;
197
+ readyResolve();
198
+ }
199
+ });
200
+
201
+ // Track exit state so shutdown() can resolve when the process is gone
202
+ let exitPromiseResolve;
203
+ const exitPromise = new Promise((resolve) => {
204
+ exitPromiseResolve = resolve;
205
+ });
206
+
207
+ child.on('close', (code, signal) => {
208
+ clearInterval(resetWatcher);
209
+ if (currentStream && !currentStream.destroyed) {
210
+ currentStream.end();
211
+ }
212
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
213
+ exitPromiseResolve({ code, signal });
214
+ // If we exited before becoming ready, fail the readiness wait too
215
+ if (!ready) {
216
+ readyReject(new Error(`Emulator child exited before ready (code=${code}, signal=${signal})`));
217
+ }
218
+ });
219
+
220
+ // Race the readiness marker against a 60s timeout
221
+ const readyTimeoutMs = 60000;
222
+ await Promise.race([
223
+ readyPromise,
224
+ new Promise((_, reject) => setTimeout(
225
+ () => reject(new Error(`Emulator did not print "All emulators ready" within ${readyTimeoutMs}ms`)),
226
+ readyTimeoutMs,
227
+ )),
228
+ ]);
229
+
230
+ // shutdown() signals the entire emulator process group (sh + firebase + java
231
+ // grandchildren), waits up to 10s for clean exit, then escalates to SIGKILL.
232
+ //
233
+ // We use `process.kill(-pgid, ...)` instead of `child.kill(...)` because firebase
234
+ // tools spawns several Java subprocesses (firestore + pubsub) that survive if
235
+ // only the sh wrapper is killed. The negative PID targets the whole process group
236
+ // (made possible by `detached: true` above).
237
+ const killGroup = (signal) => {
238
+ try {
239
+ process.kill(-child.pid, signal);
240
+ } catch (e) {
241
+ // ESRCH = group already dead, that's fine
242
+ if (e.code !== 'ESRCH') throw e;
243
+ }
244
+ };
245
+
246
+ const shutdown = async () => {
247
+ if (child.exitCode !== null || child.signalCode !== null) {
248
+ return; // already gone
249
+ }
250
+
251
+ killGroup('SIGTERM');
252
+
253
+ const killTimer = setTimeout(() => {
254
+ if (child.exitCode === null && child.signalCode === null) {
255
+ killGroup('SIGKILL');
256
+ }
257
+ }, 10000);
258
+
259
+ await exitPromise;
260
+ clearTimeout(killTimer);
261
+ };
262
+
263
+ return { child, shutdown, emulatorPorts, exitPromise };
264
+ }
265
+
266
+ /**
267
+ * Boot emulators and run a single command against them. Sends SIGTERM to the emulator
268
+ * when the command exits (or this process is interrupted) and waits for clean shutdown.
269
+ *
270
+ * Used by `npx mgr emulator` for the keep-alive flow (command is a no-op sleep).
271
+ * `npx mgr test`'s auto-start path uses startEmulators() directly so it can tee the
272
+ * test command's output to its own log (test.log) separate from emulator.log.
273
+ *
274
+ * @param {string} command - shell command to run while emulators are up
275
+ */
276
+ async runWithEmulator(command) {
277
+ const { shutdown, exitPromise } = await this.startEmulators();
278
+
279
+ // SIGINT (Ctrl+C) → graceful shutdown
280
+ const onSigint = async () => {
281
+ await shutdown();
282
+ process.exit(0);
283
+ };
284
+ process.once('SIGINT', onSigint);
285
+
286
+ try {
287
+ // Run the user command; when it exits we tear down the emulator.
288
+ const cmdChild = spawn('sh', ['-c', command], {
289
+ cwd: this.main.firebaseProjectPath,
139
290
  env: { ...process.env, FORCE_COLOR: '1' },
140
- },
141
- }, (child) => {
142
- // Tee stdout to both console and log file (strip ANSI codes for clean log)
143
- child.stdout.on('data', (data) => {
144
- process.stdout.write(data);
145
- writeToLog(data);
291
+ stdio: 'inherit',
146
292
  });
147
293
 
148
- // Tee stderr to both console and log file (strip ANSI codes for clean log)
149
- child.stderr.on('data', (data) => {
150
- process.stderr.write(data);
151
- writeToLog(data);
294
+ const cmdExit = await new Promise((resolve) => {
295
+ cmdChild.on('close', (code, signal) => resolve({ code, signal }));
152
296
  });
153
297
 
154
- // Clean up log stream + watcher when child exits
155
- child.on('close', () => {
156
- clearInterval(resetWatcher);
157
- if (currentStream && !currentStream.destroyed) {
158
- currentStream.end();
159
- }
160
- try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
161
- });
162
- });
298
+ process.removeListener('SIGINT', onSigint);
299
+ await shutdown();
300
+ await exitPromise;
301
+
302
+ if (cmdExit.code !== 0) {
303
+ throw Object.assign(new Error(`Command exited with code ${cmdExit.code}`), { code: cmdExit.code });
304
+ }
305
+ } catch (e) {
306
+ process.removeListener('SIGINT', onSigint);
307
+ await shutdown();
308
+ throw e;
309
+ }
163
310
  }
164
311
 
165
312
  /**
@@ -1,6 +1,7 @@
1
1
  const BaseCommand = require('./base-command');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const { spawn } = require('child_process');
4
5
  const chalk = require('chalk').default;
5
6
  const jetpack = require('fs-jetpack');
6
7
  const JSON5 = require('json5');
@@ -69,7 +70,7 @@ class TestCommand extends BaseCommand {
69
70
  await this.runTestsDirectly(testCommand, functionsDir, emulatorPorts);
70
71
  } else {
71
72
  this.log(chalk.cyan('Starting emulator and running tests...'));
72
- await this.runEmulatorTests(testCommand);
73
+ await this.runEmulatorTests(testCommand, functionsDir);
73
74
  }
74
75
  }
75
76
 
@@ -123,6 +124,7 @@ class TestCommand extends BaseCommand {
123
124
 
124
125
  // Derive computed values (not in config file)
125
126
  const backendManagerKey = argv.key || process.env.BACKEND_MANAGER_KEY;
127
+ const backendManagerWebhookKey = argv.webhookKey || process.env.BACKEND_MANAGER_WEBHOOK_KEY;
126
128
  const contactEmail = config.brand?.contact?.email || '';
127
129
  const domain = contactEmail.includes('@') ? contactEmail.split('@')[1] : '';
128
130
 
@@ -138,6 +140,12 @@ class TestCommand extends BaseCommand {
138
140
  return null;
139
141
  }
140
142
 
143
+ if (!backendManagerWebhookKey) {
144
+ this.logError('Error: Missing backend manager webhook key');
145
+ this.log(chalk.gray(' Set BACKEND_MANAGER_WEBHOOK_KEY in your .env file or pass --webhook-key flag'));
146
+ return null;
147
+ }
148
+
141
149
  if (!config.brand?.id) {
142
150
  this.logError('Error: Missing brand.id in backend-manager-config.json');
143
151
  return null;
@@ -152,6 +160,7 @@ class TestCommand extends BaseCommand {
152
160
  return {
153
161
  ...config,
154
162
  backendManagerKey,
163
+ backendManagerWebhookKey,
155
164
  domain,
156
165
  };
157
166
  }
@@ -280,23 +289,85 @@ class TestCommand extends BaseCommand {
280
289
  }
281
290
 
282
291
  /**
283
- * Run tests with Firebase emulator (starts emulator, runs tests, shuts down)
292
+ * Run tests with Firebase emulator (starts emulator, runs tests, shuts down).
293
+ *
294
+ * Two real child processes are used so emulator output and test-runner output
295
+ * land in separate log files:
296
+ * - `emulator.log` — `firebase emulators:start` stdout/stderr (managed by EmulatorCommand)
297
+ * - `test.log` — the test-runner subprocess stdout/stderr (managed here)
284
298
  */
285
- async runEmulatorTests(testCommand) {
299
+ async runEmulatorTests(testCommand, functionsDir) {
286
300
  this.log(chalk.gray(' Starting Firebase emulator...\n'));
287
301
 
288
- // Use EmulatorCommand to run tests with emulator
289
302
  const emulatorCmd = new EmulatorCommand(this.main);
303
+ let started;
290
304
 
291
305
  try {
292
- await emulatorCmd.runWithEmulator(testCommand);
306
+ started = await emulatorCmd.startEmulators();
293
307
  } catch (error) {
294
- // Only exit with error if it wasn't a user-initiated exit
295
- if (error.code !== 0) {
296
- this.logError(`Emulator error: ${error.message || error}`);
297
- }
308
+ this.logError(`Emulator error: ${error.message || error}`);
298
309
  process.exit(1);
299
310
  }
311
+
312
+ const { shutdown, exitPromise, emulatorPorts } = started;
313
+
314
+ // Forward Ctrl+C to a clean emulator shutdown
315
+ const onSigint = async () => {
316
+ this.log(chalk.gray('\n Shutting down emulator...'));
317
+ await shutdown();
318
+ process.exit(130);
319
+ };
320
+ process.once('SIGINT', onSigint);
321
+
322
+ // Print the same connection summary the existing-emulator path shows
323
+ this.log('');
324
+ this.log(chalk.gray(` Hosting: http://127.0.0.1:${emulatorPorts.hosting}`));
325
+ this.log(chalk.gray(` Firestore: 127.0.0.1:${emulatorPorts.firestore}`));
326
+ this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
327
+ this.log(chalk.gray(` UI: http://127.0.0.1:${emulatorPorts.ui}`));
328
+
329
+ // Spawn the test runner as its own child so its stdout/stderr can be teed to test.log
330
+ const logPath = this.getLogsPath('test.log');
331
+ const logStream = fs.createWriteStream(logPath, { flags: 'w' });
332
+ const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
333
+
334
+ this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
335
+
336
+ let testExitCode = 0;
337
+
338
+ try {
339
+ const testChild = spawn('sh', ['-c', testCommand], {
340
+ cwd: functionsDir,
341
+ env: { ...process.env, FORCE_COLOR: '1' },
342
+ stdio: ['inherit', 'pipe', 'pipe'],
343
+ });
344
+
345
+ testChild.stdout.on('data', (data) => {
346
+ process.stdout.write(data);
347
+ if (!logStream.destroyed) logStream.write(stripAnsi(data.toString()));
348
+ });
349
+
350
+ testChild.stderr.on('data', (data) => {
351
+ process.stderr.write(data);
352
+ if (!logStream.destroyed) logStream.write(stripAnsi(data.toString()));
353
+ });
354
+
355
+ testExitCode = await new Promise((resolve) => {
356
+ testChild.on('close', (code) => {
357
+ if (!logStream.destroyed) logStream.end();
358
+ resolve(code ?? 1);
359
+ });
360
+ });
361
+ } catch (error) {
362
+ this.logError(`Test runner error: ${error.message || error}`);
363
+ testExitCode = 1;
364
+ } finally {
365
+ process.removeListener('SIGINT', onSigint);
366
+ await shutdown();
367
+ await exitPromise;
368
+ }
369
+
370
+ process.exit(testExitCode);
300
371
  }
301
372
  }
302
373
 
@@ -103,7 +103,7 @@ function buildBrandConfig(config) {
103
103
  */
104
104
  function fetchRemoteBrand(brandUrl) {
105
105
  return fetch(`${brandUrl}/backend-manager/brand`, {
106
- timeout: 30000,
106
+ timeout: 120000,
107
107
  tries: 3,
108
108
  response: 'json',
109
109
  });
@@ -174,7 +174,7 @@ async function harvest(assistant, settings) {
174
174
 
175
175
  function getURLContent(url) {
176
176
  return fetch(url, {
177
- timeout: 30000,
177
+ timeout: 120000,
178
178
  tries: 3,
179
179
  response: 'raw',
180
180
  headers: {
@@ -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
  // },
@@ -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
 
@@ -97,7 +102,7 @@ async function findSubscriber(email, publicationId) {
97
102
  {
98
103
  response: 'json',
99
104
  headers: headers(),
100
- timeout: 60000,
105
+ timeout: BEEHIIV_TIMEOUT_MS,
101
106
  }
102
107
  );
103
108
 
@@ -135,7 +140,7 @@ async function removeSubscriber(email, publicationId) {
135
140
  {
136
141
  method: 'delete',
137
142
  headers: headers(),
138
- timeout: 60000,
143
+ timeout: BEEHIIV_TIMEOUT_MS,
139
144
  }
140
145
  );
141
146
 
@@ -202,7 +207,7 @@ function getPublicationId() {
202
207
  // const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
203
208
  // response: 'json',
204
209
  // headers: headers(),
205
- // timeout: 60000,
210
+ // timeout: BEEHIIV_TIMEOUT_MS,
206
211
  // });
207
212
  //
208
213
  // if (!data.data || data.data.length === 0) {
@@ -351,7 +356,7 @@ async function resolveSegmentIds() {
351
356
  const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
352
357
  response: 'json',
353
358
  headers: headers(),
354
- timeout: 60000,
359
+ timeout: BEEHIIV_TIMEOUT_MS,
355
360
  });
356
361
 
357
362
  _segmentIdCache = {};
@@ -447,7 +452,7 @@ async function createPost(options) {
447
452
  method: 'post',
448
453
  response: 'json',
449
454
  headers: headers(),
450
- timeout: 15000,
455
+ timeout: BEEHIIV_TIMEOUT_MS,
451
456
  body,
452
457
  });
453
458
 
@@ -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
 
@@ -357,7 +357,7 @@ async function createSingleSend({ name, subject, preheader, templateId, from, se
357
357
  method: 'post',
358
358
  response: 'json',
359
359
  headers: headers(),
360
- timeout: 15000,
360
+ timeout: SENDGRID_TIMEOUT_MS,
361
361
  body,
362
362
  });
363
363
 
@@ -385,7 +385,7 @@ async function scheduleSingleSend(singleSendId, sendAt) {
385
385
  method: 'put',
386
386
  response: 'json',
387
387
  headers: headers(),
388
- timeout: 15000,
388
+ timeout: SENDGRID_TIMEOUT_MS,
389
389
  body: { send_at: sendAt },
390
390
  });
391
391
 
@@ -545,7 +545,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
545
545
  method: 'post',
546
546
  response: 'json',
547
547
  headers: headers(),
548
- timeout: 15000,
548
+ timeout: SENDGRID_TIMEOUT_MS,
549
549
  body: { segment_ids: [segmentId] },
550
550
  });
551
551
 
@@ -575,7 +575,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
575
575
  // Download CSV — disable cacheBreaker to preserve presigned S3 URL signature
576
576
  const csvText = await fetch(statusData.urls[0], {
577
577
  response: 'text',
578
- timeout: 30000,
578
+ timeout: 60000,
579
579
  cacheBreaker: false,
580
580
  });
581
581
 
@@ -639,7 +639,7 @@ async function bulkDeleteContacts(contactIds) {
639
639
  method: 'delete',
640
640
  response: 'json',
641
641
  headers: headers(),
642
- timeout: 15000,
642
+ timeout: SENDGRID_TIMEOUT_MS,
643
643
  });
644
644
 
645
645
  if (data.job_id) {
@@ -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) {