@xuda.io/account_module 1.2.2282 → 1.2.2284
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 +197 -0
- package/index_ms.mjs +8 -0
- package/index_msa.mjs +8 -0
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -225,6 +225,7 @@ export const update_account_info = async function (req, job_id, headers) {
|
|
|
225
225
|
eb.status = 'cleared';
|
|
226
226
|
eb.cleared_ts = Date.now();
|
|
227
227
|
account_obj.account_email_bounce_suspended = 0;
|
|
228
|
+
_clear_bounce_suspension_banner(uid); // retire the suspension banner
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
|
|
@@ -1227,6 +1228,11 @@ export const get_account_data = async function (req) {
|
|
|
1227
1228
|
login_screen: await resolve_effective_login_screen(acc_obj),
|
|
1228
1229
|
};
|
|
1229
1230
|
|
|
1231
|
+
// A bounce-suspended account must always see the suspension banner (the
|
|
1232
|
+
// one-shot notice at suspension time can be missed or predate this). Best-
|
|
1233
|
+
// effort + fire-and-forget; only touches the DB for the rare suspended account.
|
|
1234
|
+
if (acc_obj.account_email_bounce_suspended) _ensure_bounce_suspension_banner(acc_obj);
|
|
1235
|
+
|
|
1230
1236
|
return ret;
|
|
1231
1237
|
} catch (err) {
|
|
1232
1238
|
console.error(err);
|
|
@@ -1413,6 +1419,71 @@ export const get_account_external_apps = async function (req) {
|
|
|
1413
1419
|
}
|
|
1414
1420
|
};
|
|
1415
1421
|
|
|
1422
|
+
// Eligible backends for a load balancer's pool: the user's ACTIVE apps of one
|
|
1423
|
+
// chosen deploy_type. full_stack_vps is stored as app_type:'vps' + is_full_stack,
|
|
1424
|
+
// so it's split out here. Balancers themselves are never selectable. Each row
|
|
1425
|
+
// carries the origin (ip/region) the balancer needs, plus a `reachable` flag —
|
|
1426
|
+
// farm/mesh-hosted backends (private IP) are shown but can't be pooled by a v1
|
|
1427
|
+
// DO balancer. req: { uid, data:{ backend_deploy_type } }
|
|
1428
|
+
export const get_load_balancer_candidates = async function (req) {
|
|
1429
|
+
try {
|
|
1430
|
+
const uid = req.uid;
|
|
1431
|
+
const bt = req.data?.backend_deploy_type || req.backend_deploy_type;
|
|
1432
|
+
if (!bt) return { code: -2, data: 'backend_deploy_type required' };
|
|
1433
|
+
const excluded = _conf.balancer?.excluded_backend_types || ['cron', 'emitter'];
|
|
1434
|
+
if (excluded.includes(bt)) return { code: -2, data: `${bt} is not balanceable` };
|
|
1435
|
+
|
|
1436
|
+
const isSuper = _conf.superuser_account_ids?.includes(uid);
|
|
1437
|
+
const app_type = bt === 'full_stack_vps' ? 'vps' : bt;
|
|
1438
|
+
const selector = isSuper ? { app_type, docType: 'app', app_status_code: 3 } : { app_uId: uid, app_type, docType: 'app', app_status_code: 3 };
|
|
1439
|
+
const ret = await db_module.find_couch_query('xuda_master', { selector, limit: 1000 });
|
|
1440
|
+
let docs = ret?.docs || [];
|
|
1441
|
+
|
|
1442
|
+
// Split the shared app_type:'vps' bucket into vps vs full_stack_vps.
|
|
1443
|
+
if (bt === 'full_stack_vps') docs = docs.filter((d) => d.is_full_stack);
|
|
1444
|
+
else if (bt === 'vps') docs = docs.filter((d) => !d.is_full_stack);
|
|
1445
|
+
docs = docs.filter((d) => !d.is_load_balancer && d.app_type !== 'balancer');
|
|
1446
|
+
|
|
1447
|
+
const is_private = (ip) => !ip || /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(String(ip));
|
|
1448
|
+
const rows = docs.map((d) => {
|
|
1449
|
+
const ip = d.app_hosting_server?.ip || null;
|
|
1450
|
+
const farm_hosted = is_private(ip) || d.app_hosting_server?.provider === 'proxmox';
|
|
1451
|
+
return {
|
|
1452
|
+
app_id: d._id,
|
|
1453
|
+
name: d.app_name,
|
|
1454
|
+
app_type: bt,
|
|
1455
|
+
region_code: d.app_hosting_server?.region_slug || d.app_hosting?.app_region || null,
|
|
1456
|
+
ip,
|
|
1457
|
+
public_url: `https://${d.app_access_url || `${d._id}.${_conf.app_domain}`}`,
|
|
1458
|
+
farm_hosted,
|
|
1459
|
+
reachable: !!ip && !farm_hosted,
|
|
1460
|
+
status: d.app_status_code,
|
|
1461
|
+
};
|
|
1462
|
+
});
|
|
1463
|
+
return { code: 1, data: { rows } };
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
return { code: -1, data: err.message };
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
// Load balancers are apps (app_type:'balancer') not emitted by any user_* view,
|
|
1470
|
+
// so — like get_account_external_apps — a Mango query surfaces them for the
|
|
1471
|
+
// sidenav bucket (DATA.balancer) right after create. req: { uid }
|
|
1472
|
+
export const get_account_balancers = async function (req) {
|
|
1473
|
+
try {
|
|
1474
|
+
const isSuper = _conf.superuser_account_ids?.includes(req.uid);
|
|
1475
|
+
const selector = isSuper ? { app_type: 'balancer', docType: 'app' } : { app_uId: req.uid, app_type: 'balancer', docType: 'app' };
|
|
1476
|
+
const ret = await db_module.find_couch_query('xuda_master', {
|
|
1477
|
+
selector,
|
|
1478
|
+
limit: 1000,
|
|
1479
|
+
});
|
|
1480
|
+
const rows = (ret?.docs || []).map((d) => ({ id: d._id, value: d }));
|
|
1481
|
+
return { code: 1, data: { rows } };
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
return { code: -1, data: err.message };
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1416
1487
|
export const get_account_info = async function (req) {
|
|
1417
1488
|
return await get_account_data({
|
|
1418
1489
|
uid: req.uid,
|
|
@@ -1432,6 +1503,23 @@ export const get_account_info = async function (req) {
|
|
|
1432
1503
|
// ===================================================================
|
|
1433
1504
|
const _OPS_SERVER_TYPES = ['datacenter', 'deployment', 'vps', 'managed_vps', 'instance', 'master'];
|
|
1434
1505
|
const _ops_is_super = (req) => !!(req && req.uid && Array.isArray(_conf.superuser_account_ids) && _conf.superuser_account_ids.includes(req.uid));
|
|
1506
|
+
|
|
1507
|
+
// info@xuda.ai (platform owner) + any superuser account are permanently exempt
|
|
1508
|
+
// from billing hold / suspension / termination — manual OR automated. A held or
|
|
1509
|
+
// suspended owner account would lock the operator out. Email-keyed so it holds
|
|
1510
|
+
// across the dev/master account-id divergence. Mirrors the same guard in
|
|
1511
|
+
// stripe_module's webhook paths. Extend via _conf.billing_hold_exempt_emails.
|
|
1512
|
+
const _HOLD_EXEMPT_EMAILS = new Set(
|
|
1513
|
+
['info@xuda.ai', ...(Array.isArray(_conf.billing_hold_exempt_emails) ? _conf.billing_hold_exempt_emails : [])]
|
|
1514
|
+
.map((e) => String(e).trim().toLowerCase()).filter(Boolean)
|
|
1515
|
+
);
|
|
1516
|
+
const _is_billing_restriction_exempt = (account_obj) => {
|
|
1517
|
+
if (!account_obj) return false;
|
|
1518
|
+
const email = String(account_obj.account_info?.email || '').trim().toLowerCase();
|
|
1519
|
+
if (email && _HOLD_EXEMPT_EMAILS.has(email)) return true;
|
|
1520
|
+
const supers = _conf.superuser_account_ids;
|
|
1521
|
+
return !!(Array.isArray(supers) && account_obj._id && supers.includes(account_obj._id));
|
|
1522
|
+
};
|
|
1435
1523
|
const _ops_host = () => (_conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain) || 'xuda.ai';
|
|
1436
1524
|
const _ops_appeal_days = () => Number(_conf.termination?.appeal_days) || 14;
|
|
1437
1525
|
|
|
@@ -1477,6 +1565,7 @@ export const ops_suspend_account = async function (req = {}) {
|
|
|
1477
1565
|
const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
|
|
1478
1566
|
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
1479
1567
|
const account = ar.data;
|
|
1568
|
+
if (_is_billing_restriction_exempt(account)) return { code: -1, data: 'account is on the permanent allowlist (info@ / superuser); suspension refused' };
|
|
1480
1569
|
account.account_suspension_status = 1;
|
|
1481
1570
|
account.account_suspension_date = Date.now();
|
|
1482
1571
|
account.suspension_reason = req.reason || '';
|
|
@@ -1501,6 +1590,7 @@ export const ops_terminate_account = async function (req = {}) {
|
|
|
1501
1590
|
const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
|
|
1502
1591
|
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
1503
1592
|
const account = ar.data;
|
|
1593
|
+
if (_is_billing_restriction_exempt(account)) return { code: -1, data: 'account is on the permanent allowlist (info@ / superuser); termination refused' };
|
|
1504
1594
|
const appeal_days = _ops_appeal_days();
|
|
1505
1595
|
account.account_termination_status = 1;
|
|
1506
1596
|
account.account_termination_date = Date.now();
|
|
@@ -1573,6 +1663,20 @@ export const ops_reinstate_account = async function (req = {}) {
|
|
|
1573
1663
|
account.appeal_status = 'accepted';
|
|
1574
1664
|
account.reinstated_ts = Date.now();
|
|
1575
1665
|
account.reinstated_by_uid = req.uid;
|
|
1666
|
+
// Reinstate means "reactivate", regardless of which suspension type is in
|
|
1667
|
+
// effect — so also lift an email-bounce suspension if present. This mirrors
|
|
1668
|
+
// the clear branch of sweep_email_bounces (:3630) so the two paths agree.
|
|
1669
|
+
const _eb = account.account_email_bounce;
|
|
1670
|
+
const _was_bounce_suspended = account.account_email_bounce_suspended || (_eb && (_eb.status === 'suspended' || _eb.status === 'flagged'));
|
|
1671
|
+
if (_was_bounce_suspended) {
|
|
1672
|
+
if (_eb) {
|
|
1673
|
+
_eb.status = 'cleared';
|
|
1674
|
+
_eb.cleared_ts = Date.now();
|
|
1675
|
+
account.account_email_bounce = _eb;
|
|
1676
|
+
}
|
|
1677
|
+
account.account_email_bounce_suspended = 0;
|
|
1678
|
+
}
|
|
1679
|
+
if (account.stat === 5) account.stat = 3; // defensive: clear a hard suspended stat if one was set
|
|
1576
1680
|
const sr = await db_module.save_couch_doc('xuda_accounts', account);
|
|
1577
1681
|
if (sr.code < 0) return { code: -1, data: sr.data };
|
|
1578
1682
|
const power = await _ops_services_power(account_uid, true);
|
|
@@ -1680,6 +1784,19 @@ export const ops_account_snapshot = async function (req = {}) {
|
|
|
1680
1784
|
billing_hold_date: a.account_billing_hold_date || null,
|
|
1681
1785
|
billing_hold_reason: a.billing_hold_reason || '',
|
|
1682
1786
|
reinstated_ts: a.reinstated_ts || null,
|
|
1787
|
+
// Email-bounce suspension (a distinct suspension type — an outbound
|
|
1788
|
+
// platform email to the account hard-bounced). Surfaced so the admin
|
|
1789
|
+
// can see it's the reason an account reads as inactive.
|
|
1790
|
+
email_bounce: a.account_email_bounce_suspended
|
|
1791
|
+
? {
|
|
1792
|
+
suspended: true,
|
|
1793
|
+
recipient: a.account_email_bounce?.recipient || '',
|
|
1794
|
+
reason: a.account_email_bounce?.reason || '',
|
|
1795
|
+
status: a.account_email_bounce?.status || '',
|
|
1796
|
+
bounce_count: a.account_email_bounce?.bounce_count || 0,
|
|
1797
|
+
suspended_ts: a.account_email_bounce?.suspended_ts || null,
|
|
1798
|
+
}
|
|
1799
|
+
: null,
|
|
1683
1800
|
entitlements: get_effective_entitlements(a),
|
|
1684
1801
|
credits,
|
|
1685
1802
|
},
|
|
@@ -1696,6 +1813,7 @@ export const ops_set_billing_hold = async function (req = {}) {
|
|
|
1696
1813
|
const ar = await db_module.get_couch_doc('xuda_accounts', account_uid);
|
|
1697
1814
|
if (ar.code < 0 || !ar.data) return { code: -1, data: 'account not found' };
|
|
1698
1815
|
const account = ar.data;
|
|
1816
|
+
if (_is_billing_restriction_exempt(account)) return { code: -1, data: 'account is on the permanent allowlist (info@ / superuser); billing hold refused' };
|
|
1699
1817
|
account.account_billing_hold_status = 1;
|
|
1700
1818
|
account.account_billing_hold_date = Date.now();
|
|
1701
1819
|
account.billing_hold_reason = req.reason || '';
|
|
@@ -3412,12 +3530,18 @@ const _notify_bounce_suspended = async (account) => {
|
|
|
3412
3530
|
await notification_msa.submit_notification({
|
|
3413
3531
|
type: 'account',
|
|
3414
3532
|
app_id: null,
|
|
3533
|
+
// Account-level banner: tag with an empty app_id so get_system_notifications
|
|
3534
|
+
// (which scopes to app_id: '') returns it on every reload, not just the
|
|
3535
|
+
// one live WebSocket push at suspension time.
|
|
3536
|
+
to_app_id: '',
|
|
3415
3537
|
uid_arr: [account._id],
|
|
3416
3538
|
topic: 'email_bounce_update_required',
|
|
3417
3539
|
params: {
|
|
3418
3540
|
recipient_email: eb.recipient || account.account_info?.email || '',
|
|
3419
3541
|
settings_url: _bounce_settings_url(),
|
|
3420
3542
|
support_url: `https://${host}/dashboard/support`,
|
|
3543
|
+
action_label: 'Update email',
|
|
3544
|
+
action_href: _bounce_settings_url(),
|
|
3421
3545
|
},
|
|
3422
3546
|
});
|
|
3423
3547
|
} catch (e) { console.error('bounce user notice failed:', e.message); }
|
|
@@ -3444,6 +3568,79 @@ const _notify_bounce_suspended = async (account) => {
|
|
|
3444
3568
|
}
|
|
3445
3569
|
};
|
|
3446
3570
|
|
|
3571
|
+
// Ensure a bounce-suspended account has a LIVE suspension banner on the
|
|
3572
|
+
// dashboard. The suspend-time notice (_notify_bounce_suspended) fires once and
|
|
3573
|
+
// can be missed or predate this; get_account_data calls this on every load.
|
|
3574
|
+
// Idempotent + banner-only: bails if a persistent (app_id '') banner already
|
|
3575
|
+
// exists, so it never duplicates the doc or re-sends the SMS.
|
|
3576
|
+
const _ensure_bounce_suspension_banner = async (account) => {
|
|
3577
|
+
try {
|
|
3578
|
+
const uid = account._id;
|
|
3579
|
+
const ret = await db_module.find_couch_query('xuda_notification', {
|
|
3580
|
+
selector: { docType: 'notification', uid, topic: 'email_bounce_update_required', delivery_method: 'banner', read: false },
|
|
3581
|
+
});
|
|
3582
|
+
const docs = ret?.docs || [];
|
|
3583
|
+
if (docs.length) {
|
|
3584
|
+
// Self-heal: concurrent get_account_data calls on boot can race the create
|
|
3585
|
+
// (find-then-create isn't atomic + CouchDB's index lags the fresh doc), so
|
|
3586
|
+
// more than one can slip through. Keep the newest, retire the rest.
|
|
3587
|
+
docs.sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0));
|
|
3588
|
+
for (let i = 1; i < docs.length; i++) {
|
|
3589
|
+
docs[i].read = true;
|
|
3590
|
+
docs[i].stat = 4;
|
|
3591
|
+
docs[i].stat_ts = Date.now();
|
|
3592
|
+
await db_module.save_couch_doc('xuda_notification', docs[i]).catch(() => {});
|
|
3593
|
+
}
|
|
3594
|
+
// Upgrade an older-format banner in place (created before locked / the
|
|
3595
|
+
// Update-email action existed) so it picks up the current fields.
|
|
3596
|
+
const keep = docs[0];
|
|
3597
|
+
if (!keep.locked || !keep.params?.action_href) {
|
|
3598
|
+
keep.locked = true;
|
|
3599
|
+
keep.params = { ...(keep.params || {}), action_label: 'Update email', action_href: _bounce_settings_url() };
|
|
3600
|
+
await db_module.save_couch_doc('xuda_notification', keep).catch(() => {});
|
|
3601
|
+
}
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
const eb = account.account_email_bounce || {};
|
|
3605
|
+
const host = _conf.is_debug ? process.env.XUDA_HOSTNAME || _conf.domain : _conf.domain;
|
|
3606
|
+
await notification_msa.submit_notification({
|
|
3607
|
+
type: 'account',
|
|
3608
|
+
app_id: null,
|
|
3609
|
+
to_app_id: '',
|
|
3610
|
+
uid_arr: [uid],
|
|
3611
|
+
topic: 'email_bounce_update_required',
|
|
3612
|
+
delivery_filter: ['banner'], // banner only — the SMS already went out at suspension time
|
|
3613
|
+
params: {
|
|
3614
|
+
recipient_email: eb.recipient || account.account_info?.email || '',
|
|
3615
|
+
settings_url: _bounce_settings_url(),
|
|
3616
|
+
support_url: `https://${host}/dashboard/support`,
|
|
3617
|
+
action_label: 'Update email',
|
|
3618
|
+
action_href: _bounce_settings_url(),
|
|
3619
|
+
},
|
|
3620
|
+
});
|
|
3621
|
+
} catch (e) {
|
|
3622
|
+
console.error('ensure bounce banner failed:', e.message);
|
|
3623
|
+
}
|
|
3624
|
+
};
|
|
3625
|
+
|
|
3626
|
+
// Retire the suspension banner once the bounce is resolved (email changed).
|
|
3627
|
+
// Marks it read so get_system_notifications stops returning it next load.
|
|
3628
|
+
const _clear_bounce_suspension_banner = async (uid) => {
|
|
3629
|
+
try {
|
|
3630
|
+
const ret = await db_module.find_couch_query('xuda_notification', {
|
|
3631
|
+
selector: { docType: 'notification', uid, topic: 'email_bounce_update_required', read: false },
|
|
3632
|
+
});
|
|
3633
|
+
for (const doc of ret?.docs || []) {
|
|
3634
|
+
doc.read = true;
|
|
3635
|
+
doc.stat = 4;
|
|
3636
|
+
doc.stat_ts = Date.now();
|
|
3637
|
+
await db_module.save_couch_doc('xuda_notification', doc);
|
|
3638
|
+
}
|
|
3639
|
+
} catch (e) {
|
|
3640
|
+
console.error('clear bounce banner failed:', e.message);
|
|
3641
|
+
}
|
|
3642
|
+
};
|
|
3643
|
+
|
|
3447
3644
|
// Hard-email-bounce handler. Called (via account_ms RPC) by email_module's
|
|
3448
3645
|
// scan_platform_bounces when a platform email to this account's address bounces
|
|
3449
3646
|
// permanently (5.x.x). Policy: suspend IMMEDIATELY (account_email_bounce_suspended,
|
package/index_ms.mjs
CHANGED
|
@@ -121,6 +121,14 @@ export const get_account_external_apps = async function (...args) {
|
|
|
121
121
|
return await broker.send_to_queue("get_account_external_apps", ...args);
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
export const get_load_balancer_candidates = async function (...args) {
|
|
125
|
+
return await broker.send_to_queue("get_load_balancer_candidates", ...args);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const get_account_balancers = async function (...args) {
|
|
129
|
+
return await broker.send_to_queue("get_account_balancers", ...args);
|
|
130
|
+
};
|
|
131
|
+
|
|
124
132
|
export const get_account_info = async function (...args) {
|
|
125
133
|
return await broker.send_to_queue("get_account_info", ...args);
|
|
126
134
|
};
|
package/index_msa.mjs
CHANGED
|
@@ -121,6 +121,14 @@ export const get_account_external_apps = function (...args) {
|
|
|
121
121
|
broker.send_to_queue_async("get_account_external_apps", ...args);
|
|
122
122
|
};
|
|
123
123
|
|
|
124
|
+
export const get_load_balancer_candidates = function (...args) {
|
|
125
|
+
broker.send_to_queue_async("get_load_balancer_candidates", ...args);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const get_account_balancers = function (...args) {
|
|
129
|
+
broker.send_to_queue_async("get_account_balancers", ...args);
|
|
130
|
+
};
|
|
131
|
+
|
|
124
132
|
export const get_account_info = function (...args) {
|
|
125
133
|
broker.send_to_queue_async("get_account_info", ...args);
|
|
126
134
|
};
|