@aprovan/chat-backend 0.1.0-dev.4d82df8 → 0.1.0-dev.67fbce2

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.
@@ -0,0 +1,369 @@
1
+ import { ConditionalCheckFailedException, DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import {
3
+ DynamoDBDocumentClient,
4
+ DeleteCommand,
5
+ GetCommand,
6
+ QueryCommand,
7
+ UpdateCommand,
8
+ } from "@aws-sdk/lib-dynamodb";
9
+ import {
10
+ DeleteObjectCommand,
11
+ GetObjectCommand,
12
+ PutObjectCommand,
13
+ S3Client,
14
+ } from "@aws-sdk/client-s3";
15
+ import { Hono } from "hono";
16
+ import type { AppVariables } from "../types.js";
17
+
18
+ export interface VfsChangeEntry {
19
+ path: string;
20
+ mtime: string;
21
+ version: number;
22
+ size: number;
23
+ }
24
+
25
+ interface VfsDdbItem {
26
+ PK: string;
27
+ SK: string;
28
+ s3Key: string;
29
+ size: number;
30
+ mtime: string;
31
+ etag?: string;
32
+ contentHash?: string;
33
+ version: number;
34
+ }
35
+
36
+ let ddbClient: DynamoDBDocumentClient | null = null;
37
+ let s3Client: S3Client | null = null;
38
+
39
+ function getDdb() {
40
+ if (!ddbClient) {
41
+ ddbClient = DynamoDBDocumentClient.from(
42
+ new DynamoDBClient({ region: process.env["AWS_REGION"] }),
43
+ );
44
+ }
45
+ return ddbClient;
46
+ }
47
+
48
+ function getS3() {
49
+ if (!s3Client) {
50
+ s3Client = new S3Client({ region: process.env["AWS_REGION"] });
51
+ }
52
+ return s3Client;
53
+ }
54
+
55
+ function vfsTableName(): string | undefined {
56
+ return process.env["VFS_TABLE_NAME"];
57
+ }
58
+
59
+ function vfsBucketName(): string | undefined {
60
+ return process.env["VFS_BUCKET_NAME"];
61
+ }
62
+
63
+ function pk(wsId: string) {
64
+ return `workspace#${wsId}`;
65
+ }
66
+
67
+ function sk(filePath: string) {
68
+ return `file#${filePath}`;
69
+ }
70
+
71
+ function s3Key(wsId: string, filePath: string) {
72
+ return `${wsId}/${filePath}`;
73
+ }
74
+
75
+ export const vfsRoute = new Hono<{ Variables: AppVariables }>();
76
+
77
+ /** GET /vfs/config — parity endpoint; tells the client to use path-based addressing. */
78
+ vfsRoute.get("/config", (c) => {
79
+ return c.json({ usePaths: true });
80
+ });
81
+
82
+ /**
83
+ * GET /vfs?since=<RFC3339>
84
+ *
85
+ * Returns a list of workspace files whose mtime is strictly after :since.
86
+ * The workspace is resolved from the caller's session — the URL carries no wsId.
87
+ *
88
+ * If VFS_TABLE_NAME is not set (infra not yet deployed) returns [] so the
89
+ * polling client degrades gracefully.
90
+ */
91
+ vfsRoute.get("/", async (c) => {
92
+ const since = c.req.query("since");
93
+ if (!since) {
94
+ return c.json({ error: "since query parameter is required" }, 400);
95
+ }
96
+
97
+ const sinceDate = new Date(since);
98
+ if (isNaN(sinceDate.getTime())) {
99
+ return c.json({ error: "since must be a valid RFC3339 timestamp" }, 400);
100
+ }
101
+
102
+ const tableName = vfsTableName();
103
+ if (!tableName) {
104
+ return c.json([] as VfsChangeEntry[]);
105
+ }
106
+
107
+ const workspaceId = c.get("workspaceId");
108
+
109
+ const result = await getDdb().send(
110
+ new QueryCommand({
111
+ TableName: tableName,
112
+ KeyConditionExpression: "PK = :pk",
113
+ FilterExpression: "#mtime > :since",
114
+ ExpressionAttributeNames: { "#mtime": "mtime" },
115
+ ExpressionAttributeValues: {
116
+ ":pk": pk(workspaceId),
117
+ ":since": sinceDate.toISOString(),
118
+ },
119
+ }),
120
+ );
121
+
122
+ const items = (result.Items ?? []) as VfsDdbItem[];
123
+ const changes: VfsChangeEntry[] = items.map((item) => ({
124
+ path: item.SK.replace(/^file#/, ""),
125
+ mtime: item.mtime,
126
+ version: item.version ?? 0,
127
+ size: item.size ?? 0,
128
+ }));
129
+
130
+ return c.json(changes);
131
+ });
132
+
133
+ /**
134
+ * HEAD /vfs/:path — check existence. 200 if exists, 404 if not.
135
+ * GET /vfs/:path — read file, stat, or readdir (query param selects behaviour).
136
+ *
137
+ * HEAD is merged into GET because Hono auto-routes HEAD to the matching GET
138
+ * handler before checking explicit HEAD registrations.
139
+ */
140
+ vfsRoute.on(["GET", "HEAD"], "/:path{.+}", async (c) => {
141
+ const isHead = c.req.method === "HEAD";
142
+ const filePath = c.req.param("path");
143
+ const workspaceId = c.get("workspaceId");
144
+ const tableName = vfsTableName();
145
+ const bucketName = vfsBucketName();
146
+
147
+ // --- HEAD: existence check ---
148
+ if (isHead) {
149
+ if (!tableName) return c.body(null, 404);
150
+ const result = await getDdb().send(
151
+ new GetCommand({
152
+ TableName: tableName,
153
+ Key: { PK: pk(workspaceId), SK: sk(filePath) },
154
+ ProjectionExpression: "SK",
155
+ }),
156
+ );
157
+ return result.Item ? c.body(null, 200) : c.body(null, 404);
158
+ }
159
+
160
+ if (!tableName) {
161
+ return c.json({ error: "VFS not configured" }, 503);
162
+ }
163
+
164
+ // --- stat ---
165
+ if (c.req.query("stat") === "true") {
166
+ const result = await getDdb().send(
167
+ new GetCommand({
168
+ TableName: tableName,
169
+ Key: { PK: pk(workspaceId), SK: sk(filePath) },
170
+ ProjectionExpression: "#sz, #mt, version",
171
+ ExpressionAttributeNames: { "#sz": "size", "#mt": "mtime" },
172
+ }),
173
+ );
174
+ if (!result.Item) return c.json({ error: "ENOENT" }, 404);
175
+ const item = result.Item as Pick<VfsDdbItem, "size" | "mtime" | "version">;
176
+ return c.json({
177
+ size: item.size,
178
+ mtime: item.mtime,
179
+ isFile: true,
180
+ isDirectory: false,
181
+ });
182
+ }
183
+
184
+ // --- readdir ---
185
+ if (c.req.query("readdir") === "true") {
186
+ const prefix = filePath ? `file#${filePath}/` : "file#";
187
+ const result = await getDdb().send(
188
+ new QueryCommand({
189
+ TableName: tableName,
190
+ KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
191
+ ExpressionAttributeValues: {
192
+ ":pk": pk(workspaceId),
193
+ ":prefix": prefix,
194
+ },
195
+ ProjectionExpression: "SK",
196
+ }),
197
+ );
198
+
199
+ const items = (result.Items ?? []) as Array<{ SK: string }>;
200
+ const seen = new Map<string, boolean>();
201
+
202
+ for (const item of items) {
203
+ const relativePath = item.SK.slice(prefix.length);
204
+ const slashIdx = relativePath.indexOf("/");
205
+ if (slashIdx === -1) {
206
+ seen.set(relativePath, false);
207
+ } else {
208
+ const dirName = relativePath.slice(0, slashIdx);
209
+ if (!seen.has(dirName)) {
210
+ seen.set(dirName, true);
211
+ }
212
+ }
213
+ }
214
+
215
+ const entries = Array.from(seen.entries()).map(([name, isDirectory]) => ({
216
+ name,
217
+ isDirectory,
218
+ }));
219
+
220
+ return c.json(entries);
221
+ }
222
+
223
+ // --- read file ---
224
+ if (!bucketName) {
225
+ return c.json({ error: "VFS storage not configured" }, 503);
226
+ }
227
+
228
+ const fileKey = s3Key(workspaceId, filePath);
229
+ try {
230
+ const obj = await getS3().send(
231
+ new GetObjectCommand({ Bucket: bucketName, Key: fileKey }),
232
+ );
233
+ const body = await obj.Body?.transformToString("utf-8");
234
+ if (body === undefined) return c.json({ error: "ENOENT" }, 404);
235
+ return c.text(body, 200);
236
+ } catch (err: unknown) {
237
+ const code = (err as { name?: string }).name;
238
+ if (code === "NoSuchKey" || code === "NotFound") {
239
+ return c.json({ error: "ENOENT" }, 404);
240
+ }
241
+ throw err;
242
+ }
243
+ });
244
+
245
+ /**
246
+ * PUT /vfs/:path — write file.
247
+ *
248
+ * Optionally accepts `X-Vfs-Expected-Version` header for optimistic concurrency.
249
+ * Returns {ok: true, version: <new>} or {ok: false, conflict: {serverVersion, serverEtag}}.
250
+ */
251
+ vfsRoute.put("/:path{.+}", async (c) => {
252
+ const tableName = vfsTableName();
253
+ const bucketName = vfsBucketName();
254
+ if (!tableName || !bucketName) {
255
+ return c.json({ error: "VFS not configured" }, 503);
256
+ }
257
+
258
+ const filePath = c.req.param("path");
259
+ const workspaceId = c.get("workspaceId");
260
+ const content = await c.req.text();
261
+ const expectedVersionHeader = c.req.header("X-Vfs-Expected-Version");
262
+ const expectedVersion = expectedVersionHeader !== undefined
263
+ ? parseInt(expectedVersionHeader, 10)
264
+ : undefined;
265
+
266
+ const fileKey = s3Key(workspaceId, filePath);
267
+ const now = new Date().toISOString();
268
+ const byteSize = new TextEncoder().encode(content).length;
269
+
270
+ await getS3().send(
271
+ new PutObjectCommand({
272
+ Bucket: bucketName,
273
+ Key: fileKey,
274
+ Body: content,
275
+ ContentType: "text/plain; charset=utf-8",
276
+ }),
277
+ );
278
+
279
+ try {
280
+ const condExpr =
281
+ expectedVersion !== undefined
282
+ ? "attribute_not_exists(PK) OR version = :expected"
283
+ : undefined;
284
+
285
+ const exprAttrValues: Record<string, unknown> = {
286
+ ":mtime": now,
287
+ ":sz": byteSize,
288
+ ":s3Key": fileKey,
289
+ ":inc": 1,
290
+ ":zero": 0,
291
+ };
292
+ if (expectedVersion !== undefined) {
293
+ exprAttrValues[":expected"] = expectedVersion;
294
+ }
295
+
296
+ const result = await getDdb().send(
297
+ new UpdateCommand({
298
+ TableName: tableName,
299
+ Key: { PK: pk(workspaceId), SK: sk(filePath) },
300
+ UpdateExpression:
301
+ "SET #mt = :mtime, #sz = :sz, s3Key = :s3Key, version = if_not_exists(version, :zero) + :inc",
302
+ ExpressionAttributeNames: { "#mt": "mtime", "#sz": "size" },
303
+ ExpressionAttributeValues: exprAttrValues,
304
+ ...(condExpr ? { ConditionExpression: condExpr } : {}),
305
+ ReturnValues: "ALL_NEW",
306
+ }),
307
+ );
308
+
309
+ const newVersion = (result.Attributes as VfsDdbItem | undefined)?.version ?? 1;
310
+ return c.json({ ok: true, version: newVersion });
311
+ } catch (err: unknown) {
312
+ if (err instanceof ConditionalCheckFailedException) {
313
+ const metaResult = await getDdb().send(
314
+ new GetCommand({
315
+ TableName: tableName,
316
+ Key: { PK: pk(workspaceId), SK: sk(filePath) },
317
+ ProjectionExpression: "version, etag",
318
+ }),
319
+ );
320
+ const item = metaResult.Item as Pick<VfsDdbItem, "version" | "etag"> | undefined;
321
+ return c.json(
322
+ {
323
+ ok: false,
324
+ conflict: {
325
+ serverVersion: item?.version ?? 0,
326
+ serverEtag: item?.etag,
327
+ },
328
+ },
329
+ 409,
330
+ );
331
+ }
332
+ throw err;
333
+ }
334
+ });
335
+
336
+ /** DELETE /vfs/:path — remove from S3 + DDB. */
337
+ vfsRoute.delete("/:path{.+}", async (c) => {
338
+ const tableName = vfsTableName();
339
+ const bucketName = vfsBucketName();
340
+ if (!tableName || !bucketName) {
341
+ return c.json({ error: "VFS not configured" }, 503);
342
+ }
343
+
344
+ const filePath = c.req.param("path");
345
+ const workspaceId = c.get("workspaceId");
346
+ const fileKey = s3Key(workspaceId, filePath);
347
+
348
+ await Promise.all([
349
+ getDdb().send(
350
+ new DeleteCommand({
351
+ TableName: tableName,
352
+ Key: { PK: pk(workspaceId), SK: sk(filePath) },
353
+ }),
354
+ ),
355
+ getS3().send(
356
+ new DeleteObjectCommand({ Bucket: bucketName, Key: fileKey }),
357
+ ),
358
+ ]);
359
+
360
+ return c.body(null, 204);
361
+ });
362
+
363
+ /** POST /vfs/:path?mkdir=true — no-op (S3 is keyless). */
364
+ vfsRoute.post("/:path{.+}", (c) => {
365
+ if (c.req.query("mkdir") === "true") {
366
+ return c.body(null, 204);
367
+ }
368
+ return c.json({ error: "unsupported operation" }, 400);
369
+ });
@@ -0,0 +1,41 @@
1
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
2
+ import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";
3
+ import { Hono } from "hono";
4
+ import { evictWorkspaceCache } from "../middleware/workspace.js";
5
+ import type { AppVariables } from "../types.js";
6
+
7
+ let ddbClient: DynamoDBDocumentClient | null = null;
8
+
9
+ function getDdb() {
10
+ if (!ddbClient) {
11
+ ddbClient = DynamoDBDocumentClient.from(
12
+ new DynamoDBClient({ region: process.env["AWS_REGION"] }),
13
+ );
14
+ }
15
+ return ddbClient;
16
+ }
17
+
18
+ export const workspaceRoute = new Hono<{ Variables: AppVariables }>();
19
+
20
+ /** POST /api/workspace — switch the caller's active workspace. */
21
+ workspaceRoute.post("/", async (c) => {
22
+ const claims = c.get("claims");
23
+ const body = await c.req.json<{ workspaceId?: string }>();
24
+ const workspaceId = body?.workspaceId;
25
+ if (!workspaceId || typeof workspaceId !== "string") {
26
+ return c.json({ error: "workspaceId is required" }, 400);
27
+ }
28
+
29
+ await getDdb().send(
30
+ new UpdateCommand({
31
+ TableName: process.env["USERS_TABLE_NAME"]!,
32
+ Key: { sub: claims.sub },
33
+ UpdateExpression: "SET activeWorkspaceId = :ws",
34
+ ExpressionAttributeValues: { ":ws": workspaceId },
35
+ }),
36
+ );
37
+
38
+ evictWorkspaceCache(claims.sub);
39
+
40
+ return c.json({ activeWorkspaceId: workspaceId });
41
+ });
package/test/app.test.ts CHANGED
@@ -20,8 +20,7 @@ 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),
23
+ UpdateCommand: vi.fn((input) => input),
25
24
  }));
26
25
 
27
26
  beforeAll(() => {
@@ -30,7 +29,7 @@ beforeAll(() => {
30
29
  process.env["AWS_REGION"] = "us-east-1";
31
30
  process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
32
31
  process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
33
- process.env["USER_SESSIONS_TABLE_NAME"] = "test-user-sessions";
32
+ process.env["USERS_TABLE_NAME"] = "test-users";
34
33
  });
35
34
 
36
35
  describe("chat app", () => {
@@ -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
  }));
@@ -14,109 +15,158 @@ vi.mock("@aws-sdk/lib-dynamodb", () => ({
14
15
  },
15
16
  QueryCommand: vi.fn((input) => input),
16
17
  GetCommand: vi.fn((input) => input),
17
- PutCommand: vi.fn((input) => input),
18
18
  }));
19
19
 
20
- const {
21
- workspaceMiddleware,
22
- listWorkspaceMemberships,
23
- resetMembershipCache,
24
- } = await import("../../src/middleware/workspace");
25
- const { resetSessionCache } = await import("../../src/session");
20
+ const { workspaceMiddleware, resolveWorkspaceId, evictWorkspaceCache } = await import(
21
+ "../../src/middleware/workspace"
22
+ );
26
23
 
27
- const MEMBERSHIPS_TABLE = "gateway-prd-use1-memberships";
28
- const SESSIONS_TABLE = "test-user-sessions";
24
+ const fakeClaims = {
25
+ sub: "user-sub-123",
26
+ } as unknown as CognitoAccessTokenPayload;
29
27
 
30
- type MockCommand = { TableName?: string };
31
-
32
- function buildApp(userSub: string) {
33
- const fakeClaims = { sub: userSub } as unknown as CognitoAccessTokenPayload;
28
+ function buildApp() {
34
29
  const app = new Hono<{ Variables: AppVariables }>();
35
30
  app.use("/protected", async (c, next) => {
36
31
  c.set("claims", fakeClaims);
37
32
  await next();
38
33
  });
39
34
  app.use("/protected", workspaceMiddleware);
40
- app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
35
+ app.get("/protected", (c) =>
36
+ c.json({ workspaceId: c.get("workspaceId") }),
37
+ );
41
38
  return app;
42
39
  }
43
40
 
44
41
  describe("workspaceMiddleware", () => {
45
42
  beforeEach(() => {
46
43
  vi.clearAllMocks();
47
- resetMembershipCache();
48
- resetSessionCache();
49
- process.env["MEMBERSHIPS_TABLE_NAME"] = MEMBERSHIPS_TABLE;
50
- process.env["USER_SESSIONS_TABLE_NAME"] = SESSIONS_TABLE;
44
+ process.env["MEMBERSHIPS_TABLE_NAME"] = "gateway-prd-use1-memberships";
45
+ process.env["USERS_TABLE_NAME"] = "gateway-prd-use1-users";
51
46
  process.env["AWS_REGION"] = "us-east-1";
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");
52
54
  });
53
55
 
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" }] });
56
+ it("uses activeWorkspaceId from Users table when present", async () => {
57
+ // Users table returns activeWorkspaceId — no Memberships query needed
58
+ mockSend.mockResolvedValueOnce({
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();
60
67
  });
68
+ app.use("/protected", workspaceMiddleware);
69
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
61
70
 
62
- const app = buildApp("user-session");
63
71
  const res = await app.request("/protected");
64
72
  expect(res.status).toBe(200);
65
73
  const body = await res.json();
66
- expect(body.workspaceId).toBe("ws-b");
74
+ expect(body).toEqual({ workspaceId: "ws-from-users" });
75
+ // Only one DDB call (Users Get), no Memberships query
76
+ expect(mockSend).toHaveBeenCalledTimes(1);
67
77
  });
68
78
 
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" }] });
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();
75
92
  });
93
+ app.use("/protected", workspaceMiddleware);
94
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
76
95
 
77
- const app = buildApp("user-no-session");
78
96
  const res = await app.request("/protected");
79
97
  expect(res.status).toBe(200);
80
98
  const body = await res.json();
81
- expect(body.workspaceId).toBe("ws-first");
99
+ expect(body).toEqual({ workspaceId: "ws-from-memberships" });
100
+ expect(mockSend).toHaveBeenCalledTimes(2);
82
101
  });
83
102
 
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" }] });
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
+ });
109
+
110
+ const app = new Hono<{ Variables: AppVariables }>();
111
+ const localClaims = { sub: "user-sub-456" } as unknown as CognitoAccessTokenPayload;
112
+ app.use("/protected", async (c, next) => {
113
+ c.set("claims", localClaims);
114
+ await next();
90
115
  });
116
+ app.use("/protected", workspaceMiddleware);
117
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
91
118
 
92
- const app = buildApp("user-stale");
93
119
  const res = await app.request("/protected");
94
120
  expect(res.status).toBe(200);
95
121
  const body = await res.json();
96
- expect(body.workspaceId).toBe("ws-a");
122
+ expect(body).toEqual({ workspaceId: "ws-abc" });
97
123
  });
98
124
 
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: [] });
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
129
+
130
+ const app = new Hono<{ Variables: AppVariables }>();
131
+ const localClaims = {
132
+ sub: "user-no-ws",
133
+ } as unknown as CognitoAccessTokenPayload;
134
+ app.use("/protected", async (c, next) => {
135
+ c.set("claims", localClaims);
136
+ await next();
105
137
  });
138
+ app.use("/protected", workspaceMiddleware);
139
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
106
140
 
107
- const app = buildApp("user-no-ws");
108
141
  const res = await app.request("/protected");
109
142
  expect(res.status).toBe(403);
110
143
  const body = await res.json();
111
144
  expect(body).toMatchObject({ error: "No workspace membership" });
112
145
  });
113
146
 
114
- it("uses membership cache on subsequent calls for the same sub", async () => {
115
- mockSend.mockResolvedValue({ Items: [{ workspaceId: "ws-cached" }] });
147
+ it("uses cache on subsequent calls for the same sub", async () => {
148
+ mockSend.mockResolvedValueOnce({
149
+ Item: { activeWorkspaceId: "ws-cached" },
150
+ });
151
+
152
+ // First call — hits DDB
153
+ await resolveWorkspaceId("user-cached");
154
+ // Second call — should use cache
155
+ await resolveWorkspaceId("user-cached");
116
156
 
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
157
  expect(mockSend).toHaveBeenCalledTimes(1);
121
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
+ });
122
172
  });