@tokenbuddy/tokenbuddy 1.0.26 → 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.
- package/bin/tb-clawtip-proof.js +2 -0
- package/dist/src/clawtip-bootstrap.d.ts +1 -0
- package/dist/src/clawtip-bootstrap.d.ts.map +1 -1
- package/dist/src/clawtip-bootstrap.js +1 -0
- package/dist/src/clawtip-bootstrap.js.map +1 -1
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +172 -51
- package/dist/src/cli.js.map +1 -1
- package/dist/src/daemon.d.ts +6 -0
- package/dist/src/daemon.d.ts.map +1 -1
- package/dist/src/daemon.js +562 -292
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/init-clawtip-activation.d.ts +5 -0
- package/dist/src/init-clawtip-activation.d.ts.map +1 -1
- package/dist/src/init-clawtip-activation.js +61 -1
- package/dist/src/init-clawtip-activation.js.map +1 -1
- package/dist/src/package-update.d.ts +60 -0
- package/dist/src/package-update.d.ts.map +1 -0
- package/dist/src/package-update.js +220 -0
- package/dist/src/package-update.js.map +1 -0
- package/dist/src/registry-trust.d.ts +7 -0
- package/dist/src/registry-trust.d.ts.map +1 -0
- package/dist/src/registry-trust.js +37 -0
- package/dist/src/registry-trust.js.map +1 -0
- package/dist/src/route-failover.d.ts +2 -2
- package/dist/src/route-failover.d.ts.map +1 -1
- package/dist/src/route-failover.js +11 -0
- package/dist/src/route-failover.js.map +1 -1
- package/dist/src/seller-catalog.d.ts +20 -0
- package/dist/src/seller-catalog.d.ts.map +1 -1
- package/dist/src/seller-catalog.js +41 -4
- package/dist/src/seller-catalog.js.map +1 -1
- package/dist/src/seller-concurrency-limiter.d.ts +36 -0
- package/dist/src/seller-concurrency-limiter.d.ts.map +1 -0
- package/dist/src/seller-concurrency-limiter.js +126 -0
- package/dist/src/seller-concurrency-limiter.js.map +1 -0
- package/dist/src/seller-pool.d.ts +7 -1
- package/dist/src/seller-pool.d.ts.map +1 -1
- package/dist/src/seller-pool.js +18 -0
- package/dist/src/seller-pool.js.map +1 -1
- package/dist/src/seller-route-planner.d.ts +21 -0
- package/dist/src/seller-route-planner.d.ts.map +1 -1
- package/dist/src/seller-route-planner.js +98 -20
- package/dist/src/seller-route-planner.js.map +1 -1
- package/dist/src/tb-clawtip-proof.d.ts +3 -0
- package/dist/src/tb-clawtip-proof.d.ts.map +1 -0
- package/dist/src/tb-clawtip-proof.js +24 -0
- package/dist/src/tb-clawtip-proof.js.map +1 -0
- package/dist/src/tb-proxyd.js +45 -3
- package/dist/src/tb-proxyd.js.map +1 -1
- package/package.json +3 -2
- package/src/clawtip-bootstrap.ts +1 -0
- package/src/cli.ts +200 -47
- package/src/daemon.ts +347 -50
- package/src/init-clawtip-activation.ts +77 -1
- package/src/package-update.ts +313 -0
- package/src/registry-trust.ts +51 -0
- package/src/route-failover.ts +14 -2
- package/src/seller-catalog.ts +67 -4
- package/src/seller-concurrency-limiter.ts +161 -0
- package/src/seller-pool.ts +20 -0
- package/src/seller-route-planner.ts +142 -20
- package/src/tb-clawtip-proof.ts +28 -0
- package/src/tb-proxyd.ts +48 -3
- package/static/ui/assets/index-Bzbrp7Qe.css +1 -0
- package/static/ui/assets/index-UAfOhbwC.js +236 -0
- package/static/ui/assets/index-UAfOhbwC.js.map +1 -0
- package/static/ui/index.html +2 -2
- package/tests/cli-routing.test.ts +37 -4
- package/tests/control-plane-ui-endpoints.test.ts +7 -7
- package/tests/daemon-trusted-registry-cache.test.ts +132 -0
- package/tests/e2e.test.ts +14 -1
- package/tests/package-update.test.ts +132 -0
- package/tests/registry-trust.test.ts +28 -0
- package/tests/route-failover.test.ts +13 -0
- package/tests/seller-catalog-413.test.ts +60 -1
- package/tests/seller-concurrency-limiter.test.ts +83 -0
- package/tests/seller-pool.test.ts +23 -0
- package/tests/seller-route-planner.test.ts +78 -0
- package/tests/tokenbuddy.test.ts +316 -34
- package/static/ui/assets/index-1uuyCCzj.css +0 -1
- package/static/ui/assets/index-cm_EgQZ-.js +0 -236
- package/static/ui/assets/index-cm_EgQZ-.js.map +0 -1
package/static/ui/index.html
CHANGED
|
@@ -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-
|
|
16
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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({
|
|
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).
|
|
263
|
-
expect(errors
|
|
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-
|
|
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-
|
|
141
|
-
skillId: "si-tb-
|
|
140
|
+
slug: "tb-registry",
|
|
141
|
+
skillId: "si-tb-registry",
|
|
142
142
|
description: "TokenBuddy ClawTip wallet activation",
|
|
143
|
-
resourceUrl: "https://tb-
|
|
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-
|
|
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-
|
|
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-
|
|
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({
|
|
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
|
|
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" }]);
|