@aprovan/chat-backend 0.1.0-dev.4d82df8 → 0.1.0-dev.78c0b14
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 +0 -3
- package/dist/index.js +122 -294
- package/dist/index.js.map +1 -1
- package/dist/lambda.js +122 -294
- package/dist/lambda.js.map +1 -1
- package/package.json +1 -1
- package/src/app.ts +3 -5
- package/src/env.ts +0 -1
- package/src/gateway-session.ts +1 -38
- package/src/middleware/workspace.ts +19 -56
- package/src/routes/chat.ts +8 -28
- package/test/app.test.ts +0 -3
- package/test/gateway-session.test.ts +0 -79
- package/test/middleware/workspace.test.ts +45 -66
- package/test/routes/chat.test.ts +0 -99
- package/src/routes/workspaces.ts +0 -89
- package/src/session.ts +0 -80
- package/test/routes/workspaces.test.ts +0 -164
- package/test/session.test.ts +0 -56
package/test/app.test.ts
CHANGED
|
@@ -20,8 +20,6 @@ vi.mock("@aws-sdk/lib-dynamodb", () => ({
|
|
|
20
20
|
},
|
|
21
21
|
QueryCommand: vi.fn((input) => input),
|
|
22
22
|
GetCommand: vi.fn((input) => input),
|
|
23
|
-
PutCommand: vi.fn((input) => input),
|
|
24
|
-
BatchGetCommand: vi.fn((input) => input),
|
|
25
23
|
}));
|
|
26
24
|
|
|
27
25
|
beforeAll(() => {
|
|
@@ -30,7 +28,6 @@ beforeAll(() => {
|
|
|
30
28
|
process.env["AWS_REGION"] = "us-east-1";
|
|
31
29
|
process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
|
|
32
30
|
process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
|
|
33
|
-
process.env["USER_SESSIONS_TABLE_NAME"] = "test-user-sessions";
|
|
34
31
|
});
|
|
35
32
|
|
|
36
33
|
describe("chat app", () => {
|
|
@@ -3,9 +3,6 @@ import {
|
|
|
3
3
|
getGatewaySession,
|
|
4
4
|
evictGatewaySession,
|
|
5
5
|
resetGatewaySessionCache,
|
|
6
|
-
getCachedTools,
|
|
7
|
-
setCachedTools,
|
|
8
|
-
evictCachedTools,
|
|
9
6
|
GatewaySessionError,
|
|
10
7
|
} from "../src/gateway-session";
|
|
11
8
|
import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
|
|
@@ -132,80 +129,4 @@ describe("getGatewaySession", () => {
|
|
|
132
129
|
|
|
133
130
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
134
131
|
});
|
|
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
|
-
});
|
|
211
132
|
});
|
|
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
|
|
3
3
|
import type { AppVariables } from "../../src/types";
|
|
4
4
|
import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
|
|
5
5
|
|
|
6
|
+
// Mock the DDB client before importing the middleware
|
|
6
7
|
vi.mock("@aws-sdk/client-dynamodb", () => ({
|
|
7
8
|
DynamoDBClient: vi.fn(() => ({})),
|
|
8
9
|
}));
|
|
@@ -13,110 +14,88 @@ vi.mock("@aws-sdk/lib-dynamodb", () => ({
|
|
|
13
14
|
from: vi.fn(() => ({ send: mockSend })),
|
|
14
15
|
},
|
|
15
16
|
QueryCommand: vi.fn((input) => input),
|
|
16
|
-
GetCommand: vi.fn((input) => input),
|
|
17
|
-
PutCommand: vi.fn((input) => input),
|
|
18
17
|
}));
|
|
19
18
|
|
|
20
|
-
const {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
resetMembershipCache,
|
|
24
|
-
} = await import("../../src/middleware/workspace");
|
|
25
|
-
const { resetSessionCache } = await import("../../src/session");
|
|
19
|
+
const { workspaceMiddleware, resolveWorkspaceId } = await import(
|
|
20
|
+
"../../src/middleware/workspace"
|
|
21
|
+
);
|
|
26
22
|
|
|
27
|
-
const
|
|
28
|
-
|
|
23
|
+
const fakeClaims = {
|
|
24
|
+
sub: "user-sub-123",
|
|
25
|
+
} as unknown as CognitoAccessTokenPayload;
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
function buildApp(userSub: string) {
|
|
33
|
-
const fakeClaims = { sub: userSub } as unknown as CognitoAccessTokenPayload;
|
|
27
|
+
function buildApp() {
|
|
34
28
|
const app = new Hono<{ Variables: AppVariables }>();
|
|
35
29
|
app.use("/protected", async (c, next) => {
|
|
36
30
|
c.set("claims", fakeClaims);
|
|
37
31
|
await next();
|
|
38
32
|
});
|
|
39
33
|
app.use("/protected", workspaceMiddleware);
|
|
40
|
-
app.get("/protected", (c) =>
|
|
34
|
+
app.get("/protected", (c) =>
|
|
35
|
+
c.json({ workspaceId: c.get("workspaceId") }),
|
|
36
|
+
);
|
|
41
37
|
return app;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
describe("workspaceMiddleware", () => {
|
|
45
41
|
beforeEach(() => {
|
|
46
42
|
vi.clearAllMocks();
|
|
47
|
-
|
|
48
|
-
resetSessionCache();
|
|
49
|
-
process.env["MEMBERSHIPS_TABLE_NAME"] = MEMBERSHIPS_TABLE;
|
|
50
|
-
process.env["USER_SESSIONS_TABLE_NAME"] = SESSIONS_TABLE;
|
|
43
|
+
process.env["MEMBERSHIPS_TABLE_NAME"] = "gateway-prd-use1-memberships";
|
|
51
44
|
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.
|
|
52
47
|
});
|
|
53
48
|
|
|
54
|
-
it("
|
|
55
|
-
mockSend.
|
|
56
|
-
|
|
57
|
-
return Promise.resolve({ Item: { activeWorkspaceId: "ws-b" } });
|
|
58
|
-
}
|
|
59
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }, { workspaceId: "ws-b" }] });
|
|
49
|
+
it("resolves workspaceId from DDB and sets it on context", async () => {
|
|
50
|
+
mockSend.mockResolvedValueOnce({
|
|
51
|
+
Items: [{ workspaceId: "ws-abc", userSub: "user-sub-456" }],
|
|
60
52
|
});
|
|
61
53
|
|
|
62
|
-
const app =
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("falls back to first membership when no session preference exists", async () => {
|
|
70
|
-
mockSend.mockImplementation((cmd: MockCommand) => {
|
|
71
|
-
if (cmd.TableName === SESSIONS_TABLE) {
|
|
72
|
-
return Promise.resolve({ Item: undefined });
|
|
73
|
-
}
|
|
74
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-first" }] });
|
|
54
|
+
const app = new Hono<{ Variables: AppVariables }>();
|
|
55
|
+
const localClaims = { sub: "user-sub-456" } as unknown as CognitoAccessTokenPayload;
|
|
56
|
+
app.use("/protected", async (c, next) => {
|
|
57
|
+
c.set("claims", localClaims);
|
|
58
|
+
await next();
|
|
75
59
|
});
|
|
60
|
+
app.use("/protected", workspaceMiddleware);
|
|
61
|
+
app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
|
|
76
62
|
|
|
77
|
-
const app = buildApp("user-no-session");
|
|
78
63
|
const res = await app.request("/protected");
|
|
79
64
|
expect(res.status).toBe(200);
|
|
80
65
|
const body = await res.json();
|
|
81
|
-
expect(body
|
|
66
|
+
expect(body).toEqual({ workspaceId: "ws-abc" });
|
|
82
67
|
});
|
|
83
68
|
|
|
84
|
-
it("
|
|
85
|
-
mockSend.
|
|
86
|
-
if (cmd.TableName === SESSIONS_TABLE) {
|
|
87
|
-
return Promise.resolve({ Item: { activeWorkspaceId: "ws-stale" } });
|
|
88
|
-
}
|
|
89
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }] });
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const app = buildApp("user-stale");
|
|
93
|
-
const res = await app.request("/protected");
|
|
94
|
-
expect(res.status).toBe(200);
|
|
95
|
-
const body = await res.json();
|
|
96
|
-
expect(body.workspaceId).toBe("ws-a");
|
|
97
|
-
});
|
|
69
|
+
it("returns 403 when no membership exists", async () => {
|
|
70
|
+
mockSend.mockResolvedValueOnce({ Items: [] });
|
|
98
71
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
72
|
+
const app = new Hono<{ Variables: AppVariables }>();
|
|
73
|
+
const localClaims = {
|
|
74
|
+
sub: "user-no-ws",
|
|
75
|
+
} as unknown as CognitoAccessTokenPayload;
|
|
76
|
+
app.use("/protected", async (c, next) => {
|
|
77
|
+
c.set("claims", localClaims);
|
|
78
|
+
await next();
|
|
105
79
|
});
|
|
80
|
+
app.use("/protected", workspaceMiddleware);
|
|
81
|
+
app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
|
|
106
82
|
|
|
107
|
-
const app = buildApp("user-no-ws");
|
|
108
83
|
const res = await app.request("/protected");
|
|
109
84
|
expect(res.status).toBe(403);
|
|
110
85
|
const body = await res.json();
|
|
111
86
|
expect(body).toMatchObject({ error: "No workspace membership" });
|
|
112
87
|
});
|
|
113
88
|
|
|
114
|
-
it("uses
|
|
115
|
-
mockSend.
|
|
89
|
+
it("uses cache on subsequent calls for the same sub", async () => {
|
|
90
|
+
mockSend.mockResolvedValueOnce({
|
|
91
|
+
Items: [{ workspaceId: "ws-cached", userSub: "user-cached" }],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// First call — hits DDB
|
|
95
|
+
await resolveWorkspaceId("user-cached");
|
|
96
|
+
// Second call — should use cache
|
|
97
|
+
await resolveWorkspaceId("user-cached");
|
|
116
98
|
|
|
117
|
-
await listWorkspaceMemberships("user-cached-q");
|
|
118
|
-
await listWorkspaceMemberships("user-cached-q");
|
|
119
|
-
// Only one DDB send for memberships (cache hit on second call)
|
|
120
99
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
121
100
|
});
|
|
122
101
|
});
|
package/test/routes/chat.test.ts
CHANGED
|
@@ -12,8 +12,6 @@ const {
|
|
|
12
12
|
mockProviderFactory,
|
|
13
13
|
mockGetGatewaySession,
|
|
14
14
|
mockEvictGatewaySession,
|
|
15
|
-
mockGetCachedTools,
|
|
16
|
-
mockSetCachedTools,
|
|
17
15
|
mockGetPrompt,
|
|
18
16
|
mockCompilePrompt,
|
|
19
17
|
mockGetPostHogClient,
|
|
@@ -27,8 +25,6 @@ const {
|
|
|
27
25
|
mockProviderFactory,
|
|
28
26
|
mockGetGatewaySession: vi.fn(),
|
|
29
27
|
mockEvictGatewaySession: vi.fn(),
|
|
30
|
-
mockGetCachedTools: vi.fn().mockReturnValue(undefined),
|
|
31
|
-
mockSetCachedTools: vi.fn(),
|
|
32
28
|
mockGetPrompt: vi.fn().mockResolvedValue({
|
|
33
29
|
source: "code_fallback",
|
|
34
30
|
prompt: "System: {{compilers}} {{tool_docs}}",
|
|
@@ -55,8 +51,6 @@ vi.mock("../../src/providers/openrouter.js", () => ({
|
|
|
55
51
|
vi.mock("../../src/gateway-session.js", () => ({
|
|
56
52
|
getGatewaySession: mockGetGatewaySession,
|
|
57
53
|
evictGatewaySession: mockEvictGatewaySession,
|
|
58
|
-
getCachedTools: mockGetCachedTools,
|
|
59
|
-
setCachedTools: mockSetCachedTools,
|
|
60
54
|
GatewaySessionError: class GatewaySessionError extends Error {
|
|
61
55
|
status: number;
|
|
62
56
|
constructor(status: number, message: string) {
|
|
@@ -188,8 +182,6 @@ describe("POST /chat", () => {
|
|
|
188
182
|
mockProviderFactory.mockReturnValue(mockModel);
|
|
189
183
|
mockCreateOpenRouterProvider.mockReturnValue(mockProviderFactory);
|
|
190
184
|
mockGetOpenRouterKey.mockResolvedValue("test-key");
|
|
191
|
-
mockGetCachedTools.mockReturnValue(undefined);
|
|
192
|
-
mockSetCachedTools.mockReset();
|
|
193
185
|
|
|
194
186
|
// Reset prompt + tool-docs mocks to defaults
|
|
195
187
|
mockGetPrompt.mockResolvedValue({
|
|
@@ -307,55 +299,6 @@ describe("POST /chat", () => {
|
|
|
307
299
|
expect(mockDoStream).toHaveBeenCalledTimes(1);
|
|
308
300
|
});
|
|
309
301
|
|
|
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
|
-
|
|
359
302
|
it("selects model from workspace.limits.maxModels[0]", async () => {
|
|
360
303
|
mockDoStream.mockResolvedValueOnce({
|
|
361
304
|
stream: makeSuccessStream(),
|
|
@@ -541,47 +484,5 @@ describe("POST /chat", () => {
|
|
|
541
484
|
expect.any(String),
|
|
542
485
|
);
|
|
543
486
|
});
|
|
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
|
-
});
|
|
586
487
|
});
|
|
587
488
|
});
|
package/src/routes/workspaces.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import { BatchGetCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
3
|
-
import { zValidator } from "@hono/zod-validator";
|
|
4
|
-
import { Hono } from "hono";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { listWorkspaceMemberships } from "../middleware/workspace.js";
|
|
7
|
-
import { getSessionWorkspaceId, setSessionWorkspaceId } from "../session.js";
|
|
8
|
-
import type { AppVariables, WorkspaceItem } from "../types.js";
|
|
9
|
-
|
|
10
|
-
let ddbClient: DynamoDBDocumentClient | null = null;
|
|
11
|
-
|
|
12
|
-
function getDdb() {
|
|
13
|
-
if (!ddbClient) {
|
|
14
|
-
ddbClient = DynamoDBDocumentClient.from(
|
|
15
|
-
new DynamoDBClient({ region: process.env["AWS_REGION"] }),
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
return ddbClient;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function batchGetWorkspaces(
|
|
22
|
-
workspaceIds: string[],
|
|
23
|
-
): Promise<WorkspaceItem[]> {
|
|
24
|
-
if (workspaceIds.length === 0) return [];
|
|
25
|
-
const tableName = process.env["WORKSPACE_TABLE_NAME"]!;
|
|
26
|
-
const result = await getDdb().send(
|
|
27
|
-
new BatchGetCommand({
|
|
28
|
-
RequestItems: {
|
|
29
|
-
[tableName]: { Keys: workspaceIds.map((id) => ({ workspaceId: id })) },
|
|
30
|
-
},
|
|
31
|
-
}),
|
|
32
|
-
);
|
|
33
|
-
return (result.Responses?.[tableName] ?? []) as WorkspaceItem[];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const workspacesRoute = new Hono<{ Variables: AppVariables }>();
|
|
37
|
-
|
|
38
|
-
workspacesRoute.get("/", async (c) => {
|
|
39
|
-
const claims = c.get("claims");
|
|
40
|
-
const userSub = claims.sub;
|
|
41
|
-
|
|
42
|
-
const [workspaceIds, activeWorkspaceId] = await Promise.all([
|
|
43
|
-
listWorkspaceMemberships(userSub),
|
|
44
|
-
getSessionWorkspaceId(userSub),
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
const workspaces = await batchGetWorkspaces(workspaceIds);
|
|
48
|
-
|
|
49
|
-
const effectiveActiveId =
|
|
50
|
-
activeWorkspaceId && workspaceIds.includes(activeWorkspaceId)
|
|
51
|
-
? activeWorkspaceId
|
|
52
|
-
: (workspaceIds[0] ?? null);
|
|
53
|
-
|
|
54
|
-
const sorted = workspaceIds
|
|
55
|
-
.map((id) => workspaces.find((w) => w.workspaceId === id))
|
|
56
|
-
.filter((w): w is WorkspaceItem => w !== undefined);
|
|
57
|
-
|
|
58
|
-
return c.json({
|
|
59
|
-
workspaces: sorted.map((w) => ({
|
|
60
|
-
workspaceId: w.workspaceId,
|
|
61
|
-
name: w.name,
|
|
62
|
-
plan: w.plan,
|
|
63
|
-
active: w.workspaceId === effectiveActiveId,
|
|
64
|
-
})),
|
|
65
|
-
activeWorkspaceId: effectiveActiveId,
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const setActiveSchema = z.object({
|
|
70
|
-
workspaceId: z.string().min(1),
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
workspacesRoute.put(
|
|
74
|
-
"/active",
|
|
75
|
-
zValidator("json", setActiveSchema),
|
|
76
|
-
async (c) => {
|
|
77
|
-
const claims = c.get("claims");
|
|
78
|
-
const userSub = claims.sub;
|
|
79
|
-
const { workspaceId } = c.req.valid("json");
|
|
80
|
-
|
|
81
|
-
const workspaceIds = await listWorkspaceMemberships(userSub);
|
|
82
|
-
if (!workspaceIds.includes(workspaceId)) {
|
|
83
|
-
return c.json({ error: "Not a member of this workspace" }, 403);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
await setSessionWorkspaceId(userSub, workspaceId);
|
|
87
|
-
return c.json({ activeWorkspaceId: workspaceId });
|
|
88
|
-
},
|
|
89
|
-
);
|
package/src/session.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import {
|
|
3
|
-
DynamoDBDocumentClient,
|
|
4
|
-
GetCommand,
|
|
5
|
-
PutCommand,
|
|
6
|
-
} from "@aws-sdk/lib-dynamodb";
|
|
7
|
-
|
|
8
|
-
const SESSION_CACHE_TTL_MS = 15_000;
|
|
9
|
-
|
|
10
|
-
interface SessionCacheEntry {
|
|
11
|
-
activeWorkspaceId: string | null;
|
|
12
|
-
fetchedAt: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const sessionCache = new Map<string, SessionCacheEntry>();
|
|
16
|
-
|
|
17
|
-
let ddbClient: DynamoDBDocumentClient | null = null;
|
|
18
|
-
|
|
19
|
-
function getDdb() {
|
|
20
|
-
if (!ddbClient) {
|
|
21
|
-
ddbClient = DynamoDBDocumentClient.from(
|
|
22
|
-
new DynamoDBClient({ region: process.env["AWS_REGION"] }),
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
return ddbClient;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function tableName(): string {
|
|
29
|
-
return process.env["USER_SESSIONS_TABLE_NAME"]!;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function getSessionWorkspaceId(
|
|
33
|
-
userSub: string,
|
|
34
|
-
): Promise<string | null> {
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
const cached = sessionCache.get(userSub);
|
|
37
|
-
if (cached && now - cached.fetchedAt < SESSION_CACHE_TTL_MS) {
|
|
38
|
-
return cached.activeWorkspaceId;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const result = await getDdb().send(
|
|
42
|
-
new GetCommand({
|
|
43
|
-
TableName: tableName(),
|
|
44
|
-
Key: { userSub },
|
|
45
|
-
}),
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
const activeWorkspaceId =
|
|
49
|
-
(result.Item?.activeWorkspaceId as string | undefined) ?? null;
|
|
50
|
-
sessionCache.set(userSub, { activeWorkspaceId, fetchedAt: now });
|
|
51
|
-
return activeWorkspaceId;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function setSessionWorkspaceId(
|
|
55
|
-
userSub: string,
|
|
56
|
-
workspaceId: string,
|
|
57
|
-
): Promise<void> {
|
|
58
|
-
await getDdb().send(
|
|
59
|
-
new PutCommand({
|
|
60
|
-
TableName: tableName(),
|
|
61
|
-
Item: {
|
|
62
|
-
userSub,
|
|
63
|
-
activeWorkspaceId: workspaceId,
|
|
64
|
-
updatedAt: new Date().toISOString(),
|
|
65
|
-
},
|
|
66
|
-
}),
|
|
67
|
-
);
|
|
68
|
-
sessionCache.set(userSub, {
|
|
69
|
-
activeWorkspaceId: workspaceId,
|
|
70
|
-
fetchedAt: Date.now(),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function clearSessionCache(userSub: string): void {
|
|
75
|
-
sessionCache.delete(userSub);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function resetSessionCache(): void {
|
|
79
|
-
sessionCache.clear();
|
|
80
|
-
}
|