@aprovan/chat-backend 0.1.0-dev.99f9769 → 0.1.0-dev.9c336a0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -0,0 +1,89 @@
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 ADDED
@@ -0,0 +1,80 @@
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
+ }
package/test/app.test.ts CHANGED
@@ -20,6 +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
+ UpdateCommand: vi.fn((input) => input),
23
24
  }));
24
25
 
25
26
  beforeAll(() => {
@@ -28,6 +29,7 @@ beforeAll(() => {
28
29
  process.env["AWS_REGION"] = "us-east-1";
29
30
  process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
30
31
  process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
32
+ process.env["USERS_TABLE_NAME"] = "test-users";
31
33
  });
32
34
 
33
35
  describe("chat app", () => {