@xuda.io/account_module 1.2.2297 → 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.
- package/index.mjs +110 -16
- 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';
|
|
@@ -1925,8 +1926,18 @@ export const ops_reinstate_account = async function (req = {}) {
|
|
|
1925
1926
|
export const ops_list_terminations = async function (req = {}) {
|
|
1926
1927
|
try {
|
|
1927
1928
|
if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
|
|
1928
|
-
|
|
1929
|
-
|
|
1929
|
+
// Suspended OR terminated. CouchDB Mango uses one index per query, so it
|
|
1930
|
+
// cannot serve an $or across two different fields and would full-scan every
|
|
1931
|
+
// account (logs "documents examined is high"). Query each branch separately
|
|
1932
|
+
// (each hits its own [docType, account_*_status] index) and merge, deduping
|
|
1933
|
+
// accounts flagged as both.
|
|
1934
|
+
const [susp_ret, term_ret] = await Promise.all([
|
|
1935
|
+
db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', account_suspension_status: 1 }, limit: 9999 }, true),
|
|
1936
|
+
db_module.find_couch_query('xuda_accounts', { selector: { docType: 'account', account_termination_status: 1 }, limit: 9999 }, true),
|
|
1937
|
+
]);
|
|
1938
|
+
const by_id = new Map();
|
|
1939
|
+
for (const a of [...(susp_ret?.docs || []), ...(term_ret?.docs || [])]) by_id.set(a._id, a);
|
|
1940
|
+
const rows = [...by_id.values()].map((a) => ({
|
|
1930
1941
|
account_uid: a._id,
|
|
1931
1942
|
email: a.account_info?.email,
|
|
1932
1943
|
name: `${a.account_info?.first_name || ''} ${a.account_info?.last_name || ''}`.trim(),
|
|
@@ -2519,17 +2530,61 @@ const _ops_validate_modules = (modules) => {
|
|
|
2519
2530
|
return { list, unknown: list.filter((m) => !universe.includes(m)) };
|
|
2520
2531
|
};
|
|
2521
2532
|
|
|
2522
|
-
//
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
|
|
2526
|
-
|
|
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;
|
|
2527
2546
|
try {
|
|
2528
|
-
|
|
2529
|
-
return { ok: true, exit: 0, stdout: String(stdout || ''), stderr: String(stderr || '') };
|
|
2547
|
+
fd = fs.openSync(logfile, 'a');
|
|
2530
2548
|
} catch (e) {
|
|
2531
|
-
return { ok: false, exit:
|
|
2532
|
-
}
|
|
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 });
|
|
2533
2588
|
};
|
|
2534
2589
|
|
|
2535
2590
|
// Pull hotfix_prod's human summary block + a tail for the UI.
|
|
@@ -7445,8 +7500,10 @@ setTimeout(async () => {
|
|
|
7445
7500
|
// 10% monthly Stripe coupon, saves a NUMBERED draft issue to xuda_newsletter, and
|
|
7446
7501
|
// emails a sample to ops (info@xuda.ai) + a superuser notification for review.
|
|
7447
7502
|
// Nothing goes to subscribers until a superuser hits Publish in the dashboard
|
|
7448
|
-
// (newsletter_publish), which blasts to every
|
|
7449
|
-
//
|
|
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.
|
|
7450
7507
|
// Opt-out flips the same flag the router /newsletter/unsubscribe route reads.
|
|
7451
7508
|
// ============================================================================
|
|
7452
7509
|
const NEWSLETTER_DB = 'xuda_newsletter';
|
|
@@ -7574,6 +7631,26 @@ const _nl_one = async (db, docType) => {
|
|
|
7574
7631
|
}
|
|
7575
7632
|
})();
|
|
7576
7633
|
|
|
7634
|
+
// The abuse-review queue (the flagged-accounts list, see ~line 1014) selects on
|
|
7635
|
+
// `abuse_signals.flagged` with no index, so Couch full-scans xuda_accounts and
|
|
7636
|
+
// logs "No matching index found". xuda_accounts lives locally on dev + master;
|
|
7637
|
+
// ensure the index at module load, idempotent, on those content-home nodes only.
|
|
7638
|
+
(async () => {
|
|
7639
|
+
const host = process.env.XUDA_HOSTNAME;
|
|
7640
|
+
if (host !== 'dev.xuda.ai' && host !== 'master.xuda.ai') return;
|
|
7641
|
+
try {
|
|
7642
|
+
const ret = await db_module.create_couch_index('xuda_accounts', {
|
|
7643
|
+
index: { fields: ['abuse_signals.flagged'] },
|
|
7644
|
+
name: 'idx_abuse_signals_flagged',
|
|
7645
|
+
ddoc: 'idx_abuse_signals_flagged',
|
|
7646
|
+
type: 'json',
|
|
7647
|
+
});
|
|
7648
|
+
if (ret?.error) console.error('[abuse] xuda_accounts abuse_signals.flagged index create failed:', ret.error);
|
|
7649
|
+
} catch (err) {
|
|
7650
|
+
console.error('[abuse] xuda_accounts abuse_signals.flagged index init failed:', err?.message || err);
|
|
7651
|
+
}
|
|
7652
|
+
})();
|
|
7653
|
+
|
|
7577
7654
|
const _nl_fashion_url = (p) => {
|
|
7578
7655
|
const rel = p.custom_url || (p.slug ? '/' + p.slug : '');
|
|
7579
7656
|
return `https://xuda.fashion${rel}`;
|
|
@@ -8059,8 +8136,25 @@ const _newsletter_blast = async (issue, by_uid) => {
|
|
|
8059
8136
|
const [pool, ppool] = await Promise.all([_nl_fashion_pool(), _nl_partner_pool()]);
|
|
8060
8137
|
let sent = 0, failed = 0, total = 0;
|
|
8061
8138
|
try {
|
|
8062
|
-
|
|
8063
|
-
|
|
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
|
+
);
|
|
8064
8158
|
total = recipients.length;
|
|
8065
8159
|
for (const acct of recipients) {
|
|
8066
8160
|
const email = acct.account_info && acct.account_info.email;
|