@xuda.io/account_module 1.2.2298 → 1.2.2299

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 (2) hide show
  1. package/index.mjs +78 -14
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -2,7 +2,8 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import crypto from 'node:crypto';
4
4
  import _ from 'lodash';
5
- import { execFile } from 'child_process';
5
+ import { execFile, spawn } from 'child_process';
6
+ import os from 'os';
6
7
  import { promisify } from 'util';
7
8
  import { createRequire } from 'module';
8
9
  import { parsePhoneNumber } from 'libphonenumber-js';
@@ -2529,17 +2530,61 @@ const _ops_validate_modules = (modules) => {
2529
2530
  return { list, unknown: list.filter((m) => !universe.includes(m)) };
2530
2531
  };
2531
2532
 
2532
- // Spawn utils/hotfix_prod.mjs against explicit WG hosts. execFile rejects on a
2533
- // non-zero exit (hotfix_prod exits 1 if ANY host failed), which we translate to
2534
- // ok:false rather than throwing, keeping stdout for the summary either way.
2535
- const _ops_run_hotfix = async ({ hosts, modules, dry_run, timeout_ms }) => {
2536
- const args = [_HOTFIX_TOOL(), '--hosts', hosts.map((h) => h.ssh).join(','), ...(modules || []), ...(dry_run ? ['--dry-run'] : [])];
2533
+ // Run `node <args>` DETACHED, with its own process group and its own log file (not
2534
+ // our stdio pipes). This account proc is the deploy orchestrator, and pm2 WATCHES
2535
+ // common/ + cpi/account_module, so a stray file change restarts it. A rollout spawned
2536
+ // as a normal child would then be torn down mid-flight, leaving boxes half-patched.
2537
+ // detached:true = the child leads its own session and survives a signal aimed at our
2538
+ // process group; the log file = a broken parent pipe cannot SIGPIPE it. We still await
2539
+ // its exit for the summary; if we get bounced mid-run the child finishes on its own,
2540
+ // and a re-run is a safe no-op (hotfix_prod skips already-current modules). Never
2541
+ // rejects: failures come back as { ok:false, exit, stderr }. stdout+stderr are merged
2542
+ // into the log, so a fatal error stays visible in the tail the UI shows.
2543
+ const _ops_run_node_detached = ({ args, timeout_ms, env }) => {
2544
+ const logfile = path.join(os.tmpdir(), `xuda_ops_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.log`);
2545
+ let fd;
2537
2546
  try {
2538
- const { stdout, stderr } = await _execFileP('node', args, { maxBuffer: 32 * 1024 * 1024, timeout: timeout_ms || 300000, env: { ...process.env } });
2539
- return { ok: true, exit: 0, stdout: String(stdout || ''), stderr: String(stderr || '') };
2547
+ fd = fs.openSync(logfile, 'a');
2540
2548
  } catch (e) {
2541
- return { ok: false, exit: e && typeof e.code === 'number' ? e.code : 1, stdout: String((e && e.stdout) || ''), stderr: String((e && (e.stderr || e.message)) || e) };
2542
- }
2549
+ return Promise.resolve({ ok: false, exit: 1, stdout: '', stderr: 'could not open run log: ' + e.message });
2550
+ }
2551
+ return new Promise((resolve) => {
2552
+ let done = false;
2553
+ let timer = null;
2554
+ const settle = ({ exit, stderr }) => {
2555
+ if (done) return;
2556
+ done = true;
2557
+ if (timer) clearTimeout(timer);
2558
+ try { fs.closeSync(fd); } catch (e) {}
2559
+ let stdout = '';
2560
+ try { stdout = fs.readFileSync(logfile, 'utf8'); } catch (e) {}
2561
+ try { fs.unlinkSync(logfile); } catch (e) {}
2562
+ resolve({ ok: exit === 0, exit, stdout, stderr: stderr || '' });
2563
+ };
2564
+ let child;
2565
+ try {
2566
+ child = spawn('node', args, { detached: true, stdio: ['ignore', fd, fd], env: env || { ...process.env } });
2567
+ } catch (e) {
2568
+ settle({ exit: 1, stderr: 'spawn failed: ' + e.message });
2569
+ return;
2570
+ }
2571
+ timer = setTimeout(() => {
2572
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (e) {}
2573
+ settle({ exit: 124, stderr: `timed out after ${Math.round((timeout_ms || 300000) / 1000)}s` });
2574
+ }, timeout_ms || 300000);
2575
+ child.on('error', (e) => settle({ exit: 1, stderr: e.message }));
2576
+ child.on('exit', (code, signal) => settle({ exit: code == null ? 1 : code, stderr: signal ? 'killed by ' + signal : '' }));
2577
+ child.unref();
2578
+ });
2579
+ };
2580
+
2581
+ // Spawn utils/hotfix_prod.mjs against explicit WG hosts, DETACHED (see above), so a
2582
+ // pm2 file-watch restart of this orchestrator cannot kill an in-flight rollout.
2583
+ // hotfix_prod exits 1 if ANY host failed; that becomes ok:false, keeping stdout for
2584
+ // the summary either way.
2585
+ const _ops_run_hotfix = ({ hosts, modules, dry_run, timeout_ms }) => {
2586
+ const args = [_HOTFIX_TOOL(), '--hosts', hosts.map((h) => h.ssh).join(','), ...(modules || []), ...(dry_run ? ['--dry-run'] : [])];
2587
+ return _ops_run_node_detached({ args, timeout_ms });
2543
2588
  };
2544
2589
 
2545
2590
  // Pull hotfix_prod's human summary block + a tail for the UI.
@@ -7455,8 +7500,10 @@ setTimeout(async () => {
7455
7500
  // 10% monthly Stripe coupon, saves a NUMBERED draft issue to xuda_newsletter, and
7456
7501
  // emails a sample to ops (info@xuda.ai) + a superuser notification for review.
7457
7502
  // Nothing goes to subscribers until a superuser hits Publish in the dashboard
7458
- // (newsletter_publish), which blasts to every preferences.newsletter_opt_in
7459
- // account with a per-recipient unsubscribe link + List-Unsubscribe header.
7503
+ // (newsletter_publish), which blasts to every account that has NOT opted out
7504
+ // (preferences.newsletter_opt_in !== false, so a missing flag counts as opted
7505
+ // in), minus the xuda.network seed personas, with a per-recipient unsubscribe
7506
+ // link + List-Unsubscribe header. See _newsletter_blast for why both rules exist.
7460
7507
  // Opt-out flips the same flag the router /newsletter/unsubscribe route reads.
7461
7508
  // ============================================================================
7462
7509
  const NEWSLETTER_DB = 'xuda_newsletter';
@@ -8089,8 +8136,25 @@ const _newsletter_blast = async (issue, by_uid) => {
8089
8136
  const [pool, ppool] = await Promise.all([_nl_fashion_pool(), _nl_partner_pool()]);
8090
8137
  let sent = 0, failed = 0, total = 0;
8091
8138
  try {
8092
- const rret = await db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', stat: 3, 'preferences.newsletter_opt_in': true }, limit: 100000 }, true);
8093
- const recipients = rret.docs || [];
8139
+ // Opt-in is OPT-OUT by design: the three signup surfaces stamp
8140
+ // preferences.newsletter_opt_in=true, but every account created before the
8141
+ // newsletter shipped has NO such field. Selecting on `=== true` therefore
8142
+ // matched 1 of 456 accounts on master and the audience was silently empty
8143
+ // (issue #4 reported "1 sent"). Treat a MISSING flag as opted in, and only
8144
+ // an explicit false as opted out.
8145
+ //
8146
+ // EXCLUDE the xuda.network personas. They are seed accounts on
8147
+ // @ambassadors.xuda.network / @mentors.xuda.network, domains with no mail
8148
+ // service, so mailing them is 420 guaranteed hard bounces in one blast,
8149
+ // which the bounce handler turns into 420 suspensions plus real damage to
8150
+ // the sending reputation. `source` starts with xuda_network_ on every one of
8151
+ // them and agrees with the address on all 420, so it is the reliable marker.
8152
+ // Filtering here rather than in the Mango selector: it is a few hundred docs,
8153
+ // and $ne against a MISSING field is exactly the semantic that caused this bug.
8154
+ const rret = await db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', stat: 3 }, limit: 100000 }, true);
8155
+ const recipients = (rret.docs || []).filter(
8156
+ (a) => !String(a.source || '').startsWith('xuda_network_') && a.preferences?.newsletter_opt_in !== false,
8157
+ );
8094
8158
  total = recipients.length;
8095
8159
  for (const acct of recipients) {
8096
8160
  const email = acct.account_info && acct.account_info.email;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2298",
3
+ "version": "1.2.2299",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {