routstrd 0.4.5 → 0.4.7

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.
@@ -0,0 +1,1857 @@
1
+ import { randomBytes } from "crypto";
2
+ import { type IncomingMessage, type ServerResponse } from "http";
3
+ import { Readable } from "stream";
4
+ import {
5
+ routeRequests,
6
+ InsufficientBalanceError,
7
+ ProviderManager,
8
+ } from "@routstr/sdk";
9
+ import type { UsageTrackingDriver, SdkLogger } from "@routstr/sdk";
10
+ import type { RequestResponseLogSink } from "../request-response-log-sink";
11
+ import { getEncodedToken } from "@cashu/coco-core";
12
+ import type { HistoryEntry } from "@cashu/coco-core";
13
+ import { logger } from "../../utils/logger";
14
+ import { loadDaemonConfig, saveDaemonConfig } from "../config-store";
15
+ import {
16
+ CocodHttpError,
17
+ type CocodClient,
18
+ type CocodState,
19
+ type WalletRecoveryProgress,
20
+ } from "../wallet/cocod-client";
21
+ import { receiveCashuToken } from "../wallet";
22
+ import { getClientsFromStore } from "../../utils/clients";
23
+ import { getUsageSummary } from "./usage-summary";
24
+
25
+ // Hop-by-hop headers describe the *upstream* connection, not this one, and must
26
+ // never be copied onto our response. In particular, copying the upstream's
27
+ // `Transfer-Encoding: chunked` while Node also frames the streamed body as
28
+ // chunked produces a duplicated `Transfer-Encoding: chunked, chunked` header,
29
+ // which strict HTTP/1.x clients (e.g. Go's net/http) reject with
30
+ // "too many transfer encodings". Node derives Content-Length for buffered
31
+ // bodies and Transfer-Encoding: chunked for streams itself.
32
+ const HOP_BY_HOP_HEADERS = new Set([
33
+ "connection",
34
+ "keep-alive",
35
+ "proxy-authenticate",
36
+ "proxy-authorization",
37
+ "te",
38
+ "trailer",
39
+ "transfer-encoding",
40
+ "upgrade",
41
+ ]);
42
+
43
+ type ClientMode = "xcashu" | "lazyrefund" | "apikeys";
44
+
45
+ type WalletStatusOutput = {
46
+ daemon: "running";
47
+ wallet: "connected" | "recovering" | "error";
48
+ walletState: CocodState;
49
+ balances?: Record<string, number>;
50
+ mode: ClientMode;
51
+ error?: string;
52
+ };
53
+
54
+ type DaemonDeps = {
55
+ provider: string | null;
56
+ server: { close(cb?: () => void): void };
57
+ shutdown?: () => void;
58
+ store: any;
59
+ walletClient: CocodClient;
60
+ walletAdapter: any;
61
+ storageAdapter: any;
62
+ discoveryAdapter: any;
63
+ modelManager: any;
64
+ ensureProvidersBootstrapped: () => Promise<void>;
65
+ getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
66
+ getModelProviders: (modelId: string) => Promise<any>;
67
+ refreshProvidersAndModels: () => Promise<void>;
68
+ mode?: ClientMode;
69
+ /** Nostr hex pubkey for routstr review/audit events (kind 38425). */
70
+ routstrPubkey?: string;
71
+ /** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
72
+ routstrModelsPubkey?: string;
73
+ providerManager: ProviderManager;
74
+ refundClient: any;
75
+ requestResponseLogSink?: RequestResponseLogSink;
76
+ };
77
+
78
+ /**
79
+ * Extracts the client ID from an incoming request by looking up the API key
80
+ * in the store's clientIds list.
81
+ */
82
+ function getClientIdFromRequest(
83
+ req: IncomingMessage,
84
+ store: { getState(): any },
85
+ ): string | undefined {
86
+ const authHeader = req.headers.authorization;
87
+
88
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
89
+ return undefined;
90
+ }
91
+
92
+ const apiKey = authHeader.slice(7); // Remove "Bearer " prefix
93
+
94
+ if (!apiKey.startsWith("sk-")) {
95
+ return undefined;
96
+ }
97
+
98
+ const clients = getClientsFromStore(store);
99
+ const matchingClient = clients.find((c) => c.apiKey === apiKey);
100
+
101
+ return matchingClient?.clientId;
102
+ }
103
+ function generateApiKey(): string {
104
+ const bytes = randomBytes(24);
105
+ return `sk-${bytes.toString("hex")}`;
106
+ }
107
+
108
+ async function readBody(req: IncomingMessage): Promise<string> {
109
+ return new Promise((resolve, reject) => {
110
+ let data = "";
111
+ req.on("data", (chunk) => {
112
+ data += chunk.toString();
113
+ });
114
+ req.on("end", () => resolve(data));
115
+ req.on("error", reject);
116
+ });
117
+ }
118
+
119
+ async function readJsonBody(
120
+ req: IncomingMessage,
121
+ ): Promise<Record<string, unknown>> {
122
+ const bodyText = await readBody(req);
123
+ if (!bodyText) {
124
+ return {};
125
+ }
126
+
127
+ try {
128
+ return JSON.parse(bodyText) as Record<string, unknown>;
129
+ } catch {
130
+ throw new CocodHttpError(400, "Invalid JSON body.");
131
+ }
132
+ }
133
+
134
+ function parseLimit(value: string | null, fallback = 10): number {
135
+ const requested = Number.parseInt(value || String(fallback), 10);
136
+ return Number.isFinite(requested) && requested > 0
137
+ ? Math.min(requested, 100000) // Cap at 100k entries
138
+ : fallback;
139
+ }
140
+
141
+ /** Normalize a provider URL for matching (add https:// and a trailing slash). */
142
+ function normalizeProviderBaseUrl(url: string): string {
143
+ if (!url.startsWith("http")) {
144
+ url = `https://${url}`;
145
+ }
146
+ return url.endsWith("/") ? url : `${url}/`;
147
+ }
148
+
149
+ /** Extract the provider base URLs a kind 38421 event advertises.
150
+ * Parses `u` tags first, then falls back to `content` endpoint URLs,
151
+ * mirroring ModelManager.bootstrapFromNostr. */
152
+ function collectProviderUrlsFromEvent(event: {
153
+ tags: string[][];
154
+ content: string;
155
+ }): string[] {
156
+ const urls: string[] = [];
157
+ for (const tag of event.tags) {
158
+ if (tag[0] === "u" && typeof tag[1] === "string" && tag[1]) {
159
+ urls.push(normalizeProviderBaseUrl(tag[1]));
160
+ }
161
+ }
162
+ if (urls.length > 0) return urls;
163
+ try {
164
+ const content = JSON.parse(event.content);
165
+ const providers = Array.isArray(content)
166
+ ? content
167
+ : content.providers || [];
168
+ for (const p of providers) {
169
+ if (p?.endpoint_url) urls.push(normalizeProviderBaseUrl(p.endpoint_url));
170
+ }
171
+ } catch {
172
+ /* unparseable content — ignore */
173
+ }
174
+ return urls;
175
+ }
176
+
177
+ /** Read a tag value by name from a Nostr event's tag array. */
178
+ function tagValue(tags: string[][], name: string): string | null {
179
+ for (const tag of tags) {
180
+ if (tag[0] === name && typeof tag[1] === "string" && tag[1]) return tag[1];
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function sendJson(
186
+ res: ServerResponse,
187
+ status: number,
188
+ payload: Record<string, unknown>,
189
+ ): void {
190
+ res.writeHead(status, { "Content-Type": "application/json" });
191
+ res.end(JSON.stringify(payload));
192
+ }
193
+
194
+ function toErrorMessage(error: unknown): string {
195
+ return error instanceof Error ? error.message : String(error);
196
+ }
197
+
198
+ function getWalletStateMessage(state: CocodState): string {
199
+ switch (state) {
200
+ case "LOCKED":
201
+ return "Wallet is locked. Unlock it before performing wallet operations.";
202
+ case "UNINITIALIZED":
203
+ return "Wallet is not initialized. Run 'routstrd onboard' first.";
204
+ case "RECOVERING":
205
+ return "Wallet is recovering from a previous run. Balance and send/receive operations will be available once recovery completes.";
206
+ case "ERROR":
207
+ return "Wallet is in an error state.";
208
+ default:
209
+ return "Wallet is unavailable.";
210
+ }
211
+ }
212
+
213
+ function respondWithError(
214
+ res: ServerResponse,
215
+ error: unknown,
216
+ fallbackStatus = 500,
217
+ ): void {
218
+ if (error instanceof CocodHttpError) {
219
+ sendJson(res, error.status, { error: error.message });
220
+ return;
221
+ }
222
+
223
+ sendJson(res, fallbackStatus, { error: toErrorMessage(error) });
224
+ }
225
+
226
+ async function respond(
227
+ res: ServerResponse,
228
+ getPayload: () => Promise<Record<string, unknown>>,
229
+ ): Promise<void> {
230
+ try {
231
+ sendJson(res, 200, await getPayload());
232
+ } catch (error) {
233
+ respondWithError(res, error);
234
+ }
235
+ }
236
+
237
+ function requireStringField(
238
+ body: Record<string, unknown>,
239
+ field: string,
240
+ ): string | null {
241
+ const value = body[field];
242
+ return typeof value === "string" && value.trim() ? value.trim() : null;
243
+ }
244
+
245
+ function getRequiredStringField(
246
+ body: Record<string, unknown>,
247
+ field: string,
248
+ ): string {
249
+ const value = requireStringField(body, field);
250
+ if (!value) {
251
+ throw new CocodHttpError(400, `Missing required '${field}' field.`);
252
+ }
253
+ return value;
254
+ }
255
+
256
+ function getRequiredPositiveNumberField(
257
+ body: Record<string, unknown>,
258
+ field: string,
259
+ ): number {
260
+ const value = body[field];
261
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
262
+ return value;
263
+ }
264
+ if (typeof value === "string" && value.trim()) {
265
+ const parsed = Number.parseInt(value.trim(), 10);
266
+ if (Number.isFinite(parsed) && parsed > 0) {
267
+ return parsed;
268
+ }
269
+ }
270
+ throw new CocodHttpError(400, `Missing required '${field}' field.`);
271
+ }
272
+
273
+ function optionalStringField(
274
+ body: Record<string, unknown>,
275
+ field: string,
276
+ ): string | undefined {
277
+ const value = body[field];
278
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
279
+ }
280
+
281
+ function getCurrentMode(deps: DaemonDeps): ClientMode {
282
+ const stateMode = deps.store.getState()?.mode;
283
+ return stateMode || deps.mode || "apikeys";
284
+ }
285
+
286
+ async function buildStatusOutput(
287
+ deps: DaemonDeps,
288
+ ): Promise<WalletStatusOutput> {
289
+ const mode = getCurrentMode(deps);
290
+
291
+ try {
292
+ const walletState = await deps.walletClient.getStatus();
293
+ if (walletState === "RECOVERING") {
294
+ return {
295
+ daemon: "running",
296
+ wallet: "recovering",
297
+ walletState,
298
+ mode,
299
+ error: getWalletStateMessage(walletState),
300
+ };
301
+ }
302
+ if (walletState !== "UNLOCKED") {
303
+ return {
304
+ daemon: "running",
305
+ wallet: "error",
306
+ walletState,
307
+ mode,
308
+ error: getWalletStateMessage(walletState),
309
+ };
310
+ }
311
+
312
+ const balances = await deps.walletAdapter.getBalances();
313
+ return {
314
+ daemon: "running",
315
+ wallet: "connected",
316
+ walletState,
317
+ balances,
318
+ mode,
319
+ };
320
+ } catch (error) {
321
+ return {
322
+ daemon: "running",
323
+ wallet: "error",
324
+ walletState: "ERROR",
325
+ mode,
326
+ error: toErrorMessage(error),
327
+ };
328
+ }
329
+ }
330
+
331
+ async function buildWalletDetails(deps: DaemonDeps): Promise<{
332
+ state: CocodState;
333
+ ready: boolean;
334
+ balances?: Record<string, number>;
335
+ unit?: "sat";
336
+ activeMint?: string | null;
337
+ defaultMint?: string | null;
338
+ recovery?: WalletRecoveryProgress;
339
+ }> {
340
+ const state = await deps.walletClient.getStatus();
341
+ const recovery = await deps.walletClient.getRecoveryProgress?.();
342
+ if (state !== "UNLOCKED") {
343
+ return { state, ready: false, ...(recovery ? { recovery } : {}) };
344
+ }
345
+
346
+ const [balances, defaultMint] = await Promise.all([
347
+ deps.walletAdapter.getBalances(),
348
+ deps.walletClient.getDefaultMint(),
349
+ ]);
350
+ return {
351
+ state,
352
+ ready: true,
353
+ balances,
354
+ unit: "sat",
355
+ activeMint: deps.walletAdapter.getActiveMintUrl(),
356
+ defaultMint,
357
+ ...(recovery ? { recovery } : {}),
358
+ };
359
+ }
360
+
361
+ function makeSdkLogger(...parts: string[]): SdkLogger {
362
+ const tag = parts.length ? parts.map((p) => `[${p}]`).join(" ") : undefined;
363
+ const fmt = (...args: unknown[]) => (tag ? [tag, ...args] : args);
364
+ return {
365
+ log: (...args: unknown[]) => logger.log(...fmt(...args)),
366
+ warn: (...args: unknown[]) => logger.warn(...fmt(...args)),
367
+ error: (...args: unknown[]) => logger.error(...fmt(...args)),
368
+ debug: (...args: unknown[]) => logger.debug(...fmt(...args)),
369
+ child: (p: string) => makeSdkLogger(...parts, p),
370
+ };
371
+ }
372
+
373
+ export function createDaemonRequestHandler(deps: {
374
+ provider: string | null;
375
+ server: { close(cb?: () => void): void };
376
+ shutdown?: () => void;
377
+ store: any;
378
+ walletClient: CocodClient;
379
+ walletAdapter: any;
380
+ storageAdapter: any;
381
+ discoveryAdapter: any;
382
+ modelManager: any;
383
+ ensureProvidersBootstrapped: () => Promise<void>;
384
+ getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
385
+ getModelProviders: (modelId: string) => Promise<any>;
386
+ refreshProvidersAndModels: () => Promise<void>;
387
+ mode?: "xcashu" | "apikeys";
388
+ /** Default max_tokens/max_output_tokens to inject when the client omits one. */
389
+ maxTokens: number;
390
+ /** Nostr hex pubkey for routstr review/audit events (kind 38425). */
391
+ routstrPubkey?: string;
392
+ /** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
393
+ routstrModelsPubkey?: string;
394
+ usageTrackingDriver: UsageTrackingDriver;
395
+ providerManager: ProviderManager;
396
+ refundClient: any;
397
+ requestResponseLogSink?: RequestResponseLogSink;
398
+ }) {
399
+ return async function handler(req: IncomingMessage, res: ServerResponse) {
400
+ const host = req.headers.host || "localhost";
401
+ const url = new URL(req.url || "/", `http://${host}`);
402
+
403
+ if (req.method === "GET" && url.pathname === "/health") {
404
+ sendJson(res, 200, { ok: true });
405
+ return;
406
+ }
407
+
408
+ if (req.method === "GET" && url.pathname === "/ping") {
409
+ sendJson(res, 200, { output: "pong" });
410
+ return;
411
+ }
412
+
413
+ if (req.method === "GET" && url.pathname === "/status") {
414
+ const output = await buildStatusOutput(deps);
415
+ sendJson(res, 200, { output });
416
+ return;
417
+ }
418
+
419
+ if (req.method === "GET" && url.pathname === "/wallet/status") {
420
+ await respond(res, async () => ({
421
+ output: await buildWalletDetails(deps),
422
+ }));
423
+ return;
424
+ }
425
+
426
+ if (req.method === "POST" && url.pathname === "/wallet/unlock") {
427
+ await respond(res, async () => {
428
+ const body = await readJsonBody(req);
429
+ const passphrase = getRequiredStringField(body, "passphrase");
430
+ const message = await deps.walletClient.unlock(passphrase);
431
+ const state = await deps.walletClient.getStatus();
432
+ return { output: { message, state } };
433
+ });
434
+ return;
435
+ }
436
+
437
+ if (req.method === "GET" && url.pathname === "/wallet/balance") {
438
+ await respond(res, async () => {
439
+ const balances = await deps.walletAdapter.getBalances();
440
+ return {
441
+ output: {
442
+ balances,
443
+ unit: "sat",
444
+ activeMint: deps.walletAdapter.getActiveMintUrl(),
445
+ walletState: "UNLOCKED",
446
+ },
447
+ };
448
+ });
449
+ return;
450
+ }
451
+
452
+ if (req.method === "POST" && url.pathname === "/wallet/cleanup") {
453
+ await respond(res, async () => {
454
+ if (!deps.walletClient.cleanupStuckOperations) {
455
+ throw new CocodHttpError(
456
+ 501,
457
+ "Wallet cleanup is not supported by this wallet client.",
458
+ );
459
+ }
460
+
461
+ const body = await readJsonBody(req);
462
+ const result = await deps.walletClient.cleanupStuckOperations({
463
+ mintUrl: optionalStringField(body, "mintUrl"),
464
+ minAgeMs:
465
+ typeof body.minAgeMs === "number" && Number.isFinite(body.minAgeMs)
466
+ ? body.minAgeMs
467
+ : undefined,
468
+ dryRun: body.dryRun === true,
469
+ });
470
+ return { output: result };
471
+ });
472
+ return;
473
+ }
474
+
475
+ if (req.method === "POST" && url.pathname === "/wallet/receive/cashu") {
476
+ await respond(res, async () => {
477
+ const body = await readJsonBody(req);
478
+ const token = getRequiredStringField(body, "token");
479
+ return {
480
+ output: await receiveCashuToken(deps.walletClient, token),
481
+ };
482
+ });
483
+ return;
484
+ }
485
+
486
+ if (req.method === "POST" && url.pathname === "/wallet/receive/bolt11") {
487
+ await respond(res, async () => {
488
+ const body = await readJsonBody(req);
489
+ const amount = getRequiredPositiveNumberField(body, "amount");
490
+ const mintUrl = optionalStringField(body, "mintUrl");
491
+ const invoice = await deps.walletClient.receiveBolt11(amount, mintUrl);
492
+ return { output: { invoice, amount, mintUrl } };
493
+ });
494
+ return;
495
+ }
496
+
497
+ if (req.method === "POST" && url.pathname === "/wallet/send/cashu") {
498
+ await respond(res, async () => {
499
+ const body = await readJsonBody(req);
500
+ const amount = getRequiredPositiveNumberField(body, "amount");
501
+ const mintUrl = optionalStringField(body, "mintUrl");
502
+ const token = await deps.walletClient.sendCashu(amount, mintUrl);
503
+ return { output: { token, amount, mintUrl } };
504
+ });
505
+ return;
506
+ }
507
+
508
+ if (req.method === "POST" && url.pathname === "/wallet/send/bolt11") {
509
+ await respond(res, async () => {
510
+ const body = await readJsonBody(req);
511
+ const invoice = getRequiredStringField(body, "invoice");
512
+ const mintUrl = optionalStringField(body, "mintUrl");
513
+ const message = await deps.walletClient.sendBolt11(invoice, mintUrl);
514
+ return { output: { message, invoice, mintUrl } };
515
+ });
516
+ return;
517
+ }
518
+
519
+ if (req.method === "GET" && url.pathname === "/wallet/mints") {
520
+ await respond(res, async () => {
521
+ const [mints, defaultMint] = await Promise.all([
522
+ deps.walletClient.listMints(),
523
+ deps.walletClient.getDefaultMint(),
524
+ ]);
525
+ return {
526
+ output: {
527
+ mints,
528
+ activeMint: defaultMint,
529
+ defaultMint,
530
+ },
531
+ };
532
+ });
533
+ return;
534
+ }
535
+
536
+ if (req.method === "POST" && url.pathname === "/wallet/mints") {
537
+ await respond(res, async () => {
538
+ const body = await readJsonBody(req);
539
+ const mintUrl = getRequiredStringField(body, "url");
540
+ const message = await deps.walletClient.addMint(mintUrl);
541
+ return { output: { message, url: mintUrl } };
542
+ });
543
+ return;
544
+ }
545
+
546
+ if (req.method === "POST" && url.pathname === "/wallet/mints/info") {
547
+ await respond(res, async () => {
548
+ const body = await readJsonBody(req);
549
+ const mintUrl = getRequiredStringField(body, "url");
550
+ const info = await deps.walletClient.getMintInfo(mintUrl);
551
+ return { output: { url: mintUrl, info } };
552
+ });
553
+ return;
554
+ }
555
+
556
+ if (req.method === "GET" && url.pathname === "/wallet/mints/default") {
557
+ await respond(res, async () => {
558
+ const defaultMint = await deps.walletClient.getDefaultMint();
559
+ return { output: { defaultMint } };
560
+ });
561
+ return;
562
+ }
563
+
564
+ if (req.method === "POST" && url.pathname === "/wallet/mints/default") {
565
+ await respond(res, async () => {
566
+ const body = await readJsonBody(req);
567
+ const mintUrl = getRequiredStringField(body, "url");
568
+ const message = await deps.walletClient.setDefaultMint(mintUrl);
569
+ return { output: { message, url: mintUrl } };
570
+ });
571
+ return;
572
+ }
573
+
574
+ if (req.method === "GET" && url.pathname === "/wallet/history") {
575
+ await respond(res, async () => {
576
+ const offsetParam = url.searchParams.get("offset");
577
+ const limitParam = url.searchParams.get("limit");
578
+ const offset = offsetParam ? parseInt(offsetParam, 10) || 0 : 0;
579
+ const limit = limitParam ? parseInt(limitParam, 10) || 50 : 50;
580
+ const entries = await deps.walletClient.getHistory(offset, limit);
581
+
582
+ const encoded = entries.map((entry: HistoryEntry) => {
583
+ const base = { ...entry } as Record<string, unknown>;
584
+ if (
585
+ (entry.type === "send" || entry.type === "receive") &&
586
+ entry.token
587
+ ) {
588
+ base.encodedToken = getEncodedToken(entry.token);
589
+ }
590
+ return base;
591
+ });
592
+
593
+ return { output: { entries: encoded, offset, limit } };
594
+ });
595
+ return;
596
+ }
597
+
598
+ // ── NPC (npubx.cash) Lightning address endpoints ──────────────
599
+
600
+ if (req.method === "GET" && url.pathname === "/wallet/npc/address") {
601
+ await respond(res, async () => {
602
+ const info = await deps.walletClient.getNpcAddress();
603
+ return { output: info };
604
+ });
605
+ return;
606
+ }
607
+
608
+ if (req.method === "POST" && url.pathname === "/wallet/npc/username") {
609
+ await respond(res, async () => {
610
+ const body = await readJsonBody(req);
611
+ const username = getRequiredStringField(body, "username");
612
+ const confirm = body.confirm === true;
613
+ const result = await deps.walletClient.setNpcUsername(username, confirm);
614
+ if (!result.success) {
615
+ const pr = result.paymentRequest ?? {};
616
+ const amount = typeof pr.amount === "number" ? pr.amount : 0;
617
+ const mints = Array.isArray(pr.mints) ? pr.mints.join(", ") : "";
618
+ if (confirm) {
619
+ throw new CocodHttpError(
620
+ 402,
621
+ `Failed to set username. Required amount: ${amount} SATS. Required mints: ${mints}`,
622
+ );
623
+ }
624
+ throw new CocodHttpError(
625
+ 402,
626
+ `Payment required to set username: ${amount} SATS. ` +
627
+ `Use 'routstrd wallet npc username ${username} --confirm' to proceed`,
628
+ );
629
+ }
630
+ return { output: { success: true, username } };
631
+ });
632
+ return;
633
+ }
634
+
635
+ if (req.method === "POST" && url.pathname === "/wallet/npc/sync") {
636
+ await respond(res, async () => {
637
+ await deps.walletClient.syncNpc();
638
+ return { output: { message: "NPC sync completed" } };
639
+ });
640
+ return;
641
+ }
642
+
643
+ // ── NWC endpoints ─────────────────────────────────────────────
644
+
645
+ if (req.method === "GET" && url.pathname === "/nwc/status") {
646
+ await respond(res, async () => {
647
+ const status = await deps.walletAdapter.getNwcStatus();
648
+ const autoRefill = deps.walletAdapter.getAutoRefillConfig();
649
+ return {
650
+ output: {
651
+ ...status,
652
+ autoRefill: autoRefill
653
+ ? {
654
+ enabled: autoRefill.enabled,
655
+ threshold: autoRefill.threshold,
656
+ amount: autoRefill.amount,
657
+ cooldownMs: autoRefill.cooldownMs,
658
+ cooldownMinutes: autoRefill.cooldownMs / 60000,
659
+ }
660
+ : undefined,
661
+ },
662
+ };
663
+ });
664
+ return;
665
+ }
666
+
667
+ if (req.method === "POST" && url.pathname === "/nwc/connect") {
668
+ await respond(res, async () => {
669
+ const body = await readJsonBody(req);
670
+ const connectionString = getRequiredStringField(body, "connectionString");
671
+
672
+ // Reload config and set NWC connection
673
+ const config = await loadDaemonConfig();
674
+ config.nwc = {
675
+ mode: "funding_source",
676
+ connectionString,
677
+ autoRefill: config.nwc?.autoRefill,
678
+ };
679
+ saveDaemonConfig(config);
680
+
681
+ // Hot-reload: reconnect the wallet adapter with the new connection string
682
+ await deps.walletAdapter.reconnect(connectionString);
683
+
684
+ return {
685
+ output: {
686
+ message: "NWC connection string saved and connected.",
687
+ },
688
+ };
689
+ });
690
+ return;
691
+ }
692
+
693
+ if (req.method === "POST" && url.pathname === "/nwc/disconnect") {
694
+ await respond(res, async () => {
695
+ const config = await loadDaemonConfig();
696
+ if (config.nwc) {
697
+ delete config.nwc.connectionString;
698
+ if (config.nwc.autoRefill) {
699
+ config.nwc.autoRefill.enabled = false;
700
+ }
701
+ }
702
+ saveDaemonConfig(config);
703
+
704
+ // Hot-reload: disconnect the wallet adapter
705
+ await deps.walletAdapter.reconnect();
706
+
707
+ return {
708
+ output: { message: "NWC disconnected." },
709
+ };
710
+ });
711
+ return;
712
+ }
713
+
714
+ if (req.method === "POST" && url.pathname === "/nwc/fund") {
715
+ await respond(res, async () => {
716
+ const body = await readJsonBody(req);
717
+ const amount = getRequiredPositiveNumberField(body, "amount");
718
+ const result = await deps.walletAdapter.fundFromNWC(amount);
719
+ if (!result.success) {
720
+ throw new Error(result.error || "NWC funding failed");
721
+ }
722
+ const balances = await deps.walletAdapter.getBalances();
723
+ const activeMint = deps.walletAdapter.getActiveMintUrl();
724
+ const balance = activeMint ? (balances[activeMint] ?? 0) : 0;
725
+
726
+ return {
727
+ output: {
728
+ message: `Successfully funded ${amount} sats via NWC`,
729
+ amount,
730
+ balance,
731
+ },
732
+ };
733
+ });
734
+ return;
735
+ }
736
+
737
+ if (req.method === "POST" && url.pathname === "/nwc/auto-refill") {
738
+ await respond(res, async () => {
739
+ const body = await readJsonBody(req);
740
+ const enabled = body.enabled === true || body.enabled === "true";
741
+
742
+ const config = await loadDaemonConfig();
743
+ if (!config.nwc) {
744
+ config.nwc = { mode: "funding_source" };
745
+ }
746
+
747
+ if (enabled) {
748
+ const threshold =
749
+ typeof body.threshold === "number" && body.threshold > 0
750
+ ? body.threshold
751
+ : 500;
752
+ const amount =
753
+ typeof body.amount === "number" && body.amount > 0
754
+ ? body.amount
755
+ : 1000;
756
+ const cooldownMs =
757
+ typeof body.cooldownMs === "number" && body.cooldownMs > 0
758
+ ? body.cooldownMs
759
+ : 300000;
760
+
761
+ config.nwc.autoRefill = {
762
+ enabled: true,
763
+ threshold,
764
+ amount,
765
+ cooldownMs,
766
+ };
767
+ } else {
768
+ config.nwc.autoRefill = config.nwc.autoRefill
769
+ ? { ...config.nwc.autoRefill, enabled: false }
770
+ : { enabled: false, threshold: 500, amount: 1000, cooldownMs: 300000 };
771
+ }
772
+
773
+ saveDaemonConfig(config);
774
+
775
+ return {
776
+ output: {
777
+ message: `Auto-refill ${enabled ? "enabled" : "disabled"}.`,
778
+ autoRefill: {
779
+ ...config.nwc.autoRefill,
780
+ cooldownMinutes: config.nwc.autoRefill.cooldownMs / 60000,
781
+ },
782
+ },
783
+ };
784
+ });
785
+ return;
786
+ }
787
+
788
+ if (req.method === "GET" && url.pathname === "/models") {
789
+ try {
790
+ const forceRefresh =
791
+ url.searchParams.get("refresh")?.toLowerCase() === "true";
792
+ const models = await deps.getRoutstr21Models(forceRefresh);
793
+ sendJson(res, 200, { output: { models } });
794
+ } catch (error) {
795
+ sendJson(res, 500, { error: toErrorMessage(error) });
796
+ }
797
+ return;
798
+ }
799
+
800
+ // Get providers for a specific model
801
+ const modelProvidersMatch = url.pathname.match(/^\/models\/([^\/]+)\/providers$/);
802
+ if (req.method === "GET" && modelProvidersMatch && modelProvidersMatch[1]) {
803
+ try {
804
+ const modelId = decodeURIComponent(modelProvidersMatch[1]);
805
+ const modelWithProviders = await deps.getModelProviders(modelId);
806
+ if (!modelWithProviders) {
807
+ sendJson(res, 404, { error: `Model '${modelId}' not found` });
808
+ return;
809
+ }
810
+ sendJson(res, 200, { output: modelWithProviders });
811
+ } catch (error) {
812
+ sendJson(res, 500, { error: toErrorMessage(error) });
813
+ }
814
+ return;
815
+ }
816
+
817
+ if (req.method === "GET" && url.pathname === "/v1/models") {
818
+ try {
819
+ const forceRefresh =
820
+ url.searchParams.get("refresh")?.toLowerCase() === "true";
821
+ const models = await deps.getRoutstr21Models(forceRefresh);
822
+ sendJson(res, 200, {
823
+ object: "list",
824
+ data: models.map((model) => ({ ...model, object: "model" })),
825
+ });
826
+ } catch (error) {
827
+ sendJson(res, 500, { error: toErrorMessage(error) });
828
+ }
829
+ return;
830
+ }
831
+
832
+ if (req.method === "POST" && url.pathname === "/stop") {
833
+ sendJson(res, 200, { output: "stopping" });
834
+ setTimeout(() => {
835
+ if (deps.shutdown) {
836
+ deps.shutdown();
837
+ } else {
838
+ deps.server.close(() => process.exit(0));
839
+ }
840
+ }, 50);
841
+ return;
842
+ }
843
+
844
+ if (req.method === "POST" && url.pathname === "/refund") {
845
+ try {
846
+ const body = await readJsonBody(req);
847
+ const mintUrl = getRequiredStringField(body, "mintUrl");
848
+
849
+ const state = deps.store.getState();
850
+ const pendingDistribution = (state.cachedTokens || []).map(
851
+ (t: { baseUrl: string; balance?: number }) => ({
852
+ baseUrl: t.baseUrl,
853
+ amount: t.balance || 0,
854
+ }),
855
+ );
856
+ const apiKeysStored = (state.apiKeys || []).map(
857
+ (k: { baseUrl: string; balance?: number }) => ({
858
+ baseUrl: k.baseUrl,
859
+ amount: k.balance || 0,
860
+ }),
861
+ );
862
+
863
+ if (pendingDistribution.length === 0 && apiKeysStored.length === 0) {
864
+ sendJson(res, 200, {
865
+ output: { message: "No pending tokens to refund", results: [] },
866
+ });
867
+ return;
868
+ }
869
+
870
+ const refundBaseUrls = pendingDistribution
871
+ .map((p: { baseUrl: string }) => p.baseUrl)
872
+ .concat(apiKeysStored.map((p: { baseUrl: string }) => p.baseUrl));
873
+
874
+ const spender = deps.refundClient.getCashuSpender();
875
+ const results = await spender.refundProviders(mintUrl, true);
876
+
877
+ sendJson(res, 200, {
878
+ output: {
879
+ message: `Refunded to ${mintUrl}`,
880
+ pendingTokens: pendingDistribution.length,
881
+ apiKeys: apiKeysStored.length,
882
+ results: results.map(
883
+ (r: { baseUrl: string; success: boolean }) => ({
884
+ baseUrl: r.baseUrl,
885
+ success: r.success,
886
+ }),
887
+ ),
888
+ },
889
+ });
890
+ } catch (error) {
891
+ logger.error(`Refund error: ${toErrorMessage(error)}`);
892
+ respondWithError(res, error);
893
+ }
894
+ return;
895
+ }
896
+
897
+ if (req.method === "POST" && url.pathname === "/refund/xcashu") {
898
+ try {
899
+ const body = await readJsonBody(req);
900
+ const mintUrl = getRequiredStringField(body, "mintUrl");
901
+
902
+ const spender = deps.refundClient.getCashuSpender();
903
+ const results = await spender.refundXcashuTokens(mintUrl);
904
+
905
+ sendJson(res, 200, {
906
+ output: {
907
+ message: `Refunded xcashu tokens to ${mintUrl}`,
908
+ results: results.map(
909
+ (r: { baseUrl: string; token: string; success: boolean; error?: string }) => ({
910
+ baseUrl: r.baseUrl,
911
+ token: r.token,
912
+ success: r.success,
913
+ error: r.error,
914
+ }),
915
+ ),
916
+ },
917
+ });
918
+ } catch (error) {
919
+ logger.error(`xcashu refund error: ${toErrorMessage(error)}`);
920
+ respondWithError(res, error);
921
+ }
922
+ return;
923
+ }
924
+
925
+ if (req.method === "GET" && url.pathname === "/balance") {
926
+ try {
927
+ const balances = await deps.walletAdapter.getBalances();
928
+ sendJson(res, 200, {
929
+ output: {
930
+ balances,
931
+ unit: "sat",
932
+ activeMint: deps.walletAdapter.getActiveMintUrl(),
933
+ },
934
+ });
935
+ } catch (error) {
936
+ respondWithError(res, error);
937
+ }
938
+ return;
939
+ }
940
+
941
+ if (req.method === "GET" && url.pathname === "/keys/balance") {
942
+ try {
943
+ const walletBalances = await deps.walletAdapter.getBalances();
944
+ const totalWallet = Object.values(walletBalances).reduce<number>(
945
+ (sum, balance) => sum + Number(balance),
946
+ 0,
947
+ );
948
+
949
+ const state = deps.store.getState();
950
+ const cachedTokens = state.cachedTokens || [];
951
+ const totalCached = cachedTokens.reduce(
952
+ (sum: number, t: { balance?: number }) => sum + (t.balance || 0),
953
+ 0,
954
+ );
955
+
956
+ const apiKeys = state.apiKeys || [];
957
+ const totalApiKeys = apiKeys.reduce(
958
+ (sum: number, k: { balance?: number }) => sum + (k.balance || 0),
959
+ 0,
960
+ );
961
+
962
+ const keys: Array<{ id: string; name: string; balance: number }> = [
963
+ { id: "wallet", name: "Wallet", balance: totalWallet },
964
+ ...cachedTokens.map((t: { baseUrl: string; balance?: number }) => ({
965
+ id: `cached:${t.baseUrl}`,
966
+ name: `Cached: ${t.baseUrl}`,
967
+ balance: t.balance || 0,
968
+ })),
969
+ ...apiKeys.map((k: { baseUrl: string; balance?: number }) => ({
970
+ id: `apikey:${k.baseUrl}`,
971
+ name: `API Key: ${k.baseUrl}`,
972
+ balance: k.balance || 0,
973
+ })),
974
+ ];
975
+
976
+ sendJson(res, 200, {
977
+ output: {
978
+ keys,
979
+ total: totalWallet + totalCached + totalApiKeys,
980
+ unit: "sat",
981
+ apikeysCalled: apiKeys.length,
982
+ },
983
+ });
984
+ } catch (error) {
985
+ res.writeHead(500, { "Content-Type": "application/json" });
986
+ res.end(JSON.stringify({ error: String(error) }));
987
+ }
988
+ return;
989
+ }
990
+
991
+ if (req.method === "GET" && url.pathname === "/keys/api") {
992
+ try {
993
+ const apiKeys = deps.storageAdapter.getAllApiKeys() as Array<{
994
+ baseUrl: string;
995
+ key: string;
996
+ balance: number;
997
+ lastUsed: number | null;
998
+ }>;
999
+ const totalApiKeys = apiKeys.reduce(
1000
+ (sum: number, k) => sum + (k.balance || 0),
1001
+ 0,
1002
+ );
1003
+ sendJson(res, 200, {
1004
+ output: {
1005
+ apiKeys,
1006
+ count: apiKeys.length,
1007
+ total: totalApiKeys,
1008
+ unit: "sat",
1009
+ },
1010
+ });
1011
+ } catch (error) {
1012
+ respondWithError(res, error);
1013
+ }
1014
+ return;
1015
+ }
1016
+
1017
+ // Refund remaining balance to a mint before removing the key.
1018
+ const apiKeyDeleteMatch =
1019
+ (req.method === "DELETE" || req.method === "POST") &&
1020
+ url.pathname === "/keys/api/delete";
1021
+ if (apiKeyDeleteMatch) {
1022
+ try {
1023
+ let baseUrl: string | undefined;
1024
+ let mintUrl: string | undefined;
1025
+ if (req.method === "DELETE") {
1026
+ baseUrl = url.searchParams.get("baseUrl") || undefined;
1027
+ mintUrl = url.searchParams.get("mintUrl") || undefined;
1028
+ } else {
1029
+ const body = await readJsonBody(req);
1030
+ baseUrl = getRequiredStringField(body, "baseUrl");
1031
+ mintUrl = body.mintUrl as string | undefined;
1032
+ }
1033
+ if (!baseUrl) {
1034
+ sendJson(res, 400, {
1035
+ error: "Missing required 'baseUrl' field.",
1036
+ });
1037
+ return;
1038
+ }
1039
+
1040
+ const existing = deps.storageAdapter.getApiKey(baseUrl);
1041
+ if (!existing) {
1042
+ sendJson(res, 200, {
1043
+ output: {
1044
+ baseUrl,
1045
+ removed: false,
1046
+ refunded: false,
1047
+ message: `No API key found for ${baseUrl}`,
1048
+ },
1049
+ });
1050
+ return;
1051
+ }
1052
+
1053
+ if (!mintUrl) {
1054
+ try {
1055
+ mintUrl =
1056
+ deps.walletAdapter.getActiveMintUrl() ??
1057
+ Object.keys(await deps.walletAdapter.getBalances())[0];
1058
+ } catch {
1059
+ // ignore
1060
+ }
1061
+ }
1062
+
1063
+ let refunded = false;
1064
+ let refundMessage: string | undefined;
1065
+ let refundedAmount: number | undefined;
1066
+
1067
+ if (mintUrl) {
1068
+ try {
1069
+ const balanceManager = deps.refundClient.getBalanceManager();
1070
+ if (balanceManager) {
1071
+ const refundResult = await balanceManager.refundApiKey({
1072
+ mintUrl,
1073
+ baseUrl: existing.baseUrl,
1074
+ apiKey: existing.key,
1075
+ forceRefund: true,
1076
+ });
1077
+ refunded = refundResult.success;
1078
+ refundMessage = refundResult.message;
1079
+ if (refundResult.refundedAmount) {
1080
+ refundedAmount = Math.floor(
1081
+ refundResult.refundedAmount / 1000,
1082
+ );
1083
+ }
1084
+ }
1085
+ } catch (error) {
1086
+ refundMessage = `Refund error: ${toErrorMessage(error)}`;
1087
+ }
1088
+ } else {
1089
+ refundMessage = "No mint available to refund to";
1090
+ }
1091
+
1092
+ // refundApiKey removes the key on success; remove manually on failure.
1093
+ if (!refunded) {
1094
+ deps.storageAdapter.removeApiKey(existing.baseUrl);
1095
+ }
1096
+
1097
+ sendJson(res, 200, {
1098
+ output: {
1099
+ baseUrl: existing.baseUrl,
1100
+ removed: true,
1101
+ refunded,
1102
+ refundedAmount,
1103
+ refundMessage,
1104
+ message: refunded
1105
+ ? `Refunded and removed API key for ${existing.baseUrl}`
1106
+ : `Removed API key for ${existing.baseUrl} (refund failed: ${refundMessage ?? "unknown"})`,
1107
+ },
1108
+ });
1109
+ } catch (error) {
1110
+ respondWithError(res, error);
1111
+ }
1112
+ return;
1113
+ }
1114
+
1115
+ if (req.method === "POST" && url.pathname === "/providers/disable") {
1116
+ try {
1117
+ const bodyText = await readBody(req);
1118
+ const body = bodyText ? JSON.parse(bodyText) : {};
1119
+ const indices = body.indices as number[] | undefined;
1120
+
1121
+ if (!Array.isArray(indices)) {
1122
+ res.writeHead(400, { "Content-Type": "application/json" });
1123
+ res.end(
1124
+ JSON.stringify({
1125
+ error: "Missing or invalid 'indices' field (expected number[]).",
1126
+ }),
1127
+ );
1128
+ return;
1129
+ }
1130
+
1131
+ const state = deps.store.getState();
1132
+ const baseUrlsList: string[] = state.baseUrlsList || [];
1133
+ // User-driven disables belong in the *manual* disabled list. The
1134
+ // review-based list (setDisabledProviders) is owned by the Nostr
1135
+ // kind-38425 review sync, which overwrites it and would otherwise
1136
+ // silently re-enable manually-disabled providers. Disabling also
1137
+ // clears any manual-enable override for that provider.
1138
+ const manuallyDisabledProviders: string[] = [
1139
+ ...(state.manuallyDisabledProviders || []),
1140
+ ];
1141
+ const manuallyEnabledProviders: string[] = [
1142
+ ...(state.manuallyEnabledProviders || []),
1143
+ ];
1144
+
1145
+ const toDisable: string[] = [];
1146
+ for (const idx of indices) {
1147
+ if (
1148
+ typeof idx === "number" &&
1149
+ idx >= 0 &&
1150
+ idx < baseUrlsList.length
1151
+ ) {
1152
+ const baseUrl = baseUrlsList[idx]!;
1153
+ if (!manuallyDisabledProviders.includes(baseUrl)) {
1154
+ manuallyDisabledProviders.push(baseUrl);
1155
+ toDisable.push(baseUrl);
1156
+ }
1157
+ const enabledPos = manuallyEnabledProviders.indexOf(baseUrl);
1158
+ if (enabledPos !== -1) {
1159
+ manuallyEnabledProviders.splice(enabledPos, 1);
1160
+ }
1161
+ }
1162
+ }
1163
+
1164
+ deps.store.getState().setManuallyDisabledProviders(manuallyDisabledProviders);
1165
+ deps.discoveryAdapter.setManuallyDisabledProviders(manuallyDisabledProviders);
1166
+ deps.store.getState().setManuallyEnabledProviders(manuallyEnabledProviders);
1167
+ deps.discoveryAdapter.setManuallyEnabledProviders(manuallyEnabledProviders);
1168
+
1169
+ res.writeHead(200, { "Content-Type": "application/json" });
1170
+ res.end(
1171
+ JSON.stringify({
1172
+ output: {
1173
+ message: `Disabled ${toDisable.length} provider(s)`,
1174
+ disabled: toDisable,
1175
+ },
1176
+ }),
1177
+ );
1178
+ } catch (error) {
1179
+ res.writeHead(500, { "Content-Type": "application/json" });
1180
+ res.end(JSON.stringify({ error: String(error) }));
1181
+ }
1182
+ return;
1183
+ }
1184
+
1185
+ if (req.method === "POST" && url.pathname === "/providers/enable") {
1186
+ try {
1187
+ const bodyText = await readBody(req);
1188
+ const body = bodyText ? JSON.parse(bodyText) : {};
1189
+ const indices = body.indices as number[] | undefined;
1190
+
1191
+ if (!Array.isArray(indices)) {
1192
+ res.writeHead(400, { "Content-Type": "application/json" });
1193
+ res.end(
1194
+ JSON.stringify({
1195
+ error: "Missing or invalid 'indices' field (expected number[]).",
1196
+ }),
1197
+ );
1198
+ return;
1199
+ }
1200
+
1201
+ const state = deps.store.getState();
1202
+ const baseUrlsList: string[] = state.baseUrlsList || [];
1203
+ // Re-enabling clears the manual disable and records a manual-enable
1204
+ // override so the review sync (which disables providers without an
1205
+ // lgtm review) does not silently re-disable it on the next pass.
1206
+ const manuallyDisabledProviders: string[] = [
1207
+ ...(state.manuallyDisabledProviders || []),
1208
+ ];
1209
+ const manuallyEnabledProviders: string[] = [
1210
+ ...(state.manuallyEnabledProviders || []),
1211
+ ];
1212
+
1213
+ const toEnable: string[] = [];
1214
+ for (const idx of indices) {
1215
+ if (
1216
+ typeof idx === "number" &&
1217
+ idx >= 0 &&
1218
+ idx < baseUrlsList.length
1219
+ ) {
1220
+ const baseUrl = baseUrlsList[idx]!;
1221
+ const pos = manuallyDisabledProviders.indexOf(baseUrl);
1222
+ if (pos !== -1) {
1223
+ manuallyDisabledProviders.splice(pos, 1);
1224
+ }
1225
+ if (!manuallyEnabledProviders.includes(baseUrl)) {
1226
+ manuallyEnabledProviders.push(baseUrl);
1227
+ }
1228
+ toEnable.push(baseUrl);
1229
+ }
1230
+ }
1231
+
1232
+ deps.store.getState().setManuallyDisabledProviders(manuallyDisabledProviders);
1233
+ deps.discoveryAdapter.setManuallyDisabledProviders(manuallyDisabledProviders);
1234
+ deps.store.getState().setManuallyEnabledProviders(manuallyEnabledProviders);
1235
+ deps.discoveryAdapter.setManuallyEnabledProviders(manuallyEnabledProviders);
1236
+
1237
+ res.writeHead(200, { "Content-Type": "application/json" });
1238
+ res.end(
1239
+ JSON.stringify({
1240
+ output: {
1241
+ message: `Enabled ${toEnable.length} provider(s)`,
1242
+ enabled: toEnable,
1243
+ },
1244
+ }),
1245
+ );
1246
+ } catch (error) {
1247
+ res.writeHead(500, { "Content-Type": "application/json" });
1248
+ res.end(JSON.stringify({ error: String(error) }));
1249
+ }
1250
+ return;
1251
+ }
1252
+
1253
+ // Client management endpoints
1254
+ if (req.method === "GET" && url.pathname === "/clients") {
1255
+ try {
1256
+ const clients = getClientsFromStore(deps.store).map((c) => ({
1257
+ id: c.clientId,
1258
+ name: c.name,
1259
+ apiKey: c.apiKey,
1260
+ createdAt: c.createdAt,
1261
+ lastUsed: c.lastUsed,
1262
+ ownerNpub: c.ownerNpub,
1263
+ }));
1264
+
1265
+ res.writeHead(200, { "Content-Type": "application/json" });
1266
+ res.end(
1267
+ JSON.stringify({
1268
+ output: {
1269
+ clients,
1270
+ totalCount: clients.length,
1271
+ },
1272
+ }),
1273
+ );
1274
+ } catch (error) {
1275
+ res.writeHead(500, { "Content-Type": "application/json" });
1276
+ res.end(JSON.stringify({ error: String(error) }));
1277
+ }
1278
+ return;
1279
+ }
1280
+
1281
+ if (req.method === "POST" && url.pathname === "/clients/add") {
1282
+ try {
1283
+ const bodyText = await readBody(req);
1284
+ const body = bodyText ? JSON.parse(bodyText) : {};
1285
+ const name = body.name as string | undefined;
1286
+ const explicitId = body.id as string | undefined;
1287
+ const ownerNpub = body.ownerNpub as string | undefined;
1288
+
1289
+ if (!name || typeof name !== "string" || name.trim() === "") {
1290
+ res.writeHead(400, { "Content-Type": "application/json" });
1291
+ res.end(
1292
+ JSON.stringify({
1293
+ error:
1294
+ "Missing required 'name' field (must be a non-empty string).",
1295
+ }),
1296
+ );
1297
+ return;
1298
+ }
1299
+
1300
+ if (!explicitId || typeof explicitId !== "string" || explicitId.trim() === "") {
1301
+ res.writeHead(400, { "Content-Type": "application/json" });
1302
+ res.end(
1303
+ JSON.stringify({
1304
+ error:
1305
+ "Missing required 'id' field (must be a non-empty string).",
1306
+ }),
1307
+ );
1308
+ return;
1309
+ }
1310
+
1311
+ const sanitizeId = (value: string) =>
1312
+ value
1313
+ .toLowerCase()
1314
+ .replace(/\s+/g, "-")
1315
+ .replace(/[^a-z0-9-]/g, "");
1316
+
1317
+ const clientId = sanitizeId(explicitId);
1318
+
1319
+ if (!clientId) {
1320
+ res.writeHead(400, { "Content-Type": "application/json" });
1321
+ res.end(
1322
+ JSON.stringify({
1323
+ error:
1324
+ "Invalid client id. Must contain alphanumeric characters.",
1325
+ }),
1326
+ );
1327
+ return;
1328
+ }
1329
+
1330
+ const existingClients = getClientsFromStore(deps.store);
1331
+ const existingClient = existingClients.find(
1332
+ (c) => c.clientId === clientId,
1333
+ );
1334
+
1335
+ if (existingClient) {
1336
+ res.writeHead(409, { "Content-Type": "application/json" });
1337
+ res.end(
1338
+ JSON.stringify({
1339
+ error: `Client with id '${clientId}' already exists.`,
1340
+ }),
1341
+ );
1342
+ return;
1343
+ }
1344
+
1345
+ const apiKey = generateApiKey();
1346
+ const newClient = {
1347
+ clientId,
1348
+ name: name.trim(),
1349
+ apiKey,
1350
+ createdAt: Date.now(),
1351
+ ...(ownerNpub && typeof ownerNpub === "string" ? { ownerNpub } : {}),
1352
+ };
1353
+
1354
+ deps.store
1355
+ .getState()
1356
+ .setClientIds((prev: typeof existingClients) => [
1357
+ ...(prev || []),
1358
+ newClient,
1359
+ ]);
1360
+
1361
+ logger.log(`Added client '${name}' with id '${clientId}'`);
1362
+
1363
+ res.writeHead(200, { "Content-Type": "application/json" });
1364
+ res.end(
1365
+ JSON.stringify({
1366
+ output: {
1367
+ message: `Client '${name}' added successfully`,
1368
+ client: {
1369
+ id: clientId,
1370
+ name: name.trim(),
1371
+ apiKey,
1372
+ createdAt: newClient.createdAt,
1373
+ ownerNpub: newClient.ownerNpub,
1374
+ },
1375
+ },
1376
+ }),
1377
+ );
1378
+ } catch (error) {
1379
+ respondWithError(res, error);
1380
+ }
1381
+ return;
1382
+ }
1383
+
1384
+ if (req.method === "POST" && url.pathname === "/clients/delete") {
1385
+ try {
1386
+ const bodyText = await readBody(req);
1387
+ const body = bodyText ? JSON.parse(bodyText) : {};
1388
+ const id = body.id as string | undefined;
1389
+
1390
+ if (!id || typeof id !== "string" || id.trim() === "") {
1391
+ res.writeHead(400, { "Content-Type": "application/json" });
1392
+ res.end(
1393
+ JSON.stringify({
1394
+ error:
1395
+ "Missing required 'id' field (must be a non-empty string).",
1396
+ }),
1397
+ );
1398
+ return;
1399
+ }
1400
+
1401
+ const existingClients = getClientsFromStore(deps.store);
1402
+ const index = existingClients.findIndex(
1403
+ (c) => c.clientId === id,
1404
+ );
1405
+
1406
+ if (index === -1) {
1407
+ res.writeHead(404, { "Content-Type": "application/json" });
1408
+ res.end(
1409
+ JSON.stringify({
1410
+ error: `Client with id '${id}' not found.`,
1411
+ }),
1412
+ );
1413
+ return;
1414
+ }
1415
+
1416
+ const removedClient = existingClients[index]!;
1417
+ const updatedClients = existingClients.filter(
1418
+ (_c: unknown, i: number) => i !== index,
1419
+ );
1420
+
1421
+ deps.store.getState().setClientIds(updatedClients);
1422
+
1423
+ logger.log(
1424
+ `Deleted client '${removedClient.name}' with id '${id}'`,
1425
+ );
1426
+
1427
+ res.writeHead(200, { "Content-Type": "application/json" });
1428
+ res.end(
1429
+ JSON.stringify({
1430
+ output: {
1431
+ message: `Client '${removedClient.name}' deleted successfully`,
1432
+ id,
1433
+ },
1434
+ }),
1435
+ );
1436
+ } catch (error) {
1437
+ respondWithError(res, error);
1438
+ }
1439
+ return;
1440
+ }
1441
+
1442
+ if (req.method === "GET" && url.pathname === "/providers") {
1443
+ try {
1444
+ const forceRefresh =
1445
+ url.searchParams.get("refresh")?.toLowerCase() === "true";
1446
+
1447
+ if (forceRefresh) {
1448
+ logger.log("Force-refreshing providers from Nostr and fetching models...");
1449
+ await deps.refreshProvidersAndModels();
1450
+ }
1451
+
1452
+ const state = deps.store.getState();
1453
+ const baseUrlsList: string[] = state.baseUrlsList || [];
1454
+ const manuallyEnabled = new Set(
1455
+ state.manuallyEnabledProviders || [],
1456
+ );
1457
+ const disabledProviders: string[] = [
1458
+ ...new Set([
1459
+ ...(state.disabledProviders || []),
1460
+ ...(state.manuallyDisabledProviders || []),
1461
+ ]),
1462
+ ].filter((url) => !manuallyEnabled.has(url));
1463
+
1464
+ const providers = baseUrlsList.map((baseUrl, index) => ({
1465
+ index,
1466
+ baseUrl,
1467
+ disabled: disabledProviders.includes(baseUrl),
1468
+ }));
1469
+
1470
+ // Only count disabled providers that are actually in the current list
1471
+ // (filter out stale entries from previously disabled providers that are no longer present)
1472
+ const activeDisabledCount = providers.filter((p) => p.disabled).length;
1473
+
1474
+ res.writeHead(200, { "Content-Type": "application/json" });
1475
+ res.end(
1476
+ JSON.stringify({
1477
+ output: {
1478
+ providers,
1479
+ disabledCount: activeDisabledCount,
1480
+ totalCount: baseUrlsList.length,
1481
+ },
1482
+ }),
1483
+ );
1484
+ } catch (error) {
1485
+ res.writeHead(500, { "Content-Type": "application/json" });
1486
+ res.end(JSON.stringify({ error: String(error) }));
1487
+ }
1488
+ return;
1489
+ }
1490
+
1491
+ if (req.method === "GET" && url.pathname === "/providers/reviews") {
1492
+ try {
1493
+ const state = deps.store.getState();
1494
+ const baseUrlsList: string[] = state.baseUrlsList || [];
1495
+
1496
+ // Read disable state from the discovery adapter (the same source the
1497
+ // router/ProviderManager uses), not the mirror in the SdkStore, which
1498
+ // can lag behind the review sync on warm boots.
1499
+ const manuallyEnabled = new Set(
1500
+ deps.discoveryAdapter.getManuallyEnabledProviders?.() || [],
1501
+ );
1502
+ const disabledSet = new Set(
1503
+ [
1504
+ ...(deps.discoveryAdapter.getDisabledProviders() || []),
1505
+ ...(deps.discoveryAdapter.getManuallyDisabledProviders() || []),
1506
+ ].filter((u: string) => !manuallyEnabled.has(u)),
1507
+ );
1508
+
1509
+ // url -> set of node pubkeys advertised by that provider's 38421 event(s)
1510
+ const providerNodes = new Map<string, Set<string>>();
1511
+ // All stored kind 38425 review events
1512
+ const reviewEvents: any[] = [];
1513
+
1514
+ const eventStore = await deps.modelManager.getEventStore();
1515
+ if (eventStore) {
1516
+ const providerEvents = eventStore.getTimeline({ kinds: [38421] });
1517
+ for (const ev of providerEvents) {
1518
+ const pubkey = ev.pubkey;
1519
+ for (const u of collectProviderUrlsFromEvent(ev)) {
1520
+ let set = providerNodes.get(u);
1521
+ if (!set) {
1522
+ set = new Set<string>();
1523
+ providerNodes.set(u, set);
1524
+ }
1525
+ set.add(pubkey);
1526
+ }
1527
+ }
1528
+ reviewEvents.push(...eventStore.getTimeline({ kinds: [38425] }));
1529
+ }
1530
+
1531
+ const mapReview = (rv: any) => ({
1532
+ eventId: rv.id,
1533
+ createdAt: rv.created_at,
1534
+ authorPubkey: rv.pubkey,
1535
+ nodePubkey: tagValue(rv.tags, "node"),
1536
+ label: tagValue(rv.tags, "t") ?? "",
1537
+ isLgtm:
1538
+ (tagValue(rv.tags, "t") ?? "").toLowerCase() === "lgtm",
1539
+ tags: rv.tags,
1540
+ });
1541
+
1542
+ const providers = baseUrlsList.map((baseUrl, index) => {
1543
+ const normalized = normalizeProviderBaseUrl(baseUrl);
1544
+ const nodePubkeys = Array.from(
1545
+ providerNodes.get(normalized) || new Set<string>(),
1546
+ );
1547
+ const reviews = reviewEvents
1548
+ .filter((rv) => {
1549
+ const node = tagValue(rv.tags, "node");
1550
+ return node ? nodePubkeys.includes(node) : false;
1551
+ })
1552
+ .sort((a, b) => b.created_at - a.created_at)
1553
+ .map(mapReview);
1554
+ return {
1555
+ index,
1556
+ baseUrl,
1557
+ disabled: disabledSet.has(baseUrl),
1558
+ nodePubkeys,
1559
+ reviewCount: reviews.length,
1560
+ reviews,
1561
+ };
1562
+ });
1563
+
1564
+ // Review events that reference a node pubkey we could not map back to
1565
+ // any currently-known provider URL (kept for completeness).
1566
+ const knownNodePubkeys = new Set<string>();
1567
+ for (const set of providerNodes.values()) {
1568
+ for (const pk of set) knownNodePubkeys.add(pk);
1569
+ }
1570
+ const unmatchedReviews = reviewEvents
1571
+ .filter((rv) => {
1572
+ const node = tagValue(rv.tags, "node");
1573
+ return node ? !knownNodePubkeys.has(node) : false;
1574
+ })
1575
+ .sort((a, b) => b.created_at - a.created_at)
1576
+ .map(mapReview);
1577
+
1578
+ res.writeHead(200, { "Content-Type": "application/json" });
1579
+ res.end(
1580
+ JSON.stringify({
1581
+ output: {
1582
+ providers,
1583
+ unmatchedReviews,
1584
+ totalCount: baseUrlsList.length,
1585
+ reviewEventCount: reviewEvents.length,
1586
+ },
1587
+ }),
1588
+ );
1589
+ } catch (error) {
1590
+ res.writeHead(500, { "Content-Type": "application/json" });
1591
+ res.end(JSON.stringify({ error: String(error) }));
1592
+ }
1593
+ return;
1594
+ }
1595
+
1596
+ if (req.method === "GET" && url.pathname === "/usage") {
1597
+ try {
1598
+ const npubFilter = url.searchParams.get("npub")?.trim();
1599
+ const clients = npubFilter ? getClientsFromStore(deps.store) : undefined;
1600
+ const clientFilter = npubFilter
1601
+ ? clients!
1602
+ .filter((c) => c.ownerNpub === npubFilter)
1603
+ .map((c) => c.clientId)
1604
+ : undefined;
1605
+ const output = await deps.usageTrackingDriver.list({
1606
+ limit: parseLimit(url.searchParams.get("limit")),
1607
+ ...(clientFilter ? { clients: clientFilter } : {}),
1608
+ });
1609
+ res.writeHead(200, { "Content-Type": "application/json" });
1610
+ res.end(JSON.stringify({ output }));
1611
+ } catch (error) {
1612
+ sendJson(res, 500, { error: toErrorMessage(error) });
1613
+ }
1614
+ return;
1615
+ }
1616
+
1617
+ if (req.method === "GET" && url.pathname === "/usage/summary") {
1618
+ try {
1619
+ const tz = Number.parseInt(url.searchParams.get("tz") || "0", 10) || 0;
1620
+ const npubFilter = url.searchParams.get("npub")?.trim();
1621
+ const clients = getClientsFromStore(deps.store);
1622
+ const clientFilter = npubFilter
1623
+ ? clients
1624
+ .filter((c) => c.ownerNpub === npubFilter)
1625
+ .map((c) => c.clientId)
1626
+ : undefined;
1627
+ const summary = await getUsageSummary(deps.usageTrackingDriver, clients, tz, clientFilter);
1628
+ sendJson(res, 200, { output: summary });
1629
+ } catch (error) {
1630
+ sendJson(res, 500, { error: toErrorMessage(error) });
1631
+ }
1632
+ return;
1633
+ }
1634
+
1635
+ if (req.method === "GET" && url.pathname === "/usagePi") {
1636
+ try {
1637
+ const timestamp = (url.searchParams.get("timestamp") || "").trim();
1638
+ if (!timestamp) {
1639
+ sendJson(res, 400, {
1640
+ error: "Missing required 'timestamp' query parameter.",
1641
+ });
1642
+ return;
1643
+ }
1644
+
1645
+ const usageDriver = deps.usageTrackingDriver;
1646
+ const limit = parseLimit(url.searchParams.get("limit"));
1647
+ const allMatching = await usageDriver.list();
1648
+ const requestIdPrefix = `gen-${timestamp}-`;
1649
+ const filtered = allMatching.filter((entry) =>
1650
+ entry.requestId.startsWith(requestIdPrefix),
1651
+ );
1652
+ const entries = filtered.slice(0, limit);
1653
+ const totalEntries = filtered.length;
1654
+ const totalSatsCost = filtered.reduce(
1655
+ (sum, entry) => sum + (entry.satsCost || 0),
1656
+ 0,
1657
+ );
1658
+ const recentSatsCost = entries.reduce(
1659
+ (sum, entry) => sum + (entry.satsCost || 0),
1660
+ 0,
1661
+ );
1662
+
1663
+ res.writeHead(200, { "Content-Type": "application/json" });
1664
+ res.end(
1665
+ JSON.stringify({
1666
+ output: {
1667
+ entries,
1668
+ totalEntries,
1669
+ totalSatsCost,
1670
+ recentSatsCost,
1671
+ limit,
1672
+ timestamp,
1673
+ },
1674
+ }),
1675
+ );
1676
+ } catch (error) {
1677
+ sendJson(res, 500, { error: toErrorMessage(error) });
1678
+ }
1679
+ return;
1680
+ }
1681
+
1682
+ if (
1683
+ req.method !== "POST" &&
1684
+ !url.pathname.startsWith("/clients") &&
1685
+ !url.pathname.startsWith("/keys/api")
1686
+ ) {
1687
+ res.writeHead(405, { "Content-Type": "application/json" });
1688
+ res.end(JSON.stringify({ error: "Only POST is supported." }));
1689
+ return;
1690
+ }
1691
+
1692
+ let requestBody: unknown = {};
1693
+ try {
1694
+ requestBody = await readJsonBody(req);
1695
+ } catch (error) {
1696
+ sendJson(res, 400, {
1697
+ error: "Invalid JSON body.",
1698
+ details: toErrorMessage(error),
1699
+ });
1700
+ return;
1701
+ }
1702
+
1703
+ const bodyObj = requestBody as Record<string, unknown>;
1704
+ const modelId = typeof bodyObj.model === "string" ? bodyObj.model : "";
1705
+
1706
+ if (!modelId) {
1707
+ sendJson(res, 400, { error: "Missing required 'model' field." });
1708
+ return;
1709
+ }
1710
+
1711
+ // Cap the completion budget when the client does not set an output token
1712
+ // limit. Without this, the SDK prices at the provider's worst-case
1713
+ // max_completion_cost, which varies widely across providers (2.3× for
1714
+ // kimi-k3) and balloons during provider failover. Chat/completions use
1715
+ // max_tokens; the OpenAI Responses API uses max_output_tokens.
1716
+ if (deps.maxTokens > 0) {
1717
+ const isResponsesPath = url.pathname.includes("/responses");
1718
+ if (isResponsesPath) {
1719
+ if (typeof bodyObj.max_output_tokens !== "number") {
1720
+ bodyObj.max_output_tokens = deps.maxTokens;
1721
+ }
1722
+ } else if (typeof bodyObj.max_tokens !== "number") {
1723
+ bodyObj.max_tokens = deps.maxTokens;
1724
+ }
1725
+ }
1726
+
1727
+ const forcedProvider: string | undefined =
1728
+ url.searchParams.get("provider") ||
1729
+ (req.headers["x-routstr-provider"] as string | undefined) ||
1730
+ deps.provider ||
1731
+ undefined;
1732
+
1733
+ // Convert req.headers to Record<string, string>
1734
+ const incomingHeaders: Record<string, string> = {};
1735
+ for (const [key, value] of Object.entries(req.headers)) {
1736
+ if (typeof value === "string") {
1737
+ incomingHeaders[key] = value;
1738
+ } else if (Array.isArray(value) && value.length > 0) {
1739
+ incomingHeaders[key] = value[0]!;
1740
+ }
1741
+ }
1742
+
1743
+ try {
1744
+ await deps.ensureProvidersBootstrapped();
1745
+ const reqId = randomBytes(4).toString("hex");
1746
+ const reqLogger = makeSdkLogger(`req:${reqId}`, `model:${modelId}`);
1747
+ reqLogger.log(`Routing request with path: ${url.pathname}`);
1748
+
1749
+ const response = await routeRequests({
1750
+ modelId,
1751
+ requestBody,
1752
+ path: url.pathname,
1753
+ forcedProvider,
1754
+ headers: incomingHeaders,
1755
+ walletAdapter: deps.walletAdapter,
1756
+ storageAdapter: deps.storageAdapter,
1757
+ discoveryAdapter: deps.discoveryAdapter,
1758
+ modelManager: deps.modelManager,
1759
+ debugLevel: "DEBUG",
1760
+ mode: deps.mode,
1761
+ usageTrackingDriver: deps.usageTrackingDriver,
1762
+ sdkStore: deps.store,
1763
+ providerManager: deps.providerManager,
1764
+ logger: reqLogger,
1765
+ ...(deps.requestResponseLogSink
1766
+ ? { requestResponseLogSink: deps.requestResponseLogSink }
1767
+ : {}),
1768
+ ...(deps.routstrPubkey ? { routstrPubkey: deps.routstrPubkey } : {}),
1769
+ ...(deps.routstrModelsPubkey
1770
+ ? { routstrModelsPubkey: deps.routstrModelsPubkey }
1771
+ : {}),
1772
+ });
1773
+
1774
+ // Bridge the Web `Response` to the Node `ServerResponse`: status +
1775
+ // headers + pipe(body → res). Hop-by-hop headers are dropped (see
1776
+ // HOP_BY_HOP_HEADERS) so Node computes correct framing for THIS
1777
+ // connection.
1778
+ res.statusCode = response.status;
1779
+ response.headers.forEach((value, key) => {
1780
+ if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) return;
1781
+ res.setHeader(key, value);
1782
+ });
1783
+
1784
+ const finalize = (response as any).finalize as
1785
+ | (() => Promise<number>)
1786
+ | undefined;
1787
+
1788
+ if (!response.body) {
1789
+ res.end();
1790
+ if (finalize) {
1791
+ try {
1792
+ await finalize();
1793
+ } catch (err) {
1794
+ logger.error(`[daemon] finalize error: ${toErrorMessage(err)}`);
1795
+ }
1796
+ }
1797
+ return;
1798
+ }
1799
+
1800
+ const nodeReadable = Readable.fromWeb(response.body as any);
1801
+ await new Promise<void>((resolve, reject) => {
1802
+ let settled = false;
1803
+ const finish = () => {
1804
+ if (settled) return;
1805
+ settled = true;
1806
+ resolve();
1807
+ };
1808
+ const fail = (err: unknown) => {
1809
+ if (settled) return;
1810
+ settled = true;
1811
+ reject(err);
1812
+ };
1813
+ res.once("finish", finish);
1814
+ res.once("close", finish);
1815
+ res.once("error", fail);
1816
+ nodeReadable.once("error", fail);
1817
+ nodeReadable.pipe(res);
1818
+ });
1819
+
1820
+ if (finalize) {
1821
+ try {
1822
+ await finalize();
1823
+ } catch (err) {
1824
+ logger.error(`[daemon] finalize error: ${toErrorMessage(err)}`);
1825
+ }
1826
+ }
1827
+ return;
1828
+ } catch (error) {
1829
+ const message = error instanceof Error ? error.message : String(error);
1830
+ logger.error(`[daemon] Error: ${message}`);
1831
+
1832
+ if (error instanceof InsufficientBalanceError) {
1833
+ const balanceError = error as {
1834
+ required?: number;
1835
+ available?: number;
1836
+ maxMintBalance?: number;
1837
+ maxMintUrl?: string;
1838
+ };
1839
+ res.writeHead(402, { "Content-Type": "application/json" });
1840
+ res.end(
1841
+ JSON.stringify({
1842
+ error: message,
1843
+ error_type: "insufficient_balance",
1844
+ required: balanceError.required,
1845
+ available: balanceError.available,
1846
+ maxMintBalance: balanceError.maxMintBalance,
1847
+ maxMintUrl: balanceError.maxMintUrl,
1848
+ }),
1849
+ );
1850
+ return;
1851
+ }
1852
+
1853
+ res.writeHead(500, { "Content-Type": "application/json" });
1854
+ res.end(JSON.stringify({ error: message }));
1855
+ }
1856
+ };
1857
+ }