@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.
- package/.turbo/turbo-build.log +7 -7
- package/dist/index.d.ts +9 -0
- package/dist/index.js +627 -119
- package/dist/index.js.map +1 -1
- package/dist/lambda.js +627 -119
- package/dist/lambda.js.map +1 -1
- package/package.json +2 -1
- package/src/app.ts +23 -6
- package/src/env.ts +5 -0
- package/src/gateway-session.ts +38 -1
- package/src/middleware/workspace.ts +38 -1
- package/src/routes/chat.ts +28 -8
- package/src/routes/vfs.ts +369 -0
- package/src/routes/workspace.ts +41 -0
- package/src/routes/workspaces.ts +89 -0
- package/src/session.ts +80 -0
- package/test/app.test.ts +2 -0
- package/test/gateway-session.test.ts +79 -0
- package/test/middleware/workspace.test.ts +79 -8
- package/test/routes/chat.test.ts +99 -0
- package/test/routes/vfs.test.ts +436 -0
- package/test/routes/workspace.test.ts +107 -0
- package/test/routes/workspaces.test.ts +164 -0
- package/test/session.test.ts +56 -0
|
@@ -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
|
|
46
|
-
|
|
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("
|
|
56
|
+
it("uses activeWorkspaceId from Users table when present", async () => {
|
|
57
|
+
// Users table returns activeWorkspaceId — no Memberships query needed
|
|
50
58
|
mockSend.mockResolvedValueOnce({
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/test/routes/chat.test.ts
CHANGED
|
@@ -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
|
});
|