@omnicross/daemon 0.1.2 → 0.1.4
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.cjs +3234 -332
- package/dist/cli.js +3238 -318
- package/dist/index.cjs +3144 -266
- package/dist/index.d.cts +960 -33
- package/dist/index.d.ts +960 -33
- package/dist/index.js +3150 -256
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// src/bootstrap.ts
|
|
2
|
+
import { accessSync, constants as fsConstants, existsSync as existsSync14 } from "fs";
|
|
3
|
+
import { DEFAULT_AUDIT_CONFIG } from "@omnicross/contracts/audit-types";
|
|
4
|
+
import { DEFAULT_BILLING_CONFIG } from "@omnicross/contracts/billing-types";
|
|
2
5
|
import { getGeminiCodeAssistProjectResolver } from "@omnicross/core/auth/GeminiCodeAssistProjectResolver";
|
|
3
6
|
import { ApiKeyPoolService } from "@omnicross/core/completion/ApiKeyPoolService";
|
|
4
7
|
import {
|
|
5
8
|
__resetOutboundApiServerForTests,
|
|
9
|
+
DEFAULT_ACCOUNT_PROBE,
|
|
6
10
|
getOutboundApiServer
|
|
7
11
|
} from "@omnicross/core/outbound-api";
|
|
8
12
|
import { setSubscriptionRegistryForOutbound } from "@omnicross/core/outbound-api/subscriptionRegistryPort";
|
|
13
|
+
import { getSharedAccountHealth as getSharedAccountHealth2 } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
|
|
14
|
+
import { fetchUpstream as fetchUpstream6, setUpstreamProxyResolver } from "@omnicross/core/pipeline/upstreamFetch";
|
|
15
|
+
import { __resetSharedIdentityStoreForTests } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
|
|
9
16
|
import { setGeminiCodeAssistResolver } from "@omnicross/core/ports/gemini-code-assist-resolver";
|
|
10
17
|
import {
|
|
11
18
|
__resetProviderProxyForTests,
|
|
12
19
|
getProviderProxy
|
|
13
20
|
} from "@omnicross/core/provider-proxy";
|
|
21
|
+
import { KeySpendTracker } from "@omnicross/core/outbound-api";
|
|
14
22
|
import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
|
|
15
23
|
import {
|
|
16
24
|
setSubscriptionAccountService,
|
|
@@ -30,6 +38,7 @@ var CodexOAuthSessionStore = class {
|
|
|
30
38
|
ttlMs;
|
|
31
39
|
sessions = /* @__PURE__ */ new Map();
|
|
32
40
|
activeSessionId = null;
|
|
41
|
+
aborters = /* @__PURE__ */ new Map();
|
|
33
42
|
/** Whether a codex sign-in is currently in flight (port 1455 held). */
|
|
34
43
|
isBusy() {
|
|
35
44
|
this.sweep();
|
|
@@ -41,13 +50,22 @@ var CodexOAuthSessionStore = class {
|
|
|
41
50
|
const sessionId = crypto.randomBytes(24).toString("base64url");
|
|
42
51
|
this.sessions.set(sessionId, { status: "pending", createdAt: Date.now() });
|
|
43
52
|
this.activeSessionId = sessionId;
|
|
44
|
-
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
this.aborters.set(sessionId, controller);
|
|
55
|
+
return { sessionId, signal: controller.signal };
|
|
45
56
|
}
|
|
46
57
|
/** Settle a flow (done/error) + free the active slot. */
|
|
47
58
|
settle(sessionId, status, error) {
|
|
48
59
|
const prior = this.sessions.get(sessionId);
|
|
49
60
|
this.sessions.set(sessionId, { status, error, createdAt: prior?.createdAt ?? Date.now() });
|
|
50
61
|
if (this.activeSessionId === sessionId) this.activeSessionId = null;
|
|
62
|
+
this.aborters.delete(sessionId);
|
|
63
|
+
}
|
|
64
|
+
cancel(sessionId) {
|
|
65
|
+
if (!this.sessions.has(sessionId)) return false;
|
|
66
|
+
this.aborters.get(sessionId)?.abort();
|
|
67
|
+
this.settle(sessionId, "error", "login: cancelled");
|
|
68
|
+
return true;
|
|
51
69
|
}
|
|
52
70
|
/** Read a flow's status (token-free), or null when unknown/expired. */
|
|
53
71
|
get(sessionId) {
|
|
@@ -76,13 +94,13 @@ function handleCodexOAuthStart(deps) {
|
|
|
76
94
|
);
|
|
77
95
|
}
|
|
78
96
|
const { authUrl, codeVerifier, state } = codexOAuth.generateAuthParams();
|
|
79
|
-
const sessionId = deps.codexSessions.begin();
|
|
80
|
-
void runCodexLoopback(sessionId, codeVerifier, state, deps);
|
|
97
|
+
const { sessionId, signal } = deps.codexSessions.begin();
|
|
98
|
+
void runCodexLoopback(sessionId, codeVerifier, state, signal, deps);
|
|
81
99
|
return { status: 200, body: { authUrl, sessionId } };
|
|
82
100
|
}
|
|
83
|
-
async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
101
|
+
async function runCodexLoopback(sessionId, codeVerifier, state, signal, deps) {
|
|
84
102
|
try {
|
|
85
|
-
const code = await deps.codexAwaitLoopback(state);
|
|
103
|
+
const code = await deps.codexAwaitLoopback(state, void 0, signal);
|
|
86
104
|
const result = await codexOAuth.exchangeCodeForTokens(
|
|
87
105
|
{ authorizationCode: code, codeVerifier, state },
|
|
88
106
|
deps.oauthExchangeFetch
|
|
@@ -104,6 +122,10 @@ async function runCodexLoopback(sessionId, codeVerifier, state, deps) {
|
|
|
104
122
|
deps.codexSessions.settle(sessionId, "error", reason);
|
|
105
123
|
}
|
|
106
124
|
}
|
|
125
|
+
function handleCodexOAuthCancel(sessionId, deps) {
|
|
126
|
+
if (!deps.codexSessions.cancel(sessionId)) return err(404, "unknown or expired codex sign-in session");
|
|
127
|
+
return { status: 200, body: { ok: true } };
|
|
128
|
+
}
|
|
107
129
|
function handleCodexOAuthStatus(sessionId, deps) {
|
|
108
130
|
const s = deps.codexSessions.get(sessionId);
|
|
109
131
|
if (!s) return err(404, "unknown or expired codex sign-in session");
|
|
@@ -113,15 +135,146 @@ function handleCodexOAuthStatus(sessionId, deps) {
|
|
|
113
135
|
// src/admin/AdminServer.ts
|
|
114
136
|
import { timingSafeEqual } from "crypto";
|
|
115
137
|
import http2 from "http";
|
|
138
|
+
import {
|
|
139
|
+
healthHttpStatus
|
|
140
|
+
} from "@omnicross/contracts/health-logging-types";
|
|
141
|
+
|
|
142
|
+
// src/admin/accountProbesApi.ts
|
|
143
|
+
function handleAccountProbes(res, reader) {
|
|
144
|
+
const accounts = reader ? reader.getAllHistory() : [];
|
|
145
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
146
|
+
res.end(JSON.stringify({ accounts }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/admin/auditQueryApi.ts
|
|
150
|
+
function intParam(value) {
|
|
151
|
+
if (value === null || value.trim() === "") return void 0;
|
|
152
|
+
const n = Number(value);
|
|
153
|
+
return Number.isFinite(n) ? Math.trunc(n) : void 0;
|
|
154
|
+
}
|
|
155
|
+
function handleAuditQuery(req, res, reader) {
|
|
156
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
157
|
+
const query = {};
|
|
158
|
+
const keyId = url.searchParams.get("keyId");
|
|
159
|
+
if (keyId && keyId.trim()) query.keyId = keyId.trim();
|
|
160
|
+
const from = intParam(url.searchParams.get("from"));
|
|
161
|
+
if (from !== void 0) query.from = from;
|
|
162
|
+
const to = intParam(url.searchParams.get("to"));
|
|
163
|
+
if (to !== void 0) query.to = to;
|
|
164
|
+
const limit = intParam(url.searchParams.get("limit"));
|
|
165
|
+
if (limit !== void 0) query.limit = limit;
|
|
166
|
+
const records = reader ? reader(query) : [];
|
|
167
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
168
|
+
res.end(JSON.stringify({ records }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/admin/billingStatusApi.ts
|
|
172
|
+
function handleBillingStatus(res, reader) {
|
|
173
|
+
const status = reader ? reader() : { total: 0, delivered: 0, pending: 0 };
|
|
174
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
175
|
+
res.end(JSON.stringify({ status }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/webhook/webhookRuntime.ts
|
|
179
|
+
import { setWebhookSink } from "@omnicross/core/pipeline/webhookEmit";
|
|
180
|
+
var dispatcher = null;
|
|
181
|
+
var health = null;
|
|
182
|
+
var unsubscribers = [];
|
|
183
|
+
var wired = false;
|
|
184
|
+
function setWebhookRuntime(d, h) {
|
|
185
|
+
dispatcher = d;
|
|
186
|
+
health = h;
|
|
187
|
+
}
|
|
188
|
+
function applyWebhookConfig(config) {
|
|
189
|
+
if (!dispatcher) return;
|
|
190
|
+
dispatcher.setConfig(config);
|
|
191
|
+
const shouldWire = config?.enabled === true && (config?.destinations.length ?? 0) > 0;
|
|
192
|
+
if (shouldWire && !wired) {
|
|
193
|
+
const active = dispatcher;
|
|
194
|
+
setWebhookSink((event) => active.emit(event));
|
|
195
|
+
if (health) {
|
|
196
|
+
unsubscribers.push(
|
|
197
|
+
health.onRecovered(
|
|
198
|
+
(e) => active.emit({ kind: "account.recovery", at: e.at, providerId: e.providerId, accountId: e.accountId })
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
unsubscribers.push(
|
|
202
|
+
health.onAnomaly(
|
|
203
|
+
(e) => active.emit({
|
|
204
|
+
kind: "account.anomaly",
|
|
205
|
+
at: e.at,
|
|
206
|
+
providerId: e.providerId,
|
|
207
|
+
accountId: e.accountId,
|
|
208
|
+
state: e.state
|
|
209
|
+
})
|
|
210
|
+
)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
wired = true;
|
|
214
|
+
} else if (!shouldWire && wired) {
|
|
215
|
+
teardown();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function deliverWebhookTest(destinationId) {
|
|
219
|
+
if (!dispatcher) return { ok: false, error: "webhook dispatcher not wired" };
|
|
220
|
+
return dispatcher.deliverTest(destinationId);
|
|
221
|
+
}
|
|
222
|
+
function teardown() {
|
|
223
|
+
setWebhookSink(null);
|
|
224
|
+
for (const unsub of unsubscribers) unsub();
|
|
225
|
+
unsubscribers = [];
|
|
226
|
+
wired = false;
|
|
227
|
+
}
|
|
228
|
+
function resetWebhookRuntimeForTests() {
|
|
229
|
+
if (wired) teardown();
|
|
230
|
+
dispatcher = null;
|
|
231
|
+
health = null;
|
|
232
|
+
unsubscribers = [];
|
|
233
|
+
wired = false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/admin/webhookTestApi.ts
|
|
237
|
+
function readJsonBody(req) {
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
const chunks = [];
|
|
240
|
+
req.on("data", (c) => chunks.push(c));
|
|
241
|
+
req.on("end", () => {
|
|
242
|
+
try {
|
|
243
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
244
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
245
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
246
|
+
} catch {
|
|
247
|
+
resolve({});
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
req.on("error", () => resolve({}));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
async function handleWebhookTest(req, res) {
|
|
254
|
+
const body = await readJsonBody(req);
|
|
255
|
+
const destinationId = body["destinationId"];
|
|
256
|
+
if (typeof destinationId !== "string" || !destinationId.trim()) {
|
|
257
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
258
|
+
res.end(JSON.stringify({ error: { type: "bad_request", message: "destinationId is required" } }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const result = await deliverWebhookTest(destinationId.trim());
|
|
262
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
263
|
+
res.end(JSON.stringify({ result }));
|
|
264
|
+
}
|
|
116
265
|
|
|
117
266
|
// src/admin/adminApi.ts
|
|
118
267
|
import http from "http";
|
|
119
268
|
import {
|
|
120
269
|
createNamedKey,
|
|
121
|
-
|
|
270
|
+
isKindMappedEndpoint,
|
|
271
|
+
loadServerConfig as loadServerConfig2,
|
|
122
272
|
mergeServerConfig,
|
|
123
|
-
|
|
273
|
+
normalizeProxyConfig,
|
|
274
|
+
saveServerConfig,
|
|
275
|
+
validateServerModelConfig
|
|
124
276
|
} from "@omnicross/core/outbound-api";
|
|
277
|
+
import { fetchUpstream } from "@omnicross/core/pipeline/upstreamFetch";
|
|
125
278
|
|
|
126
279
|
// src/config.ts
|
|
127
280
|
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -317,6 +470,70 @@ var SecretBox = class {
|
|
|
317
470
|
};
|
|
318
471
|
|
|
319
472
|
// src/secrets/secretFields.ts
|
|
473
|
+
function urlHasInlineCredential(url) {
|
|
474
|
+
try {
|
|
475
|
+
const u = new URL(url);
|
|
476
|
+
return u.username.length > 0 || u.password.length > 0;
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function transformProxyConfig(cfg, fn) {
|
|
482
|
+
if ("url" in cfg) {
|
|
483
|
+
if (isEnvelope(cfg.url) || urlHasInlineCredential(cfg.url)) {
|
|
484
|
+
return { url: fn(cfg.url) };
|
|
485
|
+
}
|
|
486
|
+
return cfg;
|
|
487
|
+
}
|
|
488
|
+
if (typeof cfg.password === "string" && cfg.password.length > 0) {
|
|
489
|
+
return { ...cfg, password: fn(cfg.password) };
|
|
490
|
+
}
|
|
491
|
+
return cfg;
|
|
492
|
+
}
|
|
493
|
+
function transformOutboundProxy(proxy, fn) {
|
|
494
|
+
const next = {};
|
|
495
|
+
if (proxy.global) next.global = transformProxyConfig(proxy.global, fn);
|
|
496
|
+
if (proxy.byProvider) {
|
|
497
|
+
const byProvider = {};
|
|
498
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
499
|
+
byProvider[key] = transformProxyConfig(value, fn);
|
|
500
|
+
}
|
|
501
|
+
next.byProvider = byProvider;
|
|
502
|
+
}
|
|
503
|
+
return next;
|
|
504
|
+
}
|
|
505
|
+
function encryptProxySegment(proxy, box) {
|
|
506
|
+
return transformOutboundProxy(proxy, (v) => box.encryptMaybe(v));
|
|
507
|
+
}
|
|
508
|
+
function decryptProxySegment(proxy, box) {
|
|
509
|
+
return transformOutboundProxy(proxy, (v) => box.decryptMaybe(v));
|
|
510
|
+
}
|
|
511
|
+
function transformWebhookSegment(webhook, fn) {
|
|
512
|
+
return {
|
|
513
|
+
...webhook,
|
|
514
|
+
destinations: webhook.destinations.map(
|
|
515
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: fn(d.secret) } : d
|
|
516
|
+
)
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function encryptWebhookSegment(webhook, box) {
|
|
520
|
+
return transformWebhookSegment(webhook, (v) => box.encryptMaybe(v));
|
|
521
|
+
}
|
|
522
|
+
function decryptWebhookSegment(webhook, box) {
|
|
523
|
+
return transformWebhookSegment(webhook, (v) => box.decryptMaybe(v));
|
|
524
|
+
}
|
|
525
|
+
function transformBillingSegment(billing, fn) {
|
|
526
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
527
|
+
return { ...billing, secret: fn(billing.secret) };
|
|
528
|
+
}
|
|
529
|
+
return billing;
|
|
530
|
+
}
|
|
531
|
+
function encryptBillingSegment(billing, box) {
|
|
532
|
+
return transformBillingSegment(billing, (v) => box.encryptMaybe(v));
|
|
533
|
+
}
|
|
534
|
+
function decryptBillingSegment(billing, box) {
|
|
535
|
+
return transformBillingSegment(billing, (v) => box.decryptMaybe(v));
|
|
536
|
+
}
|
|
320
537
|
function transformProvider(provider, fn) {
|
|
321
538
|
const next = { ...provider, apiKey: fn(provider.apiKey) };
|
|
322
539
|
if (provider.apiKeys) {
|
|
@@ -340,6 +557,17 @@ function transformConfigSecrets(cfg, fn) {
|
|
|
340
557
|
if (cfg.admin && typeof cfg.admin.token === "string" && cfg.admin.token.length > 0) {
|
|
341
558
|
next.admin = { ...cfg.admin, token: fn(cfg.admin.token) };
|
|
342
559
|
}
|
|
560
|
+
const proxy = cfg.server?.proxy;
|
|
561
|
+
const webhook = cfg.server?.webhook;
|
|
562
|
+
const billing = cfg.server?.billing;
|
|
563
|
+
if (cfg.server && (proxy?.global || proxy?.byProvider || webhook || billing?.secret)) {
|
|
564
|
+
next.server = { ...cfg.server };
|
|
565
|
+
if (proxy && (proxy.global || proxy.byProvider)) {
|
|
566
|
+
next.server.proxy = transformOutboundProxy(proxy, fn);
|
|
567
|
+
}
|
|
568
|
+
if (webhook) next.server.webhook = transformWebhookSegment(webhook, fn);
|
|
569
|
+
if (billing?.secret) next.server.billing = transformBillingSegment(billing, fn);
|
|
570
|
+
}
|
|
343
571
|
return next;
|
|
344
572
|
}
|
|
345
573
|
function encryptConfigSecrets(cfg, box) {
|
|
@@ -377,7 +605,7 @@ function transformTokens(tokens, fn) {
|
|
|
377
605
|
if (Array.isArray(accounts)) {
|
|
378
606
|
bag[accountsKey] = accounts.map((entry) => {
|
|
379
607
|
if (entry && typeof entry === "object" && "tokens" in entry && entry.tokens && typeof entry.tokens === "object") {
|
|
380
|
-
|
|
608
|
+
const nextEntry = {
|
|
381
609
|
...entry,
|
|
382
610
|
tokens: transformTokenBlock(
|
|
383
611
|
entry.tokens,
|
|
@@ -385,6 +613,11 @@ function transformTokens(tokens, fn) {
|
|
|
385
613
|
fn
|
|
386
614
|
)
|
|
387
615
|
};
|
|
616
|
+
const proxy = entry.proxy;
|
|
617
|
+
if (proxy && typeof proxy === "object") {
|
|
618
|
+
nextEntry.proxy = transformProxyConfig(proxy, fn);
|
|
619
|
+
}
|
|
620
|
+
return nextEntry;
|
|
388
621
|
}
|
|
389
622
|
return entry;
|
|
390
623
|
});
|
|
@@ -419,6 +652,17 @@ function resolveAdminConfig(admin) {
|
|
|
419
652
|
token: typeof admin?.token === "string" && admin.token.length > 0 ? admin.token : void 0
|
|
420
653
|
};
|
|
421
654
|
}
|
|
655
|
+
function validateLogging(raw) {
|
|
656
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
657
|
+
const l = raw;
|
|
658
|
+
const out = {};
|
|
659
|
+
if (l["level"] === "error" || l["level"] === "warn" || l["level"] === "info" || l["level"] === "debug") {
|
|
660
|
+
out.level = l["level"];
|
|
661
|
+
}
|
|
662
|
+
if (l["format"] === "text" || l["format"] === "json") out.format = l["format"];
|
|
663
|
+
if (typeof l["file"] === "string" && l["file"].length > 0) out.file = l["file"];
|
|
664
|
+
return out.level !== void 0 || out.format !== void 0 || out.file !== void 0 ? out : void 0;
|
|
665
|
+
}
|
|
422
666
|
var VALID_FORMATS = ["openai", "anthropic", "gemini"];
|
|
423
667
|
function validateApiKeys(raw) {
|
|
424
668
|
if (!Array.isArray(raw)) return void 0;
|
|
@@ -593,7 +837,8 @@ function validateConfig(raw) {
|
|
|
593
837
|
const providers = providersRaw.map((p, i) => validateProvider(p, i));
|
|
594
838
|
const server = obj["server"];
|
|
595
839
|
const admin = validateAdmin(obj["admin"]);
|
|
596
|
-
|
|
840
|
+
const logging = validateLogging(obj["logging"]);
|
|
841
|
+
return { providers, server, admin, logging };
|
|
597
842
|
}
|
|
598
843
|
var secretBox = null;
|
|
599
844
|
function setSecretBox(box) {
|
|
@@ -693,6 +938,163 @@ function listMappablePresets() {
|
|
|
693
938
|
return { mappable, excluded };
|
|
694
939
|
}
|
|
695
940
|
|
|
941
|
+
// src/proxy/sanitizeProxy.ts
|
|
942
|
+
function sanitizeProxyConfig(cfg) {
|
|
943
|
+
if ("url" in cfg) {
|
|
944
|
+
let endpoint;
|
|
945
|
+
let username;
|
|
946
|
+
let hasPassword = false;
|
|
947
|
+
try {
|
|
948
|
+
const u = new URL(cfg.url);
|
|
949
|
+
endpoint = u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
|
950
|
+
username = u.username ? decodeURIComponent(u.username) : void 0;
|
|
951
|
+
hasPassword = u.password.length > 0;
|
|
952
|
+
} catch {
|
|
953
|
+
}
|
|
954
|
+
return { kind: "url", endpoint, username, hasPassword };
|
|
955
|
+
}
|
|
956
|
+
return {
|
|
957
|
+
kind: cfg.type,
|
|
958
|
+
endpoint: `${cfg.host}:${cfg.port}`,
|
|
959
|
+
username: cfg.username,
|
|
960
|
+
hasPassword: typeof cfg.password === "string" && cfg.password.length > 0
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
function redactProxyConfig(cfg) {
|
|
964
|
+
if ("url" in cfg) {
|
|
965
|
+
try {
|
|
966
|
+
const u = new URL(cfg.url);
|
|
967
|
+
if (u.password) u.password = "";
|
|
968
|
+
return { url: u.toString() };
|
|
969
|
+
} catch {
|
|
970
|
+
return cfg;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const { password: _password, ...rest } = cfg;
|
|
974
|
+
return rest;
|
|
975
|
+
}
|
|
976
|
+
function redactOutboundProxy(proxy) {
|
|
977
|
+
const out = {};
|
|
978
|
+
if (proxy.global) out.global = redactProxyConfig(proxy.global);
|
|
979
|
+
if (proxy.byProvider) {
|
|
980
|
+
const byProvider = {};
|
|
981
|
+
for (const [key, value] of Object.entries(proxy.byProvider)) {
|
|
982
|
+
byProvider[key] = redactProxyConfig(value);
|
|
983
|
+
}
|
|
984
|
+
out.byProvider = byProvider;
|
|
985
|
+
}
|
|
986
|
+
return out;
|
|
987
|
+
}
|
|
988
|
+
function preserveProxyConfigSecret(incoming, current) {
|
|
989
|
+
if (!current) return incoming;
|
|
990
|
+
if ("url" in incoming) {
|
|
991
|
+
if ("url" in current) {
|
|
992
|
+
try {
|
|
993
|
+
const inU = new URL(incoming.url);
|
|
994
|
+
const curU = new URL(current.url);
|
|
995
|
+
if (!inU.password && curU.password) {
|
|
996
|
+
inU.password = curU.password;
|
|
997
|
+
return { url: inU.toString() };
|
|
998
|
+
}
|
|
999
|
+
} catch {
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return incoming;
|
|
1003
|
+
}
|
|
1004
|
+
if ("url" in current) return incoming;
|
|
1005
|
+
const blank = incoming.password === void 0 || incoming.password === "";
|
|
1006
|
+
if (blank && typeof current.password === "string" && current.password.length > 0) {
|
|
1007
|
+
return { ...incoming, password: current.password };
|
|
1008
|
+
}
|
|
1009
|
+
return incoming;
|
|
1010
|
+
}
|
|
1011
|
+
function preserveOutboundProxySecrets(incoming, current) {
|
|
1012
|
+
const out = {};
|
|
1013
|
+
if (incoming.global) out.global = preserveProxyConfigSecret(incoming.global, current?.global);
|
|
1014
|
+
if (incoming.byProvider) {
|
|
1015
|
+
const byProvider = {};
|
|
1016
|
+
for (const [key, value] of Object.entries(incoming.byProvider)) {
|
|
1017
|
+
byProvider[key] = preserveProxyConfigSecret(value, current?.byProvider?.[key]);
|
|
1018
|
+
}
|
|
1019
|
+
out.byProvider = byProvider;
|
|
1020
|
+
}
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/proxy/upstreamProxyResolver.ts
|
|
1025
|
+
import {
|
|
1026
|
+
bumpUpstreamProxyGeneration
|
|
1027
|
+
} from "@omnicross/core/pipeline/upstreamFetch";
|
|
1028
|
+
var serverProxy;
|
|
1029
|
+
function setServerProxyConfig(proxy) {
|
|
1030
|
+
serverProxy = proxy;
|
|
1031
|
+
bumpUpstreamProxyGeneration();
|
|
1032
|
+
}
|
|
1033
|
+
function getServerProxyConfig() {
|
|
1034
|
+
return serverProxy;
|
|
1035
|
+
}
|
|
1036
|
+
var envProxyLoggedFor;
|
|
1037
|
+
function maskProxyUrl(url) {
|
|
1038
|
+
return url.replace(/\/\/[^/@]*@/, "//***@");
|
|
1039
|
+
}
|
|
1040
|
+
function hostFromCtx(ctx) {
|
|
1041
|
+
if (!ctx.url) return void 0;
|
|
1042
|
+
try {
|
|
1043
|
+
return new URL(ctx.url).hostname.toLowerCase();
|
|
1044
|
+
} catch {
|
|
1045
|
+
return void 0;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function isLoopbackHost(host) {
|
|
1049
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.startsWith("127.") || host.endsWith(".localhost");
|
|
1050
|
+
}
|
|
1051
|
+
function noProxyMatches(noProxy, host) {
|
|
1052
|
+
if (!noProxy) return false;
|
|
1053
|
+
for (const raw of noProxy.split(",")) {
|
|
1054
|
+
const entry = raw.trim().toLowerCase();
|
|
1055
|
+
if (!entry) continue;
|
|
1056
|
+
if (entry === "*") return true;
|
|
1057
|
+
const bare = entry.startsWith(".") ? entry.slice(1) : entry;
|
|
1058
|
+
if (host === bare || host.endsWith(`.${bare}`)) return true;
|
|
1059
|
+
}
|
|
1060
|
+
return false;
|
|
1061
|
+
}
|
|
1062
|
+
function resolveEnvProxy(ctx, env = process.env) {
|
|
1063
|
+
const raw = env.HTTPS_PROXY ?? env.https_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? env.HTTP_PROXY ?? env.http_proxy;
|
|
1064
|
+
if (!raw || !raw.trim()) return void 0;
|
|
1065
|
+
const host = hostFromCtx(ctx);
|
|
1066
|
+
if (host && (isLoopbackHost(host) || noProxyMatches(env.NO_PROXY ?? env.no_proxy, host))) {
|
|
1067
|
+
return void 0;
|
|
1068
|
+
}
|
|
1069
|
+
const url = raw.trim();
|
|
1070
|
+
if (envProxyLoggedFor !== url) {
|
|
1071
|
+
envProxyLoggedFor = url;
|
|
1072
|
+
console.info(`[upstream-proxy] routing upstream egress through the environment proxy: ${maskProxyUrl(url)}`);
|
|
1073
|
+
}
|
|
1074
|
+
return { url };
|
|
1075
|
+
}
|
|
1076
|
+
function createUpstreamProxyResolver(src = {}) {
|
|
1077
|
+
const readServer = src.getServerProxy ?? getServerProxyConfig;
|
|
1078
|
+
return (ctx) => {
|
|
1079
|
+
const host = hostFromCtx(ctx);
|
|
1080
|
+
if (host) {
|
|
1081
|
+
if (isLoopbackHost(host)) return void 0;
|
|
1082
|
+
const env = src.env ?? process.env;
|
|
1083
|
+
if (noProxyMatches(env.NO_PROXY ?? env.no_proxy, host)) return void 0;
|
|
1084
|
+
}
|
|
1085
|
+
if (src.getAccountProxy && ctx.providerId && ctx.accountId) {
|
|
1086
|
+
const account = src.getAccountProxy(ctx.providerId, ctx.accountId);
|
|
1087
|
+
if (account) return account;
|
|
1088
|
+
}
|
|
1089
|
+
const server = readServer();
|
|
1090
|
+
if (ctx.providerId && server?.byProvider?.[ctx.providerId]) {
|
|
1091
|
+
return server.byProvider[ctx.providerId];
|
|
1092
|
+
}
|
|
1093
|
+
if (server?.global) return server.global;
|
|
1094
|
+
return resolveEnvProxy(ctx, src.env);
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
696
1098
|
// src/admin/accountsOAuth.ts
|
|
697
1099
|
import { claudeOAuth, geminiOAuth } from "@omnicross/subscriptions";
|
|
698
1100
|
|
|
@@ -815,6 +1217,24 @@ function validateTokenBody(providerId, body) {
|
|
|
815
1217
|
return null;
|
|
816
1218
|
}
|
|
817
1219
|
}
|
|
1220
|
+
function validateSupportedModelsBody(raw) {
|
|
1221
|
+
if (raw === null || raw === void 0) return { ok: true, value: void 0 };
|
|
1222
|
+
if (Array.isArray(raw)) {
|
|
1223
|
+
if (raw.length === 0) return { ok: false };
|
|
1224
|
+
if (!raw.every((x) => typeof x === "string" && x.trim().length > 0)) return { ok: false };
|
|
1225
|
+
return { ok: true, value: raw };
|
|
1226
|
+
}
|
|
1227
|
+
if (typeof raw === "object") {
|
|
1228
|
+
const entries = Object.entries(raw);
|
|
1229
|
+
if (entries.length === 0) return { ok: false };
|
|
1230
|
+
const valid = entries.every(
|
|
1231
|
+
([k, v]) => k.trim().length > 0 && typeof v === "string" && v.trim().length > 0
|
|
1232
|
+
);
|
|
1233
|
+
if (!valid) return { ok: false };
|
|
1234
|
+
return { ok: true, value: Object.fromEntries(entries) };
|
|
1235
|
+
}
|
|
1236
|
+
return { ok: false };
|
|
1237
|
+
}
|
|
818
1238
|
async function statusEntryFor(reader, providerId) {
|
|
819
1239
|
const all = await reader.listAll();
|
|
820
1240
|
return all.find((a) => a.providerId === providerId) ?? null;
|
|
@@ -1096,6 +1516,448 @@ async function handleCliLaunch(cli, body, ctx) {
|
|
|
1096
1516
|
return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
|
|
1097
1517
|
}
|
|
1098
1518
|
|
|
1519
|
+
// src/admin/auditConfigBody.ts
|
|
1520
|
+
var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1521
|
+
function validateAuditSegment(patch) {
|
|
1522
|
+
const errors = [];
|
|
1523
|
+
const audit = patch.audit;
|
|
1524
|
+
if (audit === void 0) return errors;
|
|
1525
|
+
if (!isPlainObject(audit)) {
|
|
1526
|
+
errors.push("audit must be an object");
|
|
1527
|
+
return errors;
|
|
1528
|
+
}
|
|
1529
|
+
for (const flag of ["enabled", "captureBodies", "trustForwardedFor"]) {
|
|
1530
|
+
if (audit[flag] !== void 0 && typeof audit[flag] !== "boolean") {
|
|
1531
|
+
errors.push(`audit.${flag} must be a boolean`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const maxBodyBytes = audit["maxBodyBytes"];
|
|
1535
|
+
if (maxBodyBytes !== void 0 && (typeof maxBodyBytes !== "number" || !Number.isFinite(maxBodyBytes) || maxBodyBytes < 0)) {
|
|
1536
|
+
errors.push("audit.maxBodyBytes must be a non-negative number");
|
|
1537
|
+
}
|
|
1538
|
+
const retentionDays = audit["retentionDays"];
|
|
1539
|
+
if (retentionDays !== void 0 && (typeof retentionDays !== "number" || !Number.isFinite(retentionDays) || retentionDays < 0)) {
|
|
1540
|
+
errors.push("audit.retentionDays must be a non-negative number");
|
|
1541
|
+
}
|
|
1542
|
+
return errors;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/admin/billingConfigBody.ts
|
|
1546
|
+
var BILLING_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1547
|
+
var isPlainObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1548
|
+
function validateBillingSegment(patch) {
|
|
1549
|
+
const errors = [];
|
|
1550
|
+
const billing = patch.billing;
|
|
1551
|
+
if (billing === void 0) return errors;
|
|
1552
|
+
if (!isPlainObject2(billing)) {
|
|
1553
|
+
errors.push("billing must be an object");
|
|
1554
|
+
return errors;
|
|
1555
|
+
}
|
|
1556
|
+
if (billing["enabled"] !== void 0 && typeof billing["enabled"] !== "boolean") {
|
|
1557
|
+
errors.push("billing.enabled must be a boolean");
|
|
1558
|
+
}
|
|
1559
|
+
if (billing["endpoint"] !== void 0 && typeof billing["endpoint"] !== "string") {
|
|
1560
|
+
errors.push("billing.endpoint must be a string");
|
|
1561
|
+
}
|
|
1562
|
+
if (billing["secret"] !== void 0 && typeof billing["secret"] !== "string") {
|
|
1563
|
+
errors.push("billing.secret must be a string");
|
|
1564
|
+
}
|
|
1565
|
+
const maxRetryAgeMs = billing["maxRetryAgeMs"];
|
|
1566
|
+
if (maxRetryAgeMs !== void 0 && (typeof maxRetryAgeMs !== "number" || !Number.isFinite(maxRetryAgeMs) || maxRetryAgeMs < 0)) {
|
|
1567
|
+
errors.push("billing.maxRetryAgeMs must be a non-negative number");
|
|
1568
|
+
}
|
|
1569
|
+
return errors;
|
|
1570
|
+
}
|
|
1571
|
+
function redactBillingConfig(billing) {
|
|
1572
|
+
if (typeof billing.secret === "string" && billing.secret.length > 0) {
|
|
1573
|
+
return { ...billing, secret: BILLING_SECRET_MASK };
|
|
1574
|
+
}
|
|
1575
|
+
return billing;
|
|
1576
|
+
}
|
|
1577
|
+
function preserveBillingSecret(incoming, current) {
|
|
1578
|
+
const isMaskedOrBlank = incoming.secret === void 0 || incoming.secret === "" || incoming.secret === BILLING_SECRET_MASK;
|
|
1579
|
+
if (isMaskedOrBlank) {
|
|
1580
|
+
if (current?.secret) return { ...incoming, secret: current.secret };
|
|
1581
|
+
const { secret: _secret, ...rest } = incoming;
|
|
1582
|
+
return rest;
|
|
1583
|
+
}
|
|
1584
|
+
return incoming;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// src/admin/dashboard.ts
|
|
1588
|
+
function startOfLocalDayMs(ts) {
|
|
1589
|
+
const d = new Date(ts);
|
|
1590
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
1591
|
+
}
|
|
1592
|
+
function accountProviderId(entry) {
|
|
1593
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1594
|
+
const e = entry;
|
|
1595
|
+
if (typeof e["providerId"] === "string" && e["providerId"]) return e["providerId"];
|
|
1596
|
+
if (typeof e["provider"] === "string" && e["provider"]) return e["provider"];
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
async function handleDashboard(deps) {
|
|
1600
|
+
const now = Date.now();
|
|
1601
|
+
const today = await deps.usageRecorder.getTotals({ startTs: startOfLocalDayMs(now), endTs: now });
|
|
1602
|
+
const total = await deps.usageRecorder.getTotals({ startTs: 0, endTs: now });
|
|
1603
|
+
const providerList = loadConfig(deps.configPath).providers;
|
|
1604
|
+
const providers = {
|
|
1605
|
+
total: providerList.length,
|
|
1606
|
+
enabled: providerList.filter((p) => p.enabled !== false).length
|
|
1607
|
+
};
|
|
1608
|
+
const keys = await deps.keyDb.outboundApiKeysList();
|
|
1609
|
+
const outboundKeys = {
|
|
1610
|
+
total: keys.length,
|
|
1611
|
+
active: keys.filter((k) => k.enabled && k.revokedAt === null).length
|
|
1612
|
+
};
|
|
1613
|
+
const accountsList = await deps.subscriptionAccounts.listAll();
|
|
1614
|
+
const byProvider = {};
|
|
1615
|
+
for (const entry of accountsList) {
|
|
1616
|
+
const providerId = accountProviderId(entry);
|
|
1617
|
+
if (providerId) byProvider[providerId] = (byProvider[providerId] ?? 0) + 1;
|
|
1618
|
+
}
|
|
1619
|
+
const accounts = { total: accountsList.length, byProvider };
|
|
1620
|
+
const status = deps.outboundApiServer.getStatus();
|
|
1621
|
+
const server = {
|
|
1622
|
+
running: status.running,
|
|
1623
|
+
port: status.port,
|
|
1624
|
+
uptimeMs: Math.round(process.uptime() * 1e3)
|
|
1625
|
+
};
|
|
1626
|
+
const summary = { today, total, providers, outboundKeys, accounts, server, generatedAt: now };
|
|
1627
|
+
return { status: 200, body: summary };
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/admin/keyPolicyBody.ts
|
|
1631
|
+
function parseKeyPolicyBody(body) {
|
|
1632
|
+
const policy = {};
|
|
1633
|
+
if ("activationMode" in body) {
|
|
1634
|
+
const m = body["activationMode"];
|
|
1635
|
+
if (m === null) policy.activationMode = null;
|
|
1636
|
+
else if (m === "fixed" || m === "activation") policy.activationMode = m;
|
|
1637
|
+
else return { ok: false, message: "activationMode must be 'fixed', 'activation', or null" };
|
|
1638
|
+
}
|
|
1639
|
+
const numericFields = [
|
|
1640
|
+
{ key: "expiresAt", min: 0 },
|
|
1641
|
+
{ key: "activationDays", min: 1, integer: true },
|
|
1642
|
+
{ key: "dailyCostLimitUsd", min: 0 },
|
|
1643
|
+
{ key: "totalCostLimitUsd", min: 0 },
|
|
1644
|
+
{ key: "weeklyCostLimitUsd", min: 0 },
|
|
1645
|
+
{ key: "rateLimitMaxRequests", min: 0, integer: true },
|
|
1646
|
+
{ key: "rateLimitWindowMs", min: 1 }
|
|
1647
|
+
];
|
|
1648
|
+
for (const { key, min, integer } of numericFields) {
|
|
1649
|
+
if (!(key in body)) continue;
|
|
1650
|
+
const v = body[key];
|
|
1651
|
+
if (v === null) {
|
|
1652
|
+
policy[key] = null;
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < min || integer && !Number.isInteger(v)) {
|
|
1656
|
+
return {
|
|
1657
|
+
ok: false,
|
|
1658
|
+
message: `${key} must be ${integer ? "an integer" : "a number"} >= ${min} or null`
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
policy[key] = v;
|
|
1662
|
+
}
|
|
1663
|
+
if ("enableModelRestriction" in body) {
|
|
1664
|
+
const v = body["enableModelRestriction"];
|
|
1665
|
+
if (v === null) policy.enableModelRestriction = null;
|
|
1666
|
+
else if (typeof v === "boolean") policy.enableModelRestriction = v;
|
|
1667
|
+
else return { ok: false, message: "enableModelRestriction must be a boolean or null" };
|
|
1668
|
+
}
|
|
1669
|
+
if ("restrictionMode" in body) {
|
|
1670
|
+
const v = body["restrictionMode"];
|
|
1671
|
+
if (v === null) policy.restrictionMode = null;
|
|
1672
|
+
else if (v === "blacklist" || v === "allowlist") policy.restrictionMode = v;
|
|
1673
|
+
else return { ok: false, message: "restrictionMode must be 'blacklist', 'allowlist', or null" };
|
|
1674
|
+
}
|
|
1675
|
+
if ("restrictedModels" in body) {
|
|
1676
|
+
const v = body["restrictedModels"];
|
|
1677
|
+
if (v === null) {
|
|
1678
|
+
policy.restrictedModels = null;
|
|
1679
|
+
} else if (Array.isArray(v) && v.every((e) => typeof e === "string")) {
|
|
1680
|
+
policy.restrictedModels = v.map((e) => e.trim()).filter((e) => e !== "");
|
|
1681
|
+
} else {
|
|
1682
|
+
return { ok: false, message: "restrictedModels must be an array of strings or null" };
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
return { ok: true, policy };
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// src/admin/voucherAdmin.ts
|
|
1689
|
+
import {
|
|
1690
|
+
generateVoucherCode,
|
|
1691
|
+
hashVoucherCode,
|
|
1692
|
+
loadServerConfig,
|
|
1693
|
+
newVoucherId,
|
|
1694
|
+
toVoucherInfo,
|
|
1695
|
+
voucherCodePrefix
|
|
1696
|
+
} from "@omnicross/core/outbound-api";
|
|
1697
|
+
function writeJson(res, status, body) {
|
|
1698
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1699
|
+
res.end(JSON.stringify(body));
|
|
1700
|
+
}
|
|
1701
|
+
function writeErr(res, status, message) {
|
|
1702
|
+
writeJson(res, status, { error: { type: "voucher_error", message } });
|
|
1703
|
+
}
|
|
1704
|
+
function readJsonBody2(req) {
|
|
1705
|
+
return new Promise((resolve, reject) => {
|
|
1706
|
+
const chunks = [];
|
|
1707
|
+
req.on("data", (c) => chunks.push(c));
|
|
1708
|
+
req.on("end", () => {
|
|
1709
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1710
|
+
if (!raw.trim()) return resolve({});
|
|
1711
|
+
try {
|
|
1712
|
+
const parsed = JSON.parse(raw);
|
|
1713
|
+
resolve(parsed && typeof parsed === "object" ? parsed : {});
|
|
1714
|
+
} catch {
|
|
1715
|
+
reject(new Error("invalid-json"));
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
req.on("error", reject);
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
function optPositive(value, integer) {
|
|
1722
|
+
if (value === void 0 || value === null) return { ok: true, value: void 0 };
|
|
1723
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return { ok: false };
|
|
1724
|
+
if (integer && !Number.isInteger(value)) return { ok: false };
|
|
1725
|
+
return { ok: true, value };
|
|
1726
|
+
}
|
|
1727
|
+
function parseVoucherCreateBody(body) {
|
|
1728
|
+
const type = body["type"];
|
|
1729
|
+
if (type !== "credit" && type !== "renewal") {
|
|
1730
|
+
return { ok: false, message: "type must be 'credit' or 'renewal'" };
|
|
1731
|
+
}
|
|
1732
|
+
const maxTotal = optPositive(body["maxTotalCostLimitUsd"], false);
|
|
1733
|
+
if (!maxTotal.ok) return { ok: false, message: "maxTotalCostLimitUsd must be a positive number" };
|
|
1734
|
+
const maxDays = optPositive(body["maxExpiryDays"], true);
|
|
1735
|
+
if (!maxDays.ok) return { ok: false, message: "maxExpiryDays must be a positive integer" };
|
|
1736
|
+
const input = { type };
|
|
1737
|
+
if (maxTotal.value !== void 0) input.maxTotalCostLimitUsd = maxTotal.value;
|
|
1738
|
+
if (maxDays.value !== void 0) input.maxExpiryDays = maxDays.value;
|
|
1739
|
+
if (type === "credit") {
|
|
1740
|
+
const credit = optPositive(body["creditUsd"], false);
|
|
1741
|
+
if (!credit.ok || credit.value === void 0) {
|
|
1742
|
+
return { ok: false, message: "creditUsd must be a positive number for a credit card" };
|
|
1743
|
+
}
|
|
1744
|
+
input.creditUsd = credit.value;
|
|
1745
|
+
} else {
|
|
1746
|
+
const days = optPositive(body["renewalDays"], true);
|
|
1747
|
+
if (!days.ok || days.value === void 0) {
|
|
1748
|
+
return { ok: false, message: "renewalDays must be a positive integer for a renewal card" };
|
|
1749
|
+
}
|
|
1750
|
+
input.renewalDays = days.value;
|
|
1751
|
+
}
|
|
1752
|
+
return { ok: true, input };
|
|
1753
|
+
}
|
|
1754
|
+
async function voucherEnabled(deps) {
|
|
1755
|
+
const config = await loadServerConfig(deps.settingsStore);
|
|
1756
|
+
return config.voucher?.enabled === true;
|
|
1757
|
+
}
|
|
1758
|
+
async function handleVoucher(req, res, method, rest, deps) {
|
|
1759
|
+
const voucherDb = deps.voucherDb;
|
|
1760
|
+
if (!voucherDb) return writeErr(res, 501, "Voucher feature is not available");
|
|
1761
|
+
if (method === "GET" && rest.length === 0) {
|
|
1762
|
+
const rows = await voucherDb.voucherList();
|
|
1763
|
+
return writeJson(res, 200, { vouchers: rows.map(toVoucherInfo) });
|
|
1764
|
+
}
|
|
1765
|
+
if (method === "POST" && rest.length === 0) {
|
|
1766
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
1767
|
+
let body;
|
|
1768
|
+
try {
|
|
1769
|
+
body = await readJsonBody2(req);
|
|
1770
|
+
} catch {
|
|
1771
|
+
return writeErr(res, 400, "Invalid JSON in request body");
|
|
1772
|
+
}
|
|
1773
|
+
const parsed = parseVoucherCreateBody(body);
|
|
1774
|
+
if (!parsed.ok) return writeErr(res, 400, parsed.message);
|
|
1775
|
+
const code = generateVoucherCode();
|
|
1776
|
+
const created = await voucherDb.voucherCreate({
|
|
1777
|
+
id: newVoucherId(),
|
|
1778
|
+
codeHash: hashVoucherCode(code),
|
|
1779
|
+
codePrefix: voucherCodePrefix(code),
|
|
1780
|
+
...parsed.input
|
|
1781
|
+
});
|
|
1782
|
+
return writeJson(res, 201, {
|
|
1783
|
+
id: created.id,
|
|
1784
|
+
codePrefix: created.codePrefix,
|
|
1785
|
+
type: created.type,
|
|
1786
|
+
createdAt: created.createdAt,
|
|
1787
|
+
// `plaintextOnce` is the ONLY place the full code crosses the wire (D3).
|
|
1788
|
+
plaintextOnce: code
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
const id = rest[0];
|
|
1792
|
+
if (method === "POST" && id && rest[1] === "revoke") {
|
|
1793
|
+
if (!await voucherEnabled(deps)) return writeErr(res, 403, "Voucher feature is disabled");
|
|
1794
|
+
const ok = await voucherDb.voucherRevokeCas(id, Date.now());
|
|
1795
|
+
return writeJson(res, ok ? 200 : 409, { ok });
|
|
1796
|
+
}
|
|
1797
|
+
return writeErr(res, 405, `method ${method} not allowed on voucher`);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// src/admin/webhookConfigBody.ts
|
|
1801
|
+
import {
|
|
1802
|
+
WEBHOOK_DESTINATION_TYPES,
|
|
1803
|
+
WEBHOOK_EVENT_KINDS
|
|
1804
|
+
} from "@omnicross/contracts/webhook-types";
|
|
1805
|
+
var WEBHOOK_SECRET_MASK = "\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
1806
|
+
var isPlainObject3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1807
|
+
function validateWebhookSegment(patch) {
|
|
1808
|
+
const errors = [];
|
|
1809
|
+
const webhook = patch.webhook;
|
|
1810
|
+
if (webhook === void 0) return errors;
|
|
1811
|
+
if (!isPlainObject3(webhook)) {
|
|
1812
|
+
errors.push("webhook must be an object");
|
|
1813
|
+
return errors;
|
|
1814
|
+
}
|
|
1815
|
+
if (typeof webhook["enabled"] !== "boolean") {
|
|
1816
|
+
errors.push("webhook.enabled must be a boolean");
|
|
1817
|
+
}
|
|
1818
|
+
const destinations = webhook["destinations"];
|
|
1819
|
+
if (destinations !== void 0 && !Array.isArray(destinations)) {
|
|
1820
|
+
errors.push("webhook.destinations must be an array");
|
|
1821
|
+
return errors;
|
|
1822
|
+
}
|
|
1823
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
1824
|
+
for (const [i, raw] of (Array.isArray(destinations) ? destinations : []).entries()) {
|
|
1825
|
+
if (!isPlainObject3(raw)) {
|
|
1826
|
+
errors.push(`webhook.destinations[${i}] must be an object`);
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
const id = raw["id"];
|
|
1830
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
1831
|
+
errors.push(`webhook.destinations[${i}].id must be a non-empty string`);
|
|
1832
|
+
} else if (seenIds.has(id.trim())) {
|
|
1833
|
+
errors.push(`webhook.destinations[${i}].id '${id.trim()}' is duplicated`);
|
|
1834
|
+
} else {
|
|
1835
|
+
seenIds.add(id.trim());
|
|
1836
|
+
}
|
|
1837
|
+
if (typeof raw["type"] !== "string" || !WEBHOOK_DESTINATION_TYPES.includes(raw["type"])) {
|
|
1838
|
+
errors.push(`webhook.destinations[${i}].type must be one of ${WEBHOOK_DESTINATION_TYPES.join(", ")}`);
|
|
1839
|
+
}
|
|
1840
|
+
if (typeof raw["url"] !== "string" || !raw["url"].trim()) {
|
|
1841
|
+
errors.push(`webhook.destinations[${i}].url must be a non-empty string`);
|
|
1842
|
+
}
|
|
1843
|
+
if (raw["secret"] !== void 0 && typeof raw["secret"] !== "string") {
|
|
1844
|
+
errors.push(`webhook.destinations[${i}].secret must be a string`);
|
|
1845
|
+
}
|
|
1846
|
+
if (raw["enabled"] !== void 0 && typeof raw["enabled"] !== "boolean") {
|
|
1847
|
+
errors.push(`webhook.destinations[${i}].enabled must be a boolean`);
|
|
1848
|
+
}
|
|
1849
|
+
const events = raw["events"];
|
|
1850
|
+
if (events !== void 0) {
|
|
1851
|
+
if (!Array.isArray(events)) {
|
|
1852
|
+
errors.push(`webhook.destinations[${i}].events must be an array`);
|
|
1853
|
+
} else {
|
|
1854
|
+
for (const e of events) {
|
|
1855
|
+
if (typeof e !== "string" || !WEBHOOK_EVENT_KINDS.includes(e)) {
|
|
1856
|
+
errors.push(`webhook.destinations[${i}].events contains an unknown kind '${String(e)}'`);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
return errors;
|
|
1863
|
+
}
|
|
1864
|
+
function redactWebhookConfig(webhook) {
|
|
1865
|
+
return {
|
|
1866
|
+
...webhook,
|
|
1867
|
+
destinations: webhook.destinations.map(
|
|
1868
|
+
(d) => typeof d.secret === "string" && d.secret.length > 0 ? { ...d, secret: WEBHOOK_SECRET_MASK } : d
|
|
1869
|
+
)
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
function preserveWebhookSecrets(incoming, current) {
|
|
1873
|
+
const currentById = /* @__PURE__ */ new Map();
|
|
1874
|
+
for (const d of current?.destinations ?? []) currentById.set(d.id, d);
|
|
1875
|
+
return {
|
|
1876
|
+
...incoming,
|
|
1877
|
+
destinations: incoming.destinations.map((d) => {
|
|
1878
|
+
const isMaskedOrBlank = d.secret === void 0 || d.secret === "" || d.secret === WEBHOOK_SECRET_MASK;
|
|
1879
|
+
if (isMaskedOrBlank) {
|
|
1880
|
+
const prev = currentById.get(d.id);
|
|
1881
|
+
if (prev?.secret) return { ...d, secret: prev.secret };
|
|
1882
|
+
const { secret: _secret, ...rest } = d;
|
|
1883
|
+
return rest;
|
|
1884
|
+
}
|
|
1885
|
+
return d;
|
|
1886
|
+
})
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// src/audit/auditRuntime.ts
|
|
1891
|
+
import { setAuditCaptureConfig, setAuditSink } from "@omnicross/core/pipeline/auditSink";
|
|
1892
|
+
var writer = null;
|
|
1893
|
+
var sweeper = null;
|
|
1894
|
+
function setAuditRuntime(w, s) {
|
|
1895
|
+
writer = w;
|
|
1896
|
+
sweeper = s;
|
|
1897
|
+
}
|
|
1898
|
+
function applyAuditConfig(config) {
|
|
1899
|
+
const enabled = config?.enabled === true && writer !== null;
|
|
1900
|
+
if (enabled && config) {
|
|
1901
|
+
setAuditCaptureConfig(config);
|
|
1902
|
+
const activeWriter = writer;
|
|
1903
|
+
setAuditSink((record) => activeWriter.record(record));
|
|
1904
|
+
if (sweeper) {
|
|
1905
|
+
sweeper.configure(config);
|
|
1906
|
+
sweeper.start();
|
|
1907
|
+
}
|
|
1908
|
+
} else {
|
|
1909
|
+
setAuditCaptureConfig(null);
|
|
1910
|
+
setAuditSink(null);
|
|
1911
|
+
if (sweeper) {
|
|
1912
|
+
if (config) sweeper.configure(config);
|
|
1913
|
+
sweeper.dispose();
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
function resetAuditRuntimeForTests() {
|
|
1918
|
+
setAuditCaptureConfig(null);
|
|
1919
|
+
setAuditSink(null);
|
|
1920
|
+
if (sweeper) sweeper.dispose();
|
|
1921
|
+
writer = null;
|
|
1922
|
+
sweeper = null;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// src/billing/billingRuntime.ts
|
|
1926
|
+
import { setBillingCaptureConfig, setBillingSink } from "@omnicross/core/pipeline/billingEmit";
|
|
1927
|
+
var publisher = null;
|
|
1928
|
+
var sweeper2 = null;
|
|
1929
|
+
function setBillingRuntime(p, s) {
|
|
1930
|
+
publisher = p;
|
|
1931
|
+
sweeper2 = s;
|
|
1932
|
+
}
|
|
1933
|
+
function applyBillingConfig(config) {
|
|
1934
|
+
const enabled = config?.enabled === true && publisher !== null;
|
|
1935
|
+
if (enabled && config) {
|
|
1936
|
+
const activePublisher = publisher;
|
|
1937
|
+
activePublisher.setConfig(config);
|
|
1938
|
+
setBillingCaptureConfig(config);
|
|
1939
|
+
setBillingSink((event) => activePublisher.record(event));
|
|
1940
|
+
if (sweeper2) {
|
|
1941
|
+
sweeper2.configure(config);
|
|
1942
|
+
sweeper2.start();
|
|
1943
|
+
}
|
|
1944
|
+
} else {
|
|
1945
|
+
setBillingCaptureConfig(null);
|
|
1946
|
+
setBillingSink(null);
|
|
1947
|
+
if (sweeper2) {
|
|
1948
|
+
if (config) sweeper2.configure(config);
|
|
1949
|
+
sweeper2.dispose();
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function resetBillingRuntimeForTests() {
|
|
1954
|
+
setBillingCaptureConfig(null);
|
|
1955
|
+
setBillingSink(null);
|
|
1956
|
+
if (sweeper2) sweeper2.dispose();
|
|
1957
|
+
publisher = null;
|
|
1958
|
+
sweeper2 = null;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1099
1961
|
// src/ports/account-multi.ts
|
|
1100
1962
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1101
1963
|
var PROVIDER_KEYS = {
|
|
@@ -1207,6 +2069,9 @@ function getAccountById(config, p, id) {
|
|
|
1207
2069
|
const account = getAccounts(config, p).find((a) => a.id === id);
|
|
1208
2070
|
return account ? { id: account.id, tokens: account.tokens } : void 0;
|
|
1209
2071
|
}
|
|
2072
|
+
function getAccountProxy(config, p, id) {
|
|
2073
|
+
return getAccounts(config, p).find((a) => a.id === id)?.proxy;
|
|
2074
|
+
}
|
|
1210
2075
|
function getActiveAccount(config, p) {
|
|
1211
2076
|
const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
|
|
1212
2077
|
return active ? { id: active.id, tokens: active.tokens } : void 0;
|
|
@@ -1247,7 +2112,17 @@ function sanitizeAccounts(config, p) {
|
|
|
1247
2112
|
isSetupToken: t.isSetupToken,
|
|
1248
2113
|
hasAccessToken: !!(t.accessToken || t.apiKey),
|
|
1249
2114
|
isActive: a.id === activeId,
|
|
1250
|
-
|
|
2115
|
+
// Scheduling metadata (subscription-account-scheduling): editable priority
|
|
2116
|
+
// (default 50 shown when unset) + display-only lastUsedAt. Secret-free.
|
|
2117
|
+
priority: a.priority,
|
|
2118
|
+
lastUsedAt: a.lastUsedAt,
|
|
2119
|
+
syncWarning: t.syncWarning,
|
|
2120
|
+
// Per-account proxy (upstream-proxy): masked view — password → hasPassword,
|
|
2121
|
+
// userinfo stripped. The plaintext password is NEVER projected.
|
|
2122
|
+
proxy: a.proxy ? sanitizeProxyConfig(a.proxy) : void 0,
|
|
2123
|
+
// Per-account model support / remap (subscription-account-model-map): model
|
|
2124
|
+
// ids are not token material → carried through verbatim for the editor.
|
|
2125
|
+
supportedModels: a.supportedModels
|
|
1251
2126
|
};
|
|
1252
2127
|
});
|
|
1253
2128
|
}
|
|
@@ -1261,19 +2136,90 @@ function renameAccount(config, p, id, label) {
|
|
|
1261
2136
|
);
|
|
1262
2137
|
return { ok: true };
|
|
1263
2138
|
}
|
|
1264
|
-
function
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
2139
|
+
function setAccountPriority(config, p, id, priority) {
|
|
2140
|
+
const accounts = getAccounts(config, p);
|
|
2141
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2142
|
+
setAccounts(
|
|
2143
|
+
config,
|
|
2144
|
+
p,
|
|
2145
|
+
accounts.map((a) => a.id === id ? { ...a, priority } : a)
|
|
2146
|
+
);
|
|
2147
|
+
return { ok: true };
|
|
1268
2148
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
2149
|
+
function setAccountProxy(config, p, id, proxy) {
|
|
2150
|
+
const accounts = getAccounts(config, p);
|
|
2151
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2152
|
+
setAccounts(
|
|
2153
|
+
config,
|
|
2154
|
+
p,
|
|
2155
|
+
accounts.map((a) => {
|
|
2156
|
+
if (a.id !== id) return a;
|
|
2157
|
+
if (!proxy) {
|
|
2158
|
+
const { proxy: _drop, ...rest } = a;
|
|
2159
|
+
return rest;
|
|
2160
|
+
}
|
|
2161
|
+
return { ...a, proxy };
|
|
2162
|
+
})
|
|
2163
|
+
);
|
|
2164
|
+
return { ok: true };
|
|
2165
|
+
}
|
|
2166
|
+
function setAccountSupportedModels(config, p, id, supportedModels) {
|
|
2167
|
+
const accounts = getAccounts(config, p);
|
|
2168
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2169
|
+
setAccounts(
|
|
2170
|
+
config,
|
|
2171
|
+
p,
|
|
2172
|
+
accounts.map((a) => {
|
|
2173
|
+
if (a.id !== id) return a;
|
|
2174
|
+
if (supportedModels === void 0) {
|
|
2175
|
+
const { supportedModels: _drop, ...rest } = a;
|
|
2176
|
+
return rest;
|
|
2177
|
+
}
|
|
2178
|
+
return { ...a, supportedModels };
|
|
2179
|
+
})
|
|
2180
|
+
);
|
|
2181
|
+
return { ok: true };
|
|
2182
|
+
}
|
|
2183
|
+
function setAccountLastUsed(config, p, id, iso) {
|
|
2184
|
+
const accounts = getAccounts(config, p);
|
|
2185
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2186
|
+
setAccounts(
|
|
2187
|
+
config,
|
|
2188
|
+
p,
|
|
2189
|
+
accounts.map((a) => a.id === id ? { ...a, lastUsedAt: iso } : a)
|
|
2190
|
+
);
|
|
2191
|
+
return { ok: true };
|
|
2192
|
+
}
|
|
2193
|
+
function setAccountIdentity(config, p, id, identity) {
|
|
2194
|
+
const accounts = getAccounts(config, p);
|
|
2195
|
+
if (!accounts.some((a) => a.id === id)) return { ok: false };
|
|
2196
|
+
setAccounts(
|
|
2197
|
+
config,
|
|
2198
|
+
p,
|
|
2199
|
+
accounts.map((a) => {
|
|
2200
|
+
if (a.id !== id) return a;
|
|
2201
|
+
if (identity === void 0) {
|
|
2202
|
+
const { identity: _drop, ...rest } = a;
|
|
2203
|
+
return rest;
|
|
2204
|
+
}
|
|
2205
|
+
return { ...a, identity };
|
|
2206
|
+
})
|
|
2207
|
+
);
|
|
2208
|
+
return { ok: true };
|
|
2209
|
+
}
|
|
2210
|
+
function clearProvider(config, p) {
|
|
2211
|
+
setBlock(config, p, void 0);
|
|
2212
|
+
setAccounts(config, p, void 0);
|
|
2213
|
+
setActiveId(config, p, void 0);
|
|
2214
|
+
}
|
|
2215
|
+
var DAEMON_PROVIDER_KEYS = PROVIDER_KEYS;
|
|
2216
|
+
|
|
2217
|
+
// src/migration/packCodec.ts
|
|
2218
|
+
import { createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2, randomBytes as randomBytes3, scryptSync } from "crypto";
|
|
2219
|
+
var PACK_MAGIC = "OMCXPACK";
|
|
2220
|
+
var PACK_VERSION = 1;
|
|
2221
|
+
var KDF_ALGORITHM = "scrypt";
|
|
2222
|
+
var PACK_PREFIX = `${PACK_MAGIC}${PACK_VERSION}.`;
|
|
1277
2223
|
var KEY_BYTES3 = 32;
|
|
1278
2224
|
var IV_BYTES2 = 12;
|
|
1279
2225
|
var TAG_BYTES2 = 16;
|
|
@@ -1526,6 +2472,12 @@ function parseRange(query) {
|
|
|
1526
2472
|
return { startTs, endTs };
|
|
1527
2473
|
}
|
|
1528
2474
|
var isRange = (v) => v.startTs !== void 0 && !("status" in v);
|
|
2475
|
+
var BUCKET_SPAN_MS = {
|
|
2476
|
+
hour: 36e5,
|
|
2477
|
+
day: 864e5,
|
|
2478
|
+
month: 28 * 864e5
|
|
2479
|
+
};
|
|
2480
|
+
var MAX_TIMESERIES_BUCKETS = 2e3;
|
|
1529
2481
|
async function handleUsageGet(view, query, deps) {
|
|
1530
2482
|
const range = parseRange(query);
|
|
1531
2483
|
if (!isRange(range)) return range;
|
|
@@ -1534,6 +2486,24 @@ async function handleUsageGet(view, query, deps) {
|
|
|
1534
2486
|
return { status: 200, body: await deps.usageRecorder.getTotals(range) };
|
|
1535
2487
|
case "by-model":
|
|
1536
2488
|
return { status: 200, body: await deps.usageRecorder.getByModel(range) };
|
|
2489
|
+
case "timeseries": {
|
|
2490
|
+
const bucket = query.get("bucket");
|
|
2491
|
+
if (bucket !== "hour" && bucket !== "day" && bucket !== "month") {
|
|
2492
|
+
return err4(400, "bucket must be one of 'hour', 'day', 'month'");
|
|
2493
|
+
}
|
|
2494
|
+
const now = Date.now();
|
|
2495
|
+
const clamped = { startTs: range.startTs, endTs: Math.min(range.endTs, now) };
|
|
2496
|
+
if (clamped.startTs < clamped.endTs) {
|
|
2497
|
+
const projected = Math.ceil((clamped.endTs - clamped.startTs) / BUCKET_SPAN_MS[bucket]) + 1;
|
|
2498
|
+
if (projected > MAX_TIMESERIES_BUCKETS) {
|
|
2499
|
+
return err4(
|
|
2500
|
+
400,
|
|
2501
|
+
`requested range projects ~${projected} '${bucket}' buckets (max ${MAX_TIMESERIES_BUCKETS}); narrow the range or use a coarser bucket`
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
return { status: 200, body: await deps.usageRecorder.getTimeSeries(clamped, bucket) };
|
|
2506
|
+
}
|
|
1537
2507
|
case "by-api-key": {
|
|
1538
2508
|
const rows = await deps.usageRecorder.getByApiKey(range);
|
|
1539
2509
|
const labels = poolKeyLabels(loadConfig(deps.configPath));
|
|
@@ -1679,7 +2649,7 @@ function readBody(req) {
|
|
|
1679
2649
|
req.on("error", reject);
|
|
1680
2650
|
});
|
|
1681
2651
|
}
|
|
1682
|
-
async function
|
|
2652
|
+
async function readJsonBody3(req) {
|
|
1683
2653
|
const raw = await readBody(req);
|
|
1684
2654
|
if (!raw.trim()) return {};
|
|
1685
2655
|
try {
|
|
@@ -1689,12 +2659,12 @@ async function readJsonBody(req) {
|
|
|
1689
2659
|
return {};
|
|
1690
2660
|
}
|
|
1691
2661
|
}
|
|
1692
|
-
function
|
|
2662
|
+
function writeJson2(res, status, body) {
|
|
1693
2663
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
1694
2664
|
res.end(JSON.stringify(body));
|
|
1695
2665
|
}
|
|
1696
2666
|
function writeJsonError(res, status, message) {
|
|
1697
|
-
|
|
2667
|
+
writeJson2(res, status, { error: { type: "admin_api_error", message } });
|
|
1698
2668
|
}
|
|
1699
2669
|
function maskProviderApiKey(apiKey) {
|
|
1700
2670
|
if (!apiKey) return "";
|
|
@@ -1710,7 +2680,23 @@ function toKeyInfo(row) {
|
|
|
1710
2680
|
enabled: row.enabled,
|
|
1711
2681
|
createdAt: row.createdAt,
|
|
1712
2682
|
lastUsedAt: row.lastUsedAt,
|
|
1713
|
-
revoked: row.revokedAt !== null
|
|
2683
|
+
revoked: row.revokedAt !== null,
|
|
2684
|
+
maxConcurrency: row.maxConcurrency,
|
|
2685
|
+
// Key-policy envelope (outbound-key-policy) — all secret-free scalar fields;
|
|
2686
|
+
// the UI reads them to render + pre-fill the policy editor.
|
|
2687
|
+
expiresAt: row.expiresAt,
|
|
2688
|
+
activationMode: row.activationMode,
|
|
2689
|
+
activationDays: row.activationDays,
|
|
2690
|
+
activatedAt: row.activatedAt,
|
|
2691
|
+
dailyCostLimitUsd: row.dailyCostLimitUsd,
|
|
2692
|
+
totalCostLimitUsd: row.totalCostLimitUsd,
|
|
2693
|
+
weeklyCostLimitUsd: row.weeklyCostLimitUsd,
|
|
2694
|
+
rateLimitMaxRequests: row.rateLimitMaxRequests,
|
|
2695
|
+
rateLimitWindowMs: row.rateLimitWindowMs,
|
|
2696
|
+
// Per-key model restriction (#6) — secret-free scalars the UI pre-fills.
|
|
2697
|
+
enableModelRestriction: row.enableModelRestriction,
|
|
2698
|
+
restrictionMode: row.restrictionMode,
|
|
2699
|
+
restrictedModels: row.restrictedModels
|
|
1714
2700
|
};
|
|
1715
2701
|
}
|
|
1716
2702
|
function toProviderView(row) {
|
|
@@ -1772,6 +2758,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
1772
2758
|
return handlePresets(res, method);
|
|
1773
2759
|
case "keys":
|
|
1774
2760
|
return await handleKeys(req, res, method, rest, deps);
|
|
2761
|
+
case "voucher":
|
|
2762
|
+
return await handleVoucher(req, res, method, rest, deps);
|
|
1775
2763
|
case "server":
|
|
1776
2764
|
return await handleServer(req, res, method, deps);
|
|
1777
2765
|
case "accounts":
|
|
@@ -1788,6 +2776,8 @@ async function handleAdminApi(req, res, path2, deps) {
|
|
|
1788
2776
|
return await handleMigrationImport(req, res, method, deps);
|
|
1789
2777
|
case "usage":
|
|
1790
2778
|
return await handleUsage(req, res, method, rest, deps);
|
|
2779
|
+
case "dashboard":
|
|
2780
|
+
return await handleDashboardRoute(res, method, deps);
|
|
1791
2781
|
case "pricing":
|
|
1792
2782
|
return await handlePricing(req, res, method, rest, deps);
|
|
1793
2783
|
default:
|
|
@@ -1803,17 +2793,22 @@ function requestQuery(req) {
|
|
|
1803
2793
|
return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
|
|
1804
2794
|
}
|
|
1805
2795
|
function writeResult(res, result) {
|
|
1806
|
-
|
|
2796
|
+
writeJson2(res, result.status, result.body);
|
|
1807
2797
|
}
|
|
1808
2798
|
async function handleUsage(req, res, method, rest, deps) {
|
|
1809
2799
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
|
|
1810
2800
|
return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
|
|
1811
2801
|
}
|
|
2802
|
+
async function handleDashboardRoute(res, method, deps) {
|
|
2803
|
+
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on dashboard`);
|
|
2804
|
+
const result = await handleDashboard(deps);
|
|
2805
|
+
return writeJson2(res, result.status, result.body);
|
|
2806
|
+
}
|
|
1812
2807
|
async function handlePricing(req, res, method, rest, deps) {
|
|
1813
2808
|
if (rest.length === 0) {
|
|
1814
2809
|
if (method === "GET") return writeResult(res, await handlePricingList(deps));
|
|
1815
2810
|
if (method === "PUT") {
|
|
1816
|
-
return writeResult(res, await handlePricingUpsert(await
|
|
2811
|
+
return writeResult(res, await handlePricingUpsert(await readJsonBody3(req), deps));
|
|
1817
2812
|
}
|
|
1818
2813
|
if (method === "DELETE") {
|
|
1819
2814
|
return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
|
|
@@ -1824,7 +2819,7 @@ async function handlePricing(req, res, method, rest, deps) {
|
|
|
1824
2819
|
return writeResult(res, await handlePricingFetchLatest(deps));
|
|
1825
2820
|
}
|
|
1826
2821
|
if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
|
|
1827
|
-
return writeResult(res, await handlePricingResolveConflicts(await
|
|
2822
|
+
return writeResult(res, await handlePricingResolveConflicts(await readJsonBody3(req), deps));
|
|
1828
2823
|
}
|
|
1829
2824
|
return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
|
|
1830
2825
|
}
|
|
@@ -1838,15 +2833,15 @@ function migrationDeps(deps) {
|
|
|
1838
2833
|
}
|
|
1839
2834
|
async function handleMigrationExport(req, res, method, deps) {
|
|
1840
2835
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on export`);
|
|
1841
|
-
const body = await
|
|
2836
|
+
const body = await readJsonBody3(req);
|
|
1842
2837
|
const result = await handleExport(body, migrationDeps(deps));
|
|
1843
|
-
return
|
|
2838
|
+
return writeJson2(res, result.status, result.body);
|
|
1844
2839
|
}
|
|
1845
2840
|
async function handleMigrationImport(req, res, method, deps) {
|
|
1846
2841
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on import`);
|
|
1847
|
-
const body = await
|
|
2842
|
+
const body = await readJsonBody3(req);
|
|
1848
2843
|
const result = await handleImport(body, migrationDeps(deps));
|
|
1849
|
-
return
|
|
2844
|
+
return writeJson2(res, result.status, result.body);
|
|
1850
2845
|
}
|
|
1851
2846
|
async function handleProviders(req, res, method, rest, deps) {
|
|
1852
2847
|
const cfg = loadConfig(deps.configPath);
|
|
@@ -1877,13 +2872,13 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1877
2872
|
if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
|
|
1878
2873
|
const row = cfg.providers.find((p) => p.id === rest[0]);
|
|
1879
2874
|
if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
|
|
1880
|
-
return
|
|
2875
|
+
return writeJson2(res, 200, { apiKey: row.apiKey ?? "" });
|
|
1881
2876
|
}
|
|
1882
2877
|
if (method === "GET") {
|
|
1883
|
-
return
|
|
2878
|
+
return writeJson2(res, 200, { providers: cfg.providers.map(toProviderView) });
|
|
1884
2879
|
}
|
|
1885
2880
|
if (method === "POST") {
|
|
1886
|
-
const body = await
|
|
2881
|
+
const body = await readJsonBody3(req);
|
|
1887
2882
|
const provider = parseProviderInput(body, void 0);
|
|
1888
2883
|
if (!provider) return writeJsonError(res, 400, "invalid provider (id, apiFormat, baseUrl required)");
|
|
1889
2884
|
if (cfg.providers.some((p) => p.id === provider.id)) {
|
|
@@ -1891,25 +2886,25 @@ async function handleProviders(req, res, method, rest, deps) {
|
|
|
1891
2886
|
}
|
|
1892
2887
|
cfg.providers.push(provider);
|
|
1893
2888
|
persistProviders(cfg, deps);
|
|
1894
|
-
return
|
|
2889
|
+
return writeJson2(res, 201, { provider: toProviderView(provider) });
|
|
1895
2890
|
}
|
|
1896
2891
|
const id = rest[0];
|
|
1897
2892
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1898
2893
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
1899
2894
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1900
2895
|
if (method === "PUT") {
|
|
1901
|
-
const body = await
|
|
2896
|
+
const body = await readJsonBody3(req);
|
|
1902
2897
|
const existing = cfg.providers[idx];
|
|
1903
2898
|
const updated = parseProviderInput(body, existing);
|
|
1904
2899
|
if (!updated) return writeJsonError(res, 400, "invalid provider (apiFormat, baseUrl required)");
|
|
1905
2900
|
cfg.providers[idx] = updated;
|
|
1906
2901
|
persistProviders(cfg, deps);
|
|
1907
|
-
return
|
|
2902
|
+
return writeJson2(res, 200, { provider: toProviderView(updated) });
|
|
1908
2903
|
}
|
|
1909
2904
|
if (method === "DELETE") {
|
|
1910
2905
|
cfg.providers.splice(idx, 1);
|
|
1911
2906
|
persistProviders(cfg, deps);
|
|
1912
|
-
return
|
|
2907
|
+
return writeJson2(res, 200, { ok: true });
|
|
1913
2908
|
}
|
|
1914
2909
|
return writeJsonError(res, 405, `method ${method} not allowed on providers`);
|
|
1915
2910
|
}
|
|
@@ -1918,7 +2913,7 @@ function persistProviders(cfg, deps) {
|
|
|
1918
2913
|
deps.llmConfig.reload(cfg);
|
|
1919
2914
|
}
|
|
1920
2915
|
async function handleProviderReorder(req, res, cfg, deps) {
|
|
1921
|
-
const body = await
|
|
2916
|
+
const body = await readJsonBody3(req);
|
|
1922
2917
|
const rawOrder = body["order"];
|
|
1923
2918
|
if (!Array.isArray(rawOrder)) {
|
|
1924
2919
|
return writeJsonError(res, 400, "reorder requires { order: string[] }");
|
|
@@ -1942,14 +2937,14 @@ async function handleProviderReorder(req, res, cfg, deps) {
|
|
|
1942
2937
|
}
|
|
1943
2938
|
cfg.providers = reordered;
|
|
1944
2939
|
persistProviders(cfg, deps);
|
|
1945
|
-
return
|
|
2940
|
+
return writeJson2(res, 200, { ok: true, providers: cfg.providers.map(toProviderView) });
|
|
1946
2941
|
}
|
|
1947
2942
|
async function handleDiscoverModels(res, id, cfg) {
|
|
1948
2943
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1949
2944
|
const row = cfg.providers.find((p) => p.id === id);
|
|
1950
2945
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1951
2946
|
if (row.apiFormat !== "openai") {
|
|
1952
|
-
return
|
|
2947
|
+
return writeJson2(res, 200, { models: [], unsupportedFormat: true });
|
|
1953
2948
|
}
|
|
1954
2949
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
1955
2950
|
const base = row.baseUrl.replace(/\/+$/, "");
|
|
@@ -1957,7 +2952,7 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1957
2952
|
try {
|
|
1958
2953
|
const headers = { Accept: "application/json" };
|
|
1959
2954
|
if (resolvedKey) headers["Authorization"] = `Bearer ${resolvedKey}`;
|
|
1960
|
-
const response = await
|
|
2955
|
+
const response = await fetchUpstream(url, { method: "GET", headers }, { providerId: "byo" });
|
|
1961
2956
|
if (!response.ok) {
|
|
1962
2957
|
const text = await response.text().catch(() => "");
|
|
1963
2958
|
let message = text.slice(0, 300);
|
|
@@ -1966,32 +2961,32 @@ async function handleDiscoverModels(res, id, cfg) {
|
|
|
1966
2961
|
message = parsed?.error?.message || parsed?.message || message;
|
|
1967
2962
|
} catch {
|
|
1968
2963
|
}
|
|
1969
|
-
return
|
|
2964
|
+
return writeJson2(res, 200, {
|
|
1970
2965
|
models: [],
|
|
1971
2966
|
error: `discovery failed (${response.status})${message ? `: ${message}` : ""}`
|
|
1972
2967
|
});
|
|
1973
2968
|
}
|
|
1974
2969
|
const data = await response.json();
|
|
1975
2970
|
const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
|
|
1976
|
-
return
|
|
2971
|
+
return writeJson2(res, 200, { models });
|
|
1977
2972
|
} catch (err5) {
|
|
1978
2973
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
1979
|
-
return
|
|
2974
|
+
return writeJson2(res, 200, { models: [], error: `discovery failed: ${message}` });
|
|
1980
2975
|
}
|
|
1981
2976
|
}
|
|
1982
2977
|
async function handleTestModel(req, res, id, cfg) {
|
|
1983
2978
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
1984
2979
|
const row = cfg.providers.find((p) => p.id === id);
|
|
1985
2980
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
1986
|
-
const body = await
|
|
2981
|
+
const body = await readJsonBody3(req);
|
|
1987
2982
|
const model = typeof body["model"] === "string" ? body["model"].trim() : "";
|
|
1988
2983
|
if (!model) return writeJsonError(res, 400, "test requires a { model } string");
|
|
1989
2984
|
if (row.apiFormat === "gemini") {
|
|
1990
|
-
return
|
|
2985
|
+
return writeJson2(res, 200, { ok: false, unsupportedFormat: true });
|
|
1991
2986
|
}
|
|
1992
2987
|
const resolvedKey = resolveEnvKey(row.apiKey);
|
|
1993
2988
|
if (!resolvedKey) {
|
|
1994
|
-
return
|
|
2989
|
+
return writeJson2(res, 200, { ok: false, message: "no API key configured for this provider" });
|
|
1995
2990
|
}
|
|
1996
2991
|
const url = row.baseUrl.replace(/\/+$/, "");
|
|
1997
2992
|
const prompt = "Reply with the single word: OK.";
|
|
@@ -2012,11 +3007,11 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2012
3007
|
}
|
|
2013
3008
|
const startedAt = Date.now();
|
|
2014
3009
|
try {
|
|
2015
|
-
const response = await
|
|
2016
|
-
|
|
2017
|
-
headers,
|
|
2018
|
-
|
|
2019
|
-
|
|
3010
|
+
const response = await fetchUpstream(
|
|
3011
|
+
url,
|
|
3012
|
+
{ method: "POST", headers, body: JSON.stringify(payload) },
|
|
3013
|
+
{ providerId: "byo" }
|
|
3014
|
+
);
|
|
2020
3015
|
const latencyMs = Date.now() - startedAt;
|
|
2021
3016
|
const text = await response.text().catch(() => "");
|
|
2022
3017
|
if (!response.ok) {
|
|
@@ -2026,9 +3021,9 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2026
3021
|
message = parsed?.error?.message || parsed?.message || message;
|
|
2027
3022
|
} catch {
|
|
2028
3023
|
}
|
|
2029
|
-
return
|
|
3024
|
+
return writeJson2(res, 200, { ok: false, status: response.status, latencyMs, message });
|
|
2030
3025
|
}
|
|
2031
|
-
return
|
|
3026
|
+
return writeJson2(res, 200, {
|
|
2032
3027
|
ok: true,
|
|
2033
3028
|
status: response.status,
|
|
2034
3029
|
latencyMs,
|
|
@@ -2036,7 +3031,7 @@ async function handleTestModel(req, res, id, cfg) {
|
|
|
2036
3031
|
});
|
|
2037
3032
|
} catch (err5) {
|
|
2038
3033
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2039
|
-
return
|
|
3034
|
+
return writeJson2(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
|
|
2040
3035
|
}
|
|
2041
3036
|
}
|
|
2042
3037
|
function extractSampleText(text, apiFormat) {
|
|
@@ -2058,9 +3053,9 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2058
3053
|
return entries.map((e) => {
|
|
2059
3054
|
const auto = deps.autoDisableStore.get(e.id);
|
|
2060
3055
|
const cd = cooldown[e.id];
|
|
2061
|
-
const
|
|
2062
|
-
if (cd)
|
|
2063
|
-
if (auto)
|
|
3056
|
+
const health2 = {};
|
|
3057
|
+
if (cd) health2.cooldown = cd;
|
|
3058
|
+
if (auto) health2.autoDisabled = { status: auto.status, at: auto.at, reason: auto.reason };
|
|
2064
3059
|
return {
|
|
2065
3060
|
id: e.id,
|
|
2066
3061
|
label: e.label && e.label.length > 0 ? e.label : e.id,
|
|
@@ -2068,7 +3063,7 @@ function toPoolKeyView(row, cooldown, deps) {
|
|
|
2068
3063
|
enabled: e.enabled !== false && !deps.autoDisableStore.isDisabled(e.id),
|
|
2069
3064
|
weight: typeof e.weight === "number" && Number.isFinite(e.weight) ? e.weight : 1,
|
|
2070
3065
|
apiKeyMasked: maskProviderApiKey(e.apiKey),
|
|
2071
|
-
...Object.keys(
|
|
3066
|
+
...Object.keys(health2).length > 0 ? { health: health2 } : {}
|
|
2072
3067
|
};
|
|
2073
3068
|
});
|
|
2074
3069
|
}
|
|
@@ -2077,7 +3072,7 @@ async function handleProviderKeys(res, id, cfg, deps) {
|
|
|
2077
3072
|
const row = cfg.providers.find((p) => p.id === id);
|
|
2078
3073
|
if (!row) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2079
3074
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2080
|
-
return
|
|
3075
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2081
3076
|
}
|
|
2082
3077
|
function parsePoolKeyInput(body, existing) {
|
|
2083
3078
|
const out = {};
|
|
@@ -2096,7 +3091,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2096
3091
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
2097
3092
|
const idx = cfg.providers.findIndex((p) => p.id === id);
|
|
2098
3093
|
if (idx < 0) return writeJsonError(res, 404, `provider '${id}' not found`);
|
|
2099
|
-
const body = await
|
|
3094
|
+
const body = await readJsonBody3(req);
|
|
2100
3095
|
const parsed = parsePoolKeyInput(body);
|
|
2101
3096
|
if (!parsed.apiKey) return writeJsonError(res, 400, "add requires a non-empty apiKey");
|
|
2102
3097
|
const keyId = `key-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -2108,7 +3103,7 @@ async function handleAddProviderKey(req, res, id, cfg, deps) {
|
|
|
2108
3103
|
row.apiKeys = [...row.apiKeys ?? [], entry];
|
|
2109
3104
|
persistProviders(cfg, deps);
|
|
2110
3105
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2111
|
-
return
|
|
3106
|
+
return writeJson2(res, 201, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2112
3107
|
}
|
|
2113
3108
|
async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2114
3109
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2118,7 +3113,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2118
3113
|
const row = cfg.providers[idx];
|
|
2119
3114
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2120
3115
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2121
|
-
const body = await
|
|
3116
|
+
const body = await readJsonBody3(req);
|
|
2122
3117
|
const existing = row.apiKeys[keyIdx];
|
|
2123
3118
|
const parsed = parsePoolKeyInput(body, existing);
|
|
2124
3119
|
const entry = { id: keyId, apiKey: parsed.apiKey || existing.apiKey };
|
|
@@ -2128,7 +3123,7 @@ async function handleUpdateProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2128
3123
|
row.apiKeys[keyIdx] = entry;
|
|
2129
3124
|
persistProviders(cfg, deps);
|
|
2130
3125
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2131
|
-
return
|
|
3126
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2132
3127
|
}
|
|
2133
3128
|
async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
2134
3129
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2142,7 +3137,7 @@ async function handleDeleteProviderKey(res, id, keyId, cfg, deps) {
|
|
|
2142
3137
|
if (row.apiKeys.length === 0) row.apiKeys = void 0;
|
|
2143
3138
|
persistProviders(cfg, deps);
|
|
2144
3139
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2145
|
-
return
|
|
3140
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2146
3141
|
}
|
|
2147
3142
|
async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
2148
3143
|
if (!id) return writeJsonError(res, 400, "provider id required in path");
|
|
@@ -2152,11 +3147,11 @@ async function handleToggleProviderKey(req, res, id, keyId, cfg, deps) {
|
|
|
2152
3147
|
const row = cfg.providers[idx];
|
|
2153
3148
|
const keyIdx = (row.apiKeys ?? []).findIndex((k) => k.id === keyId);
|
|
2154
3149
|
if (keyIdx < 0) return writeJsonError(res, 404, `pool key '${keyId}' not found`);
|
|
2155
|
-
const body = await
|
|
3150
|
+
const body = await readJsonBody3(req);
|
|
2156
3151
|
row.apiKeys[keyIdx].enabled = Boolean(body["enabled"]);
|
|
2157
3152
|
persistProviders(cfg, deps);
|
|
2158
3153
|
const cooldown = await deps.apiKeyPool.getKeyHealth(id);
|
|
2159
|
-
return
|
|
3154
|
+
return writeJson2(res, 200, { keys: toPoolKeyView(row, cooldown, deps) });
|
|
2160
3155
|
}
|
|
2161
3156
|
function parseApiKeysInput(raw, existing) {
|
|
2162
3157
|
if (!Array.isArray(raw)) return existing;
|
|
@@ -2327,18 +3322,31 @@ function handlePresets(res, method) {
|
|
|
2327
3322
|
baseUrl: p.baseUrl,
|
|
2328
3323
|
models: p.models
|
|
2329
3324
|
}));
|
|
2330
|
-
return
|
|
3325
|
+
return writeJson2(res, 200, { presets, excluded });
|
|
2331
3326
|
}
|
|
2332
3327
|
async function handleKeys(req, res, method, rest, deps) {
|
|
2333
3328
|
if (method === "GET" && rest.length === 0) {
|
|
2334
3329
|
const rows = await deps.keyDb.outboundApiKeysList();
|
|
2335
|
-
|
|
3330
|
+
const reader = deps.keySpendReader;
|
|
3331
|
+
if (!reader) return writeJson2(res, 200, { keys: rows.map(toKeyInfo) });
|
|
3332
|
+
const now = Date.now();
|
|
3333
|
+
const keys = await Promise.all(
|
|
3334
|
+
rows.map(async (row) => {
|
|
3335
|
+
const info = toKeyInfo(row);
|
|
3336
|
+
if (row.revokedAt === null) {
|
|
3337
|
+
const s = await reader.getSpend(row.id, now);
|
|
3338
|
+
info.spend = { dailyUsd: s.dailyUsd, weeklyUsd: s.weeklyUsd, totalUsd: s.totalUsd };
|
|
3339
|
+
}
|
|
3340
|
+
return info;
|
|
3341
|
+
})
|
|
3342
|
+
);
|
|
3343
|
+
return writeJson2(res, 200, { keys });
|
|
2336
3344
|
}
|
|
2337
3345
|
if (method === "POST" && rest.length === 0) {
|
|
2338
|
-
const body = await
|
|
3346
|
+
const body = await readJsonBody3(req);
|
|
2339
3347
|
const name = typeof body["name"] === "string" && body["name"].trim() ? body["name"].trim() : "key";
|
|
2340
3348
|
const created = await createNamedKey(deps.keyDb, name);
|
|
2341
|
-
return
|
|
3349
|
+
return writeJson2(res, 201, {
|
|
2342
3350
|
id: created.id,
|
|
2343
3351
|
name: created.name,
|
|
2344
3352
|
keyPrefix: created.keyPrefix,
|
|
@@ -2350,46 +3358,185 @@ async function handleKeys(req, res, method, rest, deps) {
|
|
|
2350
3358
|
const action = rest[1];
|
|
2351
3359
|
if (method === "POST" && id && action === "revoke") {
|
|
2352
3360
|
const ok = await deps.keyDb.outboundApiKeysRevoke(id);
|
|
2353
|
-
return
|
|
3361
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2354
3362
|
}
|
|
2355
3363
|
if (method === "POST" && id && action === "enabled") {
|
|
2356
|
-
const body = await
|
|
3364
|
+
const body = await readJsonBody3(req);
|
|
2357
3365
|
const enabled = body["enabled"] === true;
|
|
2358
3366
|
const ok = await deps.keyDb.outboundApiKeysSetEnabled(id, enabled);
|
|
2359
|
-
return
|
|
3367
|
+
return writeJson2(res, ok ? 200 : 404, { ok, enabled });
|
|
3368
|
+
}
|
|
3369
|
+
if (method === "POST" && id && action === "max-concurrency") {
|
|
3370
|
+
const body = await readJsonBody3(req);
|
|
3371
|
+
const raw = body["maxConcurrency"];
|
|
3372
|
+
let value;
|
|
3373
|
+
if (raw === null) {
|
|
3374
|
+
value = null;
|
|
3375
|
+
} else if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1 && raw <= 1e3) {
|
|
3376
|
+
value = raw;
|
|
3377
|
+
} else {
|
|
3378
|
+
return writeJsonError(
|
|
3379
|
+
res,
|
|
3380
|
+
400,
|
|
3381
|
+
"maxConcurrency must be an integer 1..1000 or null"
|
|
3382
|
+
);
|
|
3383
|
+
}
|
|
3384
|
+
const ok = await deps.keyDb.outboundApiKeysSetMaxConcurrency(id, value);
|
|
3385
|
+
return writeJson2(res, ok ? 200 : 404, { ok, maxConcurrency: value });
|
|
3386
|
+
}
|
|
3387
|
+
if (method === "POST" && id && action === "policy") {
|
|
3388
|
+
const body = await readJsonBody3(req);
|
|
3389
|
+
const parsed = parseKeyPolicyBody(body);
|
|
3390
|
+
if (!parsed.ok) return writeJsonError(res, 400, parsed.message);
|
|
3391
|
+
const ok = await deps.keyDb.outboundApiKeysSetPolicy(id, parsed.policy);
|
|
3392
|
+
return writeJson2(res, ok ? 200 : 404, { ok });
|
|
2360
3393
|
}
|
|
2361
3394
|
return writeJsonError(res, 405, `method ${method} not allowed on keys`);
|
|
2362
3395
|
}
|
|
3396
|
+
function validateQueueSegments(patch) {
|
|
3397
|
+
const errors = [];
|
|
3398
|
+
const checkNum = (label, value, min, max) => {
|
|
3399
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
|
3400
|
+
errors.push(`${label} must be a number ${min}..${max}`);
|
|
3401
|
+
}
|
|
3402
|
+
};
|
|
3403
|
+
const isPlainObject4 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3404
|
+
const umq = patch.userMessageQueue;
|
|
3405
|
+
if (umq !== void 0) {
|
|
3406
|
+
if (!isPlainObject4(umq)) {
|
|
3407
|
+
errors.push("userMessageQueue must be an object");
|
|
3408
|
+
} else {
|
|
3409
|
+
if (typeof umq.enabled !== "boolean") {
|
|
3410
|
+
errors.push("userMessageQueue.enabled must be a boolean");
|
|
3411
|
+
}
|
|
3412
|
+
checkNum("userMessageQueue.delayMs", umq.delayMs, 0, 1e4);
|
|
3413
|
+
checkNum("userMessageQueue.waitTimeoutMs", umq.waitTimeoutMs, 1e3, 3e5);
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
const cq = patch.concurrencyQueue;
|
|
3417
|
+
if (cq !== void 0) {
|
|
3418
|
+
if (!isPlainObject4(cq)) {
|
|
3419
|
+
errors.push("concurrencyQueue must be an object");
|
|
3420
|
+
} else {
|
|
3421
|
+
checkNum("concurrencyQueue.maxQueueSizeFactor", cq.maxQueueSizeFactor, 1, 10);
|
|
3422
|
+
checkNum("concurrencyQueue.minQueueSize", cq.minQueueSize, 1, 100);
|
|
3423
|
+
checkNum("concurrencyQueue.waitTimeoutMs", cq.waitTimeoutMs, 1e3, 3e5);
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
const ah = patch.accountHealth;
|
|
3427
|
+
if (ah !== void 0) {
|
|
3428
|
+
if (!isPlainObject4(ah)) {
|
|
3429
|
+
errors.push("accountHealth must be an object");
|
|
3430
|
+
} else {
|
|
3431
|
+
if (typeof ah.overloadCooldownEnabled !== "boolean") {
|
|
3432
|
+
errors.push("accountHealth.overloadCooldownEnabled must be a boolean");
|
|
3433
|
+
}
|
|
3434
|
+
checkNum("accountHealth.overloadCooldownMs", ah.overloadCooldownMs, 6e4, 36e5);
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
return errors;
|
|
3438
|
+
}
|
|
2363
3439
|
async function handleServer(req, res, method, deps) {
|
|
2364
3440
|
if (method === "GET") {
|
|
2365
|
-
const config = await
|
|
2366
|
-
|
|
3441
|
+
const config = await loadServerConfig2(deps.settingsStore);
|
|
3442
|
+
let server = config;
|
|
3443
|
+
if (config.proxy) server = { ...server, proxy: redactOutboundProxy(config.proxy) };
|
|
3444
|
+
if (config.webhook) server = { ...server, webhook: redactWebhookConfig(config.webhook) };
|
|
3445
|
+
if (config.billing) server = { ...server, billing: redactBillingConfig(config.billing) };
|
|
3446
|
+
return writeJson2(res, 200, { server });
|
|
2367
3447
|
}
|
|
2368
3448
|
if (method === "PUT") {
|
|
2369
|
-
const patch = await
|
|
2370
|
-
const
|
|
2371
|
-
|
|
3449
|
+
const patch = await readJsonBody3(req);
|
|
3450
|
+
const queueErrors = validateQueueSegments(patch);
|
|
3451
|
+
if (queueErrors.length > 0) {
|
|
3452
|
+
return writeJsonError(res, 400, `invalid queue config: ${queueErrors.join("; ")}`);
|
|
3453
|
+
}
|
|
3454
|
+
const webhookErrors = validateWebhookSegment(patch);
|
|
3455
|
+
if (webhookErrors.length > 0) {
|
|
3456
|
+
return writeJsonError(res, 400, `invalid webhook config: ${webhookErrors.join("; ")}`);
|
|
3457
|
+
}
|
|
3458
|
+
const auditErrors = validateAuditSegment(patch);
|
|
3459
|
+
if (auditErrors.length > 0) {
|
|
3460
|
+
return writeJsonError(res, 400, `invalid audit config: ${auditErrors.join("; ")}`);
|
|
3461
|
+
}
|
|
3462
|
+
const billingErrors = validateBillingSegment(patch);
|
|
3463
|
+
if (billingErrors.length > 0) {
|
|
3464
|
+
return writeJsonError(res, 400, `invalid billing config: ${billingErrors.join("; ")}`);
|
|
3465
|
+
}
|
|
3466
|
+
const current = await loadServerConfig2(deps.settingsStore);
|
|
3467
|
+
let effectivePatch = patch;
|
|
3468
|
+
if (patch.proxy) {
|
|
3469
|
+
effectivePatch = { ...effectivePatch, proxy: preserveOutboundProxySecrets(patch.proxy, current.proxy) };
|
|
3470
|
+
}
|
|
3471
|
+
if (patch.webhook) {
|
|
3472
|
+
effectivePatch = { ...effectivePatch, webhook: preserveWebhookSecrets(patch.webhook, current.webhook) };
|
|
3473
|
+
}
|
|
3474
|
+
if (patch.billing) {
|
|
3475
|
+
effectivePatch = { ...effectivePatch, billing: preserveBillingSecret(patch.billing, current.billing) };
|
|
3476
|
+
}
|
|
3477
|
+
const merged = mergeServerConfig(current, effectivePatch);
|
|
2372
3478
|
await saveServerConfig(deps.settingsStore, merged);
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
3479
|
+
setServerProxyConfig(merged.proxy);
|
|
3480
|
+
applyWebhookConfig(merged.webhook);
|
|
3481
|
+
applyAuditConfig(merged.audit);
|
|
3482
|
+
applyBillingConfig(merged.billing);
|
|
3483
|
+
if (merged.enabled) {
|
|
3484
|
+
const missing = validateServerModelConfig(merged);
|
|
3485
|
+
if (missing.length > 0) {
|
|
3486
|
+
if (deps.outboundApiServer.getStatus().running) {
|
|
3487
|
+
await deps.outboundApiServer.stop();
|
|
3488
|
+
}
|
|
3489
|
+
return writeJson2(res, 200, {
|
|
3490
|
+
server: merged,
|
|
3491
|
+
error: { code: "incomplete-model-config", missing }
|
|
3492
|
+
});
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
try {
|
|
3496
|
+
await deps.outboundApiServer.applyConfig({
|
|
3497
|
+
enabled: merged.enabled,
|
|
3498
|
+
networkBinding: merged.networkBinding,
|
|
3499
|
+
endpoints: merged.endpoints,
|
|
3500
|
+
port: merged.port,
|
|
3501
|
+
userMessageQueue: merged.userMessageQueue,
|
|
3502
|
+
concurrencyQueue: merged.concurrencyQueue,
|
|
3503
|
+
// voucher-redemption #9: hot-apply the voucher flag so enabling the product
|
|
3504
|
+
// takes effect without a restart.
|
|
3505
|
+
voucher: merged.voucher
|
|
3506
|
+
});
|
|
3507
|
+
} catch (err5) {
|
|
3508
|
+
const missing = incompleteConfigMissing(err5);
|
|
3509
|
+
if (missing) {
|
|
3510
|
+
return writeJson2(res, 200, {
|
|
3511
|
+
server: merged,
|
|
3512
|
+
error: { code: "incomplete-model-config", missing }
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
throw err5;
|
|
3516
|
+
}
|
|
3517
|
+
return writeJson2(res, 200, { server: merged });
|
|
2380
3518
|
}
|
|
2381
3519
|
return writeJsonError(res, 405, `method ${method} not allowed on server`);
|
|
2382
3520
|
}
|
|
3521
|
+
function incompleteConfigMissing(err5) {
|
|
3522
|
+
if (typeof err5 !== "object" || err5 === null) return null;
|
|
3523
|
+
const missing = err5.missing;
|
|
3524
|
+
return Array.isArray(missing) ? missing : null;
|
|
3525
|
+
}
|
|
2383
3526
|
async function handleAccounts(req, res, method, rest, deps) {
|
|
2384
3527
|
if (method === "GET" && rest.length === 0) {
|
|
2385
3528
|
const accounts = await deps.subscriptionAccounts.listAll();
|
|
2386
3529
|
const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
|
|
2387
3530
|
const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
|
|
2388
|
-
return
|
|
3531
|
+
return writeJson2(res, 200, { accounts, providerAccounts, externalCli });
|
|
2389
3532
|
}
|
|
2390
3533
|
if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
|
|
2391
3534
|
const result = handleCodexOAuthStatus(rest[2], deps);
|
|
2392
|
-
return
|
|
3535
|
+
return writeJson2(res, result.status, result.body);
|
|
3536
|
+
}
|
|
3537
|
+
if (method === "DELETE" && rest[0] === "codex" && rest[1] === "oauth" && rest[2]) {
|
|
3538
|
+
const result = handleCodexOAuthCancel(rest[2], deps);
|
|
3539
|
+
return writeJson2(res, result.status, result.body);
|
|
2393
3540
|
}
|
|
2394
3541
|
if (method === "PUT" || method === "POST" || method === "DELETE") {
|
|
2395
3542
|
const providerId = asSubscriptionProviderId(rest[0]);
|
|
@@ -2398,15 +3545,15 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2398
3545
|
}
|
|
2399
3546
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "start") {
|
|
2400
3547
|
const result = providerId === "codex" ? handleCodexOAuthStart(deps) : handleOAuthStart(providerId, deps);
|
|
2401
|
-
return
|
|
3548
|
+
return writeJson2(res, result.status, result.body);
|
|
2402
3549
|
}
|
|
2403
3550
|
if (method === "POST" && rest[1] === "oauth" && rest[2] === "complete") {
|
|
2404
|
-
const body2 = await
|
|
3551
|
+
const body2 = await readJsonBody3(req);
|
|
2405
3552
|
const result = await handleOAuthComplete(providerId, body2, deps);
|
|
2406
|
-
return
|
|
3553
|
+
return writeJson2(res, result.status, result.body);
|
|
2407
3554
|
}
|
|
2408
3555
|
if (method === "POST" && rest[1] === "accounts") {
|
|
2409
|
-
const body2 = await
|
|
3556
|
+
const body2 = await readJsonBody3(req);
|
|
2410
3557
|
const block = validateTokenBody(providerId, body2);
|
|
2411
3558
|
if (!block) {
|
|
2412
3559
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
@@ -2414,79 +3561,113 @@ async function handleAccounts(req, res, method, rest, deps) {
|
|
|
2414
3561
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2415
3562
|
await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
|
|
2416
3563
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2417
|
-
return
|
|
3564
|
+
return writeJson2(res, 200, status2 ? { account: status2 } : { ok: true });
|
|
2418
3565
|
}
|
|
2419
3566
|
if (method === "POST" && rest[1] === "import-external") {
|
|
2420
3567
|
if (providerId !== "claude" && providerId !== "codex") {
|
|
2421
3568
|
return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
|
|
2422
3569
|
}
|
|
2423
|
-
const body2 = await
|
|
3570
|
+
const body2 = await readJsonBody3(req);
|
|
2424
3571
|
const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
|
|
2425
3572
|
const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
|
|
2426
3573
|
if (!result.ok) {
|
|
2427
3574
|
return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
|
|
2428
3575
|
}
|
|
2429
3576
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2430
|
-
return
|
|
3577
|
+
return writeJson2(res, 200, { ok: true, account: status2 ?? void 0 });
|
|
2431
3578
|
}
|
|
2432
3579
|
if (method === "POST" && rest[1] === "refresh") {
|
|
2433
3580
|
if (providerId === "opencodego") {
|
|
2434
3581
|
return writeJsonError(res, 400, "opencodego credentials are not refreshable");
|
|
2435
3582
|
}
|
|
2436
|
-
const
|
|
2437
|
-
const ok = providerId === "claude" ? await
|
|
3583
|
+
const writer2 = deps.subscriptionTokenWriter;
|
|
3584
|
+
const ok = providerId === "claude" ? await writer2.refreshClaudeToken() : providerId === "codex" ? await writer2.refreshCodexToken() : await writer2.refreshGeminiToken();
|
|
2438
3585
|
const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2439
|
-
return
|
|
3586
|
+
return writeJson2(res, 200, { ok, account: status2 ?? void 0 });
|
|
2440
3587
|
}
|
|
2441
3588
|
if (method === "POST" && rest[2] === "label") {
|
|
2442
3589
|
const accountId = rest[1];
|
|
2443
|
-
const body2 = await
|
|
3590
|
+
const body2 = await readJsonBody3(req);
|
|
2444
3591
|
const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
|
|
2445
3592
|
const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
|
|
2446
3593
|
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2447
|
-
return
|
|
3594
|
+
return writeJson2(res, 200, { ok: true });
|
|
3595
|
+
}
|
|
3596
|
+
if (method === "POST" && rest[2] === "priority") {
|
|
3597
|
+
const accountId = rest[1];
|
|
3598
|
+
const body2 = await readJsonBody3(req);
|
|
3599
|
+
const raw = body2["priority"];
|
|
3600
|
+
const priority = typeof raw === "number" ? raw : Number(raw);
|
|
3601
|
+
if (!Number.isFinite(priority)) {
|
|
3602
|
+
return writeJsonError(res, 400, "priority must be a finite number");
|
|
3603
|
+
}
|
|
3604
|
+
const result = await deps.subscriptionTokenWriter.setAccountPriority(providerId, accountId, priority);
|
|
3605
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3606
|
+
return writeJson2(res, 200, { ok: true });
|
|
3607
|
+
}
|
|
3608
|
+
if (method === "POST" && rest[2] === "proxy") {
|
|
3609
|
+
const accountId = rest[1];
|
|
3610
|
+
const body2 = await readJsonBody3(req);
|
|
3611
|
+
const rawProxy = body2["proxy"];
|
|
3612
|
+
let proxy;
|
|
3613
|
+
if (rawProxy !== null && rawProxy !== void 0) {
|
|
3614
|
+
proxy = normalizeProxyConfig(rawProxy);
|
|
3615
|
+
if (!proxy) return writeJsonError(res, 400, "invalid proxy config");
|
|
3616
|
+
}
|
|
3617
|
+
const result = await deps.subscriptionTokenWriter.setAccountProxy(providerId, accountId, proxy);
|
|
3618
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3619
|
+
return writeJson2(res, 200, { ok: true });
|
|
3620
|
+
}
|
|
3621
|
+
if (method === "POST" && rest[2] === "supported-models") {
|
|
3622
|
+
const accountId = rest[1];
|
|
3623
|
+
const body2 = await readJsonBody3(req);
|
|
3624
|
+
const parsed = validateSupportedModelsBody(body2["supportedModels"]);
|
|
3625
|
+
if (!parsed.ok) return writeJsonError(res, 400, "invalid supportedModels");
|
|
3626
|
+
const result = await deps.subscriptionTokenWriter.setAccountSupportedModels(providerId, accountId, parsed.value);
|
|
3627
|
+
if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
3628
|
+
return writeJson2(res, 200, { ok: true });
|
|
2448
3629
|
}
|
|
2449
3630
|
if (method === "PUT" && rest[1] === "active") {
|
|
2450
|
-
const body2 = await
|
|
3631
|
+
const body2 = await readJsonBody3(req);
|
|
2451
3632
|
const id = typeof body2["id"] === "string" ? body2["id"] : "";
|
|
2452
3633
|
if (!id) return writeJsonError(res, 400, "active switch requires { id }");
|
|
2453
3634
|
const result = await deps.subscriptionTokenWriter.setActiveAccount(providerId, id);
|
|
2454
3635
|
if (!result.ok) return writeJsonError(res, 404, `account '${id}' not found`);
|
|
2455
|
-
return
|
|
3636
|
+
return writeJson2(res, 200, { ok: true });
|
|
2456
3637
|
}
|
|
2457
3638
|
if (method === "DELETE" && rest.length >= 2) {
|
|
2458
3639
|
const accountId = rest[1];
|
|
2459
3640
|
const result = await deps.subscriptionTokenWriter.removeAccount(providerId, accountId);
|
|
2460
3641
|
if (!result.removed) return writeJsonError(res, 404, `account '${accountId}' not found`);
|
|
2461
|
-
return
|
|
3642
|
+
return writeJson2(res, 200, { ok: true });
|
|
2462
3643
|
}
|
|
2463
3644
|
if (method === "DELETE") {
|
|
2464
3645
|
await deps.subscriptionTokenWriter.clearProvider(providerId);
|
|
2465
|
-
return
|
|
3646
|
+
return writeJson2(res, 200, { ok: true });
|
|
2466
3647
|
}
|
|
2467
|
-
const body = await
|
|
3648
|
+
const body = await readJsonBody3(req);
|
|
2468
3649
|
const config = validateTokenBody(providerId, body);
|
|
2469
3650
|
if (!config) {
|
|
2470
3651
|
return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
|
|
2471
3652
|
}
|
|
2472
3653
|
await deps.subscriptionTokenWriter.writeProviderTokens(providerId, config);
|
|
2473
3654
|
const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
|
|
2474
|
-
return
|
|
3655
|
+
return writeJson2(res, 200, status ? { account: status } : { ok: true });
|
|
2475
3656
|
}
|
|
2476
3657
|
return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
|
|
2477
3658
|
}
|
|
2478
3659
|
async function handleCli(req, res, method, rest, deps) {
|
|
2479
3660
|
if (method === "GET" && rest.length === 0) {
|
|
2480
3661
|
const result = handleCliList(process.platform, deps.cliPathProbe);
|
|
2481
|
-
return
|
|
3662
|
+
return writeJson2(res, result.status, result.body);
|
|
2482
3663
|
}
|
|
2483
3664
|
if (method === "GET" && rest[0] === "sessions") {
|
|
2484
3665
|
const result = handleCliSessions();
|
|
2485
|
-
return
|
|
3666
|
+
return writeJson2(res, result.status, result.body);
|
|
2486
3667
|
}
|
|
2487
3668
|
if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
|
|
2488
3669
|
const result = handleCliStop(rest[1]);
|
|
2489
|
-
return
|
|
3670
|
+
return writeJson2(res, result.status, result.body);
|
|
2490
3671
|
}
|
|
2491
3672
|
if (method === "POST" && rest[1] === "install") {
|
|
2492
3673
|
const cli = rest[0];
|
|
@@ -2494,14 +3675,14 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2494
3675
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2495
3676
|
}
|
|
2496
3677
|
const result = await handleCliInstall(cli, deps.cliCommandRunner);
|
|
2497
|
-
return
|
|
3678
|
+
return writeJson2(res, result.status, result.body);
|
|
2498
3679
|
}
|
|
2499
3680
|
if (method === "POST" && rest[1] === "launch") {
|
|
2500
3681
|
const cli = rest[0];
|
|
2501
3682
|
if (!isLaunchCliId(cli)) {
|
|
2502
3683
|
return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
|
|
2503
3684
|
}
|
|
2504
|
-
const body = await
|
|
3685
|
+
const body = await readJsonBody3(req);
|
|
2505
3686
|
const providers = loadConfig(deps.configPath).providers ?? [];
|
|
2506
3687
|
const result = await handleCliLaunch(cli, body, {
|
|
2507
3688
|
llmConfig: deps.llmConfig,
|
|
@@ -2509,20 +3690,28 @@ async function handleCli(req, res, method, rest, deps) {
|
|
|
2509
3690
|
opener: deps.cliTerminalOpener,
|
|
2510
3691
|
probe: deps.cliPathProbe
|
|
2511
3692
|
});
|
|
2512
|
-
return
|
|
3693
|
+
return writeJson2(res, result.status, result.body);
|
|
2513
3694
|
}
|
|
2514
3695
|
return writeJsonError(res, 405, `method ${method} not allowed on cli`);
|
|
2515
3696
|
}
|
|
2516
3697
|
async function handleStatus(res, method, deps) {
|
|
2517
3698
|
if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
|
|
2518
3699
|
const status = deps.outboundApiServer.getStatus();
|
|
2519
|
-
const serverConfig = await
|
|
2520
|
-
const endpoints = serverConfig.endpoints.map((e) =>
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
3700
|
+
const serverConfig = await loadServerConfig2(deps.settingsStore);
|
|
3701
|
+
const endpoints = serverConfig.endpoints.map((e) => {
|
|
3702
|
+
if (isKindMappedEndpoint(e.endpoint)) {
|
|
3703
|
+
return { endpoint: e.endpoint, kinds: e.modelMap ?? {}, useSubscription: e.useSubscription };
|
|
3704
|
+
}
|
|
3705
|
+
if (e.endpoint === "chat") {
|
|
3706
|
+
return { endpoint: e.endpoint, models: e.models ?? [], useSubscription: e.useSubscription };
|
|
3707
|
+
}
|
|
3708
|
+
return { endpoint: e.endpoint, model: e.defaultModel ?? "", useSubscription: e.useSubscription };
|
|
3709
|
+
});
|
|
3710
|
+
if (status.running) {
|
|
3711
|
+
const queueStatus = deps.outboundApiServer.getQueueStatus();
|
|
3712
|
+
return writeJson2(res, 200, { ...status, endpoints, queueStatus });
|
|
3713
|
+
}
|
|
3714
|
+
return writeJson2(res, 200, { ...status, endpoints });
|
|
2526
3715
|
}
|
|
2527
3716
|
function resolvePlaygroundPath(endpoint, body) {
|
|
2528
3717
|
switch (endpoint) {
|
|
@@ -2542,7 +3731,7 @@ function resolvePlaygroundPath(endpoint, body) {
|
|
|
2542
3731
|
}
|
|
2543
3732
|
async function handlePlayground(req, res, method, deps) {
|
|
2544
3733
|
if (method !== "POST") return writeJsonError(res, 405, `method ${method} not allowed on playground`);
|
|
2545
|
-
const body = await
|
|
3734
|
+
const body = await readJsonBody3(req);
|
|
2546
3735
|
const endpoint = typeof body["endpoint"] === "string" ? body["endpoint"] : "";
|
|
2547
3736
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2548
3737
|
const payload = body["body"];
|
|
@@ -2687,10 +3876,12 @@ async function handleUiStatic(req, res, urlPath, uiDist) {
|
|
|
2687
3876
|
return true;
|
|
2688
3877
|
}
|
|
2689
3878
|
|
|
3879
|
+
// src/admin/version.ts
|
|
3880
|
+
var DAEMON_VERSION = true ? "0.1.4" : "0.0.0-dev";
|
|
3881
|
+
|
|
2690
3882
|
// src/admin/AdminServer.ts
|
|
2691
3883
|
var LOOPBACK_ADDR = "127.0.0.1";
|
|
2692
3884
|
var LAN_ADDR = "0.0.0.0";
|
|
2693
|
-
var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2694
3885
|
var AdminServer = class {
|
|
2695
3886
|
constructor(deps) {
|
|
2696
3887
|
this.deps = deps;
|
|
@@ -2711,7 +3902,7 @@ var AdminServer = class {
|
|
|
2711
3902
|
const cfg = this.deps.getAdminConfig();
|
|
2712
3903
|
if (!cfg.enabled) return 0;
|
|
2713
3904
|
if (cfg.networkBinding && !cfg.token) {
|
|
2714
|
-
|
|
3905
|
+
this.deps.logger.error(
|
|
2715
3906
|
"[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)."
|
|
2716
3907
|
);
|
|
2717
3908
|
return 0;
|
|
@@ -2720,7 +3911,7 @@ var AdminServer = class {
|
|
|
2720
3911
|
const actualPort = await this.listen(bindAddr, cfg.port);
|
|
2721
3912
|
this.boundAddr = bindAddr;
|
|
2722
3913
|
this.boundPort = actualPort;
|
|
2723
|
-
|
|
3914
|
+
this.deps.logger.info(`[AdminServer] Dashboard listening on ${bindAddr}:${actualPort}`);
|
|
2724
3915
|
return actualPort;
|
|
2725
3916
|
}
|
|
2726
3917
|
/** Bind once; on EADDRINUSE retry with an ephemeral port (port 0). */
|
|
@@ -2742,7 +3933,7 @@ var AdminServer = class {
|
|
|
2742
3933
|
const addr = server.address();
|
|
2743
3934
|
if (addr && typeof addr === "object") {
|
|
2744
3935
|
server.removeListener("error", onError);
|
|
2745
|
-
server.on("error", (e) =>
|
|
3936
|
+
server.on("error", (e) => this.deps.logger.error("[AdminServer] server error", e));
|
|
2746
3937
|
this.server = server;
|
|
2747
3938
|
resolve(addr.port);
|
|
2748
3939
|
} else {
|
|
@@ -2755,7 +3946,7 @@ var AdminServer = class {
|
|
|
2755
3946
|
onRequest(req, res) {
|
|
2756
3947
|
void this.dispatch(req, res).catch((err5) => {
|
|
2757
3948
|
const message = err5 instanceof Error ? err5.message : String(err5);
|
|
2758
|
-
|
|
3949
|
+
this.deps.logger.error("[AdminServer] unhandled error:", message);
|
|
2759
3950
|
if (!res.headersSent) {
|
|
2760
3951
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2761
3952
|
res.end(JSON.stringify({ error: { type: "admin_error", message } }));
|
|
@@ -2766,18 +3957,42 @@ var AdminServer = class {
|
|
|
2766
3957
|
const cfg = this.deps.getAdminConfig();
|
|
2767
3958
|
res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
|
|
2768
3959
|
res.setHeader("x-omnicross-pid", String(process.pid));
|
|
3960
|
+
const url = req.url ?? "/";
|
|
3961
|
+
const path2 = url.split("?")[0];
|
|
3962
|
+
const healthPath = path2.replace(/\/+$/, "") || "/";
|
|
3963
|
+
if ((req.method === "GET" || req.method === "HEAD") && (healthPath === "/health" || healthPath === "/healthz")) {
|
|
3964
|
+
const report = this.deps.getHealthReport();
|
|
3965
|
+
const code = healthHttpStatus(report.status);
|
|
3966
|
+
res.writeHead(code, { "Content-Type": "application/json" });
|
|
3967
|
+
res.end(req.method === "HEAD" ? void 0 : JSON.stringify(report));
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
2769
3970
|
if (cfg.token && !this.isAuthorized(req, cfg.token)) {
|
|
2770
3971
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2771
3972
|
res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
|
|
2772
3973
|
return;
|
|
2773
3974
|
}
|
|
2774
|
-
const url = req.url ?? "/";
|
|
2775
|
-
const path2 = url.split("?")[0];
|
|
2776
3975
|
if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
|
|
2777
3976
|
res.writeHead(302, { Location: "/ui/" });
|
|
2778
3977
|
res.end();
|
|
2779
3978
|
return;
|
|
2780
3979
|
}
|
|
3980
|
+
if (path2 === "/admin/api/account-probes" && (req.method === "GET" || req.method === "HEAD")) {
|
|
3981
|
+
handleAccountProbes(res, this.deps.probeHistoryReader);
|
|
3982
|
+
return;
|
|
3983
|
+
}
|
|
3984
|
+
if (path2 === "/admin/api/audit" && (req.method === "GET" || req.method === "HEAD")) {
|
|
3985
|
+
handleAuditQuery(req, res, this.deps.auditReader);
|
|
3986
|
+
return;
|
|
3987
|
+
}
|
|
3988
|
+
if (path2 === "/admin/api/billing-status" && (req.method === "GET" || req.method === "HEAD")) {
|
|
3989
|
+
handleBillingStatus(res, this.deps.billingStatusReader);
|
|
3990
|
+
return;
|
|
3991
|
+
}
|
|
3992
|
+
if (path2 === "/admin/api/webhook-test" && req.method === "POST") {
|
|
3993
|
+
await handleWebhookTest(req, res);
|
|
3994
|
+
return;
|
|
3995
|
+
}
|
|
2781
3996
|
if (path2.startsWith("/admin/api/")) {
|
|
2782
3997
|
await handleAdminApi(req, res, path2, this.deps);
|
|
2783
3998
|
return;
|
|
@@ -2821,6 +4036,51 @@ function constantTimeEquals(a, b) {
|
|
|
2821
4036
|
return timingSafeEqual(bufA, bufB);
|
|
2822
4037
|
}
|
|
2823
4038
|
|
|
4039
|
+
// src/admin/health.ts
|
|
4040
|
+
var CRITICAL_CHECKS = ["config", "credentialStore"];
|
|
4041
|
+
var READINESS_CHECKS = ["outboundServer"];
|
|
4042
|
+
function safeBool(fn) {
|
|
4043
|
+
try {
|
|
4044
|
+
return fn() === true;
|
|
4045
|
+
} catch {
|
|
4046
|
+
return false;
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
function toMb(bytes) {
|
|
4050
|
+
return Math.round(bytes / (1024 * 1024) * 10) / 10;
|
|
4051
|
+
}
|
|
4052
|
+
function buildHealthReport(deps) {
|
|
4053
|
+
const checks = {
|
|
4054
|
+
config: safeBool(deps.configPresent),
|
|
4055
|
+
credentialStore: safeBool(deps.credentialStoreReadable),
|
|
4056
|
+
outboundServer: safeBool(deps.outboundServerRunning),
|
|
4057
|
+
adminServer: safeBool(deps.adminServerRunning)
|
|
4058
|
+
};
|
|
4059
|
+
if (deps.subscriptionAccountsHealthy) {
|
|
4060
|
+
let probeHealthy;
|
|
4061
|
+
try {
|
|
4062
|
+
probeHealthy = deps.subscriptionAccountsHealthy();
|
|
4063
|
+
} catch {
|
|
4064
|
+
probeHealthy = false;
|
|
4065
|
+
}
|
|
4066
|
+
if (probeHealthy !== void 0) checks.subscriptionAccountsHealthy = probeHealthy;
|
|
4067
|
+
}
|
|
4068
|
+
const criticalOk = CRITICAL_CHECKS.every((k) => checks[k]);
|
|
4069
|
+
const readinessOk = READINESS_CHECKS.every((k) => checks[k]);
|
|
4070
|
+
const status = !criticalOk ? "error" : readinessOk ? "ok" : "degraded";
|
|
4071
|
+
const mem = (deps.memoryUsage ?? process.memoryUsage)();
|
|
4072
|
+
const uptime = (deps.uptimeSeconds ?? process.uptime)();
|
|
4073
|
+
const nowMs = (deps.now ?? Date.now)();
|
|
4074
|
+
return {
|
|
4075
|
+
status,
|
|
4076
|
+
version: deps.version,
|
|
4077
|
+
uptimeSeconds: Math.floor(uptime),
|
|
4078
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
4079
|
+
memory: { rssMb: toMb(mem.rss), heapUsedMb: toMb(mem.heapUsed) },
|
|
4080
|
+
checks
|
|
4081
|
+
};
|
|
4082
|
+
}
|
|
4083
|
+
|
|
2824
4084
|
// src/admin/oauthSessions.ts
|
|
2825
4085
|
import crypto2 from "crypto";
|
|
2826
4086
|
var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
|
|
@@ -2872,7 +4132,7 @@ var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
|
2872
4132
|
function pageHtml(message) {
|
|
2873
4133
|
return `<!doctype html><meta charset="utf-8"><title>omnicross login</title><body style="font-family:sans-serif;padding:2rem"><h2>${message}</h2><p>You can close this window and return to the terminal.</p></body>`;
|
|
2874
4134
|
}
|
|
2875
|
-
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
4135
|
+
function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
2876
4136
|
return new Promise((resolve, reject) => {
|
|
2877
4137
|
let settled = false;
|
|
2878
4138
|
const finish = (server2, fn) => {
|
|
@@ -2906,6 +4166,12 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
2906
4166
|
res.end(pageHtml("Login complete."));
|
|
2907
4167
|
finish(server, () => resolve(code));
|
|
2908
4168
|
});
|
|
4169
|
+
const abort = () => finish(server, () => reject(new Error("login: cancelled")));
|
|
4170
|
+
if (signal?.aborted) {
|
|
4171
|
+
abort();
|
|
4172
|
+
return;
|
|
4173
|
+
}
|
|
4174
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2909
4175
|
server.on("error", (err5) => {
|
|
2910
4176
|
if (settled) return;
|
|
2911
4177
|
settled = true;
|
|
@@ -2994,12 +4260,21 @@ function createPoolKeysLoader(getProviderRow, autoDisabled) {
|
|
|
2994
4260
|
|
|
2995
4261
|
// src/commands/paths.ts
|
|
2996
4262
|
import { dirname as dirname2, join as join3 } from "path";
|
|
4263
|
+
function defaultVouchersPath(configPath) {
|
|
4264
|
+
return join3(dirname2(configPath), "vouchers.json");
|
|
4265
|
+
}
|
|
2997
4266
|
function defaultPricingPath(configPath) {
|
|
2998
4267
|
return join3(dirname2(configPath), "pricing.json");
|
|
2999
4268
|
}
|
|
3000
4269
|
function defaultUsageEventsPath(configPath) {
|
|
3001
4270
|
return join3(dirname2(configPath), "usage-events.jsonl");
|
|
3002
4271
|
}
|
|
4272
|
+
function defaultAuditDir(configPath) {
|
|
4273
|
+
return join3(dirname2(configPath), "audit");
|
|
4274
|
+
}
|
|
4275
|
+
function defaultBillingDir(configPath) {
|
|
4276
|
+
return join3(dirname2(configPath), "billing");
|
|
4277
|
+
}
|
|
3003
4278
|
|
|
3004
4279
|
// src/ports/ConfigFileProviderConfigSource.ts
|
|
3005
4280
|
import {
|
|
@@ -3161,46 +4436,199 @@ function toLLMProvider(row) {
|
|
|
3161
4436
|
};
|
|
3162
4437
|
}
|
|
3163
4438
|
|
|
3164
|
-
// src/ports/
|
|
3165
|
-
|
|
4439
|
+
// src/ports/ConfigurableLogger.ts
|
|
4440
|
+
import { createWriteStream } from "fs";
|
|
4441
|
+
var LEVEL_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
4442
|
+
var RESERVED_JSON_KEYS = /* @__PURE__ */ new Set(["ts", "level", "msg", "error"]);
|
|
4443
|
+
var ConfigurableLogger = class {
|
|
4444
|
+
threshold;
|
|
4445
|
+
format;
|
|
4446
|
+
filePath;
|
|
4447
|
+
fileStream = null;
|
|
4448
|
+
fileDisabled = false;
|
|
4449
|
+
constructor(cfg) {
|
|
4450
|
+
this.threshold = LEVEL_ORDER[cfg?.level ?? "debug"];
|
|
4451
|
+
this.format = cfg?.format ?? "text";
|
|
4452
|
+
this.filePath = cfg?.file && cfg.file.length > 0 ? cfg.file : void 0;
|
|
4453
|
+
}
|
|
3166
4454
|
info(message, meta) {
|
|
3167
|
-
|
|
3168
|
-
else console.info(message, meta);
|
|
4455
|
+
this.emit("info", message, void 0, meta);
|
|
3169
4456
|
}
|
|
3170
4457
|
warn(message, meta) {
|
|
3171
|
-
|
|
3172
|
-
else console.warn(message, meta);
|
|
4458
|
+
this.emit("warn", message, void 0, meta);
|
|
3173
4459
|
}
|
|
3174
4460
|
error(message, error, meta) {
|
|
3175
|
-
|
|
3176
|
-
else if (meta === void 0) console.error(message, error);
|
|
3177
|
-
else console.error(message, error, meta);
|
|
4461
|
+
this.emit("error", message, error, meta);
|
|
3178
4462
|
}
|
|
3179
4463
|
debug(message, meta) {
|
|
3180
|
-
|
|
3181
|
-
|
|
4464
|
+
this.emit("debug", message, void 0, meta);
|
|
4465
|
+
}
|
|
4466
|
+
/**
|
|
4467
|
+
* Flush + close the file sink (tests / graceful shutdown). Resolves once the
|
|
4468
|
+
* append stream has finished flushing to disk. No-op when no file sink is open.
|
|
4469
|
+
*/
|
|
4470
|
+
close() {
|
|
4471
|
+
const stream = this.fileStream;
|
|
4472
|
+
this.fileStream = null;
|
|
4473
|
+
if (!stream) return Promise.resolve();
|
|
4474
|
+
return new Promise((resolve) => stream.end(() => resolve()));
|
|
4475
|
+
}
|
|
4476
|
+
emit(level, message, error, meta) {
|
|
4477
|
+
if (LEVEL_ORDER[level] > this.threshold) return;
|
|
4478
|
+
this.writeConsole(level, message, error, meta);
|
|
4479
|
+
if (this.filePath) this.writeFile(level, message, error, meta);
|
|
4480
|
+
}
|
|
4481
|
+
/**
|
|
4482
|
+
* Console sink. In `text` format this reproduces the legacy `ConsoleLogger`
|
|
4483
|
+
* EXACTLY (same method + arg shape) so the unconfigured default is a byte-for-
|
|
4484
|
+
* byte drop-in; in `json` format it prints the structured line.
|
|
4485
|
+
*/
|
|
4486
|
+
writeConsole(level, message, error, meta) {
|
|
4487
|
+
if (this.format === "json") {
|
|
4488
|
+
this.consoleFn(level)(this.jsonLine(level, message, error, meta));
|
|
4489
|
+
return;
|
|
4490
|
+
}
|
|
4491
|
+
if (level === "error") {
|
|
4492
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
4493
|
+
else if (meta === void 0) console.error(message, error);
|
|
4494
|
+
else console.error(message, error, meta);
|
|
4495
|
+
return;
|
|
4496
|
+
}
|
|
4497
|
+
const fn = this.consoleFn(level);
|
|
4498
|
+
if (meta === void 0) fn(message);
|
|
4499
|
+
else fn(message, meta);
|
|
4500
|
+
}
|
|
4501
|
+
/** Append one line to the file sink; a failure disables the sink (swallowed). */
|
|
4502
|
+
writeFile(level, message, error, meta) {
|
|
4503
|
+
const stream = this.getFileStream();
|
|
4504
|
+
if (!stream) return;
|
|
4505
|
+
try {
|
|
4506
|
+
const line = this.format === "json" ? this.jsonLine(level, message, error, meta) : this.textLine(level, message, error, meta);
|
|
4507
|
+
stream.write(line + "\n");
|
|
4508
|
+
} catch {
|
|
4509
|
+
}
|
|
4510
|
+
}
|
|
4511
|
+
/** Lazily open the append-only file stream; disable the sink on any error. */
|
|
4512
|
+
getFileStream() {
|
|
4513
|
+
if (this.fileDisabled || !this.filePath) return null;
|
|
4514
|
+
if (this.fileStream) return this.fileStream;
|
|
4515
|
+
try {
|
|
4516
|
+
const stream = createWriteStream(this.filePath, { flags: "a" });
|
|
4517
|
+
stream.on("error", () => {
|
|
4518
|
+
this.fileDisabled = true;
|
|
4519
|
+
this.fileStream = null;
|
|
4520
|
+
});
|
|
4521
|
+
this.fileStream = stream;
|
|
4522
|
+
return stream;
|
|
4523
|
+
} catch {
|
|
4524
|
+
this.fileDisabled = true;
|
|
4525
|
+
return null;
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4528
|
+
consoleFn(level) {
|
|
4529
|
+
switch (level) {
|
|
4530
|
+
case "error":
|
|
4531
|
+
return console.error;
|
|
4532
|
+
case "warn":
|
|
4533
|
+
return console.warn;
|
|
4534
|
+
case "info":
|
|
4535
|
+
return console.info;
|
|
4536
|
+
case "debug":
|
|
4537
|
+
return console.debug;
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
/** `{ ts, level, msg, ...meta }` (+ `error` when present) as a single line. */
|
|
4541
|
+
jsonLine(level, message, error, meta) {
|
|
4542
|
+
const obj = {
|
|
4543
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4544
|
+
level,
|
|
4545
|
+
msg: message
|
|
4546
|
+
};
|
|
4547
|
+
if (error !== void 0) obj["error"] = reduceError(error);
|
|
4548
|
+
if (meta !== void 0) {
|
|
4549
|
+
if (meta instanceof Error) obj["meta"] = reduceError(meta);
|
|
4550
|
+
else if (meta && typeof meta === "object") {
|
|
4551
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
4552
|
+
if (!RESERVED_JSON_KEYS.has(k)) obj[k] = v;
|
|
4553
|
+
}
|
|
4554
|
+
} else obj["meta"] = meta;
|
|
4555
|
+
}
|
|
4556
|
+
try {
|
|
4557
|
+
return JSON.stringify(obj);
|
|
4558
|
+
} catch {
|
|
4559
|
+
return JSON.stringify({ ts: obj["ts"], level, msg: message });
|
|
4560
|
+
}
|
|
4561
|
+
}
|
|
4562
|
+
/** Human-readable file line: `ISO [level] message {metaJson}`. */
|
|
4563
|
+
textLine(level, message, error, meta) {
|
|
4564
|
+
const parts = [`${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}`];
|
|
4565
|
+
if (error !== void 0) parts.push(safeStringify(reduceError(error)));
|
|
4566
|
+
if (meta !== void 0) parts.push(safeStringify(meta instanceof Error ? reduceError(meta) : meta));
|
|
4567
|
+
return parts.join(" ");
|
|
3182
4568
|
}
|
|
3183
4569
|
};
|
|
4570
|
+
function reduceError(error) {
|
|
4571
|
+
if (error instanceof Error) {
|
|
4572
|
+
return error.stack ? { message: error.message, stack: error.stack } : { message: error.message };
|
|
4573
|
+
}
|
|
4574
|
+
return { value: String(error) };
|
|
4575
|
+
}
|
|
4576
|
+
function safeStringify(value) {
|
|
4577
|
+
try {
|
|
4578
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
4579
|
+
} catch {
|
|
4580
|
+
return "[unserializable]";
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
3184
4583
|
|
|
3185
4584
|
// src/ports/JsonApiServerSettingsStore.ts
|
|
3186
4585
|
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
3187
4586
|
import { OUTBOUND_API_SERVER_CONFIG_KEY } from "@omnicross/core/outbound-api";
|
|
3188
4587
|
var JsonApiServerSettingsStore = class {
|
|
3189
|
-
|
|
4588
|
+
/**
|
|
4589
|
+
* @param configPath the daemon config.json whose `server` field is backed.
|
|
4590
|
+
* @param box OPTIONAL at-rest `SecretBox` (upstream-proxy). When set, the
|
|
4591
|
+
* `server.proxy.*` passwords are encrypted-on-`set` /
|
|
4592
|
+
* decrypted-on-`get` (the settings-store path is otherwise not
|
|
4593
|
+
* secret-aware — every OTHER server field is non-secret). Null
|
|
4594
|
+
* ⇒ passthrough (legacy/pure tests unchanged).
|
|
4595
|
+
*/
|
|
4596
|
+
constructor(configPath, box = null) {
|
|
3190
4597
|
this.configPath = configPath;
|
|
4598
|
+
this.box = box;
|
|
3191
4599
|
}
|
|
3192
4600
|
configPath;
|
|
4601
|
+
box;
|
|
3193
4602
|
async get(key) {
|
|
3194
4603
|
if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return void 0;
|
|
3195
4604
|
const file = this.readFile();
|
|
3196
|
-
|
|
4605
|
+
if (file.server === void 0) return void 0;
|
|
4606
|
+
return this.decryptSecrets(file.server);
|
|
3197
4607
|
}
|
|
3198
4608
|
async set(key, value) {
|
|
3199
4609
|
if (key !== OUTBOUND_API_SERVER_CONFIG_KEY) return;
|
|
3200
4610
|
const file = this.readFile();
|
|
3201
|
-
file.server = value;
|
|
4611
|
+
file.server = this.encryptSecrets(value);
|
|
3202
4612
|
writeFileSync3(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
3203
4613
|
}
|
|
4614
|
+
/** Encrypt the proxy passwords + webhook + billing secrets before persisting (no-op without a box). */
|
|
4615
|
+
encryptSecrets(config) {
|
|
4616
|
+
if (!this.box) return config;
|
|
4617
|
+
let out = config;
|
|
4618
|
+
if (out?.proxy) out = { ...out, proxy: encryptProxySegment(out.proxy, this.box) };
|
|
4619
|
+
if (out?.webhook) out = { ...out, webhook: encryptWebhookSegment(out.webhook, this.box) };
|
|
4620
|
+
if (out?.billing) out = { ...out, billing: encryptBillingSegment(out.billing, this.box) };
|
|
4621
|
+
return out;
|
|
4622
|
+
}
|
|
4623
|
+
/** Decrypt the proxy passwords + webhook + billing secrets on read (no-op without a box). */
|
|
4624
|
+
decryptSecrets(config) {
|
|
4625
|
+
if (!this.box) return config;
|
|
4626
|
+
let out = config;
|
|
4627
|
+
if (out?.proxy) out = { ...out, proxy: decryptProxySegment(out.proxy, this.box) };
|
|
4628
|
+
if (out?.webhook) out = { ...out, webhook: decryptWebhookSegment(out.webhook, this.box) };
|
|
4629
|
+
if (out?.billing) out = { ...out, billing: decryptBillingSegment(out.billing, this.box) };
|
|
4630
|
+
return out;
|
|
4631
|
+
}
|
|
3204
4632
|
/** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
|
|
3205
4633
|
readFile() {
|
|
3206
4634
|
try {
|
|
@@ -3319,6 +4747,57 @@ var JsonlUsageEventStore = class {
|
|
|
3319
4747
|
}
|
|
3320
4748
|
return Array.from(groups.values());
|
|
3321
4749
|
}
|
|
4750
|
+
/**
|
|
4751
|
+
* ONE pass over a single key's events (`ts < endTs`) summing its `costUsd` into
|
|
4752
|
+
* `totalUsd` / `dailyUsd` (`ts >= dayStartTs`) / `weeklyUsd` (`ts >= weekStartTs`).
|
|
4753
|
+
* Used to lazily seed the outbound key-policy spend tracker (once per key). A
|
|
4754
|
+
* key with no attributed events yields all zeros.
|
|
4755
|
+
*/
|
|
4756
|
+
async getSpendByKey(query) {
|
|
4757
|
+
let totalUsd = 0;
|
|
4758
|
+
let dailyUsd = 0;
|
|
4759
|
+
let weeklyUsd = 0;
|
|
4760
|
+
for (const row of this.readRows({ startTs: 0, endTs: query.endTs })) {
|
|
4761
|
+
if (row.apiKeyId !== query.apiKeyId) continue;
|
|
4762
|
+
totalUsd += row.costUsd;
|
|
4763
|
+
if (row.ts >= query.dayStartTs) dailyUsd += row.costUsd;
|
|
4764
|
+
if (row.ts >= query.weekStartTs) weeklyUsd += row.costUsd;
|
|
4765
|
+
}
|
|
4766
|
+
return { totalUsd, dailyUsd, weeklyUsd };
|
|
4767
|
+
}
|
|
4768
|
+
/**
|
|
4769
|
+
* Time-series aggregation over LOCAL-time bucket boundaries. Every bucket in
|
|
4770
|
+
* `[floor(startTs), endTs)` is present (empty ones zero-filled), ascending by
|
|
4771
|
+
* `bucketStartTs`; an empty range (`startTs >= endTs`) returns `[]`. Reuses
|
|
4772
|
+
* `readRows` so malformed lines are skipped and only in-range rows contribute.
|
|
4773
|
+
*/
|
|
4774
|
+
async getTimeSeries(range, bucket) {
|
|
4775
|
+
if (range.startTs >= range.endTs) return [];
|
|
4776
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
4777
|
+
for (let b = floorToBucket(range.startTs, bucket); b < range.endTs; b = nextBoundary(b, bucket)) {
|
|
4778
|
+
buckets.set(b, {
|
|
4779
|
+
bucketStartTs: b,
|
|
4780
|
+
label: bucketLabel(b, bucket),
|
|
4781
|
+
requests: 0,
|
|
4782
|
+
inputTokens: 0,
|
|
4783
|
+
outputTokens: 0,
|
|
4784
|
+
cacheReadTokens: 0,
|
|
4785
|
+
cacheCreationTokens: 0,
|
|
4786
|
+
costUsd: 0
|
|
4787
|
+
});
|
|
4788
|
+
}
|
|
4789
|
+
for (const row of this.readRows(range)) {
|
|
4790
|
+
const g = buckets.get(floorToBucket(row.ts, bucket));
|
|
4791
|
+
if (!g) continue;
|
|
4792
|
+
g.requests += 1;
|
|
4793
|
+
g.inputTokens += row.inputTokens;
|
|
4794
|
+
g.outputTokens += row.outputTokens;
|
|
4795
|
+
g.cacheReadTokens += row.cacheReadTokens;
|
|
4796
|
+
g.cacheCreationTokens += row.cacheCreationTokens;
|
|
4797
|
+
g.costUsd += row.costUsd;
|
|
4798
|
+
}
|
|
4799
|
+
return Array.from(buckets.values());
|
|
4800
|
+
}
|
|
3322
4801
|
async getMessagesForSession(sessionId) {
|
|
3323
4802
|
return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
|
|
3324
4803
|
id: r.id,
|
|
@@ -3387,6 +4866,43 @@ var JsonlUsageEventStore = class {
|
|
|
3387
4866
|
return rows;
|
|
3388
4867
|
}
|
|
3389
4868
|
};
|
|
4869
|
+
function floorToBucket(ts, bucket) {
|
|
4870
|
+
const d = new Date(ts);
|
|
4871
|
+
switch (bucket) {
|
|
4872
|
+
case "hour":
|
|
4873
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime();
|
|
4874
|
+
case "day":
|
|
4875
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
4876
|
+
case "month":
|
|
4877
|
+
return new Date(d.getFullYear(), d.getMonth(), 1).getTime();
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
function nextBoundary(ts, bucket) {
|
|
4881
|
+
const d = new Date(ts);
|
|
4882
|
+
switch (bucket) {
|
|
4883
|
+
case "hour":
|
|
4884
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours() + 1).getTime();
|
|
4885
|
+
case "day":
|
|
4886
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
|
|
4887
|
+
case "month":
|
|
4888
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
|
|
4889
|
+
}
|
|
4890
|
+
}
|
|
4891
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
4892
|
+
function bucketLabel(bucketStartTs, bucket) {
|
|
4893
|
+
const d = new Date(bucketStartTs);
|
|
4894
|
+
const y = d.getFullYear();
|
|
4895
|
+
const mo = pad2(d.getMonth() + 1);
|
|
4896
|
+
const day = pad2(d.getDate());
|
|
4897
|
+
switch (bucket) {
|
|
4898
|
+
case "hour":
|
|
4899
|
+
return `${mo}-${day} ${pad2(d.getHours())}:00`;
|
|
4900
|
+
case "day":
|
|
4901
|
+
return `${y}-${mo}-${day}`;
|
|
4902
|
+
case "month":
|
|
4903
|
+
return `${y}-${mo}`;
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
3390
4906
|
var NUMERIC_FIELDS = [
|
|
3391
4907
|
"ts",
|
|
3392
4908
|
"inputTokens",
|
|
@@ -3470,6 +4986,45 @@ var JsonOutboundKeyDb = class {
|
|
|
3470
4986
|
return true;
|
|
3471
4987
|
});
|
|
3472
4988
|
}
|
|
4989
|
+
async outboundApiKeysSetMaxConcurrency(id, maxConcurrency) {
|
|
4990
|
+
return this.mutateRow(id, (row) => {
|
|
4991
|
+
if (row.revokedAt !== null) return false;
|
|
4992
|
+
if (maxConcurrency === null) delete row.maxConcurrency;
|
|
4993
|
+
else row.maxConcurrency = maxConcurrency;
|
|
4994
|
+
return true;
|
|
4995
|
+
});
|
|
4996
|
+
}
|
|
4997
|
+
async outboundApiKeysSetPolicy(id, policy) {
|
|
4998
|
+
return this.mutateRow(id, (row) => {
|
|
4999
|
+
if (row.revokedAt !== null) return false;
|
|
5000
|
+
applyPolicyField(row, "expiresAt", policy.expiresAt);
|
|
5001
|
+
applyPolicyField(row, "activationDays", policy.activationDays);
|
|
5002
|
+
applyPolicyField(row, "dailyCostLimitUsd", policy.dailyCostLimitUsd);
|
|
5003
|
+
applyPolicyField(row, "totalCostLimitUsd", policy.totalCostLimitUsd);
|
|
5004
|
+
applyPolicyField(row, "weeklyCostLimitUsd", policy.weeklyCostLimitUsd);
|
|
5005
|
+
applyPolicyField(row, "rateLimitMaxRequests", policy.rateLimitMaxRequests);
|
|
5006
|
+
applyPolicyField(row, "rateLimitWindowMs", policy.rateLimitWindowMs);
|
|
5007
|
+
if (policy.activationMode === null) delete row.activationMode;
|
|
5008
|
+
else if (policy.activationMode !== void 0) row.activationMode = policy.activationMode;
|
|
5009
|
+
if (policy.enableModelRestriction === null) delete row.enableModelRestriction;
|
|
5010
|
+
else if (policy.enableModelRestriction !== void 0) {
|
|
5011
|
+
row.enableModelRestriction = policy.enableModelRestriction;
|
|
5012
|
+
}
|
|
5013
|
+
if (policy.restrictionMode === null) delete row.restrictionMode;
|
|
5014
|
+
else if (policy.restrictionMode !== void 0) row.restrictionMode = policy.restrictionMode;
|
|
5015
|
+
if (policy.restrictedModels === null) delete row.restrictedModels;
|
|
5016
|
+
else if (policy.restrictedModels !== void 0) row.restrictedModels = policy.restrictedModels;
|
|
5017
|
+
return true;
|
|
5018
|
+
});
|
|
5019
|
+
}
|
|
5020
|
+
async outboundApiKeysMarkActivated(id, activatedAt) {
|
|
5021
|
+
return this.mutateRow(id, (row) => {
|
|
5022
|
+
if (row.revokedAt !== null) return false;
|
|
5023
|
+
if (row.activatedAt != null) return false;
|
|
5024
|
+
row.activatedAt = activatedAt;
|
|
5025
|
+
return true;
|
|
5026
|
+
});
|
|
5027
|
+
}
|
|
3473
5028
|
/** Apply `fn` to the row with `id`, persisting when it returns true. */
|
|
3474
5029
|
mutateRow(id, fn) {
|
|
3475
5030
|
const rows = this.readRows();
|
|
@@ -3493,6 +5048,11 @@ var JsonOutboundKeyDb = class {
|
|
|
3493
5048
|
writeFileSync4(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
3494
5049
|
}
|
|
3495
5050
|
};
|
|
5051
|
+
function applyPolicyField(row, field, value) {
|
|
5052
|
+
if (value === void 0) return;
|
|
5053
|
+
if (value === null) delete row[field];
|
|
5054
|
+
else row[field] = value;
|
|
5055
|
+
}
|
|
3496
5056
|
|
|
3497
5057
|
// src/ports/JsonPricingStore.ts
|
|
3498
5058
|
import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
@@ -3619,14 +5179,109 @@ var JsonPricingStore = class {
|
|
|
3619
5179
|
}
|
|
3620
5180
|
};
|
|
3621
5181
|
|
|
3622
|
-
// src/ports/
|
|
3623
|
-
import { existsSync as
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
5182
|
+
// src/ports/JsonVoucherDb.ts
|
|
5183
|
+
import { existsSync as existsSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
|
|
5184
|
+
var JsonVoucherDb = class {
|
|
5185
|
+
constructor(vouchersPath) {
|
|
5186
|
+
this.vouchersPath = vouchersPath;
|
|
5187
|
+
}
|
|
5188
|
+
vouchersPath;
|
|
5189
|
+
async voucherCreate(input) {
|
|
5190
|
+
const rows = this.readRows();
|
|
5191
|
+
const row = {
|
|
5192
|
+
id: input.id,
|
|
5193
|
+
codeHash: input.codeHash,
|
|
5194
|
+
codePrefix: input.codePrefix,
|
|
5195
|
+
type: input.type,
|
|
5196
|
+
status: "unredeemed",
|
|
5197
|
+
createdAt: input.createdAt ?? Date.now()
|
|
5198
|
+
};
|
|
5199
|
+
if (input.creditUsd != null) row.creditUsd = input.creditUsd;
|
|
5200
|
+
if (input.renewalDays != null) row.renewalDays = input.renewalDays;
|
|
5201
|
+
if (input.maxTotalCostLimitUsd != null) row.maxTotalCostLimitUsd = input.maxTotalCostLimitUsd;
|
|
5202
|
+
if (input.maxExpiryDays != null) row.maxExpiryDays = input.maxExpiryDays;
|
|
5203
|
+
rows.push(row);
|
|
5204
|
+
this.writeRows(rows);
|
|
5205
|
+
return row;
|
|
5206
|
+
}
|
|
5207
|
+
async voucherGetByHash(codeHash) {
|
|
5208
|
+
const rows = this.readRows();
|
|
5209
|
+
return rows.find((r) => r.codeHash === codeHash) ?? null;
|
|
5210
|
+
}
|
|
5211
|
+
async voucherRedeemCas(id, keyId, granted, now) {
|
|
5212
|
+
const rows = this.readRows();
|
|
5213
|
+
const row = rows.find((r) => r.id === id);
|
|
5214
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5215
|
+
row.status = "redeemed";
|
|
5216
|
+
row.redeemedAt = now;
|
|
5217
|
+
row.redeemedByKeyId = keyId;
|
|
5218
|
+
row.grantApplied = false;
|
|
5219
|
+
if (granted.totalCostLimitUsd != null) row.grantedTotalCostLimitUsd = granted.totalCostLimitUsd;
|
|
5220
|
+
if (granted.expiresAt != null) row.grantedExpiresAt = granted.expiresAt;
|
|
5221
|
+
this.writeRows(rows);
|
|
5222
|
+
return true;
|
|
5223
|
+
}
|
|
5224
|
+
async voucherMarkGrantApplied(id) {
|
|
5225
|
+
const rows = this.readRows();
|
|
5226
|
+
const row = rows.find((r) => r.id === id);
|
|
5227
|
+
if (!row || row.status !== "redeemed") return false;
|
|
5228
|
+
if (row.grantApplied === true) return true;
|
|
5229
|
+
row.grantApplied = true;
|
|
5230
|
+
this.writeRows(rows);
|
|
5231
|
+
return true;
|
|
5232
|
+
}
|
|
5233
|
+
async voucherRevertRedeem(id, keyId) {
|
|
5234
|
+
const rows = this.readRows();
|
|
5235
|
+
const row = rows.find((r) => r.id === id);
|
|
5236
|
+
if (!row || row.status !== "redeemed" || row.grantApplied === true) return false;
|
|
5237
|
+
if (row.redeemedByKeyId !== keyId) return false;
|
|
5238
|
+
row.status = "unredeemed";
|
|
5239
|
+
delete row.redeemedAt;
|
|
5240
|
+
delete row.redeemedByKeyId;
|
|
5241
|
+
delete row.grantApplied;
|
|
5242
|
+
delete row.grantedTotalCostLimitUsd;
|
|
5243
|
+
delete row.grantedExpiresAt;
|
|
5244
|
+
this.writeRows(rows);
|
|
5245
|
+
return true;
|
|
5246
|
+
}
|
|
5247
|
+
async voucherRevokeCas(id, now) {
|
|
5248
|
+
const rows = this.readRows();
|
|
5249
|
+
const row = rows.find((r) => r.id === id);
|
|
5250
|
+
if (!row || row.status !== "unredeemed") return false;
|
|
5251
|
+
row.status = "revoked";
|
|
5252
|
+
row.revokedAt = now;
|
|
5253
|
+
this.writeRows(rows);
|
|
5254
|
+
return true;
|
|
5255
|
+
}
|
|
5256
|
+
async voucherList() {
|
|
5257
|
+
return this.readRows();
|
|
5258
|
+
}
|
|
5259
|
+
/** Read the voucher rows, tolerating a missing/corrupt file (→ empty list). */
|
|
5260
|
+
readRows() {
|
|
5261
|
+
if (!existsSync7(this.vouchersPath)) return [];
|
|
5262
|
+
try {
|
|
5263
|
+
const parsed = JSON.parse(readFileSync7(this.vouchersPath, "utf8"));
|
|
5264
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
5265
|
+
} catch {
|
|
5266
|
+
return [];
|
|
5267
|
+
}
|
|
5268
|
+
}
|
|
5269
|
+
writeRows(rows) {
|
|
5270
|
+
writeFileSync6(this.vouchersPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
|
|
5271
|
+
}
|
|
5272
|
+
};
|
|
5273
|
+
|
|
5274
|
+
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5275
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
|
|
5276
|
+
import { dirname as dirname4 } from "path";
|
|
5277
|
+
import { getSharedAccountHealth } from "@omnicross/core/pipeline/SubscriptionAccountHealth";
|
|
5278
|
+
import { fetchUpstream as fetchUpstream2 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
5279
|
+
import { getSharedIdentityStore } from "@omnicross/core/provider-proxy/identity/SubscriptionIdentityStore";
|
|
5280
|
+
import {
|
|
5281
|
+
claudeOAuth as claudeOAuth2,
|
|
5282
|
+
codexOAuth as codexOAuth2,
|
|
5283
|
+
geminiOAuth as geminiOAuth2
|
|
5284
|
+
} from "@omnicross/subscriptions";
|
|
3630
5285
|
|
|
3631
5286
|
// src/ports/account-sync.ts
|
|
3632
5287
|
var IMPORT_EXPIRY_MARGIN_MS = 6e4;
|
|
@@ -3702,7 +5357,7 @@ function findDuplicateCredentialIds(accounts) {
|
|
|
3702
5357
|
}
|
|
3703
5358
|
|
|
3704
5359
|
// src/ports/external-cli-credentials.ts
|
|
3705
|
-
import { existsSync as
|
|
5360
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
3706
5361
|
import { homedir as homedir2 } from "os";
|
|
3707
5362
|
import { join as join4 } from "path";
|
|
3708
5363
|
function externalStorePath(provider, home = homedir2()) {
|
|
@@ -3755,10 +5410,10 @@ function parseCodexTokensEnvelope(raw) {
|
|
|
3755
5410
|
}
|
|
3756
5411
|
function readExternalCliCredentials(provider, home = homedir2()) {
|
|
3757
5412
|
const path2 = externalStorePath(provider, home);
|
|
3758
|
-
if (!
|
|
5413
|
+
if (!existsSync8(path2)) return null;
|
|
3759
5414
|
let raw;
|
|
3760
5415
|
try {
|
|
3761
|
-
const parsed = JSON.parse(
|
|
5416
|
+
const parsed = JSON.parse(readFileSync8(path2, "utf8"));
|
|
3762
5417
|
raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
3763
5418
|
} catch {
|
|
3764
5419
|
return null;
|
|
@@ -3767,7 +5422,7 @@ function readExternalCliCredentials(provider, home = homedir2()) {
|
|
|
3767
5422
|
}
|
|
3768
5423
|
|
|
3769
5424
|
// src/ports/external-cli-store.ts
|
|
3770
|
-
import { copyFileSync, existsSync as
|
|
5425
|
+
import { copyFileSync, existsSync as existsSync9, mkdirSync as mkdirSync2, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync7 } from "fs";
|
|
3771
5426
|
import { homedir as homedir3 } from "os";
|
|
3772
5427
|
import { dirname as dirname3 } from "path";
|
|
3773
5428
|
function markerPath(provider, home) {
|
|
@@ -3795,9 +5450,9 @@ function buildCodexTokensEnvelope(tokens) {
|
|
|
3795
5450
|
return envelope;
|
|
3796
5451
|
}
|
|
3797
5452
|
function readExistingObject(path2) {
|
|
3798
|
-
if (!
|
|
5453
|
+
if (!existsSync9(path2)) return {};
|
|
3799
5454
|
try {
|
|
3800
|
-
const parsed = JSON.parse(
|
|
5455
|
+
const parsed = JSON.parse(readFileSync9(path2, "utf8"));
|
|
3801
5456
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3802
5457
|
} catch {
|
|
3803
5458
|
return {};
|
|
@@ -3806,16 +5461,16 @@ function readExistingObject(path2) {
|
|
|
3806
5461
|
function writeAtomic(path2, content) {
|
|
3807
5462
|
mkdirSync2(dirname3(path2), { recursive: true });
|
|
3808
5463
|
const temp = `${path2}.omnicross-tmp`;
|
|
3809
|
-
|
|
5464
|
+
writeFileSync7(temp, content, "utf8");
|
|
3810
5465
|
renameSync(temp, path2);
|
|
3811
5466
|
}
|
|
3812
5467
|
function createExternalCliStore(home = homedir3()) {
|
|
3813
5468
|
return {
|
|
3814
5469
|
readMarkerAccountId(provider) {
|
|
3815
5470
|
const path2 = markerPath(provider, home);
|
|
3816
|
-
if (!
|
|
5471
|
+
if (!existsSync9(path2)) return void 0;
|
|
3817
5472
|
try {
|
|
3818
|
-
const parsed = JSON.parse(
|
|
5473
|
+
const parsed = JSON.parse(readFileSync9(path2, "utf8"));
|
|
3819
5474
|
return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
|
|
3820
5475
|
} catch {
|
|
3821
5476
|
return void 0;
|
|
@@ -3833,7 +5488,7 @@ function createExternalCliStore(home = homedir3()) {
|
|
|
3833
5488
|
const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
|
|
3834
5489
|
if (!envelope) return false;
|
|
3835
5490
|
const storePath = externalStorePath(provider, home);
|
|
3836
|
-
if (
|
|
5491
|
+
if (existsSync9(storePath) && !existsSync9(backupPath(provider, home))) {
|
|
3837
5492
|
copyFileSync(storePath, backupPath(provider, home));
|
|
3838
5493
|
}
|
|
3839
5494
|
const existing = readExistingObject(storePath);
|
|
@@ -3845,16 +5500,21 @@ function createExternalCliStore(home = homedir3()) {
|
|
|
3845
5500
|
}
|
|
3846
5501
|
|
|
3847
5502
|
// src/ports/JsonSubscriptionCredentialStore.ts
|
|
5503
|
+
var ACCOUNT_REFRESH_LEAD_MS = 5 * 6e4;
|
|
3848
5504
|
var JsonSubscriptionCredentialStore = class {
|
|
3849
5505
|
/**
|
|
3850
5506
|
* @param tokensPath on-disk `tokens.json` location.
|
|
3851
5507
|
* @param box at-rest `SecretBox` (encrypt-on-write / decrypt-on-read).
|
|
3852
|
-
* @param fetchImpl injectable HTTP port for the OAuth refresh
|
|
3853
|
-
* (oauth design D4).
|
|
3854
|
-
*
|
|
3855
|
-
*
|
|
5508
|
+
* @param fetchImpl OPTIONAL injectable HTTP port for the OAuth refresh
|
|
5509
|
+
* round-trips (oauth design D4). A TEST-injected transport is
|
|
5510
|
+
* used verbatim. When ABSENT (production), each refresh uses a
|
|
5511
|
+
* proxy-aware {@link fetchUpstream} that threads the
|
|
5512
|
+
* `{ providerId, accountId }` ctx (upstream-proxy M1) so a
|
|
5513
|
+
* per-account/per-provider proxy is honored on refresh exactly
|
|
5514
|
+
* as on relay — refresh egresses from the SAME proxy IP as the
|
|
5515
|
+
* account's traffic. NOT used by any read/write path.
|
|
3856
5516
|
*/
|
|
3857
|
-
constructor(tokensPath, box, fetchImpl =
|
|
5517
|
+
constructor(tokensPath, box, fetchImpl = void 0, externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
|
|
3858
5518
|
this.tokensPath = tokensPath;
|
|
3859
5519
|
this.box = box;
|
|
3860
5520
|
this.fetchImpl = fetchImpl;
|
|
@@ -3866,6 +5526,15 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3866
5526
|
fetchImpl;
|
|
3867
5527
|
externalCliReader;
|
|
3868
5528
|
externalCliStore;
|
|
5529
|
+
/**
|
|
5530
|
+
* The proxy-aware `FetchLike` for one refresh round-trip (upstream-proxy M1). A
|
|
5531
|
+
* TEST-injected `fetchImpl` is returned verbatim; otherwise the refresh routes
|
|
5532
|
+
* through {@link fetchUpstream} with the account's `{ providerId, accountId }`
|
|
5533
|
+
* ctx so the per-account/provider proxy applies. `@internal` — also a test seam.
|
|
5534
|
+
*/
|
|
5535
|
+
buildRefreshFetch(providerId, accountId) {
|
|
5536
|
+
return this.fetchImpl ?? ((url, init) => fetchUpstream2(url, init, { providerId, accountId }));
|
|
5537
|
+
}
|
|
3869
5538
|
/**
|
|
3870
5539
|
* In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
|
|
3871
5540
|
* SINGLE-USE: two concurrent refreshes of one account each spend the same
|
|
@@ -3897,6 +5566,19 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3897
5566
|
async getValidOpenCodeGoApiKey() {
|
|
3898
5567
|
return this.readConfig().opencodego?.apiKey ?? null;
|
|
3899
5568
|
}
|
|
5569
|
+
/**
|
|
5570
|
+
* DAEMON-ONLY per-account proxy lookup by id (upstream-proxy). Returns the
|
|
5571
|
+
* DECRYPTED `ProxyConfig` for the account (`readConfig` decrypts on read), or
|
|
5572
|
+
* `undefined` for an unknown provider/account or no per-account proxy. Feeds the
|
|
5573
|
+
* winning per-account layer of the upstream-proxy resolver. Synchronous like the
|
|
5574
|
+
* other hot reads. Never returns token material.
|
|
5575
|
+
*/
|
|
5576
|
+
getAccountProxy(providerId, accountId) {
|
|
5577
|
+
if (providerId !== "claude" && providerId !== "codex" && providerId !== "gemini" && providerId !== "opencodego") {
|
|
5578
|
+
return void 0;
|
|
5579
|
+
}
|
|
5580
|
+
return getAccountProxy(this.readConfig(), providerId, accountId);
|
|
5581
|
+
}
|
|
3900
5582
|
/**
|
|
3901
5583
|
* DAEMON-ONLY sanitized accounts list (design D8, NOT on the port). Projects
|
|
3902
5584
|
* each provider's accounts to the secret-free `SubscriptionAccountSanitized`
|
|
@@ -3905,10 +5587,25 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3905
5587
|
*/
|
|
3906
5588
|
async listSanitizedAccounts() {
|
|
3907
5589
|
const config = this.readConfig();
|
|
5590
|
+
const health2 = getSharedAccountHealth();
|
|
5591
|
+
const identityStore = getSharedIdentityStore();
|
|
5592
|
+
const fingerprintOn = identityStore.isEnabled();
|
|
5593
|
+
const now = Date.now();
|
|
3908
5594
|
const out = {};
|
|
3909
5595
|
for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
|
|
3910
5596
|
const sanitized = sanitizeAccounts(config, provider);
|
|
3911
|
-
if (sanitized.length
|
|
5597
|
+
if (sanitized.length === 0) continue;
|
|
5598
|
+
for (const account of sanitized) {
|
|
5599
|
+
const status = health2.getStatus(provider, account.id, now);
|
|
5600
|
+
account.health = status.state;
|
|
5601
|
+
account.cooldownUntil = status.cooldownUntil !== void 0 ? new Date(status.cooldownUntil).toISOString() : void 0;
|
|
5602
|
+
if (fingerprintOn && provider === "claude") {
|
|
5603
|
+
account.identityCaptured = identityStore.hasIdentity(provider, account.id);
|
|
5604
|
+
const capturedAt = identityStore.capturedAt(provider, account.id);
|
|
5605
|
+
account.identityCapturedAt = capturedAt !== void 0 ? new Date(capturedAt).toISOString() : void 0;
|
|
5606
|
+
}
|
|
5607
|
+
}
|
|
5608
|
+
out[provider] = this.attachSyncWarnings(config, provider, sanitized);
|
|
3912
5609
|
}
|
|
3913
5610
|
return out;
|
|
3914
5611
|
}
|
|
@@ -3959,8 +5656,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3959
5656
|
if (!active || !claude?.refreshToken) return false;
|
|
3960
5657
|
const capturedId = active.id;
|
|
3961
5658
|
this.materializeMigration(config);
|
|
5659
|
+
const refreshFetch = this.buildRefreshFetch("claude", capturedId);
|
|
3962
5660
|
try {
|
|
3963
|
-
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken,
|
|
5661
|
+
const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, refreshFetch);
|
|
3964
5662
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
3965
5663
|
const next = {
|
|
3966
5664
|
...claude,
|
|
@@ -3977,7 +5675,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
3977
5675
|
return true;
|
|
3978
5676
|
} catch (error) {
|
|
3979
5677
|
if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
|
|
3980
|
-
const r = await claudeOAuth2.refreshAccessToken(rt,
|
|
5678
|
+
const r = await claudeOAuth2.refreshAccessToken(rt, refreshFetch);
|
|
3981
5679
|
return {
|
|
3982
5680
|
accessToken: r.accessToken,
|
|
3983
5681
|
refreshToken: r.refreshToken,
|
|
@@ -4004,8 +5702,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4004
5702
|
if (!active || !codex?.refreshToken) return false;
|
|
4005
5703
|
const capturedId = active.id;
|
|
4006
5704
|
this.materializeMigration(config);
|
|
5705
|
+
const refreshFetch = this.buildRefreshFetch("codex", capturedId);
|
|
4007
5706
|
try {
|
|
4008
|
-
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken,
|
|
5707
|
+
const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, refreshFetch);
|
|
4009
5708
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4010
5709
|
const next = {
|
|
4011
5710
|
...codex,
|
|
@@ -4023,7 +5722,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4023
5722
|
return true;
|
|
4024
5723
|
} catch (error) {
|
|
4025
5724
|
if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
|
|
4026
|
-
const r = await codexOAuth2.refreshAccessToken(rt,
|
|
5725
|
+
const r = await codexOAuth2.refreshAccessToken(rt, refreshFetch);
|
|
4027
5726
|
return {
|
|
4028
5727
|
accessToken: r.accessToken,
|
|
4029
5728
|
refreshToken: r.refreshToken,
|
|
@@ -4053,8 +5752,9 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4053
5752
|
if (!active || !gemini?.refreshToken) return false;
|
|
4054
5753
|
const capturedId = active.id;
|
|
4055
5754
|
this.materializeMigration(config);
|
|
5755
|
+
const refreshFetch = this.buildRefreshFetch("gemini", capturedId);
|
|
4056
5756
|
try {
|
|
4057
|
-
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken,
|
|
5757
|
+
const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, refreshFetch);
|
|
4058
5758
|
const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
|
|
4059
5759
|
const next = {
|
|
4060
5760
|
...gemini,
|
|
@@ -4088,7 +5788,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4088
5788
|
if (!account || !captured?.refreshToken) return false;
|
|
4089
5789
|
this.materializeMigration(config);
|
|
4090
5790
|
try {
|
|
4091
|
-
const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
|
|
5791
|
+
const refreshed = await this.refreshUpstream(provider, captured.refreshToken, id);
|
|
4092
5792
|
const next = {
|
|
4093
5793
|
...captured,
|
|
4094
5794
|
accessToken: refreshed.accessToken,
|
|
@@ -4110,10 +5810,114 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4110
5810
|
}
|
|
4111
5811
|
});
|
|
4112
5812
|
}
|
|
5813
|
+
// ── By-id account-pool surface (subscription-account-scheduling, design D6) ──
|
|
5814
|
+
/**
|
|
5815
|
+
* Resolve a SPECIFIC account's access token by id (design D6). Mirrors each
|
|
5816
|
+
* provider's ACTIVE-getter policy, keyed by id: claude returns the stored token
|
|
5817
|
+
* (refresh is 401-driven, like `getValidClaudeAccessToken`); codex/gemini refresh
|
|
5818
|
+
* a near-expiry token via `refreshAccountById` (like `resolveAccessToken`);
|
|
5819
|
+
* opencodego returns the account's static key. `null` when unknown/expired/
|
|
5820
|
+
* tokenless.
|
|
5821
|
+
*/
|
|
5822
|
+
async getAccessTokenForAccount(providerId, accountId) {
|
|
5823
|
+
const account = getAccountById(this.readConfig(), providerId, accountId);
|
|
5824
|
+
if (!account) return null;
|
|
5825
|
+
if (providerId === "opencodego") {
|
|
5826
|
+
return account.tokens.apiKey ?? null;
|
|
5827
|
+
}
|
|
5828
|
+
const oauth = account.tokens;
|
|
5829
|
+
if (!oauth.accessToken) return null;
|
|
5830
|
+
if (providerId === "codex" || providerId === "gemini") {
|
|
5831
|
+
const expiresAtMs = oauth.expiresAt ? Date.parse(oauth.expiresAt) : 0;
|
|
5832
|
+
const expiringSoon = expiresAtMs > 0 && Date.now() >= expiresAtMs - ACCOUNT_REFRESH_LEAD_MS;
|
|
5833
|
+
if (expiringSoon && oauth.refreshToken) {
|
|
5834
|
+
const ok = await this.refreshAccountById(providerId, accountId);
|
|
5835
|
+
if (!ok) return null;
|
|
5836
|
+
const fresh = getAccountById(this.readConfig(), providerId, accountId);
|
|
5837
|
+
return fresh?.tokens?.accessToken ?? null;
|
|
5838
|
+
}
|
|
5839
|
+
}
|
|
5840
|
+
if (oauth.status === "expired") return null;
|
|
5841
|
+
return oauth.accessToken;
|
|
5842
|
+
}
|
|
5843
|
+
/**
|
|
5844
|
+
* Refresh a SPECIFIC account's OAuth token by id (design D6/D7). Delegates to
|
|
5845
|
+
* `refreshAccountById` (coalesced per `provider:id`); opencodego is a static key
|
|
5846
|
+
* → `false` (no refresh affordance).
|
|
5847
|
+
*/
|
|
5848
|
+
async refreshAccountToken(providerId, accountId) {
|
|
5849
|
+
if (providerId === "opencodego") return false;
|
|
5850
|
+
return this.refreshAccountById(providerId, accountId);
|
|
5851
|
+
}
|
|
5852
|
+
/**
|
|
5853
|
+
* Best-effort record of a selection time onto the account's `lastUsedAt` by id
|
|
5854
|
+
* (design D4). Entry-metadata only (the token mirror is untouched); a no-op for
|
|
5855
|
+
* an unknown id. The selector throttles the call frequency, so this stays cheap.
|
|
5856
|
+
*/
|
|
5857
|
+
async touchAccountLastUsed(providerId, accountId, iso) {
|
|
5858
|
+
const config = this.readConfig();
|
|
5859
|
+
const result = setAccountLastUsed(config, providerId, accountId, iso);
|
|
5860
|
+
if (!result.ok) return;
|
|
5861
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5862
|
+
}
|
|
5863
|
+
/**
|
|
5864
|
+
* Best-effort write-through of a per-account client `identity`
|
|
5865
|
+
* (subscription-client-fingerprint #7, P2). Entry-metadata only (NON-secret
|
|
5866
|
+
* whitelisted fingerprint headers; the token mirror is untouched); a no-op for
|
|
5867
|
+
* an unknown id. Called by the identity store's persistence port on a first-seen
|
|
5868
|
+
* freeze / TTL refresh, so it stays infrequent. Never throws to the caller — the
|
|
5869
|
+
* store's port wrapper swallows a rejection so the relay hot path is unaffected.
|
|
5870
|
+
*/
|
|
5871
|
+
async setAccountIdentity(providerId, accountId, identity) {
|
|
5872
|
+
const config = this.readConfig();
|
|
5873
|
+
const result = setAccountIdentity(config, providerId, accountId, identity);
|
|
5874
|
+
if (!result.ok) return;
|
|
5875
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5876
|
+
}
|
|
5877
|
+
/**
|
|
5878
|
+
* DAEMON-ONLY set-priority (subscription-account-scheduling, admin write, NOT on
|
|
5879
|
+
* the port). Set one account's scheduling `priority` by id. Secret-free
|
|
5880
|
+
* (entry-metadata only; the mirror invariant is untouched). Rejects an unknown id.
|
|
5881
|
+
*/
|
|
5882
|
+
async setAccountPriority(providerId, accountId, priority) {
|
|
5883
|
+
const config = this.readConfig();
|
|
5884
|
+
const result = setAccountPriority(config, providerId, accountId, priority);
|
|
5885
|
+
if (!result.ok) return result;
|
|
5886
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5887
|
+
return result;
|
|
5888
|
+
}
|
|
5889
|
+
/**
|
|
5890
|
+
* DAEMON-ONLY set/clear per-account proxy (upstream-proxy, admin write, NOT on
|
|
5891
|
+
* the port). Passing `undefined` clears the override. Write-only password: when
|
|
5892
|
+
* the incoming structured proxy omits the password but the account already had
|
|
5893
|
+
* one, the current (decrypted) password is preserved — editing host/port never
|
|
5894
|
+
* wipes the secret. Persist re-encrypts `proxy.password` via the tokens SecretBox.
|
|
5895
|
+
*/
|
|
5896
|
+
async setAccountProxy(providerId, accountId, proxy) {
|
|
5897
|
+
const config = this.readConfig();
|
|
5898
|
+
const merged = proxy ? preserveProxyConfigSecret(proxy, getAccountProxy(config, providerId, accountId)) : void 0;
|
|
5899
|
+
const result = setAccountProxy(config, providerId, accountId, merged);
|
|
5900
|
+
if (!result.ok) return result;
|
|
5901
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5902
|
+
return result;
|
|
5903
|
+
}
|
|
5904
|
+
/**
|
|
5905
|
+
* DAEMON-ONLY set/clear per-account `supportedModels` (subscription-account-
|
|
5906
|
+
* model-map, admin write, NOT on the port). Passing `undefined` clears it.
|
|
5907
|
+
* Secret-free (model ids only; the mirror invariant is untouched). Rejects an
|
|
5908
|
+
* unknown id.
|
|
5909
|
+
*/
|
|
5910
|
+
async setAccountSupportedModels(providerId, accountId, supportedModels) {
|
|
5911
|
+
const config = this.readConfig();
|
|
5912
|
+
const result = setAccountSupportedModels(config, providerId, accountId, supportedModels);
|
|
5913
|
+
if (!result.ok) return result;
|
|
5914
|
+
this.persist({ ...config, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5915
|
+
return result;
|
|
5916
|
+
}
|
|
4113
5917
|
/** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
|
|
4114
|
-
async refreshUpstream(provider, refreshToken) {
|
|
5918
|
+
async refreshUpstream(provider, refreshToken, accountId) {
|
|
4115
5919
|
const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
|
|
4116
|
-
const r = await flow.refreshAccessToken(refreshToken, this.
|
|
5920
|
+
const r = await flow.refreshAccessToken(refreshToken, this.buildRefreshFetch(provider, accountId));
|
|
4117
5921
|
return {
|
|
4118
5922
|
accessToken: r.accessToken,
|
|
4119
5923
|
refreshToken: r.refreshToken,
|
|
@@ -4328,7 +6132,7 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4328
6132
|
persist(config) {
|
|
4329
6133
|
mkdirSync3(dirname4(this.tokensPath), { recursive: true });
|
|
4330
6134
|
const encrypted = encryptTokens(config, this.box);
|
|
4331
|
-
|
|
6135
|
+
writeFileSync8(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
|
|
4332
6136
|
}
|
|
4333
6137
|
/**
|
|
4334
6138
|
* Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
|
|
@@ -4344,10 +6148,10 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4344
6148
|
* `config.ts loadConfig`, which decrypts outside its parse try.
|
|
4345
6149
|
*/
|
|
4346
6150
|
readConfig() {
|
|
4347
|
-
if (!
|
|
6151
|
+
if (!existsSync10(this.tokensPath)) return { updatedAt: "" };
|
|
4348
6152
|
let parsed;
|
|
4349
6153
|
try {
|
|
4350
|
-
const raw = JSON.parse(
|
|
6154
|
+
const raw = JSON.parse(readFileSync10(this.tokensPath, "utf8"));
|
|
4351
6155
|
parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
|
|
4352
6156
|
} catch {
|
|
4353
6157
|
parsed = null;
|
|
@@ -4358,18 +6162,254 @@ var JsonSubscriptionCredentialStore = class {
|
|
|
4358
6162
|
}
|
|
4359
6163
|
};
|
|
4360
6164
|
|
|
4361
|
-
// src/
|
|
6165
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6166
|
+
import { fetchUpstream as fetchUpstream3 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
6167
|
+
|
|
6168
|
+
// src/probe/ProbeStrategy.ts
|
|
6169
|
+
var PROVIDER_PROBE_PLANS = {
|
|
6170
|
+
claude: {
|
|
6171
|
+
kind: "upstream",
|
|
6172
|
+
// VERIFIED free authed list endpoint (no tokens billed). The anthropic OAuth
|
|
6173
|
+
// bearer is accepted here exactly as on the relay path.
|
|
6174
|
+
url: "https://api.anthropic.com/v1/models",
|
|
6175
|
+
buildInit: (token) => ({
|
|
6176
|
+
method: "GET",
|
|
6177
|
+
headers: {
|
|
6178
|
+
Authorization: `Bearer ${token}`,
|
|
6179
|
+
"anthropic-version": "2023-06-01"
|
|
6180
|
+
}
|
|
6181
|
+
})
|
|
6182
|
+
},
|
|
6183
|
+
// UNVERIFIED cheap authed GET — Phase 1 local-only (LEAD OQ1: do not guess a
|
|
6184
|
+
// billable/wrong endpoint). Upgrade to `{ kind:'upstream' }` once verified.
|
|
6185
|
+
codex: { kind: "local" },
|
|
6186
|
+
gemini: { kind: "local" },
|
|
6187
|
+
opencodego: { kind: "local" }
|
|
6188
|
+
};
|
|
6189
|
+
function probePlanFor(providerId) {
|
|
6190
|
+
return PROVIDER_PROBE_PLANS[providerId] ?? { kind: "local" };
|
|
6191
|
+
}
|
|
6192
|
+
|
|
6193
|
+
// src/AccountHealthProbeScheduler.ts
|
|
6194
|
+
var KEY_SEP = "\0";
|
|
6195
|
+
var MAX_BODY_SNIFF = 2048;
|
|
6196
|
+
var PROBE_PROVIDERS = [
|
|
6197
|
+
"claude",
|
|
6198
|
+
"codex",
|
|
6199
|
+
"gemini",
|
|
6200
|
+
"opencodego"
|
|
6201
|
+
];
|
|
6202
|
+
var AccountHealthProbeScheduler = class {
|
|
6203
|
+
constructor(store, health2, logger, config, opts = {}) {
|
|
6204
|
+
this.store = store;
|
|
6205
|
+
this.health = health2;
|
|
6206
|
+
this.logger = logger;
|
|
6207
|
+
this.config = config;
|
|
6208
|
+
this.now = opts.now ?? Date.now;
|
|
6209
|
+
this.fetchImpl = opts.fetchImpl ?? fetchUpstream3;
|
|
6210
|
+
this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
6211
|
+
this.planFor = opts.planFor ?? probePlanFor;
|
|
6212
|
+
}
|
|
6213
|
+
store;
|
|
6214
|
+
health;
|
|
6215
|
+
logger;
|
|
6216
|
+
config;
|
|
6217
|
+
timer = null;
|
|
6218
|
+
sweeping = false;
|
|
6219
|
+
history = /* @__PURE__ */ new Map();
|
|
6220
|
+
now;
|
|
6221
|
+
fetchImpl;
|
|
6222
|
+
sleep;
|
|
6223
|
+
planFor;
|
|
6224
|
+
/** Whether probing is enabled by the current config. */
|
|
6225
|
+
get enabled() {
|
|
6226
|
+
return this.config.enabled;
|
|
6227
|
+
}
|
|
6228
|
+
/**
|
|
6229
|
+
* Re-apply config to the live instance (the async `start.ts` loads the persisted
|
|
6230
|
+
* `accountProbe` segment after `buildDaemon`). Call BEFORE `start()`.
|
|
6231
|
+
*/
|
|
6232
|
+
configure(config) {
|
|
6233
|
+
this.config = config;
|
|
6234
|
+
}
|
|
6235
|
+
/** Arm the probe interval. No-op when disabled (zero regression). Idempotent. */
|
|
6236
|
+
start() {
|
|
6237
|
+
if (this.timer || !this.config.enabled) return;
|
|
6238
|
+
this.timer = setInterval(() => void this.sweep(), this.config.intervalMs);
|
|
6239
|
+
this.timer.unref?.();
|
|
6240
|
+
}
|
|
6241
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6242
|
+
dispose() {
|
|
6243
|
+
if (this.timer) {
|
|
6244
|
+
clearInterval(this.timer);
|
|
6245
|
+
this.timer = null;
|
|
6246
|
+
}
|
|
6247
|
+
}
|
|
6248
|
+
/**
|
|
6249
|
+
* One sweep: probe every ELIGIBLE account SEQUENTIALLY with a `staggerMs` gap.
|
|
6250
|
+
* Disabled ⇒ no-op. `onlyMultiAccount` skips single-account providers. Exposed
|
|
6251
|
+
* for tests; never throws.
|
|
6252
|
+
*/
|
|
6253
|
+
async sweep() {
|
|
6254
|
+
if (!this.config.enabled || this.sweeping) return;
|
|
6255
|
+
this.sweeping = true;
|
|
6256
|
+
try {
|
|
6257
|
+
const config = await this.store.getFullConfig();
|
|
6258
|
+
let probed = 0;
|
|
6259
|
+
let marked = 0;
|
|
6260
|
+
for (const providerId of PROBE_PROVIDERS) {
|
|
6261
|
+
const accounts = listAccounts(config, providerId);
|
|
6262
|
+
if (this.config.onlyMultiAccount && accounts.length < 2) continue;
|
|
6263
|
+
for (const account of accounts) {
|
|
6264
|
+
if (probed > 0 && this.config.staggerMs > 0) await this.sleep(this.config.staggerMs);
|
|
6265
|
+
const outcome = await this.probeAccount(providerId, account.id);
|
|
6266
|
+
probed += 1;
|
|
6267
|
+
if (outcome.marked) marked += 1;
|
|
6268
|
+
}
|
|
6269
|
+
}
|
|
6270
|
+
this.logger.debug("account-probe sweep complete", { probed, marked });
|
|
6271
|
+
} catch (error) {
|
|
6272
|
+
this.logger.warn("account-probe sweep failed", {
|
|
6273
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6274
|
+
});
|
|
6275
|
+
} finally {
|
|
6276
|
+
this.sweeping = false;
|
|
6277
|
+
}
|
|
6278
|
+
}
|
|
6279
|
+
/**
|
|
6280
|
+
* Probe ONE account (design D1). Local tier first (dead token → synthesized 401,
|
|
6281
|
+
* no upstream); else the upstream tier when a verified endpoint exists. Records
|
|
6282
|
+
* the rolling history entry either way; returns whether the tracker was MARKED.
|
|
6283
|
+
*/
|
|
6284
|
+
async probeAccount(providerId, accountId) {
|
|
6285
|
+
const now = this.now();
|
|
6286
|
+
let token = null;
|
|
6287
|
+
let readThrew = false;
|
|
6288
|
+
try {
|
|
6289
|
+
token = await this.store.getAccessTokenForAccount(providerId, accountId);
|
|
6290
|
+
} catch {
|
|
6291
|
+
readThrew = true;
|
|
6292
|
+
}
|
|
6293
|
+
if (readThrew) {
|
|
6294
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: null, tier: "local" });
|
|
6295
|
+
return { ok: false, marked: false };
|
|
6296
|
+
}
|
|
6297
|
+
if (!token) {
|
|
6298
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status: 401, now });
|
|
6299
|
+
this.record(providerId, accountId, { ts: now, ok: false, status: 401, tier: "local" });
|
|
6300
|
+
return { ok: false, marked: true };
|
|
6301
|
+
}
|
|
6302
|
+
const plan = this.planFor(providerId);
|
|
6303
|
+
if (plan.kind === "local") {
|
|
6304
|
+
this.record(providerId, accountId, { ts: now, ok: true, tier: "local" });
|
|
6305
|
+
return { ok: true, marked: false };
|
|
6306
|
+
}
|
|
6307
|
+
const start = this.now();
|
|
6308
|
+
let status = null;
|
|
6309
|
+
let bodyText;
|
|
6310
|
+
try {
|
|
6311
|
+
const res = await this.fetchImpl(
|
|
6312
|
+
plan.url,
|
|
6313
|
+
{ ...plan.buildInit(token), signal: AbortSignal.timeout(this.config.timeoutMs) },
|
|
6314
|
+
{ providerId, accountId }
|
|
6315
|
+
);
|
|
6316
|
+
status = res.status;
|
|
6317
|
+
if (status === 403) bodyText = await this.readBounded(res);
|
|
6318
|
+
} catch {
|
|
6319
|
+
status = null;
|
|
6320
|
+
}
|
|
6321
|
+
const latencyMs = this.now() - start;
|
|
6322
|
+
const marked = this.applyOutcome(providerId, accountId, status, bodyText, now);
|
|
6323
|
+
this.record(providerId, accountId, {
|
|
6324
|
+
ts: now,
|
|
6325
|
+
ok: status !== null && status >= 200 && status < 300,
|
|
6326
|
+
status,
|
|
6327
|
+
latencyMs,
|
|
6328
|
+
tier: "upstream"
|
|
6329
|
+
});
|
|
6330
|
+
return { ok: status !== null && status < 400, marked };
|
|
6331
|
+
}
|
|
6332
|
+
/** Per-account rolling history for the authed admin surface (design D5). */
|
|
6333
|
+
getAllHistory() {
|
|
6334
|
+
const out = [];
|
|
6335
|
+
for (const [key, records] of this.history) {
|
|
6336
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6337
|
+
out.push({ providerId, accountId, records: records.slice() });
|
|
6338
|
+
}
|
|
6339
|
+
return out;
|
|
6340
|
+
}
|
|
6341
|
+
/**
|
|
6342
|
+
* The coarse, account-ANONYMOUS `/health` signal (design D5): `true` when no
|
|
6343
|
+
* probed account is currently unhealthy (per #2's tracker). No ids, no counts —
|
|
6344
|
+
* safe for the unauthenticated `/health`. Vacuously `true` when nothing probed.
|
|
6345
|
+
*/
|
|
6346
|
+
probedAccountsHealthy(now = this.now()) {
|
|
6347
|
+
for (const key of this.history.keys()) {
|
|
6348
|
+
const [providerId, accountId] = this.parseKey(key);
|
|
6349
|
+
if (!this.health.isSchedulable(providerId, accountId, now)) return false;
|
|
6350
|
+
}
|
|
6351
|
+
return true;
|
|
6352
|
+
}
|
|
6353
|
+
/**
|
|
6354
|
+
* Feed ONLY the account/auth-decisive statuses to #2 (LEAD constraint):
|
|
6355
|
+
* 401/403 → mark; 2xx → clear transient; 429 / 5xx / other 4xx / null →
|
|
6356
|
+
* NOT forwarded (never a mark). Returns whether a NEGATIVE mark was applied.
|
|
6357
|
+
*/
|
|
6358
|
+
applyOutcome(providerId, accountId, status, bodyText, now) {
|
|
6359
|
+
if (status === null) return false;
|
|
6360
|
+
if (status === 401 || status === 403) {
|
|
6361
|
+
this.health.recordUpstreamOutcome(providerId, accountId, { status, bodyText, now });
|
|
6362
|
+
return true;
|
|
6363
|
+
}
|
|
6364
|
+
if (status >= 200 && status < 300) {
|
|
6365
|
+
this.health.clearTransientMark(providerId, accountId);
|
|
6366
|
+
return false;
|
|
6367
|
+
}
|
|
6368
|
+
return false;
|
|
6369
|
+
}
|
|
6370
|
+
/** Append a record, capping the ring at `historySize` (drop oldest). */
|
|
6371
|
+
record(providerId, accountId, rec) {
|
|
6372
|
+
const key = this.key(providerId, accountId);
|
|
6373
|
+
const list = this.history.get(key) ?? [];
|
|
6374
|
+
list.push(rec);
|
|
6375
|
+
const overflow = list.length - this.config.historySize;
|
|
6376
|
+
if (overflow > 0) list.splice(0, overflow);
|
|
6377
|
+
this.history.set(key, list);
|
|
6378
|
+
}
|
|
6379
|
+
/** Read a bounded slice of the response body for the 403-ban sniff (never throws). */
|
|
6380
|
+
async readBounded(res) {
|
|
6381
|
+
try {
|
|
6382
|
+
return (await res.text()).slice(0, MAX_BODY_SNIFF);
|
|
6383
|
+
} catch {
|
|
6384
|
+
return "";
|
|
6385
|
+
}
|
|
6386
|
+
}
|
|
6387
|
+
key(providerId, accountId) {
|
|
6388
|
+
return `${providerId}${KEY_SEP}${accountId}`;
|
|
6389
|
+
}
|
|
6390
|
+
parseKey(key) {
|
|
6391
|
+
const idx = key.indexOf(KEY_SEP);
|
|
6392
|
+
return [key.slice(0, idx), key.slice(idx + 1)];
|
|
6393
|
+
}
|
|
6394
|
+
};
|
|
6395
|
+
|
|
6396
|
+
// src/AccountHealthSweeper.ts
|
|
4362
6397
|
var REFRESH_LEAD_MS = 5 * 6e4;
|
|
4363
6398
|
var SWEEP_INTERVAL_MS = 6e4;
|
|
4364
6399
|
var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
|
|
4365
|
-
|
|
4366
|
-
|
|
6400
|
+
function isOAuthProvider(providerId) {
|
|
6401
|
+
return OAUTH_PROVIDERS.includes(providerId);
|
|
6402
|
+
}
|
|
6403
|
+
var AccountHealthSweeper = class {
|
|
6404
|
+
constructor(store, health2, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
|
|
4367
6405
|
this.store = store;
|
|
6406
|
+
this.health = health2;
|
|
4368
6407
|
this.logger = logger;
|
|
4369
6408
|
this.intervalMs = intervalMs;
|
|
4370
6409
|
this.leadMs = leadMs;
|
|
4371
6410
|
}
|
|
4372
6411
|
store;
|
|
6412
|
+
health;
|
|
4373
6413
|
logger;
|
|
4374
6414
|
intervalMs;
|
|
4375
6415
|
leadMs;
|
|
@@ -4388,21 +6428,26 @@ var TokenRefreshScheduler = class {
|
|
|
4388
6428
|
this.timer = null;
|
|
4389
6429
|
}
|
|
4390
6430
|
}
|
|
4391
|
-
/**
|
|
6431
|
+
/**
|
|
6432
|
+
* One sweep: surface accounts that just recovered (emits the recovery signal
|
|
6433
|
+
* through the tracker's hook) and nudge a fresh token for any recovered OAuth
|
|
6434
|
+
* account whose token is near expiry. Exposed for tests. Never throws.
|
|
6435
|
+
*/
|
|
4392
6436
|
async sweep(now = Date.now()) {
|
|
4393
6437
|
if (this.sweeping) return;
|
|
4394
6438
|
this.sweeping = true;
|
|
4395
6439
|
try {
|
|
6440
|
+
const recovered = this.health.sweepRecoveries(now);
|
|
6441
|
+
if (recovered.length === 0) return;
|
|
4396
6442
|
const config = await this.store.getFullConfig();
|
|
4397
|
-
for (const
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
}
|
|
6443
|
+
for (const event of recovered) {
|
|
6444
|
+
if (!isOAuthProvider(event.providerId)) continue;
|
|
6445
|
+
const account = getAccountById(config, event.providerId, event.accountId);
|
|
6446
|
+
if (!account || !this.needsRefresh(account.tokens, now)) continue;
|
|
6447
|
+
await this.refreshOne(event.providerId, event.accountId);
|
|
4403
6448
|
}
|
|
4404
6449
|
} catch (error) {
|
|
4405
|
-
this.logger.warn("
|
|
6450
|
+
this.logger.warn("account-health sweep failed", {
|
|
4406
6451
|
error: error instanceof Error ? error.message : String(error)
|
|
4407
6452
|
});
|
|
4408
6453
|
} finally {
|
|
@@ -4417,87 +6462,850 @@ var TokenRefreshScheduler = class {
|
|
|
4417
6462
|
const expiresAt = Date.parse(t.expiresAt);
|
|
4418
6463
|
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
4419
6464
|
}
|
|
4420
|
-
/** Refresh one account; failures are logged, never thrown
|
|
4421
|
-
|
|
4422
|
-
async refreshOne(provider, id, isActive) {
|
|
6465
|
+
/** Refresh one recovered account by id; failures are logged, never thrown. */
|
|
6466
|
+
async refreshOne(provider, id) {
|
|
4423
6467
|
try {
|
|
4424
|
-
const ok =
|
|
4425
|
-
if (
|
|
4426
|
-
|
|
4427
|
-
} else {
|
|
4428
|
-
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
4429
|
-
}
|
|
6468
|
+
const ok = await this.store.refreshAccountById(provider, id);
|
|
6469
|
+
if (ok) this.logger.info("account-health recovery refresh succeeded", { provider, accountId: id });
|
|
6470
|
+
else this.logger.warn("account-health recovery refresh failed", { provider, accountId: id });
|
|
4430
6471
|
} catch (error) {
|
|
4431
|
-
this.logger.warn("
|
|
6472
|
+
this.logger.warn("account-health recovery refresh threw", {
|
|
4432
6473
|
provider,
|
|
4433
6474
|
accountId: id,
|
|
4434
6475
|
error: error instanceof Error ? error.message : String(error)
|
|
4435
6476
|
});
|
|
4436
6477
|
}
|
|
4437
6478
|
}
|
|
4438
|
-
refreshActive(provider) {
|
|
4439
|
-
switch (provider) {
|
|
4440
|
-
case "claude":
|
|
4441
|
-
return this.store.refreshClaudeToken();
|
|
4442
|
-
case "codex":
|
|
4443
|
-
return this.store.refreshCodexToken();
|
|
4444
|
-
case "gemini":
|
|
4445
|
-
return this.store.refreshGeminiToken();
|
|
4446
|
-
}
|
|
4447
|
-
}
|
|
4448
6479
|
};
|
|
4449
6480
|
|
|
4450
|
-
// src/
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
const
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
);
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
6481
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6482
|
+
import { existsSync as existsSync11, readdirSync, unlinkSync } from "fs";
|
|
6483
|
+
import { join as join5 } from "path";
|
|
6484
|
+
|
|
6485
|
+
// src/audit/auditFiles.ts
|
|
6486
|
+
var AUDIT_FILE_RE = /^audit-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6487
|
+
var pad22 = (n) => String(n).padStart(2, "0");
|
|
6488
|
+
function auditFileName(ts) {
|
|
6489
|
+
const d = new Date(ts);
|
|
6490
|
+
return `audit-${d.getFullYear()}-${pad22(d.getMonth() + 1)}-${pad22(d.getDate())}.jsonl`;
|
|
6491
|
+
}
|
|
6492
|
+
function auditFileDateMs(fileName) {
|
|
6493
|
+
const m = AUDIT_FILE_RE.exec(fileName);
|
|
6494
|
+
if (!m) return null;
|
|
6495
|
+
const year = Number(m[1]);
|
|
6496
|
+
const month = Number(m[2]);
|
|
6497
|
+
const day = Number(m[3]);
|
|
6498
|
+
const d = new Date(year, month - 1, day);
|
|
6499
|
+
if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) {
|
|
6500
|
+
return null;
|
|
6501
|
+
}
|
|
6502
|
+
return d.getTime();
|
|
6503
|
+
}
|
|
6504
|
+
|
|
6505
|
+
// src/audit/AuditPruneSweeper.ts
|
|
6506
|
+
var DAY_MS = 24 * 60 * 6e4;
|
|
6507
|
+
var SWEEP_INTERVAL_MS2 = 60 * 6e4;
|
|
6508
|
+
var AuditPruneSweeper = class {
|
|
6509
|
+
constructor(auditDir, logger, config, intervalMs = SWEEP_INTERVAL_MS2, now = Date.now) {
|
|
6510
|
+
this.auditDir = auditDir;
|
|
6511
|
+
this.logger = logger;
|
|
6512
|
+
this.config = config;
|
|
6513
|
+
this.intervalMs = intervalMs;
|
|
6514
|
+
this.now = now;
|
|
6515
|
+
}
|
|
6516
|
+
auditDir;
|
|
6517
|
+
logger;
|
|
6518
|
+
config;
|
|
6519
|
+
intervalMs;
|
|
6520
|
+
now;
|
|
6521
|
+
timer = null;
|
|
6522
|
+
sweeping = false;
|
|
6523
|
+
/** Whether pruning is active (audit enabled). */
|
|
6524
|
+
get enabled() {
|
|
6525
|
+
return this.config.enabled;
|
|
6526
|
+
}
|
|
6527
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6528
|
+
configure(config) {
|
|
6529
|
+
this.config = config;
|
|
6530
|
+
}
|
|
6531
|
+
/**
|
|
6532
|
+
* Arm the prune interval AND run one prune immediately (boot cleanup). No-op
|
|
6533
|
+
* when audit is disabled (zero regression). Idempotent.
|
|
6534
|
+
*/
|
|
6535
|
+
start() {
|
|
6536
|
+
if (this.timer || !this.config.enabled) return;
|
|
6537
|
+
void this.sweep();
|
|
6538
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6539
|
+
this.timer.unref?.();
|
|
6540
|
+
}
|
|
6541
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6542
|
+
dispose() {
|
|
6543
|
+
if (this.timer) {
|
|
6544
|
+
clearInterval(this.timer);
|
|
6545
|
+
this.timer = null;
|
|
6546
|
+
}
|
|
6547
|
+
}
|
|
6548
|
+
/**
|
|
6549
|
+
* One prune: unlink every audit date file strictly OLDER than the retention
|
|
6550
|
+
* cutoff (`now - retentionDays` days, at local-midnight granularity). Exposed
|
|
6551
|
+
* for tests; never throws. Returns the number of files removed.
|
|
6552
|
+
*/
|
|
6553
|
+
async sweep() {
|
|
6554
|
+
if (!this.config.enabled || this.sweeping) return 0;
|
|
6555
|
+
this.sweeping = true;
|
|
6556
|
+
try {
|
|
6557
|
+
if (!existsSync11(this.auditDir)) return 0;
|
|
6558
|
+
const today = new Date(this.now());
|
|
6559
|
+
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
|
|
6560
|
+
const cutoff = todayMidnight - (this.config.retentionDays - 1) * DAY_MS;
|
|
6561
|
+
let removed = 0;
|
|
6562
|
+
for (const file of readdirSync(this.auditDir)) {
|
|
6563
|
+
const dateMs = auditFileDateMs(file);
|
|
6564
|
+
if (dateMs === null || dateMs >= cutoff) continue;
|
|
6565
|
+
try {
|
|
6566
|
+
unlinkSync(join5(this.auditDir, file));
|
|
6567
|
+
removed += 1;
|
|
6568
|
+
} catch (error) {
|
|
6569
|
+
this.logger.warn("[AuditPruneSweeper] failed to unlink expired audit file", {
|
|
6570
|
+
file,
|
|
6571
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6572
|
+
});
|
|
6573
|
+
}
|
|
6574
|
+
}
|
|
6575
|
+
if (removed > 0) this.logger.debug("audit prune complete", { removed });
|
|
6576
|
+
return removed;
|
|
6577
|
+
} catch (error) {
|
|
6578
|
+
this.logger.warn("audit prune sweep failed", {
|
|
6579
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6580
|
+
});
|
|
6581
|
+
return 0;
|
|
6582
|
+
} finally {
|
|
6583
|
+
this.sweeping = false;
|
|
6584
|
+
}
|
|
6585
|
+
}
|
|
6586
|
+
};
|
|
6587
|
+
|
|
6588
|
+
// src/audit/auditReader.ts
|
|
6589
|
+
import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync11 } from "fs";
|
|
6590
|
+
import { join as join6 } from "path";
|
|
6591
|
+
var DEFAULT_LIMIT = 200;
|
|
6592
|
+
var MAX_LIMIT = 2e3;
|
|
6593
|
+
function readAuditRecords(auditDir, query = {}) {
|
|
6594
|
+
if (!existsSync12(auditDir)) return [];
|
|
6595
|
+
let files;
|
|
6596
|
+
try {
|
|
6597
|
+
files = readdirSync2(auditDir).filter((f) => AUDIT_FILE_RE.test(f));
|
|
6598
|
+
} catch {
|
|
6599
|
+
return [];
|
|
6600
|
+
}
|
|
6601
|
+
const from = typeof query.from === "number" ? query.from : -Infinity;
|
|
6602
|
+
const to = typeof query.to === "number" ? query.to : Infinity;
|
|
6603
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, Math.trunc(query.limit ?? DEFAULT_LIMIT)));
|
|
6604
|
+
const matched = [];
|
|
6605
|
+
for (const file of files.sort().reverse()) {
|
|
6606
|
+
let raw;
|
|
6607
|
+
try {
|
|
6608
|
+
raw = readFileSync11(join6(auditDir, file), "utf8");
|
|
6609
|
+
} catch {
|
|
6610
|
+
continue;
|
|
6611
|
+
}
|
|
6612
|
+
for (const line of raw.split("\n")) {
|
|
6613
|
+
const trimmed = line.trim();
|
|
6614
|
+
if (!trimmed) continue;
|
|
6615
|
+
let rec;
|
|
6616
|
+
try {
|
|
6617
|
+
rec = JSON.parse(trimmed);
|
|
6618
|
+
} catch {
|
|
6619
|
+
continue;
|
|
6620
|
+
}
|
|
6621
|
+
if (!isAuditRecord(rec)) continue;
|
|
6622
|
+
if (query.keyId !== void 0 && rec.keyId !== query.keyId) continue;
|
|
6623
|
+
if (rec.ts < from || rec.ts > to) continue;
|
|
6624
|
+
matched.push(rec);
|
|
6625
|
+
}
|
|
6626
|
+
}
|
|
6627
|
+
matched.sort((a, b) => b.ts - a.ts);
|
|
6628
|
+
return matched.slice(0, limit);
|
|
6629
|
+
}
|
|
6630
|
+
function isAuditRecord(value) {
|
|
6631
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6632
|
+
const r = value;
|
|
6633
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["method"] === "string" && typeof r["path"] === "string" && typeof r["status"] === "number";
|
|
6634
|
+
}
|
|
6635
|
+
|
|
6636
|
+
// src/audit/AuditWriter.ts
|
|
6637
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync4 } from "fs";
|
|
6638
|
+
import { join as join7 } from "path";
|
|
6639
|
+
var AuditWriter = class {
|
|
6640
|
+
constructor(auditDir, logger, defer = (fn) => setTimeout(fn, 0)) {
|
|
6641
|
+
this.auditDir = auditDir;
|
|
6642
|
+
this.logger = logger;
|
|
6643
|
+
this.defer = defer;
|
|
6644
|
+
}
|
|
6645
|
+
auditDir;
|
|
6646
|
+
logger;
|
|
6647
|
+
defer;
|
|
6648
|
+
dirEnsured = false;
|
|
6649
|
+
/**
|
|
6650
|
+
* Enqueue one record for append. Returns IMMEDIATELY (fire-and-forget); the fs
|
|
6651
|
+
* write happens on the deferred tick. A failure is logged, never thrown.
|
|
6652
|
+
*/
|
|
6653
|
+
record(record) {
|
|
6654
|
+
this.defer(() => {
|
|
6655
|
+
try {
|
|
6656
|
+
this.appendNow(record);
|
|
6657
|
+
} catch (error) {
|
|
6658
|
+
this.logger.warn("[AuditWriter] failed to append audit record", {
|
|
6659
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6660
|
+
});
|
|
6661
|
+
}
|
|
6662
|
+
});
|
|
6663
|
+
}
|
|
6664
|
+
/**
|
|
6665
|
+
* Append synchronously — the awaitable form tests use to assert the line landed.
|
|
6666
|
+
* Ensures the `audit/` directory exists on first write (lazy, like the usage
|
|
6667
|
+
* store's lazy file creation).
|
|
6668
|
+
*/
|
|
6669
|
+
appendNow(record) {
|
|
6670
|
+
if (!this.dirEnsured) {
|
|
6671
|
+
mkdirSync4(this.auditDir, { recursive: true });
|
|
6672
|
+
this.dirEnsured = true;
|
|
6673
|
+
}
|
|
6674
|
+
const file = join7(this.auditDir, auditFileName(record.ts));
|
|
6675
|
+
appendFileSync2(file, JSON.stringify(record) + "\n", "utf8");
|
|
6676
|
+
}
|
|
6677
|
+
};
|
|
6678
|
+
|
|
6679
|
+
// src/billing/BillingPublisher.ts
|
|
6680
|
+
import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync5 } from "fs";
|
|
6681
|
+
import { createHmac } from "crypto";
|
|
6682
|
+
import { join as join8 } from "path";
|
|
6683
|
+
import { fetchUpstream as fetchUpstream4 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
6684
|
+
|
|
6685
|
+
// src/billing/billingFiles.ts
|
|
6686
|
+
var BILLING_FILE_RE = /^billing-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6687
|
+
var DELIVERED_FILE_RE = /^delivered-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
6688
|
+
var pad23 = (n) => String(n).padStart(2, "0");
|
|
6689
|
+
function dateStamp(ts) {
|
|
6690
|
+
const d = new Date(ts);
|
|
6691
|
+
return `${d.getFullYear()}-${pad23(d.getMonth() + 1)}-${pad23(d.getDate())}`;
|
|
6692
|
+
}
|
|
6693
|
+
function billingFileName(ts) {
|
|
6694
|
+
return `billing-${dateStamp(ts)}.jsonl`;
|
|
6695
|
+
}
|
|
6696
|
+
function deliveredFileName(ts) {
|
|
6697
|
+
return `delivered-${dateStamp(ts)}.jsonl`;
|
|
6698
|
+
}
|
|
6699
|
+
|
|
6700
|
+
// src/billing/BillingPublisher.ts
|
|
6701
|
+
var BILLING_POST_TIMEOUT_MS = 1e4;
|
|
6702
|
+
var BillingPublisher = class {
|
|
6703
|
+
constructor(billingDir, logger, opts = {}) {
|
|
6704
|
+
this.billingDir = billingDir;
|
|
6705
|
+
this.logger = logger;
|
|
6706
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream4(url, init));
|
|
6707
|
+
this.defer = opts.defer ?? ((fn) => setTimeout(fn, 0));
|
|
6708
|
+
this.timeoutMs = opts.timeoutMs ?? BILLING_POST_TIMEOUT_MS;
|
|
6709
|
+
this.now = opts.now ?? Date.now;
|
|
6710
|
+
}
|
|
6711
|
+
billingDir;
|
|
6712
|
+
logger;
|
|
6713
|
+
config;
|
|
6714
|
+
dirEnsured = false;
|
|
6715
|
+
fetchImpl;
|
|
6716
|
+
defer;
|
|
6717
|
+
timeoutMs;
|
|
6718
|
+
now;
|
|
6719
|
+
/** Install/replace the live billing config (endpoint + secret + retry bound). */
|
|
6720
|
+
setConfig(config) {
|
|
6721
|
+
this.config = config;
|
|
6722
|
+
}
|
|
6723
|
+
/**
|
|
6724
|
+
* Record one billing event. DURABLE-FIRST: append synchronously (the event is
|
|
6725
|
+
* now on disk, never lost), THEN schedule a best-effort POST off the caller's
|
|
6726
|
+
* stack (non-blocking; ledger-only when no endpoint). Returns IMMEDIATELY and
|
|
6727
|
+
* NEVER throws — a failing append/POST is logged, never propagated.
|
|
6728
|
+
*/
|
|
6729
|
+
record(event) {
|
|
6730
|
+
let appended = false;
|
|
6731
|
+
try {
|
|
6732
|
+
this.appendNow(event);
|
|
6733
|
+
appended = true;
|
|
6734
|
+
} catch (error) {
|
|
6735
|
+
this.logger.warn("[BillingPublisher] failed to append billing event", {
|
|
6736
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6737
|
+
});
|
|
6738
|
+
}
|
|
6739
|
+
if (appended && this.config?.endpoint) {
|
|
6740
|
+
this.defer(() => {
|
|
6741
|
+
void this.deliverNow(event).catch(() => {
|
|
6742
|
+
});
|
|
6743
|
+
});
|
|
6744
|
+
}
|
|
6745
|
+
}
|
|
6746
|
+
/**
|
|
6747
|
+
* Append the event as one JSON line to `billing-YYYY-MM-DD.jsonl` (the event's
|
|
6748
|
+
* LOCAL date). Synchronous — the awaitable form tests use to assert the ledger
|
|
6749
|
+
* line landed BEFORE any delivery. Ensures the `billing/` directory on first write.
|
|
6750
|
+
*/
|
|
6751
|
+
appendNow(event) {
|
|
6752
|
+
this.ensureDir();
|
|
6753
|
+
const file = join8(this.billingDir, billingFileName(event.ts));
|
|
6754
|
+
appendFileSync3(file, JSON.stringify(event) + "\n", "utf8");
|
|
6755
|
+
}
|
|
6756
|
+
/**
|
|
6757
|
+
* One best-effort delivery attempt for an event ALREADY in the ledger. POSTs the
|
|
6758
|
+
* event JSON to the configured endpoint (optionally HMAC-signed); on a 2xx ack
|
|
6759
|
+
* appends a delivery marker and returns `true`. Any non-2xx / thrown / timed-out
|
|
6760
|
+
* attempt returns `false` — the event stays UNdelivered in the ledger (never
|
|
6761
|
+
* lost). NEVER rejects. A no-op `false` when no endpoint is configured.
|
|
6762
|
+
*/
|
|
6763
|
+
async deliverNow(event) {
|
|
6764
|
+
const endpoint = this.config?.endpoint;
|
|
6765
|
+
if (!endpoint) return false;
|
|
6766
|
+
try {
|
|
6767
|
+
const body = JSON.stringify(event);
|
|
6768
|
+
const headers = { "Content-Type": "application/json" };
|
|
6769
|
+
const secret = this.config?.secret;
|
|
6770
|
+
if (secret) {
|
|
6771
|
+
const hmac = createHmac("sha256", secret).update(body).digest("hex");
|
|
6772
|
+
headers["X-Omnicross-Billing-Signature"] = `sha256=${hmac}`;
|
|
6773
|
+
}
|
|
6774
|
+
const res = await this.fetchImpl(endpoint, {
|
|
6775
|
+
method: "POST",
|
|
6776
|
+
headers,
|
|
6777
|
+
body,
|
|
6778
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
6779
|
+
});
|
|
6780
|
+
if (!res.ok) {
|
|
6781
|
+
this.logger.debug(`[billing] delivery failed ${event.id} (HTTP ${res.status})`);
|
|
6782
|
+
return false;
|
|
6783
|
+
}
|
|
6784
|
+
this.markDelivered(event);
|
|
6785
|
+
this.logger.debug(`[billing] delivered ${event.id}`);
|
|
6786
|
+
return true;
|
|
6787
|
+
} catch (error) {
|
|
6788
|
+
this.logger.debug(
|
|
6789
|
+
`[billing] delivery error ${event.id}: ${error instanceof Error ? error.message : String(error)}`
|
|
6790
|
+
);
|
|
6791
|
+
return false;
|
|
6792
|
+
}
|
|
6793
|
+
}
|
|
6794
|
+
/**
|
|
6795
|
+
* Append a delivery marker `{ id, deliveredAt }` to `delivered-YYYY-MM-DD.jsonl`
|
|
6796
|
+
* (keyed by the EVENT's date so the reader finds both together). Idempotent at
|
|
6797
|
+
* the reconciliation layer — the reader unions marker ids into a delivered set,
|
|
6798
|
+
* so a duplicate marker is harmless. A marker-write failure is logged, never thrown.
|
|
6799
|
+
*/
|
|
6800
|
+
markDelivered(event) {
|
|
6801
|
+
try {
|
|
6802
|
+
this.ensureDir();
|
|
6803
|
+
const file = join8(this.billingDir, deliveredFileName(event.ts));
|
|
6804
|
+
appendFileSync3(file, JSON.stringify({ id: event.id, deliveredAt: this.now() }) + "\n", "utf8");
|
|
6805
|
+
} catch (error) {
|
|
6806
|
+
this.logger.warn("[BillingPublisher] failed to append delivery marker", {
|
|
6807
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6808
|
+
});
|
|
6809
|
+
}
|
|
6810
|
+
}
|
|
6811
|
+
ensureDir() {
|
|
6812
|
+
if (this.dirEnsured) return;
|
|
6813
|
+
mkdirSync5(this.billingDir, { recursive: true });
|
|
6814
|
+
this.dirEnsured = true;
|
|
6815
|
+
}
|
|
6816
|
+
};
|
|
6817
|
+
|
|
6818
|
+
// src/billing/billingReader.ts
|
|
6819
|
+
import { existsSync as existsSync13, readdirSync as readdirSync3, readFileSync as readFileSync12 } from "fs";
|
|
6820
|
+
import { join as join9 } from "path";
|
|
6821
|
+
function readBillingLedger(billingDir) {
|
|
6822
|
+
const view = { events: [], deliveredIds: /* @__PURE__ */ new Set() };
|
|
6823
|
+
if (!existsSync13(billingDir)) return view;
|
|
6824
|
+
let files;
|
|
6825
|
+
try {
|
|
6826
|
+
files = readdirSync3(billingDir);
|
|
6827
|
+
} catch {
|
|
6828
|
+
return view;
|
|
6829
|
+
}
|
|
6830
|
+
for (const file of files.sort()) {
|
|
6831
|
+
if (BILLING_FILE_RE.test(file)) {
|
|
6832
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
6833
|
+
if (isBillingEvent(rec)) view.events.push(rec);
|
|
6834
|
+
}
|
|
6835
|
+
} else if (DELIVERED_FILE_RE.test(file)) {
|
|
6836
|
+
for (const rec of parseLines(billingDir, file)) {
|
|
6837
|
+
const id = rec.id;
|
|
6838
|
+
if (typeof id === "string") view.deliveredIds.add(id);
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
}
|
|
6842
|
+
return view;
|
|
6843
|
+
}
|
|
6844
|
+
function readUndeliveredEvents(billingDir) {
|
|
6845
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
6846
|
+
return events.filter((e) => !deliveredIds.has(e.id)).sort((a, b) => a.ts - b.ts);
|
|
6847
|
+
}
|
|
6848
|
+
function readBillingStatus(billingDir) {
|
|
6849
|
+
const { events, deliveredIds } = readBillingLedger(billingDir);
|
|
6850
|
+
let delivered = 0;
|
|
6851
|
+
for (const e of events) if (deliveredIds.has(e.id)) delivered += 1;
|
|
6852
|
+
return { total: events.length, delivered, pending: events.length - delivered };
|
|
6853
|
+
}
|
|
6854
|
+
function parseLines(dir, file) {
|
|
6855
|
+
let raw;
|
|
6856
|
+
try {
|
|
6857
|
+
raw = readFileSync12(join9(dir, file), "utf8");
|
|
6858
|
+
} catch {
|
|
6859
|
+
return [];
|
|
6860
|
+
}
|
|
6861
|
+
const out = [];
|
|
6862
|
+
for (const line of raw.split("\n")) {
|
|
6863
|
+
const trimmed = line.trim();
|
|
6864
|
+
if (!trimmed) continue;
|
|
6865
|
+
try {
|
|
6866
|
+
out.push(JSON.parse(trimmed));
|
|
6867
|
+
} catch {
|
|
6868
|
+
}
|
|
6869
|
+
}
|
|
6870
|
+
return out;
|
|
6871
|
+
}
|
|
6872
|
+
function isBillingEvent(value) {
|
|
6873
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
6874
|
+
const r = value;
|
|
6875
|
+
return typeof r["id"] === "string" && typeof r["ts"] === "number" && typeof r["model"] === "string" && typeof r["status"] === "number";
|
|
6876
|
+
}
|
|
6877
|
+
|
|
6878
|
+
// src/billing/BillingRetrySweeper.ts
|
|
6879
|
+
var SWEEP_INTERVAL_MS3 = 5 * 6e4;
|
|
6880
|
+
var BillingRetrySweeper = class {
|
|
6881
|
+
constructor(billingDir, publisher2, logger, config, intervalMs = SWEEP_INTERVAL_MS3, now = Date.now) {
|
|
6882
|
+
this.billingDir = billingDir;
|
|
6883
|
+
this.publisher = publisher2;
|
|
6884
|
+
this.logger = logger;
|
|
6885
|
+
this.config = config;
|
|
6886
|
+
this.intervalMs = intervalMs;
|
|
6887
|
+
this.now = now;
|
|
6888
|
+
}
|
|
6889
|
+
billingDir;
|
|
6890
|
+
publisher;
|
|
6891
|
+
logger;
|
|
6892
|
+
config;
|
|
6893
|
+
intervalMs;
|
|
6894
|
+
now;
|
|
6895
|
+
timer = null;
|
|
6896
|
+
sweeping = false;
|
|
6897
|
+
/** Whether retrying is active: billing enabled AND an endpoint is configured. */
|
|
6898
|
+
get enabled() {
|
|
6899
|
+
return this.config.enabled && typeof this.config.endpoint === "string" && this.config.endpoint.length > 0;
|
|
6900
|
+
}
|
|
6901
|
+
/** Re-apply config to the live instance (boot + admin PUT hot-reload). */
|
|
6902
|
+
configure(config) {
|
|
6903
|
+
this.config = config;
|
|
6904
|
+
}
|
|
6905
|
+
/**
|
|
6906
|
+
* Arm the retry interval AND run one sweep immediately (boot catch-up for events
|
|
6907
|
+
* that failed to deliver while the daemon was down). No-op when disabled or in
|
|
6908
|
+
* ledger-only mode (no endpoint to POST to). Idempotent.
|
|
6909
|
+
*/
|
|
6910
|
+
start() {
|
|
6911
|
+
if (this.timer || !this.enabled) return;
|
|
6912
|
+
void this.sweep();
|
|
6913
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6914
|
+
this.timer.unref?.();
|
|
6915
|
+
}
|
|
6916
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6917
|
+
dispose() {
|
|
6918
|
+
if (this.timer) {
|
|
6919
|
+
clearInterval(this.timer);
|
|
6920
|
+
this.timer = null;
|
|
6921
|
+
}
|
|
6922
|
+
}
|
|
6923
|
+
/**
|
|
6924
|
+
* One sweep: re-POST every UNdelivered ledger event still within
|
|
6925
|
+
* `maxRetryAgeMs`; leave over-age undelivered events for reconciliation (NEVER
|
|
6926
|
+
* deleted). Exposed for tests; never throws. Returns the number of events a
|
|
6927
|
+
* re-POST was attempted for.
|
|
6928
|
+
*/
|
|
6929
|
+
async sweep() {
|
|
6930
|
+
if (!this.enabled || this.sweeping) return 0;
|
|
6931
|
+
this.sweeping = true;
|
|
6932
|
+
try {
|
|
6933
|
+
const cutoff = this.now() - this.config.maxRetryAgeMs;
|
|
6934
|
+
let attempted = 0;
|
|
6935
|
+
for (const event of readUndeliveredEvents(this.billingDir)) {
|
|
6936
|
+
if (event.ts < cutoff) continue;
|
|
6937
|
+
attempted += 1;
|
|
6938
|
+
await this.publisher.deliverNow(event);
|
|
6939
|
+
}
|
|
6940
|
+
if (attempted > 0) this.logger.debug("billing retry sweep complete", { attempted });
|
|
6941
|
+
return attempted;
|
|
6942
|
+
} catch (error) {
|
|
6943
|
+
this.logger.warn("billing retry sweep failed", {
|
|
6944
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6945
|
+
});
|
|
6946
|
+
return 0;
|
|
6947
|
+
} finally {
|
|
6948
|
+
this.sweeping = false;
|
|
6949
|
+
}
|
|
6950
|
+
}
|
|
6951
|
+
};
|
|
6952
|
+
|
|
6953
|
+
// src/TokenRefreshScheduler.ts
|
|
6954
|
+
var REFRESH_LEAD_MS2 = 5 * 6e4;
|
|
6955
|
+
var SWEEP_INTERVAL_MS4 = 6e4;
|
|
6956
|
+
var OAUTH_PROVIDERS2 = ["claude", "codex", "gemini"];
|
|
6957
|
+
var TokenRefreshScheduler = class {
|
|
6958
|
+
constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS4, leadMs = REFRESH_LEAD_MS2) {
|
|
6959
|
+
this.store = store;
|
|
6960
|
+
this.logger = logger;
|
|
6961
|
+
this.intervalMs = intervalMs;
|
|
6962
|
+
this.leadMs = leadMs;
|
|
6963
|
+
}
|
|
6964
|
+
store;
|
|
6965
|
+
logger;
|
|
6966
|
+
intervalMs;
|
|
6967
|
+
leadMs;
|
|
6968
|
+
timer = null;
|
|
6969
|
+
sweeping = false;
|
|
6970
|
+
/** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
|
|
6971
|
+
start() {
|
|
6972
|
+
if (this.timer) return;
|
|
6973
|
+
this.timer = setInterval(() => void this.sweep(), this.intervalMs);
|
|
6974
|
+
this.timer.unref?.();
|
|
6975
|
+
}
|
|
6976
|
+
/** Clear the interval (daemon shutdown / test teardown). Idempotent. */
|
|
6977
|
+
dispose() {
|
|
6978
|
+
if (this.timer) {
|
|
6979
|
+
clearInterval(this.timer);
|
|
6980
|
+
this.timer = null;
|
|
6981
|
+
}
|
|
6982
|
+
}
|
|
6983
|
+
/** One sweep over every account of every OAuth provider. Exposed for tests. */
|
|
6984
|
+
async sweep(now = Date.now()) {
|
|
6985
|
+
if (this.sweeping) return;
|
|
6986
|
+
this.sweeping = true;
|
|
6987
|
+
try {
|
|
6988
|
+
const config = await this.store.getFullConfig();
|
|
6989
|
+
for (const provider of OAUTH_PROVIDERS2) {
|
|
6990
|
+
const activeId = getActiveAccount(config, provider)?.id;
|
|
6991
|
+
for (const account of listAccounts(config, provider)) {
|
|
6992
|
+
if (!this.needsRefresh(account.tokens, now)) continue;
|
|
6993
|
+
await this.refreshOne(provider, account.id, account.id === activeId);
|
|
6994
|
+
}
|
|
6995
|
+
}
|
|
6996
|
+
} catch (error) {
|
|
6997
|
+
this.logger.warn("token-refresh sweep failed", {
|
|
6998
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6999
|
+
});
|
|
7000
|
+
} finally {
|
|
7001
|
+
this.sweeping = false;
|
|
7002
|
+
}
|
|
7003
|
+
}
|
|
7004
|
+
/** Expiring within the lead window, refreshable, and not already dead. */
|
|
7005
|
+
needsRefresh(tokens, now) {
|
|
7006
|
+
const t = tokens;
|
|
7007
|
+
if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
|
|
7008
|
+
if (!t.expiresAt) return false;
|
|
7009
|
+
const expiresAt = Date.parse(t.expiresAt);
|
|
7010
|
+
return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
|
|
7011
|
+
}
|
|
7012
|
+
/** Refresh one account; failures are logged, never thrown (the store has
|
|
7013
|
+
* already flagged the account `expired`). */
|
|
7014
|
+
async refreshOne(provider, id, isActive) {
|
|
7015
|
+
try {
|
|
7016
|
+
const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
|
|
7017
|
+
if (!ok) {
|
|
7018
|
+
this.logger.warn("background token refresh failed", { provider, accountId: id });
|
|
7019
|
+
} else {
|
|
7020
|
+
this.logger.info("background token refresh succeeded", { provider, accountId: id });
|
|
7021
|
+
}
|
|
7022
|
+
} catch (error) {
|
|
7023
|
+
this.logger.warn("background token refresh threw", {
|
|
7024
|
+
provider,
|
|
7025
|
+
accountId: id,
|
|
7026
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7027
|
+
});
|
|
7028
|
+
}
|
|
7029
|
+
}
|
|
7030
|
+
refreshActive(provider) {
|
|
7031
|
+
switch (provider) {
|
|
7032
|
+
case "claude":
|
|
7033
|
+
return this.store.refreshClaudeToken();
|
|
7034
|
+
case "codex":
|
|
7035
|
+
return this.store.refreshCodexToken();
|
|
7036
|
+
case "gemini":
|
|
7037
|
+
return this.store.refreshGeminiToken();
|
|
7038
|
+
}
|
|
7039
|
+
}
|
|
7040
|
+
};
|
|
7041
|
+
|
|
7042
|
+
// src/webhook/WebhookDispatcher.ts
|
|
7043
|
+
import { createHmac as createHmac2 } from "crypto";
|
|
7044
|
+
import { fetchUpstream as fetchUpstream5 } from "@omnicross/core/pipeline/upstreamFetch";
|
|
7045
|
+
var WEBHOOK_MAX_ATTEMPTS = 3;
|
|
7046
|
+
var WEBHOOK_QUEUE_MAX = 1e3;
|
|
7047
|
+
var WEBHOOK_SEND_TIMEOUT_MS = 1e4;
|
|
7048
|
+
var WEBHOOK_BASE_BACKOFF_MS = 200;
|
|
7049
|
+
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7050
|
+
var WebhookDispatcher = class {
|
|
7051
|
+
config;
|
|
7052
|
+
queue = [];
|
|
7053
|
+
draining = false;
|
|
7054
|
+
warnedFull = false;
|
|
7055
|
+
fetchImpl;
|
|
7056
|
+
logger;
|
|
7057
|
+
maxAttempts;
|
|
7058
|
+
queueMax;
|
|
7059
|
+
timeoutMs;
|
|
7060
|
+
baseBackoffMs;
|
|
7061
|
+
sleep;
|
|
7062
|
+
now;
|
|
7063
|
+
constructor(opts = {}) {
|
|
7064
|
+
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetchUpstream5(url, init));
|
|
7065
|
+
this.logger = opts.logger;
|
|
7066
|
+
this.maxAttempts = opts.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
|
|
7067
|
+
this.queueMax = opts.queueMax ?? WEBHOOK_QUEUE_MAX;
|
|
7068
|
+
this.timeoutMs = opts.timeoutMs ?? WEBHOOK_SEND_TIMEOUT_MS;
|
|
7069
|
+
this.baseBackoffMs = opts.baseBackoffMs ?? WEBHOOK_BASE_BACKOFF_MS;
|
|
7070
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
7071
|
+
this.now = opts.now ?? Date.now;
|
|
7072
|
+
}
|
|
7073
|
+
/** Install/replace the live webhook config (destinations + master switch). */
|
|
7074
|
+
setConfig(config) {
|
|
7075
|
+
this.config = config;
|
|
7076
|
+
}
|
|
7077
|
+
/**
|
|
7078
|
+
* Enqueue an event and return IMMEDIATELY (fire-and-forget). NEVER awaits a
|
|
7079
|
+
* send, NEVER throws — the drain loop does all sending on a side channel. A
|
|
7080
|
+
* full queue drops the OLDEST event (with a one-shot warn) so a runaway source
|
|
7081
|
+
* can't OOM the process.
|
|
7082
|
+
*/
|
|
7083
|
+
emit(event) {
|
|
7084
|
+
if (this.queue.length >= this.queueMax) {
|
|
7085
|
+
this.queue.shift();
|
|
7086
|
+
if (!this.warnedFull) {
|
|
7087
|
+
this.logger?.warn("[webhook] queue full \u2014 dropping oldest events");
|
|
7088
|
+
this.warnedFull = true;
|
|
7089
|
+
}
|
|
7090
|
+
}
|
|
7091
|
+
this.queue.push(event);
|
|
7092
|
+
if (!this.draining) {
|
|
7093
|
+
this.draining = true;
|
|
7094
|
+
queueMicrotask(() => void this.drain());
|
|
7095
|
+
}
|
|
7096
|
+
}
|
|
7097
|
+
/** Drain the queue, sending each event to its matching destinations concurrently. */
|
|
7098
|
+
async drain() {
|
|
7099
|
+
try {
|
|
7100
|
+
while (this.queue.length > 0) {
|
|
7101
|
+
const event = this.queue.shift();
|
|
7102
|
+
const destinations = this.matchingDestinations(event.kind);
|
|
7103
|
+
if (destinations.length === 0) continue;
|
|
7104
|
+
await Promise.all(destinations.map((d) => this.sendWithRetry(event, d)));
|
|
7105
|
+
}
|
|
7106
|
+
} finally {
|
|
7107
|
+
this.draining = false;
|
|
7108
|
+
if (this.queue.length > 0) {
|
|
7109
|
+
this.draining = true;
|
|
7110
|
+
queueMicrotask(() => void this.drain());
|
|
7111
|
+
}
|
|
7112
|
+
}
|
|
7113
|
+
}
|
|
7114
|
+
/** The enabled destinations whose event filter admits this kind (empty ⇒ all). */
|
|
7115
|
+
matchingDestinations(kind) {
|
|
7116
|
+
const cfg = this.config;
|
|
7117
|
+
if (!cfg || !cfg.enabled) return [];
|
|
7118
|
+
return cfg.destinations.filter(
|
|
7119
|
+
(d) => d.enabled && (!d.events || d.events.length === 0 || d.events.includes(kind))
|
|
7120
|
+
);
|
|
7121
|
+
}
|
|
7122
|
+
/** Send with bounded exponential backoff; log-and-drop after `maxAttempts`. */
|
|
7123
|
+
async sendWithRetry(event, dest) {
|
|
7124
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
|
7125
|
+
const result = await this.sendOnce(event, dest);
|
|
7126
|
+
if (result.ok) {
|
|
7127
|
+
this.logger?.debug(`[webhook] delivered ${event.kind} \u2192 ${dest.id} (${result.status})`);
|
|
7128
|
+
return;
|
|
7129
|
+
}
|
|
7130
|
+
if (attempt < this.maxAttempts) {
|
|
7131
|
+
await this.sleep(this.baseBackoffMs * 2 ** (attempt - 1));
|
|
7132
|
+
} else {
|
|
7133
|
+
this.logger?.warn(
|
|
7134
|
+
`[webhook] dropped ${event.kind} \u2192 ${dest.id} after ${this.maxAttempts} attempts: ${result.error ?? `HTTP ${result.status}`}`
|
|
7135
|
+
);
|
|
7136
|
+
}
|
|
7137
|
+
}
|
|
7138
|
+
}
|
|
7139
|
+
/** One POST attempt. Returns an outcome; a thrown error becomes `{ ok:false }`. */
|
|
7140
|
+
async sendOnce(event, dest) {
|
|
7141
|
+
try {
|
|
7142
|
+
const { body, headers } = buildRequest(event, dest, this.now());
|
|
7143
|
+
const res = await this.fetchImpl(dest.url, {
|
|
7144
|
+
method: "POST",
|
|
7145
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
7146
|
+
body,
|
|
7147
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
7148
|
+
});
|
|
7149
|
+
return res.ok ? { ok: true, status: res.status } : { ok: false, status: res.status };
|
|
7150
|
+
} catch (err5) {
|
|
7151
|
+
return { ok: false, error: err5 instanceof Error ? err5.message : String(err5) };
|
|
7152
|
+
}
|
|
7153
|
+
}
|
|
7154
|
+
/**
|
|
7155
|
+
* ADMIN test path (design D8): deliver a `test` event to ONE destination and
|
|
7156
|
+
* AWAIT the single-attempt result. This is the ONLY awaited send — it runs on
|
|
7157
|
+
* the admin request path (an operator clicking "Test"), NEVER on a relay path,
|
|
7158
|
+
* so awaiting it is safe. Finds the destination regardless of its `enabled`
|
|
7159
|
+
* flag or the master switch (an explicit operator action).
|
|
7160
|
+
*/
|
|
7161
|
+
async deliverTest(destinationId) {
|
|
7162
|
+
const dest = this.config?.destinations.find((d) => d.id === destinationId);
|
|
7163
|
+
if (!dest) return { ok: false, error: "destination not found" };
|
|
7164
|
+
return this.sendOnce({ kind: "test", at: this.now() }, dest);
|
|
7165
|
+
}
|
|
7166
|
+
};
|
|
7167
|
+
function buildRequest(event, dest, nowMs) {
|
|
7168
|
+
if (dest.type === "feishu") return buildFeishu(event, dest, nowMs);
|
|
7169
|
+
return buildCustom(event, dest);
|
|
7170
|
+
}
|
|
7171
|
+
function buildCustom(event, dest) {
|
|
7172
|
+
const body = JSON.stringify(event);
|
|
7173
|
+
const headers = {};
|
|
7174
|
+
if (dest.secret) {
|
|
7175
|
+
const hmac = createHmac2("sha256", dest.secret).update(body).digest("hex");
|
|
7176
|
+
headers["X-Omnicross-Signature"] = `sha256=${hmac}`;
|
|
7177
|
+
}
|
|
7178
|
+
return { body, headers };
|
|
7179
|
+
}
|
|
7180
|
+
function buildFeishu(event, dest, nowMs) {
|
|
7181
|
+
const payload = {
|
|
7182
|
+
msg_type: "text",
|
|
7183
|
+
content: { text: feishuText(event) }
|
|
7184
|
+
};
|
|
7185
|
+
if (dest.secret) {
|
|
7186
|
+
const timestamp = Math.floor(nowMs / 1e3).toString();
|
|
7187
|
+
const stringToSign = `${timestamp}
|
|
7188
|
+
${dest.secret}`;
|
|
7189
|
+
payload["timestamp"] = timestamp;
|
|
7190
|
+
payload["sign"] = createHmac2("sha256", stringToSign).digest("base64");
|
|
7191
|
+
}
|
|
7192
|
+
return { body: JSON.stringify(payload), headers: {} };
|
|
7193
|
+
}
|
|
7194
|
+
function feishuText(event) {
|
|
7195
|
+
switch (event.kind) {
|
|
7196
|
+
case "account.recovery":
|
|
7197
|
+
return `omnicross: account recovered \u2014 ${event.providerId}/${event.accountId}`;
|
|
7198
|
+
case "account.anomaly":
|
|
7199
|
+
return `omnicross: account anomaly [${event.state}] \u2014 ${event.providerId}/${event.accountId}`;
|
|
7200
|
+
case "key.quotaWarning":
|
|
7201
|
+
return `omnicross: key quota warning (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7202
|
+
case "key.quotaExceeded":
|
|
7203
|
+
return `omnicross: key quota EXCEEDED (${event.scope}) \u2014 $${event.spentUsd.toFixed(2)} of $${event.limitUsd.toFixed(2)} (key ${event.keyId})`;
|
|
7204
|
+
case "server.error":
|
|
7205
|
+
return `omnicross: server error \u2014 ${event.message}`;
|
|
7206
|
+
case "test":
|
|
7207
|
+
return "omnicross: webhook test";
|
|
7208
|
+
}
|
|
7209
|
+
}
|
|
7210
|
+
|
|
7211
|
+
// src/bootstrap.ts
|
|
7212
|
+
function buildDaemon(config, paths) {
|
|
7213
|
+
const logger = new ConfigurableLogger(config.logging);
|
|
7214
|
+
const secretBox3 = new SecretBox(() => resolveMasterKey({ keyFilePath: paths.masterKeyFilePath }));
|
|
7215
|
+
setSecretBox(secretBox3);
|
|
7216
|
+
setSecretBox2(secretBox3);
|
|
7217
|
+
const decryptedConfig = decryptConfigSecrets(config, secretBox3);
|
|
7218
|
+
const llmConfig = new ConfigFileProviderConfigSource(decryptedConfig);
|
|
7219
|
+
const keyDb = new JsonOutboundKeyDb(paths.keysPath);
|
|
7220
|
+
const voucherDb = new JsonVoucherDb(defaultVouchersPath(paths.configPath));
|
|
7221
|
+
const settingsStore = new JsonApiServerSettingsStore(paths.configPath, secretBox3);
|
|
7222
|
+
const credentialStore = new JsonSubscriptionCredentialStore(paths.tokensPath, secretBox3);
|
|
7223
|
+
const subscriptionAccounts = new SubscriptionAccountService(credentialStore);
|
|
7224
|
+
setSubscriptionAccountService(subscriptionAccounts);
|
|
7225
|
+
const subscriptionRegistry = new SubscriptionProviderRegistry(
|
|
7226
|
+
subscriptionAccounts,
|
|
7227
|
+
credentialStore
|
|
7228
|
+
);
|
|
7229
|
+
setSubscriptionProviderRegistry(subscriptionRegistry);
|
|
7230
|
+
setServerProxyConfig(decryptedConfig.server?.proxy);
|
|
7231
|
+
setUpstreamProxyResolver(
|
|
7232
|
+
createUpstreamProxyResolver({
|
|
7233
|
+
getAccountProxy: (providerId, accountId) => credentialStore.getAccountProxy(providerId, accountId)
|
|
7234
|
+
})
|
|
7235
|
+
);
|
|
7236
|
+
setGeminiCodeAssistResolver(getGeminiCodeAssistProjectResolver());
|
|
7237
|
+
const autoDisableStore = new AutoDisableStore();
|
|
7238
|
+
const apiKeyPool = new ApiKeyPoolService(
|
|
7239
|
+
createPoolKeysLoader((id) => llmConfig.getProviderRow(id), autoDisableStore),
|
|
7240
|
+
resolveEnvKey,
|
|
7241
|
+
logger,
|
|
7242
|
+
async (keyId) => {
|
|
7243
|
+
autoDisableStore.markAutoDisabled(keyId, 0, Date.now());
|
|
7244
|
+
return true;
|
|
7245
|
+
},
|
|
7246
|
+
async (keyId, status, at) => {
|
|
7247
|
+
autoDisableStore.markAutoDisabled(keyId, status, at);
|
|
7248
|
+
}
|
|
7249
|
+
);
|
|
7250
|
+
const pricingStore = new JsonPricingStore(defaultPricingPath(paths.configPath));
|
|
7251
|
+
const pricingEngine = new PricingEngine(pricingStore, logger);
|
|
7252
|
+
const usageEventStore = new JsonlUsageEventStore(
|
|
7253
|
+
defaultUsageEventsPath(paths.configPath),
|
|
7254
|
+
async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
|
|
7255
|
+
);
|
|
7256
|
+
const keySpendTracker = new KeySpendTracker(usageEventStore);
|
|
7257
|
+
const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger, {
|
|
7258
|
+
onRecord: (apiKeyId, costUsd, at) => keySpendTracker.add(apiKeyId, costUsd, at)
|
|
7259
|
+
});
|
|
7260
|
+
const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
|
|
4490
7261
|
llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
|
|
7262
|
+
const accountHealthProbeScheduler = new AccountHealthProbeScheduler(
|
|
7263
|
+
credentialStore,
|
|
7264
|
+
getSharedAccountHealth2(),
|
|
7265
|
+
logger,
|
|
7266
|
+
DEFAULT_ACCOUNT_PROBE
|
|
7267
|
+
);
|
|
7268
|
+
const getHealthReport = () => buildHealthReport({
|
|
7269
|
+
version: DAEMON_VERSION,
|
|
7270
|
+
// CRITICAL: the config loaded with a providers array.
|
|
7271
|
+
configPresent: () => Array.isArray(decryptedConfig.providers),
|
|
7272
|
+
// CRITICAL: the credential store's tokens.json is readable WITHOUT
|
|
7273
|
+
// decrypting (a missing file is fine — no accounts yet). A stat/access
|
|
7274
|
+
// only; never reads or decrypts token material.
|
|
7275
|
+
credentialStoreReadable: () => isTokensStoreReadable(paths.tokensPath),
|
|
7276
|
+
outboundServerRunning: () => outboundApiServer.getStatus().running,
|
|
7277
|
+
adminServerRunning: () => adminServer.getStatus().running,
|
|
7278
|
+
// Coarse, account-anonymous probe signal (#8, D5) — added to `checks` ONLY
|
|
7279
|
+
// when probing is ENABLED; disabled ⇒ `undefined` ⇒ key omitted ⇒ the
|
|
7280
|
+
// `/health` body stays byte-identical (zero regression).
|
|
7281
|
+
subscriptionAccountsHealthy: () => accountHealthProbeScheduler.enabled ? accountHealthProbeScheduler.probedAccountsHealthy() : void 0
|
|
7282
|
+
});
|
|
4491
7283
|
const outboundApiServer = getOutboundApiServer({
|
|
4492
7284
|
db: keyDb,
|
|
7285
|
+
// voucher-redemption #9: the key-authenticated `POST /redeem` endpoint redeems
|
|
7286
|
+
// cards against the presenting key (gated on `voucher.enabled`).
|
|
7287
|
+
voucherDb,
|
|
4493
7288
|
llmConfig,
|
|
4494
7289
|
providerProxy,
|
|
4495
|
-
proxyDeps: providerProxy.getDeps()
|
|
7290
|
+
proxyDeps: providerProxy.getDeps(),
|
|
7291
|
+
healthReportProvider: getHealthReport,
|
|
7292
|
+
// outbound-key-policy: the wire layer's 402 cost check reads per-key spend.
|
|
7293
|
+
keySpendTracker,
|
|
7294
|
+
// configurable-logging: route the server's OWN lifecycle + relay dispatch-error
|
|
7295
|
+
// lines through the injected logger (honors level/format/file sink).
|
|
7296
|
+
logger
|
|
4496
7297
|
});
|
|
7298
|
+
const auditDir = defaultAuditDir(paths.configPath);
|
|
7299
|
+
const billingDir = defaultBillingDir(paths.configPath);
|
|
4497
7300
|
const adminServer = new AdminServer({
|
|
4498
7301
|
configPath: paths.configPath,
|
|
4499
7302
|
llmConfig,
|
|
4500
7303
|
keyDb,
|
|
7304
|
+
// voucher-redemption #9: the admin `/admin/api/voucher` surface generates/
|
|
7305
|
+
// lists/revokes redemption cards (gated on `voucher.enabled`).
|
|
7306
|
+
voucherDb,
|
|
7307
|
+
// outbound-key-policy: the admin key list surfaces each key's OWN spend.
|
|
7308
|
+
keySpendReader: keySpendTracker,
|
|
4501
7309
|
settingsStore,
|
|
4502
7310
|
outboundApiServer,
|
|
4503
7311
|
subscriptionAccounts,
|
|
@@ -4519,14 +7327,16 @@ function buildDaemon(config, paths) {
|
|
|
4519
7327
|
oauthSessions: new OAuthSessionStore(),
|
|
4520
7328
|
// Real global fetch by default; a test seam (`paths.oauthExchangeFetch`) can
|
|
4521
7329
|
// inject a mock so no real token endpoint is hit.
|
|
4522
|
-
|
|
7330
|
+
// upstream-proxy: default the OAuth token-exchange fetch to the proxy-aware
|
|
7331
|
+
// helper so interactive login honors a configured proxy (global/env layers).
|
|
7332
|
+
oauthExchangeFetch: paths.oauthExchangeFetch ?? ((url, init) => fetchUpstream6(url, init)),
|
|
4523
7333
|
subscriptionAccountAppender: credentialStore,
|
|
4524
7334
|
// Codex interactive OAuth (app-parity-2 child 5) — the async loopback flow store
|
|
4525
7335
|
// + the one-shot 127.0.0.1:1455 listener. Token captured + persisted daemon-side;
|
|
4526
7336
|
// the app polls the token-free status. A test seam (`paths.codexAwaitLoopback`)
|
|
4527
7337
|
// can inject a mock so no real port is bound.
|
|
4528
7338
|
codexSessions: new CodexOAuthSessionStore(),
|
|
4529
|
-
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs) => awaitLoopbackCode(state, timeoutMs)),
|
|
7339
|
+
codexAwaitLoopback: paths.codexAwaitLoopback ?? ((state, timeoutMs, signal) => awaitLoopbackCode(state, timeoutMs, signal)),
|
|
4530
7340
|
// Migration pack (app-parity child 6, design D2/D3) — the concrete credential
|
|
4531
7341
|
// store provides BOTH the full DECRYPTED read (`getFullConfig`, export) and
|
|
4532
7342
|
// the multi-account append (`appendProviderAccount`, import re-encrypts at-
|
|
@@ -4546,9 +7356,48 @@ function buildDaemon(config, paths) {
|
|
|
4546
7356
|
pricingStore,
|
|
4547
7357
|
// Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
|
|
4548
7358
|
// plaintext bearer the AdminServer's constant-time compare expects (D4).
|
|
4549
|
-
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
|
|
7359
|
+
getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin),
|
|
7360
|
+
// Unauthenticated `/health` probe (daemon-health-endpoint) — the SAME shared
|
|
7361
|
+
// builder the outbound server uses, served before the admin auth gate.
|
|
7362
|
+
getHealthReport,
|
|
7363
|
+
// configurable-logging: the admin listener's lifecycle lines route through
|
|
7364
|
+
// the injected logger.
|
|
7365
|
+
logger,
|
|
7366
|
+
// subscription-account-probe #8: the AUTHED `GET /admin/api/account-probes`
|
|
7367
|
+
// reads per-account probe history from the scheduler (secret-free — ids +
|
|
7368
|
+
// status labels only). Routed in `AdminServer` (not `adminApi.ts`).
|
|
7369
|
+
probeHistoryReader: accountHealthProbeScheduler,
|
|
7370
|
+
// request-audit-log: the AUTHED `GET /admin/api/audit` reads + filters the
|
|
7371
|
+
// date-rotated audit store. Bound to the store dir here so the AdminServer
|
|
7372
|
+
// carries no path/store coupling. Records hold IP/UA/bodies → admin-only,
|
|
7373
|
+
// NEVER unauth, NEVER on `/health`. Routed in `AdminServer` (not `adminApi.ts`).
|
|
7374
|
+
auditReader: (query) => readAuditRecords(auditDir, query),
|
|
7375
|
+
// billing-event-stream: the AUTHED `GET /admin/api/billing-status` returns the
|
|
7376
|
+
// secret-free total/delivered/pending counts of the durable ledger.
|
|
7377
|
+
billingStatusReader: () => readBillingStatus(billingDir)
|
|
4550
7378
|
});
|
|
7379
|
+
const webhookDispatcher = new WebhookDispatcher({
|
|
7380
|
+
logger,
|
|
7381
|
+
fetchImpl: (url, init) => fetchUpstream6(url, init)
|
|
7382
|
+
});
|
|
7383
|
+
setWebhookRuntime(webhookDispatcher, getSharedAccountHealth2());
|
|
7384
|
+
const auditWriter = new AuditWriter(auditDir, logger);
|
|
7385
|
+
const auditPruneSweeper = new AuditPruneSweeper(auditDir, logger, DEFAULT_AUDIT_CONFIG);
|
|
7386
|
+
setAuditRuntime(auditWriter, auditPruneSweeper);
|
|
7387
|
+
const billingPublisher = new BillingPublisher(billingDir, logger);
|
|
7388
|
+
const billingRetrySweeper = new BillingRetrySweeper(
|
|
7389
|
+
billingDir,
|
|
7390
|
+
billingPublisher,
|
|
7391
|
+
logger,
|
|
7392
|
+
DEFAULT_BILLING_CONFIG
|
|
7393
|
+
);
|
|
7394
|
+
setBillingRuntime(billingPublisher, billingRetrySweeper);
|
|
4551
7395
|
const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
|
|
7396
|
+
const accountHealthSweeper = new AccountHealthSweeper(
|
|
7397
|
+
credentialStore,
|
|
7398
|
+
getSharedAccountHealth2(),
|
|
7399
|
+
logger
|
|
7400
|
+
);
|
|
4552
7401
|
return {
|
|
4553
7402
|
logger,
|
|
4554
7403
|
llmConfig,
|
|
@@ -4565,7 +7414,14 @@ function buildDaemon(config, paths) {
|
|
|
4565
7414
|
pricingEngine,
|
|
4566
7415
|
usageRecorder,
|
|
4567
7416
|
adminServer,
|
|
4568
|
-
tokenRefreshScheduler
|
|
7417
|
+
tokenRefreshScheduler,
|
|
7418
|
+
accountHealthSweeper,
|
|
7419
|
+
accountHealthProbeScheduler,
|
|
7420
|
+
webhookDispatcher,
|
|
7421
|
+
auditWriter,
|
|
7422
|
+
auditPruneSweeper,
|
|
7423
|
+
billingPublisher,
|
|
7424
|
+
billingRetrySweeper
|
|
4569
7425
|
};
|
|
4570
7426
|
}
|
|
4571
7427
|
function resetDaemonSingletonsForTests() {
|
|
@@ -4574,11 +7430,47 @@ function resetDaemonSingletonsForTests() {
|
|
|
4574
7430
|
setSubscriptionRegistryForOutbound(null);
|
|
4575
7431
|
setSubscriptionProviderRegistry(null);
|
|
4576
7432
|
setSubscriptionAccountService(null);
|
|
7433
|
+
setUpstreamProxyResolver(null);
|
|
7434
|
+
setServerProxyConfig(void 0);
|
|
4577
7435
|
setGeminiCodeAssistResolver(null);
|
|
4578
7436
|
setSecretBox(null);
|
|
4579
7437
|
setSecretBox2(null);
|
|
7438
|
+
resetWebhookRuntimeForTests();
|
|
7439
|
+
resetAuditRuntimeForTests();
|
|
7440
|
+
resetBillingRuntimeForTests();
|
|
7441
|
+
__resetSharedIdentityStoreForTests();
|
|
7442
|
+
}
|
|
7443
|
+
function isTokensStoreReadable(tokensPath) {
|
|
7444
|
+
try {
|
|
7445
|
+
if (!existsSync14(tokensPath)) return true;
|
|
7446
|
+
accessSync(tokensPath, fsConstants.R_OK);
|
|
7447
|
+
return true;
|
|
7448
|
+
} catch {
|
|
7449
|
+
return false;
|
|
7450
|
+
}
|
|
4580
7451
|
}
|
|
4581
7452
|
|
|
7453
|
+
// src/ports/ConsoleLogger.ts
|
|
7454
|
+
var ConsoleLogger = class {
|
|
7455
|
+
info(message, meta) {
|
|
7456
|
+
if (meta === void 0) console.info(message);
|
|
7457
|
+
else console.info(message, meta);
|
|
7458
|
+
}
|
|
7459
|
+
warn(message, meta) {
|
|
7460
|
+
if (meta === void 0) console.warn(message);
|
|
7461
|
+
else console.warn(message, meta);
|
|
7462
|
+
}
|
|
7463
|
+
error(message, error, meta) {
|
|
7464
|
+
if (error === void 0 && meta === void 0) console.error(message);
|
|
7465
|
+
else if (meta === void 0) console.error(message, error);
|
|
7466
|
+
else console.error(message, error, meta);
|
|
7467
|
+
}
|
|
7468
|
+
debug(message, meta) {
|
|
7469
|
+
if (meta === void 0) console.debug(message);
|
|
7470
|
+
else console.debug(message, meta);
|
|
7471
|
+
}
|
|
7472
|
+
};
|
|
7473
|
+
|
|
4582
7474
|
// src/ccr-import.ts
|
|
4583
7475
|
function parseCcrConfig(raw) {
|
|
4584
7476
|
if (!raw || typeof raw !== "object") {
|
|
@@ -4653,12 +7545,14 @@ function mapCcrToOmnicross(ccr) {
|
|
|
4653
7545
|
export {
|
|
4654
7546
|
AdminServer,
|
|
4655
7547
|
ConfigFileProviderConfigSource,
|
|
7548
|
+
ConfigurableLogger,
|
|
4656
7549
|
ConsoleLogger,
|
|
4657
7550
|
DEFAULT_ADMIN_PORT,
|
|
4658
7551
|
JsonApiServerSettingsStore,
|
|
4659
7552
|
JsonOutboundKeyDb,
|
|
4660
7553
|
JsonSubscriptionCredentialStore,
|
|
4661
7554
|
buildDaemon,
|
|
7555
|
+
buildHealthReport,
|
|
4662
7556
|
handleAdminApi,
|
|
4663
7557
|
inferApiFormat,
|
|
4664
7558
|
loadConfig,
|