@nexvora/mcp-server 0.3.1 → 0.3.3

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 (66) hide show
  1. package/README.md +15 -13
  2. package/dist/NexvoraClient.d.ts.map +1 -1
  3. package/dist/NexvoraClient.js +21 -3
  4. package/dist/NexvoraClient.js.map +1 -1
  5. package/dist/cli.d.ts +2 -2
  6. package/dist/cli.js +26 -20
  7. package/dist/cli.js.map +1 -1
  8. package/dist/createServer.d.ts +7 -0
  9. package/dist/createServer.d.ts.map +1 -1
  10. package/dist/createServer.js +3 -3
  11. package/dist/createServer.js.map +1 -1
  12. package/package.json +6 -2
  13. package/CHANGELOG.md +0 -208
  14. package/docs/setup/chatgpt-desktop.md +0 -120
  15. package/docs/setup/claude-code.md +0 -152
  16. package/docs/setup/cursor.md +0 -129
  17. package/src/NexvoraClient.ts +0 -328
  18. package/src/RateLimiter.ts +0 -74
  19. package/src/__tests__/NexvoraClient.test.ts +0 -424
  20. package/src/__tests__/RateLimiter.test.ts +0 -151
  21. package/src/__tests__/auth/oauth.test.ts +0 -246
  22. package/src/__tests__/cache.test.ts +0 -64
  23. package/src/__tests__/config.test.ts +0 -98
  24. package/src/__tests__/defineTool.test.ts +0 -223
  25. package/src/__tests__/fixtures/config.json +0 -7
  26. package/src/__tests__/integration/agentstack.integration.test.ts +0 -259
  27. package/src/__tests__/integration/auth_refresh.integration.test.ts +0 -227
  28. package/src/__tests__/integration/consulting.integration.test.ts +0 -213
  29. package/src/__tests__/integration/feed.integration.test.ts +0 -200
  30. package/src/__tests__/integration/helpers.ts +0 -118
  31. package/src/__tests__/integration/knowledge.integration.test.ts +0 -194
  32. package/src/__tests__/integration/rate_limiting.integration.test.ts +0 -207
  33. package/src/__tests__/integration/submit_task.integration.test.ts +0 -120
  34. package/src/__tests__/integration/wallet_observatory.integration.test.ts +0 -240
  35. package/src/__tests__/nexvora_agentstack_answer.test.ts +0 -120
  36. package/src/__tests__/nexvora_agentstack_ask.test.ts +0 -140
  37. package/src/__tests__/nexvora_agentstack_search.test.ts +0 -188
  38. package/src/__tests__/nexvora_consulting_book.test.ts +0 -277
  39. package/src/__tests__/nexvora_consulting_search.test.ts +0 -153
  40. package/src/__tests__/nexvora_feed_post.test.ts +0 -147
  41. package/src/__tests__/nexvora_feed_react.test.ts +0 -98
  42. package/src/__tests__/nexvora_knowledge_search.test.ts +0 -148
  43. package/src/__tests__/nexvora_knowledge_subscribe.test.ts +0 -173
  44. package/src/__tests__/nexvora_observatory.test.ts +0 -125
  45. package/src/__tests__/nexvora_wallet_balance.test.ts +0 -165
  46. package/src/auth/oauth.ts +0 -247
  47. package/src/cache.ts +0 -34
  48. package/src/cli.ts +0 -171
  49. package/src/config.ts +0 -70
  50. package/src/createServer.ts +0 -90
  51. package/src/defineTool.ts +0 -120
  52. package/src/index.ts +0 -36
  53. package/src/server/sse.ts +0 -149
  54. package/src/tools/nexvora_agentstack_answer.ts +0 -62
  55. package/src/tools/nexvora_agentstack_ask.ts +0 -70
  56. package/src/tools/nexvora_agentstack_search.ts +0 -82
  57. package/src/tools/nexvora_consulting_book.ts +0 -130
  58. package/src/tools/nexvora_consulting_search.ts +0 -85
  59. package/src/tools/nexvora_feed_post.ts +0 -69
  60. package/src/tools/nexvora_feed_react.ts +0 -48
  61. package/src/tools/nexvora_knowledge_search.ts +0 -81
  62. package/src/tools/nexvora_knowledge_subscribe.ts +0 -90
  63. package/src/tools/nexvora_observatory.ts +0 -87
  64. package/src/tools/nexvora_submit_task.ts +0 -42
  65. package/src/tools/nexvora_wallet_balance.ts +0 -112
  66. 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
- });