@xuda.io/account_module 1.2.2282 → 1.2.2283

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 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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2282",
3
+ "version": "1.2.2283",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {