@tokenbuddy/tokenbuddy 1.0.25 → 1.0.27

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 (84) hide show
  1. package/bin/tb-clawtip-proof.js +2 -0
  2. package/dist/src/clawtip-bootstrap.d.ts +1 -0
  3. package/dist/src/clawtip-bootstrap.d.ts.map +1 -1
  4. package/dist/src/clawtip-bootstrap.js +1 -0
  5. package/dist/src/clawtip-bootstrap.js.map +1 -1
  6. package/dist/src/cli.d.ts +1 -0
  7. package/dist/src/cli.d.ts.map +1 -1
  8. package/dist/src/cli.js +172 -51
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/daemon.d.ts +6 -0
  11. package/dist/src/daemon.d.ts.map +1 -1
  12. package/dist/src/daemon.js +562 -292
  13. package/dist/src/daemon.js.map +1 -1
  14. package/dist/src/init-clawtip-activation.d.ts +12 -0
  15. package/dist/src/init-clawtip-activation.d.ts.map +1 -1
  16. package/dist/src/init-clawtip-activation.js +82 -2
  17. package/dist/src/init-clawtip-activation.js.map +1 -1
  18. package/dist/src/package-update.d.ts +60 -0
  19. package/dist/src/package-update.d.ts.map +1 -0
  20. package/dist/src/package-update.js +220 -0
  21. package/dist/src/package-update.js.map +1 -0
  22. package/dist/src/registry-trust.d.ts +7 -0
  23. package/dist/src/registry-trust.d.ts.map +1 -0
  24. package/dist/src/registry-trust.js +37 -0
  25. package/dist/src/registry-trust.js.map +1 -0
  26. package/dist/src/route-failover.d.ts +2 -2
  27. package/dist/src/route-failover.d.ts.map +1 -1
  28. package/dist/src/route-failover.js +11 -0
  29. package/dist/src/route-failover.js.map +1 -1
  30. package/dist/src/seller-catalog.d.ts +20 -0
  31. package/dist/src/seller-catalog.d.ts.map +1 -1
  32. package/dist/src/seller-catalog.js +41 -4
  33. package/dist/src/seller-catalog.js.map +1 -1
  34. package/dist/src/seller-concurrency-limiter.d.ts +36 -0
  35. package/dist/src/seller-concurrency-limiter.d.ts.map +1 -0
  36. package/dist/src/seller-concurrency-limiter.js +126 -0
  37. package/dist/src/seller-concurrency-limiter.js.map +1 -0
  38. package/dist/src/seller-pool.d.ts +7 -1
  39. package/dist/src/seller-pool.d.ts.map +1 -1
  40. package/dist/src/seller-pool.js +18 -0
  41. package/dist/src/seller-pool.js.map +1 -1
  42. package/dist/src/seller-route-planner.d.ts +21 -0
  43. package/dist/src/seller-route-planner.d.ts.map +1 -1
  44. package/dist/src/seller-route-planner.js +98 -20
  45. package/dist/src/seller-route-planner.js.map +1 -1
  46. package/dist/src/tb-clawtip-proof.d.ts +3 -0
  47. package/dist/src/tb-clawtip-proof.d.ts.map +1 -0
  48. package/dist/src/tb-clawtip-proof.js +24 -0
  49. package/dist/src/tb-clawtip-proof.js.map +1 -0
  50. package/dist/src/tb-proxyd.js +45 -3
  51. package/dist/src/tb-proxyd.js.map +1 -1
  52. package/package.json +3 -2
  53. package/src/clawtip-bootstrap.ts +1 -0
  54. package/src/cli.ts +200 -47
  55. package/src/daemon.ts +347 -50
  56. package/src/init-clawtip-activation.ts +77 -1
  57. package/src/package-update.ts +313 -0
  58. package/src/registry-trust.ts +51 -0
  59. package/src/route-failover.ts +14 -2
  60. package/src/seller-catalog.ts +67 -4
  61. package/src/seller-concurrency-limiter.ts +161 -0
  62. package/src/seller-pool.ts +20 -0
  63. package/src/seller-route-planner.ts +142 -20
  64. package/src/tb-clawtip-proof.ts +28 -0
  65. package/src/tb-proxyd.ts +48 -3
  66. package/static/ui/assets/index-Bzbrp7Qe.css +1 -0
  67. package/static/ui/assets/index-UAfOhbwC.js +236 -0
  68. package/static/ui/assets/index-UAfOhbwC.js.map +1 -0
  69. package/static/ui/index.html +2 -2
  70. package/tests/cli-routing.test.ts +37 -4
  71. package/tests/control-plane-ui-endpoints.test.ts +7 -7
  72. package/tests/daemon-trusted-registry-cache.test.ts +132 -0
  73. package/tests/e2e.test.ts +14 -1
  74. package/tests/package-update.test.ts +132 -0
  75. package/tests/registry-trust.test.ts +28 -0
  76. package/tests/route-failover.test.ts +13 -0
  77. package/tests/seller-catalog-413.test.ts +60 -1
  78. package/tests/seller-concurrency-limiter.test.ts +83 -0
  79. package/tests/seller-pool.test.ts +23 -0
  80. package/tests/seller-route-planner.test.ts +78 -0
  81. package/tests/tokenbuddy.test.ts +316 -34
  82. package/static/ui/assets/index-1uuyCCzj.css +0 -1
  83. package/static/ui/assets/index-BJSOFJIU.js +0 -236
  84. package/static/ui/assets/index-BJSOFJIU.js.map +0 -1
@@ -12,8 +12,8 @@
12
12
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/tokenbuddy-192.png" />
13
13
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
14
14
  <title>TokenBuddy · Local Control</title>
15
- <script type="module" crossorigin src="/assets/index-BJSOFJIU.js"></script>
16
- <link rel="stylesheet" crossorigin href="/assets/index-1uuyCCzj.css">
15
+ <script type="module" crossorigin src="/assets/index-UAfOhbwC.js"></script>
16
+ <link rel="stylesheet" crossorigin href="/assets/index-Bzbrp7Qe.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
4
  import { BuyerStore } from "../src/buyer-store.js";
5
+ import { DEFAULT_CLAWTIP_BOOTSTRAP_URL } from "../src/clawtip-bootstrap.js";
5
6
  import { buildCli } from "../src/cli.js";
6
7
 
7
8
  describe("tb routing set", () => {
@@ -245,12 +246,25 @@ describe("tb routing set", () => {
245
246
  }
246
247
  });
247
248
 
248
- test("payment add clawtip requires a bootstrap url", async () => {
249
+ test("payment add clawtip uses the default registry control plane", async () => {
249
250
  const errors: string[] = [];
250
251
  const errorSpy = jest.spyOn(console, "error").mockImplementation((message?: unknown) => {
251
252
  errors.push(String(message));
252
253
  });
253
- const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue(response({ ok: true }));
254
+ const fetchMock = jest.spyOn(globalThis, "fetch").mockResolvedValue(response({
255
+ activationFeeFen: 1,
256
+ payment: {
257
+ orderNo: "order_default_bootstrap",
258
+ amountFen: 1,
259
+ indicator: "indicator_default_bootstrap",
260
+ payTo: "pay-to-test",
261
+ encryptedData: "ciphertext",
262
+ slug: "tb-registry",
263
+ skillId: "si-tb-registry",
264
+ description: "TokenBuddy ClawTip wallet activation",
265
+ resourceUrl: DEFAULT_CLAWTIP_BOOTSTRAP_URL,
266
+ }
267
+ }));
254
268
  const previousExitCode = process.exitCode;
255
269
  const previousBootstrapUrl = process.env.TOKENBUDDY_BOOTSTRAP_URL;
256
270
 
@@ -259,8 +273,27 @@ describe("tb routing set", () => {
259
273
  delete process.env.TOKENBUDDY_BOOTSTRAP_URL;
260
274
  await buildCli().parseAsync(["node", "tb", "payment", "add", "clawtip"]);
261
275
 
262
- expect(process.exitCode).toBe(1);
263
- expect(errors.join("\n")).toContain("ClawTip bootstrap URL is required");
276
+ expect(process.exitCode).toBeUndefined();
277
+ expect(errors).toEqual([]);
278
+ expect(fetchMock).toHaveBeenCalledWith(
279
+ `${DEFAULT_CLAWTIP_BOOTSTRAP_URL}/payments/clawtip/bootstrap`,
280
+ expect.objectContaining({ method: "POST" })
281
+ );
282
+ const store = new BuyerStore({ root: storeRoot });
283
+ try {
284
+ expect(store.getPayment("clawtip")).toMatchObject({
285
+ method: "clawtip",
286
+ enabled: true,
287
+ isDefault: true,
288
+ config: {
289
+ bootstrapUrl: DEFAULT_CLAWTIP_BOOTSTRAP_URL,
290
+ orderNo: "order_default_bootstrap",
291
+ resourceUrl: DEFAULT_CLAWTIP_BOOTSTRAP_URL
292
+ }
293
+ });
294
+ } finally {
295
+ store.close();
296
+ }
264
297
  } finally {
265
298
  process.exitCode = previousExitCode;
266
299
  restoreEnv("TOKENBUDDY_BOOTSTRAP_URL", previousBootstrapUrl);
@@ -111,7 +111,7 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
111
111
  enabled: true,
112
112
  isDefault: true,
113
113
  config: {
114
- resourceUrl: "https://tb-wallet-bootstrap.example.test",
114
+ resourceUrl: "https://tb-registry.example.test",
115
115
  walletConfigPresent: true
116
116
  }
117
117
  });
@@ -137,10 +137,10 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
137
137
  payTo: "real-pay-to",
138
138
  encryptedData: "ciphertext",
139
139
  indicator: "indicator_ui_qr",
140
- slug: "tb-wallet-bootstrap",
141
- skillId: "si-tb-wallet-bootstrap",
140
+ slug: "tb-registry",
141
+ skillId: "si-tb-registry",
142
142
  description: "TokenBuddy ClawTip wallet activation",
143
- resourceUrl: "https://tb-wallet-bootstrap.example.test"
143
+ resourceUrl: "https://tb-registry.example.test"
144
144
  }
145
145
  }),
146
146
  clawtipWalletBootstrapStarter: async (payment) => ({
@@ -183,7 +183,7 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
183
183
  payTo: "real-pay-to",
184
184
  encryptedData: "ciphertext",
185
185
  indicator: "indicator_wait_qr",
186
- resourceUrl: "https://tb-wallet-bootstrap.example.test"
186
+ resourceUrl: "https://tb-registry.example.test"
187
187
  }
188
188
  }),
189
189
  clawtipWalletBootstrapStarter: async (payment) => ({
@@ -228,7 +228,7 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
228
228
  payTo: "real-pay-to",
229
229
  encryptedData: "ciphertext",
230
230
  indicator: "indicator_no_media",
231
- resourceUrl: "https://tb-wallet-bootstrap.example.test"
231
+ resourceUrl: "https://tb-registry.example.test"
232
232
  }
233
233
  }),
234
234
  clawtipWalletBootstrapStarter: async (payment) => ({
@@ -282,7 +282,7 @@ describe("TokenbuddyDaemon control-plane UI endpoints (PR-0)", () => {
282
282
  isDefault: true,
283
283
  config: {
284
284
  orderNo: "order_recharge_static",
285
- resourceUrl: "https://tb-wallet-bootstrap.example.test",
285
+ resourceUrl: "https://tb-registry.example.test",
286
286
  walletConfigPresent: true
287
287
  }
288
288
  });
@@ -0,0 +1,132 @@
1
+ import * as http from "http";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { AddressInfo } from "net";
5
+ import { TokenbuddyDaemon } from "../src/daemon.js";
6
+ import { BuyerStore } from "../src/buyer-store.js";
7
+
8
+ describe("TokenbuddyDaemon trusted registry cache", () => {
9
+ const TEMP_DB = path.resolve(__dirname, "../../data-test/trusted-registry-cache-test.db");
10
+ let server: http.Server;
11
+ let serverPort: number;
12
+ let registryAvailable = true;
13
+ let daemon: TokenbuddyDaemon | undefined;
14
+
15
+ function rmDb(): void {
16
+ for (const suffix of ["", "-wal", "-shm"]) {
17
+ fs.rmSync(`${TEMP_DB}${suffix}`, { force: true });
18
+ }
19
+ }
20
+
21
+ function startDaemon(): { controlPort: number; proxyPort: number } {
22
+ daemon = new TokenbuddyDaemon({
23
+ controlPort: 0,
24
+ proxyPort: 0,
25
+ dbPath: TEMP_DB,
26
+ sellerRegistryUrl: `http://127.0.0.1:${serverPort}/registry/sellers`
27
+ });
28
+ daemon.start();
29
+ return {
30
+ controlPort: ((daemon as unknown as { controlServer: { address(): AddressInfo } }).controlServer.address()).port,
31
+ proxyPort: ((daemon as unknown as { proxyServer: { address(): AddressInfo } }).proxyServer.address()).port
32
+ };
33
+ }
34
+
35
+ async function stopDaemon(): Promise<void> {
36
+ daemon?.stop();
37
+ daemon = undefined;
38
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
39
+ }
40
+
41
+ beforeAll((done) => {
42
+ server = http.createServer((req, res) => {
43
+ res.setHeader("Content-Type", "application/json");
44
+ if (req.url === "/registry/sellers") {
45
+ if (!registryAvailable) {
46
+ res.statusCode = 500;
47
+ res.end(JSON.stringify({ error: "registry unavailable" }));
48
+ return;
49
+ }
50
+ res.statusCode = 200;
51
+ res.end(JSON.stringify({
52
+ version: 7,
53
+ defaultSeller: "cached-seller",
54
+ sellers: [
55
+ {
56
+ id: "cached-seller",
57
+ name: "Cached Seller",
58
+ status: "active",
59
+ url: `http://127.0.0.1:${serverPort}/cached-seller`,
60
+ supportedProtocols: ["chat_completions"],
61
+ paymentMethods: ["mock"],
62
+ models: ["gpt-cache"]
63
+ }
64
+ ]
65
+ }));
66
+ return;
67
+ }
68
+ if (req.url === "/cached-seller/manifest") {
69
+ res.statusCode = 200;
70
+ res.end(JSON.stringify({
71
+ sellerId: "cached-seller",
72
+ supportedProtocols: ["chat_completions"],
73
+ paymentMethods: ["mock"],
74
+ models: [{ id: "gpt-cache" }]
75
+ }));
76
+ return;
77
+ }
78
+ res.statusCode = 404;
79
+ res.end(JSON.stringify({ error: "not found" }));
80
+ });
81
+ server.listen(0, "127.0.0.1", () => {
82
+ serverPort = (server.address() as AddressInfo).port;
83
+ done();
84
+ });
85
+ });
86
+
87
+ afterAll((done) => {
88
+ server.close(() => done());
89
+ });
90
+
91
+ beforeEach(() => {
92
+ rmDb();
93
+ registryAvailable = true;
94
+ });
95
+
96
+ afterEach(async () => {
97
+ await stopDaemon();
98
+ rmDb();
99
+ });
100
+
101
+ test("persists a trusted registry snapshot and reuses it after daemon restart", async () => {
102
+ let ports = startDaemon();
103
+ const firstModels = await (await fetch(`http://127.0.0.1:${ports.proxyPort}/v1/models`)).json() as any;
104
+ expect(firstModels.data).toEqual(expect.arrayContaining([
105
+ expect.objectContaining({ id: "gpt-cache", sellerId: "cached-seller" })
106
+ ]));
107
+
108
+ const store = new BuyerStore({ dbPath: TEMP_DB });
109
+ const cache = store.getDaemonRuntimeConfig<any>("trusted-registry-snapshot")?.config;
110
+ store.close();
111
+ expect(cache).toMatchObject({
112
+ schemaVersion: 1,
113
+ registryUrl: `http://127.0.0.1:${serverPort}/registry/sellers`,
114
+ version: 7,
115
+ trust: {
116
+ verified: false
117
+ }
118
+ });
119
+ expect(cache.registrySha256).toMatch(/^[a-f0-9]{64}$/);
120
+
121
+ await stopDaemon();
122
+ registryAvailable = false;
123
+
124
+ ports = startDaemon();
125
+ const secondModelsResponse = await fetch(`http://127.0.0.1:${ports.proxyPort}/v1/models`);
126
+ expect(secondModelsResponse.status).toBe(200);
127
+ const secondModels = await secondModelsResponse.json() as any;
128
+ expect(secondModels.data).toEqual([
129
+ expect.objectContaining({ id: "gpt-cache", sellerId: "cached-seller" })
130
+ ]);
131
+ });
132
+ });
package/tests/e2e.test.ts CHANGED
@@ -32,7 +32,19 @@ describe("TokenBuddy Full End-to-End Integration Flow Tests", () => {
32
32
  if (!fs.existsSync(TEMP_DIR)) {
33
33
  fs.mkdirSync(TEMP_DIR, { recursive: true });
34
34
  }
35
- fs.writeFileSync(TEMP_BOOTSTRAP_JSON, JSON.stringify({ version: 1, sellers: [] }), "utf8");
35
+ fs.writeFileSync(TEMP_BOOTSTRAP_JSON, JSON.stringify({
36
+ version: 1,
37
+ sellers: [
38
+ {
39
+ id: "seller-e2e-seed",
40
+ name: "Seller E2E Seed",
41
+ url: "http://127.0.0.1:1",
42
+ supportedProtocols: ["chat_completions"],
43
+ paymentMethods: ["mock"],
44
+ models: ["gpt-4"]
45
+ }
46
+ ]
47
+ }), "utf8");
36
48
 
37
49
  if (fs.existsSync(TEMP_SELLER_DB)) {
38
50
  try { fs.unlinkSync(TEMP_SELLER_DB); } catch (e) {}
@@ -86,6 +98,7 @@ describe("TokenBuddy Full End-to-End Integration Flow Tests", () => {
86
98
  // 3. Launch Seller server
87
99
  const { app: sellerApp, close: closeDb } = buildSellerApp(TEMP_SELLER_DB, {
88
100
  allowMock: true,
101
+ publicMockPayments: true,
89
102
  upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
90
103
  upstreamApiKey: "upstream-mock-key",
91
104
  operatorSecret: "op-secret"
@@ -0,0 +1,132 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import {
5
+ checkPackageUpdate,
6
+ runPackageUpdate,
7
+ scheduleLaunchAgentRestart,
8
+ } from "../src/package-update.js";
9
+
10
+ function makeFetch(latestVersion: string, ok = true): typeof fetch {
11
+ return (async () => ({
12
+ ok,
13
+ status: ok ? 200 : 404,
14
+ json: async () => ({ "dist-tags": { latest: latestVersion } }),
15
+ })) as unknown as typeof fetch;
16
+ }
17
+
18
+ function writePackageJson(root: string, version: string, name = "@tokenbuddy/tokenbuddy"): string {
19
+ const binDir = path.join(root, "bin");
20
+ fs.mkdirSync(binDir, { recursive: true });
21
+ const binPath = path.join(binDir, "tb.js");
22
+ fs.writeFileSync(binPath, "", "utf8");
23
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name, version }), "utf8");
24
+ return binPath;
25
+ }
26
+
27
+ describe("TokenBuddy package update", () => {
28
+ let tempRoot: string;
29
+
30
+ beforeEach(() => {
31
+ tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tokenbuddy-package-update-"));
32
+ });
33
+
34
+ afterEach(() => {
35
+ fs.rmSync(tempRoot, { recursive: true, force: true });
36
+ });
37
+
38
+ test("checks npm latest and maps the scoped workspace package to the public tokenbuddy package", async () => {
39
+ const binPath = writePackageJson(tempRoot, "1.0.0");
40
+
41
+ const check = await checkPackageUpdate({
42
+ fetch: makeFetch("1.2.0"),
43
+ argv: ["node", binPath],
44
+ cwd: tempRoot,
45
+ env: {},
46
+ });
47
+
48
+ expect(check).toMatchObject({
49
+ packageName: "tokenbuddy",
50
+ currentVersion: "1.0.0",
51
+ latestVersion: "1.2.0",
52
+ updateAvailable: true,
53
+ installCommand: "npm install -g tokenbuddy@1.2.0",
54
+ });
55
+ });
56
+
57
+ test("installs the latest package and restarts tb-proxyd when an update is available", async () => {
58
+ const binPath = writePackageJson(tempRoot, "1.0.0");
59
+ const installCalls: Array<{ command: string; args: string[] }> = [];
60
+ const restartCalls: number[] = [];
61
+
62
+ const result = await runPackageUpdate(
63
+ { apply: true, controlPort: 17820 },
64
+ {
65
+ fetch: makeFetch("1.1.0"),
66
+ argv: ["node", binPath],
67
+ cwd: tempRoot,
68
+ env: {},
69
+ runNpmInstall: (command, args) => {
70
+ installCalls.push({ command, args });
71
+ },
72
+ restartProxyd: async (controlPort) => {
73
+ restartCalls.push(controlPort);
74
+ return { attempted: true, restarted: true, method: "launchd" };
75
+ },
76
+ },
77
+ );
78
+
79
+ expect(result.install).toMatchObject({ attempted: true, succeeded: true });
80
+ expect(result.restart).toMatchObject({ attempted: true, restarted: true });
81
+ expect(installCalls).toEqual([{ command: "npm", args: ["install", "-g", "tokenbuddy@1.1.0"] }]);
82
+ expect(restartCalls).toEqual([17820]);
83
+ });
84
+
85
+ test("does not install or restart when the current version is already latest", async () => {
86
+ const binPath = writePackageJson(tempRoot, "1.0.0");
87
+ const result = await runPackageUpdate(
88
+ { apply: true, controlPort: 17820 },
89
+ {
90
+ fetch: makeFetch("1.0.0"),
91
+ argv: ["node", binPath],
92
+ cwd: tempRoot,
93
+ env: {},
94
+ runNpmInstall: () => {
95
+ throw new Error("install should not run");
96
+ },
97
+ restartProxyd: async () => {
98
+ throw new Error("restart should not run");
99
+ },
100
+ },
101
+ );
102
+
103
+ expect(result.install.attempted).toBe(false);
104
+ expect(result.restart.attempted).toBe(false);
105
+ });
106
+
107
+ test("reports launchd restart scheduling without blocking on the current daemon process", () => {
108
+ const homeDir = path.join(tempRoot, "home");
109
+ const plistPath = path.join(homeDir, "Library", "LaunchAgents", "com.tokenbuddy.proxyd.plist");
110
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
111
+ fs.writeFileSync(plistPath, "", "utf8");
112
+ const children: string[][] = [];
113
+
114
+ const result = scheduleLaunchAgentRestart({
115
+ platform: "darwin",
116
+ homeDir,
117
+ spawn: ((command: string, args: string[]) => {
118
+ children.push([command, ...args]);
119
+ return { unref: () => undefined };
120
+ }) as unknown as typeof import("child_process").spawn,
121
+ });
122
+
123
+ expect(result).toMatchObject({
124
+ attempted: true,
125
+ scheduled: true,
126
+ method: "launchd",
127
+ plistPath,
128
+ });
129
+ expect(children[0]).toEqual(expect.arrayContaining(["sh", "-c"]));
130
+ expect(children[0][2]).toContain("launchctl kickstart -k");
131
+ });
132
+ });
@@ -0,0 +1,28 @@
1
+ import * as crypto from "crypto";
2
+ import {
3
+ DEFAULT_SELLER_REGISTRY_SIGNATURE_URL,
4
+ DEFAULT_SELLER_REGISTRY_URL,
5
+ signatureUrlForRegistryUrl,
6
+ verifyRegistrySignatureWithKeys
7
+ } from "../src/registry-trust.js";
8
+
9
+ describe("registry trust", () => {
10
+ test("derives the default signature URL", () => {
11
+ expect(signatureUrlForRegistryUrl(DEFAULT_SELLER_REGISTRY_URL)).toBe(DEFAULT_SELLER_REGISTRY_SIGNATURE_URL);
12
+ expect(signatureUrlForRegistryUrl("https://example.test/v1/registry.json")).toBe("https://example.test/v1/registry.sig");
13
+ });
14
+
15
+ test("verifies Ed25519 detached signatures with supplied keys", () => {
16
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
17
+ const registry = JSON.stringify({ version: 1, sellers: [] });
18
+ const signature = crypto.sign(null, Buffer.from(registry, "utf8"), privateKey).toString("base64url");
19
+ const keyId = verifyRegistrySignatureWithKeys(registry, signature, {
20
+ "test-key": publicKey.export({ format: "der", type: "spki" }).toString("base64")
21
+ });
22
+
23
+ expect(keyId).toBe("test-key");
24
+ expect(() => verifyRegistrySignatureWithKeys(`${registry}\n`, signature, {
25
+ "test-key": publicKey.export({ format: "der", type: "spki" }).toString("base64")
26
+ })).toThrow("registry signature verification failed");
27
+ });
28
+ });
@@ -154,6 +154,19 @@ describe("RouteFailover", () => {
154
154
  expect(decision.freshPurchase).toBe(true);
155
155
  });
156
156
 
157
+ test("decide on purchase_failed fails over without scheduling a soft retry", () => {
158
+ const { failover, credit } = buildHarness([{ id: "s1" }, { id: "s2" }]);
159
+ credit.recordPurchase("s1", 1_000_000, 750_000);
160
+ const decision = failover.decide(
161
+ { sellerId: "s1", errorKind: "purchase_failed", errorMessage: "purchase create failed", attempt: 0 },
162
+ 2
163
+ );
164
+
165
+ expect(decision.action).toBe("failover_next");
166
+ expect(decision.reason).toBe("purchase_failed");
167
+ expect(decision.wastedCreditMicros).toBe(750_000);
168
+ });
169
+
157
170
  test("budgetExceeded flag is set when the per-minute purchase budget is exhausted", () => {
158
171
  const { failover, credit } = buildHarness([{ id: "s1" }]);
159
172
  // Burn through the per-minute budget.
@@ -1,4 +1,6 @@
1
- import { RegistryTooLargeError, fetchSellerRegistry } from "../src/seller-catalog.js";
1
+ import * as crypto from "crypto";
2
+ import { RegistryTooLargeError, fetchSellerRegistry, fetchSellerRegistryWithTrust } from "../src/seller-catalog.js";
3
+ import { DEFAULT_SELLER_REGISTRY_URL } from "../src/registry-trust.js";
2
4
 
3
5
  /**
4
6
  * v1.2 §18.9: registry-too-large fallback. The bootstrap returns 413
@@ -46,6 +48,63 @@ describe("RegistryTooLargeError + fetchSellerRegistry 413", () => {
46
48
  ).rejects.toThrow(/registry returned 500/);
47
49
  });
48
50
 
51
+ test("fetchSellerRegistry rejects the default registry when signature is missing", async () => {
52
+ globalThis.fetch = (async (url: string | URL | Request) => {
53
+ const href = String(url);
54
+ if (href.endsWith("/registry.json")) {
55
+ return mockJsonResponse(200, { version: 1, sellers: [] });
56
+ }
57
+ if (href.endsWith("/registry.sig")) {
58
+ return mockJsonResponse(404, { error: "missing" });
59
+ }
60
+ throw new Error(`unexpected fetch ${href}`);
61
+ }) as unknown as typeof globalThis.fetch;
62
+
63
+ await expect(fetchSellerRegistry(DEFAULT_SELLER_REGISTRY_URL)).rejects.toThrow("registry signature returned 404");
64
+ });
65
+
66
+ test("fetchSellerRegistry can explicitly allow unsigned registry for local development", async () => {
67
+ const previous = process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
68
+ process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = "1";
69
+ globalThis.fetch = (async () => mockJsonResponse(200, { version: 1, sellers: [] })) as unknown as typeof globalThis.fetch;
70
+ try {
71
+ await expect(fetchSellerRegistry(DEFAULT_SELLER_REGISTRY_URL)).resolves.toMatchObject({ version: 1, sellers: [] });
72
+ } finally {
73
+ if (previous === undefined) {
74
+ delete process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
75
+ } else {
76
+ process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = previous;
77
+ }
78
+ }
79
+ });
80
+
81
+ test("fetchSellerRegistryWithTrust returns hash and unsigned trust metadata for allowed local development", async () => {
82
+ const previous = process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
83
+ process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = "1";
84
+ const body = JSON.stringify({ version: 1, sellers: [] });
85
+ globalThis.fetch = (async () => new Response(body, {
86
+ status: 200,
87
+ headers: { "Content-Type": "application/json" }
88
+ })) as unknown as typeof globalThis.fetch;
89
+ try {
90
+ const result = await fetchSellerRegistryWithTrust(DEFAULT_SELLER_REGISTRY_URL);
91
+ expect(result.registry).toMatchObject({ version: 1, sellers: [] });
92
+ expect(result.trust).toMatchObject({
93
+ registryUrl: DEFAULT_SELLER_REGISTRY_URL,
94
+ registrySha256: crypto.createHash("sha256").update(body).digest("hex"),
95
+ verified: false
96
+ });
97
+ expect(result.trust.signature).toBeUndefined();
98
+ expect(result.trust.signingKeyId).toBeUndefined();
99
+ } finally {
100
+ if (previous === undefined) {
101
+ delete process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY;
102
+ } else {
103
+ process.env.TB_PROXYD_ALLOW_UNSIGNED_REGISTRY = previous;
104
+ }
105
+ }
106
+ });
107
+
49
108
  test("RegistryTooLargeError is a stable Error subclass with a useful message", () => {
50
109
  const err = new RegistryTooLargeError({
51
110
  status: 413,
@@ -0,0 +1,83 @@
1
+ import { SellerConcurrencyLimiter } from "../src/seller-concurrency-limiter.js";
2
+
3
+ describe("SellerConcurrencyLimiter", () => {
4
+ afterEach(() => {
5
+ jest.useRealTimers();
6
+ });
7
+
8
+ test("returns disabled no-op leases by default", () => {
9
+ const limiter = new SellerConcurrencyLimiter();
10
+ const first = limiter.tryAcquire("seller-a");
11
+ const second = limiter.tryAcquire("seller-a");
12
+
13
+ expect(first).toBeDefined();
14
+ expect(second).toBeDefined();
15
+ expect(limiter.snapshot()).toEqual({
16
+ enabled: false,
17
+ maxInFlightPerSeller: 2,
18
+ leaseTtlMs: 185000,
19
+ active: []
20
+ });
21
+ });
22
+
23
+ test("rejects sellers at the local in-flight limit and releases idempotently", () => {
24
+ const limiter = new SellerConcurrencyLimiter({
25
+ enabled: true,
26
+ maxInFlightPerSeller: 1,
27
+ leaseTtlMs: 10000
28
+ });
29
+
30
+ const lease = limiter.tryAcquire("seller-a");
31
+ expect(lease).toBeDefined();
32
+ expect(limiter.tryAcquire("seller-a")).toBeUndefined();
33
+ expect(limiter.tryAcquire("seller-b")).toBeDefined();
34
+ expect(limiter.snapshot().active).toEqual([
35
+ { sellerId: "seller-a", activeCount: 1 },
36
+ { sellerId: "seller-b", activeCount: 1 }
37
+ ]);
38
+
39
+ lease?.release();
40
+ lease?.release();
41
+
42
+ expect(limiter.tryAcquire("seller-a")).toBeDefined();
43
+ });
44
+
45
+ test("expires leaked leases so sellers become selectable again", () => {
46
+ jest.useFakeTimers();
47
+ const limiter = new SellerConcurrencyLimiter({
48
+ enabled: true,
49
+ maxInFlightPerSeller: 1,
50
+ leaseTtlMs: 1000
51
+ });
52
+
53
+ expect(limiter.tryAcquire("seller-a")).toBeDefined();
54
+ expect(limiter.tryAcquire("seller-a")).toBeUndefined();
55
+
56
+ jest.advanceTimersByTime(1000);
57
+
58
+ expect(limiter.snapshot().active).toEqual([]);
59
+ expect(limiter.tryAcquire("seller-a")).toBeDefined();
60
+ });
61
+
62
+ test("refresh extends an active lease watchdog", () => {
63
+ jest.useFakeTimers();
64
+ const limiter = new SellerConcurrencyLimiter({
65
+ enabled: true,
66
+ maxInFlightPerSeller: 1,
67
+ leaseTtlMs: 1000
68
+ });
69
+
70
+ const lease = limiter.tryAcquire("seller-a");
71
+ expect(lease).toBeDefined();
72
+
73
+ jest.advanceTimersByTime(900);
74
+ lease?.refresh();
75
+ jest.advanceTimersByTime(900);
76
+
77
+ expect(limiter.snapshot().active).toEqual([{ sellerId: "seller-a", activeCount: 1 }]);
78
+
79
+ jest.advanceTimersByTime(100);
80
+
81
+ expect(limiter.snapshot().active).toEqual([]);
82
+ });
83
+ });
@@ -112,6 +112,29 @@ describe("SellerPool", () => {
112
112
  expect(pool.snapshot()[0].circuit).toBe("half_open");
113
113
  });
114
114
 
115
+ test("recycleOpenCircuits refreshes snapshot-based route planning state", () => {
116
+ const clock = makeClock();
117
+ const ctx = build([{ id: "s1" }, { id: "s2" }]);
118
+ const pool = new SellerPool({
119
+ modelIndex: ctx.index,
120
+ cache: ctx.cache,
121
+ creditTracker: ctx.credit,
122
+ failureThreshold: 1,
123
+ openStateMs: 1000,
124
+ now: () => clock.now
125
+ });
126
+ pool.sync();
127
+ pool.recordFailure("s1", "soft_5xx");
128
+
129
+ clock.advance(500);
130
+ expect(pool.recycleOpenCircuits()).toBe(0);
131
+ expect(pool.snapshot().find((entry) => entry.sellerId === "s1")?.circuit).toBe("open");
132
+
133
+ clock.advance(600);
134
+ expect(pool.recycleOpenCircuits()).toBe(1);
135
+ expect(pool.snapshot().find((entry) => entry.sellerId === "s1")?.circuit).toBe("half_open");
136
+ });
137
+
115
138
  test("recordFailure escalates to open after the configured threshold", () => {
116
139
  const clock = makeClock();
117
140
  const ctx = build([{ id: "s1" }]);