@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.
- package/.turbo/turbo-build.log +7 -7
- package/dist/index.d.ts +9 -3
- package/dist/index.js +405 -154
- package/dist/index.js.map +1 -1
- package/dist/lambda.js +405 -154
- package/dist/lambda.js.map +1 -1
- package/package.json +2 -1
- package/src/app.ts +18 -7
- package/src/env.ts +5 -1
- package/src/middleware/workspace.ts +55 -55
- package/src/routes/vfs.ts +369 -0
- package/src/routes/workspace.ts +41 -0
- package/test/app.test.ts +2 -3
- package/test/middleware/workspace.test.ts +104 -54
- package/test/routes/vfs.test.ts +436 -0
- package/test/routes/workspace.test.ts +107 -0
- package/src/routes/proxy.ts +0 -60
- package/src/routes/services.ts +0 -82
- package/test/routes/proxy.test.ts +0 -133
- package/test/routes/services.test.ts +0 -128
|
@@ -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
|
-
|
|
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["
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
28
|
-
|
|
24
|
+
const fakeClaims = {
|
|
25
|
+
sub: "user-sub-123",
|
|
26
|
+
} as unknown as CognitoAccessTokenPayload;
|
|
29
27
|
|
|
30
|
-
|
|
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) =>
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
99
|
+
expect(body).toEqual({ workspaceId: "ws-from-memberships" });
|
|
100
|
+
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
82
101
|
});
|
|
83
102
|
|
|
84
|
-
it("
|
|
85
|
-
mockSend
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
122
|
+
expect(body).toEqual({ workspaceId: "ws-abc" });
|
|
97
123
|
});
|
|
98
124
|
|
|
99
|
-
it("returns 403 when
|
|
100
|
-
mockSend
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
115
|
-
mockSend.
|
|
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
|
});
|