@xuda.io/account_module 1.2.2290 → 1.2.2291

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
@@ -2,9 +2,13 @@ import path from 'path';
2
2
  import fs from 'fs';
3
3
  import crypto from 'node:crypto';
4
4
  import _ from 'lodash';
5
- // import { exec } from 'child_process';
6
- // import util from 'util';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { createRequire } from 'module';
7
8
  import { parsePhoneNumber } from 'libphonenumber-js';
9
+
10
+ const _execFileP = promisify(execFile);
11
+ const _require = createRequire(import.meta.url);
8
12
  import { countryCodeEmoji } from 'country-code-emoji';
9
13
 
10
14
  const account_info_properties = [
@@ -514,6 +518,41 @@ export const get_effective_entitlements = function (account_doc) {
514
518
  return { ai_credits, drive_gb };
515
519
  };
516
520
 
521
+ // Which website-builder image modes an account may use. SVG and stock are always
522
+ // available; the two gpt-image modes (gen_low = img-1, gen_high = img-2) require a
523
+ // paid membership AND a non-free workspace credit plan. Single source of truth for
524
+ // both the builder gate (ai_module.generate_site_draft) and the UI selector, which
525
+ // disables the locked modes and shows an "upgrade" prompt.
526
+ export const _site_build_image_modes = function (account_doc) {
527
+ const paid = !!(account_doc?.membership_plan && account_doc.membership_plan !== 'free');
528
+ const ws = account_doc?.ai_workspace_plan;
529
+ const has_ws_credits = !!(paid && ws && ws !== 'free_ai_workspace' && _conf.PLAN_OBJ?.[ws]?.category === 'ai_workspace');
530
+ const gen_ok = paid && has_ws_credits;
531
+ return {
532
+ svg: true,
533
+ stock: true,
534
+ gen_low: gen_ok,
535
+ gen_high: gen_ok,
536
+ upgrade: !gen_ok,
537
+ reason: gen_ok
538
+ ? ''
539
+ : (!paid
540
+ ? 'AI image generation needs a paid website plan and a workspace credit plan. Upgrade to unlock it.'
541
+ : 'AI image generation needs a workspace credit plan. Add one to unlock it.'),
542
+ };
543
+ };
544
+
545
+ export const get_site_build_image_modes = async function (req) {
546
+ try {
547
+ const uid = req?.uid || req?.token_ret?.data?.uid;
548
+ if (!uid) return { code: -1, data: 'not authorized' };
549
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
550
+ return { code: 1, data: _site_build_image_modes(account_doc) };
551
+ } catch (err) {
552
+ return { code: -1, data: err.message };
553
+ }
554
+ };
555
+
517
556
  export const increment_account_usage = async function (req) {
518
557
  const { uid } = req;
519
558
 
@@ -1082,7 +1121,7 @@ export const mark_account_reviewed = async function (req) {
1082
1121
  // limit: number — cap on docs processed (canary backfill).
1083
1122
  // app_id: string — backfill only this specific app.
1084
1123
  export const backfill_app_costs = async function (opts = {}) {
1085
- const { dry_run = false, limit = null, app_id = null } = opts || {};
1124
+ const { dry_run = false, limit = null, app_id = null, created_anchor_ts = null } = opts || {};
1086
1125
 
1087
1126
  // compute_app_cost lives in deploy_module/cost.mjs. Imported via
1088
1127
  // the same cross-module path convention used elsewhere in this
@@ -1146,6 +1185,17 @@ export const backfill_app_costs = async function (opts = {}) {
1146
1185
  action = 'nothing-to-bill';
1147
1186
  reason = 'monthly_total = 0';
1148
1187
  } else {
1188
+ // Anchor the ledger START. The default `created_ts = now` keeps
1189
+ // pre-ledger hours unbilled — correct for a fresh legacy stamp. But a
1190
+ // migration whose OLD billing already covered up to a cycle boundary
1191
+ // should pass created_anchor_ts = that boundary (the cycle start), so
1192
+ // the new ledger bills FROM there and there's no cycle_start ->
1193
+ // migration_date gap. last_billed_ts stays `now`: the cron is
1194
+ // forward-only from the stamp regardless.
1195
+ if (Number.isFinite(created_anchor_ts) && created_anchor_ts > 0) {
1196
+ cost.created_ts = created_anchor_ts;
1197
+ cost.segment_from_ts = created_anchor_ts;
1198
+ }
1149
1199
  if (doc.app_status_code === APP_STATUS_DELETED) {
1150
1200
  cost.terminated_ts = doc.app_status_data ? new Date(doc.app_status_data).getTime() : Date.now();
1151
1201
  action = 'terminated';
@@ -1177,6 +1227,118 @@ export const backfill_app_costs = async function (opts = {}) {
1177
1227
  return summary;
1178
1228
  };
1179
1229
 
1230
+ // Targeted correction for the backfill's "created_ts = now" anchor. When a
1231
+ // migrated account's OLD billing already covered up to the current cycle start,
1232
+ // anchoring the new ledger at the migration date leaves the cycle_start ->
1233
+ // migration_date window UNBILLED. This re-anchors the EARLIEST app_cost segment
1234
+ // of each of the account's billable apps back to `anchor_ts` (the cycle start /
1235
+ // last-charge date) so the full cycle bills.
1236
+ //
1237
+ // - Shifts ONLY the earliest segment start (created_ts + segment_from_ts, or
1238
+ // the first history segment's from_ts when the app was resized this cycle);
1239
+ // later resize segments are untouched, so the resize timeline is preserved.
1240
+ // - Idempotent: an app whose earliest start is already at/before anchor_ts is
1241
+ // skipped, so re-running is a no-op.
1242
+ // - dry_run defaults TRUE: it NEVER writes unless called with { dry_run: false }.
1243
+ // Recommended flow: dry-run -> review per_doc billable_before/after ->
1244
+ // { dry_run:false } -> confirm the dashboard summary -> flush_deployment_usage_to_stripe.
1245
+ //
1246
+ // opts:
1247
+ // uid: string — account whose apps to re-anchor (required).
1248
+ // anchor_ts: number — ms epoch to anchor to = the cycle start (required).
1249
+ // dry_run: boolean — default true; pass false to persist.
1250
+ // app_id: string — limit to one app.
1251
+ // cycle_end_ts: number — optional; when given, logs before/after billable $ per app.
1252
+ export const rebase_app_cost_anchor = async function (opts = {}) {
1253
+ const { uid, anchor_ts, app_id = null, cycle_end_ts = null } = opts || {};
1254
+ const dry_run = opts.dry_run !== false; // SAFE default: writes only when explicitly dry_run:false
1255
+ if (!uid) return { code: -1, data: 'uid required' };
1256
+ if (!Number.isFinite(anchor_ts) || anchor_ts <= 0) return { code: -1, data: 'anchor_ts (ms epoch of the cycle start) required' };
1257
+
1258
+ // compute_cycle_billable_amount lets the dry-run show the $ delta per app.
1259
+ let compute_cycle_billable_amount = null;
1260
+ try {
1261
+ const cost_mod = await import(`${module_path}/deploy_module/cost.mjs`);
1262
+ compute_cycle_billable_amount = cost_mod.compute_cycle_billable_amount;
1263
+ } catch (e) {
1264
+ // best-effort; the re-anchor itself does not need it.
1265
+ }
1266
+
1267
+ const apps_ret = await db_module.get_couch_view('xuda_master', 'user_apps', {
1268
+ startkey: [uid, ''],
1269
+ endkey: [uid, 'ZZZZZ'],
1270
+ include_docs: true,
1271
+ });
1272
+
1273
+ const summary = {
1274
+ note: 'dry_run=true logs only; call with { dry_run:false } to persist',
1275
+ dry_run,
1276
+ uid,
1277
+ anchor_ts,
1278
+ anchor_iso: new Date(anchor_ts).toISOString(),
1279
+ scanned: 0,
1280
+ rebased: 0,
1281
+ skipped: 0,
1282
+ errors: [],
1283
+ per_doc: [],
1284
+ };
1285
+
1286
+ for (const row of apps_ret?.data?.rows || []) {
1287
+ const doc = row.doc;
1288
+ if (!doc || doc.docType !== 'app') continue;
1289
+ if (app_id && doc._id !== app_id) continue;
1290
+ const cost = doc.app_cost;
1291
+ if (!cost || !cost.created_ts) continue;
1292
+ summary.scanned++;
1293
+
1294
+ try {
1295
+ const has_history = Array.isArray(cost.history) && cost.history.length > 0;
1296
+ const first_from = has_history ? cost.history[0].from_ts || cost.created_ts : cost.created_ts;
1297
+
1298
+ // Idempotent: the earliest segment already reaches back to (or past) the
1299
+ // anchor -> nothing to do.
1300
+ if (cost.created_ts <= anchor_ts && first_from <= anchor_ts) {
1301
+ summary.skipped++;
1302
+ summary.per_doc.push({ _id: doc._id, app_name: doc.app_name, action: 'skipped', reason: 'already anchored', created_ts: cost.created_ts });
1303
+ continue;
1304
+ }
1305
+
1306
+ const created_before = cost.created_ts;
1307
+ const billable_before = cycle_end_ts && compute_cycle_billable_amount ? compute_cycle_billable_amount(doc, anchor_ts, cycle_end_ts) : null;
1308
+
1309
+ // Re-anchor ONLY the earliest segment start.
1310
+ if (cost.created_ts > anchor_ts) cost.created_ts = anchor_ts;
1311
+ if (has_history) {
1312
+ if ((cost.history[0].from_ts || 0) > anchor_ts) cost.history[0].from_ts = anchor_ts;
1313
+ } else if (cost.segment_from_ts && cost.segment_from_ts > anchor_ts) {
1314
+ cost.segment_from_ts = anchor_ts; // single segment: keep it consistent with created_ts
1315
+ }
1316
+
1317
+ const billable_after = cycle_end_ts && compute_cycle_billable_amount ? compute_cycle_billable_amount(doc, anchor_ts, cycle_end_ts) : null;
1318
+
1319
+ if (!dry_run) {
1320
+ doc.app_cost = cost;
1321
+ await db_module.save_couch_doc('xuda_master', doc);
1322
+ }
1323
+
1324
+ summary.rebased++;
1325
+ summary.per_doc.push({
1326
+ _id: doc._id,
1327
+ app_name: doc.app_name,
1328
+ app_type: doc.app_type,
1329
+ action: dry_run ? 'would-rebase' : 'rebased',
1330
+ created_ts_before: created_before,
1331
+ created_ts_after: cost.created_ts,
1332
+ ...(billable_before != null ? { billable_before: Number(billable_before.toFixed(2)), billable_after: Number(billable_after.toFixed(2)) } : {}),
1333
+ });
1334
+ } catch (err) {
1335
+ summary.errors.push({ _id: doc._id, message: err.message });
1336
+ }
1337
+ }
1338
+
1339
+ return { code: 1, data: summary };
1340
+ };
1341
+
1180
1342
  // True when a plan is team-tier or higher (team/agency/enterprise_*). Sponsoring a
1181
1343
  // login screen requires this tier both at invite time and while the sponsorship is served.
1182
1344
  export const is_login_sponsor_tier = function (membership_plan) {
@@ -2101,7 +2263,7 @@ export const ops_deploy_server = async function (req = {}) {
2101
2263
  const plan = { server_id, mode, current_state: _srv_deploy_state(doc), busy: _srv_busy(doc), private_ip: doc.private || null };
2102
2264
  if (req.dry_run) return { code: 1, data: { dry_run: true, would_deploy: plan } };
2103
2265
 
2104
- // Confirmation gate proportional to blast radius.
2266
+ // Confirmation gate, proportional to blast radius.
2105
2267
  if (mode === 'hard') {
2106
2268
  if (req.confirm !== server_id) return { code: -1, data: `hard deploy tears down and reinstalls "${server_id}" (brief downtime). Re-send with confirm:"${server_id}" to proceed.` };
2107
2269
  } else {
@@ -2167,6 +2329,325 @@ export const ops_server_deploy_status = async function (req = {}) {
2167
2329
  } catch (err) { return { code: -1, data: err.message }; }
2168
2330
  };
2169
2331
 
2332
+ // ─────────────────────────────────────────────────────────────────────────────
2333
+ // Ops: batch module hot-fix across selected region app-servers.
2334
+ //
2335
+ // utils/hotfix_prod.mjs is the surgical per-module patcher: it pulls the freshly
2336
+ // published @xuda.io npm versions onto each box and reloads ONLY the affected pm2
2337
+ // processes (no teardown, no config touch, unlike a full rollout). These methods
2338
+ // expose it to the ops Servers screen so a superuser can pick servers + modules
2339
+ // and run it as a batch instead of ssh'ing to a box. It runs on whatever fleet
2340
+ // node serves this request (regions carry utils/, npm auth and the WG key), and
2341
+ // --hosts is ALWAYS explicit so it targets exactly the selection regardless of
2342
+ // which node runs it. Dry-run (preview) writes nothing; apply is confirm-gated
2343
+ // and audited, the same discipline as ops_deploy_server.
2344
+ // ─────────────────────────────────────────────────────────────────────────────
2345
+
2346
+ const _HOTFIX_TOOL = () => path.join(process.env.XUDA_HOME, 'utils', 'hotfix_prod.mjs');
2347
+ const _NPM_SCOPE = '@xuda.io';
2348
+
2349
+ // Managed-module universe = config.json `modules` keys (what a full rollout
2350
+ // installs), the same set hotfix_prod manages.
2351
+ const _ops_module_universe = () => Object.keys((_conf && _conf.modules) || {}).sort();
2352
+
2353
+ // module -> pm2 process name, derived from ecosystem.config.js exactly like
2354
+ // hotfix_prod.buildModuleProcMap: a broker app whose `args` is a bare
2355
+ // "<x>_module", or a script that runs a published module. Modules with no entry
2356
+ // are libraries (no own process; a version bump only lands when importers reload).
2357
+ let _ops_proc_map_cache = null;
2358
+ const _ops_proc_map = () => {
2359
+ if (_ops_proc_map_cache) return _ops_proc_map_cache;
2360
+ const map = {};
2361
+ try {
2362
+ const eco = _require(path.join(process.env.XUDA_HOME, 'ecosystem.config.js'));
2363
+ for (const app of eco.apps || []) {
2364
+ let mod = null;
2365
+ if (typeof app.args === 'string' && /^[a-z0-9_]+_module$/.test(app.args.trim())) mod = app.args.trim();
2366
+ else if (typeof app.script === 'string') { const m = app.script.match(/@xuda\.io\/([a-z0-9_]+_module)\//); if (m) mod = m[1]; }
2367
+ if (mod) map[mod] = app.name;
2368
+ }
2369
+ } catch (e) {}
2370
+ _ops_proc_map_cache = map;
2371
+ return map;
2372
+ };
2373
+
2374
+ // Bounded-concurrency map (npm view is per-package and slowish).
2375
+ const _ops_pool = async (items, n, fn) => {
2376
+ const res = new Array(items.length);
2377
+ let i = 0;
2378
+ await Promise.all(Array.from({ length: Math.min(n, items.length || 1) }, async () => {
2379
+ while (i < items.length) { const idx = i++; res[idx] = await fn(items[idx], idx); }
2380
+ }));
2381
+ return res;
2382
+ };
2383
+
2384
+ // Latest published version + its publish date for one @xuda.io module (uses the
2385
+ // box's ~/.npmrc for private-registry auth, same as hotfix_prod).
2386
+ const _ops_npm_info = async (mod) => {
2387
+ try {
2388
+ const { stdout } = await _execFileP('npm', ['view', `${_NPM_SCOPE}/${mod}`, 'version', 'time', '--json'], { maxBuffer: 8 * 1024 * 1024, timeout: 20000 });
2389
+ const j = JSON.parse(stdout || '{}');
2390
+ const version = j.version || null;
2391
+ const published_at = (version && j.time && j.time[version]) || (j.time && j.time.modified) || null;
2392
+ return version ? { ok: true, version, published_at } : { ok: false, reason: 'no version' };
2393
+ } catch (e) {
2394
+ const t = String((e && (e.stderr || e.message)) || e);
2395
+ if (/E404|404 Not Found|is not in this registry/i.test(t)) return { ok: false, reason: 'not published' };
2396
+ if (/E403|403 Forbidden/i.test(t)) return { ok: false, reason: 'npm auth (403)' };
2397
+ return { ok: false, reason: (t.split('\n')[0] || 'error').slice(0, 100) };
2398
+ }
2399
+ };
2400
+
2401
+ // Short cache: the module dropdown reopens often and npm view is slow.
2402
+ let _ops_modinfo_cache = { ts: 0, data: null };
2403
+
2404
+ // Resolve selected server ids -> deployable region app-servers with a WG private
2405
+ // ip, as hotfix_prod --hosts targets (root@<private>). Rejects non-deployable /
2406
+ // dev / missing-ip with a reason, so the caller can surface exactly what dropped.
2407
+ const _ops_resolve_hotfix_hosts = async (server_ids) => {
2408
+ const ids = [...new Set((server_ids || []).map((s) => String(s || '').trim()).filter(Boolean))];
2409
+ if (!ids.length) return { ok: false, reason: 'select at least one server' };
2410
+ const docs = await _load_all_server_docs();
2411
+ const byId = {};
2412
+ docs.forEach((d) => (byId[d._id] = d));
2413
+ const hosts = [];
2414
+ const rejected = [];
2415
+ for (const id of ids) {
2416
+ const d = byId[id];
2417
+ if (!d) { rejected.push(`${id} (not found)`); continue; }
2418
+ if (!_srv_is_deployable(d)) { rejected.push(`${id} (not a deployable region app-server)`); continue; }
2419
+ const priv = d.private || (d.server_info && d.server_info.private_ip) || null;
2420
+ if (!priv) { rejected.push(`${id} (no WireGuard private ip)`); continue; }
2421
+ hosts.push({ id, ssh: `root@${priv}` });
2422
+ }
2423
+ return { ok: hosts.length > 0, hosts, rejected };
2424
+ };
2425
+
2426
+ const _ops_validate_modules = (modules) => {
2427
+ const universe = _ops_module_universe();
2428
+ const list = [...new Set((modules || []).map((m) => String(m || '').trim()).filter(Boolean))];
2429
+ return { list, unknown: list.filter((m) => !universe.includes(m)) };
2430
+ };
2431
+
2432
+ // Spawn utils/hotfix_prod.mjs against explicit WG hosts. execFile rejects on a
2433
+ // non-zero exit (hotfix_prod exits 1 if ANY host failed), which we translate to
2434
+ // ok:false rather than throwing, keeping stdout for the summary either way.
2435
+ const _ops_run_hotfix = async ({ hosts, modules, dry_run, timeout_ms }) => {
2436
+ const args = [_HOTFIX_TOOL(), '--hosts', hosts.map((h) => h.ssh).join(','), ...(modules || []), ...(dry_run ? ['--dry-run'] : [])];
2437
+ try {
2438
+ const { stdout, stderr } = await _execFileP('node', args, { maxBuffer: 32 * 1024 * 1024, timeout: timeout_ms || 300000, env: { ...process.env } });
2439
+ return { ok: true, exit: 0, stdout: String(stdout || ''), stderr: String(stderr || '') };
2440
+ } catch (e) {
2441
+ 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) };
2442
+ }
2443
+ };
2444
+
2445
+ // Pull hotfix_prod's human summary block + a tail for the UI.
2446
+ const _ops_hotfix_summary = (stdout) => {
2447
+ const lines = String(stdout || '').split('\n');
2448
+ const i = lines.findIndex((l) => /######## summary ########/.test(l));
2449
+ const summary = i >= 0 ? lines.slice(i + 1).map((l) => l.trim()).filter(Boolean).slice(0, 20) : [];
2450
+ const tail = lines.map((l) => l.replace(/\s+$/, '')).filter((l) => l.trim()).slice(-40);
2451
+ return { summary, tail };
2452
+ };
2453
+
2454
+ // List the managed modules with their latest published version + publish date and
2455
+ // whether they have a reloadable pm2 process. Feeds the batch hot-fix module picker.
2456
+ export const ops_list_modules = async function (req = {}) {
2457
+ try {
2458
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2459
+ const universe = _ops_module_universe();
2460
+ if (!universe.length) return { code: -1, data: 'no managed modules in config.modules' };
2461
+ const proc = _ops_proc_map();
2462
+ const now = Date.now();
2463
+ if (!_ops_modinfo_cache.data || now - _ops_modinfo_cache.ts > 60000 || req.refresh) {
2464
+ const infos = await _ops_pool(universe, 8, (m) => _ops_npm_info(m));
2465
+ const byMod = {};
2466
+ universe.forEach((m, i) => (byMod[m] = infos[i]));
2467
+ _ops_modinfo_cache = { ts: now, data: byMod };
2468
+ }
2469
+ const info = _ops_modinfo_cache.data;
2470
+ const modules = universe.map((name) => {
2471
+ const ni = info[name] || {};
2472
+ return {
2473
+ name,
2474
+ proc: proc[name] || null,
2475
+ has_process: !!proc[name],
2476
+ version: ni.ok ? ni.version : null,
2477
+ published_at: ni.ok ? ni.published_at : null,
2478
+ unavailable: ni.ok ? null : ni.reason || 'unresolved',
2479
+ };
2480
+ });
2481
+ return { code: 1, data: { modules, count: modules.length, scope: _NPM_SCOPE, cached_ts: _ops_modinfo_cache.ts } };
2482
+ } catch (err) { return { code: -1, data: err.message }; }
2483
+ };
2484
+
2485
+ // Preview a batch hot-fix: runs hotfix_prod --dry-run against the selected servers
2486
+ // (and optional module subset) and returns the plan. Writes NOTHING, so it needs
2487
+ // no confirmation and is safe to call from the confirm dialog's "Preview" button.
2488
+ export const ops_hotfix_preview = async function (req = {}) {
2489
+ try {
2490
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2491
+ const h = await _ops_resolve_hotfix_hosts(req.server_ids);
2492
+ if (!h.ok) return { code: -1, data: `no deployable servers in the selection${h.rejected && h.rejected.length ? ': ' + h.rejected.join(', ') : ''}` };
2493
+ const { list, unknown } = _ops_validate_modules(req.modules);
2494
+ if (unknown.length) return { code: -1, data: `unknown module(s): ${unknown.join(', ')}` };
2495
+ const run = await _ops_run_hotfix({ hosts: h.hosts, modules: list, dry_run: true, timeout_ms: 180000 });
2496
+ const { summary, tail } = _ops_hotfix_summary(run.stdout);
2497
+ return {
2498
+ code: 1,
2499
+ data: {
2500
+ dry_run: true,
2501
+ hosts: h.hosts.map((x) => x.id),
2502
+ rejected: h.rejected || [],
2503
+ modules: list.length ? list : 'all changed',
2504
+ resolved: run.ok,
2505
+ summary,
2506
+ log_tail: tail,
2507
+ },
2508
+ };
2509
+ } catch (err) { return { code: -1, data: err.message }; }
2510
+ };
2511
+
2512
+ // Apply a batch hot-fix to the selected region app-servers. JOB method (async,
2513
+ // long-running): the platform returns a job_id and the dashboard tracks progress.
2514
+ // Superuser + confirm:true gated (this reloads live prod processes); audited +
2515
+ // notified, the same discipline as ops_deploy_server. Idempotent per hotfix_prod:
2516
+ // modules already current on a box are skipped, so a re-run is safe.
2517
+ export const ops_batch_hotfix = async function (req = {}) {
2518
+ try {
2519
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2520
+ const h = await _ops_resolve_hotfix_hosts(req.server_ids);
2521
+ if (!h.ok) return { code: -1, data: `no deployable servers in the selection${h.rejected && h.rejected.length ? ': ' + h.rejected.join(', ') : ''}` };
2522
+ const { list, unknown } = _ops_validate_modules(req.modules);
2523
+ if (unknown.length) return { code: -1, data: `unknown module(s): ${unknown.join(', ')}` };
2524
+ if (req.confirm !== true && req.confirm !== 'true') {
2525
+ return { code: -1, data: `this hot-fixes ${h.hosts.length} live server(s): ${h.hosts.map((x) => x.id).join(', ')}. Re-send with confirm:true to proceed.` };
2526
+ }
2527
+ const started = Date.now();
2528
+ const mod_label = list.length ? list.join(', ') : 'all changed';
2529
+ const run = await _ops_run_hotfix({ hosts: h.hosts, modules: list, dry_run: false, timeout_ms: 1200000 });
2530
+ const { summary, tail } = _ops_hotfix_summary(run.stdout);
2531
+ const host_ids = h.hosts.map((x) => x.id);
2532
+ _ops_notify_ops(
2533
+ `Batch hot-fix ${run.ok ? 'converged' : 'FAILED'}: ${host_ids.join(', ')}`,
2534
+ [['Servers', host_ids.join(', ')], ['Modules', mod_label], ['By', req.uid], ['Result', run.ok ? 'converged' : `exit ${run.exit}`], ['Elapsed', Math.round((Date.now() - started) / 1000) + 's']],
2535
+ 'OPS-HOTFIX'
2536
+ );
2537
+ logs_msa.add_account_log({ uid: req.uid, method: 'ops_batch_hotfix', security: true, source: 'ops dashboard', response_status_code: run.ok ? 200 : 500, response_data: `hotfix [${mod_label}] on ${host_ids.join(',')} by ${req.uid}: ${run.ok ? 'converged' : 'FAILED exit ' + run.exit}` });
2538
+ if (!run.ok) return { code: -1, data: { converged: false, hosts: host_ids, modules: list.length ? list : 'all changed', summary, log_tail: tail, error: (run.stderr || '').split('\n')[0] || `hotfix exit ${run.exit}` } };
2539
+ return { code: 1, data: { converged: true, hosts: host_ids, modules: list.length ? list : 'all changed', summary, log_tail: tail, elapsed_ms: Date.now() - started } };
2540
+ } catch (err) { return { code: -1, data: err.message }; }
2541
+ };
2542
+
2543
+ // utils/regional_server_setup/index.mjs - the raw-box onboarding engine the supervisor
2544
+ // "Add infra box and region" button drives. Runs on the dev orchestrator (like the
2545
+ // publish rollout). The engine reads config.json + on-box secrets to get the fleet couch
2546
+ // admin + control-plane key.
2547
+ const _SETUP_TOOL = () => path.join(process.env.XUDA_HOME, 'utils', 'regional_server_setup', 'index.mjs');
2548
+
2549
+ // The secrets file the engine merges over config.json. Prefer prod secrets; fall back to
2550
+ // the dev secrets (the fleet couch admin is shared). The box running this job must carry
2551
+ // one (the copy_secrets flow).
2552
+ const _ops_setup_secrets = () => {
2553
+ for (const p of [process.env.XUDA_SECRETS, '/root/secrets.json', '/root/secrets_dev.json']) {
2554
+ try { if (p && fs.existsSync(p)) return p; } catch (e) {}
2555
+ }
2556
+ return process.env.XUDA_SECRETS || '/root/secrets.json';
2557
+ };
2558
+
2559
+ // Strip the ssh password from the spec before it is written to a temp file, logged, or
2560
+ // returned. The password ONLY ever travels as XUDA_SSH_PASS in the child env, is used for
2561
+ // the single first hop, and is never persisted to couch, a job doc, a log, or the audit.
2562
+ const _ops_setup_sanitize = (spec) => {
2563
+ const s = JSON.parse(JSON.stringify(spec || {}));
2564
+ if (s.ssh) { delete s.ssh.password; delete s.ssh.pass; }
2565
+ delete s.ssh_password; delete s.password;
2566
+ return s;
2567
+ };
2568
+
2569
+ // Server-side spec validation (mirrors the engine's validate_spec) so the dialog gets a
2570
+ // clean error before anything is spawned.
2571
+ const _ops_setup_validate = (spec) => {
2572
+ if (!spec || typeof spec !== 'object') return 'spec is required';
2573
+ if (!Array.isArray(spec.targets) || !spec.targets.length) return 'targets must include "infra" and/or "region"';
2574
+ for (const t of spec.targets) if (!['infra', 'region'].includes(t)) return `unknown target "${t}"`;
2575
+ if (!spec.ssh || !spec.ssh.host) return 'ssh.host is required';
2576
+ if (spec.targets.includes('infra') && (!spec.infra || !spec.infra.metro || !spec.infra.region)) return 'infra.metro + infra.region are required';
2577
+ if (spec.targets.includes('region')) {
2578
+ const c = Number(spec.region && spec.region.case);
2579
+ if (![1, 2, 3].includes(c)) return 'region.case must be 1, 2, or 3';
2580
+ if (!spec.region.code) return 'region.code is required';
2581
+ if (!spec.region.source_region) return 'region.source_region is required';
2582
+ if (c !== 1 && !spec.region.of_region) return 'region.of_region is required for a peer (case 2/3)';
2583
+ }
2584
+ return null;
2585
+ };
2586
+
2587
+ // Spawn the engine against a temp spec file (password never in the file). Mirrors
2588
+ // _ops_run_hotfix: execFile rejects on a non-zero exit, translated to ok:false.
2589
+ const _ops_spawn_setup = async ({ spec, password, apply_fleet, dry_run, timeout_ms }) => {
2590
+ const dir = path.join(process.env.XUDA_HOME, 'utils', 'temp');
2591
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
2592
+ const spec_path = path.join(dir, `rss_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
2593
+ fs.writeFileSync(spec_path, JSON.stringify(_ops_setup_sanitize(spec)), { mode: 0o600 });
2594
+ const args = [_SETUP_TOOL(), spec_path, ...(dry_run ? ['--dry-run'] : []), ...(apply_fleet && !dry_run ? ['--apply-fleet'] : [])];
2595
+ const env = { ...process.env, XUDA_SECRETS: _ops_setup_secrets() };
2596
+ if (password && !dry_run) env.XUDA_SSH_PASS = String(password);
2597
+ try {
2598
+ const { stdout, stderr } = await _execFileP('node', args, { maxBuffer: 32 * 1024 * 1024, timeout: timeout_ms || 300000, env });
2599
+ return { ok: true, exit: 0, stdout: String(stdout || ''), stderr: String(stderr || '') };
2600
+ } catch (e) {
2601
+ 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) };
2602
+ } finally { try { fs.unlinkSync(spec_path); } catch (e) {} }
2603
+ };
2604
+
2605
+ // Onboard a raw box (IP + user/pass) as an infra host, a region control-plane, or both.
2606
+ // JOB method (long-running). Superuser + confirm gated (this provisions a prod box + runs
2607
+ // the gated fleet writes); audited + notified with the password redacted. dry_run returns
2608
+ // the ordered plan and writes nothing. NOTE for the request layer: `ssh_password` is
2609
+ // transient and must be excluded from any request logging (the slim app_log does not
2610
+ // capture bodies; this method never persists it).
2611
+ export const ops_add_infra_box_region = async function (req = {}) {
2612
+ try {
2613
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2614
+ const spec = req.spec || {};
2615
+ const verr = _ops_setup_validate(spec);
2616
+ if (verr) return { code: -1, data: verr };
2617
+ const password = req.ssh_password || (spec.ssh && (spec.ssh.password || spec.ssh.pass)) || '';
2618
+ const label = `${(spec.targets || []).join('+')} on ${spec.ssh.host}`;
2619
+
2620
+ // Preview: run the engine --dry-run and return its ordered plan (writes nothing, so no
2621
+ // confirm needed - safe from the dialog's Preview button).
2622
+ if (req.dry_run) {
2623
+ const run = await _ops_spawn_setup({ spec, dry_run: true, timeout_ms: 60000 });
2624
+ const plan = String(run.stdout || run.stderr || '').split('\n').filter((l) => l.trim()).slice(-60);
2625
+ return { code: 1, data: { dry_run: true, target: label, plan } };
2626
+ }
2627
+
2628
+ if (!password) return { code: -1, data: 'ssh_password is required for the first hop (it is used once and never stored)' };
2629
+ // install_pve reboots + reconfigures the host - a typed confirm, like ops_deploy_server hard mode.
2630
+ const destructive = !!(spec.infra && spec.infra.install_pve !== false && (spec.targets || []).includes('infra'));
2631
+ if (destructive) {
2632
+ if (req.confirm !== spec.ssh.host) return { code: -1, data: `installing Proxmox on ${spec.ssh.host} reboots + reconfigures the host. Re-send with confirm:"${spec.ssh.host}" to proceed.` };
2633
+ } else if (req.confirm !== true && req.confirm !== 'true') {
2634
+ return { code: -1, data: `this provisions ${label} and applies the fleet writes. Re-send with confirm:true to proceed.` };
2635
+ }
2636
+
2637
+ const started = Date.now();
2638
+ const run = await _ops_spawn_setup({ spec, password, apply_fleet: true, dry_run: false, timeout_ms: 2400000 });
2639
+ const tail = String(run.stdout || '').split('\n').map((l) => l.replace(/\s+$/, '')).filter((l) => l.trim()).slice(-50);
2640
+ _ops_notify_ops(
2641
+ `Add infra/region ${run.ok ? 'DONE' : 'FAILED'}: ${label}`,
2642
+ [['Target', label], ['By', req.uid], ['Result', run.ok ? 'ok' : `exit ${run.exit}`], ['Elapsed', Math.round((Date.now() - started) / 1000) + 's']],
2643
+ 'OPS-ADD-BOX'
2644
+ );
2645
+ logs_msa.add_account_log({ uid: req.uid, method: 'ops_add_infra_box_region', security: true, source: 'ops dashboard', response_status_code: run.ok ? 200 : 500, response_data: `add box [${label}] by ${req.uid}: ${run.ok ? 'ok' : 'FAILED exit ' + run.exit}` });
2646
+ if (!run.ok) return { code: -1, data: { ok: false, target: label, log_tail: tail, error: (run.stderr || '').split('\n')[0] || `engine exit ${run.exit}` } };
2647
+ return { code: 1, data: { ok: true, target: label, log_tail: tail, elapsed_ms: Date.now() - started } };
2648
+ } catch (err) { return { code: -1, data: err.message }; }
2649
+ };
2650
+
2170
2651
  const TIPS_FILE_PATH = path.join(process.env.XUDA_HOME, 'dist', 'tips', 'tips.json');
2171
2652
  let _did_you_know_tips_cache = null;
2172
2653
  let _did_you_know_tips_cache_mtime = 0;
@@ -5951,6 +6432,435 @@ export const get_account_ai_usage = async function (req, job_id, headers) {
5951
6432
  }
5952
6433
  };
5953
6434
 
6435
+ /////////////////////////////////////////////////////////////////////////
6436
+ // CREDIT MANAGEMENT (Team plan): pool + per-profile / per-user / per-model /
6437
+ // per-source soft and hard limits, model allow/deny, freeze, threshold alerts.
6438
+ // Rules live on the owner account doc (xuda_accounts.<owner>.credit_rules) and
6439
+ // are evaluated centrally in evaluate_credit_gate. ai_module's
6440
+ // validate_credits_limit is a thin adapter that preserves the legacy
6441
+ // { credit_limit_error, account_id } Error contract, so absent or disabled
6442
+ // rules behave exactly as before (a single account-wide ledger cap).
6443
+ /////////////////////////////////////////////////////////////////////////
6444
+
6445
+ // A limit is { type: 'credits' | 'percent', value: number }. percent is a share
6446
+ // of the granted pool (get_effective_entitlements().ai_credits) and is allowed on
6447
+ // pool / profile / user (allocation); model and source caps are absolute credits.
6448
+ const _resolve_limit = function (limit, entitlement) {
6449
+ if (!limit || typeof limit.value !== 'number' || !(limit.value >= 0)) return Infinity;
6450
+ if (limit.type === 'percent') {
6451
+ if (!Number.isFinite(entitlement)) return Infinity; // unlimited pool: percent is uncapped
6452
+ return entitlement * (limit.value / 100);
6453
+ }
6454
+ return limit.value;
6455
+ };
6456
+
6457
+ // Team plan or above (rank compare, same basis as is_login_sponsor_tier).
6458
+ const _is_team_tier = function (membership_plan) {
6459
+ const rank = _conf.PLAN_OBJ?.[membership_plan]?.rank;
6460
+ const team_rank = _conf.PLAN_OBJ?.['team']?.rank;
6461
+ if (typeof rank === 'number' && typeof team_rank === 'number') return rank >= team_rank;
6462
+ return ['team', 'agency', 'enterprise_t1', 'enterprise_t2', 'enterprise_t3'].includes(membership_plan);
6463
+ };
6464
+
6465
+ const _default_credit_rules = function () {
6466
+ const d = _conf.credit_rules_defaults || {};
6467
+ return {
6468
+ version: 1,
6469
+ enabled: false,
6470
+ window: d.window || 'lifetime',
6471
+ pool: {},
6472
+ profiles: {},
6473
+ users: {},
6474
+ models: {},
6475
+ sources: {},
6476
+ alerts: { thresholds: Array.isArray(d.alert_thresholds) ? d.alert_thresholds : [80, 100], channels: ['ws', 'email'] },
6477
+ };
6478
+ };
6479
+
6480
+ // Fold the authoritative usage view ({ apid: { uid: credits } }) into per-profile
6481
+ // and per-user credit totals.
6482
+ const _fold_usage = function (profile_map) {
6483
+ const by_profile = {};
6484
+ const by_user = {};
6485
+ for (const [apid, users] of Object.entries(profile_map || {})) {
6486
+ for (const [u, amt] of Object.entries(users || {})) {
6487
+ const n = Number(amt) || 0;
6488
+ by_profile[apid] = (by_profile[apid] || 0) + n;
6489
+ by_user[u] = (by_user[u] || 0) + n;
6490
+ }
6491
+ }
6492
+ return { by_profile, by_user };
6493
+ };
6494
+
6495
+ // Wide window so "used" reads as all-time spend (the lifetime / remaining-pool model).
6496
+ const _LIFETIME_WINDOW = { year_from: 2000, month_from: 1, day_from: 1, year_to: 2999, month_to: 12, day_to: 31 };
6497
+
6498
+ // Per-model / per-source spend is not emitted by the usage view, so re-derive it
6499
+ // from the raw ai_usage docs and CALIBRATE to the view's authoritative total, so the
6500
+ // numbers stay in the same credit unit (factor = view_total / raw_token_cost_total).
6501
+ // Cached per owner (in-process, short TTL) so the hot gate path stays cheap.
6502
+ const _scoped_usage_cache = {};
6503
+ const _SCOPED_USAGE_TTL = 45000;
6504
+ const _get_scoped_usage = async function (owner_uid, need_raw) {
6505
+ const now = Date.now();
6506
+ const cached = _scoped_usage_cache[owner_uid];
6507
+ if (cached && now - cached.ts < _SCOPED_USAGE_TTL && (!need_raw || cached.has_raw)) return cached.data;
6508
+
6509
+ const usage_ret = await get_account_ai_usage({ uid: owner_uid, ..._LIFETIME_WINDOW });
6510
+ const total = Number(usage_ret?.data?.usage?.total) || 0;
6511
+ const { by_profile, by_user } = _fold_usage(usage_ret?.data?.usage?.profile);
6512
+
6513
+ let by_model = {};
6514
+ let by_source = {};
6515
+ if (need_raw) {
6516
+ try {
6517
+ // ignore_warning=true: this selects on a nested field; add a Mango index on
6518
+ // ['account_profile_info.uid'] before any prod rollout of per-model/source caps.
6519
+ const raw = await db_module.find_couch_query(
6520
+ 'xuda_usage',
6521
+ { selector: { docType: 'ai_usage', 'account_profile_info.uid': owner_uid }, fields: ['model', 'cost', 'input_tokens', 'output_tokens', 'source'], limit: 200000 },
6522
+ true,
6523
+ );
6524
+ const raw_model = {};
6525
+ const raw_source = {};
6526
+ let raw_total = 0;
6527
+ for (const doc of raw?.docs || []) {
6528
+ const cost = doc.cost || { input: 1, output: 1 };
6529
+ const c = ((Number(doc.input_tokens) || 0) * (Number(cost.input) || 0) + (Number(doc.output_tokens) || 0) * (Number(cost.output) || 0)) / 1e6;
6530
+ raw_total += c;
6531
+ const code = _conf.ai_model_aliases?.[doc.model] || doc.model || 'unknown';
6532
+ raw_model[code] = (raw_model[code] || 0) + c;
6533
+ const src = doc.source || 'other';
6534
+ raw_source[src] = (raw_source[src] || 0) + c;
6535
+ }
6536
+ const factor = raw_total > 0 ? total / raw_total : 0;
6537
+ for (const [k, v] of Object.entries(raw_model)) by_model[k] = Math.round(v * factor * 100) / 100;
6538
+ for (const [k, v] of Object.entries(raw_source)) by_source[k] = Math.round(v * factor * 100) / 100;
6539
+ } catch (e) {
6540
+ console.error('[credit] scoped usage raw agg failed', e?.message || e);
6541
+ }
6542
+ }
6543
+
6544
+ const data = { total, credits_total: Number(usage_ret?.data?.credits?.total) || 0, by_profile, by_user, by_model, by_source };
6545
+ _scoped_usage_cache[owner_uid] = { ts: now, has_raw: !!need_raw, data };
6546
+ return data;
6547
+ };
6548
+
6549
+ // Threshold alert (soft breach): debounced in-app WS event to owner (and the spender).
6550
+ const _soft_emit_timers = {};
6551
+ export const emit_credit_soft_limit = function (owner_uid, member_uid, scopes, channels) {
6552
+ try {
6553
+ if (!owner_uid || !Array.isArray(scopes) || !scopes.length) return;
6554
+ if (Array.isArray(channels) && !channels.includes('ws')) return;
6555
+ clearTimeout(_soft_emit_timers[owner_uid]);
6556
+ _soft_emit_timers[owner_uid] = setTimeout(() => {
6557
+ delete _soft_emit_timers[owner_uid];
6558
+ try {
6559
+ const to = [owner_uid];
6560
+ if (member_uid && member_uid !== owner_uid) to.push(member_uid);
6561
+ ws_dashboard_msa.emit_message_to_dashboard({ service: 'credit_soft_limit', to, data: { account_id: owner_uid, scopes, ts: Date.now() } });
6562
+ } catch (e) {
6563
+ console.error('[emit_credit_soft_limit]', e?.message || e);
6564
+ }
6565
+ }, 800);
6566
+ } catch (e) {}
6567
+ };
6568
+
6569
+ // Deduped (once per scope per day, in-process) owner email when a hard cap blocks.
6570
+ const _alert_email_sent = {};
6571
+ const _maybe_alert_email = function (owner_uid, scope, reason, channels) {
6572
+ try {
6573
+ if (!owner_uid || scope === 'model_access' || scope === 'freeze') return;
6574
+ if (Array.isArray(channels) && !channels.includes('email')) return;
6575
+ const day = Math.floor(Date.now() / 86400000);
6576
+ const key = `${owner_uid}|${scope}|${day}`;
6577
+ if (_alert_email_sent[key]) return;
6578
+ _alert_email_sent[key] = true;
6579
+ (async () => {
6580
+ try {
6581
+ const acc = await db_module.get_couch_doc_native('xuda_accounts', owner_uid);
6582
+ const email = acc?.account_info?.email;
6583
+ if (!email) return;
6584
+ const body =
6585
+ `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#111;line-height:1.5">` +
6586
+ `<p>Heads up: an AI credit limit on your Xuda account was just reached.</p>` +
6587
+ `<p>${_.escape(reason || 'A credit limit was reached.')}</p>` +
6588
+ `<p>Open Credit Management in your dashboard to review your limits or top up.</p>` +
6589
+ `</div>`;
6590
+ email_msa.send_email({ email, subject: 'AI credit limit reached', body, display_type: 'info' });
6591
+ } catch (e) {
6592
+ console.error('[credit alert email]', e?.message || e);
6593
+ }
6594
+ })();
6595
+ } catch (e) {}
6596
+ };
6597
+
6598
+ const _model_access_blocks = function (ma, model_code) {
6599
+ if (!ma || ma.mode === 'off' || !model_code) return false;
6600
+ const allow = Array.isArray(ma.allow) ? ma.allow : [];
6601
+ const deny = Array.isArray(ma.deny) ? ma.deny : [];
6602
+ if (ma.mode === 'deny') return deny.includes(model_code);
6603
+ if (ma.mode === 'allow') return allow.length > 0 && !allow.includes(model_code);
6604
+ return false;
6605
+ };
6606
+
6607
+ const _SCOPE_REASON = {
6608
+ ledger: 'ai credits reach to hard limit',
6609
+ pool: 'Your account has reached its AI credit limit.',
6610
+ profile: 'This profile has reached its AI credit limit.',
6611
+ user: 'This member has reached their AI credit limit.',
6612
+ model: 'This model has reached its AI credit limit.',
6613
+ source: 'This action has reached its AI credit limit.',
6614
+ };
6615
+
6616
+ const _has_keys = (o) => !!o && typeof o === 'object' && Object.keys(o).length > 0;
6617
+ const _profile_has_sources = function (profiles) {
6618
+ if (!profiles) return false;
6619
+ for (const p of Object.values(profiles)) if (_has_keys(p?.sources)) return true;
6620
+ return false;
6621
+ };
6622
+
6623
+ const _block = function (owner, kind, reason, breach) {
6624
+ return { block: true, scope: breach.scope, reason, hard_breached: [breach], soft_breached: [], account_id: owner, block_kind: kind };
6625
+ };
6626
+
6627
+ // The single credit gate. Never throws; returns a plain verdict object.
6628
+ export const evaluate_credit_gate = async function (req) {
6629
+ const { uid, profile_id, model, source } = req || {};
6630
+ try {
6631
+ const api = await get_active_account_profile_info(uid, profile_id);
6632
+ const owner = api?.uid || uid;
6633
+ const apid = api?.account_profile_id;
6634
+ const member_uid = uid;
6635
+ const model_code = _conf.ai_model_aliases?.[model] || model || null;
6636
+
6637
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', owner);
6638
+ const rules = account_doc?.credit_rules;
6639
+
6640
+ // Back-compat: no rules or disabled => legacy single ledger cap, byte-for-byte.
6641
+ if (!rules || rules.enabled === false) {
6642
+ const usage = await get_account_ai_usage({ uid: owner });
6643
+ const over = (Number(usage?.data?.credits?.total) || 0) - (Number(usage?.data?.usage?.total) || 0) < -0.5;
6644
+ return { block: over, scope: over ? 'pool' : null, reason: over ? _SCOPE_REASON.ledger : '', hard_breached: over ? [{ scope: 'pool', key: 'ledger' }] : [], soft_breached: [], account_id: owner };
6645
+ }
6646
+
6647
+ // Freeze (hard stop, no usage lookup needed).
6648
+ if (apid && rules.profiles?.[apid]?.frozen) return _block(owner, 'freeze', 'This profile is paused.', { scope: 'profile', key: apid, kind: 'frozen' });
6649
+ if (member_uid && rules.users?.[member_uid]?.frozen) return _block(owner, 'freeze', 'This member is paused.', { scope: 'user', key: member_uid, kind: 'frozen' });
6650
+
6651
+ // Model access (allow/deny): profile rule then user rule.
6652
+ if (_model_access_blocks(rules.profiles?.[apid]?.model_access, model_code)) return _block(owner, 'model_access', 'This model is not allowed for this profile.', { scope: 'model', key: model_code, kind: 'model_access' });
6653
+ if (_model_access_blocks(rules.users?.[member_uid]?.model_access, model_code)) return _block(owner, 'model_access', 'This model is not allowed for this member.', { scope: 'model', key: model_code, kind: 'model_access' });
6654
+
6655
+ // Usage-based caps.
6656
+ const entitlement = get_effective_entitlements(account_doc).ai_credits;
6657
+ const need_raw = !!(_has_keys(rules.models) || _has_keys(rules.sources) || _profile_has_sources(rules.profiles));
6658
+ const su = await _get_scoped_usage(owner, need_raw);
6659
+
6660
+ const hard = [];
6661
+ const soft = [];
6662
+ const consider = function (scope, key, rule, used) {
6663
+ if (!rule) return;
6664
+ const h = _resolve_limit(rule.hard, entitlement);
6665
+ const s = _resolve_limit(rule.soft, entitlement);
6666
+ if (used >= h) hard.push({ scope, key, limit: h, used, type: rule.hard?.type || 'credits' });
6667
+ else if (used >= s) soft.push({ scope, key, limit: s, used, type: rule.soft?.type || 'credits', pct: s > 0 ? Math.round((used / s) * 100) : 100 });
6668
+ };
6669
+
6670
+ // Pool: ledger safety (remaining below zero) plus the configured pool guardrail.
6671
+ if (su.credits_total - su.total < -0.5) hard.push({ scope: 'pool', key: 'ledger' });
6672
+ consider('pool', 'pool', rules.pool, su.total);
6673
+ if (apid) consider('profile', apid, rules.profiles?.[apid], su.by_profile[apid] || 0);
6674
+ if (member_uid) consider('user', member_uid, rules.users?.[member_uid], su.by_user[member_uid] || 0);
6675
+ if (model_code) consider('model', model_code, rules.models?.[model_code], su.by_model[model_code] || 0);
6676
+ if (source) {
6677
+ consider('source', source, rules.sources?.[source], su.by_source[source] || 0);
6678
+ if (apid) consider('source', `${apid}:${source}`, rules.profiles?.[apid]?.sources?.[source], su.by_source[source] || 0);
6679
+ }
6680
+
6681
+ if (hard.length) {
6682
+ const first = hard[0];
6683
+ const reason = _SCOPE_REASON[first.key === 'ledger' ? 'ledger' : first.scope] || _SCOPE_REASON.pool;
6684
+ _maybe_alert_email(owner, first.scope, reason, rules.alerts?.channels);
6685
+ return { block: true, scope: first.scope, reason, hard_breached: hard, soft_breached: soft, account_id: owner };
6686
+ }
6687
+ if (soft.length) emit_credit_soft_limit(owner, member_uid, soft, rules.alerts?.channels);
6688
+ return { block: false, scope: null, reason: '', hard_breached: [], soft_breached: soft, account_id: owner };
6689
+ } catch (err) {
6690
+ // Fail open: a rules bug must not block all AI usage. Logged for follow-up.
6691
+ console.error('[evaluate_credit_gate]', err?.message || err);
6692
+ return { block: false, scope: null, reason: '', hard_breached: [], soft_breached: [], account_id: uid, error: err?.message };
6693
+ }
6694
+ };
6695
+
6696
+ // Originals only (not shared copies) belonging to the owner.
6697
+ const _list_owner_profiles = async function (owner_uid, app_id) {
6698
+ if (!app_id) return [];
6699
+ try {
6700
+ const ret = await db_module.find_app_couch_query(app_id, { selector: { docType: 'account_profile', uid: owner_uid, stat: 3 }, fields: ['_id', 'profile_name', 'share_item_id'], limit: 500 });
6701
+ return (ret?.docs || []).filter((d) => !d.share_item_id).map((d) => ({ account_profile_id: d._id, profile_name: d.profile_name || 'Profile' }));
6702
+ } catch (e) {
6703
+ console.error('[credit] list profiles', e?.message || e);
6704
+ return [];
6705
+ }
6706
+ };
6707
+
6708
+ // Owner plus every member their profiles are shared with, name-resolved.
6709
+ const _list_owner_members = async function (owner_uid, profiles) {
6710
+ const uids = new Set([owner_uid]);
6711
+ for (const p of profiles || []) {
6712
+ try {
6713
+ const members = await get_account_profile_group_member_uids(owner_uid, p.account_profile_id);
6714
+ for (const m of members) uids.add(m);
6715
+ } catch (e) {}
6716
+ }
6717
+ const out = [];
6718
+ for (const u of uids) {
6719
+ try {
6720
+ const acc = await db_module.get_couch_doc_native('xuda_accounts', u);
6721
+ const ai = acc?.account_info || {};
6722
+ const name = ai.account_type === 'business' ? ai.business_name || 'Member' : `${ai.first_name || ''} ${ai.last_name || ''}`.trim() || ai.email || 'Member';
6723
+ out.push({ uid: u, name });
6724
+ } catch (e) {
6725
+ out.push({ uid: u, name: 'Member' });
6726
+ }
6727
+ }
6728
+ return out;
6729
+ };
6730
+
6731
+ // Combined read powering the Credit Management page and the profile-settings embed.
6732
+ export const get_credit_management = async function (req) {
6733
+ try {
6734
+ const uid = req?.uid || req?.token_ret?.data?.uid;
6735
+ if (!uid) return { code: -1, data: 'not authorized' };
6736
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
6737
+ const plan = account_doc?.membership_plan || 'free';
6738
+ const can_edit = _is_team_tier(plan);
6739
+ const rules = account_doc?.credit_rules || _default_credit_rules();
6740
+ const ent = get_effective_entitlements(account_doc);
6741
+ const unlimited = !Number.isFinite(ent.ai_credits);
6742
+ const entitlements = { ai_credits: unlimited ? null : ent.ai_credits, unlimited };
6743
+
6744
+ const su = await _get_scoped_usage(uid, true);
6745
+ const remaining = unlimited ? null : Math.round((ent.ai_credits - su.total) * 100) / 100;
6746
+ const usage_breakdown = {
6747
+ window: { mode: rules.window || 'lifetime' },
6748
+ pool: { used: Math.round(su.total * 100) / 100, remaining },
6749
+ by_profile: su.by_profile,
6750
+ by_user: su.by_user,
6751
+ by_model: su.by_model,
6752
+ by_source: su.by_source,
6753
+ };
6754
+
6755
+ const model_catalog = {};
6756
+ for (const [code, e] of Object.entries(_conf.ai_models || {})) {
6757
+ model_catalog[code] = { name: e.name, type: e.type, price: e.price, model: e.model };
6758
+ }
6759
+
6760
+ const app_id = account_doc?.account_project_id;
6761
+ const profiles = await _list_owner_profiles(uid, app_id);
6762
+ const members = await _list_owner_members(uid, profiles);
6763
+
6764
+ return { code: 1, data: { rules, can_edit, plan, entitlements, usage_breakdown, model_catalog, profiles, members } };
6765
+ } catch (err) {
6766
+ return { code: -1, data: err.message };
6767
+ }
6768
+ };
6769
+
6770
+ const _normalize_limit = function (l, allow_percent) {
6771
+ if (!l || typeof l !== 'object') return undefined;
6772
+ const value = Number(l.value);
6773
+ if (!(value >= 0)) return undefined;
6774
+ const type = allow_percent && l.type === 'percent' ? 'percent' : 'credits';
6775
+ const out = { type, value };
6776
+ if (type === 'percent' && value > 100) out.value = 100;
6777
+ return out;
6778
+ };
6779
+ const _normalize_softhard = function (obj, allow_percent) {
6780
+ const out = {};
6781
+ const s = _normalize_limit(obj?.soft, allow_percent);
6782
+ const h = _normalize_limit(obj?.hard, allow_percent);
6783
+ if (s) out.soft = s;
6784
+ if (h) out.hard = h;
6785
+ return out;
6786
+ };
6787
+ const _normalize_model_access = function (m) {
6788
+ const mode = ['off', 'allow', 'deny'].includes(m?.mode) ? m.mode : 'off';
6789
+ const codes = _conf.ai_models || {};
6790
+ const clean = (arr) => (Array.isArray(arr) ? arr.filter((c) => typeof c === 'string' && codes[c]) : []);
6791
+ return { mode, allow: clean(m?.allow), deny: clean(m?.deny) };
6792
+ };
6793
+ const _normalize_sources = function (src) {
6794
+ const out = {};
6795
+ if (src && typeof src === 'object') {
6796
+ for (const [k, v] of Object.entries(src)) {
6797
+ const sh = _normalize_softhard(v, false);
6798
+ if (sh.soft || sh.hard) out[k] = sh;
6799
+ }
6800
+ }
6801
+ return out;
6802
+ };
6803
+ const _normalize_credit_rules = function (input, uid) {
6804
+ const d = _default_credit_rules();
6805
+ const r = input && typeof input === 'object' ? input : {};
6806
+ const out = {
6807
+ version: 1,
6808
+ enabled: r.enabled !== false,
6809
+ window: r.window === 'monthly' ? 'monthly' : 'lifetime',
6810
+ pool: _normalize_softhard(r.pool, true),
6811
+ profiles: {},
6812
+ users: {},
6813
+ models: {},
6814
+ sources: _normalize_sources(r.sources),
6815
+ alerts: {
6816
+ thresholds: Array.isArray(r.alerts?.thresholds) ? r.alerts.thresholds.map(Number).filter((n) => n > 0 && n <= 100) : d.alerts.thresholds,
6817
+ channels: Array.isArray(r.alerts?.channels) ? r.alerts.channels.filter((c) => ['ws', 'email'].includes(c)) : ['ws', 'email'],
6818
+ },
6819
+ updated_ts: Date.now(),
6820
+ updated_by: uid,
6821
+ };
6822
+ if (r.profiles && typeof r.profiles === 'object') {
6823
+ for (const [apid, pv] of Object.entries(r.profiles)) {
6824
+ out.profiles[apid] = { ..._normalize_softhard(pv, true), frozen: !!pv?.frozen, model_access: _normalize_model_access(pv?.model_access), sources: _normalize_sources(pv?.sources) };
6825
+ }
6826
+ }
6827
+ if (r.users && typeof r.users === 'object') {
6828
+ for (const [u, uv] of Object.entries(r.users)) {
6829
+ out.users[u] = { ..._normalize_softhard(uv, true), frozen: !!uv?.frozen, model_access: _normalize_model_access(uv?.model_access) };
6830
+ }
6831
+ }
6832
+ if (r.models && typeof r.models === 'object') {
6833
+ const codes = _conf.ai_models || {};
6834
+ for (const [code, mv] of Object.entries(r.models)) {
6835
+ if (!codes[code]) continue;
6836
+ const sh = _normalize_softhard(mv, false);
6837
+ if (sh.soft || sh.hard) out.models[code] = sh;
6838
+ }
6839
+ }
6840
+ return out;
6841
+ };
6842
+
6843
+ // Save the credit rules on the owner account doc. Team plan only (also gated by
6844
+ // the cpi min_plan). A member saving here writes to their own account and never
6845
+ // touches the owner's pool, so "owner authors" falls out of the data model.
6846
+ export const set_credit_rules = async function (req) {
6847
+ try {
6848
+ const uid = req?.uid || req?.token_ret?.data?.uid;
6849
+ if (!uid) return { code: -1, data: 'not authorized' };
6850
+ const acc_ret = await db_module.get_couch_doc('xuda_accounts', uid, true); // fresh read for the write
6851
+ if (acc_ret.code < 0) return acc_ret;
6852
+ const account_doc = acc_ret.data;
6853
+ if (!_is_team_tier(account_doc?.membership_plan)) return { code: -1, data: 'Credit management is available on the Team plan.' };
6854
+ account_doc.credit_rules = _normalize_credit_rules(req.rules, uid);
6855
+ await db_module.save_couch_doc('xuda_accounts', account_doc);
6856
+ delete _scoped_usage_cache[uid];
6857
+ broadcast_credits(uid);
6858
+ return { code: 1, data: { rules: account_doc.credit_rules } };
6859
+ } catch (err) {
6860
+ return { code: -1, data: err.message };
6861
+ }
6862
+ };
6863
+
5954
6864
  export const get_account_ai_usage_old = async function (req, job_id, headers) {
5955
6865
  const { uid, year_from, month_from, day_from, year_to, month_to, day_to } = req;
5956
6866
  try {
package/index_ms.mjs CHANGED
@@ -57,6 +57,14 @@ export const get_effective_entitlements = async function (...args) {
57
57
  return await broker.send_to_queue("get_effective_entitlements", ...args);
58
58
  };
59
59
 
60
+ export const _site_build_image_modes = async function (...args) {
61
+ return await broker.send_to_queue("_site_build_image_modes", ...args);
62
+ };
63
+
64
+ export const get_site_build_image_modes = async function (...args) {
65
+ return await broker.send_to_queue("get_site_build_image_modes", ...args);
66
+ };
67
+
60
68
  export const increment_account_usage = async function (...args) {
61
69
  return await broker.send_to_queue("increment_account_usage", ...args);
62
70
  };
@@ -81,6 +89,10 @@ export const backfill_app_costs = async function (...args) {
81
89
  return await broker.send_to_queue("backfill_app_costs", ...args);
82
90
  };
83
91
 
92
+ export const rebase_app_cost_anchor = async function (...args) {
93
+ return await broker.send_to_queue("rebase_app_cost_anchor", ...args);
94
+ };
95
+
84
96
  export const is_login_sponsor_tier = async function (...args) {
85
97
  return await broker.send_to_queue("is_login_sponsor_tier", ...args);
86
98
  };
@@ -201,6 +213,22 @@ export const ops_server_deploy_status = async function (...args) {
201
213
  return await broker.send_to_queue("ops_server_deploy_status", ...args);
202
214
  };
203
215
 
216
+ export const ops_list_modules = async function (...args) {
217
+ return await broker.send_to_queue("ops_list_modules", ...args);
218
+ };
219
+
220
+ export const ops_hotfix_preview = async function (...args) {
221
+ return await broker.send_to_queue("ops_hotfix_preview", ...args);
222
+ };
223
+
224
+ export const ops_batch_hotfix = async function (...args) {
225
+ return await broker.send_to_queue("ops_batch_hotfix", ...args);
226
+ };
227
+
228
+ export const ops_add_infra_box_region = async function (...args) {
229
+ return await broker.send_to_queue("ops_add_infra_box_region", ...args);
230
+ };
231
+
204
232
  export const did_you_know_tips = async function (...args) {
205
233
  return await broker.send_to_queue("did_you_know_tips", ...args);
206
234
  };
@@ -477,6 +505,22 @@ export const get_account_ai_usage = async function (...args) {
477
505
  return await broker.send_to_queue("get_account_ai_usage", ...args);
478
506
  };
479
507
 
508
+ export const emit_credit_soft_limit = async function (...args) {
509
+ return await broker.send_to_queue("emit_credit_soft_limit", ...args);
510
+ };
511
+
512
+ export const evaluate_credit_gate = async function (...args) {
513
+ return await broker.send_to_queue("evaluate_credit_gate", ...args);
514
+ };
515
+
516
+ export const get_credit_management = async function (...args) {
517
+ return await broker.send_to_queue("get_credit_management", ...args);
518
+ };
519
+
520
+ export const set_credit_rules = async function (...args) {
521
+ return await broker.send_to_queue("set_credit_rules", ...args);
522
+ };
523
+
480
524
  export const get_account_ai_usage_old = async function (...args) {
481
525
  return await broker.send_to_queue("get_account_ai_usage_old", ...args);
482
526
  };
package/index_msa.mjs CHANGED
@@ -57,6 +57,14 @@ export const get_effective_entitlements = function (...args) {
57
57
  broker.send_to_queue_async("get_effective_entitlements", ...args);
58
58
  };
59
59
 
60
+ export const _site_build_image_modes = function (...args) {
61
+ broker.send_to_queue_async("_site_build_image_modes", ...args);
62
+ };
63
+
64
+ export const get_site_build_image_modes = function (...args) {
65
+ broker.send_to_queue_async("get_site_build_image_modes", ...args);
66
+ };
67
+
60
68
  export const increment_account_usage = function (...args) {
61
69
  broker.send_to_queue_async("increment_account_usage", ...args);
62
70
  };
@@ -81,6 +89,10 @@ export const backfill_app_costs = function (...args) {
81
89
  broker.send_to_queue_async("backfill_app_costs", ...args);
82
90
  };
83
91
 
92
+ export const rebase_app_cost_anchor = function (...args) {
93
+ broker.send_to_queue_async("rebase_app_cost_anchor", ...args);
94
+ };
95
+
84
96
  export const is_login_sponsor_tier = function (...args) {
85
97
  broker.send_to_queue_async("is_login_sponsor_tier", ...args);
86
98
  };
@@ -201,6 +213,22 @@ export const ops_server_deploy_status = function (...args) {
201
213
  broker.send_to_queue_async("ops_server_deploy_status", ...args);
202
214
  };
203
215
 
216
+ export const ops_list_modules = function (...args) {
217
+ broker.send_to_queue_async("ops_list_modules", ...args);
218
+ };
219
+
220
+ export const ops_hotfix_preview = function (...args) {
221
+ broker.send_to_queue_async("ops_hotfix_preview", ...args);
222
+ };
223
+
224
+ export const ops_batch_hotfix = function (...args) {
225
+ broker.send_to_queue_async("ops_batch_hotfix", ...args);
226
+ };
227
+
228
+ export const ops_add_infra_box_region = function (...args) {
229
+ broker.send_to_queue_async("ops_add_infra_box_region", ...args);
230
+ };
231
+
204
232
  export const did_you_know_tips = function (...args) {
205
233
  broker.send_to_queue_async("did_you_know_tips", ...args);
206
234
  };
@@ -477,6 +505,22 @@ export const get_account_ai_usage = function (...args) {
477
505
  broker.send_to_queue_async("get_account_ai_usage", ...args);
478
506
  };
479
507
 
508
+ export const emit_credit_soft_limit = function (...args) {
509
+ broker.send_to_queue_async("emit_credit_soft_limit", ...args);
510
+ };
511
+
512
+ export const evaluate_credit_gate = function (...args) {
513
+ broker.send_to_queue_async("evaluate_credit_gate", ...args);
514
+ };
515
+
516
+ export const get_credit_management = function (...args) {
517
+ broker.send_to_queue_async("get_credit_management", ...args);
518
+ };
519
+
520
+ export const set_credit_rules = function (...args) {
521
+ broker.send_to_queue_async("set_credit_rules", ...args);
522
+ };
523
+
480
524
  export const get_account_ai_usage_old = function (...args) {
481
525
  broker.send_to_queue_async("get_account_ai_usage_old", ...args);
482
526
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xuda.io/account_module",
3
- "version": "1.2.2290",
3
+ "version": "1.2.2291",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {