@omnicross/daemon 0.1.2 → 0.1.3

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/dist/cli.js CHANGED
@@ -270,6 +270,70 @@ var SecretBox = class {
270
270
  };
271
271
 
272
272
  // src/secrets/secretFields.ts
273
+ function urlHasInlineCredential(url) {
274
+ try {
275
+ const u = new URL(url);
276
+ return u.username.length > 0 || u.password.length > 0;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+ function transformProxyConfig(cfg, fn) {
282
+ if ("url" in cfg) {
283
+ if (isEnvelope(cfg.url) || urlHasInlineCredential(cfg.url)) {
284
+ return { url: fn(cfg.url) };
285
+ }
286
+ return cfg;
287
+ }
288
+ if (typeof cfg.password === "string" && cfg.password.length > 0) {
289
+ return { ...cfg, password: fn(cfg.password) };
290
+ }
291
+ return cfg;
292
+ }
293
+ function transformOutboundProxy(proxy, fn) {
294
+ const next = {};
295
+ if (proxy.global) next.global = transformProxyConfig(proxy.global, fn);
296
+ if (proxy.byProvider) {
297
+ const byProvider = {};
298
+ for (const [key, value] of Object.entries(proxy.byProvider)) {
299
+ byProvider[key] = transformProxyConfig(value, fn);
300
+ }
301
+ next.byProvider = byProvider;
302
+ }
303
+ return next;
304
+ }
305
+ function encryptProxySegment(proxy, box) {
306
+ return transformOutboundProxy(proxy, (v) => box.encryptMaybe(v));
307
+ }
308
+ function decryptProxySegment(proxy, box) {
309
+ return transformOutboundProxy(proxy, (v) => box.decryptMaybe(v));
310
+ }
311
+ function transformWebhookSegment(webhook, fn) {
312
+ return {
313
+ ...webhook,
314
+ destinations: webhook.destinations.map(
315
+ (d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: fn(d.secret) } : d
316
+ )
317
+ };
318
+ }
319
+ function encryptWebhookSegment(webhook, box) {
320
+ return transformWebhookSegment(webhook, (v) => box.encryptMaybe(v));
321
+ }
322
+ function decryptWebhookSegment(webhook, box) {
323
+ return transformWebhookSegment(webhook, (v) => box.decryptMaybe(v));
324
+ }
325
+ function transformBillingSegment(billing, fn) {
326
+ if (typeof billing.secret === "string" && billing.secret.length > 0) {
327
+ return { ...billing, secret: fn(billing.secret) };
328
+ }
329
+ return billing;
330
+ }
331
+ function encryptBillingSegment(billing, box) {
332
+ return transformBillingSegment(billing, (v) => box.encryptMaybe(v));
333
+ }
334
+ function decryptBillingSegment(billing, box) {
335
+ return transformBillingSegment(billing, (v) => box.decryptMaybe(v));
336
+ }
273
337
  function transformProvider(provider, fn) {
274
338
  const next = { ...provider, apiKey: fn(provider.apiKey) };
275
339
  if (provider.apiKeys) {
@@ -293,6 +357,17 @@ function transformConfigSecrets(cfg, fn) {
293
357
  if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
294
358
  next.admin = { ...cfg.admin, token: fn(cfg.admin.token) };
295
359
  }
360
+ const proxy = cfg.server?.proxy;
361
+ const webhook = cfg.server?.webhook;
362
+ const billing = cfg.server?.billing;
363
+ if (cfg.server && (proxy?.global || proxy?.byProvider || webhook || billing?.secret)) {
364
+ next.server = { ...cfg.server };
365
+ if (proxy && (proxy.global || proxy.byProvider)) {
366
+ next.server.proxy = transformOutboundProxy(proxy, fn);
367
+ }
368
+ if (webhook) next.server.webhook = transformWebhookSegment(webhook, fn);
369
+ if (billing?.secret) next.server.billing = transformBillingSegment(billing, fn);
370
+ }
296
371
  return next;
297
372
  }
298
373
  function encryptConfigSecrets(cfg, box) {
@@ -330,7 +405,7 @@ function transformTokens(tokens, fn) {
330
405
  if (Array.isArray(accounts)) {
331
406
  bag[accountsKey] = accounts.map((entry) => {
332
407
  if (entry && typeof entry === "object" && "tokens" in entry && entry.tokens && typeof entry.tokens === "object") {
333
- return {
408
+ const nextEntry = {
334
409
  ...entry,
335
410
  tokens: transformTokenBlock(
336
411
  entry.tokens,
@@ -338,6 +413,11 @@ function transformTokens(tokens, fn) {
338
413
  fn
339
414
  )
340
415
  };
416
+ const proxy = entry.proxy;
417
+ if (proxy && typeof proxy === "object") {
418
+ nextEntry.proxy = transformProxyConfig(proxy, fn);
419
+ }
420
+ return nextEntry;
341
421
  }
342
422
  return entry;
343
423
  });
@@ -372,6 +452,17 @@ function resolveAdminConfig(admin) {
372
452
  token: typeof admin?.token === "string" && admin.token.length > 0 ? admin.token : void 0
373
453
  };
374
454
  }
455
+ function validateLogging(raw) {
456
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
457
+ const l = raw;
458
+ const out = {};
459
+ if (l["level"] === "error" || l["level"] === "warn" || l["level"] === "info" || l["level"] === "debug") {
460
+ out.level = l["level"];
461
+ }
462
+ if (l["format"] === "text" || l["format"] === "json") out.format = l["format"];
463
+ if (typeof l["file"] === "string" && l["file"].length > 0) out.file = l["file"];
464
+ return out.level !== void 0 || out.format !== void 0 || out.file !== void 0 ? out : void 0;
465
+ }
375
466
  var VALID_FORMATS = ["openai", "anthropic", "gemini"];
376
467
  function validateApiKeys(raw) {
377
468
  if (!Array.isArray(raw)) return void 0;
@@ -546,7 +637,8 @@ function validateConfig(raw) {
546
637
  const providers = providersRaw.map((p, i) => validateProvider(p, i));
547
638
  const server = obj["server"];
548
639
  const admin = validateAdmin(obj["admin"]);
549
- return { providers, server, admin };
640
+ const logging = validateLogging(obj["logging"]);
641
+ return { providers, server, admin, logging };
550
642
  }
551
643
  var secretBox = null;
552
644
  function setSecretBox(box) {
@@ -578,6 +670,9 @@ import { dirname as dirname2, join as join2 } from "path";
578
670
  function defaultKeysPath(configPath) {
579
671
  return join2(dirname2(configPath), "keys.json");
580
672
  }
673
+ function defaultVouchersPath(configPath) {
674
+ return join2(dirname2(configPath), "vouchers.json");
675
+ }
581
676
  function defaultTokensPath(configPath) {
582
677
  return join2(dirname2(configPath), "tokens.json");
583
678
  }
@@ -587,6 +682,12 @@ function defaultPricingPath(configPath) {
587
682
  function defaultUsageEventsPath(configPath) {
588
683
  return join2(dirname2(configPath), "usage-events.jsonl");
589
684
  }
685
+ function defaultAuditDir(configPath) {
686
+ return join2(dirname2(configPath), "audit");
687
+ }
688
+ function defaultBillingDir(configPath) {
689
+ return join2(dirname2(configPath), "billing");
690
+ }
590
691
  function resolveSecretBox(masterKeyFilePath) {
591
692
  return new SecretBox(() => resolveMasterKey({ keyFilePath: masterKeyFilePath }));
592
693
  }
@@ -686,6 +787,45 @@ var JsonOutboundKeyDb = class {
686
787
  return true;
687
788
  });
688
789
  }
790
+ async outboundApiKeysSetMaxConcurrency(id, maxConcurrency) {
791
+ return this.mutateRow(id, (row) => {
792
+ if (row.revokedAt !== null) return false;
793
+ if (maxConcurrency === null) delete row.maxConcurrency;
794
+ else row.maxConcurrency = maxConcurrency;
795
+ return true;
796
+ });
797
+ }
798
+ async outboundApiKeysSetPolicy(id, policy) {
799
+ return this.mutateRow(id, (row) => {
800
+ if (row.revokedAt !== null) return false;
801
+ applyPolicyField(row, "expiresAt", policy.expiresAt);
802
+ applyPolicyField(row, "activationDays", policy.activationDays);
803
+ applyPolicyField(row, "dailyCostLimitUsd", policy.dailyCostLimitUsd);
804
+ applyPolicyField(row, "totalCostLimitUsd", policy.totalCostLimitUsd);
805
+ applyPolicyField(row, "weeklyCostLimitUsd", policy.weeklyCostLimitUsd);
806
+ applyPolicyField(row, "rateLimitMaxRequests", policy.rateLimitMaxRequests);
807
+ applyPolicyField(row, "rateLimitWindowMs", policy.rateLimitWindowMs);
808
+ if (policy.activationMode === null) delete row.activationMode;
809
+ else if (policy.activationMode !== void 0) row.activationMode = policy.activationMode;
810
+ if (policy.enableModelRestriction === null) delete row.enableModelRestriction;
811
+ else if (policy.enableModelRestriction !== void 0) {
812
+ row.enableModelRestriction = policy.enableModelRestriction;
813
+ }
814
+ if (policy.restrictionMode === null) delete row.restrictionMode;
815
+ else if (policy.restrictionMode !== void 0) row.restrictionMode = policy.restrictionMode;
816
+ if (policy.restrictedModels === null) delete row.restrictedModels;
817
+ else if (policy.restrictedModels !== void 0) row.restrictedModels = policy.restrictedModels;
818
+ return true;
819
+ });
820
+ }
821
+ async outboundApiKeysMarkActivated(id, activatedAt) {
822
+ return this.mutateRow(id, (row) => {
823
+ if (row.revokedAt !== null) return false;
824
+ if (row.activatedAt != null) return false;
825
+ row.activatedAt = activatedAt;
826
+ return true;
827
+ });
828
+ }
689
829
  /** Apply `fn` to the row with `id`, persisting when it returns true. */
690
830
  mutateRow(id, fn) {
691
831
  const rows = this.readRows();
@@ -709,6 +849,11 @@ var JsonOutboundKeyDb = class {
709
849
  writeFileSync3(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
710
850
  }
711
851
  };
852
+ function applyPolicyField(row, field, value) {
853
+ if (value === void 0) return;
854
+ if (value === null) delete row[field];
855
+ else row[field] = value;
856
+ }
712
857
 
713
858
  // src/commands/keys.ts
714
859
  async function runKeys(argv) {
@@ -765,8 +910,8 @@ async function keysRevoke(db, id) {
765
910
 
766
911
  // src/commands/launch.ts
767
912
  import { spawn as spawn2 } from "child_process";
768
- import { existsSync as existsSync10 } from "fs";
769
- import { delimiter as delimiter2, join as join5 } from "path";
913
+ import { existsSync as existsSync15 } from "fs";
914
+ import { delimiter as delimiter2, join as join10 } from "path";
770
915
  import { parseArgs as parseArgs3 } from "util";
771
916
  import {
772
917
  buildChatCliLaunchConfig as buildChatCliLaunchConfig2,
@@ -776,18 +921,26 @@ import {
776
921
  } from "@omnicross/cli-launcher";
777
922
 
778
923
  // src/bootstrap.ts
924
+ import { accessSync, constants as fsConstants, existsSync as existsSync14 } from "fs";
925
+ import { DEFAULT_AUDIT_CONFIG } from "@omnicross/contracts/audit-types";
926
+ import { DEFAULT_BILLING_CONFIG } from "@omnicross/contracts/billing-types";
779
927
  import { getGeminiCodeAssistProjectResolver } from "@omnicross/core/auth/GeminiCodeAssistProjectResolver";
780
928
  import { ApiKeyPoolService } from "@omnicross/core/completion/ApiKeyPoolService";
781
929
  import {
782
930
  __resetOutboundApiServerForTests,
931
+ DEFAULT_ACCOUNT_PROBE,
783
932
  getOutboundApiServer
784
933
  } from "@omnicross/core/outbound-api";
785
934
  import { setSubscriptionRegistryForOutbound } from "@omnicross/core/outbound-api/subscriptionRegistryPort";
935
+ import { getSharedAccountHealth as getSharedAccountHealth2 } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
936
+ import { fetchUpstream as fetchUpstream6, setUpstreamProxyResolver } from "@omnicross/core/pipeline/upstreamFetch";
937
+ import { __resetSharedIdentityStoreForTests } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
786
938
  import { setGeminiCodeAssistResolver } from "@omnicross/core/ports/gemini-code-assist-resolver";
787
939
  import {
788
940
  __resetProviderProxyForTests,
789
941
  getProviderProxy
790
942
  } from "@omnicross/core/provider-proxy";
943
+ import { KeySpendTracker } from "@omnicross/core/outbound-api";
791
944
  import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
792
945
  import {
793
946
  setSubscriptionAccountService,
@@ -890,15 +1043,139 @@ function handleCodexOAuthStatus(sessionId, deps) {
890
1043
  // src/admin/AdminServer.ts
891
1044
  import { timingSafeEqual } from "crypto";
892
1045
  import http2 from "http";
1046
+ import {
1047
+ healthHttpStatus
1048
+ } from "@omnicross/contracts/health-logging-types";
1049
+
1050
+ // src/admin/accountProbesApi.ts
1051
+ function handleAccountProbes(res, reader) {
1052
+ const accounts = reader ? reader.getAllHistory() : [];
1053
+ res.writeHead(200, { "Content-Type": "application/json" });
1054
+ res.end(JSON.stringify({ accounts }));
1055
+ }
1056
+
1057
+ // src/admin/auditQueryApi.ts
1058
+ function intParam(value) {
1059
+ if (value === null || value.trim() === "") return void 0;
1060
+ const n = Number(value);
1061
+ return Number.isFinite(n) ? Math.trunc(n) : void 0;
1062
+ }
1063
+ function handleAuditQuery(req, res, reader) {
1064
+ const url = new URL(req.url ?? "/", "http://localhost");
1065
+ const query = {};
1066
+ const keyId = url.searchParams.get("keyId");
1067
+ if (keyId && keyId.trim()) query.keyId = keyId.trim();
1068
+ const from = intParam(url.searchParams.get("from"));
1069
+ if (from !== void 0) query.from = from;
1070
+ const to = intParam(url.searchParams.get("to"));
1071
+ if (to !== void 0) query.to = to;
1072
+ const limit = intParam(url.searchParams.get("limit"));
1073
+ if (limit !== void 0) query.limit = limit;
1074
+ const records = reader ? reader(query) : [];
1075
+ res.writeHead(200, { "Content-Type": "application/json" });
1076
+ res.end(JSON.stringify({ records }));
1077
+ }
1078
+
1079
+ // src/admin/billingStatusApi.ts
1080
+ function handleBillingStatus(res, reader) {
1081
+ const status = reader ? reader() : { total: 0, delivered: 0, pending: 0 };
1082
+ res.writeHead(200, { "Content-Type": "application/json" });
1083
+ res.end(JSON.stringify({ status }));
1084
+ }
1085
+
1086
+ // src/webhook/webhookRuntime.ts
1087
+ import { setWebhookSink } from "@omnicross/core/pipeline/webhookEmit";
1088
+ var dispatcher = null;
1089
+ var health = null;
1090
+ var unsubscribers = [];
1091
+ var wired = false;
1092
+ function setWebhookRuntime(d, h) {
1093
+ dispatcher = d;
1094
+ health = h;
1095
+ }
1096
+ function applyWebhookConfig(config) {
1097
+ if (!dispatcher) return;
1098
+ dispatcher.setConfig(config);
1099
+ const shouldWire = config?.enabled === true && (config?.destinations.length ?? 0) > 0;
1100
+ if (shouldWire && !wired) {
1101
+ const active = dispatcher;
1102
+ setWebhookSink((event) => active.emit(event));
1103
+ if (health) {
1104
+ unsubscribers.push(
1105
+ health.onRecovered(
1106
+ (e) => active.emit({ kind: "account.recovery", at: e.at, providerId: e.providerId, accountId: e.accountId })
1107
+ )
1108
+ );
1109
+ unsubscribers.push(
1110
+ health.onAnomaly(
1111
+ (e) => active.emit({
1112
+ kind: "account.anomaly",
1113
+ at: e.at,
1114
+ providerId: e.providerId,
1115
+ accountId: e.accountId,
1116
+ state: e.state
1117
+ })
1118
+ )
1119
+ );
1120
+ }
1121
+ wired = true;
1122
+ } else if (!shouldWire && wired) {
1123
+ teardown();
1124
+ }
1125
+ }
1126
+ async function deliverWebhookTest(destinationId) {
1127
+ if (!dispatcher) return { ok: false, error: "webhook dispatcher not wired" };
1128
+ return dispatcher.deliverTest(destinationId);
1129
+ }
1130
+ function teardown() {
1131
+ setWebhookSink(null);
1132
+ for (const unsub of unsubscribers) unsub();
1133
+ unsubscribers = [];
1134
+ wired = false;
1135
+ }
1136
+
1137
+ // src/admin/webhookTestApi.ts
1138
+ function readJsonBody(req) {
1139
+ return new Promise((resolve) => {
1140
+ const chunks = [];
1141
+ req.on("data", (c) => chunks.push(c));
1142
+ req.on("end", () => {
1143
+ try {
1144
+ const raw = Buffer.concat(chunks).toString("utf8");
1145
+ const parsed = raw ? JSON.parse(raw) : {};
1146
+ resolve(parsed && typeof parsed === "object" ? parsed : {});
1147
+ } catch {
1148
+ resolve({});
1149
+ }
1150
+ });
1151
+ req.on("error", () => resolve({}));
1152
+ });
1153
+ }
1154
+ async function handleWebhookTest(req, res) {
1155
+ const body = await readJsonBody(req);
1156
+ const destinationId = body["destinationId"];
1157
+ if (typeof destinationId !== "string" || !destinationId.trim()) {
1158
+ res.writeHead(400, { "Content-Type": "application/json" });
1159
+ res.end(JSON.stringify({ error: { type: "bad_request", message: "destinationId is required" } }));
1160
+ return;
1161
+ }
1162
+ const result = await deliverWebhookTest(destinationId.trim());
1163
+ res.writeHead(200, { "Content-Type": "application/json" });
1164
+ res.end(JSON.stringify({ result }));
1165
+ }
893
1166
 
894
1167
  // src/admin/adminApi.ts
895
1168
  import http from "http";
896
1169
  import {
897
1170
  createNamedKey as createNamedKey2,
898
- loadServerConfig,
1171
+ isKindMappedEndpoint,
1172
+ loadServerConfig as loadServerConfig2,
899
1173
  mergeServerConfig,
900
- saveServerConfig
1174
+ normalizeProxyConfig,
1175
+ saveServerConfig,
1176
+ validateServerModelConfig
901
1177
  } from "@omnicross/core/outbound-api";
1178
+ import { fetchUpstream } from "@omnicross/core/pipeline/upstreamFetch";
902
1179
 
903
1180
  // src/pool/resolveEnvKey.ts
904
1181
  function resolveEnvKey(rawKey) {
@@ -993,6 +1270,163 @@ function listMappablePresets() {
993
1270
  return { mappable, excluded };
994
1271
  }
995
1272
 
1273
+ // src/proxy/sanitizeProxy.ts
1274
+ function sanitizeProxyConfig(cfg) {
1275
+ if ("url" in cfg) {
1276
+ let endpoint;
1277
+ let username;
1278
+ let hasPassword = false;
1279
+ try {
1280
+ const u = new URL(cfg.url);
1281
+ endpoint = u.port ? `${u.hostname}:${u.port}` : u.hostname;
1282
+ username = u.username ? decodeURIComponent(u.username) : void 0;
1283
+ hasPassword = u.password.length > 0;
1284
+ } catch {
1285
+ }
1286
+ return { kind: "url", endpoint, username, hasPassword };
1287
+ }
1288
+ return {
1289
+ kind: cfg.type,
1290
+ endpoint: `${cfg.host}:${cfg.port}`,
1291
+ username: cfg.username,
1292
+ hasPassword: typeof cfg.password === "string" && cfg.password.length > 0
1293
+ };
1294
+ }
1295
+ function redactProxyConfig(cfg) {
1296
+ if ("url" in cfg) {
1297
+ try {
1298
+ const u = new URL(cfg.url);
1299
+ if (u.password) u.password = "";
1300
+ return { url: u.toString() };
1301
+ } catch {
1302
+ return cfg;
1303
+ }
1304
+ }
1305
+ const { password: _password, ...rest } = cfg;
1306
+ return rest;
1307
+ }
1308
+ function redactOutboundProxy(proxy) {
1309
+ const out = {};
1310
+ if (proxy.global) out.global = redactProxyConfig(proxy.global);
1311
+ if (proxy.byProvider) {
1312
+ const byProvider = {};
1313
+ for (const [key, value] of Object.entries(proxy.byProvider)) {
1314
+ byProvider[key] = redactProxyConfig(value);
1315
+ }
1316
+ out.byProvider = byProvider;
1317
+ }
1318
+ return out;
1319
+ }
1320
+ function preserveProxyConfigSecret(incoming, current) {
1321
+ if (!current) return incoming;
1322
+ if ("url" in incoming) {
1323
+ if ("url" in current) {
1324
+ try {
1325
+ const inU = new URL(incoming.url);
1326
+ const curU = new URL(current.url);
1327
+ if (!inU.password && curU.password) {
1328
+ inU.password = curU.password;
1329
+ return { url: inU.toString() };
1330
+ }
1331
+ } catch {
1332
+ }
1333
+ }
1334
+ return incoming;
1335
+ }
1336
+ if ("url" in current) return incoming;
1337
+ const blank = incoming.password === void 0 || incoming.password === "";
1338
+ if (blank && typeof current.password === "string" && current.password.length > 0) {
1339
+ return { ...incoming, password: current.password };
1340
+ }
1341
+ return incoming;
1342
+ }
1343
+ function preserveOutboundProxySecrets(incoming, current) {
1344
+ const out = {};
1345
+ if (incoming.global) out.global = preserveProxyConfigSecret(incoming.global, current?.global);
1346
+ if (incoming.byProvider) {
1347
+ const byProvider = {};
1348
+ for (const [key, value] of Object.entries(incoming.byProvider)) {
1349
+ byProvider[key] = preserveProxyConfigSecret(value, current?.byProvider?.[key]);
1350
+ }
1351
+ out.byProvider = byProvider;
1352
+ }
1353
+ return out;
1354
+ }
1355
+
1356
+ // src/proxy/upstreamProxyResolver.ts
1357
+ import {
1358
+ bumpUpstreamProxyGeneration
1359
+ } from "@omnicross/core/pipeline/upstreamFetch";
1360
+ var serverProxy;
1361
+ function setServerProxyConfig(proxy) {
1362
+ serverProxy = proxy;
1363
+ bumpUpstreamProxyGeneration();
1364
+ }
1365
+ function getServerProxyConfig() {
1366
+ return serverProxy;
1367
+ }
1368
+ var envProxyLoggedFor;
1369
+ function maskProxyUrl(url) {
1370
+ return url.replace(/\/\/[^/@]*@/, "//***@");
1371
+ }
1372
+ function hostFromCtx(ctx) {
1373
+ if (!ctx.url) return void 0;
1374
+ try {
1375
+ return new URL(ctx.url).hostname.toLowerCase();
1376
+ } catch {
1377
+ return void 0;
1378
+ }
1379
+ }
1380
+ function isLoopbackHost(host) {
1381
+ return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.startsWith("127.") || host.endsWith(".localhost");
1382
+ }
1383
+ function noProxyMatches(noProxy, host) {
1384
+ if (!noProxy) return false;
1385
+ for (const raw of noProxy.split(",")) {
1386
+ const entry = raw.trim().toLowerCase();
1387
+ if (!entry) continue;
1388
+ if (entry === "*") return true;
1389
+ const bare = entry.startsWith(".") ? entry.slice(1) : entry;
1390
+ if (host === bare || host.endsWith(`.${bare}`)) return true;
1391
+ }
1392
+ return false;
1393
+ }
1394
+ function resolveEnvProxy(ctx, env = process.env) {
1395
+ const raw = env.HTTPS_PROXY ?? env.https_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
1396
+ if (!raw || !raw.trim()) return void 0;
1397
+ const host = hostFromCtx(ctx);
1398
+ if (host && (isLoopbackHost(host) || noProxyMatches(env.NO_PROXY ?? env.no_proxy, host))) {
1399
+ return void 0;
1400
+ }
1401
+ const url = raw.trim();
1402
+ if (envProxyLoggedFor !== url) {
1403
+ envProxyLoggedFor = url;
1404
+ console.info(`[upstream-proxy] routing upstream egress through the environment proxy: ${maskProxyUrl(url)}`);
1405
+ }
1406
+ return { url };
1407
+ }
1408
+ function createUpstreamProxyResolver(src = {}) {
1409
+ const readServer = src.getServerProxy ?? getServerProxyConfig;
1410
+ return (ctx) => {
1411
+ const host = hostFromCtx(ctx);
1412
+ if (host) {
1413
+ if (isLoopbackHost(host)) return void 0;
1414
+ const env = src.env ?? process.env;
1415
+ if (noProxyMatches(env.NO_PROXY ?? env.no_proxy, host)) return void 0;
1416
+ }
1417
+ if (src.getAccountProxy && ctx.providerId && ctx.accountId) {
1418
+ const account = src.getAccountProxy(ctx.providerId, ctx.accountId);
1419
+ if (account) return account;
1420
+ }
1421
+ const server = readServer();
1422
+ if (ctx.providerId && server?.byProvider?.[ctx.providerId]) {
1423
+ return server.byProvider[ctx.providerId];
1424
+ }
1425
+ if (server?.global) return server.global;
1426
+ return resolveEnvProxy(ctx, src.env);
1427
+ };
1428
+ }
1429
+
996
1430
  // src/admin/accountsOAuth.ts
997
1431
  import { claudeOAuth, geminiOAuth } from "@omnicross/subscriptions";
998
1432
 
@@ -1115,6 +1549,24 @@ function validateTokenBody(providerId, body) {
1115
1549
  return null;
1116
1550
  }
1117
1551
  }
1552
+ function validateSupportedModelsBody(raw) {
1553
+ if (raw === null || raw === void 0) return { ok: true, value: void 0 };
1554
+ if (Array.isArray(raw)) {
1555
+ if (raw.length === 0) return { ok: false };
1556
+ if (!raw.every((x) => typeof x === "string" && x.trim().length > 0)) return { ok: false };
1557
+ return { ok: true, value: raw };
1558
+ }
1559
+ if (typeof raw === "object") {
1560
+ const entries = Object.entries(raw);
1561
+ if (entries.length === 0) return { ok: false };
1562
+ const valid = entries.every(
1563
+ ([k, v]) => k.trim().length > 0 && typeof v === "string" && v.trim().length > 0
1564
+ );
1565
+ if (!valid) return { ok: false };
1566
+ return { ok: true, value: Object.fromEntries(entries) };
1567
+ }
1568
+ return { ok: false };
1569
+ }
1118
1570
  async function statusEntryFor(reader, providerId) {
1119
1571
  const all = await reader.listAll();
1120
1572
  return all.find((a) => a.providerId === providerId) ?? null;
@@ -1396,6 +1848,434 @@ async function handleCliLaunch(cli, body, ctx) {
1396
1848
  return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
1397
1849
  }
1398
1850
 
1851
+ // src/admin/auditConfigBody.ts
1852
+ var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1853
+ function validateAuditSegment(patch) {
1854
+ const errors = [];
1855
+ const audit = patch.audit;
1856
+ if (audit === void 0) return errors;
1857
+ if (!isPlainObject(audit)) {
1858
+ errors.push("audit must be an object");
1859
+ return errors;
1860
+ }
1861
+ for (const flag of ["enabled", "captureBodies", "trustForwardedFor"]) {
1862
+ if (audit[flag] !== void 0 && typeof audit[flag] !== "boolean") {
1863
+ errors.push(`audit.${flag} must be a boolean`);
1864
+ }
1865
+ }
1866
+ const maxBodyBytes = audit["maxBodyBytes"];
1867
+ if (maxBodyBytes !== void 0 && (typeof maxBodyBytes !== "number" || !Number.isFinite(maxBodyBytes) || maxBodyBytes < 0)) {
1868
+ errors.push("audit.maxBodyBytes must be a non-negative number");
1869
+ }
1870
+ const retentionDays = audit["retentionDays"];
1871
+ if (retentionDays !== void 0 && (typeof retentionDays !== "number" || !Number.isFinite(retentionDays) || retentionDays < 0)) {
1872
+ errors.push("audit.retentionDays must be a non-negative number");
1873
+ }
1874
+ return errors;
1875
+ }
1876
+
1877
+ // src/admin/billingConfigBody.ts
1878
+ var BILLING_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
1879
+ var isPlainObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1880
+ function validateBillingSegment(patch) {
1881
+ const errors = [];
1882
+ const billing = patch.billing;
1883
+ if (billing === void 0) return errors;
1884
+ if (!isPlainObject2(billing)) {
1885
+ errors.push("billing must be an object");
1886
+ return errors;
1887
+ }
1888
+ if (billing["enabled"] !== void 0 && typeof billing["enabled"] !== "boolean") {
1889
+ errors.push("billing.enabled must be a boolean");
1890
+ }
1891
+ if (billing["endpoint"] !== void 0 && typeof billing["endpoint"] !== "string") {
1892
+ errors.push("billing.endpoint must be a string");
1893
+ }
1894
+ if (billing["secret"] !== void 0 && typeof billing["secret"] !== "string") {
1895
+ errors.push("billing.secret must be a string");
1896
+ }
1897
+ const maxRetryAgeMs = billing["maxRetryAgeMs"];
1898
+ if (maxRetryAgeMs !== void 0 && (typeof maxRetryAgeMs !== "number" || !Number.isFinite(maxRetryAgeMs) || maxRetryAgeMs < 0)) {
1899
+ errors.push("billing.maxRetryAgeMs must be a non-negative number");
1900
+ }
1901
+ return errors;
1902
+ }
1903
+ function redactBillingConfig(billing) {
1904
+ if (typeof billing.secret === "string" && billing.secret.length > 0) {
1905
+ return { ...billing, secret: BILLING_SECRET_MASK };
1906
+ }
1907
+ return billing;
1908
+ }
1909
+ function preserveBillingSecret(incoming, current) {
1910
+ const isMaskedOrBlank = incoming.secret === void 0 || incoming.secret === "" || incoming.secret === BILLING_SECRET_MASK;
1911
+ if (isMaskedOrBlank) {
1912
+ if (current?.secret) return { ...incoming, secret: current.secret };
1913
+ const { secret: _secret, ...rest } = incoming;
1914
+ return rest;
1915
+ }
1916
+ return incoming;
1917
+ }
1918
+
1919
+ // src/admin/dashboard.ts
1920
+ function startOfLocalDayMs(ts) {
1921
+ const d = new Date(ts);
1922
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
1923
+ }
1924
+ function accountProviderId(entry) {
1925
+ if (!entry || typeof entry !== "object") return null;
1926
+ const e = entry;
1927
+ if (typeof e["providerId"] === "string" && e["providerId"]) return e["providerId"];
1928
+ if (typeof e["provider"] === "string" && e["provider"]) return e["provider"];
1929
+ return null;
1930
+ }
1931
+ async function handleDashboard(deps) {
1932
+ const now = Date.now();
1933
+ const today = await deps.usageRecorder.getTotals({ startTs: startOfLocalDayMs(now), endTs: now });
1934
+ const total = await deps.usageRecorder.getTotals({ startTs: 0, endTs: now });
1935
+ const providerList = loadConfig(deps.configPath).providers;
1936
+ const providers = {
1937
+ total: providerList.length,
1938
+ enabled: providerList.filter((p) => p.enabled !== false).length
1939
+ };
1940
+ const keys = await deps.keyDb.outboundApiKeysList();
1941
+ const outboundKeys = {
1942
+ total: keys.length,
1943
+ active: keys.filter((k) => k.enabled && k.revokedAt === null).length
1944
+ };
1945
+ const accountsList = await deps.subscriptionAccounts.listAll();
1946
+ const byProvider = {};
1947
+ for (const entry of accountsList) {
1948
+ const providerId = accountProviderId(entry);
1949
+ if (providerId) byProvider[providerId] = (byProvider[providerId] ?? 0) + 1;
1950
+ }
1951
+ const accounts = { total: accountsList.length, byProvider };
1952
+ const status = deps.outboundApiServer.getStatus();
1953
+ const server = {
1954
+ running: status.running,
1955
+ port: status.port,
1956
+ uptimeMs: Math.round(process.uptime() * 1e3)
1957
+ };
1958
+ const summary = { today, total, providers, outboundKeys, accounts, server, generatedAt: now };
1959
+ return { status: 200, body: summary };
1960
+ }
1961
+
1962
+ // src/admin/keyPolicyBody.ts
1963
+ function parseKeyPolicyBody(body) {
1964
+ const policy = {};
1965
+ if ("activationMode" in body) {
1966
+ const m = body["activationMode"];
1967
+ if (m === null) policy.activationMode = null;
1968
+ else if (m === "fixed" || m === "activation") policy.activationMode = m;
1969
+ else return { ok: false, message: "activationMode must be 'fixed', 'activation', or null" };
1970
+ }
1971
+ const numericFields = [
1972
+ { key: "expiresAt", min: 0 },
1973
+ { key: "activationDays", min: 1, integer: true },
1974
+ { key: "dailyCostLimitUsd", min: 0 },
1975
+ { key: "totalCostLimitUsd", min: 0 },
1976
+ { key: "weeklyCostLimitUsd", min: 0 },
1977
+ { key: "rateLimitMaxRequests", min: 0, integer: true },
1978
+ { key: "rateLimitWindowMs", min: 1 }
1979
+ ];
1980
+ for (const { key, min, integer } of numericFields) {
1981
+ if (!(key in body)) continue;
1982
+ const v = body[key];
1983
+ if (v === null) {
1984
+ policy[key] = null;
1985
+ continue;
1986
+ }
1987
+ if (typeof v !== "number" || !Number.isFinite(v) || v < min || integer && !Number.isInteger(v)) {
1988
+ return {
1989
+ ok: false,
1990
+ message: `${key} must be ${integer ? "an integer" : "a number"} >= ${min} or null`
1991
+ };
1992
+ }
1993
+ policy[key] = v;
1994
+ }
1995
+ if ("enableModelRestriction" in body) {
1996
+ const v = body["enableModelRestriction"];
1997
+ if (v === null) policy.enableModelRestriction = null;
1998
+ else if (typeof v === "boolean") policy.enableModelRestriction = v;
1999
+ else return { ok: false, message: "enableModelRestriction must be a boolean or null" };
2000
+ }
2001
+ if ("restrictionMode" in body) {
2002
+ const v = body["restrictionMode"];
2003
+ if (v === null) policy.restrictionMode = null;
2004
+ else if (v === "blacklist" || v === "allowlist") policy.restrictionMode = v;
2005
+ else return { ok: false, message: "restrictionMode must be 'blacklist', 'allowlist', or null" };
2006
+ }
2007
+ if ("restrictedModels" in body) {
2008
+ const v = body["restrictedModels"];
2009
+ if (v === null) {
2010
+ policy.restrictedModels = null;
2011
+ } else if (Array.isArray(v) && v.every((e) => typeof e === "string")) {
2012
+ policy.restrictedModels = v.map((e) => e.trim()).filter((e) => e !== "");
2013
+ } else {
2014
+ return { ok: false, message: "restrictedModels must be an array of strings or null" };
2015
+ }
2016
+ }
2017
+ return { ok: true, policy };
2018
+ }
2019
+
2020
+ // src/admin/voucherAdmin.ts
2021
+ import {
2022
+ generateVoucherCode,
2023
+ hashVoucherCode,
2024
+ loadServerConfig,
2025
+ newVoucherId,
2026
+ toVoucherInfo,
2027
+ voucherCodePrefix
2028
+ } from "@omnicross/core/outbound-api";
2029
+ function writeJson(res, status, body) {
2030
+ res.writeHead(status, { "Content-Type": "application/json" });
2031
+ res.end(JSON.stringify(body));
2032
+ }
2033
+ function writeErr(res, status, message) {
2034
+ writeJson(res, status, { error: { type: "voucher_error", message } });
2035
+ }
2036
+ function readJsonBody2(req) {
2037
+ return new Promise((resolve, reject) => {
2038
+ const chunks = [];
2039
+ req.on("data", (c) => chunks.push(c));
2040
+ req.on("end", () => {
2041
+ const raw = Buffer.concat(chunks).toString("utf8");
2042
+ if (!raw.trim()) return resolve({});
2043
+ try {
2044
+ const parsed = JSON.parse(raw);
2045
+ resolve(parsed && typeof parsed === "object" ? parsed : {});
2046
+ } catch {
2047
+ reject(new Error("invalid-json"));
2048
+ }
2049
+ });
2050
+ req.on("error", reject);
2051
+ });
2052
+ }
2053
+ function optPositive(value, integer) {
2054
+ if (value === void 0 || value === null) return { ok: true, value: void 0 };
2055
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return { ok: false };
2056
+ if (integer && !Number.isInteger(value)) return { ok: false };
2057
+ return { ok: true, value };
2058
+ }
2059
+ function parseVoucherCreateBody(body) {
2060
+ const type = body["type"];
2061
+ if (type !== "credit" && type !== "renewal") {
2062
+ return { ok: false, message: "type must be 'credit' or 'renewal'" };
2063
+ }
2064
+ const maxTotal = optPositive(body["maxTotalCostLimitUsd"], false);
2065
+ if (!maxTotal.ok) return { ok: false, message: "maxTotalCostLimitUsd must be a positive number" };
2066
+ const maxDays = optPositive(body["maxExpiryDays"], true);
2067
+ if (!maxDays.ok) return { ok: false, message: "maxExpiryDays must be a positive integer" };
2068
+ const input = { type };
2069
+ if (maxTotal.value !== void 0) input.maxTotalCostLimitUsd = maxTotal.value;
2070
+ if (maxDays.value !== void 0) input.maxExpiryDays = maxDays.value;
2071
+ if (type === "credit") {
2072
+ const credit = optPositive(body["creditUsd"], false);
2073
+ if (!credit.ok || credit.value === void 0) {
2074
+ return { ok: false, message: "creditUsd must be a positive number for a credit card" };
2075
+ }
2076
+ input.creditUsd = credit.value;
2077
+ } else {
2078
+ const days = optPositive(body["renewalDays"], true);
2079
+ if (!days.ok || days.value === void 0) {
2080
+ return { ok: false, message: "renewalDays must be a positive integer for a renewal card" };
2081
+ }
2082
+ input.renewalDays = days.value;
2083
+ }
2084
+ return { ok: true, input };
2085
+ }
2086
+ async function voucherEnabled(deps) {
2087
+ const config = await loadServerConfig(deps.settingsStore);
2088
+ return config.voucher?.enabled === true;
2089
+ }
2090
+ async function handleVoucher(req, res, method, rest, deps) {
2091
+ const voucherDb = deps.voucherDb;
2092
+ if (!voucherDb) return writeErr(res, 501, "Voucher feature is not available");
2093
+ if (method === "GET" && rest.length === 0) {
2094
+ const rows = await voucherDb.voucherList();
2095
+ return writeJson(res, 200, { vouchers: rows.map(toVoucherInfo) });
2096
+ }
2097
+ if (method === "POST" && rest.length === 0) {
2098
+ if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
2099
+ let body;
2100
+ try {
2101
+ body = await readJsonBody2(req);
2102
+ } catch {
2103
+ return writeErr(res, 400, "Invalid JSON in request body");
2104
+ }
2105
+ const parsed = parseVoucherCreateBody(body);
2106
+ if (!parsed.ok) return writeErr(res, 400, parsed.message);
2107
+ const code = generateVoucherCode();
2108
+ const created = await voucherDb.voucherCreate({
2109
+ id: newVoucherId(),
2110
+ codeHash: hashVoucherCode(code),
2111
+ codePrefix: voucherCodePrefix(code),
2112
+ ...parsed.input
2113
+ });
2114
+ return writeJson(res, 201, {
2115
+ id: created.id,
2116
+ codePrefix: created.codePrefix,
2117
+ type: created.type,
2118
+ createdAt: created.createdAt,
2119
+ // `plaintextOnce` is the ONLY place the full code crosses the wire (D3).
2120
+ plaintextOnce: code
2121
+ });
2122
+ }
2123
+ const id = rest[0];
2124
+ if (method === "POST" && id && rest[1] === "revoke") {
2125
+ if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
2126
+ const ok = await voucherDb.voucherRevokeCas(id, Date.now());
2127
+ return writeJson(res, ok ? 200 : 409, { ok });
2128
+ }
2129
+ return writeErr(res, 405, `method ${method} not allowed on voucher`);
2130
+ }
2131
+
2132
+ // src/admin/webhookConfigBody.ts
2133
+ import {
2134
+ WEBHOOK_DESTINATION_TYPES,
2135
+ WEBHOOK_EVENT_KINDS
2136
+ } from "@omnicross/contracts/webhook-types";
2137
+ var WEBHOOK_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
2138
+ var isPlainObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2139
+ function validateWebhookSegment(patch) {
2140
+ const errors = [];
2141
+ const webhook = patch.webhook;
2142
+ if (webhook === void 0) return errors;
2143
+ if (!isPlainObject3(webhook)) {
2144
+ errors.push("webhook must be an object");
2145
+ return errors;
2146
+ }
2147
+ if (typeof webhook["enabled"] !== "boolean") {
2148
+ errors.push("webhook.enabled must be a boolean");
2149
+ }
2150
+ const destinations = webhook["destinations"];
2151
+ if (destinations !== void 0 && !Array.isArray(destinations)) {
2152
+ errors.push("webhook.destinations must be an array");
2153
+ return errors;
2154
+ }
2155
+ const seenIds = /* @__PURE__ */ new Set();
2156
+ for (const [i, raw] of (Array.isArray(destinations) ? destinations : []).entries()) {
2157
+ if (!isPlainObject3(raw)) {
2158
+ errors.push(`webhook.destinations[${i}] must be an object`);
2159
+ continue;
2160
+ }
2161
+ const id = raw["id"];
2162
+ if (typeof id !== "string" || !id.trim()) {
2163
+ errors.push(`webhook.destinations[${i}].id must be a non-empty string`);
2164
+ } else if (seenIds.has(id.trim())) {
2165
+ errors.push(`webhook.destinations[${i}].id '${id.trim()}' is duplicated`);
2166
+ } else {
2167
+ seenIds.add(id.trim());
2168
+ }
2169
+ if (typeof raw["type"] !== "string" || !WEBHOOK_DESTINATION_TYPES.includes(raw["type"])) {
2170
+ errors.push(`webhook.destinations[${i}].type must be one of ${WEBHOOK_DESTINATION_TYPES.join(", ")}`);
2171
+ }
2172
+ if (typeof raw["url"] !== "string" || !raw["url"].trim()) {
2173
+ errors.push(`webhook.destinations[${i}].url must be a non-empty string`);
2174
+ }
2175
+ if (raw["secret"] !== void 0 && typeof raw["secret"] !== "string") {
2176
+ errors.push(`webhook.destinations[${i}].secret must be a string`);
2177
+ }
2178
+ if (raw["enabled"] !== void 0 && typeof raw["enabled"] !== "boolean") {
2179
+ errors.push(`webhook.destinations[${i}].enabled must be a boolean`);
2180
+ }
2181
+ const events = raw["events"];
2182
+ if (events !== void 0) {
2183
+ if (!Array.isArray(events)) {
2184
+ errors.push(`webhook.destinations[${i}].events must be an array`);
2185
+ } else {
2186
+ for (const e of events) {
2187
+ if (typeof e !== "string" || !WEBHOOK_EVENT_KINDS.includes(e)) {
2188
+ errors.push(`webhook.destinations[${i}].events contains an unknown kind '${String(e)}'`);
2189
+ }
2190
+ }
2191
+ }
2192
+ }
2193
+ }
2194
+ return errors;
2195
+ }
2196
+ function redactWebhookConfig(webhook) {
2197
+ return {
2198
+ ...webhook,
2199
+ destinations: webhook.destinations.map(
2200
+ (d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: WEBHOOK_SECRET_MASK } : d
2201
+ )
2202
+ };
2203
+ }
2204
+ function preserveWebhookSecrets(incoming, current) {
2205
+ const currentById = /* @__PURE__ */ new Map();
2206
+ for (const d of current?.destinations ?? []) currentById.set(d.id, d);
2207
+ return {
2208
+ ...incoming,
2209
+ destinations: incoming.destinations.map((d) => {
2210
+ const isMaskedOrBlank = d.secret === void 0 || d.secret === "" || d.secret === WEBHOOK_SECRET_MASK;
2211
+ if (isMaskedOrBlank) {
2212
+ const prev = currentById.get(d.id);
2213
+ if (prev?.secret) return { ...d, secret: prev.secret };
2214
+ const { secret: _secret, ...rest } = d;
2215
+ return rest;
2216
+ }
2217
+ return d;
2218
+ })
2219
+ };
2220
+ }
2221
+
2222
+ // src/audit/auditRuntime.ts
2223
+ import { setAuditCaptureConfig, setAuditSink } from "@omnicross/core/pipeline/auditSink";
2224
+ var writer = null;
2225
+ var sweeper = null;
2226
+ function setAuditRuntime(w, s) {
2227
+ writer = w;
2228
+ sweeper = s;
2229
+ }
2230
+ function applyAuditConfig(config) {
2231
+ const enabled = config?.enabled === true && writer !== null;
2232
+ if (enabled && config) {
2233
+ setAuditCaptureConfig(config);
2234
+ const activeWriter = writer;
2235
+ setAuditSink((record) => activeWriter.record(record));
2236
+ if (sweeper) {
2237
+ sweeper.configure(config);
2238
+ sweeper.start();
2239
+ }
2240
+ } else {
2241
+ setAuditCaptureConfig(null);
2242
+ setAuditSink(null);
2243
+ if (sweeper) {
2244
+ if (config) sweeper.configure(config);
2245
+ sweeper.dispose();
2246
+ }
2247
+ }
2248
+ }
2249
+
2250
+ // src/billing/billingRuntime.ts
2251
+ import { setBillingCaptureConfig, setBillingSink } from "@omnicross/core/pipeline/billingEmit";
2252
+ var publisher = null;
2253
+ var sweeper2 = null;
2254
+ function setBillingRuntime(p, s) {
2255
+ publisher = p;
2256
+ sweeper2 = s;
2257
+ }
2258
+ function applyBillingConfig(config) {
2259
+ const enabled = config?.enabled === true && publisher !== null;
2260
+ if (enabled && config) {
2261
+ const activePublisher = publisher;
2262
+ activePublisher.setConfig(config);
2263
+ setBillingCaptureConfig(config);
2264
+ setBillingSink((event) => activePublisher.record(event));
2265
+ if (sweeper2) {
2266
+ sweeper2.configure(config);
2267
+ sweeper2.start();
2268
+ }
2269
+ } else {
2270
+ setBillingCaptureConfig(null);
2271
+ setBillingSink(null);
2272
+ if (sweeper2) {
2273
+ if (config) sweeper2.configure(config);
2274
+ sweeper2.dispose();
2275
+ }
2276
+ }
2277
+ }
2278
+
1399
2279
  // src/ports/account-multi.ts
1400
2280
  import { randomUUID as randomUUID2 } from "crypto";
1401
2281
  var PROVIDER_KEYS = {
@@ -1507,6 +2387,9 @@ function getAccountById(config, p, id) {
1507
2387
  const account = getAccounts(config, p).find((a) => a.id === id);
1508
2388
  return account ? { id: account.id, tokens: account.tokens } : void 0;
1509
2389
  }
2390
+ function getAccountProxy(config, p, id) {
2391
+ return getAccounts(config, p).find((a) => a.id === id)?.proxy;
2392
+ }
1510
2393
  function getActiveAccount(config, p) {
1511
2394
  const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
1512
2395
  return active ? { id: active.id, tokens: active.tokens } : void 0;
@@ -1547,7 +2430,17 @@ function sanitizeAccounts(config, p) {
1547
2430
  isSetupToken: t.isSetupToken,
1548
2431
  hasAccessToken: !!(t.accessToken || t.apiKey),
1549
2432
  isActive: a.id === activeId,
1550
- syncWarning: t.syncWarning
2433
+ // Scheduling metadata (subscription-account-scheduling): editable priority
2434
+ // (default 50 shown when unset) + display-only lastUsedAt. Secret-free.
2435
+ priority: a.priority,
2436
+ lastUsedAt: a.lastUsedAt,
2437
+ syncWarning: t.syncWarning,
2438
+ // Per-account proxy (upstream-proxy): masked view — password → hasPassword,
2439
+ // userinfo stripped. The plaintext password is NEVER projected.
2440
+ proxy: a.proxy ? sanitizeProxyConfig(a.proxy) : void 0,
2441
+ // Per-account model support / remap (subscription-account-model-map): model
2442
+ // ids are not token material → carried through verbatim for the editor.
2443
+ supportedModels: a.supportedModels
1551
2444
  };
1552
2445
  });
1553
2446
  }
@@ -1561,13 +2454,84 @@ function renameAccount(config, p, id, label) {
1561
2454
  );
1562
2455
  return { ok: true };
1563
2456
  }
1564
- function clearProvider(config, p) {
1565
- setBlock(config, p, void 0);
1566
- setAccounts(config, p, void 0);
1567
- setActiveId(config, p, void 0);
1568
- }
1569
- var DAEMON_PROVIDER_KEYS = PROVIDER_KEYS;
1570
-
2457
+ function setAccountPriority(config, p, id, priority) {
2458
+ const accounts = getAccounts(config, p);
2459
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
2460
+ setAccounts(
2461
+ config,
2462
+ p,
2463
+ accounts.map((a) => a.id === id ? { ...a, priority } : a)
2464
+ );
2465
+ return { ok: true };
2466
+ }
2467
+ function setAccountProxy(config, p, id, proxy) {
2468
+ const accounts = getAccounts(config, p);
2469
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
2470
+ setAccounts(
2471
+ config,
2472
+ p,
2473
+ accounts.map((a) => {
2474
+ if (a.id !== id) return a;
2475
+ if (!proxy) {
2476
+ const { proxy: _drop, ...rest } = a;
2477
+ return rest;
2478
+ }
2479
+ return { ...a, proxy };
2480
+ })
2481
+ );
2482
+ return { ok: true };
2483
+ }
2484
+ function setAccountSupportedModels(config, p, id, supportedModels) {
2485
+ const accounts = getAccounts(config, p);
2486
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
2487
+ setAccounts(
2488
+ config,
2489
+ p,
2490
+ accounts.map((a) => {
2491
+ if (a.id !== id) return a;
2492
+ if (supportedModels === void 0) {
2493
+ const { supportedModels: _drop, ...rest } = a;
2494
+ return rest;
2495
+ }
2496
+ return { ...a, supportedModels };
2497
+ })
2498
+ );
2499
+ return { ok: true };
2500
+ }
2501
+ function setAccountLastUsed(config, p, id, iso) {
2502
+ const accounts = getAccounts(config, p);
2503
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
2504
+ setAccounts(
2505
+ config,
2506
+ p,
2507
+ accounts.map((a) => a.id === id ? { ...a, lastUsedAt: iso } : a)
2508
+ );
2509
+ return { ok: true };
2510
+ }
2511
+ function setAccountIdentity(config, p, id, identity) {
2512
+ const accounts = getAccounts(config, p);
2513
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
2514
+ setAccounts(
2515
+ config,
2516
+ p,
2517
+ accounts.map((a) => {
2518
+ if (a.id !== id) return a;
2519
+ if (identity === void 0) {
2520
+ const { identity: _drop, ...rest } = a;
2521
+ return rest;
2522
+ }
2523
+ return { ...a, identity };
2524
+ })
2525
+ );
2526
+ return { ok: true };
2527
+ }
2528
+ function clearProvider(config, p) {
2529
+ setBlock(config, p, void 0);
2530
+ setAccounts(config, p, void 0);
2531
+ setActiveId(config, p, void 0);
2532
+ }
2533
+ var DAEMON_PROVIDER_KEYS = PROVIDER_KEYS;
2534
+
1571
2535
  // src/migration/packCodec.ts
1572
2536
  import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes3, scryptSync } from "crypto";
1573
2537
  var PACK_MAGIC = "OMCXPACK";
@@ -1826,6 +2790,12 @@ function parseRange(query) {
1826
2790
  return { startTs, endTs };
1827
2791
  }
1828
2792
  var isRange = (v) => v.startTs !== void 0 && !("status" in v);
2793
+ var BUCKET_SPAN_MS = {
2794
+ hour: 36e5,
2795
+ day: 864e5,
2796
+ month: 28 * 864e5
2797
+ };
2798
+ var MAX_TIMESERIES_BUCKETS = 2e3;
1829
2799
  async function handleUsageGet(view, query, deps) {
1830
2800
  const range = parseRange(query);
1831
2801
  if (!isRange(range)) return range;
@@ -1834,6 +2804,24 @@ async function handleUsageGet(view, query, deps) {
1834
2804
  return { status: 200, body: await deps.usageRecorder.getTotals(range) };
1835
2805
  case "by-model":
1836
2806
  return { status: 200, body: await deps.usageRecorder.getByModel(range) };
2807
+ case "timeseries": {
2808
+ const bucket = query.get("bucket");
2809
+ if (bucket !== "hour" && bucket !== "day" && bucket !== "month") {
2810
+ return err4(400, "bucket must be one of 'hour', 'day', 'month'");
2811
+ }
2812
+ const now = Date.now();
2813
+ const clamped = { startTs: range.startTs, endTs: Math.min(range.endTs, now) };
2814
+ if (clamped.startTs < clamped.endTs) {
2815
+ const projected = Math.ceil((clamped.endTs - clamped.startTs) / BUCKET_SPAN_MS[bucket]) + 1;
2816
+ if (projected > MAX_TIMESERIES_BUCKETS) {
2817
+ return err4(
2818
+ 400,
2819
+ `requested range projects ~${projected} '${bucket}' buckets (max ${MAX_TIMESERIES_BUCKETS}); narrow the range or use a coarser bucket`
2820
+ );
2821
+ }
2822
+ }
2823
+ return { status: 200, body: await deps.usageRecorder.getTimeSeries(clamped, bucket) };
2824
+ }
1837
2825
  case "by-api-key": {
1838
2826
  const rows = await deps.usageRecorder.getByApiKey(range);
1839
2827
  const labels = poolKeyLabels(loadConfig(deps.configPath));
@@ -1979,7 +2967,7 @@ function readBody(req) {
1979
2967
  req.on("error", reject);
1980
2968
  });
1981
2969
  }
1982
- async function readJsonBody(req) {
2970
+ async function readJsonBody3(req) {
1983
2971
  const raw = await readBody(req);
1984
2972
  if (!raw.trim()) return {};
1985
2973
  try {
@@ -1989,12 +2977,12 @@ async function readJsonBody(req) {
1989
2977
  return {};
1990
2978
  }
1991
2979
  }
1992
- function writeJson(res, status, body) {
2980
+ function writeJson2(res, status, body) {
1993
2981
  res.writeHead(status, { "Content-Type": "application/json" });
1994
2982
  res.end(JSON.stringify(body));
1995
2983
  }
1996
2984
  function writeJsonError(res, status, message) {
1997
- writeJson(res, status, { error: { type: "admin_api_error", message } });
2985
+ writeJson2(res, status, { error: { type: "admin_api_error", message } });
1998
2986
  }
1999
2987
  function maskProviderApiKey(apiKey) {
2000
2988
  if (!apiKey) return "";
@@ -2010,7 +2998,23 @@ function toKeyInfo(row) {
2010
2998
  enabled: row.enabled,
2011
2999
  createdAt: row.createdAt,
2012
3000
  lastUsedAt: row.lastUsedAt,
2013
- revoked: row.revokedAt !== null
3001
+ revoked: row.revokedAt !== null,
3002
+ maxConcurrency: row.maxConcurrency,
3003
+ // Key-policy envelope (outbound-key-policy) — all secret-free scalar fields;
3004
+ // the UI reads them to render + pre-fill the policy editor.
3005
+ expiresAt: row.expiresAt,
3006
+ activationMode: row.activationMode,
3007
+ activationDays: row.activationDays,
3008
+ activatedAt: row.activatedAt,
3009
+ dailyCostLimitUsd: row.dailyCostLimitUsd,
3010
+ totalCostLimitUsd: row.totalCostLimitUsd,
3011
+ weeklyCostLimitUsd: row.weeklyCostLimitUsd,
3012
+ rateLimitMaxRequests: row.rateLimitMaxRequests,
3013
+ rateLimitWindowMs: row.rateLimitWindowMs,
3014
+ // Per-key model restriction (#6) — secret-free scalars the UI pre-fills.
3015
+ enableModelRestriction: row.enableModelRestriction,
3016
+ restrictionMode: row.restrictionMode,
3017
+ restrictedModels: row.restrictedModels
2014
3018
  };
2015
3019
  }
2016
3020
  function toProviderView(row) {
@@ -2072,6 +3076,8 @@ async function handleAdminApi(req, res, path2, deps) {
2072
3076
  return handlePresets(res, method);
2073
3077
  case "keys":
2074
3078
  return await handleKeys(req, res, method, rest, deps);
3079
+ case "voucher":
3080
+ return await handleVoucher(req, res, method, rest, deps);
2075
3081
  case "server":
2076
3082
  return await handleServer(req, res, method, deps);
2077
3083
  case "accounts":
@@ -2088,6 +3094,8 @@ async function handleAdminApi(req, res, path2, deps) {
2088
3094
  return await handleMigrationImport(req, res, method, deps);
2089
3095
  case "usage":
2090
3096
  return await handleUsage(req, res, method, rest, deps);
3097
+ case "dashboard":
3098
+ return await handleDashboardRoute(res, method, deps);
2091
3099
  case "pricing":
2092
3100
  return await handlePricing(req, res, method, rest, deps);
2093
3101
  default:
@@ -2103,17 +3111,22 @@ function requestQuery(req) {
2103
3111
  return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
2104
3112
  }
2105
3113
  function writeResult(res, result) {
2106
- writeJson(res, result.status, result.body);
3114
+ writeJson2(res, result.status, result.body);
2107
3115
  }
2108
3116
  async function handleUsage(req, res, method, rest, deps) {
2109
3117
  if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
2110
3118
  return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
2111
3119
  }
3120
+ async function handleDashboardRoute(res, method, deps) {
3121
+ if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on dashboard`);
3122
+ const result = await handleDashboard(deps);
3123
+ return writeJson2(res, result.status, result.body);
3124
+ }
2112
3125
  async function handlePricing(req, res, method, rest, deps) {
2113
3126
  if (rest.length === 0) {
2114
3127
  if (method === "GET") return writeResult(res, await handlePricingList(deps));
2115
3128
  if (method === "PUT") {
2116
- return writeResult(res, await handlePricingUpsert(await readJsonBody(req), deps));
3129
+ return writeResult(res, await handlePricingUpsert(await readJsonBody3(req), deps));
2117
3130
  }
2118
3131
  if (method === "DELETE") {
2119
3132
  return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
@@ -2124,7 +3137,7 @@ async function handlePricing(req, res, method, rest, deps) {
2124
3137
  return writeResult(res, await handlePricingFetchLatest(deps));
2125
3138
  }
2126
3139
  if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
2127
- return writeResult(res, await handlePricingResolveConflicts(await readJsonBody(req), deps));
3140
+ return writeResult(res, await handlePricingResolveConflicts(await readJsonBody3(req), deps));
2128
3141
  }
2129
3142
  return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
2130
3143
  }
@@ -2138,15 +3151,15 @@ function migrationDeps(deps) {
2138
3151
  }
2139
3152
  async function handleMigrationExport(req, res, method, deps) {
2140
3153
  if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on export`);
2141
- const body = await readJsonBody(req);
3154
+ const body = await readJsonBody3(req);
2142
3155
  const result = await handleExport(body, migrationDeps(deps));
2143
- return writeJson(res, result.status, result.body);
3156
+ return writeJson2(res, result.status, result.body);
2144
3157
  }
2145
3158
  async function handleMigrationImport(req, res, method, deps) {
2146
3159
  if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on import`);
2147
- const body = await readJsonBody(req);
3160
+ const body = await readJsonBody3(req);
2148
3161
  const result = await handleImport(body, migrationDeps(deps));
2149
- return writeJson(res, result.status, result.body);
3162
+ return writeJson2(res, result.status, result.body);
2150
3163
  }
2151
3164
  async function handleProviders(req, res, method, rest, deps) {
2152
3165
  const cfg = loadConfig(deps.configPath);
@@ -2177,13 +3190,13 @@ async function handleProviders(req, res, method, rest, deps) {
2177
3190
  if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
2178
3191
  const row = cfg.providers.find((p) => p.id === rest[0]);
2179
3192
  if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
2180
- return writeJson(res, 200, { apiKey: row.apiKey ?? "" });
3193
+ return writeJson2(res, 200, { apiKey: row.apiKey ?? "" });
2181
3194
  }
2182
3195
  if (method === "GET") {
2183
- return writeJson(res, 200, { providers: cfg.providers.map(toProviderView) });
3196
+ return writeJson2(res, 200, { providers: cfg.providers.map(toProviderView) });
2184
3197
  }
2185
3198
  if (method === "POST") {
2186
- const body = await readJsonBody(req);
3199
+ const body = await readJsonBody3(req);
2187
3200
  const provider = parseProviderInput(body, void 0);
2188
3201
  if (!provider) return writeJsonError(res, 400, "invalid provider (id, apiFormat, baseUrl required)");
2189
3202
  if (cfg.providers.some((p) => p.id === provider.id)) {
@@ -2191,25 +3204,25 @@ async function handleProviders(req, res, method, rest, deps) {
2191
3204
  }
2192
3205
  cfg.providers.push(provider);
2193
3206
  persistProviders(cfg, deps);
2194
- return writeJson(res, 201, { provider: toProviderView(provider) });
3207
+ return writeJson2(res, 201, { provider: toProviderView(provider) });
2195
3208
  }
2196
3209
  const id = rest[0];
2197
3210
  if (!id) return writeJsonError(res, 400, "provider id required in path");
2198
3211
  const idx = cfg.providers.findIndex((p) => p.id === id);
2199
3212
  if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
2200
3213
  if (method === "PUT") {
2201
- const body = await readJsonBody(req);
3214
+ const body = await readJsonBody3(req);
2202
3215
  const existing = cfg.providers[idx];
2203
3216
  const updated = parseProviderInput(body, existing);
2204
3217
  if (!updated) return writeJsonError(res, 400, "invalid provider (apiFormat, baseUrl required)");
2205
3218
  cfg.providers[idx] = updated;
2206
3219
  persistProviders(cfg, deps);
2207
- return writeJson(res, 200, { provider: toProviderView(updated) });
3220
+ return writeJson2(res, 200, { provider: toProviderView(updated) });
2208
3221
  }
2209
3222
  if (method === "DELETE") {
2210
3223
  cfg.providers.splice(idx, 1);
2211
3224
  persistProviders(cfg, deps);
2212
- return writeJson(res, 200, { ok: true });
3225
+ return writeJson2(res, 200, { ok: true });
2213
3226
  }
2214
3227
  return writeJsonError(res, 405, `method ${method} not allowed on providers`);
2215
3228
  }
@@ -2218,7 +3231,7 @@ function persistProviders(cfg, deps) {
2218
3231
  deps.llmConfig.reload(cfg);
2219
3232
  }
2220
3233
  async function handleProviderReorder(req, res, cfg, deps) {
2221
- const body = await readJsonBody(req);
3234
+ const body = await readJsonBody3(req);
2222
3235
  const rawOrder = body["order"];
2223
3236
  if (!Array.isArray(rawOrder)) {
2224
3237
  return writeJsonError(res, 400, "reorder requires { order: string[] }");
@@ -2242,14 +3255,14 @@ async function handleProviderReorder(req, res, cfg, deps) {
2242
3255
  }
2243
3256
  cfg.providers = reordered;
2244
3257
  persistProviders(cfg, deps);
2245
- return writeJson(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
3258
+ return writeJson2(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
2246
3259
  }
2247
3260
  async function handleDiscoverModels(res, id, cfg) {
2248
3261
  if (!id) return writeJsonError(res, 400, "provider id required in path");
2249
3262
  const row = cfg.providers.find((p) => p.id === id);
2250
3263
  if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
2251
3264
  if (row.apiFormat !== "openai") {
2252
- return writeJson(res, 200, { models: [], unsupportedFormat: true });
3265
+ return writeJson2(res, 200, { models: [], unsupportedFormat: true });
2253
3266
  }
2254
3267
  const resolvedKey = resolveEnvKey(row.apiKey);
2255
3268
  const base = row.baseUrl.replace(/\/+$/, "");
@@ -2257,7 +3270,7 @@ async function handleDiscoverModels(res, id, cfg) {
2257
3270
  try {
2258
3271
  const headers = { Accept: "application/json" };
2259
3272
  if (resolvedKey) headers["Authorization"] = `Bearer ${resolvedKey}`;
2260
- const response = await fetch(url, { method: "GET", headers });
3273
+ const response = await fetchUpstream(url, { method: "GET", headers }, { providerId: "byo" });
2261
3274
  if (!response.ok) {
2262
3275
  const text = await response.text().catch(() => "");
2263
3276
  let message = text.slice(0, 300);
@@ -2266,32 +3279,32 @@ async function handleDiscoverModels(res, id, cfg) {
2266
3279
  message = parsed?.error?.message || parsed?.message || message;
2267
3280
  } catch {
2268
3281
  }
2269
- return writeJson(res, 200, {
3282
+ return writeJson2(res, 200, {
2270
3283
  models: [],
2271
3284
  error: `discovery failed (${response.status})${message ? `: ${message}` : ""}`
2272
3285
  });
2273
3286
  }
2274
3287
  const data = await response.json();
2275
3288
  const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
2276
- return writeJson(res, 200, { models });
3289
+ return writeJson2(res, 200, { models });
2277
3290
  } catch (err5) {
2278
3291
  const message = err5 instanceof Error ? err5.message : String(err5);
2279
- return writeJson(res, 200, { models: [], error: `discovery failed: ${message}` });
3292
+ return writeJson2(res, 200, { models: [], error: `discovery failed: ${message}` });
2280
3293
  }
2281
3294
  }
2282
3295
  async function handleTestModel(req, res, id, cfg) {
2283
3296
  if (!id) return writeJsonError(res, 400, "provider id required in path");
2284
3297
  const row = cfg.providers.find((p) => p.id === id);
2285
3298
  if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
2286
- const body = await readJsonBody(req);
3299
+ const body = await readJsonBody3(req);
2287
3300
  const model = typeof body["model"] === "string" ? body["model"].trim() : "";
2288
3301
  if (!model) return writeJsonError(res, 400, "test requires a { model } string");
2289
3302
  if (row.apiFormat === "gemini") {
2290
- return writeJson(res, 200, { ok: false, unsupportedFormat: true });
3303
+ return writeJson2(res, 200, { ok: false, unsupportedFormat: true });
2291
3304
  }
2292
3305
  const resolvedKey = resolveEnvKey(row.apiKey);
2293
3306
  if (!resolvedKey) {
2294
- return writeJson(res, 200, { ok: false, message: "no API key configured for this provider" });
3307
+ return writeJson2(res, 200, { ok: false, message: "no API key configured for this provider" });
2295
3308
  }
2296
3309
  const url = row.baseUrl.replace(/\/+$/, "");
2297
3310
  const prompt = "Reply with the single word: OK.";
@@ -2312,11 +3325,11 @@ async function handleTestModel(req, res, id, cfg) {
2312
3325
  }
2313
3326
  const startedAt = Date.now();
2314
3327
  try {
2315
- const response = await fetch(url, {
2316
- method: "POST",
2317
- headers,
2318
- body: JSON.stringify(payload)
2319
- });
3328
+ const response = await fetchUpstream(
3329
+ url,
3330
+ { method: "POST", headers, body: JSON.stringify(payload) },
3331
+ { providerId: "byo" }
3332
+ );
2320
3333
  const latencyMs = Date.now() - startedAt;
2321
3334
  const text = await response.text().catch(() => "");
2322
3335
  if (!response.ok) {
@@ -2326,9 +3339,9 @@ async function handleTestModel(req, res, id, cfg) {
2326
3339
  message = parsed?.error?.message || parsed?.message || message;
2327
3340
  } catch {
2328
3341
  }
2329
- return writeJson(res, 200, { ok: false, status: response.status, latencyMs, message });
3342
+ return writeJson2(res, 200, { ok: false, status: response.status, latencyMs, message });
2330
3343
  }
2331
- return writeJson(res, 200, {
3344
+ return writeJson2(res, 200, {
2332
3345
  ok: true,
2333
3346
  status: response.status,
2334
3347
  latencyMs,
@@ -2336,7 +3349,7 @@ async function handleTestModel(req, res, id, cfg) {
2336
3349
  });
2337
3350
  } catch (err5) {
2338
3351
  const message = err5 instanceof Error ? err5.message : String(err5);
2339
- return writeJson(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
3352
+ return writeJson2(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
2340
3353
  }
2341
3354
  }
2342
3355
  function extractSampleText(text, apiFormat) {
@@ -2358,9 +3371,9 @@ function toPoolKeyView(row, cooldown, deps) {
2358
3371
  return entries.map((e) => {
2359
3372
  const auto = deps.autoDisableStore.get(e.id);
2360
3373
  const cd = cooldown[e.id];
2361
- const health = {};
2362
- if (cd) health.cooldown = cd;
2363
- if (auto) health.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
3374
+ const health2 = {};
3375
+ if (cd) health2.cooldown = cd;
3376
+ if (auto) health2.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
2364
3377
  return {
2365
3378
  id: e.id,
2366
3379
  label: e.label && e.label.length > 0 ? e.label : e.id,
@@ -2368,7 +3381,7 @@ function toPoolKeyView(row, cooldown, deps) {
2368
3381
  enabled: e.enabled !== false && !deps.autoDisableStore.isDisabled(e.id),
2369
3382
  weight: typeof e.weight === "number" && Number.isFinite(e.weight) ? e.weight : 1,
2370
3383
  apiKeyMasked: maskProviderApiKey(e.apiKey),
2371
- ...Object.keys(health).length > 0 ? { health } : {}
3384
+ ...Object.keys(health2).length > 0 ? { health: health2 } : {}
2372
3385
  };
2373
3386
  });
2374
3387
  }
@@ -2377,7 +3390,7 @@ async function handleProviderKeys(res, id, cfg, deps) {
2377
3390
  const row = cfg.providers.find((p) => p.id === id);
2378
3391
  if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
2379
3392
  const cooldown = await deps.apiKeyPool.getKeyHealth(id);
2380
- return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
3393
+ return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
2381
3394
  }
2382
3395
  function parsePoolKeyInput(body, existing) {
2383
3396
  const out = {};
@@ -2396,7 +3409,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
2396
3409
  if (!id) return writeJsonError(res, 400, "provider id required in path");
2397
3410
  const idx = cfg.providers.findIndex((p) => p.id === id);
2398
3411
  if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
2399
- const body = await readJsonBody(req);
3412
+ const body = await readJsonBody3(req);
2400
3413
  const parsed = parsePoolKeyInput(body);
2401
3414
  if (!parsed.apiKey) return writeJsonError(res, 400, "add requires a non-empty apiKey");
2402
3415
  const keyId = `key-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -2408,7 +3421,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
2408
3421
  row.apiKeys = [...row.apiKeys ?? [], entry];
2409
3422
  persistProviders(cfg, deps);
2410
3423
  const cooldown = await deps.apiKeyPool.getKeyHealth(id);
2411
- return writeJson(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
3424
+ return writeJson2(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
2412
3425
  }
2413
3426
  async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
2414
3427
  if (!id) return writeJsonError(res, 400, "provider id required in path");
@@ -2418,7 +3431,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
2418
3431
  const row = cfg.providers[idx];
2419
3432
  const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
2420
3433
  if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
2421
- const body = await readJsonBody(req);
3434
+ const body = await readJsonBody3(req);
2422
3435
  const existing = row.apiKeys[keyIdx];
2423
3436
  const parsed = parsePoolKeyInput(body, existing);
2424
3437
  const entry = { id: keyId, apiKey: parsed.apiKey || existing.apiKey };
@@ -2428,7 +3441,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
2428
3441
  row.apiKeys[keyIdx] = entry;
2429
3442
  persistProviders(cfg, deps);
2430
3443
  const cooldown = await deps.apiKeyPool.getKeyHealth(id);
2431
- return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
3444
+ return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
2432
3445
  }
2433
3446
  async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
2434
3447
  if (!id) return writeJsonError(res, 400, "provider id required in path");
@@ -2442,7 +3455,7 @@ async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
2442
3455
  if (row.apiKeys.length === 0) row.apiKeys = void 0;
2443
3456
  persistProviders(cfg, deps);
2444
3457
  const cooldown = await deps.apiKeyPool.getKeyHealth(id);
2445
- return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
3458
+ return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
2446
3459
  }
2447
3460
  async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
2448
3461
  if (!id) return writeJsonError(res, 400, "provider id required in path");
@@ -2452,11 +3465,11 @@ async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
2452
3465
  const row = cfg.providers[idx];
2453
3466
  const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
2454
3467
  if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
2455
- const body = await readJsonBody(req);
3468
+ const body = await readJsonBody3(req);
2456
3469
  row.apiKeys[keyIdx].enabled = Boolean(body["enabled"]);
2457
3470
  persistProviders(cfg, deps);
2458
3471
  const cooldown = await deps.apiKeyPool.getKeyHealth(id);
2459
- return writeJson(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
3472
+ return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
2460
3473
  }
2461
3474
  function parseApiKeysInput(raw, existing) {
2462
3475
  if (!Array.isArray(raw)) return existing;
@@ -2627,18 +3640,31 @@ function handlePresets(res, method) {
2627
3640
  baseUrl: p.baseUrl,
2628
3641
  models: p.models
2629
3642
  }));
2630
- return writeJson(res, 200, { presets, excluded });
3643
+ return writeJson2(res, 200, { presets, excluded });
2631
3644
  }
2632
3645
  async function handleKeys(req, res, method, rest, deps) {
2633
3646
  if (method === "GET" && rest.length === 0) {
2634
3647
  const rows = await deps.keyDb.outboundApiKeysList();
2635
- return writeJson(res, 200, { keys: rows.map(toKeyInfo) });
3648
+ const reader = deps.keySpendReader;
3649
+ if (!reader) return writeJson2(res, 200, { keys: rows.map(toKeyInfo) });
3650
+ const now = Date.now();
3651
+ const keys = await Promise.all(
3652
+ rows.map(async (row) => {
3653
+ const info = toKeyInfo(row);
3654
+ if (row.revokedAt === null) {
3655
+ const s = await reader.getSpend(row.id, now);
3656
+ info.spend = { dailyUsd: s.dailyUsd, weeklyUsd: s.weeklyUsd, totalUsd: s.totalUsd };
3657
+ }
3658
+ return info;
3659
+ })
3660
+ );
3661
+ return writeJson2(res, 200, { keys });
2636
3662
  }
2637
3663
  if (method === "POST" && rest.length === 0) {
2638
- const body = await readJsonBody(req);
3664
+ const body = await readJsonBody3(req);
2639
3665
  const name = typeof body["name"] === "string" && body["name"].trim() ? body["name"].trim() : "key";
2640
3666
  const created = await createNamedKey2(deps.keyDb, name);
2641
- return writeJson(res, 201, {
3667
+ return writeJson2(res, 201, {
2642
3668
  id: created.id,
2643
3669
  name: created.name,
2644
3670
  keyPrefix: created.keyPrefix,
@@ -2650,46 +3676,181 @@ async function handleKeys(req, res, method, rest, deps) {
2650
3676
  const action = rest[1];
2651
3677
  if (method === "POST" && id && action === "revoke") {
2652
3678
  const ok = await deps.keyDb.outboundApiKeysRevoke(id);
2653
- return writeJson(res, ok ? 200 : 404, { ok });
3679
+ return writeJson2(res, ok ? 200 : 404, { ok });
2654
3680
  }
2655
3681
  if (method === "POST" && id && action === "enabled") {
2656
- const body = await readJsonBody(req);
3682
+ const body = await readJsonBody3(req);
2657
3683
  const enabled = body["enabled"] === true;
2658
3684
  const ok = await deps.keyDb.outboundApiKeysSetEnabled(id, enabled);
2659
- return writeJson(res, ok ? 200 : 404, { ok, enabled });
3685
+ return writeJson2(res, ok ? 200 : 404, { ok, enabled });
3686
+ }
3687
+ if (method === "POST" && id && action === "max-concurrency") {
3688
+ const body = await readJsonBody3(req);
3689
+ const raw = body["maxConcurrency"];
3690
+ let value;
3691
+ if (raw === null) {
3692
+ value = null;
3693
+ } else if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 1e3) {
3694
+ value = raw;
3695
+ } else {
3696
+ return writeJsonError(
3697
+ res,
3698
+ 400,
3699
+ "maxConcurrency must be an integer 1..1000 or null"
3700
+ );
3701
+ }
3702
+ const ok = await deps.keyDb.outboundApiKeysSetMaxConcurrency(id, value);
3703
+ return writeJson2(res, ok ? 200 : 404, { ok, maxConcurrency: value });
3704
+ }
3705
+ if (method === "POST" && id && action === "policy") {
3706
+ const body = await readJsonBody3(req);
3707
+ const parsed = parseKeyPolicyBody(body);
3708
+ if (!parsed.ok) return writeJsonError(res, 400, parsed.message);
3709
+ const ok = await deps.keyDb.outboundApiKeysSetPolicy(id, parsed.policy);
3710
+ return writeJson2(res, ok ? 200 : 404, { ok });
2660
3711
  }
2661
3712
  return writeJsonError(res, 405, `method ${method} not allowed on keys`);
2662
3713
  }
3714
+ function validateQueueSegments(patch) {
3715
+ const errors = [];
3716
+ const checkNum = (label, value, min, max) => {
3717
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
3718
+ errors.push(`${label} must be a number ${min}..${max}`);
3719
+ }
3720
+ };
3721
+ const isPlainObject4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3722
+ const umq = patch.userMessageQueue;
3723
+ if (umq !== void 0) {
3724
+ if (!isPlainObject4(umq)) {
3725
+ errors.push("userMessageQueue must be an object");
3726
+ } else {
3727
+ if (typeof umq.enabled !== "boolean") {
3728
+ errors.push("userMessageQueue.enabled must be a boolean");
3729
+ }
3730
+ checkNum("userMessageQueue.delayMs", umq.delayMs, 0, 1e4);
3731
+ checkNum("userMessageQueue.waitTimeoutMs", umq.waitTimeoutMs, 1e3, 3e5);
3732
+ }
3733
+ }
3734
+ const cq = patch.concurrencyQueue;
3735
+ if (cq !== void 0) {
3736
+ if (!isPlainObject4(cq)) {
3737
+ errors.push("concurrencyQueue must be an object");
3738
+ } else {
3739
+ checkNum("concurrencyQueue.maxQueueSizeFactor", cq.maxQueueSizeFactor, 1, 10);
3740
+ checkNum("concurrencyQueue.minQueueSize", cq.minQueueSize, 1, 100);
3741
+ checkNum("concurrencyQueue.waitTimeoutMs", cq.waitTimeoutMs, 1e3, 3e5);
3742
+ }
3743
+ }
3744
+ const ah = patch.accountHealth;
3745
+ if (ah !== void 0) {
3746
+ if (!isPlainObject4(ah)) {
3747
+ errors.push("accountHealth must be an object");
3748
+ } else {
3749
+ if (typeof ah.overloadCooldownEnabled !== "boolean") {
3750
+ errors.push("accountHealth.overloadCooldownEnabled must be a boolean");
3751
+ }
3752
+ checkNum("accountHealth.overloadCooldownMs", ah.overloadCooldownMs, 6e4, 36e5);
3753
+ }
3754
+ }
3755
+ return errors;
3756
+ }
2663
3757
  async function handleServer(req, res, method, deps) {
2664
3758
  if (method === "GET") {
2665
- const config = await loadServerConfig(deps.settingsStore);
2666
- return writeJson(res, 200, { server: config });
3759
+ const config = await loadServerConfig2(deps.settingsStore);
3760
+ let server = config;
3761
+ if (config.proxy) server = { ...server, proxy: redactOutboundProxy(config.proxy) };
3762
+ if (config.webhook) server = { ...server, webhook: redactWebhookConfig(config.webhook) };
3763
+ if (config.billing) server = { ...server, billing: redactBillingConfig(config.billing) };
3764
+ return writeJson2(res, 200, { server });
2667
3765
  }
2668
3766
  if (method === "PUT") {
2669
- const patch = await readJsonBody(req);
2670
- const current = await loadServerConfig(deps.settingsStore);
2671
- const merged = mergeServerConfig(current, patch);
3767
+ const patch = await readJsonBody3(req);
3768
+ const queueErrors = validateQueueSegments(patch);
3769
+ if (queueErrors.length > 0) {
3770
+ return writeJsonError(res, 400, `invalid queue config: ${queueErrors.join("; ")}`);
3771
+ }
3772
+ const webhookErrors = validateWebhookSegment(patch);
3773
+ if (webhookErrors.length > 0) {
3774
+ return writeJsonError(res, 400, `invalid webhook config: ${webhookErrors.join("; ")}`);
3775
+ }
3776
+ const auditErrors = validateAuditSegment(patch);
3777
+ if (auditErrors.length > 0) {
3778
+ return writeJsonError(res, 400, `invalid audit config: ${auditErrors.join("; ")}`);
3779
+ }
3780
+ const billingErrors = validateBillingSegment(patch);
3781
+ if (billingErrors.length > 0) {
3782
+ return writeJsonError(res, 400, `invalid billing config: ${billingErrors.join("; ")}`);
3783
+ }
3784
+ const current = await loadServerConfig2(deps.settingsStore);
3785
+ let effectivePatch = patch;
3786
+ if (patch.proxy) {
3787
+ effectivePatch = { ...effectivePatch, proxy: preserveOutboundProxySecrets(patch.proxy, current.proxy) };
3788
+ }
3789
+ if (patch.webhook) {
3790
+ effectivePatch = { ...effectivePatch, webhook: preserveWebhookSecrets(patch.webhook, current.webhook) };
3791
+ }
3792
+ if (patch.billing) {
3793
+ effectivePatch = { ...effectivePatch, billing: preserveBillingSecret(patch.billing, current.billing) };
3794
+ }
3795
+ const merged = mergeServerConfig(current, effectivePatch);
2672
3796
  await saveServerConfig(deps.settingsStore, merged);
2673
- await deps.outboundApiServer.applyConfig({
2674
- enabled: merged.enabled,
2675
- networkBinding: merged.networkBinding,
2676
- endpoints: merged.endpoints,
2677
- port: merged.port
2678
- });
2679
- return writeJson(res, 200, { server: merged });
3797
+ setServerProxyConfig(merged.proxy);
3798
+ applyWebhookConfig(merged.webhook);
3799
+ applyAuditConfig(merged.audit);
3800
+ applyBillingConfig(merged.billing);
3801
+ if (merged.enabled) {
3802
+ const missing = validateServerModelConfig(merged);
3803
+ if (missing.length > 0) {
3804
+ if (deps.outboundApiServer.getStatus().running) {
3805
+ await deps.outboundApiServer.stop();
3806
+ }
3807
+ return writeJson2(res, 200, {
3808
+ server: merged,
3809
+ error: { code: "incomplete-model-config", missing }
3810
+ });
3811
+ }
3812
+ }
3813
+ try {
3814
+ await deps.outboundApiServer.applyConfig({
3815
+ enabled: merged.enabled,
3816
+ networkBinding: merged.networkBinding,
3817
+ endpoints: merged.endpoints,
3818
+ port: merged.port,
3819
+ userMessageQueue: merged.userMessageQueue,
3820
+ concurrencyQueue: merged.concurrencyQueue,
3821
+ // voucher-redemption #9: hot-apply the voucher flag so enabling the product
3822
+ // takes effect without a restart.
3823
+ voucher: merged.voucher
3824
+ });
3825
+ } catch (err5) {
3826
+ const missing = incompleteConfigMissing(err5);
3827
+ if (missing) {
3828
+ return writeJson2(res, 200, {
3829
+ server: merged,
3830
+ error: { code: "incomplete-model-config", missing }
3831
+ });
3832
+ }
3833
+ throw err5;
3834
+ }
3835
+ return writeJson2(res, 200, { server: merged });
2680
3836
  }
2681
3837
  return writeJsonError(res, 405, `method ${method} not allowed on server`);
2682
3838
  }
3839
+ function incompleteConfigMissing(err5) {
3840
+ if (typeof err5 !== "object" || err5 === null) return null;
3841
+ const missing = err5.missing;
3842
+ return Array.isArray(missing) ? missing : null;
3843
+ }
2683
3844
  async function handleAccounts(req, res, method, rest, deps) {
2684
3845
  if (method === "GET" && rest.length === 0) {
2685
3846
  const accounts = await deps.subscriptionAccounts.listAll();
2686
3847
  const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
2687
3848
  const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
2688
- return writeJson(res, 200, { accounts, providerAccounts, externalCli });
3849
+ return writeJson2(res, 200, { accounts, providerAccounts, externalCli });
2689
3850
  }
2690
3851
  if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
2691
3852
  const result = handleCodexOAuthStatus(rest[2], deps);
2692
- return writeJson(res, result.status, result.body);
3853
+ return writeJson2(res, result.status, result.body);
2693
3854
  }
2694
3855
  if (method === "PUT" || method === "POST" || method === "DELETE") {
2695
3856
  const providerId = asSubscriptionProviderId(rest[0]);
@@ -2698,15 +3859,15 @@ async function handleAccounts(req, res, method, rest, deps) {
2698
3859
  }
2699
3860
  if (method === "POST" && rest[1] === "oauth" && rest[2] === "start") {
2700
3861
  const result = providerId === "codex" ? handleCodexOAuthStart(deps) : handleOAuthStart(providerId, deps);
2701
- return writeJson(res, result.status, result.body);
3862
+ return writeJson2(res, result.status, result.body);
2702
3863
  }
2703
3864
  if (method === "POST" && rest[1] === "oauth" && rest[2] === "complete") {
2704
- const body2 = await readJsonBody(req);
3865
+ const body2 = await readJsonBody3(req);
2705
3866
  const result = await handleOAuthComplete(providerId, body2, deps);
2706
- return writeJson(res, result.status, result.body);
3867
+ return writeJson2(res, result.status, result.body);
2707
3868
  }
2708
3869
  if (method === "POST" && rest[1] === "accounts") {
2709
- const body2 = await readJsonBody(req);
3870
+ const body2 = await readJsonBody3(req);
2710
3871
  const block = validateTokenBody(providerId, body2);
2711
3872
  if (!block) {
2712
3873
  return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
@@ -2714,79 +3875,113 @@ async function handleAccounts(req, res, method, rest, deps) {
2714
3875
  const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2715
3876
  await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
2716
3877
  const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2717
- return writeJson(res, 200, status2 ? { account: status2 } : { ok: true });
3878
+ return writeJson2(res, 200, status2 ? { account: status2 } : { ok: true });
2718
3879
  }
2719
3880
  if (method === "POST" && rest[1] === "import-external") {
2720
3881
  if (providerId !== "claude" && providerId !== "codex") {
2721
3882
  return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
2722
3883
  }
2723
- const body2 = await readJsonBody(req);
3884
+ const body2 = await readJsonBody3(req);
2724
3885
  const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2725
3886
  const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
2726
3887
  if (!result.ok) {
2727
3888
  return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
2728
3889
  }
2729
3890
  const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2730
- return writeJson(res, 200, { ok: true, account: status2 ?? void 0 });
3891
+ return writeJson2(res, 200, { ok: true, account: status2 ?? void 0 });
2731
3892
  }
2732
3893
  if (method === "POST" && rest[1] === "refresh") {
2733
3894
  if (providerId === "opencodego") {
2734
3895
  return writeJsonError(res, 400, "opencodego credentials are not refreshable");
2735
3896
  }
2736
- const writer = deps.subscriptionTokenWriter;
2737
- const ok = providerId === "claude" ? await writer.refreshClaudeToken() : providerId === "codex" ? await writer.refreshCodexToken() : await writer.refreshGeminiToken();
3897
+ const writer2 = deps.subscriptionTokenWriter;
3898
+ const ok = providerId === "claude" ? await writer2.refreshClaudeToken() : providerId === "codex" ? await writer2.refreshCodexToken() : await writer2.refreshGeminiToken();
2738
3899
  const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2739
- return writeJson(res, 200, { ok, account: status2 ?? void 0 });
3900
+ return writeJson2(res, 200, { ok, account: status2 ?? void 0 });
2740
3901
  }
2741
3902
  if (method === "POST" && rest[2] === "label") {
2742
3903
  const accountId = rest[1];
2743
- const body2 = await readJsonBody(req);
3904
+ const body2 = await readJsonBody3(req);
2744
3905
  const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
2745
3906
  const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
2746
3907
  if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
2747
- return writeJson(res, 200, { ok: true });
3908
+ return writeJson2(res, 200, { ok: true });
3909
+ }
3910
+ if (method === "POST" && rest[2] === "priority") {
3911
+ const accountId = rest[1];
3912
+ const body2 = await readJsonBody3(req);
3913
+ const raw = body2["priority"];
3914
+ const priority = typeof raw === "number" ? raw : Number(raw);
3915
+ if (!Number.isFinite(priority)) {
3916
+ return writeJsonError(res, 400, "priority must be a finite number");
3917
+ }
3918
+ const result = await deps.subscriptionTokenWriter.setAccountPriority(providerId, accountId, priority);
3919
+ if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
3920
+ return writeJson2(res, 200, { ok: true });
3921
+ }
3922
+ if (method === "POST" && rest[2] === "proxy") {
3923
+ const accountId = rest[1];
3924
+ const body2 = await readJsonBody3(req);
3925
+ const rawProxy = body2["proxy"];
3926
+ let proxy;
3927
+ if (rawProxy !== null && rawProxy !== void 0) {
3928
+ proxy = normalizeProxyConfig(rawProxy);
3929
+ if (!proxy) return writeJsonError(res, 400, "invalid proxy config");
3930
+ }
3931
+ const result = await deps.subscriptionTokenWriter.setAccountProxy(providerId, accountId, proxy);
3932
+ if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
3933
+ return writeJson2(res, 200, { ok: true });
3934
+ }
3935
+ if (method === "POST" && rest[2] === "supported-models") {
3936
+ const accountId = rest[1];
3937
+ const body2 = await readJsonBody3(req);
3938
+ const parsed = validateSupportedModelsBody(body2["supportedModels"]);
3939
+ if (!parsed.ok) return writeJsonError(res, 400, "invalid supportedModels");
3940
+ const result = await deps.subscriptionTokenWriter.setAccountSupportedModels(providerId, accountId, parsed.value);
3941
+ if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
3942
+ return writeJson2(res, 200, { ok: true });
2748
3943
  }
2749
3944
  if (method === "PUT" && rest[1] === "active") {
2750
- const body2 = await readJsonBody(req);
3945
+ const body2 = await readJsonBody3(req);
2751
3946
  const id = typeof body2["id"] === "string" ? body2["id"] : "";
2752
3947
  if (!id) return writeJsonError(res, 400, "active switch requires { id }");
2753
3948
  const result = await deps.subscriptionTokenWriter.setActiveAccount(providerId, id);
2754
3949
  if (!result.ok) return writeJsonError(res, 404, `account '${id}' not found`);
2755
- return writeJson(res, 200, { ok: true });
3950
+ return writeJson2(res, 200, { ok: true });
2756
3951
  }
2757
3952
  if (method === "DELETE" && rest.length >= 2) {
2758
3953
  const accountId = rest[1];
2759
3954
  const result = await deps.subscriptionTokenWriter.removeAccount(providerId, accountId);
2760
3955
  if (!result.removed) return writeJsonError(res, 404, `account '${accountId}' not found`);
2761
- return writeJson(res, 200, { ok: true });
3956
+ return writeJson2(res, 200, { ok: true });
2762
3957
  }
2763
3958
  if (method === "DELETE") {
2764
3959
  await deps.subscriptionTokenWriter.clearProvider(providerId);
2765
- return writeJson(res, 200, { ok: true });
3960
+ return writeJson2(res, 200, { ok: true });
2766
3961
  }
2767
- const body = await readJsonBody(req);
3962
+ const body = await readJsonBody3(req);
2768
3963
  const config = validateTokenBody(providerId, body);
2769
3964
  if (!config) {
2770
3965
  return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
2771
3966
  }
2772
3967
  await deps.subscriptionTokenWriter.writeProviderTokens(providerId, config);
2773
3968
  const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
2774
- return writeJson(res, 200, status ? { account: status } : { ok: true });
3969
+ return writeJson2(res, 200, status ? { account: status } : { ok: true });
2775
3970
  }
2776
3971
  return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
2777
3972
  }
2778
3973
  async function handleCli(req, res, method, rest, deps) {
2779
3974
  if (method === "GET" && rest.length === 0) {
2780
3975
  const result = handleCliList(process.platform, deps.cliPathProbe);
2781
- return writeJson(res, result.status, result.body);
3976
+ return writeJson2(res, result.status, result.body);
2782
3977
  }
2783
3978
  if (method === "GET" && rest[0] === "sessions") {
2784
3979
  const result = handleCliSessions();
2785
- return writeJson(res, result.status, result.body);
3980
+ return writeJson2(res, result.status, result.body);
2786
3981
  }
2787
3982
  if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
2788
3983
  const result = handleCliStop(rest[1]);
2789
- return writeJson(res, result.status, result.body);
3984
+ return writeJson2(res, result.status, result.body);
2790
3985
  }
2791
3986
  if (method === "POST" && rest[1] === "install") {
2792
3987
  const cli = rest[0];
@@ -2794,14 +3989,14 @@ async function handleCli(req, res, method, rest, deps) {
2794
3989
  return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2795
3990
  }
2796
3991
  const result = await handleCliInstall(cli, deps.cliCommandRunner);
2797
- return writeJson(res, result.status, result.body);
3992
+ return writeJson2(res, result.status, result.body);
2798
3993
  }
2799
3994
  if (method === "POST" && rest[1] === "launch") {
2800
3995
  const cli = rest[0];
2801
3996
  if (!isLaunchCliId(cli)) {
2802
3997
  return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2803
3998
  }
2804
- const body = await readJsonBody(req);
3999
+ const body = await readJsonBody3(req);
2805
4000
  const providers = loadConfig(deps.configPath).providers ?? [];
2806
4001
  const result = await handleCliLaunch(cli, body, {
2807
4002
  llmConfig: deps.llmConfig,
@@ -2809,20 +4004,28 @@ async function handleCli(req, res, method, rest, deps) {
2809
4004
  opener: deps.cliTerminalOpener,
2810
4005
  probe: deps.cliPathProbe
2811
4006
  });
2812
- return writeJson(res, result.status, result.body);
4007
+ return writeJson2(res, result.status, result.body);
2813
4008
  }
2814
4009
  return writeJsonError(res, 405, `method ${method} not allowed on cli`);
2815
4010
  }
2816
4011
  async function handleStatus(res, method, deps) {
2817
4012
  if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
2818
4013
  const status = deps.outboundApiServer.getStatus();
2819
- const serverConfig = await loadServerConfig(deps.settingsStore);
2820
- const endpoints = serverConfig.endpoints.map((e) => ({
2821
- endpoint: e.endpoint,
2822
- model: e.defaultModel,
2823
- useSubscription: e.useSubscription
2824
- }));
2825
- return writeJson(res, 200, { ...status, endpoints });
4014
+ const serverConfig = await loadServerConfig2(deps.settingsStore);
4015
+ const endpoints = serverConfig.endpoints.map((e) => {
4016
+ if (isKindMappedEndpoint(e.endpoint)) {
4017
+ return { endpoint: e.endpoint, kinds: e.modelMap ?? {}, useSubscription: e.useSubscription };
4018
+ }
4019
+ if (e.endpoint === "chat") {
4020
+ return { endpoint: e.endpoint, models: e.models ?? [], useSubscription: e.useSubscription };
4021
+ }
4022
+ return { endpoint: e.endpoint, model: e.defaultModel ?? "", useSubscription: e.useSubscription };
4023
+ });
4024
+ if (status.running) {
4025
+ const queueStatus = deps.outboundApiServer.getQueueStatus();
4026
+ return writeJson2(res, 200, { ...status, endpoints, queueStatus });
4027
+ }
4028
+ return writeJson2(res, 200, { ...status, endpoints });
2826
4029
  }
2827
4030
  function resolvePlaygroundPath(endpoint, body) {
2828
4031
  switch (endpoint) {
@@ -2842,7 +4045,7 @@ function resolvePlaygroundPath(endpoint, body) {
2842
4045
  }
2843
4046
  async function handlePlayground(req, res, method, deps) {
2844
4047
  if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on playground`);
2845
- const body = await readJsonBody(req);
4048
+ const body = await readJsonBody3(req);
2846
4049
  const endpoint = typeof body["endpoint"] === "string" ? body["endpoint"] : "";
2847
4050
  const key = typeof body["key"] === "string" ? body["key"] : "";
2848
4051
  const payload = body["body"];
@@ -2987,10 +4190,12 @@ async function handleUiStatic(req, res, urlPath, uiDist) {
2987
4190
  return true;
2988
4191
  }
2989
4192
 
4193
+ // src/admin/version.ts
4194
+ var DAEMON_VERSION = true ? "0.1.3" : "0.0.0-dev";
4195
+
2990
4196
  // src/admin/AdminServer.ts
2991
4197
  var LOOPBACK_ADDR = "127.0.0.1";
2992
4198
  var LAN_ADDR = "0.0.0.0";
2993
- var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
2994
4199
  var AdminServer = class {
2995
4200
  constructor(deps) {
2996
4201
  this.deps = deps;
@@ -3011,7 +4216,7 @@ var AdminServer = class {
3011
4216
  const cfg = this.deps.getAdminConfig();
3012
4217
  if (!cfg.enabled) return 0;
3013
4218
  if (cfg.networkBinding && !cfg.token) {
3014
- console.error(
4219
+ this.deps.logger.error(
3015
4220
  "[AdminServer] REFUSING to bind: admin.networkBinding (LAN/0.0.0.0) requires a non-empty admin.token. Set admin.token in config.json or disable networkBinding. Dashboard stays DOWN (fail closed)."
3016
4221
  );
3017
4222
  return 0;
@@ -3020,7 +4225,7 @@ var AdminServer = class {
3020
4225
  const actualPort = await this.listen(bindAddr, cfg.port);
3021
4226
  this.boundAddr = bindAddr;
3022
4227
  this.boundPort = actualPort;
3023
- console.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
4228
+ this.deps.logger.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
3024
4229
  return actualPort;
3025
4230
  }
3026
4231
  /** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
@@ -3042,7 +4247,7 @@ var AdminServer = class {
3042
4247
  const addr = server.address();
3043
4248
  if (addr && typeof addr === "object") {
3044
4249
  server.removeListener("error", onError);
3045
- server.on("error", (e) => console.error("[AdminServer] server error", e));
4250
+ server.on("error", (e) => this.deps.logger.error("[AdminServer] server error", e));
3046
4251
  this.server = server;
3047
4252
  resolve(addr.port);
3048
4253
  } else {
@@ -3055,7 +4260,7 @@ var AdminServer = class {
3055
4260
  onRequest(req, res) {
3056
4261
  void this.dispatch(req, res).catch((err5) => {
3057
4262
  const message = err5 instanceof Error ? err5.message : String(err5);
3058
- console.error("[AdminServer] unhandled error:", message);
4263
+ this.deps.logger.error("[AdminServer] unhandled error:", message);
3059
4264
  if (!res.headersSent) {
3060
4265
  res.writeHead(500, { "Content-Type": "application/json" });
3061
4266
  res.end(JSON.stringify({ error: { type: "admin_error", message } }));
@@ -3066,18 +4271,42 @@ var AdminServer = class {
3066
4271
  const cfg = this.deps.getAdminConfig();
3067
4272
  res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
3068
4273
  res.setHeader("x-omnicross-pid", String(process.pid));
4274
+ const url = req.url ?? "/";
4275
+ const path2 = url.split("?")[0];
4276
+ const healthPath = path2.replace(/\/+$/, "") || "/";
4277
+ if ((req.method === "GET" || req.method === "HEAD") && (healthPath === "/health" || healthPath === "/healthz")) {
4278
+ const report = this.deps.getHealthReport();
4279
+ const code = healthHttpStatus(report.status);
4280
+ res.writeHead(code, { "Content-Type": "application/json" });
4281
+ res.end(req.method === "HEAD" ? void 0 : JSON.stringify(report));
4282
+ return;
4283
+ }
3069
4284
  if (cfg.token && !this.isAuthorized(req, cfg.token)) {
3070
4285
  res.writeHead(401, { "Content-Type": "application/json" });
3071
4286
  res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
3072
4287
  return;
3073
4288
  }
3074
- const url = req.url ?? "/";
3075
- const path2 = url.split("?")[0];
3076
4289
  if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
3077
4290
  res.writeHead(302, { Location: "/ui/" });
3078
4291
  res.end();
3079
4292
  return;
3080
4293
  }
4294
+ if (path2 === "/admin/api/account-probes" && (req.method === "GET" || req.method === "HEAD")) {
4295
+ handleAccountProbes(res, this.deps.probeHistoryReader);
4296
+ return;
4297
+ }
4298
+ if (path2 === "/admin/api/audit" && (req.method === "GET" || req.method === "HEAD")) {
4299
+ handleAuditQuery(req, res, this.deps.auditReader);
4300
+ return;
4301
+ }
4302
+ if (path2 === "/admin/api/billing-status" && (req.method === "GET" || req.method === "HEAD")) {
4303
+ handleBillingStatus(res, this.deps.billingStatusReader);
4304
+ return;
4305
+ }
4306
+ if (path2 === "/admin/api/webhook-test" && req.method === "POST") {
4307
+ await handleWebhookTest(req, res);
4308
+ return;
4309
+ }
3081
4310
  if (path2.startsWith("/admin/api/")) {
3082
4311
  await handleAdminApi(req, res, path2, this.deps);
3083
4312
  return;
@@ -3121,6 +4350,51 @@ function constantTimeEquals(a, b) {
3121
4350
  return timingSafeEqual(bufA, bufB);
3122
4351
  }
3123
4352
 
4353
+ // src/admin/health.ts
4354
+ var CRITICAL_CHECKS = ["config", "credentialStore"];
4355
+ var READINESS_CHECKS = ["outboundServer"];
4356
+ function safeBool(fn) {
4357
+ try {
4358
+ return fn() === true;
4359
+ } catch {
4360
+ return false;
4361
+ }
4362
+ }
4363
+ function toMb(bytes) {
4364
+ return Math.round(bytes / (1024 * 1024) * 10) / 10;
4365
+ }
4366
+ function buildHealthReport(deps) {
4367
+ const checks = {
4368
+ config: safeBool(deps.configPresent),
4369
+ credentialStore: safeBool(deps.credentialStoreReadable),
4370
+ outboundServer: safeBool(deps.outboundServerRunning),
4371
+ adminServer: safeBool(deps.adminServerRunning)
4372
+ };
4373
+ if (deps.subscriptionAccountsHealthy) {
4374
+ let probeHealthy;
4375
+ try {
4376
+ probeHealthy = deps.subscriptionAccountsHealthy();
4377
+ } catch {
4378
+ probeHealthy = false;
4379
+ }
4380
+ if (probeHealthy !== void 0) checks.subscriptionAccountsHealthy = probeHealthy;
4381
+ }
4382
+ const criticalOk = CRITICAL_CHECKS.every((k) => checks[k]);
4383
+ const readinessOk = READINESS_CHECKS.every((k) => checks[k]);
4384
+ const status = !criticalOk ? "error" : readinessOk ? "ok" : "degraded";
4385
+ const mem = (deps.memoryUsage ?? process.memoryUsage)();
4386
+ const uptime = (deps.uptimeSeconds ?? process.uptime)();
4387
+ const nowMs = (deps.now ?? Date.now)();
4388
+ return {
4389
+ status,
4390
+ version: deps.version,
4391
+ uptimeSeconds: Math.floor(uptime),
4392
+ timestamp: new Date(nowMs).toISOString(),
4393
+ memory: { rssMb: toMb(mem.rss), heapUsedMb: toMb(mem.heapUsed) },
4394
+ checks
4395
+ };
4396
+ }
4397
+
3124
4398
  // src/admin/oauthSessions.ts
3125
4399
  import crypto2 from "crypto";
3126
4400
  var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
@@ -3452,46 +4726,199 @@ function toLLMProvider(row) {
3452
4726
  };
3453
4727
  }
3454
4728
 
3455
- // src/ports/ConsoleLogger.ts
3456
- var ConsoleLogger = class {
4729
+ // src/ports/ConfigurableLogger.ts
4730
+ import { createWriteStream } from "fs";
4731
+ var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
4732
+ var RESERVED_JSON_KEYS = /* @__PURE__ */ new Set(["ts", "level", "msg", "error"]);
4733
+ var ConfigurableLogger = class {
4734
+ threshold;
4735
+ format;
4736
+ filePath;
4737
+ fileStream = null;
4738
+ fileDisabled = false;
4739
+ constructor(cfg) {
4740
+ this.threshold = LEVEL_ORDER[cfg?.level ?? "debug"];
4741
+ this.format = cfg?.format ?? "text";
4742
+ this.filePath = cfg?.file && cfg.file.length > 0 ? cfg.file : void 0;
4743
+ }
3457
4744
  info(message, meta) {
3458
- if (meta === void 0) console.info(message);
3459
- else console.info(message, meta);
4745
+ this.emit("info", message, void 0, meta);
3460
4746
  }
3461
4747
  warn(message, meta) {
3462
- if (meta === void 0) console.warn(message);
3463
- else console.warn(message, meta);
4748
+ this.emit("warn", message, void 0, meta);
3464
4749
  }
3465
4750
  error(message, error, meta) {
3466
- if (error === void 0 && meta === void 0) console.error(message);
3467
- else if (meta === void 0) console.error(message, error);
3468
- else console.error(message, error, meta);
4751
+ this.emit("error", message, error, meta);
3469
4752
  }
3470
4753
  debug(message, meta) {
3471
- if (meta === void 0) console.debug(message);
3472
- else console.debug(message, meta);
4754
+ this.emit("debug", message, void 0, meta);
4755
+ }
4756
+ /**
4757
+ * Flush + close the file sink (tests / graceful shutdown). Resolves once the
4758
+ * append stream has finished flushing to disk. No-op when no file sink is open.
4759
+ */
4760
+ close() {
4761
+ const stream = this.fileStream;
4762
+ this.fileStream = null;
4763
+ if (!stream) return Promise.resolve();
4764
+ return new Promise((resolve) => stream.end(() => resolve()));
4765
+ }
4766
+ emit(level, message, error, meta) {
4767
+ if (LEVEL_ORDER[level] > this.threshold) return;
4768
+ this.writeConsole(level, message, error, meta);
4769
+ if (this.filePath) this.writeFile(level, message, error, meta);
4770
+ }
4771
+ /**
4772
+ * Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
4773
+ * EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
4774
+ * byte drop-in; in `json` format it prints the structured line.
4775
+ */
4776
+ writeConsole(level, message, error, meta) {
4777
+ if (this.format === "json") {
4778
+ this.consoleFn(level)(this.jsonLine(level, message, error, meta));
4779
+ return;
4780
+ }
4781
+ if (level === "error") {
4782
+ if (error === void 0 && meta === void 0) console.error(message);
4783
+ else if (meta === void 0) console.error(message, error);
4784
+ else console.error(message, error, meta);
4785
+ return;
4786
+ }
4787
+ const fn = this.consoleFn(level);
4788
+ if (meta === void 0) fn(message);
4789
+ else fn(message, meta);
4790
+ }
4791
+ /** Append one line to the file sink; a failure disables the sink (swallowed). */
4792
+ writeFile(level, message, error, meta) {
4793
+ const stream = this.getFileStream();
4794
+ if (!stream) return;
4795
+ try {
4796
+ const line = this.format === "json" ? this.jsonLine(level, message, error, meta) : this.textLine(level, message, error, meta);
4797
+ stream.write(line + "\n");
4798
+ } catch {
4799
+ }
4800
+ }
4801
+ /** Lazily open the append-only file stream; disable the sink on any error. */
4802
+ getFileStream() {
4803
+ if (this.fileDisabled || !this.filePath) return null;
4804
+ if (this.fileStream) return this.fileStream;
4805
+ try {
4806
+ const stream = createWriteStream(this.filePath, { flags: "a" });
4807
+ stream.on("error", () => {
4808
+ this.fileDisabled = true;
4809
+ this.fileStream = null;
4810
+ });
4811
+ this.fileStream = stream;
4812
+ return stream;
4813
+ } catch {
4814
+ this.fileDisabled = true;
4815
+ return null;
4816
+ }
4817
+ }
4818
+ consoleFn(level) {
4819
+ switch (level) {
4820
+ case "error":
4821
+ return console.error;
4822
+ case "warn":
4823
+ return console.warn;
4824
+ case "info":
4825
+ return console.info;
4826
+ case "debug":
4827
+ return console.debug;
4828
+ }
4829
+ }
4830
+ /** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
4831
+ jsonLine(level, message, error, meta) {
4832
+ const obj = {
4833
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4834
+ level,
4835
+ msg: message
4836
+ };
4837
+ if (error !== void 0) obj["error"] = reduceError(error);
4838
+ if (meta !== void 0) {
4839
+ if (meta instanceof Error) obj["meta"] = reduceError(meta);
4840
+ else if (meta && typeof meta === "object") {
4841
+ for (const [k, v] of Object.entries(meta)) {
4842
+ if (!RESERVED_JSON_KEYS.has(k)) obj[k] = v;
4843
+ }
4844
+ } else obj["meta"] = meta;
4845
+ }
4846
+ try {
4847
+ return JSON.stringify(obj);
4848
+ } catch {
4849
+ return JSON.stringify({ ts: obj["ts"], level, msg: message });
4850
+ }
4851
+ }
4852
+ /** Human-readable file line: `ISO [level] message {metaJson}`. */
4853
+ textLine(level, message, error, meta) {
4854
+ const parts = [`${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}`];
4855
+ if (error !== void 0) parts.push(safeStringify(reduceError(error)));
4856
+ if (meta !== void 0) parts.push(safeStringify(meta instanceof Error ? reduceError(meta) : meta));
4857
+ return parts.join(" ");
3473
4858
  }
3474
4859
  };
4860
+ function reduceError(error) {
4861
+ if (error instanceof Error) {
4862
+ return error.stack ? { message: error.message, stack: error.stack } : { message: error.message };
4863
+ }
4864
+ return { value: String(error) };
4865
+ }
4866
+ function safeStringify(value) {
4867
+ try {
4868
+ return typeof value === "string" ? value : JSON.stringify(value);
4869
+ } catch {
4870
+ return "[unserializable]";
4871
+ }
4872
+ }
3475
4873
 
3476
4874
  // src/ports/JsonApiServerSettingsStore.ts
3477
4875
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
3478
4876
  import { OUTBOUND_API_SERVER_CONFIG_KEY } from "@omnicross/core/outbound-api";
3479
4877
  var JsonApiServerSettingsStore = class {
3480
- constructor(configPath) {
4878
+ /**
4879
+ * @param configPath the daemon config.json whose `server` field is backed.
4880
+ * @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
4881
+ * `server.proxy.*` passwords are encrypted-on-`set` /
4882
+ * decrypted-on-`get` (the settings-store path is otherwise not
4883
+ * secret-aware — every OTHER server field is non-secret). Null
4884
+ * ⇒ passthrough (legacy/pure tests unchanged).
4885
+ */
4886
+ constructor(configPath, box = null) {
3481
4887
  this.configPath = configPath;
4888
+ this.box = box;
3482
4889
  }
3483
4890
  configPath;
4891
+ box;
3484
4892
  async get(key) {
3485
4893
  if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return void 0;
3486
4894
  const file = this.readFile();
3487
- return file.server ?? void 0;
4895
+ if (file.server === void 0) return void 0;
4896
+ return this.decryptSecrets(file.server);
3488
4897
  }
3489
4898
  async set(key, value) {
3490
4899
  if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return;
3491
4900
  const file = this.readFile();
3492
- file.server = value;
4901
+ file.server = this.encryptSecrets(value);
3493
4902
  writeFileSync4(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
3494
4903
  }
4904
+ /** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
4905
+ encryptSecrets(config) {
4906
+ if (!this.box) return config;
4907
+ let out = config;
4908
+ if (out?.proxy) out = { ...out, proxy: encryptProxySegment(out.proxy, this.box) };
4909
+ if (out?.webhook) out = { ...out, webhook: encryptWebhookSegment(out.webhook, this.box) };
4910
+ if (out?.billing) out = { ...out, billing: encryptBillingSegment(out.billing, this.box) };
4911
+ return out;
4912
+ }
4913
+ /** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
4914
+ decryptSecrets(config) {
4915
+ if (!this.box) return config;
4916
+ let out = config;
4917
+ if (out?.proxy) out = { ...out, proxy: decryptProxySegment(out.proxy, this.box) };
4918
+ if (out?.webhook) out = { ...out, webhook: decryptWebhookSegment(out.webhook, this.box) };
4919
+ if (out?.billing) out = { ...out, billing: decryptBillingSegment(out.billing, this.box) };
4920
+ return out;
4921
+ }
3495
4922
  /** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
3496
4923
  readFile() {
3497
4924
  try {
@@ -3610,6 +5037,57 @@ var JsonlUsageEventStore = class {
3610
5037
  }
3611
5038
  return Array.from(groups.values());
3612
5039
  }
5040
+ /**
5041
+ * ONE pass over a single key's events (`ts < endTs`) summing its `costUsd` into
5042
+ * `totalUsd` / `dailyUsd` (`ts >= dayStartTs`) / `weeklyUsd` (`ts >= weekStartTs`).
5043
+ * Used to lazily seed the outbound key-policy spend tracker (once per key). A
5044
+ * key with no attributed events yields all zeros.
5045
+ */
5046
+ async getSpendByKey(query) {
5047
+ let totalUsd = 0;
5048
+ let dailyUsd = 0;
5049
+ let weeklyUsd = 0;
5050
+ for (const row of this.readRows({ startTs: 0, endTs: query.endTs })) {
5051
+ if (row.apiKeyId !== query.apiKeyId) continue;
5052
+ totalUsd += row.costUsd;
5053
+ if (row.ts >= query.dayStartTs) dailyUsd += row.costUsd;
5054
+ if (row.ts >= query.weekStartTs) weeklyUsd += row.costUsd;
5055
+ }
5056
+ return { totalUsd, dailyUsd, weeklyUsd };
5057
+ }
5058
+ /**
5059
+ * Time-series aggregation over LOCAL-time bucket boundaries. Every bucket in
5060
+ * `[floor(startTs), endTs)` is present (empty ones zero-filled), ascending by
5061
+ * `bucketStartTs`; an empty range (`startTs >= endTs`) returns `[]`. Reuses
5062
+ * `readRows` so malformed lines are skipped and only in-range rows contribute.
5063
+ */
5064
+ async getTimeSeries(range, bucket) {
5065
+ if (range.startTs >= range.endTs) return [];
5066
+ const buckets = /* @__PURE__ */ new Map();
5067
+ for (let b = floorToBucket(range.startTs, bucket); b < range.endTs; b = nextBoundary(b, bucket)) {
5068
+ buckets.set(b, {
5069
+ bucketStartTs: b,
5070
+ label: bucketLabel(b, bucket),
5071
+ requests: 0,
5072
+ inputTokens: 0,
5073
+ outputTokens: 0,
5074
+ cacheReadTokens: 0,
5075
+ cacheCreationTokens: 0,
5076
+ costUsd: 0
5077
+ });
5078
+ }
5079
+ for (const row of this.readRows(range)) {
5080
+ const g = buckets.get(floorToBucket(row.ts, bucket));
5081
+ if (!g) continue;
5082
+ g.requests += 1;
5083
+ g.inputTokens += row.inputTokens;
5084
+ g.outputTokens += row.outputTokens;
5085
+ g.cacheReadTokens += row.cacheReadTokens;
5086
+ g.cacheCreationTokens += row.cacheCreationTokens;
5087
+ g.costUsd += row.costUsd;
5088
+ }
5089
+ return Array.from(buckets.values());
5090
+ }
3613
5091
  async getMessagesForSession(sessionId) {
3614
5092
  return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
3615
5093
  id: r.id,
@@ -3678,6 +5156,43 @@ var JsonlUsageEventStore = class {
3678
5156
  return rows;
3679
5157
  }
3680
5158
  };
5159
+ function floorToBucket(ts, bucket) {
5160
+ const d = new Date(ts);
5161
+ switch (bucket) {
5162
+ case "hour":
5163
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
5164
+ case "day":
5165
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
5166
+ case "month":
5167
+ return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
5168
+ }
5169
+ }
5170
+ function nextBoundary(ts, bucket) {
5171
+ const d = new Date(ts);
5172
+ switch (bucket) {
5173
+ case "hour":
5174
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours() + 1).getTime();
5175
+ case "day":
5176
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
5177
+ case "month":
5178
+ return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
5179
+ }
5180
+ }
5181
+ var pad2 = (n) => String(n).padStart(2, "0");
5182
+ function bucketLabel(bucketStartTs, bucket) {
5183
+ const d = new Date(bucketStartTs);
5184
+ const y = d.getFullYear();
5185
+ const mo = pad2(d.getMonth() + 1);
5186
+ const day = pad2(d.getDate());
5187
+ switch (bucket) {
5188
+ case "hour":
5189
+ return `${mo}-${day} ${pad2(d.getHours())}:00`;
5190
+ case "day":
5191
+ return `${y}-${mo}-${day}`;
5192
+ case "month":
5193
+ return `${y}-${mo}`;
5194
+ }
5195
+ }
3681
5196
  var NUMERIC_FIELDS = [
3682
5197
  "ts",
3683
5198
  "inputTokens",
@@ -3832,46 +5347,141 @@ var JsonPricingStore = class {
3832
5347
  }
3833
5348
  };
3834
5349
 
3835
- // src/ports/JsonSubscriptionCredentialStore.ts
3836
- import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
3837
- import { dirname as dirname4 } from "path";
3838
- import {
3839
- claudeOAuth as claudeOAuth2,
3840
- codexOAuth as codexOAuth2,
3841
- geminiOAuth as geminiOAuth2
3842
- } from "@omnicross/subscriptions";
3843
-
3844
- // src/ports/account-sync.ts
3845
- var IMPORT_EXPIRY_MARGIN_MS = 6e4;
3846
- function viewOf(tokens) {
3847
- return tokens;
3848
- }
3849
- function decideExternalImport(captured, external, now = Date.now()) {
3850
- if (!external?.accessToken) return "no-credential";
3851
- const capturedRt = viewOf(captured).refreshToken;
3852
- const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
3853
- const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
3854
- return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
3855
- }
3856
- function buildImportedTokens(captured, external) {
3857
- const imported = {
3858
- ...captured,
3859
- accessToken: external.accessToken,
3860
- status: "authorized",
3861
- errorMessage: void 0,
3862
- syncWarning: void 0,
3863
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3864
- };
3865
- if (external.refreshToken) imported.refreshToken = external.refreshToken;
3866
- if (external.expiresAt) imported.expiresAt = external.expiresAt;
3867
- else delete imported.expiresAt;
3868
- if (external.idToken) imported.idToken = external.idToken;
3869
- if (external.scopes) imported.scopes = external.scopes;
3870
- return imported;
3871
- }
3872
- function buildTokensFromExternal(provider, external) {
3873
- const base = {
3874
- authMethod: "oauth",
5350
+ // src/ports/JsonVoucherDb.ts
5351
+ import { existsSync as existsSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
5352
+ var JsonVoucherDb = class {
5353
+ constructor(vouchersPath) {
5354
+ this.vouchersPath = vouchersPath;
5355
+ }
5356
+ vouchersPath;
5357
+ async voucherCreate(input) {
5358
+ const rows = this.readRows();
5359
+ const row = {
5360
+ id: input.id,
5361
+ codeHash: input.codeHash,
5362
+ codePrefix: input.codePrefix,
5363
+ type: input.type,
5364
+ status: "unredeemed",
5365
+ createdAt: input.createdAt ?? Date.now()
5366
+ };
5367
+ if (input.creditUsd != null) row.creditUsd = input.creditUsd;
5368
+ if (input.renewalDays != null) row.renewalDays = input.renewalDays;
5369
+ if (input.maxTotalCostLimitUsd != null) row.maxTotalCostLimitUsd = input.maxTotalCostLimitUsd;
5370
+ if (input.maxExpiryDays != null) row.maxExpiryDays = input.maxExpiryDays;
5371
+ rows.push(row);
5372
+ this.writeRows(rows);
5373
+ return row;
5374
+ }
5375
+ async voucherGetByHash(codeHash) {
5376
+ const rows = this.readRows();
5377
+ return rows.find((r) => r.codeHash === codeHash) ?? null;
5378
+ }
5379
+ async voucherRedeemCas(id, keyId, granted, now) {
5380
+ const rows = this.readRows();
5381
+ const row = rows.find((r) => r.id === id);
5382
+ if (!row || row.status !== "unredeemed") return false;
5383
+ row.status = "redeemed";
5384
+ row.redeemedAt = now;
5385
+ row.redeemedByKeyId = keyId;
5386
+ row.grantApplied = false;
5387
+ if (granted.totalCostLimitUsd != null) row.grantedTotalCostLimitUsd = granted.totalCostLimitUsd;
5388
+ if (granted.expiresAt != null) row.grantedExpiresAt = granted.expiresAt;
5389
+ this.writeRows(rows);
5390
+ return true;
5391
+ }
5392
+ async voucherMarkGrantApplied(id) {
5393
+ const rows = this.readRows();
5394
+ const row = rows.find((r) => r.id === id);
5395
+ if (!row || row.status !== "redeemed") return false;
5396
+ if (row.grantApplied === true) return true;
5397
+ row.grantApplied = true;
5398
+ this.writeRows(rows);
5399
+ return true;
5400
+ }
5401
+ async voucherRevertRedeem(id, keyId) {
5402
+ const rows = this.readRows();
5403
+ const row = rows.find((r) => r.id === id);
5404
+ if (!row || row.status !== "redeemed" || row.grantApplied === true) return false;
5405
+ if (row.redeemedByKeyId !== keyId) return false;
5406
+ row.status = "unredeemed";
5407
+ delete row.redeemedAt;
5408
+ delete row.redeemedByKeyId;
5409
+ delete row.grantApplied;
5410
+ delete row.grantedTotalCostLimitUsd;
5411
+ delete row.grantedExpiresAt;
5412
+ this.writeRows(rows);
5413
+ return true;
5414
+ }
5415
+ async voucherRevokeCas(id, now) {
5416
+ const rows = this.readRows();
5417
+ const row = rows.find((r) => r.id === id);
5418
+ if (!row || row.status !== "unredeemed") return false;
5419
+ row.status = "revoked";
5420
+ row.revokedAt = now;
5421
+ this.writeRows(rows);
5422
+ return true;
5423
+ }
5424
+ async voucherList() {
5425
+ return this.readRows();
5426
+ }
5427
+ /** Read the voucher rows, tolerating a missing/corrupt file (→ empty list). */
5428
+ readRows() {
5429
+ if (!existsSync7(this.vouchersPath)) return [];
5430
+ try {
5431
+ const parsed = JSON.parse(readFileSync8(this.vouchersPath, "utf8"));
5432
+ return Array.isArray(parsed) ? parsed : [];
5433
+ } catch {
5434
+ return [];
5435
+ }
5436
+ }
5437
+ writeRows(rows) {
5438
+ writeFileSync6(this.vouchersPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
5439
+ }
5440
+ };
5441
+
5442
+ // src/ports/JsonSubscriptionCredentialStore.ts
5443
+ import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
5444
+ import { dirname as dirname4 } from "path";
5445
+ import { getSharedAccountHealth } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
5446
+ import { fetchUpstream as fetchUpstream2 } from "@omnicross/core/pipeline/upstreamFetch";
5447
+ import { getSharedIdentityStore } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
5448
+ import {
5449
+ claudeOAuth as claudeOAuth2,
5450
+ codexOAuth as codexOAuth2,
5451
+ geminiOAuth as geminiOAuth2
5452
+ } from "@omnicross/subscriptions";
5453
+
5454
+ // src/ports/account-sync.ts
5455
+ var IMPORT_EXPIRY_MARGIN_MS = 6e4;
5456
+ function viewOf(tokens) {
5457
+ return tokens;
5458
+ }
5459
+ function decideExternalImport(captured, external, now = Date.now()) {
5460
+ if (!external?.accessToken) return "no-credential";
5461
+ const capturedRt = viewOf(captured).refreshToken;
5462
+ const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
5463
+ const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
5464
+ return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
5465
+ }
5466
+ function buildImportedTokens(captured, external) {
5467
+ const imported = {
5468
+ ...captured,
5469
+ accessToken: external.accessToken,
5470
+ status: "authorized",
5471
+ errorMessage: void 0,
5472
+ syncWarning: void 0,
5473
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
5474
+ };
5475
+ if (external.refreshToken) imported.refreshToken = external.refreshToken;
5476
+ if (external.expiresAt) imported.expiresAt = external.expiresAt;
5477
+ else delete imported.expiresAt;
5478
+ if (external.idToken) imported.idToken = external.idToken;
5479
+ if (external.scopes) imported.scopes = external.scopes;
5480
+ return imported;
5481
+ }
5482
+ function buildTokensFromExternal(provider, external) {
5483
+ const base = {
5484
+ authMethod: "oauth",
3875
5485
  status: "authorized",
3876
5486
  accessToken: external.accessToken,
3877
5487
  lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -3915,7 +5525,7 @@ function findDuplicateCredentialIds(accounts) {
3915
5525
  }
3916
5526
 
3917
5527
  // src/ports/external-cli-credentials.ts
3918
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
5528
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
3919
5529
  import { homedir as homedir2 } from "os";
3920
5530
  import { join as join4 } from "path";
3921
5531
  function externalStorePath(provider, home = homedir2()) {
@@ -3968,10 +5578,10 @@ function parseCodexTokensEnvelope(raw) {
3968
5578
  }
3969
5579
  function readExternalCliCredentials(provider, home = homedir2()) {
3970
5580
  const path2 = externalStorePath(provider, home);
3971
- if (!existsSync7(path2)) return null;
5581
+ if (!existsSync8(path2)) return null;
3972
5582
  let raw;
3973
5583
  try {
3974
- const parsed = JSON.parse(readFileSync8(path2, "utf8"));
5584
+ const parsed = JSON.parse(readFileSync9(path2, "utf8"));
3975
5585
  raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3976
5586
  } catch {
3977
5587
  return null;
@@ -3980,7 +5590,7 @@ function readExternalCliCredentials(provider, home = homedir2()) {
3980
5590
  }
3981
5591
 
3982
5592
  // src/ports/external-cli-store.ts
3983
- import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync6 } from "fs";
5593
+ import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync2, readFileSync as readFileSync10, renameSync, writeFileSync as writeFileSync7 } from "fs";
3984
5594
  import { homedir as homedir3 } from "os";
3985
5595
  import { dirname as dirname3 } from "path";
3986
5596
  function markerPath(provider, home) {
@@ -4008,9 +5618,9 @@ function buildCodexTokensEnvelope(tokens) {
4008
5618
  return envelope;
4009
5619
  }
4010
5620
  function readExistingObject(path2) {
4011
- if (!existsSync8(path2)) return {};
5621
+ if (!existsSync9(path2)) return {};
4012
5622
  try {
4013
- const parsed = JSON.parse(readFileSync9(path2, "utf8"));
5623
+ const parsed = JSON.parse(readFileSync10(path2, "utf8"));
4014
5624
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4015
5625
  } catch {
4016
5626
  return {};
@@ -4019,16 +5629,16 @@ function readExistingObject(path2) {
4019
5629
  function writeAtomic(path2, content) {
4020
5630
  mkdirSync2(dirname3(path2), { recursive: true });
4021
5631
  const temp = `${path2}.omnicross-tmp`;
4022
- writeFileSync6(temp, content, "utf8");
5632
+ writeFileSync7(temp, content, "utf8");
4023
5633
  renameSync(temp, path2);
4024
5634
  }
4025
5635
  function createExternalCliStore(home = homedir3()) {
4026
5636
  return {
4027
5637
  readMarkerAccountId(provider) {
4028
5638
  const path2 = markerPath(provider, home);
4029
- if (!existsSync8(path2)) return void 0;
5639
+ if (!existsSync9(path2)) return void 0;
4030
5640
  try {
4031
- const parsed = JSON.parse(readFileSync9(path2, "utf8"));
5641
+ const parsed = JSON.parse(readFileSync10(path2, "utf8"));
4032
5642
  return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
4033
5643
  } catch {
4034
5644
  return void 0;
@@ -4046,7 +5656,7 @@ function createExternalCliStore(home = homedir3()) {
4046
5656
  const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
4047
5657
  if (!envelope) return false;
4048
5658
  const storePath = externalStorePath(provider, home);
4049
- if (existsSync8(storePath) && !existsSync8(backupPath(provider, home))) {
5659
+ if (existsSync9(storePath) && !existsSync9(backupPath(provider, home))) {
4050
5660
  copyFileSync(storePath, backupPath(provider, home));
4051
5661
  }
4052
5662
  const existing = readExistingObject(storePath);
@@ -4058,16 +5668,21 @@ function createExternalCliStore(home = homedir3()) {
4058
5668
  }
4059
5669
 
4060
5670
  // src/ports/JsonSubscriptionCredentialStore.ts
5671
+ var ACCOUNT_REFRESH_LEAD_MS = 5 * 6e4;
4061
5672
  var JsonSubscriptionCredentialStore = class {
4062
5673
  /**
4063
5674
  * @param tokensPath on-disk `tokens.json` location.
4064
5675
  * @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
4065
- * @param fetchImpl injectable HTTP port for the OAuth refresh round-trips
4066
- * (oauth design D4). Defaults to the global `fetch` so boot
4067
- * is unchanged; tests inject a mock fetch. NOT used by any
4068
- * read/write path only by `refresh*Token`.
5676
+ * @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
5677
+ * round-trips (oauth design D4). A TEST-injected transport is
5678
+ * used verbatim. When ABSENT (production), each refresh uses a
5679
+ * proxy-aware {@link fetchUpstream} that threads the
5680
+ * `{ providerId, accountId }` ctx (upstream-proxy M1) so a
5681
+ * per-account/per-provider proxy is honored on refresh exactly
5682
+ * as on relay — refresh egresses from the SAME proxy IP as the
5683
+ * account's traffic. NOT used by any read/write path.
4069
5684
  */
4070
- constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init), externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
5685
+ constructor(tokensPath, box, fetchImpl = void 0, externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
4071
5686
  this.tokensPath = tokensPath;
4072
5687
  this.box = box;
4073
5688
  this.fetchImpl = fetchImpl;
@@ -4079,6 +5694,15 @@ var JsonSubscriptionCredentialStore = class {
4079
5694
  fetchImpl;
4080
5695
  externalCliReader;
4081
5696
  externalCliStore;
5697
+ /**
5698
+ * The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
5699
+ * TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
5700
+ * through {@link fetchUpstream} with the account's `{ providerId, accountId }`
5701
+ * ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
5702
+ */
5703
+ buildRefreshFetch(providerId, accountId) {
5704
+ return this.fetchImpl ?? ((url, init) => fetchUpstream2(url, init, { providerId, accountId }));
5705
+ }
4082
5706
  /**
4083
5707
  * In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
4084
5708
  * SINGLE-USE: two concurrent refreshes of one account each spend the same
@@ -4110,6 +5734,19 @@ var JsonSubscriptionCredentialStore = class {
4110
5734
  async getValidOpenCodeGoApiKey() {
4111
5735
  return this.readConfig().opencodego?.apiKey ?? null;
4112
5736
  }
5737
+ /**
5738
+ * DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
5739
+ * DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
5740
+ * `undefined` for an unknown provider/account or no per-account proxy. Feeds the
5741
+ * winning per-account layer of the upstream-proxy resolver. Synchronous like the
5742
+ * other hot reads. Never returns token material.
5743
+ */
5744
+ getAccountProxy(providerId, accountId) {
5745
+ if (providerId !== "claude" && providerId !== "codex" && providerId !== "gemini" && providerId !== "opencodego") {
5746
+ return void 0;
5747
+ }
5748
+ return getAccountProxy(this.readConfig(), providerId, accountId);
5749
+ }
4113
5750
  /**
4114
5751
  * DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
4115
5752
  * each provider's accounts to the secret-free `SubscriptionAccountSanitized`
@@ -4118,10 +5755,25 @@ var JsonSubscriptionCredentialStore = class {
4118
5755
  */
4119
5756
  async listSanitizedAccounts() {
4120
5757
  const config = this.readConfig();
5758
+ const health2 = getSharedAccountHealth();
5759
+ const identityStore = getSharedIdentityStore();
5760
+ const fingerprintOn = identityStore.isEnabled();
5761
+ const now = Date.now();
4121
5762
  const out = {};
4122
5763
  for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
4123
5764
  const sanitized = sanitizeAccounts(config, provider);
4124
- if (sanitized.length > 0) out[provider] = this.attachSyncWarnings(config, provider, sanitized);
5765
+ if (sanitized.length === 0) continue;
5766
+ for (const account of sanitized) {
5767
+ const status = health2.getStatus(provider, account.id, now);
5768
+ account.health = status.state;
5769
+ account.cooldownUntil = status.cooldownUntil !== void 0 ? new Date(status.cooldownUntil).toISOString() : void 0;
5770
+ if (fingerprintOn && provider === "claude") {
5771
+ account.identityCaptured = identityStore.hasIdentity(provider, account.id);
5772
+ const capturedAt = identityStore.capturedAt(provider, account.id);
5773
+ account.identityCapturedAt = capturedAt !== void 0 ? new Date(capturedAt).toISOString() : void 0;
5774
+ }
5775
+ }
5776
+ out[provider] = this.attachSyncWarnings(config, provider, sanitized);
4125
5777
  }
4126
5778
  return out;
4127
5779
  }
@@ -4172,8 +5824,9 @@ var JsonSubscriptionCredentialStore = class {
4172
5824
  if (!active || !claude?.refreshToken) return false;
4173
5825
  const capturedId = active.id;
4174
5826
  this.materializeMigration(config);
5827
+ const refreshFetch = this.buildRefreshFetch("claude", capturedId);
4175
5828
  try {
4176
- const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, this.fetchImpl);
5829
+ const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, refreshFetch);
4177
5830
  const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4178
5831
  const next = {
4179
5832
  ...claude,
@@ -4190,7 +5843,7 @@ var JsonSubscriptionCredentialStore = class {
4190
5843
  return true;
4191
5844
  } catch (error) {
4192
5845
  if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
4193
- const r = await claudeOAuth2.refreshAccessToken(rt, this.fetchImpl);
5846
+ const r = await claudeOAuth2.refreshAccessToken(rt, refreshFetch);
4194
5847
  return {
4195
5848
  accessToken: r.accessToken,
4196
5849
  refreshToken: r.refreshToken,
@@ -4217,8 +5870,9 @@ var JsonSubscriptionCredentialStore = class {
4217
5870
  if (!active || !codex?.refreshToken) return false;
4218
5871
  const capturedId = active.id;
4219
5872
  this.materializeMigration(config);
5873
+ const refreshFetch = this.buildRefreshFetch("codex", capturedId);
4220
5874
  try {
4221
- const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, this.fetchImpl);
5875
+ const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, refreshFetch);
4222
5876
  const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4223
5877
  const next = {
4224
5878
  ...codex,
@@ -4236,7 +5890,7 @@ var JsonSubscriptionCredentialStore = class {
4236
5890
  return true;
4237
5891
  } catch (error) {
4238
5892
  if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
4239
- const r = await codexOAuth2.refreshAccessToken(rt, this.fetchImpl);
5893
+ const r = await codexOAuth2.refreshAccessToken(rt, refreshFetch);
4240
5894
  return {
4241
5895
  accessToken: r.accessToken,
4242
5896
  refreshToken: r.refreshToken,
@@ -4266,8 +5920,9 @@ var JsonSubscriptionCredentialStore = class {
4266
5920
  if (!active || !gemini?.refreshToken) return false;
4267
5921
  const capturedId = active.id;
4268
5922
  this.materializeMigration(config);
5923
+ const refreshFetch = this.buildRefreshFetch("gemini", capturedId);
4269
5924
  try {
4270
- const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
5925
+ const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, refreshFetch);
4271
5926
  const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4272
5927
  const next = {
4273
5928
  ...gemini,
@@ -4301,7 +5956,7 @@ var JsonSubscriptionCredentialStore = class {
4301
5956
  if (!account || !captured?.refreshToken) return false;
4302
5957
  this.materializeMigration(config);
4303
5958
  try {
4304
- const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
5959
+ const refreshed = await this.refreshUpstream(provider, captured.refreshToken, id);
4305
5960
  const next = {
4306
5961
  ...captured,
4307
5962
  accessToken: refreshed.accessToken,
@@ -4323,10 +5978,114 @@ var JsonSubscriptionCredentialStore = class {
4323
5978
  }
4324
5979
  });
4325
5980
  }
5981
+ // ── By-id account-pool surface (subscription-account-scheduling, design D6) ──
5982
+ /**
5983
+ * Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
5984
+ * provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
5985
+ * (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
5986
+ * a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
5987
+ * opencodego returns the account's static key. `null` when unknown/expired/
5988
+ * tokenless.
5989
+ */
5990
+ async getAccessTokenForAccount(providerId, accountId) {
5991
+ const account = getAccountById(this.readConfig(), providerId, accountId);
5992
+ if (!account) return null;
5993
+ if (providerId === "opencodego") {
5994
+ return account.tokens.apiKey ?? null;
5995
+ }
5996
+ const oauth = account.tokens;
5997
+ if (!oauth.accessToken) return null;
5998
+ if (providerId === "codex" || providerId === "gemini") {
5999
+ const expiresAtMs = oauth.expiresAt ? Date.parse(oauth.expiresAt) : 0;
6000
+ const expiringSoon = expiresAtMs > 0 && Date.now() >= expiresAtMs - ACCOUNT_REFRESH_LEAD_MS;
6001
+ if (expiringSoon && oauth.refreshToken) {
6002
+ const ok = await this.refreshAccountById(providerId, accountId);
6003
+ if (!ok) return null;
6004
+ const fresh = getAccountById(this.readConfig(), providerId, accountId);
6005
+ return fresh?.tokens?.accessToken ?? null;
6006
+ }
6007
+ }
6008
+ if (oauth.status === "expired") return null;
6009
+ return oauth.accessToken;
6010
+ }
6011
+ /**
6012
+ * Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
6013
+ * `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
6014
+ * → `false` (no refresh affordance).
6015
+ */
6016
+ async refreshAccountToken(providerId, accountId) {
6017
+ if (providerId === "opencodego") return false;
6018
+ return this.refreshAccountById(providerId, accountId);
6019
+ }
6020
+ /**
6021
+ * Best-effort record of a selection time onto the account's `lastUsedAt` by id
6022
+ * (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
6023
+ * an unknown id. The selector throttles the call frequency, so this stays cheap.
6024
+ */
6025
+ async touchAccountLastUsed(providerId, accountId, iso) {
6026
+ const config = this.readConfig();
6027
+ const result = setAccountLastUsed(config, providerId, accountId, iso);
6028
+ if (!result.ok) return;
6029
+ this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6030
+ }
6031
+ /**
6032
+ * Best-effort write-through of a per-account client `identity`
6033
+ * (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
6034
+ * whitelisted fingerprint headers; the token mirror is untouched); a no-op for
6035
+ * an unknown id. Called by the identity store's persistence port on a first-seen
6036
+ * freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
6037
+ * store's port wrapper swallows a rejection so the relay hot path is unaffected.
6038
+ */
6039
+ async setAccountIdentity(providerId, accountId, identity) {
6040
+ const config = this.readConfig();
6041
+ const result = setAccountIdentity(config, providerId, accountId, identity);
6042
+ if (!result.ok) return;
6043
+ this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6044
+ }
6045
+ /**
6046
+ * DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
6047
+ * the port). Set one account's scheduling `priority` by id. Secret-free
6048
+ * (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
6049
+ */
6050
+ async setAccountPriority(providerId, accountId, priority) {
6051
+ const config = this.readConfig();
6052
+ const result = setAccountPriority(config, providerId, accountId, priority);
6053
+ if (!result.ok) return result;
6054
+ this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6055
+ return result;
6056
+ }
6057
+ /**
6058
+ * DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
6059
+ * the port). Passing `undefined` clears the override. Write-only password: when
6060
+ * the incoming structured proxy omits the password but the account already had
6061
+ * one, the current (decrypted) password is preserved — editing host/port never
6062
+ * wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
6063
+ */
6064
+ async setAccountProxy(providerId, accountId, proxy) {
6065
+ const config = this.readConfig();
6066
+ const merged = proxy ? preserveProxyConfigSecret(proxy, getAccountProxy(config, providerId, accountId)) : void 0;
6067
+ const result = setAccountProxy(config, providerId, accountId, merged);
6068
+ if (!result.ok) return result;
6069
+ this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6070
+ return result;
6071
+ }
6072
+ /**
6073
+ * DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
6074
+ * model-map, admin write, NOT on the port). Passing `undefined` clears it.
6075
+ * Secret-free (model ids only; the mirror invariant is untouched). Rejects an
6076
+ * unknown id.
6077
+ */
6078
+ async setAccountSupportedModels(providerId, accountId, supportedModels) {
6079
+ const config = this.readConfig();
6080
+ const result = setAccountSupportedModels(config, providerId, accountId, supportedModels);
6081
+ if (!result.ok) return result;
6082
+ this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6083
+ return result;
6084
+ }
4326
6085
  /** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
4327
- async refreshUpstream(provider, refreshToken) {
6086
+ async refreshUpstream(provider, refreshToken, accountId) {
4328
6087
  const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
4329
- const r = await flow.refreshAccessToken(refreshToken, this.fetchImpl);
6088
+ const r = await flow.refreshAccessToken(refreshToken, this.buildRefreshFetch(provider, accountId));
4330
6089
  return {
4331
6090
  accessToken: r.accessToken,
4332
6091
  refreshToken: r.refreshToken,
@@ -4464,119 +6223,907 @@ var JsonSubscriptionCredentialStore = class {
4464
6223
  });
4465
6224
  }
4466
6225
  /**
4467
- * DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
4468
- * provider's token block into the current `AccountTokensConfig`, stamp a fresh
4469
- * `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
4470
- * OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
4471
- * tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
4472
- * so a first-ever write still produces a valid config. No cache → the next read
4473
- * sees this write.
6226
+ * DAEMON-ONLY WRITE (design D1, NOT on the port). Read-merge the given
6227
+ * provider's token block into the current `AccountTokensConfig`, stamp a fresh
6228
+ * `updatedAt`, and re-persist `tokens.json` as pretty JSON. Preserves every
6229
+ * OTHER provider's existing block (read-merge-write, not overwrite). Reuses the
6230
+ * tolerate-on-read base (`{ updatedAt: '' }` when the file is absent/corrupt),
6231
+ * so a first-ever write still produces a valid config. No cache → the next read
6232
+ * sees this write.
6233
+ */
6234
+ async writeProviderTokens(providerId, config) {
6235
+ const current = this.readConfig();
6236
+ writeActiveTokens(current, providerId, config);
6237
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6238
+ }
6239
+ /**
6240
+ * DAEMON-ONLY login append (design D5, NOT on the port). Append a NEW account
6241
+ * (optional label) and set it active, then re-derive the mirror — used by
6242
+ * `omnicross login <provider> --label` to add an account instead of overwriting.
6243
+ */
6244
+ async appendProviderAccount(providerId, config, label) {
6245
+ const current = this.readConfig();
6246
+ const result = addAccount(current, providerId, config, label);
6247
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6248
+ return result;
6249
+ }
6250
+ /**
6251
+ * DAEMON-ONLY active switch (design D5, NOT on the port). Switch the active
6252
+ * account for a provider; rejects an unknown id. Re-derives the mirror.
6253
+ */
6254
+ async setActiveAccount(providerId, id) {
6255
+ const current = this.readConfig();
6256
+ const result = setActiveAccount(current, providerId, id);
6257
+ if (!result.ok) return result;
6258
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6259
+ return result;
6260
+ }
6261
+ /**
6262
+ * DAEMON-ONLY per-account remove (design D5, NOT on the port). Remove one
6263
+ * account; promote the most-recent remaining on active-removal (or clear the
6264
+ * mirror when none remain). Re-derives the mirror.
6265
+ */
6266
+ async removeAccount(providerId, id) {
6267
+ const current = this.readConfig();
6268
+ const result = removeAccount(current, providerId, id);
6269
+ if (!result.removed) return result;
6270
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6271
+ return result;
6272
+ }
6273
+ /**
6274
+ * DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
6275
+ * rejects an unknown id. Label-only — no token material is read or written
6276
+ * (the secret-free invariant holds).
6277
+ */
6278
+ async renameAccount(providerId, id, label) {
6279
+ const current = this.readConfig();
6280
+ const result = renameAccount(current, providerId, id, label);
6281
+ if (!result.ok) return result;
6282
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6283
+ return result;
6284
+ }
6285
+ /**
6286
+ * DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
6287
+ * block from `tokens.json` and re-persist (the strategies already tolerate an
6288
+ * absent block). Stamps a fresh `updatedAt`. A no-op-shaped write when the
6289
+ * provider was already absent (still re-stamps + persists).
6290
+ */
6291
+ async clearProvider(providerId) {
6292
+ const current = this.readConfig();
6293
+ clearProvider(current, providerId);
6294
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6295
+ }
6296
+ /** Write the merged config to disk as pretty JSON (mkdir parent if needed).
6297
+ * Encrypt-on-write: the token-material fields are encrypted (legacy plaintext
6298
+ * → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
6299
+ * write — incl. child 4's future refresh writes — lands encrypted. */
6300
+ persist(config) {
6301
+ mkdirSync3(dirname4(this.tokensPath), { recursive: true });
6302
+ const encrypted = encryptTokens(config, this.box);
6303
+ writeFileSync8(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
6304
+ }
6305
+ /**
6306
+ * Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
6307
+ * the token-material fields so every getter returns plaintext (the
6308
+ * subscription bearer path is byte-identical).
6309
+ *
6310
+ * The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
6311
+ * file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
6312
+ * wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
6313
+ * box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
6314
+ * SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
6315
+ * tokens" and silently send the WRONG bearer upstream → 401). Mirrors
6316
+ * `config.ts loadConfig`, which decrypts outside its parse try.
6317
+ */
6318
+ readConfig() {
6319
+ if (!existsSync10(this.tokensPath)) return { updatedAt: "" };
6320
+ let parsed;
6321
+ try {
6322
+ const raw = JSON.parse(readFileSync11(this.tokensPath, "utf8"));
6323
+ parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
6324
+ } catch {
6325
+ parsed = null;
6326
+ }
6327
+ if (!parsed) return { updatedAt: "" };
6328
+ const decrypted = decryptTokens(parsed, this.box);
6329
+ return migrateLazily(decrypted);
6330
+ }
6331
+ };
6332
+
6333
+ // src/AccountHealthProbeScheduler.ts
6334
+ import { fetchUpstream as fetchUpstream3 } from "@omnicross/core/pipeline/upstreamFetch";
6335
+
6336
+ // src/probe/ProbeStrategy.ts
6337
+ var PROVIDER_PROBE_PLANS = {
6338
+ claude: {
6339
+ kind: "upstream",
6340
+ // VERIFIED free authed list endpoint (no tokens billed). The anthropic OAuth
6341
+ // bearer is accepted here exactly as on the relay path.
6342
+ url: "https://api.anthropic.com/v1/models",
6343
+ buildInit: (token) => ({
6344
+ method: "GET",
6345
+ headers: {
6346
+ Authorization: `Bearer ${token}`,
6347
+ "anthropic-version": "2023-06-01"
6348
+ }
6349
+ })
6350
+ },
6351
+ // UNVERIFIED cheap authed GET — Phase 1 local-only (LEAD OQ1: do not guess a
6352
+ // billable/wrong endpoint). Upgrade to `{ kind:'upstream' }` once verified.
6353
+ codex: { kind: "local" },
6354
+ gemini: { kind: "local" },
6355
+ opencodego: { kind: "local" }
6356
+ };
6357
+ function probePlanFor(providerId) {
6358
+ return PROVIDER_PROBE_PLANS[providerId] ?? { kind: "local" };
6359
+ }
6360
+
6361
+ // src/AccountHealthProbeScheduler.ts
6362
+ var KEY_SEP = "\0";
6363
+ var MAX_BODY_SNIFF = 2048;
6364
+ var PROBE_PROVIDERS = [
6365
+ "claude",
6366
+ "codex",
6367
+ "gemini",
6368
+ "opencodego"
6369
+ ];
6370
+ var AccountHealthProbeScheduler = class {
6371
+ constructor(store, health2, logger, config, opts = {}) {
6372
+ this.store = store;
6373
+ this.health = health2;
6374
+ this.logger = logger;
6375
+ this.config = config;
6376
+ this.now = opts.now ?? Date.now;
6377
+ this.fetchImpl = opts.fetchImpl ?? fetchUpstream3;
6378
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6379
+ this.planFor = opts.planFor ?? probePlanFor;
6380
+ }
6381
+ store;
6382
+ health;
6383
+ logger;
6384
+ config;
6385
+ timer = null;
6386
+ sweeping = false;
6387
+ history = /* @__PURE__ */ new Map();
6388
+ now;
6389
+ fetchImpl;
6390
+ sleep;
6391
+ planFor;
6392
+ /** Whether probing is enabled by the current config. */
6393
+ get enabled() {
6394
+ return this.config.enabled;
6395
+ }
6396
+ /**
6397
+ * Re-apply config to the live instance (the async `start.ts` loads the persisted
6398
+ * `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
6399
+ */
6400
+ configure(config) {
6401
+ this.config = config;
6402
+ }
6403
+ /** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
6404
+ start() {
6405
+ if (this.timer || !this.config.enabled) return;
6406
+ this.timer = setInterval(() => void this.sweep(), this.config.intervalMs);
6407
+ this.timer.unref?.();
6408
+ }
6409
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
6410
+ dispose() {
6411
+ if (this.timer) {
6412
+ clearInterval(this.timer);
6413
+ this.timer = null;
6414
+ }
6415
+ }
6416
+ /**
6417
+ * One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
6418
+ * Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
6419
+ * for tests; never throws.
6420
+ */
6421
+ async sweep() {
6422
+ if (!this.config.enabled || this.sweeping) return;
6423
+ this.sweeping = true;
6424
+ try {
6425
+ const config = await this.store.getFullConfig();
6426
+ let probed = 0;
6427
+ let marked = 0;
6428
+ for (const providerId of PROBE_PROVIDERS) {
6429
+ const accounts = listAccounts(config, providerId);
6430
+ if (this.config.onlyMultiAccount && accounts.length < 2) continue;
6431
+ for (const account of accounts) {
6432
+ if (probed > 0 && this.config.staggerMs > 0) await this.sleep(this.config.staggerMs);
6433
+ const outcome = await this.probeAccount(providerId, account.id);
6434
+ probed += 1;
6435
+ if (outcome.marked) marked += 1;
6436
+ }
6437
+ }
6438
+ this.logger.debug("account-probe sweep complete", { probed, marked });
6439
+ } catch (error) {
6440
+ this.logger.warn("account-probe sweep failed", {
6441
+ error: error instanceof Error ? error.message : String(error)
6442
+ });
6443
+ } finally {
6444
+ this.sweeping = false;
6445
+ }
6446
+ }
6447
+ /**
6448
+ * Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
6449
+ * no upstream); else the upstream tier when a verified endpoint exists. Records
6450
+ * the rolling history entry either way; returns whether the tracker was MARKED.
6451
+ */
6452
+ async probeAccount(providerId, accountId) {
6453
+ const now = this.now();
6454
+ let token = null;
6455
+ let readThrew = false;
6456
+ try {
6457
+ token = await this.store.getAccessTokenForAccount(providerId, accountId);
6458
+ } catch {
6459
+ readThrew = true;
6460
+ }
6461
+ if (readThrew) {
6462
+ this.record(providerId, accountId, { ts: now, ok: false, status: null, tier: "local" });
6463
+ return { ok: false, marked: false };
6464
+ }
6465
+ if (!token) {
6466
+ this.health.recordUpstreamOutcome(providerId, accountId, { status: 401, now });
6467
+ this.record(providerId, accountId, { ts: now, ok: false, status: 401, tier: "local" });
6468
+ return { ok: false, marked: true };
6469
+ }
6470
+ const plan = this.planFor(providerId);
6471
+ if (plan.kind === "local") {
6472
+ this.record(providerId, accountId, { ts: now, ok: true, tier: "local" });
6473
+ return { ok: true, marked: false };
6474
+ }
6475
+ const start = this.now();
6476
+ let status = null;
6477
+ let bodyText;
6478
+ try {
6479
+ const res = await this.fetchImpl(
6480
+ plan.url,
6481
+ { ...plan.buildInit(token), signal: AbortSignal.timeout(this.config.timeoutMs) },
6482
+ { providerId, accountId }
6483
+ );
6484
+ status = res.status;
6485
+ if (status === 403) bodyText = await this.readBounded(res);
6486
+ } catch {
6487
+ status = null;
6488
+ }
6489
+ const latencyMs = this.now() - start;
6490
+ const marked = this.applyOutcome(providerId, accountId, status, bodyText, now);
6491
+ this.record(providerId, accountId, {
6492
+ ts: now,
6493
+ ok: status !== null && status >= 200 && status < 300,
6494
+ status,
6495
+ latencyMs,
6496
+ tier: "upstream"
6497
+ });
6498
+ return { ok: status !== null && status < 400, marked };
6499
+ }
6500
+ /** Per-account rolling history for the authed admin surface (design D5). */
6501
+ getAllHistory() {
6502
+ const out = [];
6503
+ for (const [key, records] of this.history) {
6504
+ const [providerId, accountId] = this.parseKey(key);
6505
+ out.push({ providerId, accountId, records: records.slice() });
6506
+ }
6507
+ return out;
6508
+ }
6509
+ /**
6510
+ * The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
6511
+ * probed account is currently unhealthy (per #2's tracker). No ids, no counts —
6512
+ * safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
6513
+ */
6514
+ probedAccountsHealthy(now = this.now()) {
6515
+ for (const key of this.history.keys()) {
6516
+ const [providerId, accountId] = this.parseKey(key);
6517
+ if (!this.health.isSchedulable(providerId, accountId, now)) return false;
6518
+ }
6519
+ return true;
6520
+ }
6521
+ /**
6522
+ * Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
6523
+ * 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
6524
+ * NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
6525
+ */
6526
+ applyOutcome(providerId, accountId, status, bodyText, now) {
6527
+ if (status === null) return false;
6528
+ if (status === 401 || status === 403) {
6529
+ this.health.recordUpstreamOutcome(providerId, accountId, { status, bodyText, now });
6530
+ return true;
6531
+ }
6532
+ if (status >= 200 && status < 300) {
6533
+ this.health.clearTransientMark(providerId, accountId);
6534
+ return false;
6535
+ }
6536
+ return false;
6537
+ }
6538
+ /** Append a record, capping the ring at `historySize` (drop oldest). */
6539
+ record(providerId, accountId, rec) {
6540
+ const key = this.key(providerId, accountId);
6541
+ const list = this.history.get(key) ?? [];
6542
+ list.push(rec);
6543
+ const overflow = list.length - this.config.historySize;
6544
+ if (overflow > 0) list.splice(0, overflow);
6545
+ this.history.set(key, list);
6546
+ }
6547
+ /** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
6548
+ async readBounded(res) {
6549
+ try {
6550
+ return (await res.text()).slice(0, MAX_BODY_SNIFF);
6551
+ } catch {
6552
+ return "";
6553
+ }
6554
+ }
6555
+ key(providerId, accountId) {
6556
+ return `${providerId}${KEY_SEP}${accountId}`;
6557
+ }
6558
+ parseKey(key) {
6559
+ const idx = key.indexOf(KEY_SEP);
6560
+ return [key.slice(0, idx), key.slice(idx + 1)];
6561
+ }
6562
+ };
6563
+
6564
+ // src/AccountHealthSweeper.ts
6565
+ var REFRESH_LEAD_MS = 5 * 6e4;
6566
+ var SWEEP_INTERVAL_MS = 6e4;
6567
+ var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
6568
+ function isOAuthProvider(providerId) {
6569
+ return OAUTH_PROVIDERS.includes(providerId);
6570
+ }
6571
+ var AccountHealthSweeper = class {
6572
+ constructor(store, health2, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
6573
+ this.store = store;
6574
+ this.health = health2;
6575
+ this.logger = logger;
6576
+ this.intervalMs = intervalMs;
6577
+ this.leadMs = leadMs;
6578
+ }
6579
+ store;
6580
+ health;
6581
+ logger;
6582
+ intervalMs;
6583
+ leadMs;
6584
+ timer = null;
6585
+ sweeping = false;
6586
+ /** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
6587
+ start() {
6588
+ if (this.timer) return;
6589
+ this.timer = setInterval(() => void this.sweep(), this.intervalMs);
6590
+ this.timer.unref?.();
6591
+ }
6592
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
6593
+ dispose() {
6594
+ if (this.timer) {
6595
+ clearInterval(this.timer);
6596
+ this.timer = null;
6597
+ }
6598
+ }
6599
+ /**
6600
+ * One sweep: surface accounts that just recovered (emits the recovery signal
6601
+ * through the tracker's hook) and nudge a fresh token for any recovered OAuth
6602
+ * account whose token is near expiry. Exposed for tests. Never throws.
6603
+ */
6604
+ async sweep(now = Date.now()) {
6605
+ if (this.sweeping) return;
6606
+ this.sweeping = true;
6607
+ try {
6608
+ const recovered = this.health.sweepRecoveries(now);
6609
+ if (recovered.length === 0) return;
6610
+ const config = await this.store.getFullConfig();
6611
+ for (const event of recovered) {
6612
+ if (!isOAuthProvider(event.providerId)) continue;
6613
+ const account = getAccountById(config, event.providerId, event.accountId);
6614
+ if (!account || !this.needsRefresh(account.tokens, now)) continue;
6615
+ await this.refreshOne(event.providerId, event.accountId);
6616
+ }
6617
+ } catch (error) {
6618
+ this.logger.warn("account-health sweep failed", {
6619
+ error: error instanceof Error ? error.message : String(error)
6620
+ });
6621
+ } finally {
6622
+ this.sweeping = false;
6623
+ }
6624
+ }
6625
+ /** Expiring within the lead window, refreshable, and not already dead. */
6626
+ needsRefresh(tokens, now) {
6627
+ const t = tokens;
6628
+ if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
6629
+ if (!t.expiresAt) return false;
6630
+ const expiresAt = Date.parse(t.expiresAt);
6631
+ return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
6632
+ }
6633
+ /** Refresh one recovered account by id; failures are logged, never thrown. */
6634
+ async refreshOne(provider, id) {
6635
+ try {
6636
+ const ok = await this.store.refreshAccountById(provider, id);
6637
+ if (ok) this.logger.info("account-health recovery refresh succeeded", { provider, accountId: id });
6638
+ else this.logger.warn("account-health recovery refresh failed", { provider, accountId: id });
6639
+ } catch (error) {
6640
+ this.logger.warn("account-health recovery refresh threw", {
6641
+ provider,
6642
+ accountId: id,
6643
+ error: error instanceof Error ? error.message : String(error)
6644
+ });
6645
+ }
6646
+ }
6647
+ };
6648
+
6649
+ // src/audit/AuditPruneSweeper.ts
6650
+ import { existsSync as existsSync11, readdirSync, unlinkSync } from "fs";
6651
+ import { join as join5 } from "path";
6652
+
6653
+ // src/audit/auditFiles.ts
6654
+ var AUDIT_FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
6655
+ var pad22 = (n) => String(n).padStart(2, "0");
6656
+ function auditFileName(ts) {
6657
+ const d = new Date(ts);
6658
+ return `audit-${d.getFullYear()}-${pad22(d.getMonth() + 1)}-${pad22(d.getDate())}.jsonl`;
6659
+ }
6660
+ function auditFileDateMs(fileName) {
6661
+ const m = AUDIT_FILE_RE.exec(fileName);
6662
+ if (!m) return null;
6663
+ const year = Number(m[1]);
6664
+ const month = Number(m[2]);
6665
+ const day = Number(m[3]);
6666
+ const d = new Date(year, month - 1, day);
6667
+ if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) {
6668
+ return null;
6669
+ }
6670
+ return d.getTime();
6671
+ }
6672
+
6673
+ // src/audit/AuditPruneSweeper.ts
6674
+ var DAY_MS = 24 * 60 * 6e4;
6675
+ var SWEEP_INTERVAL_MS2 = 60 * 6e4;
6676
+ var AuditPruneSweeper = class {
6677
+ constructor(auditDir, logger, config, intervalMs = SWEEP_INTERVAL_MS2, now = Date.now) {
6678
+ this.auditDir = auditDir;
6679
+ this.logger = logger;
6680
+ this.config = config;
6681
+ this.intervalMs = intervalMs;
6682
+ this.now = now;
6683
+ }
6684
+ auditDir;
6685
+ logger;
6686
+ config;
6687
+ intervalMs;
6688
+ now;
6689
+ timer = null;
6690
+ sweeping = false;
6691
+ /** Whether pruning is active (audit enabled). */
6692
+ get enabled() {
6693
+ return this.config.enabled;
6694
+ }
6695
+ /** Re-apply config to the live instance (boot + admin PUT hot-reload). */
6696
+ configure(config) {
6697
+ this.config = config;
6698
+ }
6699
+ /**
6700
+ * Arm the prune interval AND run one prune immediately (boot cleanup). No-op
6701
+ * when audit is disabled (zero regression). Idempotent.
6702
+ */
6703
+ start() {
6704
+ if (this.timer || !this.config.enabled) return;
6705
+ void this.sweep();
6706
+ this.timer = setInterval(() => void this.sweep(), this.intervalMs);
6707
+ this.timer.unref?.();
6708
+ }
6709
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
6710
+ dispose() {
6711
+ if (this.timer) {
6712
+ clearInterval(this.timer);
6713
+ this.timer = null;
6714
+ }
6715
+ }
6716
+ /**
6717
+ * One prune: unlink every audit date file strictly OLDER than the retention
6718
+ * cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
6719
+ * for tests; never throws. Returns the number of files removed.
6720
+ */
6721
+ async sweep() {
6722
+ if (!this.config.enabled || this.sweeping) return 0;
6723
+ this.sweeping = true;
6724
+ try {
6725
+ if (!existsSync11(this.auditDir)) return 0;
6726
+ const today = new Date(this.now());
6727
+ const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
6728
+ const cutoff = todayMidnight - (this.config.retentionDays - 1) * DAY_MS;
6729
+ let removed = 0;
6730
+ for (const file of readdirSync(this.auditDir)) {
6731
+ const dateMs = auditFileDateMs(file);
6732
+ if (dateMs === null || dateMs >= cutoff) continue;
6733
+ try {
6734
+ unlinkSync(join5(this.auditDir, file));
6735
+ removed += 1;
6736
+ } catch (error) {
6737
+ this.logger.warn("[AuditPruneSweeper] failed to unlink expired audit file", {
6738
+ file,
6739
+ error: error instanceof Error ? error.message : String(error)
6740
+ });
6741
+ }
6742
+ }
6743
+ if (removed > 0) this.logger.debug("audit prune complete", { removed });
6744
+ return removed;
6745
+ } catch (error) {
6746
+ this.logger.warn("audit prune sweep failed", {
6747
+ error: error instanceof Error ? error.message : String(error)
6748
+ });
6749
+ return 0;
6750
+ } finally {
6751
+ this.sweeping = false;
6752
+ }
6753
+ }
6754
+ };
6755
+
6756
+ // src/audit/auditReader.ts
6757
+ import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync12 } from "fs";
6758
+ import { join as join6 } from "path";
6759
+ var DEFAULT_LIMIT = 200;
6760
+ var MAX_LIMIT = 2e3;
6761
+ function readAuditRecords(auditDir, query = {}) {
6762
+ if (!existsSync12(auditDir)) return [];
6763
+ let files;
6764
+ try {
6765
+ files = readdirSync2(auditDir).filter((f) => AUDIT_FILE_RE.test(f));
6766
+ } catch {
6767
+ return [];
6768
+ }
6769
+ const from = typeof query.from === "number" ? query.from : -Infinity;
6770
+ const to = typeof query.to === "number" ? query.to : Infinity;
6771
+ const limit = Math.min(MAX_LIMIT, Math.max(1, Math.trunc(query.limit ?? DEFAULT_LIMIT)));
6772
+ const matched = [];
6773
+ for (const file of files.sort().reverse()) {
6774
+ let raw;
6775
+ try {
6776
+ raw = readFileSync12(join6(auditDir, file), "utf8");
6777
+ } catch {
6778
+ continue;
6779
+ }
6780
+ for (const line of raw.split("\n")) {
6781
+ const trimmed = line.trim();
6782
+ if (!trimmed) continue;
6783
+ let rec;
6784
+ try {
6785
+ rec = JSON.parse(trimmed);
6786
+ } catch {
6787
+ continue;
6788
+ }
6789
+ if (!isAuditRecord(rec)) continue;
6790
+ if (query.keyId !== void 0 && rec.keyId !== query.keyId) continue;
6791
+ if (rec.ts < from || rec.ts > to) continue;
6792
+ matched.push(rec);
6793
+ }
6794
+ }
6795
+ matched.sort((a, b) => b.ts - a.ts);
6796
+ return matched.slice(0, limit);
6797
+ }
6798
+ function isAuditRecord(value) {
6799
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
6800
+ const r = value;
6801
+ return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["method"] === "string" && typeof r["path"] === "string" && typeof r["status"] === "number";
6802
+ }
6803
+
6804
+ // src/audit/AuditWriter.ts
6805
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
6806
+ import { join as join7 } from "path";
6807
+ var AuditWriter = class {
6808
+ constructor(auditDir, logger, defer = (fn) => setTimeout(fn, 0)) {
6809
+ this.auditDir = auditDir;
6810
+ this.logger = logger;
6811
+ this.defer = defer;
6812
+ }
6813
+ auditDir;
6814
+ logger;
6815
+ defer;
6816
+ dirEnsured = false;
6817
+ /**
6818
+ * Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
6819
+ * write happens on the deferred tick. A failure is logged, never thrown.
6820
+ */
6821
+ record(record) {
6822
+ this.defer(() => {
6823
+ try {
6824
+ this.appendNow(record);
6825
+ } catch (error) {
6826
+ this.logger.warn("[AuditWriter] failed to append audit record", {
6827
+ error: error instanceof Error ? error.message : String(error)
6828
+ });
6829
+ }
6830
+ });
6831
+ }
6832
+ /**
6833
+ * Append synchronously — the awaitable form tests use to assert the line landed.
6834
+ * Ensures the `audit/` directory exists on first write (lazy, like the usage
6835
+ * store's lazy file creation).
4474
6836
  */
4475
- async writeProviderTokens(providerId, config) {
4476
- const current = this.readConfig();
4477
- writeActiveTokens(current, providerId, config);
4478
- this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
6837
+ appendNow(record) {
6838
+ if (!this.dirEnsured) {
6839
+ mkdirSync4(this.auditDir, { recursive: true });
6840
+ this.dirEnsured = true;
6841
+ }
6842
+ const file = join7(this.auditDir, auditFileName(record.ts));
6843
+ appendFileSync2(file, JSON.stringify(record) + "\n", "utf8");
6844
+ }
6845
+ };
6846
+
6847
+ // src/billing/BillingPublisher.ts
6848
+ import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync5 } from "fs";
6849
+ import { createHmac } from "crypto";
6850
+ import { join as join8 } from "path";
6851
+ import { fetchUpstream as fetchUpstream4 } from "@omnicross/core/pipeline/upstreamFetch";
6852
+
6853
+ // src/billing/billingFiles.ts
6854
+ var BILLING_FILE_RE = /^billing-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
6855
+ var DELIVERED_FILE_RE = /^delivered-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
6856
+ var pad23 = (n) => String(n).padStart(2, "0");
6857
+ function dateStamp(ts) {
6858
+ const d = new Date(ts);
6859
+ return `${d.getFullYear()}-${pad23(d.getMonth() + 1)}-${pad23(d.getDate())}`;
6860
+ }
6861
+ function billingFileName(ts) {
6862
+ return `billing-${dateStamp(ts)}.jsonl`;
6863
+ }
6864
+ function deliveredFileName(ts) {
6865
+ return `delivered-${dateStamp(ts)}.jsonl`;
6866
+ }
6867
+
6868
+ // src/billing/BillingPublisher.ts
6869
+ var BILLING_POST_TIMEOUT_MS = 1e4;
6870
+ var BillingPublisher = class {
6871
+ constructor(billingDir, logger, opts = {}) {
6872
+ this.billingDir = billingDir;
6873
+ this.logger = logger;
6874
+ this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream4(url, init));
6875
+ this.defer = opts.defer ?? ((fn) => setTimeout(fn, 0));
6876
+ this.timeoutMs = opts.timeoutMs ?? BILLING_POST_TIMEOUT_MS;
6877
+ this.now = opts.now ?? Date.now;
6878
+ }
6879
+ billingDir;
6880
+ logger;
6881
+ config;
6882
+ dirEnsured = false;
6883
+ fetchImpl;
6884
+ defer;
6885
+ timeoutMs;
6886
+ now;
6887
+ /** Install/replace the live billing config (endpoint + secret + retry bound). */
6888
+ setConfig(config) {
6889
+ this.config = config;
4479
6890
  }
4480
6891
  /**
4481
- * DAEMON-ONLY login append (design D5, NOT on the port). Append a NEW account
4482
- * (optional label) and set it active, then re-derive the mirror used by
4483
- * `omnicross login <provider> --label` to add an account instead of overwriting.
6892
+ * Record one billing event. DURABLE-FIRST: append synchronously (the event is
6893
+ * now on disk, never lost), THEN schedule a best-effort POST off the caller's
6894
+ * stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
6895
+ * NEVER throws — a failing append/POST is logged, never propagated.
4484
6896
  */
4485
- async appendProviderAccount(providerId, config, label) {
4486
- const current = this.readConfig();
4487
- const result = addAccount(current, providerId, config, label);
4488
- this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4489
- return result;
6897
+ record(event) {
6898
+ let appended = false;
6899
+ try {
6900
+ this.appendNow(event);
6901
+ appended = true;
6902
+ } catch (error) {
6903
+ this.logger.warn("[BillingPublisher] failed to append billing event", {
6904
+ error: error instanceof Error ? error.message : String(error)
6905
+ });
6906
+ }
6907
+ if (appended && this.config?.endpoint) {
6908
+ this.defer(() => {
6909
+ void this.deliverNow(event).catch(() => {
6910
+ });
6911
+ });
6912
+ }
4490
6913
  }
4491
6914
  /**
4492
- * DAEMON-ONLY active switch (design D5, NOT on the port). Switch the active
4493
- * account for a provider; rejects an unknown id. Re-derives the mirror.
6915
+ * Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
6916
+ * LOCAL date). Synchronous the awaitable form tests use to assert the ledger
6917
+ * line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
4494
6918
  */
4495
- async setActiveAccount(providerId, id) {
4496
- const current = this.readConfig();
4497
- const result = setActiveAccount(current, providerId, id);
4498
- if (!result.ok) return result;
4499
- this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4500
- return result;
6919
+ appendNow(event) {
6920
+ this.ensureDir();
6921
+ const file = join8(this.billingDir, billingFileName(event.ts));
6922
+ appendFileSync3(file, JSON.stringify(event) + "\n", "utf8");
4501
6923
  }
4502
6924
  /**
4503
- * DAEMON-ONLY per-account remove (design D5, NOT on the port). Remove one
4504
- * account; promote the most-recent remaining on active-removal (or clear the
4505
- * mirror when none remain). Re-derives the mirror.
6925
+ * One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
6926
+ * event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
6927
+ * appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
6928
+ * attempt returns `false` — the event stays UNdelivered in the ledger (never
6929
+ * lost). NEVER rejects. A no-op `false` when no endpoint is configured.
4506
6930
  */
4507
- async removeAccount(providerId, id) {
4508
- const current = this.readConfig();
4509
- const result = removeAccount(current, providerId, id);
4510
- if (!result.removed) return result;
4511
- this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4512
- return result;
6931
+ async deliverNow(event) {
6932
+ const endpoint = this.config?.endpoint;
6933
+ if (!endpoint) return false;
6934
+ try {
6935
+ const body = JSON.stringify(event);
6936
+ const headers = { "Content-Type": "application/json" };
6937
+ const secret = this.config?.secret;
6938
+ if (secret) {
6939
+ const hmac = createHmac("sha256", secret).update(body).digest("hex");
6940
+ headers["X-Omnicross-Billing-Signature"] = `sha256=${hmac}`;
6941
+ }
6942
+ const res = await this.fetchImpl(endpoint, {
6943
+ method: "POST",
6944
+ headers,
6945
+ body,
6946
+ signal: AbortSignal.timeout(this.timeoutMs)
6947
+ });
6948
+ if (!res.ok) {
6949
+ this.logger.debug(`[billing] delivery failed ${event.id} (HTTP ${res.status})`);
6950
+ return false;
6951
+ }
6952
+ this.markDelivered(event);
6953
+ this.logger.debug(`[billing] delivered ${event.id}`);
6954
+ return true;
6955
+ } catch (error) {
6956
+ this.logger.debug(
6957
+ `[billing] delivery error ${event.id}: ${error instanceof Error ? error.message : String(error)}`
6958
+ );
6959
+ return false;
6960
+ }
4513
6961
  }
4514
6962
  /**
4515
- * DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
4516
- * rejects an unknown id. Label-only no token material is read or written
4517
- * (the secret-free invariant holds).
6963
+ * Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
6964
+ * (keyed by the EVENT's date so the reader finds both together). Idempotent at
6965
+ * the reconciliation layer — the reader unions marker ids into a delivered set,
6966
+ * so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
4518
6967
  */
4519
- async renameAccount(providerId, id, label) {
4520
- const current = this.readConfig();
4521
- const result = renameAccount(current, providerId, id, label);
4522
- if (!result.ok) return result;
4523
- this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4524
- return result;
6968
+ markDelivered(event) {
6969
+ try {
6970
+ this.ensureDir();
6971
+ const file = join8(this.billingDir, deliveredFileName(event.ts));
6972
+ appendFileSync3(file, JSON.stringify({ id: event.id, deliveredAt: this.now() }) + "\n", "utf8");
6973
+ } catch (error) {
6974
+ this.logger.warn("[BillingPublisher] failed to append delivery marker", {
6975
+ error: error instanceof Error ? error.message : String(error)
6976
+ });
6977
+ }
6978
+ }
6979
+ ensureDir() {
6980
+ if (this.dirEnsured) return;
6981
+ mkdirSync5(this.billingDir, { recursive: true });
6982
+ this.dirEnsured = true;
6983
+ }
6984
+ };
6985
+
6986
+ // src/billing/billingReader.ts
6987
+ import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync13 } from "fs";
6988
+ import { join as join9 } from "path";
6989
+ function readBillingLedger(billingDir) {
6990
+ const view = { events: [], deliveredIds: /* @__PURE__ */ new Set() };
6991
+ if (!existsSync13(billingDir)) return view;
6992
+ let files;
6993
+ try {
6994
+ files = readdirSync3(billingDir);
6995
+ } catch {
6996
+ return view;
6997
+ }
6998
+ for (const file of files.sort()) {
6999
+ if (BILLING_FILE_RE.test(file)) {
7000
+ for (const rec of parseLines(billingDir, file)) {
7001
+ if (isBillingEvent(rec)) view.events.push(rec);
7002
+ }
7003
+ } else if (DELIVERED_FILE_RE.test(file)) {
7004
+ for (const rec of parseLines(billingDir, file)) {
7005
+ const id = rec.id;
7006
+ if (typeof id === "string") view.deliveredIds.add(id);
7007
+ }
7008
+ }
7009
+ }
7010
+ return view;
7011
+ }
7012
+ function readUndeliveredEvents(billingDir) {
7013
+ const { events, deliveredIds } = readBillingLedger(billingDir);
7014
+ return events.filter((e) => !deliveredIds.has(e.id)).sort((a, b) => a.ts - b.ts);
7015
+ }
7016
+ function readBillingStatus(billingDir) {
7017
+ const { events, deliveredIds } = readBillingLedger(billingDir);
7018
+ let delivered = 0;
7019
+ for (const e of events) if (deliveredIds.has(e.id)) delivered += 1;
7020
+ return { total: events.length, delivered, pending: events.length - delivered };
7021
+ }
7022
+ function parseLines(dir, file) {
7023
+ let raw;
7024
+ try {
7025
+ raw = readFileSync13(join9(dir, file), "utf8");
7026
+ } catch {
7027
+ return [];
7028
+ }
7029
+ const out = [];
7030
+ for (const line of raw.split("\n")) {
7031
+ const trimmed = line.trim();
7032
+ if (!trimmed) continue;
7033
+ try {
7034
+ out.push(JSON.parse(trimmed));
7035
+ } catch {
7036
+ }
7037
+ }
7038
+ return out;
7039
+ }
7040
+ function isBillingEvent(value) {
7041
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
7042
+ const r = value;
7043
+ return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["model"] === "string" && typeof r["status"] === "number";
7044
+ }
7045
+
7046
+ // src/billing/BillingRetrySweeper.ts
7047
+ var SWEEP_INTERVAL_MS3 = 5 * 6e4;
7048
+ var BillingRetrySweeper = class {
7049
+ constructor(billingDir, publisher2, logger, config, intervalMs = SWEEP_INTERVAL_MS3, now = Date.now) {
7050
+ this.billingDir = billingDir;
7051
+ this.publisher = publisher2;
7052
+ this.logger = logger;
7053
+ this.config = config;
7054
+ this.intervalMs = intervalMs;
7055
+ this.now = now;
7056
+ }
7057
+ billingDir;
7058
+ publisher;
7059
+ logger;
7060
+ config;
7061
+ intervalMs;
7062
+ now;
7063
+ timer = null;
7064
+ sweeping = false;
7065
+ /** Whether retrying is active: billing enabled AND an endpoint is configured. */
7066
+ get enabled() {
7067
+ return this.config.enabled && typeof this.config.endpoint === "string" && this.config.endpoint.length > 0;
7068
+ }
7069
+ /** Re-apply config to the live instance (boot + admin PUT hot-reload). */
7070
+ configure(config) {
7071
+ this.config = config;
4525
7072
  }
4526
7073
  /**
4527
- * DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
4528
- * block from `tokens.json` and re-persist (the strategies already tolerate an
4529
- * absent block). Stamps a fresh `updatedAt`. A no-op-shaped write when the
4530
- * provider was already absent (still re-stamps + persists).
7074
+ * Arm the retry interval AND run one sweep immediately (boot catch-up for events
7075
+ * that failed to deliver while the daemon was down). No-op when disabled or in
7076
+ * ledger-only mode (no endpoint to POST to). Idempotent.
4531
7077
  */
4532
- async clearProvider(providerId) {
4533
- const current = this.readConfig();
4534
- clearProvider(current, providerId);
4535
- this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
7078
+ start() {
7079
+ if (this.timer || !this.enabled) return;
7080
+ void this.sweep();
7081
+ this.timer = setInterval(() => void this.sweep(), this.intervalMs);
7082
+ this.timer.unref?.();
4536
7083
  }
4537
- /** Write the merged config to disk as pretty JSON (mkdir parent if needed).
4538
- * Encrypt-on-write: the token-material fields are encrypted (legacy plaintext
4539
- * → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
4540
- * write — incl. child 4's future refresh writes — lands encrypted. */
4541
- persist(config) {
4542
- mkdirSync3(dirname4(this.tokensPath), { recursive: true });
4543
- const encrypted = encryptTokens(config, this.box);
4544
- writeFileSync7(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
7084
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
7085
+ dispose() {
7086
+ if (this.timer) {
7087
+ clearInterval(this.timer);
7088
+ this.timer = null;
7089
+ }
4545
7090
  }
4546
7091
  /**
4547
- * Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
4548
- * the token-material fields so every getter returns plaintext (the
4549
- * subscription bearer path is byte-identical).
4550
- *
4551
- * The fs-read + JSON-parse tolerance is INSIDE the try (a missing or corrupt
4552
- * file → empty `{ updatedAt: '' }`). The DECRYPT runs OUTSIDE the try, so a
4553
- * wrong/missing master key or a tampered `enc:` envelope FAILS FAST with the
4554
- * box's clear, secret-free error (secrets spec "错误密钥 / 篡改的解密失败 UX":
4555
- * SHALL fail-fast, SHALL NOT 静默降级 — a swallowed decrypt would report "no
4556
- * tokens" and silently send the WRONG bearer upstream → 401). Mirrors
4557
- * `config.ts loadConfig`, which decrypts outside its parse try.
7092
+ * One sweep: re-POST every UNdelivered ledger event still within
7093
+ * `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
7094
+ * deleted). Exposed for tests; never throws. Returns the number of events a
7095
+ * re-POST was attempted for.
4558
7096
  */
4559
- readConfig() {
4560
- if (!existsSync9(this.tokensPath)) return { updatedAt: "" };
4561
- let parsed;
7097
+ async sweep() {
7098
+ if (!this.enabled || this.sweeping) return 0;
7099
+ this.sweeping = true;
4562
7100
  try {
4563
- const raw = JSON.parse(readFileSync10(this.tokensPath, "utf8"));
4564
- parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
4565
- } catch {
4566
- parsed = null;
7101
+ const cutoff = this.now() - this.config.maxRetryAgeMs;
7102
+ let attempted = 0;
7103
+ for (const event of readUndeliveredEvents(this.billingDir)) {
7104
+ if (event.ts < cutoff) continue;
7105
+ attempted += 1;
7106
+ await this.publisher.deliverNow(event);
7107
+ }
7108
+ if (attempted > 0) this.logger.debug("billing retry sweep complete", { attempted });
7109
+ return attempted;
7110
+ } catch (error) {
7111
+ this.logger.warn("billing retry sweep failed", {
7112
+ error: error instanceof Error ? error.message : String(error)
7113
+ });
7114
+ return 0;
7115
+ } finally {
7116
+ this.sweeping = false;
4567
7117
  }
4568
- if (!parsed) return { updatedAt: "" };
4569
- const decrypted = decryptTokens(parsed, this.box);
4570
- return migrateLazily(decrypted);
4571
7118
  }
4572
7119
  };
4573
7120
 
4574
7121
  // src/TokenRefreshScheduler.ts
4575
- var REFRESH_LEAD_MS = 5 * 6e4;
4576
- var SWEEP_INTERVAL_MS = 6e4;
4577
- var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
7122
+ var REFRESH_LEAD_MS2 = 5 * 6e4;
7123
+ var SWEEP_INTERVAL_MS4 = 6e4;
7124
+ var OAUTH_PROVIDERS2 = ["claude", "codex", "gemini"];
4578
7125
  var TokenRefreshScheduler = class {
4579
- constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
7126
+ constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS4, leadMs = REFRESH_LEAD_MS2) {
4580
7127
  this.store = store;
4581
7128
  this.logger = logger;
4582
7129
  this.intervalMs = intervalMs;
@@ -4607,7 +7154,7 @@ var TokenRefreshScheduler = class {
4607
7154
  this.sweeping = true;
4608
7155
  try {
4609
7156
  const config = await this.store.getFullConfig();
4610
- for (const provider of OAUTH_PROVIDERS) {
7157
+ for (const provider of OAUTH_PROVIDERS2) {
4611
7158
  const activeId = getActiveAccount(config, provider)?.id;
4612
7159
  for (const account of listAccounts(config, provider)) {
4613
7160
  if (!this.needsRefresh(account.tokens, now)) continue;
@@ -4660,16 +7207,186 @@ var TokenRefreshScheduler = class {
4660
7207
  }
4661
7208
  };
4662
7209
 
7210
+ // src/webhook/WebhookDispatcher.ts
7211
+ import { createHmac as createHmac2 } from "crypto";
7212
+ import { fetchUpstream as fetchUpstream5 } from "@omnicross/core/pipeline/upstreamFetch";
7213
+ var WEBHOOK_MAX_ATTEMPTS = 3;
7214
+ var WEBHOOK_QUEUE_MAX = 1e3;
7215
+ var WEBHOOK_SEND_TIMEOUT_MS = 1e4;
7216
+ var WEBHOOK_BASE_BACKOFF_MS = 200;
7217
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
7218
+ var WebhookDispatcher = class {
7219
+ config;
7220
+ queue = [];
7221
+ draining = false;
7222
+ warnedFull = false;
7223
+ fetchImpl;
7224
+ logger;
7225
+ maxAttempts;
7226
+ queueMax;
7227
+ timeoutMs;
7228
+ baseBackoffMs;
7229
+ sleep;
7230
+ now;
7231
+ constructor(opts = {}) {
7232
+ this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream5(url, init));
7233
+ this.logger = opts.logger;
7234
+ this.maxAttempts = opts.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
7235
+ this.queueMax = opts.queueMax ?? WEBHOOK_QUEUE_MAX;
7236
+ this.timeoutMs = opts.timeoutMs ?? WEBHOOK_SEND_TIMEOUT_MS;
7237
+ this.baseBackoffMs = opts.baseBackoffMs ?? WEBHOOK_BASE_BACKOFF_MS;
7238
+ this.sleep = opts.sleep ?? defaultSleep;
7239
+ this.now = opts.now ?? Date.now;
7240
+ }
7241
+ /** Install/replace the live webhook config (destinations + master switch). */
7242
+ setConfig(config) {
7243
+ this.config = config;
7244
+ }
7245
+ /**
7246
+ * Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
7247
+ * send, NEVER throws — the drain loop does all sending on a side channel. A
7248
+ * full queue drops the OLDEST event (with a one-shot warn) so a runaway source
7249
+ * can't OOM the process.
7250
+ */
7251
+ emit(event) {
7252
+ if (this.queue.length >= this.queueMax) {
7253
+ this.queue.shift();
7254
+ if (!this.warnedFull) {
7255
+ this.logger?.warn("[webhook] queue full \u2014 dropping oldest events");
7256
+ this.warnedFull = true;
7257
+ }
7258
+ }
7259
+ this.queue.push(event);
7260
+ if (!this.draining) {
7261
+ this.draining = true;
7262
+ queueMicrotask(() => void this.drain());
7263
+ }
7264
+ }
7265
+ /** Drain the queue, sending each event to its matching destinations concurrently. */
7266
+ async drain() {
7267
+ try {
7268
+ while (this.queue.length > 0) {
7269
+ const event = this.queue.shift();
7270
+ const destinations = this.matchingDestinations(event.kind);
7271
+ if (destinations.length === 0) continue;
7272
+ await Promise.all(destinations.map((d) => this.sendWithRetry(event, d)));
7273
+ }
7274
+ } finally {
7275
+ this.draining = false;
7276
+ if (this.queue.length > 0) {
7277
+ this.draining = true;
7278
+ queueMicrotask(() => void this.drain());
7279
+ }
7280
+ }
7281
+ }
7282
+ /** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
7283
+ matchingDestinations(kind) {
7284
+ const cfg = this.config;
7285
+ if (!cfg || !cfg.enabled) return [];
7286
+ return cfg.destinations.filter(
7287
+ (d) => d.enabled && (!d.events || d.events.length === 0 || d.events.includes(kind))
7288
+ );
7289
+ }
7290
+ /** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
7291
+ async sendWithRetry(event, dest) {
7292
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
7293
+ const result = await this.sendOnce(event, dest);
7294
+ if (result.ok) {
7295
+ this.logger?.debug(`[webhook] delivered ${event.kind} \u2192 ${dest.id} (${result.status})`);
7296
+ return;
7297
+ }
7298
+ if (attempt < this.maxAttempts) {
7299
+ await this.sleep(this.baseBackoffMs * 2 ** (attempt - 1));
7300
+ } else {
7301
+ this.logger?.warn(
7302
+ `[webhook] dropped ${event.kind} \u2192 ${dest.id} after ${this.maxAttempts} attempts: ${result.error ?? `HTTP ${result.status}`}`
7303
+ );
7304
+ }
7305
+ }
7306
+ }
7307
+ /** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
7308
+ async sendOnce(event, dest) {
7309
+ try {
7310
+ const { body, headers } = buildRequest(event, dest, this.now());
7311
+ const res = await this.fetchImpl(dest.url, {
7312
+ method: "POST",
7313
+ headers: { "Content-Type": "application/json", ...headers },
7314
+ body,
7315
+ signal: AbortSignal.timeout(this.timeoutMs)
7316
+ });
7317
+ return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status };
7318
+ } catch (err5) {
7319
+ return { ok: false, error: err5 instanceof Error ? err5.message : String(err5) };
7320
+ }
7321
+ }
7322
+ /**
7323
+ * ADMIN test path (design D8): deliver a `test` event to ONE destination and
7324
+ * AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
7325
+ * the admin request path (an operator clicking "Test"), NEVER on a relay path,
7326
+ * so awaiting it is safe. Finds the destination regardless of its `enabled`
7327
+ * flag or the master switch (an explicit operator action).
7328
+ */
7329
+ async deliverTest(destinationId) {
7330
+ const dest = this.config?.destinations.find((d) => d.id === destinationId);
7331
+ if (!dest) return { ok: false, error: "destination not found" };
7332
+ return this.sendOnce({ kind: "test", at: this.now() }, dest);
7333
+ }
7334
+ };
7335
+ function buildRequest(event, dest, nowMs) {
7336
+ if (dest.type === "feishu") return buildFeishu(event, dest, nowMs);
7337
+ return buildCustom(event, dest);
7338
+ }
7339
+ function buildCustom(event, dest) {
7340
+ const body = JSON.stringify(event);
7341
+ const headers = {};
7342
+ if (dest.secret) {
7343
+ const hmac = createHmac2("sha256", dest.secret).update(body).digest("hex");
7344
+ headers["X-Omnicross-Signature"] = `sha256=${hmac}`;
7345
+ }
7346
+ return { body, headers };
7347
+ }
7348
+ function buildFeishu(event, dest, nowMs) {
7349
+ const payload = {
7350
+ msg_type: "text",
7351
+ content: { text: feishuText(event) }
7352
+ };
7353
+ if (dest.secret) {
7354
+ const timestamp = Math.floor(nowMs / 1e3).toString();
7355
+ const stringToSign = `${timestamp}
7356
+ ${dest.secret}`;
7357
+ payload["timestamp"] = timestamp;
7358
+ payload["sign"] = createHmac2("sha256", stringToSign).digest("base64");
7359
+ }
7360
+ return { body: JSON.stringify(payload), headers: {} };
7361
+ }
7362
+ function feishuText(event) {
7363
+ switch (event.kind) {
7364
+ case "account.recovery":
7365
+ return `omnicross: account recovered \u2014 ${event.providerId}/${event.accountId}`;
7366
+ case "account.anomaly":
7367
+ return `omnicross: account anomaly [${event.state}] \u2014 ${event.providerId}/${event.accountId}`;
7368
+ case "key.quotaWarning":
7369
+ return `omnicross: key quota warning (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
7370
+ case "key.quotaExceeded":
7371
+ return `omnicross: key quota EXCEEDED (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
7372
+ case "server.error":
7373
+ return `omnicross: server error \u2014 ${event.message}`;
7374
+ case "test":
7375
+ return "omnicross: webhook test";
7376
+ }
7377
+ }
7378
+
4663
7379
  // src/bootstrap.ts
4664
7380
  function buildDaemon(config, paths) {
4665
- const logger = new ConsoleLogger();
7381
+ const logger = new ConfigurableLogger(config.logging);
4666
7382
  const secretBox3 = new SecretBox(() => resolveMasterKey({ keyFilePath: paths.masterKeyFilePath }));
4667
7383
  setSecretBox(secretBox3);
4668
7384
  setSecretBox2(secretBox3);
4669
7385
  const decryptedConfig = decryptConfigSecrets(config, secretBox3);
4670
7386
  const llmConfig = new ConfigFileProviderConfigSource(decryptedConfig);
4671
7387
  const keyDb = new JsonOutboundKeyDb(paths.keysPath);
4672
- const settingsStore = new JsonApiServerSettingsStore(paths.configPath);
7388
+ const voucherDb = new JsonVoucherDb(defaultVouchersPath(paths.configPath));
7389
+ const settingsStore = new JsonApiServerSettingsStore(paths.configPath, secretBox3);
4673
7390
  const credentialStore = new JsonSubscriptionCredentialStore(paths.tokensPath, secretBox3);
4674
7391
  const subscriptionAccounts = new SubscriptionAccountService(credentialStore);
4675
7392
  setSubscriptionAccountService(subscriptionAccounts);
@@ -4678,6 +7395,12 @@ function buildDaemon(config, paths) {
4678
7395
  credentialStore
4679
7396
  );
4680
7397
  setSubscriptionProviderRegistry(subscriptionRegistry);
7398
+ setServerProxyConfig(decryptedConfig.server?.proxy);
7399
+ setUpstreamProxyResolver(
7400
+ createUpstreamProxyResolver({
7401
+ getAccountProxy: (providerId, accountId) => credentialStore.getAccountProxy(providerId, accountId)
7402
+ })
7403
+ );
4681
7404
  setGeminiCodeAssistResolver(getGeminiCodeAssistProjectResolver());
4682
7405
  const autoDisableStore = new AutoDisableStore();
4683
7406
  const apiKeyPool = new ApiKeyPoolService(
@@ -4698,19 +7421,59 @@ function buildDaemon(config, paths) {
4698
7421
  defaultUsageEventsPath(paths.configPath),
4699
7422
  async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
4700
7423
  );
4701
- const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger);
7424
+ const keySpendTracker = new KeySpendTracker(usageEventStore);
7425
+ const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger, {
7426
+ onRecord: (apiKeyId, costUsd, at) => keySpendTracker.add(apiKeyId, costUsd, at)
7427
+ });
4702
7428
  const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
4703
7429
  llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
7430
+ const accountHealthProbeScheduler = new AccountHealthProbeScheduler(
7431
+ credentialStore,
7432
+ getSharedAccountHealth2(),
7433
+ logger,
7434
+ DEFAULT_ACCOUNT_PROBE
7435
+ );
7436
+ const getHealthReport = () => buildHealthReport({
7437
+ version: DAEMON_VERSION,
7438
+ // CRITICAL: the config loaded with a providers array.
7439
+ configPresent: () => Array.isArray(decryptedConfig.providers),
7440
+ // CRITICAL: the credential store's tokens.json is readable WITHOUT
7441
+ // decrypting (a missing file is fine — no accounts yet). A stat/access
7442
+ // only; never reads or decrypts token material.
7443
+ credentialStoreReadable: () => isTokensStoreReadable(paths.tokensPath),
7444
+ outboundServerRunning: () => outboundApiServer.getStatus().running,
7445
+ adminServerRunning: () => adminServer.getStatus().running,
7446
+ // Coarse, account-anonymous probe signal (#8, D5) — added to `checks` ONLY
7447
+ // when probing is ENABLED; disabled ⇒ `undefined` ⇒ key omitted ⇒ the
7448
+ // `/health` body stays byte-identical (zero regression).
7449
+ subscriptionAccountsHealthy: () => accountHealthProbeScheduler.enabled ? accountHealthProbeScheduler.probedAccountsHealthy() : void 0
7450
+ });
4704
7451
  const outboundApiServer = getOutboundApiServer({
4705
7452
  db: keyDb,
7453
+ // voucher-redemption #9: the key-authenticated `POST /redeem` endpoint redeems
7454
+ // cards against the presenting key (gated on `voucher.enabled`).
7455
+ voucherDb,
4706
7456
  llmConfig,
4707
7457
  providerProxy,
4708
- proxyDeps: providerProxy.getDeps()
7458
+ proxyDeps: providerProxy.getDeps(),
7459
+ healthReportProvider: getHealthReport,
7460
+ // outbound-key-policy: the wire layer's 402 cost check reads per-key spend.
7461
+ keySpendTracker,
7462
+ // configurable-logging: route the server's OWN lifecycle + relay dispatch-error
7463
+ // lines through the injected logger (honors level/format/file sink).
7464
+ logger
4709
7465
  });
7466
+ const auditDir = defaultAuditDir(paths.configPath);
7467
+ const billingDir = defaultBillingDir(paths.configPath);
4710
7468
  const adminServer = new AdminServer({
4711
7469
  configPath: paths.configPath,
4712
7470
  llmConfig,
4713
7471
  keyDb,
7472
+ // voucher-redemption #9: the admin `/admin/api/voucher` surface generates/
7473
+ // lists/revokes redemption cards (gated on `voucher.enabled`).
7474
+ voucherDb,
7475
+ // outbound-key-policy: the admin key list surfaces each key's OWN spend.
7476
+ keySpendReader: keySpendTracker,
4714
7477
  settingsStore,
4715
7478
  outboundApiServer,
4716
7479
  subscriptionAccounts,
@@ -4732,7 +7495,9 @@ function buildDaemon(config, paths) {
4732
7495
  oauthSessions: new OAuthSessionStore(),
4733
7496
  // Real global fetch by default; a test seam (`paths.oauthExchangeFetch`) can
4734
7497
  // inject a mock so no real token endpoint is hit.
4735
- oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => fetch(url, init)),
7498
+ // upstream-proxy: default the OAuth token-exchange fetch to the proxy-aware
7499
+ // helper so interactive login honors a configured proxy (global/env layers).
7500
+ oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => fetchUpstream6(url, init)),
4736
7501
  subscriptionAccountAppender: credentialStore,
4737
7502
  // Codex interactive OAuth (app-parity-2 child 5) — the async loopback flow store
4738
7503
  // + the one-shot 127.0.0.1:1455 listener. Token captured + persisted daemon-side;
@@ -4759,9 +7524,48 @@ function buildDaemon(config, paths) {
4759
7524
  pricingStore,
4760
7525
  // Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
4761
7526
  // plaintext bearer the AdminServer's constant-time compare expects (D4).
4762
- getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
7527
+ getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin),
7528
+ // Unauthenticated `/health` probe (daemon-health-endpoint) — the SAME shared
7529
+ // builder the outbound server uses, served before the admin auth gate.
7530
+ getHealthReport,
7531
+ // configurable-logging: the admin listener's lifecycle lines route through
7532
+ // the injected logger.
7533
+ logger,
7534
+ // subscription-account-probe #8: the AUTHED `GET /admin/api/account-probes`
7535
+ // reads per-account probe history from the scheduler (secret-free — ids +
7536
+ // status labels only). Routed in `AdminServer` (not `adminApi.ts`).
7537
+ probeHistoryReader: accountHealthProbeScheduler,
7538
+ // request-audit-log: the AUTHED `GET /admin/api/audit` reads + filters the
7539
+ // date-rotated audit store. Bound to the store dir here so the AdminServer
7540
+ // carries no path/store coupling. Records hold IP/UA/bodies → admin-only,
7541
+ // NEVER unauth, NEVER on `/health`. Routed in `AdminServer` (not `adminApi.ts`).
7542
+ auditReader: (query) => readAuditRecords(auditDir, query),
7543
+ // billing-event-stream: the AUTHED `GET /admin/api/billing-status` returns the
7544
+ // secret-free total/delivered/pending counts of the durable ledger.
7545
+ billingStatusReader: () => readBillingStatus(billingDir)
4763
7546
  });
7547
+ const webhookDispatcher = new WebhookDispatcher({
7548
+ logger,
7549
+ fetchImpl: (url, init) => fetchUpstream6(url, init)
7550
+ });
7551
+ setWebhookRuntime(webhookDispatcher, getSharedAccountHealth2());
7552
+ const auditWriter = new AuditWriter(auditDir, logger);
7553
+ const auditPruneSweeper = new AuditPruneSweeper(auditDir, logger, DEFAULT_AUDIT_CONFIG);
7554
+ setAuditRuntime(auditWriter, auditPruneSweeper);
7555
+ const billingPublisher = new BillingPublisher(billingDir, logger);
7556
+ const billingRetrySweeper = new BillingRetrySweeper(
7557
+ billingDir,
7558
+ billingPublisher,
7559
+ logger,
7560
+ DEFAULT_BILLING_CONFIG
7561
+ );
7562
+ setBillingRuntime(billingPublisher, billingRetrySweeper);
4764
7563
  const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
7564
+ const accountHealthSweeper = new AccountHealthSweeper(
7565
+ credentialStore,
7566
+ getSharedAccountHealth2(),
7567
+ logger
7568
+ );
4765
7569
  return {
4766
7570
  logger,
4767
7571
  llmConfig,
@@ -4778,9 +7582,25 @@ function buildDaemon(config, paths) {
4778
7582
  pricingEngine,
4779
7583
  usageRecorder,
4780
7584
  adminServer,
4781
- tokenRefreshScheduler
7585
+ tokenRefreshScheduler,
7586
+ accountHealthSweeper,
7587
+ accountHealthProbeScheduler,
7588
+ webhookDispatcher,
7589
+ auditWriter,
7590
+ auditPruneSweeper,
7591
+ billingPublisher,
7592
+ billingRetrySweeper
4782
7593
  };
4783
7594
  }
7595
+ function isTokensStoreReadable(tokensPath) {
7596
+ try {
7597
+ if (!existsSync14(tokensPath)) return true;
7598
+ accessSync(tokensPath, fsConstants.R_OK);
7599
+ return true;
7600
+ } catch {
7601
+ return false;
7602
+ }
7603
+ }
4784
7604
 
4785
7605
  // src/commands/launch.ts
4786
7606
  var SUPPORTED_LAUNCH_CLIS = [
@@ -4822,8 +7642,8 @@ function buildCliSpawnPlan(opts) {
4822
7642
  function resolveInPathDefault(candidate) {
4823
7643
  const segments = (process.env["PATH"] ?? "").split(delimiter2).filter(Boolean);
4824
7644
  for (const seg of segments) {
4825
- const full = join5(seg, candidate);
4826
- if (existsSync10(full)) return full;
7645
+ const full = join10(seg, candidate);
7646
+ if (existsSync15(full)) return full;
4827
7647
  }
4828
7648
  return null;
4829
7649
  }
@@ -4866,6 +7686,10 @@ async function runLaunch(argv, deps) {
4866
7686
  } catch (err5) {
4867
7687
  daemon.apiKeyPool.dispose();
4868
7688
  daemon.tokenRefreshScheduler.dispose();
7689
+ daemon.accountHealthSweeper.dispose();
7690
+ daemon.accountHealthProbeScheduler.dispose();
7691
+ daemon.auditPruneSweeper.dispose();
7692
+ daemon.billingRetrySweeper.dispose();
4869
7693
  throw err5;
4870
7694
  }
4871
7695
  let launch;
@@ -4878,6 +7702,10 @@ async function runLaunch(argv, deps) {
4878
7702
  await daemon.providerProxy.stop();
4879
7703
  daemon.apiKeyPool.dispose();
4880
7704
  daemon.tokenRefreshScheduler.dispose();
7705
+ daemon.accountHealthSweeper.dispose();
7706
+ daemon.accountHealthProbeScheduler.dispose();
7707
+ daemon.auditPruneSweeper.dispose();
7708
+ daemon.billingRetrySweeper.dispose();
4881
7709
  throw err5;
4882
7710
  }
4883
7711
  try {
@@ -4900,6 +7728,10 @@ async function runLaunch(argv, deps) {
4900
7728
  await daemon.providerProxy.stop();
4901
7729
  daemon.apiKeyPool.dispose();
4902
7730
  daemon.tokenRefreshScheduler.dispose();
7731
+ daemon.accountHealthSweeper.dispose();
7732
+ daemon.accountHealthProbeScheduler.dispose();
7733
+ daemon.auditPruneSweeper.dispose();
7734
+ daemon.billingRetrySweeper.dispose();
4903
7735
  }
4904
7736
  }
4905
7737
  async function buildLaunchConfig(cli, llmConfig, opts) {
@@ -4972,6 +7804,7 @@ function spawnCliInherit(plan) {
4972
7804
  import { spawn as spawn3 } from "child_process";
4973
7805
  import { createInterface } from "readline";
4974
7806
  import { parseArgs as parseArgs4 } from "util";
7807
+ import { fetchUpstream as fetchUpstream7, setUpstreamProxyResolver as setUpstreamProxyResolver2 } from "@omnicross/core/pipeline/upstreamFetch";
4975
7808
  import { claudeOAuth as claudeOAuth3, codexOAuth as codexOAuth3, geminiOAuth as geminiOAuth3 } from "@omnicross/subscriptions";
4976
7809
  var PROVIDERS = ["claude", "codex", "gemini"];
4977
7810
  async function runLogin(argv, deps) {
@@ -5003,9 +7836,10 @@ async function runLogin(argv, deps) {
5003
7836
  };
5004
7837
  const box = resolveSecretBox(values["master-key-file"]);
5005
7838
  setSecretBox(box);
7839
+ setUpstreamProxyResolver2(createUpstreamProxyResolver());
5006
7840
  try {
5007
7841
  const tokensPath = defaultTokensPath(values.config);
5008
- const exchangeFetch = resolved.tokensFetch ?? ((url, init) => fetch(url, init));
7842
+ const exchangeFetch = resolved.tokensFetch ?? ((url, init) => fetchUpstream7(url, init, { providerId: provider }));
5009
7843
  const store = new JsonSubscriptionCredentialStore(tokensPath, box, exchangeFetch);
5010
7844
  const expiresAt = await runProviderLogin(
5011
7845
  provider,
@@ -5018,6 +7852,7 @@ async function runLogin(argv, deps) {
5018
7852
  console.info(` token: [stored, encrypted] expiresAt: ${expiresAt ?? "n/a"}`);
5019
7853
  } finally {
5020
7854
  setSecretBox(null);
7855
+ setUpstreamProxyResolver2(null);
5021
7856
  }
5022
7857
  }
5023
7858
  async function runProviderLogin(provider, store, deps, exchangeFetch, label) {
@@ -5290,7 +8125,7 @@ function providersRmKey(configPath, providerId, keyId) {
5290
8125
  }
5291
8126
 
5292
8127
  // src/commands/secrets.ts
5293
- import { existsSync as existsSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
8128
+ import { existsSync as existsSync16, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
5294
8129
  import { parseArgs as parseArgs6 } from "util";
5295
8130
  async function runSecrets(argv) {
5296
8131
  const { values, positionals } = parseArgs6({
@@ -5362,7 +8197,7 @@ function secretsStatus(args) {
5362
8197
  reportField("admin.token", cfg.admin.token);
5363
8198
  }
5364
8199
  const tokensPath = defaultTokensPath(args.config);
5365
- if (existsSync11(tokensPath)) {
8200
+ if (existsSync16(tokensPath)) {
5366
8201
  console.info(`Secret status for ${tokensPath}:`);
5367
8202
  reportTokenFields(tokensPath);
5368
8203
  }
@@ -5402,7 +8237,7 @@ function secretsRotate(args) {
5402
8237
  const tokensPath = defaultTokensPath(args.config);
5403
8238
  try {
5404
8239
  cfg = loadConfig(args.config);
5405
- if (existsSync11(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
8240
+ if (existsSync16(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
5406
8241
  } finally {
5407
8242
  setSecretBox(null);
5408
8243
  }
@@ -5431,20 +8266,20 @@ function secretsDecrypt(args) {
5431
8266
  let tokensPlain = null;
5432
8267
  try {
5433
8268
  cfg = loadConfig(args.config);
5434
- if (existsSync11(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
8269
+ if (existsSync16(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
5435
8270
  } finally {
5436
8271
  setSecretBox(null);
5437
8272
  }
5438
8273
  saveConfig(args.config, cfg);
5439
8274
  if (tokensPlain) {
5440
- writeFileSync8(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
8275
+ writeFileSync9(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
5441
8276
  }
5442
8277
  console.info(`Decrypted secrets to plaintext in ${args.config}` + tokensSuffix(args.config));
5443
8278
  }
5444
8279
  function readRawConfig(path2) {
5445
8280
  let parsed;
5446
8281
  try {
5447
- parsed = JSON.parse(readFileSync11(path2, "utf8"));
8282
+ parsed = JSON.parse(readFileSync14(path2, "utf8"));
5448
8283
  } catch {
5449
8284
  throw new Error(`secrets: cannot read or parse '${path2}'`);
5450
8285
  }
@@ -5452,7 +8287,7 @@ function readRawConfig(path2) {
5452
8287
  }
5453
8288
  function readRawJson(path2) {
5454
8289
  try {
5455
- const parsed = JSON.parse(readFileSync11(path2, "utf8"));
8290
+ const parsed = JSON.parse(readFileSync14(path2, "utf8"));
5456
8291
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
5457
8292
  return parsed;
5458
8293
  }
@@ -5462,7 +8297,7 @@ function readRawJson(path2) {
5462
8297
  }
5463
8298
  function encryptTokensFileInPlace(configPath, box) {
5464
8299
  const tokensPath = defaultTokensPath(configPath);
5465
- if (!existsSync11(tokensPath)) return;
8300
+ if (!existsSync16(tokensPath)) return;
5466
8301
  const plain = decryptTokensFile(tokensPath, box);
5467
8302
  writeTokensEncrypted(tokensPath, plain, box);
5468
8303
  }
@@ -5475,7 +8310,7 @@ function writeTokensEncrypted(tokensPath, plain, box) {
5475
8310
  { updatedAt: "", ...plain },
5476
8311
  box
5477
8312
  );
5478
- writeFileSync8(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
8313
+ writeFileSync9(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
5479
8314
  }
5480
8315
  var TOKEN_FIELDS2 = {
5481
8316
  claude: ["accessToken", "refreshToken"],
@@ -5498,12 +8333,47 @@ function walkTokens(raw, fn) {
5498
8333
  return next;
5499
8334
  }
5500
8335
  function tokensSuffix(configPath) {
5501
- return existsSync11(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
8336
+ return existsSync16(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
5502
8337
  }
5503
8338
 
5504
8339
  // src/commands/start.ts
5505
8340
  import { parseArgs as parseArgs7 } from "util";
5506
- import { loadServerConfig as loadServerConfig2 } from "@omnicross/core/outbound-api";
8341
+ import { loadServerConfig as loadServerConfig3, OutboundApiConfigError } from "@omnicross/core/outbound-api";
8342
+ import { getSharedAccountHealth as getSharedAccountHealth3 } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
8343
+
8344
+ // src/identity/identityRuntime.ts
8345
+ import { getSharedIdentityStore as getSharedIdentityStore2 } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
8346
+ async function applyFingerprintConfig(config, credentialStore) {
8347
+ const store = getSharedIdentityStore2();
8348
+ const enabled = config?.enabled === true;
8349
+ store.configure({ enabled, ua: config?.ua ?? null });
8350
+ if (!enabled) {
8351
+ store.setPersistence(null);
8352
+ return;
8353
+ }
8354
+ await seedIdentities(store, credentialStore);
8355
+ store.setPersistence({
8356
+ persist: (providerId, accountId, identity) => {
8357
+ void credentialStore.setAccountIdentity(providerId, accountId, identity).catch(() => {
8358
+ });
8359
+ }
8360
+ });
8361
+ }
8362
+ async function seedIdentities(store, credentialStore) {
8363
+ let config;
8364
+ try {
8365
+ config = await credentialStore.getFullConfig();
8366
+ } catch {
8367
+ return;
8368
+ }
8369
+ for (const provider of Object.keys(DAEMON_PROVIDER_KEYS)) {
8370
+ for (const account of listAccounts(config, provider)) {
8371
+ if (account.identity) store.seed(provider, account.id, account.identity);
8372
+ }
8373
+ }
8374
+ }
8375
+
8376
+ // src/commands/start.ts
5507
8377
  async function runStart(argv) {
5508
8378
  const { values } = parseArgs7({
5509
8379
  args: argv,
@@ -5528,19 +8398,45 @@ async function runStart(argv) {
5528
8398
  const daemon = buildDaemon(config, paths);
5529
8399
  await daemon.llmConfig.ready();
5530
8400
  await daemon.providerProxy.start();
5531
- const serverConfig = await loadServerConfig2(daemon.settingsStore);
5532
- await daemon.outboundApiServer.applyConfig({
5533
- enabled: true,
5534
- networkBinding: serverConfig.networkBinding,
5535
- endpoints: serverConfig.endpoints,
5536
- port: serverConfig.port
8401
+ const serverConfig = await loadServerConfig3(daemon.settingsStore);
8402
+ getSharedAccountHealth3().configure({
8403
+ overloadEnabled: serverConfig.accountHealth?.overloadCooldownEnabled,
8404
+ overloadTtlMs: serverConfig.accountHealth?.overloadCooldownMs
5537
8405
  });
8406
+ try {
8407
+ await daemon.outboundApiServer.applyConfig({
8408
+ enabled: true,
8409
+ networkBinding: serverConfig.networkBinding,
8410
+ endpoints: serverConfig.endpoints,
8411
+ port: serverConfig.port,
8412
+ userMessageQueue: serverConfig.userMessageQueue,
8413
+ concurrencyQueue: serverConfig.concurrencyQueue,
8414
+ // voucher-redemption #9: carry the persisted flag so `POST /redeem` works on
8415
+ // boot when the operator has enabled the product.
8416
+ voucher: serverConfig.voucher
8417
+ });
8418
+ } catch (err5) {
8419
+ if (err5 instanceof OutboundApiConfigError) {
8420
+ console.warn(`[outbound] not started \u2014 incomplete model configuration: ${err5.message}`);
8421
+ } else {
8422
+ throw err5;
8423
+ }
8424
+ }
5538
8425
  let dashboardUrl = null;
5539
8426
  if (!values["no-dashboard"]) {
5540
8427
  await daemon.adminServer.start();
5541
8428
  dashboardUrl = daemon.adminServer.getStatus().url;
5542
8429
  }
5543
8430
  daemon.tokenRefreshScheduler.start();
8431
+ daemon.accountHealthSweeper.start();
8432
+ if (serverConfig.accountProbe) {
8433
+ daemon.accountHealthProbeScheduler.configure(serverConfig.accountProbe);
8434
+ }
8435
+ daemon.accountHealthProbeScheduler.start();
8436
+ applyWebhookConfig(serverConfig.webhook);
8437
+ applyAuditConfig(serverConfig.audit);
8438
+ applyBillingConfig(serverConfig.billing);
8439
+ await applyFingerprintConfig(serverConfig.fingerprint, daemon.credentialStore);
5544
8440
  const status = daemon.outboundApiServer.getStatus();
5545
8441
  console.info("omnicross daemon is running.");
5546
8442
  if (dashboardUrl) console.info(` dashboard : ${dashboardUrl}`);