@nexvora/mcp-server 0.3.2 → 0.4.0
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/README.md +15 -13
- package/dist/NexvoraClient.d.ts.map +1 -1
- package/dist/NexvoraClient.js +21 -3
- package/dist/NexvoraClient.js.map +1 -1
- package/dist/cli.js +17 -11
- package/dist/cli.js.map +1 -1
- package/dist/createServer.d.ts +7 -0
- package/dist/createServer.d.ts.map +1 -1
- package/dist/createServer.js +3 -3
- package/dist/createServer.js.map +1 -1
- package/dist/tools/nexvora_submit_task.d.ts +7 -4
- package/dist/tools/nexvora_submit_task.d.ts.map +1 -1
- package/dist/tools/nexvora_submit_task.js +74 -4
- package/dist/tools/nexvora_submit_task.js.map +1 -1
- package/package.json +5 -1
- package/CHANGELOG.md +0 -208
- package/docs/setup/chatgpt-desktop.md +0 -120
- package/docs/setup/claude-code.md +0 -152
- package/docs/setup/cursor.md +0 -129
- package/src/NexvoraClient.ts +0 -328
- package/src/RateLimiter.ts +0 -74
- package/src/__tests__/NexvoraClient.test.ts +0 -424
- package/src/__tests__/RateLimiter.test.ts +0 -151
- package/src/__tests__/auth/oauth.test.ts +0 -246
- package/src/__tests__/cache.test.ts +0 -64
- package/src/__tests__/config.test.ts +0 -98
- package/src/__tests__/defineTool.test.ts +0 -223
- package/src/__tests__/fixtures/config.json +0 -7
- package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
- package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
- package/src/__tests__/integration/consulting.integration.test.ts +0 -213
- package/src/__tests__/integration/feed.integration.test.ts +0 -200
- package/src/__tests__/integration/helpers.ts +0 -118
- package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
- package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
- package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
- package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
- package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
- package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
- package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
- package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
- package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
- package/src/__tests__/nexvora_feed_post.test.ts +0 -147
- package/src/__tests__/nexvora_feed_react.test.ts +0 -98
- package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
- package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
- package/src/__tests__/nexvora_observatory.test.ts +0 -125
- package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
- package/src/auth/oauth.ts +0 -247
- package/src/cache.ts +0 -34
- package/src/cli.ts +0 -171
- package/src/config.ts +0 -70
- package/src/createServer.ts +0 -90
- package/src/defineTool.ts +0 -120
- package/src/index.ts +0 -36
- package/src/server/sse.ts +0 -149
- package/src/tools/nexvora_agentstack_answer.ts +0 -62
- package/src/tools/nexvora_agentstack_ask.ts +0 -70
- package/src/tools/nexvora_agentstack_search.ts +0 -82
- package/src/tools/nexvora_consulting_book.ts +0 -130
- package/src/tools/nexvora_consulting_search.ts +0 -85
- package/src/tools/nexvora_feed_post.ts +0 -69
- package/src/tools/nexvora_feed_react.ts +0 -48
- package/src/tools/nexvora_knowledge_search.ts +0 -81
- package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
- package/src/tools/nexvora_observatory.ts +0 -87
- package/src/tools/nexvora_submit_task.ts +0 -42
- package/src/tools/nexvora_wallet_balance.ts +0 -112
- package/tsconfig.json +0 -19
|
@@ -1,424 +0,0 @@
|
|
|
1
|
-
import { jest } from "@jest/globals";
|
|
2
|
-
|
|
3
|
-
import { type Config, type IConfigStore } from "../config.js";
|
|
4
|
-
import {
|
|
5
|
-
NexvoraApiError,
|
|
6
|
-
NexvoraClient,
|
|
7
|
-
PatRevokedOrExpiredError,
|
|
8
|
-
PatScopeMissingError,
|
|
9
|
-
SessionExpiredError,
|
|
10
|
-
} from "../NexvoraClient.js";
|
|
11
|
-
|
|
12
|
-
const BASE_URL = "https://api.nxvora.online";
|
|
13
|
-
const TOKEN = "test-token-abc";
|
|
14
|
-
|
|
15
|
-
function makeClient(overrides?: Partial<{ baseUrl: string; accessToken: string; agentId?: string }>) {
|
|
16
|
-
return new NexvoraClient({ baseUrl: BASE_URL, accessToken: TOKEN, ...overrides });
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// ── In-memory config store for refresh tests ──────────────────────────────────
|
|
20
|
-
|
|
21
|
-
function makeConfigStore(overrides?: Partial<Config>): IConfigStore & { config: Config } {
|
|
22
|
-
const store = {
|
|
23
|
-
config: {
|
|
24
|
-
accessToken: TOKEN,
|
|
25
|
-
refreshToken: "refresh-token-xyz",
|
|
26
|
-
expiresAt: Math.floor(Date.now() / 1000) + 3600, // valid for 1h by default
|
|
27
|
-
serverUrl: BASE_URL,
|
|
28
|
-
userId: "user-001",
|
|
29
|
-
...overrides,
|
|
30
|
-
} as Config,
|
|
31
|
-
read(): Config {
|
|
32
|
-
return this.config;
|
|
33
|
-
},
|
|
34
|
-
write(c: Config): void {
|
|
35
|
-
this.config = c;
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
return store;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe("NexvoraClient", () => {
|
|
42
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
-
let fetchMock: any;
|
|
44
|
-
|
|
45
|
-
beforeEach(() => {
|
|
46
|
-
fetchMock = jest.fn();
|
|
47
|
-
global.fetch = fetchMock;
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterEach(() => {
|
|
51
|
-
jest.resetAllMocks();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("sendAudit", () => {
|
|
55
|
-
it("posts to /mcp/audit with Bearer token", async () => {
|
|
56
|
-
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
57
|
-
|
|
58
|
-
const client = makeClient();
|
|
59
|
-
await client.sendAudit({ toolName: "nexvora_wallet_balance", outcome: "success" });
|
|
60
|
-
|
|
61
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
62
|
-
`${BASE_URL}/mcp/audit`,
|
|
63
|
-
expect.objectContaining({
|
|
64
|
-
method: "POST",
|
|
65
|
-
headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }),
|
|
66
|
-
}),
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("swallows network errors silently", async () => {
|
|
71
|
-
fetchMock.mockRejectedValueOnce(new Error("Network failure"));
|
|
72
|
-
|
|
73
|
-
const client = makeClient();
|
|
74
|
-
await expect(
|
|
75
|
-
client.sendAudit({ toolName: "nexvora_wallet_balance", outcome: "success" }),
|
|
76
|
-
).resolves.toBeUndefined();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("swallows non-2xx responses silently", async () => {
|
|
80
|
-
fetchMock.mockResolvedValueOnce(new Response("Server Error", { status: 500 }));
|
|
81
|
-
|
|
82
|
-
const client = makeClient();
|
|
83
|
-
await expect(
|
|
84
|
-
client.sendAudit({ toolName: "nexvora_wallet_balance", outcome: "error" }),
|
|
85
|
-
).resolves.toBeUndefined();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("strips trailing slash from baseUrl", async () => {
|
|
89
|
-
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
90
|
-
|
|
91
|
-
const client = new NexvoraClient({ baseUrl: `${BASE_URL}/`, accessToken: TOKEN });
|
|
92
|
-
await client.sendAudit({ toolName: "tool", outcome: "success" });
|
|
93
|
-
|
|
94
|
-
const [url] = fetchMock.mock.calls[0]!;
|
|
95
|
-
expect(url).toBe(`${BASE_URL}/mcp/audit`);
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
describe("post", () => {
|
|
100
|
-
it("returns parsed JSON on 2xx response", async () => {
|
|
101
|
-
const body = { id: "task-123", status: "PENDING" };
|
|
102
|
-
fetchMock.mockResolvedValueOnce(
|
|
103
|
-
new Response(JSON.stringify(body), {
|
|
104
|
-
status: 200,
|
|
105
|
-
headers: { "Content-Type": "application/json" },
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
const client = makeClient();
|
|
110
|
-
const result = await client.post("/tasks", { prompt: "test" });
|
|
111
|
-
|
|
112
|
-
expect(result).toEqual(body);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("throws NexvoraApiError on 4xx", async () => {
|
|
116
|
-
fetchMock.mockResolvedValueOnce(new Response("Not Found", { status: 404 }));
|
|
117
|
-
|
|
118
|
-
const client = makeClient();
|
|
119
|
-
await expect(client.post("/tasks", {})).rejects.toBeInstanceOf(NexvoraApiError);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("throws NexvoraApiError with correct statusCode", async () => {
|
|
123
|
-
fetchMock.mockResolvedValueOnce(new Response("Too Many Requests", { status: 429 }));
|
|
124
|
-
|
|
125
|
-
const client = makeClient();
|
|
126
|
-
try {
|
|
127
|
-
await client.post("/tasks", {});
|
|
128
|
-
} catch (err) {
|
|
129
|
-
expect(err).toBeInstanceOf(NexvoraApiError);
|
|
130
|
-
expect((err as NexvoraApiError).statusCode).toBe(429);
|
|
131
|
-
expect((err as NexvoraApiError).isRateLimited).toBe(true);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
describe("NexvoraApiError.toAuditOutcome", () => {
|
|
137
|
-
it("returns rate_limited for 429", () => {
|
|
138
|
-
const err = new NexvoraApiError(429, "", "/tasks");
|
|
139
|
-
expect(err.toAuditOutcome()).toBe("rate_limited");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("returns unauthorized for 401", () => {
|
|
143
|
-
const err = new NexvoraApiError(401, "", "/tasks");
|
|
144
|
-
expect(err.toAuditOutcome()).toBe("unauthorized");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("returns unauthorized for 403", () => {
|
|
148
|
-
const err = new NexvoraApiError(403, "", "/tasks");
|
|
149
|
-
expect(err.toAuditOutcome()).toBe("unauthorized");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("returns error for 500", () => {
|
|
153
|
-
const err = new NexvoraApiError(500, "", "/tasks");
|
|
154
|
-
expect(err.toAuditOutcome()).toBe("error");
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
describe("token refresh", () => {
|
|
159
|
-
const REFRESH_RESPONSE = {
|
|
160
|
-
accessToken: "new-access-token",
|
|
161
|
-
refreshToken: "new-refresh-token",
|
|
162
|
-
expiresAt: Math.floor(Date.now() / 1000) + 7200,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
it("proactively refreshes when token expires within 60 seconds", async () => {
|
|
166
|
-
const store = makeConfigStore({
|
|
167
|
-
expiresAt: Math.floor(Date.now() / 1000) + 30, // expires in 30s
|
|
168
|
-
});
|
|
169
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: TOKEN, configStore: store });
|
|
170
|
-
|
|
171
|
-
// refresh call then actual GET
|
|
172
|
-
fetchMock
|
|
173
|
-
.mockResolvedValueOnce(
|
|
174
|
-
new Response(JSON.stringify(REFRESH_RESPONSE), {
|
|
175
|
-
status: 200,
|
|
176
|
-
headers: { "Content-Type": "application/json" },
|
|
177
|
-
}),
|
|
178
|
-
)
|
|
179
|
-
.mockResolvedValueOnce(
|
|
180
|
-
new Response(JSON.stringify({ ok: true }), {
|
|
181
|
-
status: 200,
|
|
182
|
-
headers: { "Content-Type": "application/json" },
|
|
183
|
-
}),
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
await client.get("/some-path");
|
|
187
|
-
|
|
188
|
-
// First call should be to /auth/refresh
|
|
189
|
-
const [firstUrl] = fetchMock.mock.calls[0] as [string];
|
|
190
|
-
expect(firstUrl).toBe(`${BASE_URL}/auth/refresh`);
|
|
191
|
-
|
|
192
|
-
// Config store should be updated with new tokens
|
|
193
|
-
expect(store.config.accessToken).toBe("new-access-token");
|
|
194
|
-
expect(store.config.refreshToken).toBe("new-refresh-token");
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("retries original request with new token after 401", async () => {
|
|
198
|
-
const store = makeConfigStore();
|
|
199
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: TOKEN, configStore: store });
|
|
200
|
-
|
|
201
|
-
fetchMock
|
|
202
|
-
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 })) // original request
|
|
203
|
-
.mockResolvedValueOnce(
|
|
204
|
-
new Response(JSON.stringify(REFRESH_RESPONSE), {
|
|
205
|
-
status: 200,
|
|
206
|
-
headers: { "Content-Type": "application/json" },
|
|
207
|
-
}),
|
|
208
|
-
) // refresh
|
|
209
|
-
.mockResolvedValueOnce(
|
|
210
|
-
new Response(JSON.stringify({ data: "result" }), {
|
|
211
|
-
status: 200,
|
|
212
|
-
headers: { "Content-Type": "application/json" },
|
|
213
|
-
}),
|
|
214
|
-
); // retry
|
|
215
|
-
|
|
216
|
-
const result = await client.get<{ data: string }>("/protected");
|
|
217
|
-
|
|
218
|
-
expect(result).toEqual({ data: "result" });
|
|
219
|
-
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
220
|
-
// Retry uses the new token
|
|
221
|
-
const [, retryInit] = fetchMock.mock.calls[2] as [string, RequestInit];
|
|
222
|
-
expect((retryInit.headers as Record<string, string>)["Authorization"]).toBe(
|
|
223
|
-
"Bearer new-access-token",
|
|
224
|
-
);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it("throws SessionExpiredError when refresh endpoint returns 401", async () => {
|
|
228
|
-
const store = makeConfigStore();
|
|
229
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: TOKEN, configStore: store });
|
|
230
|
-
|
|
231
|
-
fetchMock
|
|
232
|
-
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 })) // original
|
|
233
|
-
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 })); // refresh also fails
|
|
234
|
-
|
|
235
|
-
await expect(client.get("/protected")).rejects.toBeInstanceOf(SessionExpiredError);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("SessionExpiredError message tells user to re-login", async () => {
|
|
239
|
-
const store = makeConfigStore();
|
|
240
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: TOKEN, configStore: store });
|
|
241
|
-
|
|
242
|
-
fetchMock
|
|
243
|
-
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }))
|
|
244
|
-
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }));
|
|
245
|
-
|
|
246
|
-
try {
|
|
247
|
-
await client.get("/protected");
|
|
248
|
-
} catch (err) {
|
|
249
|
-
expect((err as Error).message).toContain("nexvora login");
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("deduplicates concurrent refresh requests — only ONE /auth/refresh call", async () => {
|
|
254
|
-
const store = makeConfigStore({
|
|
255
|
-
expiresAt: Math.floor(Date.now() / 1000) + 30, // proactive refresh
|
|
256
|
-
});
|
|
257
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: TOKEN, configStore: store });
|
|
258
|
-
|
|
259
|
-
const dataResponse = () =>
|
|
260
|
-
new Response(JSON.stringify({ ok: true }), {
|
|
261
|
-
status: 200,
|
|
262
|
-
headers: { "Content-Type": "application/json" },
|
|
263
|
-
});
|
|
264
|
-
const refreshResponse = new Response(JSON.stringify(REFRESH_RESPONSE), {
|
|
265
|
-
status: 200,
|
|
266
|
-
headers: { "Content-Type": "application/json" },
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// One refresh + three data responses
|
|
270
|
-
fetchMock
|
|
271
|
-
.mockResolvedValueOnce(refreshResponse)
|
|
272
|
-
.mockResolvedValueOnce(dataResponse())
|
|
273
|
-
.mockResolvedValueOnce(dataResponse())
|
|
274
|
-
.mockResolvedValueOnce(dataResponse());
|
|
275
|
-
|
|
276
|
-
// Fire three concurrent requests
|
|
277
|
-
await Promise.all([
|
|
278
|
-
client.get("/path-a"),
|
|
279
|
-
client.get("/path-b"),
|
|
280
|
-
client.get("/path-c"),
|
|
281
|
-
]);
|
|
282
|
-
|
|
283
|
-
const refreshCalls = (fetchMock.mock.calls as [string][]).filter(([url]) =>
|
|
284
|
-
url.endsWith("/auth/refresh"),
|
|
285
|
-
);
|
|
286
|
-
expect(refreshCalls).toHaveLength(1);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("does not attempt refresh when no configStore is provided", async () => {
|
|
290
|
-
// Client without configStore
|
|
291
|
-
const client = makeClient();
|
|
292
|
-
|
|
293
|
-
fetchMock.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }));
|
|
294
|
-
|
|
295
|
-
// Should throw NexvoraApiError (not attempt refresh)
|
|
296
|
-
await expect(client.get("/protected")).rejects.toBeInstanceOf(NexvoraApiError);
|
|
297
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
298
|
-
});
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
// ── PAT (Personal Access Token) authentication path ─────────────────────────
|
|
302
|
-
describe("PAT auth path", () => {
|
|
303
|
-
const PAT = "nxv_pat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA111";
|
|
304
|
-
|
|
305
|
-
it("PAT path skips token refresh entirely (no /auth/refresh call)", async () => {
|
|
306
|
-
// Even with an expired config + a config store present, a PAT must never
|
|
307
|
-
// hit /auth/refresh — refresh tokens belong to the OAuth path only.
|
|
308
|
-
const store = makeConfigStore({ expiresAt: 0 }); // expired
|
|
309
|
-
fetchMock.mockResolvedValueOnce(
|
|
310
|
-
new Response(JSON.stringify({ balance: 1000 }), {
|
|
311
|
-
status: 200,
|
|
312
|
-
headers: { "Content-Type": "application/json" },
|
|
313
|
-
}),
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
const client = new NexvoraClient({
|
|
317
|
-
baseUrl: BASE_URL,
|
|
318
|
-
accessToken: PAT,
|
|
319
|
-
configStore: store,
|
|
320
|
-
});
|
|
321
|
-
await client.get("/wallet");
|
|
322
|
-
|
|
323
|
-
const calledUrls = fetchMock.mock.calls.map((c: unknown[]) => c[0]);
|
|
324
|
-
expect(calledUrls).toEqual([`${BASE_URL}/wallet`]);
|
|
325
|
-
expect(calledUrls).not.toContain(`${BASE_URL}/auth/refresh`);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("PAT 401 throws PatRevokedOrExpiredError pointing to regen URL", async () => {
|
|
329
|
-
fetchMock.mockResolvedValueOnce(
|
|
330
|
-
new Response("Unauthorized", { status: 401 }),
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: PAT });
|
|
334
|
-
const error = await client.get("/wallet").catch((e: Error) => e);
|
|
335
|
-
|
|
336
|
-
expect(error).toBeInstanceOf(PatRevokedOrExpiredError);
|
|
337
|
-
expect(error.message).toContain(
|
|
338
|
-
"https://app.nxvora.online/app/settings/mcp-tokens",
|
|
339
|
-
);
|
|
340
|
-
// No refresh attempted — exactly one fetch
|
|
341
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("PAT 403 with type=pat-scope-missing throws PatScopeMissingError", async () => {
|
|
345
|
-
fetchMock.mockResolvedValueOnce(
|
|
346
|
-
new Response(
|
|
347
|
-
JSON.stringify({
|
|
348
|
-
type: "https://nxvora.online/errors/pat-scope-missing",
|
|
349
|
-
required_scope: "tool:submit_task",
|
|
350
|
-
detail: "This token lacks the required scope: tool:submit_task",
|
|
351
|
-
}),
|
|
352
|
-
{
|
|
353
|
-
status: 403,
|
|
354
|
-
headers: { "Content-Type": "application/problem+json" },
|
|
355
|
-
},
|
|
356
|
-
),
|
|
357
|
-
);
|
|
358
|
-
|
|
359
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: PAT });
|
|
360
|
-
const error = await client.post("/tasks", { prompt: "x" }).catch(
|
|
361
|
-
(e: Error) => e,
|
|
362
|
-
);
|
|
363
|
-
|
|
364
|
-
expect(error).toBeInstanceOf(PatScopeMissingError);
|
|
365
|
-
expect((error as PatScopeMissingError).requiredScope).toBe(
|
|
366
|
-
"tool:submit_task",
|
|
367
|
-
);
|
|
368
|
-
expect(error.message).toContain("tool:submit_task");
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("PAT 403 without pat-scope-missing falls through to NexvoraApiError", async () => {
|
|
372
|
-
// A generic 403 (not scope-related) should still surface as the standard
|
|
373
|
-
// NexvoraApiError so the existing tool-side rate-limit / unauthorized
|
|
374
|
-
// branching keeps working.
|
|
375
|
-
fetchMock.mockResolvedValueOnce(
|
|
376
|
-
new Response("Forbidden", { status: 403 }),
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
const client = new NexvoraClient({ baseUrl: BASE_URL, accessToken: PAT });
|
|
380
|
-
await expect(client.get("/wallet")).rejects.toBeInstanceOf(
|
|
381
|
-
NexvoraApiError,
|
|
382
|
-
);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it("JWT 401 still triggers refresh (regression guard)", async () => {
|
|
386
|
-
// Config is fresh (expiresAt in the future) so ensureTokenFresh does NOT
|
|
387
|
-
// proactively refresh. The 401 from the backend triggers the *reactive*
|
|
388
|
-
// refresh path — this is the regression we're guarding against.
|
|
389
|
-
const store = makeConfigStore();
|
|
390
|
-
// Initial request returns 401, refresh succeeds, retry returns 200.
|
|
391
|
-
fetchMock
|
|
392
|
-
.mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }))
|
|
393
|
-
.mockResolvedValueOnce(
|
|
394
|
-
new Response(
|
|
395
|
-
JSON.stringify({
|
|
396
|
-
accessToken: "new-jwt",
|
|
397
|
-
refreshToken: "new-refresh",
|
|
398
|
-
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
399
|
-
}),
|
|
400
|
-
{
|
|
401
|
-
status: 200,
|
|
402
|
-
headers: { "Content-Type": "application/json" },
|
|
403
|
-
},
|
|
404
|
-
),
|
|
405
|
-
)
|
|
406
|
-
.mockResolvedValueOnce(
|
|
407
|
-
new Response(JSON.stringify({ ok: true }), {
|
|
408
|
-
status: 200,
|
|
409
|
-
headers: { "Content-Type": "application/json" },
|
|
410
|
-
}),
|
|
411
|
-
);
|
|
412
|
-
|
|
413
|
-
const client = new NexvoraClient({
|
|
414
|
-
baseUrl: BASE_URL,
|
|
415
|
-
accessToken: "test-jwt",
|
|
416
|
-
configStore: store,
|
|
417
|
-
});
|
|
418
|
-
const result = await client.get<{ ok: boolean }>("/wallet");
|
|
419
|
-
expect(result).toEqual({ ok: true });
|
|
420
|
-
// Three calls: original 401, /auth/refresh, retry
|
|
421
|
-
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
422
|
-
});
|
|
423
|
-
});
|
|
424
|
-
});
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import { jest } from "@jest/globals";
|
|
2
|
-
|
|
3
|
-
import { DEFAULT_RATE_LIMITS, RateLimiterRegistry, TokenBucket } from "../RateLimiter.js";
|
|
4
|
-
|
|
5
|
-
// ── TokenBucket ────────────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
describe("TokenBucket", () => {
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
jest.useFakeTimers({ now: 1_000_000_000_000 });
|
|
10
|
-
});
|
|
11
|
-
afterEach(() => {
|
|
12
|
-
jest.useRealTimers();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("allows the first call when bucket is full", () => {
|
|
16
|
-
const bucket = new TokenBucket(5, 5 / 60);
|
|
17
|
-
expect(bucket.tryConsume()).toEqual({ allowed: true });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("allows up to capacity calls before exhausting", () => {
|
|
21
|
-
const bucket = new TokenBucket(3, 3 / 60);
|
|
22
|
-
expect(bucket.tryConsume().allowed).toBe(true);
|
|
23
|
-
expect(bucket.tryConsume().allowed).toBe(true);
|
|
24
|
-
expect(bucket.tryConsume().allowed).toBe(true);
|
|
25
|
-
expect(bucket.tryConsume().allowed).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("returns retryAfterMs > 0 when exhausted", () => {
|
|
29
|
-
const bucket = new TokenBucket(1, 1 / 60);
|
|
30
|
-
bucket.tryConsume(); // consume the only token
|
|
31
|
-
const result = bucket.tryConsume();
|
|
32
|
-
expect(result.allowed).toBe(false);
|
|
33
|
-
if (!result.allowed) {
|
|
34
|
-
expect(result.retryAfterMs).toBeGreaterThan(0);
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("refills tokens after time passes", () => {
|
|
39
|
-
const bucket = new TokenBucket(5, 5 / 60); // 5/min → 1 token per 12s
|
|
40
|
-
// drain all 5 tokens
|
|
41
|
-
for (let i = 0; i < 5; i++) bucket.tryConsume();
|
|
42
|
-
expect(bucket.tryConsume().allowed).toBe(false);
|
|
43
|
-
|
|
44
|
-
// advance 15 seconds (>12s → should have > 1 token)
|
|
45
|
-
jest.advanceTimersByTime(15_000);
|
|
46
|
-
expect(bucket.tryConsume().allowed).toBe(true);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("does not refill above capacity", () => {
|
|
50
|
-
const bucket = new TokenBucket(3, 3 / 60);
|
|
51
|
-
// advance 10 minutes — should not exceed capacity of 3
|
|
52
|
-
jest.advanceTimersByTime(600_000);
|
|
53
|
-
// consume 3 (capacity) — all should be allowed
|
|
54
|
-
expect(bucket.tryConsume().allowed).toBe(true);
|
|
55
|
-
expect(bucket.tryConsume().allowed).toBe(true);
|
|
56
|
-
expect(bucket.tryConsume().allowed).toBe(true);
|
|
57
|
-
expect(bucket.tryConsume().allowed).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("retryAfterMs decreases as time passes", () => {
|
|
61
|
-
const bucket = new TokenBucket(1, 1 / 60); // 1/min → refills 1 token per 60s
|
|
62
|
-
bucket.tryConsume(); // drain
|
|
63
|
-
|
|
64
|
-
const before = bucket.tryConsume();
|
|
65
|
-
expect(before.allowed).toBe(false);
|
|
66
|
-
const retryBefore = before.allowed ? 0 : before.retryAfterMs;
|
|
67
|
-
|
|
68
|
-
jest.advanceTimersByTime(10_000); // advance 10s
|
|
69
|
-
|
|
70
|
-
const after = bucket.tryConsume();
|
|
71
|
-
expect(after.allowed).toBe(false);
|
|
72
|
-
const retryAfter = after.allowed ? 0 : after.retryAfterMs;
|
|
73
|
-
|
|
74
|
-
expect(retryAfter).toBeLessThan(retryBefore);
|
|
75
|
-
});
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// ── RateLimiterRegistry ────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
describe("RateLimiterRegistry", () => {
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
jest.useFakeTimers({ now: 1_000_000_000_000 });
|
|
83
|
-
});
|
|
84
|
-
afterEach(() => {
|
|
85
|
-
jest.useRealTimers();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("always allows tools not in the registry", () => {
|
|
89
|
-
const registry = new RateLimiterRegistry({});
|
|
90
|
-
expect(registry.tryConsume("unknown_tool")).toEqual({ allowed: true });
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("enforces limits for tools in the registry", () => {
|
|
94
|
-
const registry = new RateLimiterRegistry({ my_tool: 2 });
|
|
95
|
-
expect(registry.tryConsume("my_tool").allowed).toBe(true);
|
|
96
|
-
expect(registry.tryConsume("my_tool").allowed).toBe(true);
|
|
97
|
-
expect(registry.tryConsume("my_tool").allowed).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("isolates buckets per tool — exhausting one does not affect another", () => {
|
|
101
|
-
const registry = new RateLimiterRegistry({ tool_a: 1, tool_b: 1 });
|
|
102
|
-
registry.tryConsume("tool_a"); // exhaust tool_a
|
|
103
|
-
expect(registry.tryConsume("tool_a").allowed).toBe(false);
|
|
104
|
-
expect(registry.tryConsume("tool_b").allowed).toBe(true); // tool_b unaffected
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("refills after time passes", () => {
|
|
108
|
-
const registry = new RateLimiterRegistry({ tight_tool: 1 }); // 1/min = 1 token per 60s
|
|
109
|
-
registry.tryConsume("tight_tool"); // drain
|
|
110
|
-
expect(registry.tryConsume("tight_tool").allowed).toBe(false);
|
|
111
|
-
|
|
112
|
-
jest.advanceTimersByTime(65_000); // 65s > 60s refill period
|
|
113
|
-
expect(registry.tryConsume("tight_tool").allowed).toBe(true);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("DEFAULT_RATE_LIMITS contains all 11 required tools", () => {
|
|
117
|
-
const required = [
|
|
118
|
-
"nexvora_wallet_balance",
|
|
119
|
-
"nexvora_observatory",
|
|
120
|
-
"nexvora_agentstack_search",
|
|
121
|
-
"nexvora_agentstack_ask",
|
|
122
|
-
"nexvora_agentstack_answer",
|
|
123
|
-
"nexvora_feed_post",
|
|
124
|
-
"nexvora_feed_react",
|
|
125
|
-
"nexvora_consulting_search",
|
|
126
|
-
"nexvora_consulting_book",
|
|
127
|
-
"nexvora_knowledge_search",
|
|
128
|
-
"nexvora_knowledge_subscribe",
|
|
129
|
-
];
|
|
130
|
-
for (const tool of required) {
|
|
131
|
-
expect(DEFAULT_RATE_LIMITS).toHaveProperty(tool);
|
|
132
|
-
expect(DEFAULT_RATE_LIMITS[tool]).toBeGreaterThan(0);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("uses DEFAULT_RATE_LIMITS when constructed with no arguments", () => {
|
|
137
|
-
const registry = new RateLimiterRegistry();
|
|
138
|
-
// nexvora_agentstack_ask has limit 5 — exhaust and verify
|
|
139
|
-
for (let i = 0; i < 5; i++) {
|
|
140
|
-
expect(registry.tryConsume("nexvora_agentstack_ask").allowed).toBe(true);
|
|
141
|
-
}
|
|
142
|
-
expect(registry.tryConsume("nexvora_agentstack_ask").allowed).toBe(false);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("allows overriding individual limits via constructor", () => {
|
|
146
|
-
const registry = new RateLimiterRegistry({ nexvora_feed_post: 2 });
|
|
147
|
-
expect(registry.tryConsume("nexvora_feed_post").allowed).toBe(true);
|
|
148
|
-
expect(registry.tryConsume("nexvora_feed_post").allowed).toBe(true);
|
|
149
|
-
expect(registry.tryConsume("nexvora_feed_post").allowed).toBe(false);
|
|
150
|
-
});
|
|
151
|
-
});
|