@xuda.io/account_module 1.2.2290 → 1.2.2292

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 {
@@ -2125,7 +2287,10 @@ export const ops_deploy_server = async function (req = {}) {
2125
2287
  return { code: -1, data: `enqueue write to orchestrator couch failed: ${e.message}` };
2126
2288
  }
2127
2289
 
2128
- _ops_notify_ops(`Server deploy queued (${mode}): ${server_id}`, [['Server', server_id], ['Mode', mode], ['By', req.uid], ['State', 'queued'], ['Orchestrator', 'dev.xuda.ai']], 'OPS-DEPLOY-' + server_id);
2290
+ // No ops notification for the 'queued' state: it is immediate feedback on a button
2291
+ // the operator just clicked (the queued state is returned to the dashboard below and
2292
+ // pollable via ops_server_deploy_status), so the email/banner was pure noise. The
2293
+ // audit log is kept for traceability. Deploy done/failed notifications are unaffected.
2129
2294
  logs_msa.add_account_log({ uid: req.uid, method: 'ops_deploy_server', source: 'ops dashboard', response_status_code: 200, response_data: `queued ${mode} deploy for ${server_id} by ${req.uid}` });
2130
2295
 
2131
2296
  return { code: 1, data: { enqueued: true, server_id, mode, state: 'queued', orchestrator: 'dev.xuda.ai', poll: 'ops_server_deploy_status' } };
@@ -2167,6 +2332,325 @@ export const ops_server_deploy_status = async function (req = {}) {
2167
2332
  } catch (err) { return { code: -1, data: err.message }; }
2168
2333
  };
2169
2334
 
2335
+ // ─────────────────────────────────────────────────────────────────────────────
2336
+ // Ops: batch module hot-fix across selected region app-servers.
2337
+ //
2338
+ // utils/hotfix_prod.mjs is the surgical per-module patcher: it pulls the freshly
2339
+ // published @xuda.io npm versions onto each box and reloads ONLY the affected pm2
2340
+ // processes (no teardown, no config touch, unlike a full rollout). These methods
2341
+ // expose it to the ops Servers screen so a superuser can pick servers + modules
2342
+ // and run it as a batch instead of ssh'ing to a box. It runs on whatever fleet
2343
+ // node serves this request (regions carry utils/, npm auth and the WG key), and
2344
+ // --hosts is ALWAYS explicit so it targets exactly the selection regardless of
2345
+ // which node runs it. Dry-run (preview) writes nothing; apply is confirm-gated
2346
+ // and audited, the same discipline as ops_deploy_server.
2347
+ // ─────────────────────────────────────────────────────────────────────────────
2348
+
2349
+ const _HOTFIX_TOOL = () => path.join(process.env.XUDA_HOME, 'utils', 'hotfix_prod.mjs');
2350
+ const _NPM_SCOPE = '@xuda.io';
2351
+
2352
+ // Managed-module universe = config.json `modules` keys (what a full rollout
2353
+ // installs), the same set hotfix_prod manages.
2354
+ const _ops_module_universe = () => Object.keys((_conf && _conf.modules) || {}).sort();
2355
+
2356
+ // module -> pm2 process name, derived from ecosystem.config.js exactly like
2357
+ // hotfix_prod.buildModuleProcMap: a broker app whose `args` is a bare
2358
+ // "<x>_module", or a script that runs a published module. Modules with no entry
2359
+ // are libraries (no own process; a version bump only lands when importers reload).
2360
+ let _ops_proc_map_cache = null;
2361
+ const _ops_proc_map = () => {
2362
+ if (_ops_proc_map_cache) return _ops_proc_map_cache;
2363
+ const map = {};
2364
+ try {
2365
+ const eco = _require(path.join(process.env.XUDA_HOME, 'ecosystem.config.js'));
2366
+ for (const app of eco.apps || []) {
2367
+ let mod = null;
2368
+ if (typeof app.args === 'string' && /^[a-z0-9_]+_module$/.test(app.args.trim())) mod = app.args.trim();
2369
+ else if (typeof app.script === 'string') { const m = app.script.match(/@xuda\.io\/([a-z0-9_]+_module)\//); if (m) mod = m[1]; }
2370
+ if (mod) map[mod] = app.name;
2371
+ }
2372
+ } catch (e) {}
2373
+ _ops_proc_map_cache = map;
2374
+ return map;
2375
+ };
2376
+
2377
+ // Bounded-concurrency map (npm view is per-package and slowish).
2378
+ const _ops_pool = async (items, n, fn) => {
2379
+ const res = new Array(items.length);
2380
+ let i = 0;
2381
+ await Promise.all(Array.from({ length: Math.min(n, items.length || 1) }, async () => {
2382
+ while (i < items.length) { const idx = i++; res[idx] = await fn(items[idx], idx); }
2383
+ }));
2384
+ return res;
2385
+ };
2386
+
2387
+ // Latest published version + its publish date for one @xuda.io module (uses the
2388
+ // box's ~/.npmrc for private-registry auth, same as hotfix_prod).
2389
+ const _ops_npm_info = async (mod) => {
2390
+ try {
2391
+ const { stdout } = await _execFileP('npm', ['view', `${_NPM_SCOPE}/${mod}`, 'version', 'time', '--json'], { maxBuffer: 8 * 1024 * 1024, timeout: 20000 });
2392
+ const j = JSON.parse(stdout || '{}');
2393
+ const version = j.version || null;
2394
+ const published_at = (version && j.time && j.time[version]) || (j.time && j.time.modified) || null;
2395
+ return version ? { ok: true, version, published_at } : { ok: false, reason: 'no version' };
2396
+ } catch (e) {
2397
+ const t = String((e && (e.stderr || e.message)) || e);
2398
+ if (/E404|404 Not Found|is not in this registry/i.test(t)) return { ok: false, reason: 'not published' };
2399
+ if (/E403|403 Forbidden/i.test(t)) return { ok: false, reason: 'npm auth (403)' };
2400
+ return { ok: false, reason: (t.split('\n')[0] || 'error').slice(0, 100) };
2401
+ }
2402
+ };
2403
+
2404
+ // Short cache: the module dropdown reopens often and npm view is slow.
2405
+ let _ops_modinfo_cache = { ts: 0, data: null };
2406
+
2407
+ // Resolve selected server ids -> deployable region app-servers with a WG private
2408
+ // ip, as hotfix_prod --hosts targets (root@<private>). Rejects non-deployable /
2409
+ // dev / missing-ip with a reason, so the caller can surface exactly what dropped.
2410
+ const _ops_resolve_hotfix_hosts = async (server_ids) => {
2411
+ const ids = [...new Set((server_ids || []).map((s) => String(s || '').trim()).filter(Boolean))];
2412
+ if (!ids.length) return { ok: false, reason: 'select at least one server' };
2413
+ const docs = await _load_all_server_docs();
2414
+ const byId = {};
2415
+ docs.forEach((d) => (byId[d._id] = d));
2416
+ const hosts = [];
2417
+ const rejected = [];
2418
+ for (const id of ids) {
2419
+ const d = byId[id];
2420
+ if (!d) { rejected.push(`${id} (not found)`); continue; }
2421
+ if (!_srv_is_deployable(d)) { rejected.push(`${id} (not a deployable region app-server)`); continue; }
2422
+ const priv = d.private || (d.server_info && d.server_info.private_ip) || null;
2423
+ if (!priv) { rejected.push(`${id} (no WireGuard private ip)`); continue; }
2424
+ hosts.push({ id, ssh: `root@${priv}` });
2425
+ }
2426
+ return { ok: hosts.length > 0, hosts, rejected };
2427
+ };
2428
+
2429
+ const _ops_validate_modules = (modules) => {
2430
+ const universe = _ops_module_universe();
2431
+ const list = [...new Set((modules || []).map((m) => String(m || '').trim()).filter(Boolean))];
2432
+ return { list, unknown: list.filter((m) => !universe.includes(m)) };
2433
+ };
2434
+
2435
+ // Spawn utils/hotfix_prod.mjs against explicit WG hosts. execFile rejects on a
2436
+ // non-zero exit (hotfix_prod exits 1 if ANY host failed), which we translate to
2437
+ // ok:false rather than throwing, keeping stdout for the summary either way.
2438
+ const _ops_run_hotfix = async ({ hosts, modules, dry_run, timeout_ms }) => {
2439
+ const args = [_HOTFIX_TOOL(), '--hosts', hosts.map((h) => h.ssh).join(','), ...(modules || []), ...(dry_run ? ['--dry-run'] : [])];
2440
+ try {
2441
+ const { stdout, stderr } = await _execFileP('node', args, { maxBuffer: 32 * 1024 * 1024, timeout: timeout_ms || 300000, env: { ...process.env } });
2442
+ return { ok: true, exit: 0, stdout: String(stdout || ''), stderr: String(stderr || '') };
2443
+ } catch (e) {
2444
+ 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) };
2445
+ }
2446
+ };
2447
+
2448
+ // Pull hotfix_prod's human summary block + a tail for the UI.
2449
+ const _ops_hotfix_summary = (stdout) => {
2450
+ const lines = String(stdout || '').split('\n');
2451
+ const i = lines.findIndex((l) => /######## summary ########/.test(l));
2452
+ const summary = i >= 0 ? lines.slice(i + 1).map((l) => l.trim()).filter(Boolean).slice(0, 20) : [];
2453
+ const tail = lines.map((l) => l.replace(/\s+$/, '')).filter((l) => l.trim()).slice(-40);
2454
+ return { summary, tail };
2455
+ };
2456
+
2457
+ // List the managed modules with their latest published version + publish date and
2458
+ // whether they have a reloadable pm2 process. Feeds the batch hot-fix module picker.
2459
+ export const ops_list_modules = async function (req = {}) {
2460
+ try {
2461
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2462
+ const universe = _ops_module_universe();
2463
+ if (!universe.length) return { code: -1, data: 'no managed modules in config.modules' };
2464
+ const proc = _ops_proc_map();
2465
+ const now = Date.now();
2466
+ if (!_ops_modinfo_cache.data || now - _ops_modinfo_cache.ts > 60000 || req.refresh) {
2467
+ const infos = await _ops_pool(universe, 8, (m) => _ops_npm_info(m));
2468
+ const byMod = {};
2469
+ universe.forEach((m, i) => (byMod[m] = infos[i]));
2470
+ _ops_modinfo_cache = { ts: now, data: byMod };
2471
+ }
2472
+ const info = _ops_modinfo_cache.data;
2473
+ const modules = universe.map((name) => {
2474
+ const ni = info[name] || {};
2475
+ return {
2476
+ name,
2477
+ proc: proc[name] || null,
2478
+ has_process: !!proc[name],
2479
+ version: ni.ok ? ni.version : null,
2480
+ published_at: ni.ok ? ni.published_at : null,
2481
+ unavailable: ni.ok ? null : ni.reason || 'unresolved',
2482
+ };
2483
+ });
2484
+ return { code: 1, data: { modules, count: modules.length, scope: _NPM_SCOPE, cached_ts: _ops_modinfo_cache.ts } };
2485
+ } catch (err) { return { code: -1, data: err.message }; }
2486
+ };
2487
+
2488
+ // Preview a batch hot-fix: runs hotfix_prod --dry-run against the selected servers
2489
+ // (and optional module subset) and returns the plan. Writes NOTHING, so it needs
2490
+ // no confirmation and is safe to call from the confirm dialog's "Preview" button.
2491
+ export const ops_hotfix_preview = async function (req = {}) {
2492
+ try {
2493
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2494
+ const h = await _ops_resolve_hotfix_hosts(req.server_ids);
2495
+ if (!h.ok) return { code: -1, data: `no deployable servers in the selection${h.rejected && h.rejected.length ? ': ' + h.rejected.join(', ') : ''}` };
2496
+ const { list, unknown } = _ops_validate_modules(req.modules);
2497
+ if (unknown.length) return { code: -1, data: `unknown module(s): ${unknown.join(', ')}` };
2498
+ const run = await _ops_run_hotfix({ hosts: h.hosts, modules: list, dry_run: true, timeout_ms: 180000 });
2499
+ const { summary, tail } = _ops_hotfix_summary(run.stdout);
2500
+ return {
2501
+ code: 1,
2502
+ data: {
2503
+ dry_run: true,
2504
+ hosts: h.hosts.map((x) => x.id),
2505
+ rejected: h.rejected || [],
2506
+ modules: list.length ? list : 'all changed',
2507
+ resolved: run.ok,
2508
+ summary,
2509
+ log_tail: tail,
2510
+ },
2511
+ };
2512
+ } catch (err) { return { code: -1, data: err.message }; }
2513
+ };
2514
+
2515
+ // Apply a batch hot-fix to the selected region app-servers. JOB method (async,
2516
+ // long-running): the platform returns a job_id and the dashboard tracks progress.
2517
+ // Superuser + confirm:true gated (this reloads live prod processes); audited +
2518
+ // notified, the same discipline as ops_deploy_server. Idempotent per hotfix_prod:
2519
+ // modules already current on a box are skipped, so a re-run is safe.
2520
+ export const ops_batch_hotfix = async function (req = {}) {
2521
+ try {
2522
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2523
+ const h = await _ops_resolve_hotfix_hosts(req.server_ids);
2524
+ if (!h.ok) return { code: -1, data: `no deployable servers in the selection${h.rejected && h.rejected.length ? ': ' + h.rejected.join(', ') : ''}` };
2525
+ const { list, unknown } = _ops_validate_modules(req.modules);
2526
+ if (unknown.length) return { code: -1, data: `unknown module(s): ${unknown.join(', ')}` };
2527
+ if (req.confirm !== true && req.confirm !== 'true') {
2528
+ 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.` };
2529
+ }
2530
+ const started = Date.now();
2531
+ const mod_label = list.length ? list.join(', ') : 'all changed';
2532
+ const run = await _ops_run_hotfix({ hosts: h.hosts, modules: list, dry_run: false, timeout_ms: 1200000 });
2533
+ const { summary, tail } = _ops_hotfix_summary(run.stdout);
2534
+ const host_ids = h.hosts.map((x) => x.id);
2535
+ _ops_notify_ops(
2536
+ `Batch hot-fix ${run.ok ? 'converged' : 'FAILED'}: ${host_ids.join(', ')}`,
2537
+ [['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']],
2538
+ 'OPS-HOTFIX'
2539
+ );
2540
+ 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}` });
2541
+ 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}` } };
2542
+ return { code: 1, data: { converged: true, hosts: host_ids, modules: list.length ? list : 'all changed', summary, log_tail: tail, elapsed_ms: Date.now() - started } };
2543
+ } catch (err) { return { code: -1, data: err.message }; }
2544
+ };
2545
+
2546
+ // utils/regional_server_setup/index.mjs - the raw-box onboarding engine the supervisor
2547
+ // "Add infra box and region" button drives. Runs on the dev orchestrator (like the
2548
+ // publish rollout). The engine reads config.json + on-box secrets to get the fleet couch
2549
+ // admin + control-plane key.
2550
+ const _SETUP_TOOL = () => path.join(process.env.XUDA_HOME, 'utils', 'regional_server_setup', 'index.mjs');
2551
+
2552
+ // The secrets file the engine merges over config.json. Prefer prod secrets; fall back to
2553
+ // the dev secrets (the fleet couch admin is shared). The box running this job must carry
2554
+ // one (the copy_secrets flow).
2555
+ const _ops_setup_secrets = () => {
2556
+ for (const p of [process.env.XUDA_SECRETS, '/root/secrets.json', '/root/secrets_dev.json']) {
2557
+ try { if (p && fs.existsSync(p)) return p; } catch (e) {}
2558
+ }
2559
+ return process.env.XUDA_SECRETS || '/root/secrets.json';
2560
+ };
2561
+
2562
+ // Strip the ssh password from the spec before it is written to a temp file, logged, or
2563
+ // returned. The password ONLY ever travels as XUDA_SSH_PASS in the child env, is used for
2564
+ // the single first hop, and is never persisted to couch, a job doc, a log, or the audit.
2565
+ const _ops_setup_sanitize = (spec) => {
2566
+ const s = JSON.parse(JSON.stringify(spec || {}));
2567
+ if (s.ssh) { delete s.ssh.password; delete s.ssh.pass; }
2568
+ delete s.ssh_password; delete s.password;
2569
+ return s;
2570
+ };
2571
+
2572
+ // Server-side spec validation (mirrors the engine's validate_spec) so the dialog gets a
2573
+ // clean error before anything is spawned.
2574
+ const _ops_setup_validate = (spec) => {
2575
+ if (!spec || typeof spec !== 'object') return 'spec is required';
2576
+ if (!Array.isArray(spec.targets) || !spec.targets.length) return 'targets must include "infra" and/or "region"';
2577
+ for (const t of spec.targets) if (!['infra', 'region'].includes(t)) return `unknown target "${t}"`;
2578
+ if (!spec.ssh || !spec.ssh.host) return 'ssh.host is required';
2579
+ if (spec.targets.includes('infra') && (!spec.infra || !spec.infra.metro || !spec.infra.region)) return 'infra.metro + infra.region are required';
2580
+ if (spec.targets.includes('region')) {
2581
+ const c = Number(spec.region && spec.region.case);
2582
+ if (![1, 2, 3].includes(c)) return 'region.case must be 1, 2, or 3';
2583
+ if (!spec.region.code) return 'region.code is required';
2584
+ if (!spec.region.source_region) return 'region.source_region is required';
2585
+ if (c !== 1 && !spec.region.of_region) return 'region.of_region is required for a peer (case 2/3)';
2586
+ }
2587
+ return null;
2588
+ };
2589
+
2590
+ // Spawn the engine against a temp spec file (password never in the file). Mirrors
2591
+ // _ops_run_hotfix: execFile rejects on a non-zero exit, translated to ok:false.
2592
+ const _ops_spawn_setup = async ({ spec, password, apply_fleet, dry_run, timeout_ms }) => {
2593
+ const dir = path.join(process.env.XUDA_HOME, 'utils', 'temp');
2594
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}
2595
+ const spec_path = path.join(dir, `rss_${Date.now()}_${Math.floor(Math.random() * 1e6)}.json`);
2596
+ fs.writeFileSync(spec_path, JSON.stringify(_ops_setup_sanitize(spec)), { mode: 0o600 });
2597
+ const args = [_SETUP_TOOL(), spec_path, ...(dry_run ? ['--dry-run'] : []), ...(apply_fleet && !dry_run ? ['--apply-fleet'] : [])];
2598
+ const env = { ...process.env, XUDA_SECRETS: _ops_setup_secrets() };
2599
+ if (password && !dry_run) env.XUDA_SSH_PASS = String(password);
2600
+ try {
2601
+ const { stdout, stderr } = await _execFileP('node', args, { maxBuffer: 32 * 1024 * 1024, timeout: timeout_ms || 300000, env });
2602
+ return { ok: true, exit: 0, stdout: String(stdout || ''), stderr: String(stderr || '') };
2603
+ } catch (e) {
2604
+ 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) };
2605
+ } finally { try { fs.unlinkSync(spec_path); } catch (e) {} }
2606
+ };
2607
+
2608
+ // Onboard a raw box (IP + user/pass) as an infra host, a region control-plane, or both.
2609
+ // JOB method (long-running). Superuser + confirm gated (this provisions a prod box + runs
2610
+ // the gated fleet writes); audited + notified with the password redacted. dry_run returns
2611
+ // the ordered plan and writes nothing. NOTE for the request layer: `ssh_password` is
2612
+ // transient and must be excluded from any request logging (the slim app_log does not
2613
+ // capture bodies; this method never persists it).
2614
+ export const ops_add_infra_box_region = async function (req = {}) {
2615
+ try {
2616
+ if (!_ops_is_super(req)) return { code: -403, data: 'superuser only' };
2617
+ const spec = req.spec || {};
2618
+ const verr = _ops_setup_validate(spec);
2619
+ if (verr) return { code: -1, data: verr };
2620
+ const password = req.ssh_password || (spec.ssh && (spec.ssh.password || spec.ssh.pass)) || '';
2621
+ const label = `${(spec.targets || []).join('+')} on ${spec.ssh.host}`;
2622
+
2623
+ // Preview: run the engine --dry-run and return its ordered plan (writes nothing, so no
2624
+ // confirm needed - safe from the dialog's Preview button).
2625
+ if (req.dry_run) {
2626
+ const run = await _ops_spawn_setup({ spec, dry_run: true, timeout_ms: 60000 });
2627
+ const plan = String(run.stdout || run.stderr || '').split('\n').filter((l) => l.trim()).slice(-60);
2628
+ return { code: 1, data: { dry_run: true, target: label, plan } };
2629
+ }
2630
+
2631
+ if (!password) return { code: -1, data: 'ssh_password is required for the first hop (it is used once and never stored)' };
2632
+ // install_pve reboots + reconfigures the host - a typed confirm, like ops_deploy_server hard mode.
2633
+ const destructive = !!(spec.infra && spec.infra.install_pve !== false && (spec.targets || []).includes('infra'));
2634
+ if (destructive) {
2635
+ 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.` };
2636
+ } else if (req.confirm !== true && req.confirm !== 'true') {
2637
+ return { code: -1, data: `this provisions ${label} and applies the fleet writes. Re-send with confirm:true to proceed.` };
2638
+ }
2639
+
2640
+ const started = Date.now();
2641
+ const run = await _ops_spawn_setup({ spec, password, apply_fleet: true, dry_run: false, timeout_ms: 2400000 });
2642
+ const tail = String(run.stdout || '').split('\n').map((l) => l.replace(/\s+$/, '')).filter((l) => l.trim()).slice(-50);
2643
+ _ops_notify_ops(
2644
+ `Add infra/region ${run.ok ? 'DONE' : 'FAILED'}: ${label}`,
2645
+ [['Target', label], ['By', req.uid], ['Result', run.ok ? 'ok' : `exit ${run.exit}`], ['Elapsed', Math.round((Date.now() - started) / 1000) + 's']],
2646
+ 'OPS-ADD-BOX'
2647
+ );
2648
+ 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}` });
2649
+ if (!run.ok) return { code: -1, data: { ok: false, target: label, log_tail: tail, error: (run.stderr || '').split('\n')[0] || `engine exit ${run.exit}` } };
2650
+ return { code: 1, data: { ok: true, target: label, log_tail: tail, elapsed_ms: Date.now() - started } };
2651
+ } catch (err) { return { code: -1, data: err.message }; }
2652
+ };
2653
+
2170
2654
  const TIPS_FILE_PATH = path.join(process.env.XUDA_HOME, 'dist', 'tips', 'tips.json');
2171
2655
  let _did_you_know_tips_cache = null;
2172
2656
  let _did_you_know_tips_cache_mtime = 0;
@@ -2308,6 +2792,34 @@ export const get_active_account_profile_info = async function (uid, profile_id)
2308
2792
  // (e.g. interrupted Google signup flow), instead of throwing on every chat-widget poll.
2309
2793
  let active_account_profile_id = profile_id || acc_obj.account_info?.active_account_profile_id || acc_obj.account_profile_id;
2310
2794
 
2795
+ if (!active_account_profile_id) {
2796
+ // Lazy self-heal: an interrupted signup can leave an orphan account_profile doc in the
2797
+ // project db while the account itself was never linked (account_profile_id unset). Rather
2798
+ // than return null forever, adopt an existing profile here, on the very read path the chat
2799
+ // widget polls, and persist the link so it is a one-time repair. Gated by the same dedup set
2800
+ // as the warning below, so a truly profile-less account is queried at most once per process.
2801
+ if (!_warned_no_profile_id.has(acc_obj._id)) {
2802
+ try {
2803
+ const existing = await db_module.find_app_couch_query(acc_obj.account_project_id, {
2804
+ selector: { docType: 'account_profile', uid },
2805
+ limit: 50,
2806
+ });
2807
+ const docs = (existing && existing.docs) || [];
2808
+ if (docs.length) {
2809
+ const chosen = docs.find((d) => d.main) || docs.slice().sort((a, b) => (b.date_created_ts || 0) - (a.date_created_ts || 0))[0];
2810
+ active_account_profile_id = chosen._id;
2811
+ acc_obj.account_profile_id = chosen._id;
2812
+ acc_obj.account_info = acc_obj.account_info || {};
2813
+ acc_obj.account_info.active_account_profile_id = chosen._id;
2814
+ await db_module.save_couch_doc('xuda_accounts', acc_obj); // conflict-safe (retry loop)
2815
+ console.log(`[get_active_account_profile_info] self-healed acc ${acc_obj._id}: adopted orphan profile ${chosen._id}`);
2816
+ }
2817
+ } catch (heal_err) {
2818
+ /* fall through to the soft-fail below on any heal error */
2819
+ }
2820
+ }
2821
+ }
2822
+
2311
2823
  if (!active_account_profile_id) {
2312
2824
  // Soft-fail: log once PER ACCOUNT and return a degraded response. Callers that need a real
2313
2825
  // profile should null-check `account_profile_obj`. The chat widget treats this as an anonymous
@@ -5951,6 +6463,453 @@ export const get_account_ai_usage = async function (req, job_id, headers) {
5951
6463
  }
5952
6464
  };
5953
6465
 
6466
+ /////////////////////////////////////////////////////////////////////////
6467
+ // CREDIT MANAGEMENT (Team plan): pool + per-profile / per-user / per-model /
6468
+ // per-source soft and hard limits, model allow/deny, freeze, threshold alerts.
6469
+ // Rules live on the owner account doc (xuda_accounts.<owner>.credit_rules) and
6470
+ // are evaluated centrally in evaluate_credit_gate. ai_module's
6471
+ // validate_credits_limit is a thin adapter that preserves the legacy
6472
+ // { credit_limit_error, account_id } Error contract, so absent or disabled
6473
+ // rules behave exactly as before (a single account-wide ledger cap).
6474
+ /////////////////////////////////////////////////////////////////////////
6475
+
6476
+ // A limit is { type: 'credits' | 'percent', value: number }. percent is a share
6477
+ // of the granted pool (get_effective_entitlements().ai_credits) and is allowed on
6478
+ // pool / profile / user (allocation); model and source caps are absolute credits.
6479
+ const _resolve_limit = function (limit, entitlement) {
6480
+ if (!limit || typeof limit.value !== 'number' || !(limit.value >= 0)) return Infinity;
6481
+ if (limit.type === 'percent') {
6482
+ if (!Number.isFinite(entitlement)) return Infinity; // unlimited pool: percent is uncapped
6483
+ return entitlement * (limit.value / 100);
6484
+ }
6485
+ return limit.value;
6486
+ };
6487
+
6488
+ // Team plan or above (rank compare, same basis as is_login_sponsor_tier).
6489
+ const _is_team_tier = function (membership_plan) {
6490
+ const rank = _conf.PLAN_OBJ?.[membership_plan]?.rank;
6491
+ const team_rank = _conf.PLAN_OBJ?.['team']?.rank;
6492
+ if (typeof rank === 'number' && typeof team_rank === 'number') return rank >= team_rank;
6493
+ return ['team', 'agency', 'enterprise_t1', 'enterprise_t2', 'enterprise_t3'].includes(membership_plan);
6494
+ };
6495
+
6496
+ const _default_credit_rules = function () {
6497
+ const d = _conf.credit_rules_defaults || {};
6498
+ return {
6499
+ version: 1,
6500
+ enabled: false,
6501
+ window: d.window || 'lifetime',
6502
+ pool: {},
6503
+ profiles: {},
6504
+ users: {},
6505
+ models: {},
6506
+ sources: {},
6507
+ alerts: { thresholds: Array.isArray(d.alert_thresholds) ? d.alert_thresholds : [80, 100], channels: ['ws', 'email'] },
6508
+ };
6509
+ };
6510
+
6511
+ // Fold the authoritative usage view ({ apid: { uid: credits } }) into per-profile
6512
+ // and per-user credit totals.
6513
+ const _fold_usage = function (profile_map) {
6514
+ const by_profile = {};
6515
+ const by_user = {};
6516
+ for (const [apid, users] of Object.entries(profile_map || {})) {
6517
+ for (const [u, amt] of Object.entries(users || {})) {
6518
+ const n = Number(amt) || 0;
6519
+ by_profile[apid] = (by_profile[apid] || 0) + n;
6520
+ by_user[u] = (by_user[u] || 0) + n;
6521
+ }
6522
+ }
6523
+ return { by_profile, by_user };
6524
+ };
6525
+
6526
+ // Wide window so "used" reads as all-time spend (the lifetime / remaining-pool model).
6527
+ const _LIFETIME_WINDOW = { year_from: 2000, month_from: 1, day_from: 1, year_to: 2999, month_to: 12, day_to: 31 };
6528
+
6529
+ // Per-model / per-source spend is not emitted by the usage view, so re-derive it
6530
+ // from the raw ai_usage docs and CALIBRATE to the view's authoritative total, so the
6531
+ // numbers stay in the same credit unit (factor = view_total / raw_token_cost_total).
6532
+ // Cached per owner (in-process, short TTL) so the hot gate path stays cheap.
6533
+ const _scoped_usage_cache = {};
6534
+ const _SCOPED_USAGE_TTL = 45000;
6535
+ const _get_scoped_usage = async function (owner_uid, need_raw) {
6536
+ const now = Date.now();
6537
+ const cached = _scoped_usage_cache[owner_uid];
6538
+ if (cached && now - cached.ts < _SCOPED_USAGE_TTL && (!need_raw || cached.has_raw)) return cached.data;
6539
+
6540
+ const usage_ret = await get_account_ai_usage({ uid: owner_uid, ..._LIFETIME_WINDOW });
6541
+ const total = Number(usage_ret?.data?.usage?.total) || 0;
6542
+ const { by_profile, by_user } = _fold_usage(usage_ret?.data?.usage?.profile);
6543
+
6544
+ let by_model = {};
6545
+ let by_source = {};
6546
+ if (need_raw) {
6547
+ try {
6548
+ // ignore_warning=true: this selects on a nested field; add a Mango index on
6549
+ // ['account_profile_info.uid'] before any prod rollout of per-model/source caps.
6550
+ const raw = await db_module.find_couch_query(
6551
+ 'xuda_usage',
6552
+ { selector: { docType: 'ai_usage', 'account_profile_info.uid': owner_uid }, fields: ['model', 'cost', 'input_tokens', 'output_tokens', 'source'], limit: 200000 },
6553
+ true,
6554
+ );
6555
+ const raw_model = {};
6556
+ const raw_source = {};
6557
+ let raw_total = 0;
6558
+ for (const doc of raw?.docs || []) {
6559
+ const cost = doc.cost || { input: 1, output: 1 };
6560
+ const c = ((Number(doc.input_tokens) || 0) * (Number(cost.input) || 0) + (Number(doc.output_tokens) || 0) * (Number(cost.output) || 0)) / 1e6;
6561
+ raw_total += c;
6562
+ const code = _conf.ai_model_aliases?.[doc.model] || doc.model || 'unknown';
6563
+ raw_model[code] = (raw_model[code] || 0) + c;
6564
+ const src = doc.source || 'other';
6565
+ raw_source[src] = (raw_source[src] || 0) + c;
6566
+ }
6567
+ const factor = raw_total > 0 ? total / raw_total : 0;
6568
+ for (const [k, v] of Object.entries(raw_model)) by_model[k] = Math.round(v * factor * 100) / 100;
6569
+ for (const [k, v] of Object.entries(raw_source)) by_source[k] = Math.round(v * factor * 100) / 100;
6570
+ } catch (e) {
6571
+ console.error('[credit] scoped usage raw agg failed', e?.message || e);
6572
+ }
6573
+ }
6574
+
6575
+ const data = { total, credits_total: Number(usage_ret?.data?.credits?.total) || 0, by_profile, by_user, by_model, by_source };
6576
+ _scoped_usage_cache[owner_uid] = { ts: now, has_raw: !!need_raw, data };
6577
+ return data;
6578
+ };
6579
+
6580
+ // Threshold alert (soft breach): debounced in-app WS event to owner (and the spender).
6581
+ const _soft_emit_timers = {};
6582
+ export const emit_credit_soft_limit = function (owner_uid, member_uid, scopes, channels) {
6583
+ try {
6584
+ if (!owner_uid || !Array.isArray(scopes) || !scopes.length) return;
6585
+ if (Array.isArray(channels) && !channels.includes('ws')) return;
6586
+ clearTimeout(_soft_emit_timers[owner_uid]);
6587
+ _soft_emit_timers[owner_uid] = setTimeout(() => {
6588
+ delete _soft_emit_timers[owner_uid];
6589
+ try {
6590
+ const to = [owner_uid];
6591
+ if (member_uid && member_uid !== owner_uid) to.push(member_uid);
6592
+ ws_dashboard_msa.emit_message_to_dashboard({ service: 'credit_soft_limit', to, data: { account_id: owner_uid, scopes, ts: Date.now() } });
6593
+ } catch (e) {
6594
+ console.error('[emit_credit_soft_limit]', e?.message || e);
6595
+ }
6596
+ }, 800);
6597
+ } catch (e) {}
6598
+ };
6599
+
6600
+ // Deduped (once per scope per day, in-process) owner email when a hard cap blocks.
6601
+ const _alert_email_sent = {};
6602
+ const _maybe_alert_email = function (owner_uid, scope, reason, channels) {
6603
+ try {
6604
+ if (!owner_uid || scope === 'model_access' || scope === 'freeze') return;
6605
+ if (Array.isArray(channels) && !channels.includes('email')) return;
6606
+ const day = Math.floor(Date.now() / 86400000);
6607
+ const key = `${owner_uid}|${scope}|${day}`;
6608
+ if (_alert_email_sent[key]) return;
6609
+ _alert_email_sent[key] = true;
6610
+ (async () => {
6611
+ try {
6612
+ const acc = await db_module.get_couch_doc_native('xuda_accounts', owner_uid);
6613
+ const email = acc?.account_info?.email;
6614
+ if (!email) return;
6615
+ const body =
6616
+ `<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#111;line-height:1.5">` +
6617
+ `<p>Heads up: an AI credit limit on your Xuda account was just reached.</p>` +
6618
+ `<p>${_.escape(reason || 'A credit limit was reached.')}</p>` +
6619
+ `<p>Open Credit Management in your dashboard to review your limits or top up.</p>` +
6620
+ `</div>`;
6621
+ email_msa.send_email({ email, subject: 'AI credit limit reached', body, display_type: 'info' });
6622
+ } catch (e) {
6623
+ console.error('[credit alert email]', e?.message || e);
6624
+ }
6625
+ })();
6626
+ } catch (e) {}
6627
+ };
6628
+
6629
+ const _model_access_blocks = function (ma, model_code) {
6630
+ if (!ma || ma.mode === 'off' || !model_code) return false;
6631
+ const allow = Array.isArray(ma.allow) ? ma.allow : [];
6632
+ const deny = Array.isArray(ma.deny) ? ma.deny : [];
6633
+ if (ma.mode === 'deny') return deny.includes(model_code);
6634
+ if (ma.mode === 'allow') return allow.length > 0 && !allow.includes(model_code);
6635
+ return false;
6636
+ };
6637
+
6638
+ const _SCOPE_REASON = {
6639
+ ledger: 'ai credits reach to hard limit',
6640
+ pool: 'Your account has reached its AI credit limit.',
6641
+ profile: 'This profile has reached its AI credit limit.',
6642
+ user: 'This member has reached their AI credit limit.',
6643
+ model: 'This model has reached its AI credit limit.',
6644
+ source: 'This action has reached its AI credit limit.',
6645
+ };
6646
+
6647
+ const _has_keys = (o) => !!o && typeof o === 'object' && Object.keys(o).length > 0;
6648
+ const _profile_has_sources = function (profiles) {
6649
+ if (!profiles) return false;
6650
+ for (const p of Object.values(profiles)) if (_has_keys(p?.sources)) return true;
6651
+ return false;
6652
+ };
6653
+
6654
+ const _block = function (owner, kind, reason, breach) {
6655
+ return { block: true, scope: breach.scope, reason, hard_breached: [breach], soft_breached: [], account_id: owner, block_kind: kind };
6656
+ };
6657
+
6658
+ // The single credit gate. Never throws; returns a plain verdict object.
6659
+ export const evaluate_credit_gate = async function (req) {
6660
+ const { uid, profile_id, model, source } = req || {};
6661
+ try {
6662
+ const api = await get_active_account_profile_info(uid, profile_id);
6663
+ const owner = api?.uid || uid;
6664
+ const apid = api?.account_profile_id;
6665
+ const member_uid = uid;
6666
+ const model_code = _conf.ai_model_aliases?.[model] || model || null;
6667
+
6668
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', owner);
6669
+ const rules = account_doc?.credit_rules;
6670
+
6671
+ // Back-compat: no rules or disabled => legacy single ledger cap, byte-for-byte.
6672
+ if (!rules || rules.enabled === false) {
6673
+ const usage = await get_account_ai_usage({ uid: owner });
6674
+ const over = (Number(usage?.data?.credits?.total) || 0) - (Number(usage?.data?.usage?.total) || 0) < -0.5;
6675
+ return { block: over, scope: over ? 'pool' : null, reason: over ? _SCOPE_REASON.ledger : '', hard_breached: over ? [{ scope: 'pool', key: 'ledger' }] : [], soft_breached: [], account_id: owner };
6676
+ }
6677
+
6678
+ // The MAIN profile is not sharable, so per-profile credit control has no
6679
+ // meaning on it: there is no member to scope it to, and the pool cap already
6680
+ // covers the owner. Ignore any rule stored against it, including legacy docs
6681
+ // written before the editor stopped offering it, so it can never gate the
6682
+ // owner's own profile.
6683
+ const main_apid = account_doc?.account_profile_id;
6684
+ const profile_rule = apid && apid !== main_apid ? rules.profiles?.[apid] : null;
6685
+
6686
+ // Freeze (hard stop, no usage lookup needed).
6687
+ if (apid && profile_rule?.frozen) return _block(owner, 'freeze', 'This profile is paused.', { scope: 'profile', key: apid, kind: 'frozen' });
6688
+ if (member_uid && rules.users?.[member_uid]?.frozen) return _block(owner, 'freeze', 'This member is paused.', { scope: 'user', key: member_uid, kind: 'frozen' });
6689
+
6690
+ // Model access (allow/deny): profile rule then user rule.
6691
+ if (_model_access_blocks(profile_rule?.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' });
6692
+ 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' });
6693
+
6694
+ // Usage-based caps.
6695
+ const entitlement = get_effective_entitlements(account_doc).ai_credits;
6696
+ const need_raw = !!(_has_keys(rules.models) || _has_keys(rules.sources) || _profile_has_sources(rules.profiles));
6697
+ const su = await _get_scoped_usage(owner, need_raw);
6698
+
6699
+ const hard = [];
6700
+ const soft = [];
6701
+ const consider = function (scope, key, rule, used) {
6702
+ if (!rule) return;
6703
+ const h = _resolve_limit(rule.hard, entitlement);
6704
+ const s = _resolve_limit(rule.soft, entitlement);
6705
+ if (used >= h) hard.push({ scope, key, limit: h, used, type: rule.hard?.type || 'credits' });
6706
+ 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 });
6707
+ };
6708
+
6709
+ // Pool: ledger safety (remaining below zero) plus the configured pool guardrail.
6710
+ if (su.credits_total - su.total < -0.5) hard.push({ scope: 'pool', key: 'ledger' });
6711
+ consider('pool', 'pool', rules.pool, su.total);
6712
+ if (apid) consider('profile', apid, profile_rule, su.by_profile[apid] || 0);
6713
+ if (member_uid) consider('user', member_uid, rules.users?.[member_uid], su.by_user[member_uid] || 0);
6714
+ if (model_code) consider('model', model_code, rules.models?.[model_code], su.by_model[model_code] || 0);
6715
+ if (source) {
6716
+ consider('source', source, rules.sources?.[source], su.by_source[source] || 0);
6717
+ if (apid) consider('source', `${apid}:${source}`, profile_rule?.sources?.[source], su.by_source[source] || 0);
6718
+ }
6719
+
6720
+ if (hard.length) {
6721
+ const first = hard[0];
6722
+ const reason = _SCOPE_REASON[first.key === 'ledger' ? 'ledger' : first.scope] || _SCOPE_REASON.pool;
6723
+ _maybe_alert_email(owner, first.scope, reason, rules.alerts?.channels);
6724
+ return { block: true, scope: first.scope, reason, hard_breached: hard, soft_breached: soft, account_id: owner };
6725
+ }
6726
+ if (soft.length) emit_credit_soft_limit(owner, member_uid, soft, rules.alerts?.channels);
6727
+ return { block: false, scope: null, reason: '', hard_breached: [], soft_breached: soft, account_id: owner };
6728
+ } catch (err) {
6729
+ // Fail open: a rules bug must not block all AI usage. Logged for follow-up.
6730
+ console.error('[evaluate_credit_gate]', err?.message || err);
6731
+ return { block: false, scope: null, reason: '', hard_breached: [], soft_breached: [], account_id: uid, error: err?.message };
6732
+ }
6733
+ };
6734
+
6735
+ // Originals only (not shared copies) belonging to the owner.
6736
+ const _list_owner_profiles = async function (owner_uid, app_id, main_profile_id) {
6737
+ if (!app_id) return [];
6738
+ try {
6739
+ 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 });
6740
+ // Drop the MAIN profile: it is not sharable, so there is no member to scope
6741
+ // credit control to and the pool cap already covers the owner. Leaving it in
6742
+ // this list is what made the editor offer per-profile limits and model
6743
+ // restrictions that could only ever gate the owner themselves.
6744
+ return (ret?.docs || [])
6745
+ .filter((d) => !d.share_item_id && d._id !== main_profile_id)
6746
+ .map((d) => ({ account_profile_id: d._id, profile_name: d.profile_name || 'Profile' }));
6747
+ } catch (e) {
6748
+ console.error('[credit] list profiles', e?.message || e);
6749
+ return [];
6750
+ }
6751
+ };
6752
+
6753
+ // Owner plus every member their profiles are shared with, name-resolved.
6754
+ const _list_owner_members = async function (owner_uid, profiles) {
6755
+ const uids = new Set([owner_uid]);
6756
+ for (const p of profiles || []) {
6757
+ try {
6758
+ const members = await get_account_profile_group_member_uids(owner_uid, p.account_profile_id);
6759
+ for (const m of members) uids.add(m);
6760
+ } catch (e) {}
6761
+ }
6762
+ const out = [];
6763
+ for (const u of uids) {
6764
+ try {
6765
+ const acc = await db_module.get_couch_doc_native('xuda_accounts', u);
6766
+ const ai = acc?.account_info || {};
6767
+ const name = ai.account_type === 'business' ? ai.business_name || 'Member' : `${ai.first_name || ''} ${ai.last_name || ''}`.trim() || ai.email || 'Member';
6768
+ out.push({ uid: u, name });
6769
+ } catch (e) {
6770
+ out.push({ uid: u, name: 'Member' });
6771
+ }
6772
+ }
6773
+ return out;
6774
+ };
6775
+
6776
+ // Combined read powering the Credit Management page and the profile-settings embed.
6777
+ export const get_credit_management = async function (req) {
6778
+ try {
6779
+ const uid = req?.uid || req?.token_ret?.data?.uid;
6780
+ if (!uid) return { code: -1, data: 'not authorized' };
6781
+ const account_doc = await db_module.get_couch_doc_native('xuda_accounts', uid);
6782
+ const plan = account_doc?.membership_plan || 'free';
6783
+ const can_edit = _is_team_tier(plan);
6784
+ const rules = account_doc?.credit_rules || _default_credit_rules();
6785
+ const ent = get_effective_entitlements(account_doc);
6786
+ const unlimited = !Number.isFinite(ent.ai_credits);
6787
+ const entitlements = { ai_credits: unlimited ? null : ent.ai_credits, unlimited };
6788
+
6789
+ const su = await _get_scoped_usage(uid, true);
6790
+ const remaining = unlimited ? null : Math.round((ent.ai_credits - su.total) * 100) / 100;
6791
+ const usage_breakdown = {
6792
+ window: { mode: rules.window || 'lifetime' },
6793
+ pool: { used: Math.round(su.total * 100) / 100, remaining },
6794
+ by_profile: su.by_profile,
6795
+ by_user: su.by_user,
6796
+ by_model: su.by_model,
6797
+ by_source: su.by_source,
6798
+ };
6799
+
6800
+ const model_catalog = {};
6801
+ for (const [code, e] of Object.entries(_conf.ai_models || {})) {
6802
+ model_catalog[code] = { name: e.name, type: e.type, price: e.price, model: e.model };
6803
+ }
6804
+
6805
+ const app_id = account_doc?.account_project_id;
6806
+ const profiles = await _list_owner_profiles(uid, app_id, account_doc?.account_profile_id);
6807
+ const members = await _list_owner_members(uid, profiles);
6808
+
6809
+ return { code: 1, data: { rules, can_edit, plan, entitlements, usage_breakdown, model_catalog, profiles, members } };
6810
+ } catch (err) {
6811
+ return { code: -1, data: err.message };
6812
+ }
6813
+ };
6814
+
6815
+ const _normalize_limit = function (l, allow_percent) {
6816
+ if (!l || typeof l !== 'object') return undefined;
6817
+ const value = Number(l.value);
6818
+ if (!(value >= 0)) return undefined;
6819
+ const type = allow_percent && l.type === 'percent' ? 'percent' : 'credits';
6820
+ const out = { type, value };
6821
+ if (type === 'percent' && value > 100) out.value = 100;
6822
+ return out;
6823
+ };
6824
+ const _normalize_softhard = function (obj, allow_percent) {
6825
+ const out = {};
6826
+ const s = _normalize_limit(obj?.soft, allow_percent);
6827
+ const h = _normalize_limit(obj?.hard, allow_percent);
6828
+ if (s) out.soft = s;
6829
+ if (h) out.hard = h;
6830
+ return out;
6831
+ };
6832
+ const _normalize_model_access = function (m) {
6833
+ const mode = ['off', 'allow', 'deny'].includes(m?.mode) ? m.mode : 'off';
6834
+ const codes = _conf.ai_models || {};
6835
+ const clean = (arr) => (Array.isArray(arr) ? arr.filter((c) => typeof c === 'string' && codes[c]) : []);
6836
+ return { mode, allow: clean(m?.allow), deny: clean(m?.deny) };
6837
+ };
6838
+ const _normalize_sources = function (src) {
6839
+ const out = {};
6840
+ if (src && typeof src === 'object') {
6841
+ for (const [k, v] of Object.entries(src)) {
6842
+ const sh = _normalize_softhard(v, false);
6843
+ if (sh.soft || sh.hard) out[k] = sh;
6844
+ }
6845
+ }
6846
+ return out;
6847
+ };
6848
+ const _normalize_credit_rules = function (input, uid, main_profile_id) {
6849
+ const d = _default_credit_rules();
6850
+ const r = input && typeof input === 'object' ? input : {};
6851
+ const out = {
6852
+ version: 1,
6853
+ enabled: r.enabled !== false,
6854
+ window: r.window === 'monthly' ? 'monthly' : 'lifetime',
6855
+ pool: _normalize_softhard(r.pool, true),
6856
+ profiles: {},
6857
+ users: {},
6858
+ models: {},
6859
+ sources: _normalize_sources(r.sources),
6860
+ alerts: {
6861
+ thresholds: Array.isArray(r.alerts?.thresholds) ? r.alerts.thresholds.map(Number).filter((n) => n > 0 && n <= 100) : d.alerts.thresholds,
6862
+ channels: Array.isArray(r.alerts?.channels) ? r.alerts.channels.filter((c) => ['ws', 'email'].includes(c)) : ['ws', 'email'],
6863
+ },
6864
+ updated_ts: Date.now(),
6865
+ updated_by: uid,
6866
+ };
6867
+ if (r.profiles && typeof r.profiles === 'object') {
6868
+ for (const [apid, pv] of Object.entries(r.profiles)) {
6869
+ // Never persist a rule for the MAIN profile: it is not sharable, so credit
6870
+ // control there could only gate the owner. Dropping it here also cleans up
6871
+ // any legacy entry the moment the rules are next saved.
6872
+ if (main_profile_id && apid === main_profile_id) continue;
6873
+ out.profiles[apid] = { ..._normalize_softhard(pv, true), frozen: !!pv?.frozen, model_access: _normalize_model_access(pv?.model_access), sources: _normalize_sources(pv?.sources) };
6874
+ }
6875
+ }
6876
+ if (r.users && typeof r.users === 'object') {
6877
+ for (const [u, uv] of Object.entries(r.users)) {
6878
+ out.users[u] = { ..._normalize_softhard(uv, true), frozen: !!uv?.frozen, model_access: _normalize_model_access(uv?.model_access) };
6879
+ }
6880
+ }
6881
+ if (r.models && typeof r.models === 'object') {
6882
+ const codes = _conf.ai_models || {};
6883
+ for (const [code, mv] of Object.entries(r.models)) {
6884
+ if (!codes[code]) continue;
6885
+ const sh = _normalize_softhard(mv, false);
6886
+ if (sh.soft || sh.hard) out.models[code] = sh;
6887
+ }
6888
+ }
6889
+ return out;
6890
+ };
6891
+
6892
+ // Save the credit rules on the owner account doc. Team plan only (also gated by
6893
+ // the cpi min_plan). A member saving here writes to their own account and never
6894
+ // touches the owner's pool, so "owner authors" falls out of the data model.
6895
+ export const set_credit_rules = async function (req) {
6896
+ try {
6897
+ const uid = req?.uid || req?.token_ret?.data?.uid;
6898
+ if (!uid) return { code: -1, data: 'not authorized' };
6899
+ const acc_ret = await db_module.get_couch_doc('xuda_accounts', uid, true); // fresh read for the write
6900
+ if (acc_ret.code < 0) return acc_ret;
6901
+ const account_doc = acc_ret.data;
6902
+ if (!_is_team_tier(account_doc?.membership_plan)) return { code: -1, data: 'Credit management is available on the Team plan.' };
6903
+ account_doc.credit_rules = _normalize_credit_rules(req.rules, uid, account_doc?.account_profile_id);
6904
+ await db_module.save_couch_doc('xuda_accounts', account_doc);
6905
+ delete _scoped_usage_cache[uid];
6906
+ broadcast_credits(uid);
6907
+ return { code: 1, data: { rules: account_doc.credit_rules } };
6908
+ } catch (err) {
6909
+ return { code: -1, data: err.message };
6910
+ }
6911
+ };
6912
+
5954
6913
  export const get_account_ai_usage_old = async function (req, job_id, headers) {
5955
6914
  const { uid, year_from, month_from, day_from, year_to, month_to, day_to } = req;
5956
6915
  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.2292",
4
4
  "description": "Xuda Account Server Module",
5
5
  "main": "index.mjs",
6
6
  "dependencies": {