@aprovan/chat-backend 0.1.0-dev.93c7b6a → 0.1.0-dev.9c336a0

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.
@@ -3,6 +3,9 @@ import {
3
3
  getGatewaySession,
4
4
  evictGatewaySession,
5
5
  resetGatewaySessionCache,
6
+ getCachedTools,
7
+ setCachedTools,
8
+ evictCachedTools,
6
9
  GatewaySessionError,
7
10
  } from "../src/gateway-session";
8
11
  import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
@@ -129,4 +132,80 @@ describe("getGatewaySession", () => {
129
132
 
130
133
  expect(fetchSpy).toHaveBeenCalledTimes(2);
131
134
  });
135
+
136
+ it("retries once after 200ms on 5xx and returns session if retry succeeds", async () => {
137
+ const fetchSpy = vi.spyOn(globalThis, "fetch")
138
+ .mockResolvedValueOnce(new Response("Service Unavailable", { status: 503 }))
139
+ .mockResolvedValueOnce(
140
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
141
+ );
142
+
143
+ const session = await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
144
+ expect(session.token).toBe(COGNITO_TOKEN);
145
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
146
+ });
147
+
148
+ it("throws GatewaySessionError on persistent 5xx after retry", async () => {
149
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
150
+ new Response("Service Unavailable", { status: 503 }),
151
+ );
152
+
153
+ await expect(
154
+ getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN),
155
+ ).rejects.toThrow(GatewaySessionError);
156
+ });
157
+
158
+ it("retries once after 200ms on network error and returns session if retry succeeds", async () => {
159
+ const fetchSpy = vi.spyOn(globalThis, "fetch")
160
+ .mockRejectedValueOnce(new TypeError("fetch failed"))
161
+ .mockResolvedValueOnce(
162
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
163
+ );
164
+
165
+ const session = await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
166
+ expect(session.token).toBe(COGNITO_TOKEN);
167
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
168
+ });
169
+ });
170
+
171
+ describe("tool cache", () => {
172
+ beforeEach(() => {
173
+ resetGatewaySessionCache();
174
+ });
175
+
176
+ afterEach(() => {
177
+ resetGatewaySessionCache();
178
+ });
179
+
180
+ it("getCachedTools returns undefined when nothing cached", () => {
181
+ expect(getCachedTools("sub-123")).toBeUndefined();
182
+ });
183
+
184
+ it("setCachedTools stores tools and getCachedTools retrieves them", () => {
185
+ const tools = [{ name: "github_repos_list" }];
186
+ setCachedTools("sub-123", tools);
187
+ expect(getCachedTools("sub-123")).toEqual(tools);
188
+ });
189
+
190
+ it("evictCachedTools removes tools for a sub", () => {
191
+ setCachedTools("sub-123", [{ name: "github_repos_list" }]);
192
+ evictCachedTools("sub-123");
193
+ expect(getCachedTools("sub-123")).toBeUndefined();
194
+ });
195
+
196
+ it("evictGatewaySession also evicts the tool cache for that sub", async () => {
197
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
198
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
199
+ );
200
+ await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
201
+ setCachedTools(USER_SUB, [{ name: "tool" }]);
202
+ evictGatewaySession(USER_SUB);
203
+ expect(getCachedTools(USER_SUB)).toBeUndefined();
204
+ });
205
+
206
+ it("resetGatewaySessionCache also clears the tool cache", () => {
207
+ setCachedTools("sub-a", [{ name: "tool" }]);
208
+ resetGatewaySessionCache();
209
+ expect(getCachedTools("sub-a")).toBeUndefined();
210
+ });
132
211
  });
@@ -14,9 +14,10 @@ vi.mock("@aws-sdk/lib-dynamodb", () => ({
14
14
  from: vi.fn(() => ({ send: mockSend })),
15
15
  },
16
16
  QueryCommand: vi.fn((input) => input),
17
+ GetCommand: vi.fn((input) => input),
17
18
  }));
18
19
 
19
- const { workspaceMiddleware, resolveWorkspaceId } = await import(
20
+ const { workspaceMiddleware, resolveWorkspaceId, evictWorkspaceCache } = await import(
20
21
  "../../src/middleware/workspace"
21
22
  );
22
23
 
@@ -41,15 +42,70 @@ describe("workspaceMiddleware", () => {
41
42
  beforeEach(() => {
42
43
  vi.clearAllMocks();
43
44
  process.env["MEMBERSHIPS_TABLE_NAME"] = "gateway-prd-use1-memberships";
45
+ process.env["USERS_TABLE_NAME"] = "gateway-prd-use1-users";
44
46
  process.env["AWS_REGION"] = "us-east-1";
45
- // Clear the module-level cache between tests by resetting the module state.
46
- // resolveWorkspaceId memoizes per sub; tests use unique subs or clear cache.
47
+ // Clear module-level cache between tests
48
+ evictWorkspaceCache("user-sub-123");
49
+ evictWorkspaceCache("user-sub-456");
50
+ evictWorkspaceCache("user-no-ws");
51
+ evictWorkspaceCache("user-cached");
52
+ evictWorkspaceCache("user-users-table");
53
+ evictWorkspaceCache("user-fallback");
47
54
  });
48
55
 
49
- it("resolves workspaceId from DDB and sets it on context", async () => {
56
+ it("uses activeWorkspaceId from Users table when present", async () => {
57
+ // Users table returns activeWorkspaceId — no Memberships query needed
50
58
  mockSend.mockResolvedValueOnce({
51
- Items: [{ workspaceId: "ws-abc", userSub: "user-sub-456" }],
59
+ Item: { sub: "user-users-table", activeWorkspaceId: "ws-from-users" },
60
+ });
61
+
62
+ const app = new Hono<{ Variables: AppVariables }>();
63
+ const localClaims = { sub: "user-users-table" } as unknown as CognitoAccessTokenPayload;
64
+ app.use("/protected", async (c, next) => {
65
+ c.set("claims", localClaims);
66
+ await next();
67
+ });
68
+ app.use("/protected", workspaceMiddleware);
69
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
70
+
71
+ const res = await app.request("/protected");
72
+ expect(res.status).toBe(200);
73
+ const body = await res.json();
74
+ expect(body).toEqual({ workspaceId: "ws-from-users" });
75
+ // Only one DDB call (Users Get), no Memberships query
76
+ expect(mockSend).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("falls back to Memberships when Users table has no activeWorkspaceId", async () => {
80
+ // Users Get returns no item; Memberships query returns ws-from-memberships
81
+ mockSend
82
+ .mockResolvedValueOnce({}) // Users GetCommand → no item
83
+ .mockResolvedValueOnce({
84
+ Items: [{ workspaceId: "ws-from-memberships", userSub: "user-fallback" }],
85
+ }); // Memberships QueryCommand
86
+
87
+ const app = new Hono<{ Variables: AppVariables }>();
88
+ const localClaims = { sub: "user-fallback" } as unknown as CognitoAccessTokenPayload;
89
+ app.use("/protected", async (c, next) => {
90
+ c.set("claims", localClaims);
91
+ await next();
52
92
  });
93
+ app.use("/protected", workspaceMiddleware);
94
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
95
+
96
+ const res = await app.request("/protected");
97
+ expect(res.status).toBe(200);
98
+ const body = await res.json();
99
+ expect(body).toEqual({ workspaceId: "ws-from-memberships" });
100
+ expect(mockSend).toHaveBeenCalledTimes(2);
101
+ });
102
+
103
+ it("resolves workspaceId from DDB and sets it on context", async () => {
104
+ mockSend
105
+ .mockResolvedValueOnce({}) // Users GetCommand → no item
106
+ .mockResolvedValueOnce({
107
+ Items: [{ workspaceId: "ws-abc", userSub: "user-sub-456" }],
108
+ });
53
109
 
54
110
  const app = new Hono<{ Variables: AppVariables }>();
55
111
  const localClaims = { sub: "user-sub-456" } as unknown as CognitoAccessTokenPayload;
@@ -66,8 +122,10 @@ describe("workspaceMiddleware", () => {
66
122
  expect(body).toEqual({ workspaceId: "ws-abc" });
67
123
  });
68
124
 
69
- it("returns 403 when no membership exists", async () => {
70
- mockSend.mockResolvedValueOnce({ Items: [] });
125
+ it("returns 403 when no membership exists and Users table has no activeWorkspaceId", async () => {
126
+ mockSend
127
+ .mockResolvedValueOnce({}) // Users GetCommand → no item
128
+ .mockResolvedValueOnce({ Items: [] }); // Memberships QueryCommand → empty
71
129
 
72
130
  const app = new Hono<{ Variables: AppVariables }>();
73
131
  const localClaims = {
@@ -88,7 +146,7 @@ describe("workspaceMiddleware", () => {
88
146
 
89
147
  it("uses cache on subsequent calls for the same sub", async () => {
90
148
  mockSend.mockResolvedValueOnce({
91
- Items: [{ workspaceId: "ws-cached", userSub: "user-cached" }],
149
+ Item: { activeWorkspaceId: "ws-cached" },
92
150
  });
93
151
 
94
152
  // First call — hits DDB
@@ -98,4 +156,17 @@ describe("workspaceMiddleware", () => {
98
156
 
99
157
  expect(mockSend).toHaveBeenCalledTimes(1);
100
158
  });
159
+
160
+ it("evictWorkspaceCache clears the cache so next call re-fetches", async () => {
161
+ mockSend
162
+ .mockResolvedValueOnce({ Item: { activeWorkspaceId: "ws-v1" } })
163
+ .mockResolvedValueOnce({ Item: { activeWorkspaceId: "ws-v2" } });
164
+
165
+ await resolveWorkspaceId("user-cached");
166
+ evictWorkspaceCache("user-cached");
167
+ const result = await resolveWorkspaceId("user-cached");
168
+
169
+ expect(mockSend).toHaveBeenCalledTimes(2);
170
+ expect(result).toBe("ws-v2");
171
+ });
101
172
  });
@@ -12,6 +12,8 @@ const {
12
12
  mockProviderFactory,
13
13
  mockGetGatewaySession,
14
14
  mockEvictGatewaySession,
15
+ mockGetCachedTools,
16
+ mockSetCachedTools,
15
17
  mockGetPrompt,
16
18
  mockCompilePrompt,
17
19
  mockGetPostHogClient,
@@ -25,6 +27,8 @@ const {
25
27
  mockProviderFactory,
26
28
  mockGetGatewaySession: vi.fn(),
27
29
  mockEvictGatewaySession: vi.fn(),
30
+ mockGetCachedTools: vi.fn().mockReturnValue(undefined),
31
+ mockSetCachedTools: vi.fn(),
28
32
  mockGetPrompt: vi.fn().mockResolvedValue({
29
33
  source: "code_fallback",
30
34
  prompt: "System: {{compilers}} {{tool_docs}}",
@@ -51,6 +55,8 @@ vi.mock("../../src/providers/openrouter.js", () => ({
51
55
  vi.mock("../../src/gateway-session.js", () => ({
52
56
  getGatewaySession: mockGetGatewaySession,
53
57
  evictGatewaySession: mockEvictGatewaySession,
58
+ getCachedTools: mockGetCachedTools,
59
+ setCachedTools: mockSetCachedTools,
54
60
  GatewaySessionError: class GatewaySessionError extends Error {
55
61
  status: number;
56
62
  constructor(status: number, message: string) {
@@ -182,6 +188,8 @@ describe("POST /chat", () => {
182
188
  mockProviderFactory.mockReturnValue(mockModel);
183
189
  mockCreateOpenRouterProvider.mockReturnValue(mockProviderFactory);
184
190
  mockGetOpenRouterKey.mockResolvedValue("test-key");
191
+ mockGetCachedTools.mockReturnValue(undefined);
192
+ mockSetCachedTools.mockReset();
185
193
 
186
194
  // Reset prompt + tool-docs mocks to defaults
187
195
  mockGetPrompt.mockResolvedValue({
@@ -299,6 +307,55 @@ describe("POST /chat", () => {
299
307
  expect(mockDoStream).toHaveBeenCalledTimes(1);
300
308
  });
301
309
 
310
+ it("returns 403 when requested model is not in workspace.limits.maxModels", async () => {
311
+ const app = buildApp();
312
+ const res = await app.request("/chat", {
313
+ method: "POST",
314
+ headers: validHeaders,
315
+ body: JSON.stringify({
316
+ id: "chat-1",
317
+ messages: [{ role: "user", parts: [{ type: "text", text: "Hello" }], id: "msg-1" }],
318
+ trigger: "submit-message",
319
+ model: "anthropic/claude-opus-4",
320
+ }),
321
+ });
322
+ expect(res.status).toBe(403);
323
+ const body = await res.json();
324
+ expect(body).toMatchObject({ error: expect.stringContaining("Model not allowed") });
325
+ expect(mockDoStream).not.toHaveBeenCalled();
326
+ });
327
+
328
+ it("uses the requested model when it is in workspace.limits.maxModels", async () => {
329
+ mockDoStream.mockResolvedValueOnce({
330
+ stream: makeSuccessStream(),
331
+ rawResponse: { headers: {} },
332
+ });
333
+
334
+ const proWorkspace: WorkspaceItem = {
335
+ ...fakeWorkspace,
336
+ plan: "pro",
337
+ limits: {
338
+ ...fakeWorkspace.limits,
339
+ maxModels: ["openrouter/auto", "anthropic/claude-opus-4"],
340
+ },
341
+ };
342
+
343
+ const app = buildApp(proWorkspace);
344
+ const res = await app.request("/chat", {
345
+ method: "POST",
346
+ headers: validHeaders,
347
+ body: JSON.stringify({
348
+ id: "chat-1",
349
+ messages: [{ role: "user", parts: [{ type: "text", text: "Hello" }], id: "msg-1" }],
350
+ trigger: "submit-message",
351
+ model: "anthropic/claude-opus-4",
352
+ }),
353
+ });
354
+
355
+ expect(res.status).toBe(200);
356
+ expect(mockProviderFactory).toHaveBeenCalledWith("anthropic/claude-opus-4");
357
+ });
358
+
302
359
  it("selects model from workspace.limits.maxModels[0]", async () => {
303
360
  mockDoStream.mockResolvedValueOnce({
304
361
  stream: makeSuccessStream(),
@@ -484,5 +541,47 @@ describe("POST /chat", () => {
484
541
  expect.any(String),
485
542
  );
486
543
  });
544
+
545
+ it("uses cached tools when available — skips gateway fetch", async () => {
546
+ mockGetCachedTools.mockReturnValue(MOCK_TOOLS_RESPONSE.tools);
547
+ mockDoStream.mockResolvedValueOnce({
548
+ stream: makeSuccessStream(),
549
+ rawResponse: { headers: {} },
550
+ });
551
+
552
+ const app = buildApp();
553
+ const res = await app.request("/chat", {
554
+ method: "POST",
555
+ headers: validHeaders,
556
+ body: validBody,
557
+ });
558
+
559
+ expect(res.status).toBe(200);
560
+ // No gateway tools fetch should have occurred
561
+ expect(mockFetch).not.toHaveBeenCalled();
562
+ });
563
+
564
+ it("caches fetched tools via setCachedTools", async () => {
565
+ mockFetch.mockResolvedValueOnce(
566
+ new Response(JSON.stringify(MOCK_TOOLS_RESPONSE), { status: 200 }),
567
+ );
568
+ mockDoStream.mockResolvedValueOnce({
569
+ stream: makeSuccessStream(),
570
+ rawResponse: { headers: {} },
571
+ });
572
+
573
+ const app = buildApp();
574
+ await app.request("/chat", {
575
+ method: "POST",
576
+ headers: validHeaders,
577
+ body: validBody,
578
+ });
579
+
580
+ expect(mockSetCachedTools).toHaveBeenCalledOnce();
581
+ expect(mockSetCachedTools).toHaveBeenCalledWith(
582
+ fakeClaims.sub,
583
+ MOCK_TOOLS_RESPONSE.tools,
584
+ );
585
+ });
487
586
  });
488
587
  });