@tokenbuddy/tb-admin 1.0.13 → 1.0.15

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.
Files changed (45) hide show
  1. package/dist/src/bootstrap-registry.d.ts +1 -0
  2. package/dist/src/bootstrap-registry.d.ts.map +1 -1
  3. package/dist/src/bootstrap-registry.js.map +1 -1
  4. package/dist/src/cli.d.ts.map +1 -1
  5. package/dist/src/cli.js +30 -16
  6. package/dist/src/cli.js.map +1 -1
  7. package/dist/src/server-cmd.d.ts +27 -2
  8. package/dist/src/server-cmd.d.ts.map +1 -1
  9. package/dist/src/server-cmd.js +131 -26
  10. package/dist/src/server-cmd.js.map +1 -1
  11. package/dist/src/ui-actions.d.ts +88 -0
  12. package/dist/src/ui-actions.d.ts.map +1 -0
  13. package/dist/src/ui-actions.js +763 -0
  14. package/dist/src/ui-actions.js.map +1 -0
  15. package/dist/src/ui-command.d.ts +4 -0
  16. package/dist/src/ui-command.d.ts.map +1 -0
  17. package/dist/src/ui-command.js +37 -0
  18. package/dist/src/ui-command.js.map +1 -0
  19. package/dist/src/ui-server.d.ts +23 -0
  20. package/dist/src/ui-server.d.ts.map +1 -0
  21. package/dist/src/ui-server.js +245 -0
  22. package/dist/src/ui-server.js.map +1 -0
  23. package/dist/src/ui-state.d.ts +134 -0
  24. package/dist/src/ui-state.d.ts.map +1 -0
  25. package/dist/src/ui-state.js +407 -0
  26. package/dist/src/ui-state.js.map +1 -0
  27. package/dist/src/ui-static.d.ts +2 -0
  28. package/dist/src/ui-static.d.ts.map +1 -0
  29. package/dist/src/ui-static.js +144 -0
  30. package/dist/src/ui-static.js.map +1 -0
  31. package/dist/src/upstream-balance-probe.d.ts +41 -0
  32. package/dist/src/upstream-balance-probe.d.ts.map +1 -0
  33. package/dist/src/upstream-balance-probe.js +379 -0
  34. package/dist/src/upstream-balance-probe.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/bootstrap-registry.ts +1 -0
  37. package/src/cli.ts +32 -16
  38. package/src/server-cmd.ts +163 -29
  39. package/src/ui-actions.ts +901 -0
  40. package/src/ui-command.ts +39 -0
  41. package/src/ui-server.ts +308 -0
  42. package/src/ui-state.ts +575 -0
  43. package/src/ui-static.ts +144 -0
  44. package/src/upstream-balance-probe.ts +505 -0
  45. package/tests/admin.test.ts +893 -4
@@ -1,10 +1,19 @@
1
1
  import { ConfigManager } from "../src/config.js";
2
2
  import { buildAdminCli } from "../src/cli.js";
3
- import { FlyProvider } from "../src/server-cmd.js";
3
+ import { FlyProvider, parseFlyMachineIds, requirePublishedDockerImage } from "../src/server-cmd.js";
4
+ import { startAdminUiServer } from "../src/ui-server.js";
5
+ import { AdminUiState } from "../src/ui-state.js";
6
+ import { UiActions, type UiActionResult } from "../src/ui-actions.js";
7
+ import { adminUiHtml } from "../src/ui-static.js";
8
+ import {
9
+ BalanceProbeCache,
10
+ probeUpstreamBalance
11
+ } from "../src/upstream-balance-probe.js";
4
12
  import {
5
13
  validateRegistryDocument
6
14
  } from "../src/bootstrap-registry.js";
7
15
  import * as fs from "fs";
16
+ import * as http from "http";
8
17
  import * as path from "path";
9
18
 
10
19
  const TEMP_CONF_PATH = path.resolve(__dirname, "../../data-test/admin-config.json");
@@ -67,6 +76,8 @@ describe("Admin CLI Config Profile Management Tests", () => {
67
76
  const sellerConfig = program.commands.find((command) => command.name() === "seller-config");
68
77
  const payments = program.commands.find((command) => command.name() === "payments");
69
78
  const upstreams = program.commands.find((command) => command.name() === "upstreams");
79
+ const models = program.commands.find((command) => command.name() === "models");
80
+ const ui = program.commands.find((command) => command.name() === "ui");
70
81
 
71
82
  expect(bootstrap).toBeDefined();
72
83
  expect(sellers).toBeDefined();
@@ -78,6 +89,11 @@ describe("Admin CLI Config Profile Management Tests", () => {
78
89
  expect(sellerConfig?.commands.find((command) => command.name() === "put")?.options.some((option) => option.long === "--file")).toBe(true);
79
90
  expect(sellerConfig?.commands.find((command) => command.name() === "validate")?.options.some((option) => option.long === "--file")).toBe(true);
80
91
  expect(upstreams).toBeDefined();
92
+ expect(ui).toBeDefined();
93
+ expect(ui?.options.find((option) => option.long === "--host")?.defaultValue).toBe("127.0.0.1");
94
+ expect(ui?.options.find((option) => option.long === "--port")?.defaultValue).toBe(17822);
95
+ expect(models).toBeDefined();
96
+ expect(models?.options.some((option) => option.long === "--json")).toBe(true);
81
97
  expect(upstreams?.commands.map((command) => command.name()).sort()).toEqual(["get", "refresh", "update"]);
82
98
  expect(upstreams?.commands.find((command) => command.name() === "update")?.options.some((option) => option.long === "--upstream-url")).toBe(true);
83
99
  expect(upstreams?.commands.find((command) => command.name() === "refresh")?.options.some((option) => option.long === "--auto-models")).toBe(true);
@@ -90,7 +106,7 @@ describe("Admin CLI Config Profile Management Tests", () => {
90
106
  ]);
91
107
  });
92
108
 
93
- test("seller Fly deploy commands require explicit image and fly config", () => {
109
+ test("seller Fly commands separate create config from deploy image updates", () => {
94
110
  const program = buildAdminCli(new ConfigManager(TEMP_CONF_PATH));
95
111
  const seller = program.commands.find((command) => command.name() === "seller");
96
112
  const create = seller?.commands.find((command) => command.name() === "create");
@@ -100,8 +116,9 @@ describe("Admin CLI Config Profile Management Tests", () => {
100
116
  expect(deploy).toBeDefined();
101
117
  expect(create?.options.some((option) => option.long === "--fly-config" && option.required)).toBe(true);
102
118
  expect(create?.options.some((option) => option.long === "--image" && option.required)).toBe(true);
119
+ expect(create?.options.some((option) => option.long === "--initial-config")).toBe(true);
103
120
  expect(create?.options.some((option) => option.long === "--config")).toBe(false);
104
- expect(deploy?.options.some((option) => option.long === "--fly-config" && option.required)).toBe(true);
121
+ expect(deploy?.options.some((option) => option.long === "--fly-config")).toBe(false);
105
122
  expect(deploy?.options.some((option) => option.long === "--image" && option.required)).toBe(true);
106
123
  expect(deploy?.options.some((option) => option.long === "--config")).toBe(false);
107
124
 
@@ -117,7 +134,50 @@ describe("Admin CLI Config Profile Management Tests", () => {
117
134
  expect(() => provider.deploySeller({
118
135
  app: "tbs-test",
119
136
  dryRun: true
120
- })).toThrow("seller deploy requires --fly-config");
137
+ })).toThrow("seller deploy requires --image");
138
+
139
+ expect(provider.deploySeller({
140
+ app: "tbs-test",
141
+ image: "registry.fly.io/tb-seller:1.0.13",
142
+ dryRun: true
143
+ })).toContain("Volumes: unchanged");
144
+ });
145
+
146
+ test("seller create checks the published image before creating Fly resources", () => {
147
+ expect(() => requirePublishedDockerImage("registry.fly.io/tb-seller:missing", () => ({
148
+ ok: false,
149
+ error: "not found"
150
+ }))).toThrow("No Fly app was created");
151
+
152
+ const commands: string[] = [];
153
+ const provider = new FlyProvider(undefined, {
154
+ checkFlyctlInstalled: () => true,
155
+ imageInspector: () => ({ ok: false, error: "not found" }),
156
+ execSync: (command) => {
157
+ commands.push(command);
158
+ return "";
159
+ }
160
+ });
161
+
162
+ expect(() => provider.createSeller({
163
+ name: "tbs-test",
164
+ app: "tbs-test",
165
+ image: "registry.fly.io/tb-seller:missing",
166
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml",
167
+ operatorSecret: "operator-secret"
168
+ })).toThrow("seller image is not published or is not accessible");
169
+
170
+ expect(commands).toEqual([]);
171
+ });
172
+
173
+ test("parseFlyMachineIds reads machine ids and rejects unusable machine lists", () => {
174
+ expect(parseFlyMachineIds(JSON.stringify([
175
+ { id: "machine-1" },
176
+ { id: "machine-2" }
177
+ ]), "tbs-test")).toEqual(["machine-1", "machine-2"]);
178
+
179
+ expect(() => parseFlyMachineIds("{", "tbs-test")).toThrow("invalid JSON");
180
+ expect(() => parseFlyMachineIds("[]", "tbs-test")).toThrow("has no machines");
121
181
  });
122
182
 
123
183
  test("bootstrap registry validation accepts demo config and rejects unsafe variants", () => {
@@ -139,4 +199,833 @@ describe("Admin CLI Config Profile Management Tests", () => {
139
199
  badDefault.defaultSeller = "missing";
140
200
  expect(() => validateRegistryDocument(badDefault)).toThrow("defaultSeller `missing`");
141
201
  });
202
+
203
+ test("tb-admin ui server binds loopback, protects APIs with a session, and serves bootstrap data", async () => {
204
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
205
+ const started = await startAdminUiServer({
206
+ host: "127.0.0.1",
207
+ port: 0,
208
+ openBrowser: false,
209
+ configManager: mgr,
210
+ url: "https://bootstrap.example.test",
211
+ fetchJson: async () => ({
212
+ version: 7,
213
+ sellers: [{
214
+ id: "tbs-sin-06",
215
+ name: "tbs-sin-06",
216
+ url: "https://seller.example.test",
217
+ status: "active",
218
+ region: "sin",
219
+ supportedProtocols: ["responses"],
220
+ paymentMethods: ["clawtip"]
221
+ }]
222
+ })
223
+ });
224
+ try {
225
+ await expect(fetch(`${started.url.replace(/\?.*/, "")}api/bootstrap`).then((res) => res.status)).resolves.toBe(401);
226
+ const response = await fetch(`${started.url.replace(/\?.*/, "")}api/bootstrap`, {
227
+ headers: { "X-TokenBuddy-Ui-Session": started.sessionToken }
228
+ });
229
+ await expect(response.json()).resolves.toMatchObject({
230
+ status: "available",
231
+ registryVersion: 7,
232
+ sellerEntries: 1,
233
+ regions: ["sin"]
234
+ });
235
+ } finally {
236
+ await new Promise<void>((resolve) => started.server.close(() => resolve()));
237
+ }
238
+
239
+ await expect(startAdminUiServer({
240
+ host: "0.0.0.0",
241
+ port: 0,
242
+ openBrowser: false,
243
+ configManager: mgr
244
+ })).rejects.toThrow("loopback");
245
+ });
246
+
247
+ test("tb-admin ui create seller returns a progress job", async () => {
248
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
249
+ mgr.setSellerProvider("fly", { operator_secret: "operator-token-value" });
250
+ const calls: string[][] = [];
251
+ const started = await startAdminUiServer({
252
+ host: "127.0.0.1",
253
+ port: 0,
254
+ openBrowser: false,
255
+ configManager: mgr,
256
+ url: "https://bootstrap.example.test",
257
+ fetchJson: async (url) => createSellerFetchJson(url),
258
+ commandRunner: async (args): Promise<UiActionResult> => {
259
+ if (isBootstrapSellersValidate(args)) {
260
+ const filePath = args[args.indexOf("--file") + 1];
261
+ const doc = JSON.parse(fs.readFileSync(filePath, "utf8")) as { sellers: Array<{ id: string; status?: string }> };
262
+ expect(doc.sellers.find((seller) => seller.id === "tbs-nrt-07")?.status).toBe("pending");
263
+ }
264
+ calls.push(args);
265
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
266
+ }
267
+ });
268
+ try {
269
+ const response = await fetch(`${started.url.replace(/\?.*/, "")}api/sellers`, {
270
+ method: "POST",
271
+ headers: {
272
+ "Content-Type": "application/json",
273
+ "X-TokenBuddy-Ui-Session": started.sessionToken
274
+ },
275
+ body: JSON.stringify({
276
+ sellerName: "tbs-nrt-07",
277
+ app: "tbs-nrt-07",
278
+ region: "nrt",
279
+ image: "registry.fly.io/tb-seller:latest",
280
+ upstreamWebsite: "https://openrouter.ai",
281
+ upstreamUrl: "https://openrouter.ai/api/v1",
282
+ upstreamApiKey: "fixture-upstream-key",
283
+ upstreamBalanceProbeTemplate: "openrouter",
284
+ upstreamBalanceProbeUrl: "https://openrouter.ai/api/v1/credits",
285
+ upstreamBalanceProbeRechargeUrl: "https://openrouter.ai/settings/credits",
286
+ maxConnections: 8,
287
+ maxQueueDepth: 4,
288
+ markupRatio: 1.2,
289
+ discountRatio: 1,
290
+ paymentMethods: ["clawtip", "mock"],
291
+ clawtipPayTo: "pay-to-seller",
292
+ clawtipSm4KeyBase64: "0123456789abcdef012345==",
293
+ clawtipSkillSlug: "tokenbuddy-seller",
294
+ clawtipSkillId: "si-tokenbuddy-seller",
295
+ clawtipDescription: "TokenBuddy Seller",
296
+ clawtipResourceUrl: "https://tbs-nrt-07.fly.dev",
297
+ clawtipActivationFeeFen: 1,
298
+ clawtipMicrosPerFen: 10000,
299
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml"
300
+ })
301
+ });
302
+ expect(response.status).toBe(202);
303
+ const created = await response.json() as { jobId: string };
304
+ expect(created.jobId).toBeTruthy();
305
+
306
+ let job: any;
307
+ for (let attempt = 0; attempt < 20; attempt += 1) {
308
+ const poll = await fetch(`${started.url.replace(/\?.*/, "")}api/jobs/${created.jobId}`, {
309
+ headers: { "X-TokenBuddy-Ui-Session": started.sessionToken }
310
+ });
311
+ job = await poll.json();
312
+ if (job.status !== "running") {
313
+ break;
314
+ }
315
+ await new Promise((resolve) => setTimeout(resolve, 10));
316
+ }
317
+
318
+ expect(job.status).toBe("succeeded");
319
+ expect(job.events.map((event: any) => event.stepId)).toEqual([
320
+ "check_registry",
321
+ "validate_config",
322
+ "create_deployment",
323
+ "wait_seller",
324
+ "apply_config",
325
+ "refresh_models",
326
+ "publish_registry"
327
+ ]);
328
+ expect(job.events.find((event: any) => event.stepId === "validate_config").message).toContain("https://openrouter.ai/api");
329
+ expect(JSON.stringify(job)).not.toContain("fixture-upstream-key");
330
+ expect(JSON.stringify(job)).not.toContain("0123456789abcdef012345==");
331
+ expect(JSON.stringify(job)).not.toContain("operator-token-value");
332
+ const commandLines = calls.map((args) => args.join(" "));
333
+ expect(commandLines).toEqual([
334
+ expect.stringContaining("seller-config validate --file"),
335
+ expect.stringContaining("seller create tbs-nrt-07"),
336
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value status"),
337
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value seller-config put --file"),
338
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-token-value upstreams refresh --auto-models"),
339
+ expect.stringContaining("bootstrap sellers validate --file"),
340
+ expect.stringContaining("bootstrap sellers put --file")
341
+ ]);
342
+ expect(commandLines.find((line) => line.includes("seller create tbs-nrt-07"))).toContain("--initial-config");
343
+ } finally {
344
+ await new Promise<void>((resolve) => started.server.close(() => resolve()));
345
+ }
346
+ });
347
+
348
+ test("AdminUiState reads seller list from bootstrap registry and masks seller detail API keys", async () => {
349
+ const fixture = await startFixtureAdminServer();
350
+ try {
351
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
352
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
353
+ mgr.setProfile("seller-sin", { url: fixture.baseUrl, token: "seller-token" });
354
+
355
+ const state = new AdminUiState({
356
+ configManager: mgr,
357
+ profile: "bootstrap",
358
+ balanceFetch: async (url, init) => {
359
+ expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
360
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-live-key-27f9");
361
+ return jsonResponse({ data: { total_credits: 50, total_usage: 2.05 } });
362
+ }
363
+ });
364
+ const sellers = await state.sellers();
365
+ expect(sellers).toHaveLength(1);
366
+ expect(sellers[0]).toMatchObject({
367
+ id: "tbs-sin-06",
368
+ upstreamDomain: "openrouter.ai",
369
+ capacityUsed: 3,
370
+ capacityLimit: 8,
371
+ ttftMs: 321,
372
+ avgInferenceMs: 640,
373
+ lastInferenceMs: 700,
374
+ latencySamples: 2,
375
+ upstreamStatus: "healthy",
376
+ upstreamBalance: "USD 47.95",
377
+ upstreamBalanceSource: "openrouter"
378
+ });
379
+
380
+ const detail = await state.sellerDetail("tbs-sin-06");
381
+ expect(detail.configuration.upstreamApiKeyMasked).toBe("**** **** **** 27f9");
382
+ expect(detail.configuration.upstreamBalance).toBe("USD 47.95");
383
+ expect(detail.configuration.upstreamBalanceSource).toBe("openrouter");
384
+ expect(detail.configuration.upstreamBalanceProbeTemplate).toBe("openrouter");
385
+ expect(detail.configuration.upstreamBalanceProbeUrl).toBe("https://openrouter.ai/api/v1/credits");
386
+ expect(detail.configuration.upstreamBalanceProbeRechargeUrl).toBe("https://openrouter.ai/settings/credits");
387
+ expect(JSON.stringify(detail)).not.toContain("fixture-live-key");
388
+ expect(detail.models[0]).toMatchObject({
389
+ upstreamModel: "openai/gpt-5.4",
390
+ billingModel: "gpt-5.4"
391
+ });
392
+ } finally {
393
+ await fixture.close();
394
+ }
395
+ });
396
+
397
+ test("admin UI seller rows render missing telemetry without unknown data labels", () => {
398
+ const html = adminUiHtml();
399
+ expect(html).toContain("class=\"spinner\"");
400
+ expect(html).toContain("@keyframes spin");
401
+ expect(html).toContain("role=\"status\" aria-label=\"Loading sellers\"");
402
+ expect(html).toContain("id=\"createStatus\" class=\"status-line hidden\"");
403
+ expect(html).toContain("id=\"createProgress\"");
404
+ expect(html).toContain("create-progress");
405
+ expect(html).toContain("progress-step");
406
+ expect(html).toContain("progress-log");
407
+ expect(html).toContain("currentCreateJob");
408
+ expect(html).toContain("setCreateFormDisabled(true)");
409
+ expect(html).toContain("setCreateFormDisabled(false)");
410
+ expect(html).toContain("data-progress-step");
411
+ expect(html).toContain("aria-expanded");
412
+ expect(html).toContain("progress-title");
413
+ expect(html).toContain("Show details");
414
+ expect(html).toContain("Hide details");
415
+ expect(html).toContain("/api/jobs/");
416
+ expect(html).toContain("function uiErrorMessage");
417
+ expect(html).toContain("Admin UI connection lost. Reopen the latest tb-admin UI URL and reload this page.");
418
+ expect(html).toContain("Admin UI session expired. Reopen the latest tb-admin UI URL.");
419
+ expect(html).toContain("renderCreateJob");
420
+ expect(html).toContain("pollCreateJob");
421
+ expect(html).toContain("Created and published to bootstrap registry.");
422
+ expect(html).toContain("upstreamUrl:\"https://openrouter.ai/api/v1\"");
423
+ expect(html).toContain("loadingSpinner(\"Loading sellers\")");
424
+ expect(html).toContain("loadingSpinner(\"Loading configuration\")");
425
+ expect(html).toContain("loadingSpinner(\"Loading models\")");
426
+ expect(html).toContain("sellerRefreshIntervalMs = 30000");
427
+ expect(html).toContain("Last updated: never");
428
+ expect(html).toContain("function scheduleSellerRefresh()");
429
+ expect(html).toContain("sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs)");
430
+ expect(html).toContain("sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs)");
431
+ expect(html).toContain("Next refresh: ");
432
+ expect(html).toContain("s ago");
433
+ expect(html).toContain("sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000)");
434
+ expect(html).toContain("function renderSellerRows(rows)");
435
+ expect(html).toContain("sellerLastUpdatedAt = new Date()");
436
+ expect(html).toContain("readonly-value");
437
+ expect(html).toContain("--seller-grid");
438
+ expect(html).toContain("grid-template-columns:var(--seller-grid)");
439
+ expect(html).toContain("#sellerRows{display:grid;gap:10px;width:100%;min-width:0}");
440
+ expect(html).toContain("gap:12px;width:100%;min-width:0");
441
+ expect(html).toContain("detailFieldsHtml");
442
+ expect(html).toContain("data-original");
443
+ expect(html).toContain("Latency");
444
+ expect(html).toContain("avg n/a");
445
+ expect(html).toContain("Avg infer");
446
+ expect(html).toContain("Samples");
447
+ expect(html).toContain("lastInferenceMs");
448
+ expect(html).toContain("upstreamBalanceSource");
449
+ expect(html).toContain("upstreamBalanceFetchedAt");
450
+ expect(html).toContain("upstreamBalanceError");
451
+ expect(html).toContain("upstreamBalanceProbeTemplate");
452
+ expect(html).toContain("upstreamBalanceProbeUrl");
453
+ expect(html).toContain("upstreamBalanceProbeUserId");
454
+ expect(html).toContain("upstreamBalanceProbeRechargeUrl");
455
+ expect(html).toContain("paymentMethods");
456
+ expect(html).toContain("payment-tabs");
457
+ expect(html).toContain("payment-tab");
458
+ expect(html).toContain("payment-panel");
459
+ expect(html).toContain("pill-switch");
460
+ expect(html).toContain("data-payment-tab");
461
+ expect(html).toContain("data-payment-panel");
462
+ expect(html).toContain("data-payment-toggle");
463
+ expect(html).toContain("enabledPaymentMethods()");
464
+ expect(html).toContain("setupPaymentTabs()");
465
+ expect(html).toContain("togglePaymentMethod");
466
+ expect(html).toContain("selectPaymentTab");
467
+ expect(html).toContain("updatePaymentPanels");
468
+ expect(html).toContain("data-enabled");
469
+ expect(html).toContain("aria-pressed");
470
+ expect(html).toContain("启用即可使用 mock 支付方式,无需额外参数");
471
+ expect(html).toContain("required-star");
472
+ expect(html).toContain("Seller name");
473
+ expect(html).toContain("tbs-<seller-name>-<random>");
474
+ expect(html).toContain("Fly app name");
475
+ expect(html).toContain("Seller image");
476
+ expect(html).toContain("Fly config file");
477
+ expect(html).toContain("Balance probe URL (auto from template)");
478
+ expect(html).toContain("Recharge URL");
479
+ expect(html).toContain("randomAppSuffix");
480
+ expect(html).toContain("updateGeneratedCreateFields");
481
+ expect(html).toContain("appNameFromSellerName");
482
+ expect(html).toContain("balanceProbeUrlForTemplate");
483
+ expect(html).toContain("data-generated-summary=\"clawtip\"");
484
+ expect(html).toContain("clawtipPayTo");
485
+ expect(html).toContain("clawtipSm4KeyBase64");
486
+ expect(html).toContain("clawtipSkillSlug");
487
+ expect(html).toContain("clawtipSkillId");
488
+ expect(html).toContain("clawtipDescription");
489
+ expect(html).toContain("clawtipResourceUrl");
490
+ expect(html).toContain("clawtipActivationFeeFen");
491
+ expect(html).toContain("clawtipMicrosPerFen");
492
+ expect(html).toContain("not reported");
493
+ expect(html).toContain("aria-label=\"Seller specs\"");
494
+ expect(html).toContain("<svg viewBox=\"0 0 24 24\"");
495
+ expect(html).not.toContain(">i</span>");
496
+ expect(html).not.toContain(">×</button>");
497
+ expect(html).not.toContain("Loading sellers...");
498
+ expect(html).not.toContain("Loading seller data...");
499
+ expect(html).not.toContain("Loading configuration...");
500
+ expect(html).not.toContain("Loading models...");
501
+ expect(html).not.toContain("<div id=\"createStatus\" class=\"status-line\">Ready</div>");
502
+ expect(html).not.toContain("not set");
503
+ expect(html).not.toContain("tok/s");
504
+ expect(html).not.toContain(" multiple");
505
+ });
506
+
507
+ test("UiActions validates then puts seller config without shell command strings", async () => {
508
+ const fixture = await startFixtureAdminServer();
509
+ try {
510
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
511
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
512
+ mgr.setProfile("seller-sin", { url: fixture.baseUrl, token: "seller-token" });
513
+ const calls: string[][] = [];
514
+ const actions = new UiActions({
515
+ configManager: mgr,
516
+ profile: "bootstrap",
517
+ commandRunner: async (args): Promise<UiActionResult> => {
518
+ calls.push(args);
519
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
520
+ }
521
+ });
522
+
523
+ const result = await actions.updateSellerConfig("tbs-sin-06", {
524
+ markupRatio: 1.4,
525
+ upstreamApiKey: "new-secret"
526
+ });
527
+
528
+ expect(result.ok).toBe(true);
529
+ expect(calls).toHaveLength(2);
530
+ expect(calls[0]).toContain("validate");
531
+ expect(calls[1]).toContain("put");
532
+ expect(calls.flat()).not.toContain("seller-config validate");
533
+ expect(calls[0]).toContain("--profile");
534
+ expect(calls[0]).toContain("seller-sin");
535
+ } finally {
536
+ await fixture.close();
537
+ }
538
+ });
539
+
540
+ test("probeUpstreamBalance parses OpenRouter balance and caches failed probes", async () => {
541
+ const cache = new BalanceProbeCache();
542
+ let calls = 0;
543
+ const openRouter = await probeUpstreamBalance({
544
+ upstreamUrl: "https://openrouter.ai/api",
545
+ upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
546
+ upstreamApiKey: "fixture-key"
547
+ }, {
548
+ now: () => 1000,
549
+ cache,
550
+ fetch: async (url, init) => {
551
+ calls += 1;
552
+ expect(String(url)).toBe("https://openrouter.ai/api/v1/credits");
553
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
554
+ return jsonResponse({ data: { total_credits: 50, total_usage: 2.05 } });
555
+ }
556
+ });
557
+
558
+ expect(openRouter).toMatchObject({
559
+ source: "openrouter",
560
+ rawAmount: 47.95,
561
+ amountUsdMicros: 47950000,
562
+ currency: "USD"
563
+ });
564
+ expect(calls).toBe(1);
565
+
566
+ const firstFailure = await probeUpstreamBalance({
567
+ upstreamUrl: "https://custom-upstream.example/v1",
568
+ upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
569
+ upstreamApiKey: "fixture-key"
570
+ }, {
571
+ now: () => 2000,
572
+ cache,
573
+ fetch: async () => {
574
+ calls += 1;
575
+ return jsonResponse({ success: false });
576
+ }
577
+ });
578
+ const cachedFailure = await probeUpstreamBalance({
579
+ upstreamUrl: "https://custom-upstream.example/v1",
580
+ upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
581
+ upstreamApiKey: "fixture-key"
582
+ }, {
583
+ now: () => 3000,
584
+ cache,
585
+ fetch: async () => {
586
+ throw new Error("cache miss");
587
+ }
588
+ });
589
+
590
+ expect(firstFailure.error?.message).toBe("missing upstreamUserId for newapi upstream");
591
+ expect(cachedFailure).toBe(firstFailure);
592
+ expect(calls).toBe(1);
593
+ });
594
+
595
+ test("probeUpstreamBalance sends New-Api-User for generic newapi balances", async () => {
596
+ const snapshot = await probeUpstreamBalance({
597
+ upstreamUrl: "https://custom-upstream.example/v1",
598
+ upstreamBalanceUrl: "https://custom-upstream.example/api/user/quota",
599
+ upstreamApiKey: "fixture-key",
600
+ upstreamUserId: "12345"
601
+ }, {
602
+ now: () => 4000,
603
+ fetch: async (url, init) => {
604
+ expect(String(url)).toBe("https://custom-upstream.example/api/user/quota");
605
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
606
+ expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBe("12345");
607
+ return jsonResponse({ success: true, data: { quota: 50000000, used_quota: 1250000 } });
608
+ }
609
+ });
610
+
611
+ expect(snapshot).toMatchObject({
612
+ source: "newapi_generic",
613
+ rawAmount: 97.5,
614
+ amountUsdMicros: 97500000,
615
+ currency: "USD"
616
+ });
617
+ });
618
+
619
+ test("probeUpstreamBalance parses generic /v1/usage balances without user id", async () => {
620
+ const snapshot = await probeUpstreamBalance({
621
+ upstreamUrl: "https://code.shoestravel.xin",
622
+ upstreamApiKey: "fixture-key"
623
+ }, {
624
+ now: () => 5000,
625
+ fetch: async (url, init) => {
626
+ expect(String(url)).toBe("https://code.shoestravel.xin/v1/usage");
627
+ expect((init?.headers as Record<string, string>)?.Authorization).toBe("Bearer fixture-key");
628
+ expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBeUndefined();
629
+ return jsonResponse({
630
+ balance: 37.96221984,
631
+ isValid: true,
632
+ remaining: 37.96221984,
633
+ unit: "USD"
634
+ });
635
+ }
636
+ });
637
+
638
+ expect(snapshot).toMatchObject({
639
+ source: "usage_generic",
640
+ rawAmount: 37.96221984,
641
+ amountUsdMicros: 37962220,
642
+ currency: "USD"
643
+ });
644
+ });
645
+
646
+ test("probeUpstreamBalance honors explicit balance probe templates", async () => {
647
+ const newApi = await probeUpstreamBalance({
648
+ upstreamUrl: "https://custom-upstream.example/v1",
649
+ upstreamApiKey: "fixture-key",
650
+ upstreamBalanceProbe: {
651
+ template: "newapi_generic",
652
+ url: "https://custom-upstream.example/api/user/self",
653
+ userId: "67890"
654
+ }
655
+ }, {
656
+ now: () => 7000,
657
+ fetch: async (url, init) => {
658
+ expect(String(url)).toBe("https://custom-upstream.example/api/user/self");
659
+ expect((init?.headers as Record<string, string>)?.["New-Api-User"]).toBe("67890");
660
+ return jsonResponse({ success: true, data: { quota: 1000000, used_quota: 500000 } });
661
+ }
662
+ });
663
+
664
+ expect(newApi).toMatchObject({
665
+ source: "newapi_generic",
666
+ rawAmount: 1,
667
+ amountUsdMicros: 1000000,
668
+ currency: "USD"
669
+ });
670
+
671
+ const usage = await probeUpstreamBalance({
672
+ upstreamUrl: "https://code.shoestravel.xin/v1",
673
+ upstreamApiKey: "fixture-key",
674
+ upstreamBalanceProbe: {
675
+ template: "usage_generic"
676
+ }
677
+ }, {
678
+ now: () => 8000,
679
+ fetch: async (url) => {
680
+ expect(String(url)).toBe("https://code.shoestravel.xin/v1/usage");
681
+ return jsonResponse({ remaining: 37.96221984, unit: "USD", isValid: true });
682
+ }
683
+ });
684
+
685
+ expect(usage).toMatchObject({
686
+ source: "usage_generic",
687
+ rawAmount: 37.96221984,
688
+ amountUsdMicros: 37962220,
689
+ currency: "USD"
690
+ });
691
+ });
692
+
693
+ test("probeUpstreamBalance reports inactive generic /v1/usage keys", async () => {
694
+ const snapshot = await probeUpstreamBalance({
695
+ upstreamUrl: "https://custom-upstream.example/v1",
696
+ upstreamBalanceUrl: "https://custom-upstream.example/v1/usage",
697
+ upstreamApiKey: "fixture-key"
698
+ }, {
699
+ now: () => 6000,
700
+ fetch: async () => jsonResponse({
701
+ remaining: 12.5,
702
+ unit: "USD",
703
+ isValid: false
704
+ })
705
+ });
706
+
707
+ expect(snapshot).toMatchObject({
708
+ source: "usage_generic",
709
+ rawAmount: 12.5,
710
+ amountUsdMicros: 12500000,
711
+ currency: "USD",
712
+ error: {
713
+ httpStatus: 200,
714
+ message: "upstream key is not active"
715
+ }
716
+ });
717
+ });
718
+
719
+ test("UiActions create validates, creates, then applies initial seller config", async () => {
720
+ const fixture = await startFixtureAdminServer();
721
+ try {
722
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
723
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
724
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
725
+ const calls: string[][] = [];
726
+ const actions = new UiActions({
727
+ configManager: mgr,
728
+ configPath: TEMP_CONF_PATH,
729
+ profile: "bootstrap",
730
+ fetchJson: async (url) => createSellerFetchJson(url),
731
+ commandRunner: async (args): Promise<UiActionResult> => {
732
+ if (isBootstrapSellersValidate(args)) {
733
+ const filePath = args[args.indexOf("--file") + 1];
734
+ const doc = JSON.parse(fs.readFileSync(filePath, "utf8")) as { sellers: Array<{ id: string; status?: string }> };
735
+ expect(doc.sellers.find((seller) => seller.id === "tbs-nrt-07")?.status).toBe("pending");
736
+ }
737
+ calls.push(args);
738
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
739
+ }
740
+ });
741
+
742
+ const response = await actions.createSeller({
743
+ sellerName: "tbs-nrt-07",
744
+ app: "tbs-nrt-07",
745
+ region: "nrt",
746
+ image: "registry.fly.io/tb-seller:latest",
747
+ upstreamWebsite: "https://openrouter.ai",
748
+ upstreamUrl: "https://openrouter.ai/api/v1",
749
+ upstreamApiKey: "fixture-upstream-key",
750
+ upstreamBalanceProbeTemplate: "usage_generic",
751
+ upstreamBalanceProbeUrl: "https://code.shoestravel.xin/v1/usage",
752
+ upstreamBalanceProbeRechargeUrl: "https://code.shoestravel.xin/topup",
753
+ maxConnections: 8,
754
+ maxQueueDepth: 4,
755
+ markupRatio: 1.2,
756
+ discountRatio: 1,
757
+ paymentMethods: ["clawtip", "mock"],
758
+ clawtipPayTo: "pay-to-seller",
759
+ clawtipSm4KeyBase64: "0123456789abcdef012345==",
760
+ clawtipSkillSlug: "tokenbuddy-seller",
761
+ clawtipSkillId: "si-tokenbuddy-seller",
762
+ clawtipDescription: "TokenBuddy Seller",
763
+ clawtipResourceUrl: "https://tbs-nrt-07.fly.dev",
764
+ clawtipActivationFeeFen: 1,
765
+ clawtipMicrosPerFen: 10000,
766
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml"
767
+ });
768
+
769
+ expect(response.result.ok).toBe(true);
770
+ expect(response.configPut?.ok).toBe(true);
771
+ expect(response.modelsRefresh?.ok).toBe(true);
772
+ expect(response.registryPublish?.ok).toBe(true);
773
+ expect(response.publishRegistry).toBe("completed");
774
+ expect(response.configPreview.upstreamUrl).toBe("https://openrouter.ai/api");
775
+ expect(response.configPreview.upstreamBalanceProbe).toEqual({
776
+ template: "usage_generic",
777
+ url: "https://code.shoestravel.xin/v1/usage",
778
+ userId: undefined,
779
+ rechargeUrl: "https://code.shoestravel.xin/topup"
780
+ });
781
+ expect(response.configPreview.upstreamBalanceUrl).toBe("https://code.shoestravel.xin/v1/usage");
782
+ expect(response.configPreview.upstreamRechargeUrl).toBe("https://code.shoestravel.xin/topup");
783
+ expect(response.configPreview.allowMock).toBe(true);
784
+ expect(response.configPreview.clawtip).toEqual({
785
+ payTo: "pay-to-seller",
786
+ sm4KeyBase64: "********",
787
+ skillSlug: "tokenbuddy-seller",
788
+ skillId: "si-tokenbuddy-seller",
789
+ description: "TokenBuddy Seller",
790
+ resourceUrl: "https://tbs-nrt-07.fly.dev",
791
+ activationFeeFen: 1,
792
+ microsPerFen: 10000
793
+ });
794
+ const commandLines = calls.map((args) => args.join(" "));
795
+ expect(commandLines).toEqual([
796
+ expect.stringContaining("seller-config validate --file"),
797
+ expect.stringContaining("seller create tbs-nrt-07"),
798
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret status"),
799
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret seller-config put --file"),
800
+ expect.stringContaining("--url https://tbs-nrt-07.fly.dev --token operator-secret upstreams refresh --auto-models"),
801
+ expect.stringContaining("bootstrap sellers validate --file"),
802
+ expect.stringContaining("bootstrap sellers put --file")
803
+ ]);
804
+ expect(commandLines.find((line) => line.includes("seller create tbs-nrt-07"))).toContain("--initial-config");
805
+ } finally {
806
+ await fixture.close();
807
+ }
808
+ });
809
+
810
+ test("UiActions create supports mock-only payment without ClawTip parameters", async () => {
811
+ const fixture = await startFixtureAdminServer();
812
+ try {
813
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
814
+ mgr.setProfile("bootstrap", { url: fixture.baseUrl, token: "bootstrap-token" });
815
+ mgr.setSellerProvider("fly", { operator_secret: "operator-secret" });
816
+ const actions = new UiActions({
817
+ configManager: mgr,
818
+ profile: "bootstrap",
819
+ commandRunner: async (args): Promise<UiActionResult> => {
820
+ return { ok: true, stdout: "ok", stderr: "", command: ["node", "tb-admin", ...args] };
821
+ }
822
+ });
823
+
824
+ const response = await actions.createSeller({
825
+ sellerName: "tbs-nrt-08",
826
+ app: "tbs-nrt-08",
827
+ region: "nrt",
828
+ image: "registry.fly.io/tb-seller:latest",
829
+ upstreamWebsite: "https://openrouter.ai",
830
+ upstreamUrl: "https://openrouter.ai/api/v1",
831
+ upstreamApiKey: "fixture-upstream-key",
832
+ upstreamBalanceProbeTemplate: "none",
833
+ maxConnections: 8,
834
+ maxQueueDepth: 4,
835
+ markupRatio: 1.2,
836
+ discountRatio: 1,
837
+ paymentMethods: ["mock"],
838
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml",
839
+ dryRun: true
840
+ });
841
+
842
+ expect(response.result.ok).toBe(true);
843
+ expect(response.readiness).toBeUndefined();
844
+ expect(response.configPut).toBeUndefined();
845
+ expect(response.configPreview.allowMock).toBe(true);
846
+ expect(response.configPreview.clawtip).toBeUndefined();
847
+ } finally {
848
+ await fixture.close();
849
+ }
850
+ });
851
+
852
+ test("UiActions create requires ClawTip parameters for ClawTip payment", async () => {
853
+ const mgr = new ConfigManager(TEMP_CONF_PATH);
854
+ const actions = new UiActions({
855
+ configManager: mgr,
856
+ profile: "bootstrap",
857
+ commandRunner: async (): Promise<UiActionResult> => {
858
+ throw new Error("create should fail before running commands");
859
+ }
860
+ });
861
+
862
+ await expect(actions.createSeller({
863
+ sellerName: "tbs-nrt-09",
864
+ app: "tbs-nrt-09",
865
+ region: "nrt",
866
+ image: "registry.fly.io/tb-seller:latest",
867
+ upstreamWebsite: "https://openrouter.ai",
868
+ upstreamUrl: "https://openrouter.ai/api/v1",
869
+ upstreamApiKey: "fixture-upstream-key",
870
+ upstreamBalanceProbeTemplate: "none",
871
+ maxConnections: 8,
872
+ maxQueueDepth: 4,
873
+ markupRatio: 1.2,
874
+ discountRatio: 1,
875
+ paymentMethods: ["clawtip"],
876
+ flyConfig: "deploy/fly.io/fly.tb-seller.toml"
877
+ })).rejects.toThrow("clawtipPayTo is required");
878
+ });
879
+
880
+ test("UiActions subprocess runner uses the tb-admin bin entrypoint", async () => {
881
+ const previousArgv = process.argv[1];
882
+ process.argv[1] = path.resolve(__dirname, "../dist/src/index.js");
883
+ const { runTbAdmin } = await import("../src/ui-actions.js");
884
+ try {
885
+ const result = await runTbAdmin(["--help"], 30000);
886
+ expect(result.ok).toBe(true);
887
+ expect(result.command[1]).toMatch(/packages\/admin-cli\/bin\/tb-admin\.js$/);
888
+ expect(result.stdout).toContain("Remote admin CLI");
889
+ } finally {
890
+ process.argv[1] = previousArgv;
891
+ }
892
+ });
142
893
  });
894
+
895
+ async function startFixtureAdminServer(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
896
+ const server = http.createServer((req, res) => {
897
+ const pathName = new URL(req.url || "/", "http://127.0.0.1").pathname;
898
+ if (pathName === "/registry/sellers") {
899
+ sendJson(res, {
900
+ version: 7,
901
+ updatedAt: "2026-06-05T00:00:00.000Z",
902
+ defaultSeller: "tbs-sin-06",
903
+ sellers: [{
904
+ id: "tbs-sin-06",
905
+ name: "tbs-sin-06",
906
+ profile: "seller-sin",
907
+ app: "tbs-sin-06",
908
+ url: "https://seller.example.test",
909
+ status: "active",
910
+ region: "sin",
911
+ modelsCount: 1,
912
+ sampleModels: ["openai/gpt-5.4"],
913
+ supportedProtocols: ["responses"],
914
+ paymentMethods: ["clawtip"]
915
+ }]
916
+ });
917
+ return;
918
+ }
919
+ if (pathName === "/operator/status") {
920
+ sendJson(res, {
921
+ status: "healthy",
922
+ capacity: { activeConnections: 3, maxConnections: 8, queueDepth: 0, maxQueueDepth: 4 },
923
+ upstream: { status: "healthy" },
924
+ latency: { ttftMs: 321, avgInferenceMs: 640, lastInferenceMs: 700, sampleCount: 2 }
925
+ });
926
+ return;
927
+ }
928
+ if (pathName === "/operator/admin/service") {
929
+ sendJson(res, {
930
+ sellerId: "tbs-sin-06",
931
+ upstreamUrl: "https://openrouter.ai/api/v1",
932
+ modelsCount: 1,
933
+ capacity: { activeConnections: 3, maxConnections: 8, queueDepth: 0, maxQueueDepth: 4 }
934
+ });
935
+ return;
936
+ }
937
+ if (pathName === "/operator/admin/upstreams") {
938
+ sendJson(res, {
939
+ upstreamUrl: "https://openrouter.ai/api/v1",
940
+ upstreamApiKey: "****27f9",
941
+ markupRatio: 1.2,
942
+ discountRatio: 1,
943
+ modelAliases: { "openai/gpt-5.4": "gpt-5.4" },
944
+ models: [{
945
+ id: "openai/gpt-5.4",
946
+ inputPriceMicrosPer1m: 1000000,
947
+ outputPriceMicrosPer1m: 3000000
948
+ }]
949
+ });
950
+ return;
951
+ }
952
+ if (pathName === "/operator/admin/config") {
953
+ sendJson(res, {
954
+ config: {
955
+ upstreamUrl: "https://openrouter.ai/api/v1",
956
+ upstreamApiKey: "fixture-live-key-27f9",
957
+ upstreamBalanceUrl: "https://openrouter.ai/api/v1/credits",
958
+ upstreamRechargeUrl: "https://openrouter.ai/settings/credits",
959
+ upstreamBalanceProbe: {
960
+ template: "openrouter",
961
+ url: "https://openrouter.ai/api/v1/credits",
962
+ rechargeUrl: "https://openrouter.ai/settings/credits"
963
+ },
964
+ markupRatio: 1.2,
965
+ discountRatio: 1,
966
+ maxConnections: 8,
967
+ maxQueueDepth: 4,
968
+ modelAliases: { "openai/gpt-5.4": "gpt-5.4" },
969
+ models: [{ id: "openai/gpt-5.4" }]
970
+ }
971
+ });
972
+ return;
973
+ }
974
+ res.statusCode = 404;
975
+ res.end();
976
+ });
977
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
978
+ const address = server.address();
979
+ if (!address || typeof address === "string") {
980
+ throw new Error("fixture server did not bind a TCP port");
981
+ }
982
+ return {
983
+ baseUrl: `http://127.0.0.1:${address.port}`,
984
+ close: () => new Promise<void>((resolve) => server.close(() => resolve()))
985
+ };
986
+ }
987
+
988
+ function sendJson(res: http.ServerResponse, body: unknown): void {
989
+ res.writeHead(200, { "Content-Type": "application/json" });
990
+ res.end(JSON.stringify(body));
991
+ }
992
+
993
+ async function createSellerFetchJson(url: string): Promise<unknown> {
994
+ const pathName = new URL(url).pathname;
995
+ if (pathName === "/registry/sellers") {
996
+ return {
997
+ version: 7,
998
+ sellers: []
999
+ };
1000
+ }
1001
+ if (pathName === "/operator/admin/upstreams") {
1002
+ return {
1003
+ upstreamUrl: "https://openrouter.ai/api",
1004
+ models: [
1005
+ { id: "openai/gpt-5.4" },
1006
+ { id: "openai/gpt-5.4-mini" }
1007
+ ],
1008
+ supportedProtocols: ["chat_completions"]
1009
+ };
1010
+ }
1011
+ if (pathName === "/operator/admin/service") {
1012
+ return {
1013
+ sellerId: "tbs-nrt-07",
1014
+ modelsCount: 2,
1015
+ supportedProtocols: ["chat_completions"]
1016
+ };
1017
+ }
1018
+ throw new Error(`unexpected fetch url ${url}`);
1019
+ }
1020
+
1021
+ function isBootstrapSellersValidate(args: string[]): boolean {
1022
+ const validateIndex = args.indexOf("validate");
1023
+ return validateIndex >= 2 && args[validateIndex - 2] === "bootstrap" && args[validateIndex - 1] === "sellers";
1024
+ }
1025
+
1026
+ function jsonResponse(body: unknown, status = 200): Response {
1027
+ return new Response(JSON.stringify(body), {
1028
+ status,
1029
+ headers: { "Content-Type": "application/json" }
1030
+ });
1031
+ }