@aprovan/chat-backend 0.1.0-dev.4d82df8

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 (42) hide show
  1. package/.turbo/turbo-build.log +20 -0
  2. package/LICENSE +373 -0
  3. package/dist/index.d.ts +85 -0
  4. package/dist/index.js +1158 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lambda.d.ts +5 -0
  7. package/dist/lambda.js +1145 -0
  8. package/dist/lambda.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/app.ts +33 -0
  11. package/src/env.ts +27 -0
  12. package/src/fallback-prompts.ts +343 -0
  13. package/src/gateway-session.ts +148 -0
  14. package/src/index.ts +19 -0
  15. package/src/lambda.ts +17 -0
  16. package/src/middleware/.gitkeep +0 -0
  17. package/src/middleware/auth.ts +38 -0
  18. package/src/middleware/plan.ts +67 -0
  19. package/src/middleware/workspace.ts +96 -0
  20. package/src/posthog.ts +48 -0
  21. package/src/providers/openrouter.ts +37 -0
  22. package/src/routes/chat.ts +235 -0
  23. package/src/routes/edit.ts +57 -0
  24. package/src/routes/health.ts +7 -0
  25. package/src/routes/proxy.ts +60 -0
  26. package/src/routes/services.ts +82 -0
  27. package/src/routes/workspaces.ts +89 -0
  28. package/src/session.ts +80 -0
  29. package/src/tool-docs.ts +77 -0
  30. package/src/types.ts +29 -0
  31. package/test/app.test.ts +62 -0
  32. package/test/gateway-session.test.ts +211 -0
  33. package/test/middleware/auth.test.ts +87 -0
  34. package/test/middleware/plan.test.ts +99 -0
  35. package/test/middleware/workspace.test.ts +122 -0
  36. package/test/routes/chat.test.ts +587 -0
  37. package/test/routes/proxy.test.ts +133 -0
  38. package/test/routes/services.test.ts +128 -0
  39. package/test/routes/workspaces.test.ts +164 -0
  40. package/test/session.test.ts +56 -0
  41. package/tsconfig.json +13 -0
  42. package/tsup.config.ts +17 -0
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
2
+
3
+ export interface WorkspaceLimits {
4
+ dailyChatCap: number;
5
+ maxModels: string[];
6
+ maxToolSteps: number;
7
+ maxTokensPerRequest: number;
8
+ }
9
+
10
+ export interface WorkspaceFeatures {
11
+ advancedTools: boolean;
12
+ customPrompts: boolean;
13
+ }
14
+
15
+ export interface WorkspaceItem {
16
+ workspaceId: string;
17
+ name: string;
18
+ plan: string;
19
+ limits: WorkspaceLimits;
20
+ features: WorkspaceFeatures;
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+
25
+ export type AppVariables = {
26
+ claims: CognitoAccessTokenPayload;
27
+ workspaceId: string;
28
+ workspace: WorkspaceItem;
29
+ };
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import { createChatApp } from "../src/app";
3
+
4
+ vi.mock("aws-jwt-verify", () => {
5
+ const verify = vi.fn();
6
+ return {
7
+ CognitoJwtVerifier: {
8
+ create: vi.fn(() => ({ verify })),
9
+ },
10
+ };
11
+ });
12
+
13
+ vi.mock("@aws-sdk/client-dynamodb", () => ({
14
+ DynamoDBClient: vi.fn(() => ({})),
15
+ }));
16
+
17
+ vi.mock("@aws-sdk/lib-dynamodb", () => ({
18
+ DynamoDBDocumentClient: {
19
+ from: vi.fn(() => ({ send: vi.fn() })),
20
+ },
21
+ QueryCommand: vi.fn((input) => input),
22
+ GetCommand: vi.fn((input) => input),
23
+ PutCommand: vi.fn((input) => input),
24
+ BatchGetCommand: vi.fn((input) => input),
25
+ }));
26
+
27
+ beforeAll(() => {
28
+ process.env["COGNITO_USER_POOL_ID"] = "us-east-1_test";
29
+ process.env["COGNITO_CLIENT_ID"] = "test-client-id";
30
+ process.env["AWS_REGION"] = "us-east-1";
31
+ process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
32
+ process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
33
+ process.env["USER_SESSIONS_TABLE_NAME"] = "test-user-sessions";
34
+ });
35
+
36
+ describe("chat app", () => {
37
+ const app = createChatApp();
38
+
39
+ it("GET /health returns ok without auth", async () => {
40
+ const res = await app.request("/health");
41
+ expect(res.status).toBe(200);
42
+ const body = await res.json();
43
+ expect(body).toEqual({ status: "ok" });
44
+ });
45
+
46
+ it("GET /api/* returns 401 without Authorization header", async () => {
47
+ const res = await app.request("/api/chat");
48
+ expect(res.status).toBe(401);
49
+ });
50
+
51
+ it("GET /api/* returns 401 with invalid Bearer token", async () => {
52
+ const { CognitoJwtVerifier } = await import("aws-jwt-verify");
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
54
+ (CognitoJwtVerifier.create({} as any) as any).verify.mockRejectedValueOnce(
55
+ new Error("invalid token"),
56
+ );
57
+ const res = await app.request("/api/chat", {
58
+ headers: { Authorization: "Bearer bad.token" },
59
+ });
60
+ expect(res.status).toBe(401);
61
+ });
62
+ });
@@ -0,0 +1,211 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ getGatewaySession,
4
+ evictGatewaySession,
5
+ resetGatewaySessionCache,
6
+ getCachedTools,
7
+ setCachedTools,
8
+ evictCachedTools,
9
+ GatewaySessionError,
10
+ } from "../src/gateway-session";
11
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
12
+
13
+ const GATEWAY_URL = "https://gateway.test";
14
+ const WORKSPACE_ID = "ws-abc";
15
+ const COGNITO_TOKEN = "cognito-access-token";
16
+ const USER_SUB = "user-sub-123";
17
+ const TOKEN_EXP = Math.floor(Date.now() / 1000) + 3600;
18
+
19
+ function makeClaims(overrides?: Partial<CognitoAccessTokenPayload>): CognitoAccessTokenPayload {
20
+ return {
21
+ sub: USER_SUB,
22
+ exp: TOKEN_EXP,
23
+ iss: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_test",
24
+ client_id: "test-client",
25
+ username: "testuser",
26
+ token_use: "access",
27
+ iat: Math.floor(Date.now() / 1000),
28
+ jti: "jti-test",
29
+ auth_time: Math.floor(Date.now() / 1000),
30
+ scope: "",
31
+ version: 2,
32
+ origin_jti: "",
33
+ ...overrides,
34
+ } as unknown as CognitoAccessTokenPayload;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ resetGatewaySessionCache();
39
+ process.env["GATEWAY_URL"] = GATEWAY_URL;
40
+ });
41
+
42
+ afterEach(() => {
43
+ resetGatewaySessionCache();
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ describe("getGatewaySession", () => {
48
+ it("calls POST /auth/sessions and returns the cognito token", async () => {
49
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
50
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
51
+ );
52
+
53
+ const session = await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
54
+
55
+ expect(session.token).toBe(COGNITO_TOKEN);
56
+ expect(session.expires_at).toBe(TOKEN_EXP);
57
+ expect(fetchSpy).toHaveBeenCalledWith(
58
+ `${GATEWAY_URL}/auth/sessions`,
59
+ expect.objectContaining({
60
+ method: "POST",
61
+ headers: expect.objectContaining({ Authorization: `Bearer ${COGNITO_TOKEN}` }),
62
+ body: JSON.stringify({ workspace_id: WORKSPACE_ID }),
63
+ }),
64
+ );
65
+ });
66
+
67
+ it("caches the session and does not re-call the gateway on second call", async () => {
68
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
69
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
70
+ );
71
+
72
+ const s1 = await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
73
+ const s2 = await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
74
+
75
+ expect(s1.token).toBe(s2.token);
76
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("evicts and re-exchanges after TTL expires", async () => {
80
+ const expiredClaims = makeClaims({ exp: Math.floor(Date.now() / 1000) - 1 });
81
+
82
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
83
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
84
+ );
85
+
86
+ await getGatewaySession(expiredClaims, WORKSPACE_ID, COGNITO_TOKEN);
87
+ await getGatewaySession(expiredClaims, WORKSPACE_ID, COGNITO_TOKEN);
88
+
89
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
90
+ });
91
+
92
+ it("retries once on 401 and returns session if retry succeeds", async () => {
93
+ const fetchSpy = vi.spyOn(globalThis, "fetch")
94
+ .mockResolvedValueOnce(new Response("Unauthorized", { status: 401 }))
95
+ .mockResolvedValueOnce(
96
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
97
+ );
98
+
99
+ const session = await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
100
+ expect(session.token).toBe(COGNITO_TOKEN);
101
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it("throws GatewaySessionError on persistent 401", async () => {
105
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
106
+ new Response("Unauthorized", { status: 401 }),
107
+ );
108
+
109
+ await expect(
110
+ getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN),
111
+ ).rejects.toThrow(GatewaySessionError);
112
+ });
113
+
114
+ it("throws GatewaySessionError on 403", async () => {
115
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
116
+ new Response("Forbidden", { status: 403 }),
117
+ );
118
+
119
+ await expect(
120
+ getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN),
121
+ ).rejects.toThrow(GatewaySessionError);
122
+ });
123
+
124
+ it("evictGatewaySession removes the cached entry", async () => {
125
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
126
+ new Response(JSON.stringify({ workspace_id: WORKSPACE_ID }), { status: 200 }),
127
+ );
128
+
129
+ await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
130
+ evictGatewaySession(USER_SUB);
131
+ await getGatewaySession(makeClaims(), WORKSPACE_ID, COGNITO_TOKEN);
132
+
133
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
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
+ });
211
+ });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Hono } from "hono";
3
+ import { authMiddleware } from "../../src/middleware/auth";
4
+ import type { AppVariables } from "../../src/types";
5
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
6
+
7
+ // Mock aws-jwt-verify at the module level
8
+ vi.mock("aws-jwt-verify", () => {
9
+ const verify = vi.fn();
10
+ return {
11
+ CognitoJwtVerifier: {
12
+ create: vi.fn(() => ({ verify })),
13
+ },
14
+ };
15
+ });
16
+
17
+ // Access the mocked verify function
18
+ const { CognitoJwtVerifier } = await import("aws-jwt-verify");
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ const mockVerify = (CognitoJwtVerifier.create({} as any) as any).verify as ReturnType<typeof vi.fn>;
21
+
22
+ function buildApp() {
23
+ const app = new Hono<{ Variables: AppVariables }>();
24
+ app.use("/protected", authMiddleware);
25
+ app.get("/protected", (c) => c.json({ sub: c.get("claims").sub }));
26
+ return app;
27
+ }
28
+
29
+ const fakeClaims = {
30
+ sub: "user-sub-123",
31
+ token_use: "access",
32
+ iss: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc",
33
+ aud: "client-id",
34
+ exp: Math.floor(Date.now() / 1000) + 3600,
35
+ iat: Math.floor(Date.now() / 1000),
36
+ } as unknown as CognitoAccessTokenPayload;
37
+
38
+ describe("authMiddleware", () => {
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ process.env["COGNITO_USER_POOL_ID"] = "us-east-1_abc";
42
+ process.env["COGNITO_CLIENT_ID"] = "client-id";
43
+ });
44
+
45
+ it("passes with a valid Bearer token and sets claims", async () => {
46
+ mockVerify.mockResolvedValueOnce(fakeClaims);
47
+ const app = buildApp();
48
+ const res = await app.request("/protected", {
49
+ headers: { Authorization: "Bearer valid.token.here" },
50
+ });
51
+ expect(res.status).toBe(200);
52
+ const body = await res.json();
53
+ expect(body).toEqual({ sub: "user-sub-123" });
54
+ });
55
+
56
+ it("returns 401 when Authorization header is missing", async () => {
57
+ const app = buildApp();
58
+ const res = await app.request("/protected");
59
+ expect(res.status).toBe(401);
60
+ });
61
+
62
+ it("returns 401 when Authorization header is not Bearer", async () => {
63
+ const app = buildApp();
64
+ const res = await app.request("/protected", {
65
+ headers: { Authorization: "Basic dXNlcjpwYXNz" },
66
+ });
67
+ expect(res.status).toBe(401);
68
+ });
69
+
70
+ it("returns 401 for an expired token", async () => {
71
+ mockVerify.mockRejectedValueOnce(new Error("Token expired"));
72
+ const app = buildApp();
73
+ const res = await app.request("/protected", {
74
+ headers: { Authorization: "Bearer expired.token.here" },
75
+ });
76
+ expect(res.status).toBe(401);
77
+ });
78
+
79
+ it("returns 401 for wrong audience", async () => {
80
+ mockVerify.mockRejectedValueOnce(new Error("Token has expired or audience mismatch"));
81
+ const app = buildApp();
82
+ const res = await app.request("/protected", {
83
+ headers: { Authorization: "Bearer wrong.audience.token" },
84
+ });
85
+ expect(res.status).toBe(401);
86
+ });
87
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Hono } from "hono";
3
+ import type { AppVariables, WorkspaceItem } from "../../src/types";
4
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
5
+
6
+ vi.mock("@aws-sdk/client-dynamodb", () => ({
7
+ DynamoDBClient: vi.fn(() => ({})),
8
+ }));
9
+
10
+ const mockSend = vi.fn();
11
+ vi.mock("@aws-sdk/lib-dynamodb", () => ({
12
+ DynamoDBDocumentClient: {
13
+ from: vi.fn(() => ({ send: mockSend })),
14
+ },
15
+ GetCommand: vi.fn((input) => input),
16
+ }));
17
+
18
+ const { planMiddleware, getWorkspace } = await import("../../src/middleware/plan");
19
+
20
+ const freeWorkspace: WorkspaceItem = {
21
+ workspaceId: "ws-plan-test",
22
+ name: "Test Workspace",
23
+ plan: "free",
24
+ limits: {
25
+ dailyChatCap: 50,
26
+ maxModels: ["openrouter/auto"],
27
+ maxToolSteps: 5,
28
+ maxTokensPerRequest: 4096,
29
+ },
30
+ features: {
31
+ advancedTools: false,
32
+ customPrompts: false,
33
+ },
34
+ createdAt: "2026-07-01T00:00:00Z",
35
+ updatedAt: "2026-07-01T00:00:00Z",
36
+ };
37
+
38
+ function buildApp(workspaceId: string) {
39
+ const app = new Hono<{ Variables: AppVariables }>();
40
+ app.use("/protected", async (c, next) => {
41
+ c.set("claims", { sub: "user-sub" } as unknown as CognitoAccessTokenPayload);
42
+ c.set("workspaceId", workspaceId);
43
+ await next();
44
+ });
45
+ app.use("/protected", planMiddleware);
46
+ app.get("/protected", (c) =>
47
+ c.json({ plan: c.get("workspace").plan }),
48
+ );
49
+ return app;
50
+ }
51
+
52
+ describe("planMiddleware", () => {
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ process.env["WORKSPACE_TABLE_NAME"] = "gateway-prd-use1-workspaces";
56
+ process.env["AWS_REGION"] = "us-east-1";
57
+ });
58
+
59
+ it("passes and sets workspace for a valid free plan", async () => {
60
+ mockSend.mockResolvedValueOnce({ Item: { ...freeWorkspace, workspaceId: "ws-free" } });
61
+ const app = buildApp("ws-free");
62
+ const res = await app.request("/protected");
63
+ expect(res.status).toBe(200);
64
+ const body = await res.json();
65
+ expect(body).toEqual({ plan: "free" });
66
+ });
67
+
68
+ it("returns 404 when workspace is not found", async () => {
69
+ mockSend.mockResolvedValueOnce({ Item: undefined });
70
+ const app = buildApp("ws-missing");
71
+ const res = await app.request("/protected");
72
+ expect(res.status).toBe(404);
73
+ const body = await res.json();
74
+ expect(body).toMatchObject({ error: "Workspace not found" });
75
+ });
76
+
77
+ it("returns 402 when chat feature is explicitly disabled", async () => {
78
+ const restrictedWorkspace = {
79
+ ...freeWorkspace,
80
+ workspaceId: "ws-restricted",
81
+ features: { ...freeWorkspace.features, chat: false },
82
+ };
83
+ mockSend.mockResolvedValueOnce({ Item: restrictedWorkspace });
84
+ const app = buildApp("ws-restricted");
85
+ const res = await app.request("/protected");
86
+ expect(res.status).toBe(402);
87
+ const body = await res.json();
88
+ expect(body).toMatchObject({ error: expect.stringContaining("plan") });
89
+ });
90
+
91
+ it("uses cache on subsequent calls for the same workspaceId", async () => {
92
+ mockSend.mockResolvedValueOnce({
93
+ Item: { ...freeWorkspace, workspaceId: "ws-cache-test" },
94
+ });
95
+ await getWorkspace("ws-cache-test");
96
+ await getWorkspace("ws-cache-test");
97
+ expect(mockSend).toHaveBeenCalledTimes(1);
98
+ });
99
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { Hono } from "hono";
3
+ import type { AppVariables } from "../../src/types";
4
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
5
+
6
+ vi.mock("@aws-sdk/client-dynamodb", () => ({
7
+ DynamoDBClient: vi.fn(() => ({})),
8
+ }));
9
+
10
+ const mockSend = vi.fn();
11
+ vi.mock("@aws-sdk/lib-dynamodb", () => ({
12
+ DynamoDBDocumentClient: {
13
+ from: vi.fn(() => ({ send: mockSend })),
14
+ },
15
+ QueryCommand: vi.fn((input) => input),
16
+ GetCommand: vi.fn((input) => input),
17
+ PutCommand: vi.fn((input) => input),
18
+ }));
19
+
20
+ const {
21
+ workspaceMiddleware,
22
+ listWorkspaceMemberships,
23
+ resetMembershipCache,
24
+ } = await import("../../src/middleware/workspace");
25
+ const { resetSessionCache } = await import("../../src/session");
26
+
27
+ const MEMBERSHIPS_TABLE = "gateway-prd-use1-memberships";
28
+ const SESSIONS_TABLE = "test-user-sessions";
29
+
30
+ type MockCommand = { TableName?: string };
31
+
32
+ function buildApp(userSub: string) {
33
+ const fakeClaims = { sub: userSub } as unknown as CognitoAccessTokenPayload;
34
+ const app = new Hono<{ Variables: AppVariables }>();
35
+ app.use("/protected", async (c, next) => {
36
+ c.set("claims", fakeClaims);
37
+ await next();
38
+ });
39
+ app.use("/protected", workspaceMiddleware);
40
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
41
+ return app;
42
+ }
43
+
44
+ describe("workspaceMiddleware", () => {
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ resetMembershipCache();
48
+ resetSessionCache();
49
+ process.env["MEMBERSHIPS_TABLE_NAME"] = MEMBERSHIPS_TABLE;
50
+ process.env["USER_SESSIONS_TABLE_NAME"] = SESSIONS_TABLE;
51
+ process.env["AWS_REGION"] = "us-east-1";
52
+ });
53
+
54
+ it("uses session activeWorkspaceId when membership is valid", async () => {
55
+ mockSend.mockImplementation((cmd: MockCommand) => {
56
+ if (cmd.TableName === SESSIONS_TABLE) {
57
+ return Promise.resolve({ Item: { activeWorkspaceId: "ws-b" } });
58
+ }
59
+ return Promise.resolve({ Items: [{ workspaceId: "ws-a" }, { workspaceId: "ws-b" }] });
60
+ });
61
+
62
+ const app = buildApp("user-session");
63
+ const res = await app.request("/protected");
64
+ expect(res.status).toBe(200);
65
+ const body = await res.json();
66
+ expect(body.workspaceId).toBe("ws-b");
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" }] });
75
+ });
76
+
77
+ const app = buildApp("user-no-session");
78
+ const res = await app.request("/protected");
79
+ expect(res.status).toBe(200);
80
+ const body = await res.json();
81
+ expect(body.workspaceId).toBe("ws-first");
82
+ });
83
+
84
+ it("falls back to first membership when session workspace is not in memberships", async () => {
85
+ mockSend.mockImplementation((cmd: MockCommand) => {
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
+ });
98
+
99
+ it("returns 403 when user has no workspace memberships", async () => {
100
+ mockSend.mockImplementation((cmd: MockCommand) => {
101
+ if (cmd.TableName === SESSIONS_TABLE) {
102
+ return Promise.resolve({ Item: undefined });
103
+ }
104
+ return Promise.resolve({ Items: [] });
105
+ });
106
+
107
+ const app = buildApp("user-no-ws");
108
+ const res = await app.request("/protected");
109
+ expect(res.status).toBe(403);
110
+ const body = await res.json();
111
+ expect(body).toMatchObject({ error: "No workspace membership" });
112
+ });
113
+
114
+ it("uses membership cache on subsequent calls for the same sub", async () => {
115
+ mockSend.mockResolvedValue({ Items: [{ workspaceId: "ws-cached" }] });
116
+
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
+ expect(mockSend).toHaveBeenCalledTimes(1);
121
+ });
122
+ });