@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,436 @@
1
+ import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
2
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
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
+ ConditionalCheckFailedException: class ConditionalCheckFailedException extends Error {
16
+ name = "ConditionalCheckFailedException";
17
+ },
18
+ }));
19
+
20
+ const mockDdbSend = vi.fn();
21
+ vi.mock("@aws-sdk/lib-dynamodb", () => ({
22
+ DynamoDBDocumentClient: {
23
+ from: vi.fn(() => ({ send: mockDdbSend })),
24
+ },
25
+ UpdateCommand: vi.fn((input) => input),
26
+ GetCommand: vi.fn((input) => input),
27
+ QueryCommand: vi.fn((input) => input),
28
+ DeleteCommand: vi.fn((input) => input),
29
+ }));
30
+
31
+ const mockS3Send = vi.fn();
32
+ vi.mock("@aws-sdk/client-s3", () => ({
33
+ S3Client: vi.fn(() => ({ send: mockS3Send })),
34
+ GetObjectCommand: vi.fn((input) => input),
35
+ PutObjectCommand: vi.fn((input) => input),
36
+ DeleteObjectCommand: vi.fn((input) => input),
37
+ }));
38
+
39
+ beforeAll(() => {
40
+ process.env["COGNITO_USER_POOL_ID"] = "us-east-1_test";
41
+ process.env["COGNITO_CLIENT_ID"] = "test-client-id";
42
+ process.env["AWS_REGION"] = "us-east-1";
43
+ process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
44
+ process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
45
+ process.env["USERS_TABLE_NAME"] = "test-users";
46
+ process.env["USER_SESSIONS_TABLE_NAME"] = "test-sessions";
47
+ });
48
+
49
+ const { createChatApp } = await import("../../src/app");
50
+
51
+ const USER_SUB = "vfs-test-unique-user-sub";
52
+ const WORKSPACE_ID = "ws-test-vfs-123";
53
+ const BEARER = "Bearer valid.token.here";
54
+
55
+ async function setupAuth() {
56
+ const { CognitoJwtVerifier } = await import("aws-jwt-verify");
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ (CognitoJwtVerifier.create({} as any) as any).verify.mockResolvedValue({
59
+ sub: USER_SUB,
60
+ } as unknown as CognitoAccessTokenPayload);
61
+ }
62
+
63
+ /**
64
+ * Install a mockImplementation that dispatches on table name so the workspace
65
+ * middleware's cache behaviour (hit vs miss) never affects VFS query mocks.
66
+ * Workspace calls go to "test-users"; VFS calls go to the optional "test-vfs".
67
+ */
68
+ function setupDdbRouter(vfsItems?: Array<Record<string, unknown>>) {
69
+ mockDdbSend.mockImplementation(
70
+ (command: { TableName?: string; Key?: unknown }) => {
71
+ if (command.TableName === "test-users") {
72
+ // Workspace resolution — GetCommand
73
+ return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
74
+ }
75
+ if (command.TableName === "test-memberships") {
76
+ // Fallback membership query (should not be reached)
77
+ return Promise.resolve({ Items: [{ workspaceId: WORKSPACE_ID }] });
78
+ }
79
+ if (command.TableName === "test-vfs") {
80
+ return Promise.resolve({ Items: vfsItems ?? [] });
81
+ }
82
+ return Promise.resolve({});
83
+ },
84
+ );
85
+ }
86
+
87
+ describe("GET /vfs?since=", () => {
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ delete process.env["VFS_TABLE_NAME"];
91
+ });
92
+
93
+ it("returns 401 without Authorization header", async () => {
94
+ const app = createChatApp();
95
+ const res = await app.request("/vfs?since=2026-01-01T00:00:00Z");
96
+ expect(res.status).toBe(401);
97
+ });
98
+
99
+ it("returns 400 when since is missing", async () => {
100
+ await setupAuth();
101
+ setupDdbRouter();
102
+
103
+ const app = createChatApp();
104
+ const res = await app.request("/vfs", {
105
+ headers: { Authorization: BEARER },
106
+ });
107
+ expect(res.status).toBe(400);
108
+ const body = await res.json() as { error: string };
109
+ expect(body.error).toMatch(/since/);
110
+ });
111
+
112
+ it("returns 400 when since is not a valid date", async () => {
113
+ await setupAuth();
114
+ setupDdbRouter();
115
+
116
+ const app = createChatApp();
117
+ const res = await app.request("/vfs?since=not-a-date", {
118
+ headers: { Authorization: BEARER },
119
+ });
120
+ expect(res.status).toBe(400);
121
+ const body = await res.json() as { error: string };
122
+ expect(body.error).toMatch(/RFC3339/);
123
+ });
124
+
125
+ it("returns [] when VFS_TABLE_NAME is not configured", async () => {
126
+ await setupAuth();
127
+ setupDdbRouter();
128
+
129
+ const app = createChatApp();
130
+ const res = await app.request("/vfs?since=2026-01-01T00:00:00Z", {
131
+ headers: { Authorization: BEARER },
132
+ });
133
+ expect(res.status).toBe(200);
134
+ const body = await res.json();
135
+ expect(body).toEqual([]);
136
+ });
137
+
138
+ it("queries DDB and returns changed files when table is configured", async () => {
139
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
140
+ await setupAuth();
141
+
142
+ const since = "2026-07-01T00:00:00.000Z";
143
+ const fakeItems = [
144
+ { SK: "file#src/index.ts", mtime: "2026-07-11T10:00:00.000Z", version: 3, size: 512 },
145
+ { SK: "file#src/app.tsx", mtime: "2026-07-11T10:05:00.000Z", version: 1, size: 1024 },
146
+ ];
147
+ setupDdbRouter(fakeItems);
148
+
149
+ const app = createChatApp();
150
+ const res = await app.request(`/vfs?since=${encodeURIComponent(since)}`, {
151
+ headers: { Authorization: BEARER },
152
+ });
153
+
154
+ expect(res.status).toBe(200);
155
+ const body = await res.json() as Array<{ path: string; mtime: string; version: number; size: number }>;
156
+ expect(body).toHaveLength(2);
157
+ expect(body[0]).toMatchObject({ path: "src/index.ts", version: 3, size: 512 });
158
+ expect(body[1]).toMatchObject({ path: "src/app.tsx", version: 1, size: 1024 });
159
+
160
+ // Verify the VFS query used the right PK and since filter.
161
+ const vfsCall = mockDdbSend.mock.calls
162
+ .map((c) => c[0] as { TableName?: string; ExpressionAttributeValues?: Record<string, unknown> })
163
+ .find((c) => c.TableName === "test-vfs");
164
+ expect(vfsCall).toBeDefined();
165
+ expect(vfsCall!.ExpressionAttributeValues?.[":pk"]).toBe(`workspace#${WORKSPACE_ID}`);
166
+ expect(vfsCall!.ExpressionAttributeValues?.[":since"]).toBe(since);
167
+ });
168
+
169
+ it("strips file# prefix from SK in the response path", async () => {
170
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
171
+ await setupAuth();
172
+ setupDdbRouter([{ SK: "file#components/Button.tsx", mtime: "2026-07-11T00:00:00.000Z" }]);
173
+
174
+ const app = createChatApp();
175
+ const res = await app.request("/vfs?since=2026-01-01T00:00:00Z", {
176
+ headers: { Authorization: BEARER },
177
+ });
178
+
179
+ const body = await res.json() as Array<{ path: string }>;
180
+ expect(body[0]?.path).toBe("components/Button.tsx");
181
+ });
182
+ });
183
+
184
+ describe("GET /vfs/config", () => {
185
+ beforeEach(async () => {
186
+ vi.clearAllMocks();
187
+ await setupAuth();
188
+ setupDdbRouter();
189
+ });
190
+
191
+ it("returns { usePaths: true }", async () => {
192
+ const app = createChatApp();
193
+ const res = await app.request("/vfs/config", {
194
+ headers: { Authorization: BEARER },
195
+ });
196
+ expect(res.status).toBe(200);
197
+ const body = await res.json() as { usePaths: boolean };
198
+ expect(body.usePaths).toBe(true);
199
+ });
200
+ });
201
+
202
+ describe("HEAD /vfs/:path", () => {
203
+ beforeEach(async () => {
204
+ vi.clearAllMocks();
205
+ await setupAuth();
206
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
207
+ });
208
+
209
+ it("returns 404 when VFS_TABLE_NAME is not set", async () => {
210
+ delete process.env["VFS_TABLE_NAME"];
211
+ setupDdbRouter();
212
+ const app = createChatApp();
213
+ const res = await app.request("/vfs/src/index.ts", { method: "HEAD", headers: { Authorization: BEARER } });
214
+ expect(res.status).toBe(404);
215
+ });
216
+
217
+ it("returns 200 when item exists in DDB", async () => {
218
+ mockDdbSend.mockImplementation((cmd: { TableName?: string; Key?: unknown }) => {
219
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
220
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Item: { SK: "file#src/index.ts" } });
221
+ return Promise.resolve({});
222
+ });
223
+
224
+ const app = createChatApp();
225
+ const res = await app.request("/vfs/src/index.ts", { method: "HEAD", headers: { Authorization: BEARER } });
226
+ expect(res.status).toBe(200);
227
+ });
228
+
229
+ it("returns 404 when item not found in DDB", async () => {
230
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
231
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
232
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Item: undefined });
233
+ return Promise.resolve({});
234
+ });
235
+
236
+ const app = createChatApp();
237
+ const res = await app.request("/vfs/missing.ts", { method: "HEAD", headers: { Authorization: BEARER } });
238
+ expect(res.status).toBe(404);
239
+ });
240
+ });
241
+
242
+ describe("GET /vfs/:path?stat=true", () => {
243
+ beforeEach(async () => {
244
+ vi.clearAllMocks();
245
+ await setupAuth();
246
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
247
+ });
248
+
249
+ it("returns file metadata from DDB", async () => {
250
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
251
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
252
+ if (cmd.TableName === "test-vfs") return Promise.resolve({
253
+ Item: { size: 1024, mtime: "2026-07-11T00:00:00.000Z", version: 5 },
254
+ });
255
+ return Promise.resolve({});
256
+ });
257
+
258
+ const app = createChatApp();
259
+ const res = await app.request("/vfs/src/index.ts?stat=true", { headers: { Authorization: BEARER } });
260
+ expect(res.status).toBe(200);
261
+ const body = await res.json() as { size: number; mtime: string; isFile: boolean; isDirectory: boolean };
262
+ expect(body.size).toBe(1024);
263
+ expect(body.isFile).toBe(true);
264
+ expect(body.isDirectory).toBe(false);
265
+ });
266
+
267
+ it("returns 404 when file not in DDB", async () => {
268
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
269
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
270
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Item: undefined });
271
+ return Promise.resolve({});
272
+ });
273
+
274
+ const app = createChatApp();
275
+ const res = await app.request("/vfs/nope.ts?stat=true", { headers: { Authorization: BEARER } });
276
+ expect(res.status).toBe(404);
277
+ });
278
+ });
279
+
280
+ describe("GET /vfs/:path?readdir=true", () => {
281
+ beforeEach(async () => {
282
+ vi.clearAllMocks();
283
+ await setupAuth();
284
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
285
+ });
286
+
287
+ it("collapses DDB items to one-level directory entries", async () => {
288
+ // Mock returns only items that DDB's begins_with(SK, "file#src/") would select.
289
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
290
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
291
+ if (cmd.TableName === "test-vfs") return Promise.resolve({
292
+ Items: [
293
+ { SK: "file#src/index.ts" },
294
+ { SK: "file#src/app.tsx" },
295
+ { SK: "file#src/lib/utils.ts" },
296
+ ],
297
+ });
298
+ return Promise.resolve({});
299
+ });
300
+
301
+ const app = createChatApp();
302
+ const res = await app.request("/vfs/src?readdir=true", { headers: { Authorization: BEARER } });
303
+ expect(res.status).toBe(200);
304
+ const body = await res.json() as Array<{ name: string; isDirectory: boolean }>;
305
+ const names = body.map((e) => e.name).sort();
306
+ expect(names).toEqual(["app.tsx", "index.ts", "lib"]);
307
+ expect(body.find((e) => e.name === "lib")?.isDirectory).toBe(true);
308
+ expect(body.find((e) => e.name === "index.ts")?.isDirectory).toBe(false);
309
+ });
310
+ });
311
+
312
+ describe("PUT /vfs/:path", () => {
313
+ beforeEach(async () => {
314
+ vi.clearAllMocks();
315
+ await setupAuth();
316
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
317
+ process.env["VFS_BUCKET_NAME"] = "test-vfs-bucket";
318
+ });
319
+
320
+ it("returns 503 when VFS not configured", async () => {
321
+ delete process.env["VFS_TABLE_NAME"];
322
+ delete process.env["VFS_BUCKET_NAME"];
323
+ setupDdbRouter();
324
+
325
+ const app = createChatApp();
326
+ const res = await app.request("/vfs/src/index.ts", {
327
+ method: "PUT",
328
+ headers: { Authorization: BEARER, "Content-Type": "text/plain" },
329
+ body: "const x = 1;",
330
+ });
331
+ expect(res.status).toBe(503);
332
+ });
333
+
334
+ it("writes to S3 and DDB, returns ok+version", async () => {
335
+ mockS3Send.mockResolvedValue({});
336
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
337
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
338
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Attributes: { version: 1 } });
339
+ return Promise.resolve({});
340
+ });
341
+
342
+ const app = createChatApp();
343
+ const res = await app.request("/vfs/src/index.ts", {
344
+ method: "PUT",
345
+ headers: { Authorization: BEARER, "Content-Type": "text/plain" },
346
+ body: "const x = 1;",
347
+ });
348
+ expect(res.status).toBe(200);
349
+ const body = await res.json() as { ok: boolean; version: number };
350
+ expect(body.ok).toBe(true);
351
+ expect(body.version).toBe(1);
352
+ expect(mockS3Send).toHaveBeenCalledTimes(1);
353
+ });
354
+
355
+ it("returns 409 on concurrent write conflict", async () => {
356
+ const { ConditionalCheckFailedException } = await import("@aws-sdk/client-dynamodb");
357
+ mockS3Send.mockResolvedValue({});
358
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
359
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
360
+ if (cmd.TableName === "test-vfs") {
361
+ // First call (UpdateCommand) throws; second call (GetCommand for conflict metadata) succeeds
362
+ if (mockDdbSend.mock.calls.filter((c) => (c[0] as { TableName?: string }).TableName === "test-vfs").length === 1) {
363
+ throw new ConditionalCheckFailedException("conflict");
364
+ }
365
+ return Promise.resolve({ Item: { version: 3 } });
366
+ }
367
+ return Promise.resolve({});
368
+ });
369
+
370
+ const app = createChatApp();
371
+ const res = await app.request("/vfs/src/index.ts", {
372
+ method: "PUT",
373
+ headers: {
374
+ Authorization: BEARER,
375
+ "Content-Type": "text/plain",
376
+ "X-Vfs-Expected-Version": "2",
377
+ },
378
+ body: "conflict",
379
+ });
380
+ expect(res.status).toBe(409);
381
+ const body = await res.json() as { ok: boolean; conflict: { serverVersion: number } };
382
+ expect(body.ok).toBe(false);
383
+ expect(body.conflict.serverVersion).toBe(3);
384
+ });
385
+ });
386
+
387
+ describe("DELETE /vfs/:path", () => {
388
+ beforeEach(async () => {
389
+ vi.clearAllMocks();
390
+ await setupAuth();
391
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
392
+ process.env["VFS_BUCKET_NAME"] = "test-vfs-bucket";
393
+ });
394
+
395
+ it("deletes from DDB and S3, returns 204", async () => {
396
+ mockS3Send.mockResolvedValue({});
397
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
398
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
399
+ return Promise.resolve({});
400
+ });
401
+
402
+ const app = createChatApp();
403
+ const res = await app.request("/vfs/src/old.ts", {
404
+ method: "DELETE",
405
+ headers: { Authorization: BEARER },
406
+ });
407
+ expect(res.status).toBe(204);
408
+ expect(mockS3Send).toHaveBeenCalledTimes(1);
409
+ });
410
+ });
411
+
412
+ describe("POST /vfs/:path?mkdir=true", () => {
413
+ beforeEach(async () => {
414
+ vi.clearAllMocks();
415
+ await setupAuth();
416
+ setupDdbRouter();
417
+ });
418
+
419
+ it("returns 204 (no-op)", async () => {
420
+ const app = createChatApp();
421
+ const res = await app.request("/vfs/src/components?mkdir=true", {
422
+ method: "POST",
423
+ headers: { Authorization: BEARER },
424
+ });
425
+ expect(res.status).toBe(204);
426
+ });
427
+
428
+ it("returns 400 for unknown POST operations", async () => {
429
+ const app = createChatApp();
430
+ const res = await app.request("/vfs/src/something", {
431
+ method: "POST",
432
+ headers: { Authorization: BEARER },
433
+ });
434
+ expect(res.status).toBe(400);
435
+ });
436
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
2
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
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
+ const mockDdbSend = vi.fn();
18
+ vi.mock("@aws-sdk/lib-dynamodb", () => ({
19
+ DynamoDBDocumentClient: {
20
+ from: vi.fn(() => ({ send: mockDdbSend })),
21
+ },
22
+ UpdateCommand: vi.fn((input) => input),
23
+ GetCommand: vi.fn((input) => input),
24
+ QueryCommand: 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["USERS_TABLE_NAME"] = "test-users";
34
+ });
35
+
36
+ const { createChatApp } = await import("../../src/app");
37
+
38
+ const USER_SUB = "test-user-sub";
39
+ const BEARER = "Bearer valid.token.here";
40
+
41
+ async function makeVerify(sub: string) {
42
+ const { CognitoJwtVerifier } = await import("aws-jwt-verify");
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ (CognitoJwtVerifier.create({} as any) as any).verify.mockResolvedValue({
45
+ sub,
46
+ } as unknown as CognitoAccessTokenPayload);
47
+ }
48
+
49
+ describe("POST /api/workspace", () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ });
53
+
54
+ it("returns 401 without Authorization header", async () => {
55
+ const app = createChatApp();
56
+ const res = await app.request("/api/workspace", { method: "POST" });
57
+ expect(res.status).toBe(401);
58
+ });
59
+
60
+ it("switches workspace and evicts cache", async () => {
61
+ await makeVerify(USER_SUB);
62
+ mockDdbSend.mockResolvedValueOnce({}); // UpdateCommand succeeds
63
+
64
+ const app = createChatApp();
65
+ const res = await app.request("/api/workspace", {
66
+ method: "POST",
67
+ headers: {
68
+ Authorization: BEARER,
69
+ "Content-Type": "application/json",
70
+ },
71
+ body: JSON.stringify({ workspaceId: "ws-new" }),
72
+ });
73
+
74
+ expect(res.status).toBe(200);
75
+ const body = await res.json() as { activeWorkspaceId: string };
76
+ expect(body.activeWorkspaceId).toBe("ws-new");
77
+
78
+ // UpdateCommand should have been called with USERS_TABLE_NAME
79
+ expect(mockDdbSend).toHaveBeenCalledTimes(1);
80
+ const call = mockDdbSend.mock.calls[0]![0] as {
81
+ TableName?: string;
82
+ Key?: Record<string, unknown>;
83
+ ExpressionAttributeValues?: Record<string, unknown>;
84
+ };
85
+ expect(call.TableName).toBe("test-users");
86
+ expect(call.Key?.["sub"]).toBe(USER_SUB);
87
+ expect(call.ExpressionAttributeValues?.[":ws"]).toBe("ws-new");
88
+ });
89
+
90
+ it("returns 400 when workspaceId is missing", async () => {
91
+ await makeVerify(USER_SUB);
92
+
93
+ const app = createChatApp();
94
+ const res = await app.request("/api/workspace", {
95
+ method: "POST",
96
+ headers: {
97
+ Authorization: BEARER,
98
+ "Content-Type": "application/json",
99
+ },
100
+ body: JSON.stringify({}),
101
+ });
102
+
103
+ expect(res.status).toBe(400);
104
+ const body = await res.json() as { error: string };
105
+ expect(body.error).toMatch(/workspaceId/);
106
+ });
107
+ });
@@ -1,60 +0,0 @@
1
- /**
2
- * POST /api/proxy/:ns/:proc
3
- *
4
- * Forwards the request body to `POST <gateway>/tools/:ns/:proc` using the
5
- * caller's gateway session bearer. This keeps tool invocations server-side
6
- * (credentials never reach the browser).
7
- */
8
-
9
- import { Hono } from "hono";
10
- import { evictGatewaySession, GatewaySessionError, getGatewaySession } from "../gateway-session.js";
11
- import type { AppVariables } from "../types.js";
12
-
13
- export const proxy = new Hono<{ Variables: AppVariables }>();
14
-
15
- proxy.post("/:ns/:proc{.*}", async (c) => {
16
- const claims = c.get("claims");
17
- const workspaceId = c.get("workspaceId");
18
-
19
- const ns = c.req.param("ns");
20
- const proc = c.req.param("proc");
21
-
22
- const authHeader = c.req.header("Authorization")!;
23
- const cognitoToken = authHeader.slice("Bearer ".length);
24
-
25
- let sessionToken: string;
26
- try {
27
- const session = await getGatewaySession(claims, workspaceId, cognitoToken);
28
- sessionToken = session.token;
29
- } catch (err) {
30
- if (err instanceof GatewaySessionError && err.status === 401) {
31
- return c.json({ error: "Gateway session setup failed" }, 401);
32
- }
33
- return c.json({ error: "Failed to connect to gateway" }, 502);
34
- }
35
-
36
- let body: unknown = {};
37
- try {
38
- body = await c.req.json();
39
- } catch {
40
- // empty body is fine
41
- }
42
-
43
- const gatewayUrl = process.env["GATEWAY_URL"]!.replace(/\/$/, "");
44
- const res = await fetch(`${gatewayUrl}/tools/${ns}/${proc}`, {
45
- method: "POST",
46
- headers: {
47
- "Content-Type": "application/json",
48
- Authorization: `Bearer ${sessionToken}`,
49
- },
50
- body: JSON.stringify(body),
51
- });
52
-
53
- if (res.status === 401) {
54
- evictGatewaySession(claims.sub);
55
- return c.json({ error: "Gateway authentication failed" }, 502);
56
- }
57
-
58
- const responseData = await res.json();
59
- return c.json(responseData, res.status as 200);
60
- });
@@ -1,82 +0,0 @@
1
- /**
2
- * GET /api/services
3
- *
4
- * Fetches the gateway's tool list and returns a service summary the frontend
5
- * uses to populate namespace suggestions and the ServicesInspector panel.
6
- *
7
- * Response shape:
8
- * { namespaces: string[], services: ServiceInfo[] }
9
- *
10
- * where ServiceInfo matches the frontend's expected `ServiceInfo` type:
11
- * { namespace: string, name: string, description?: string }
12
- */
13
-
14
- import { Hono } from "hono";
15
- import { evictGatewaySession, GatewaySessionError, getGatewaySession } from "../gateway-session.js";
16
- import type { AppVariables } from "../types.js";
17
-
18
- export interface ServiceInfo {
19
- namespace: string;
20
- name: string;
21
- procedure: string;
22
- description: string;
23
- parameters?: Record<string, unknown>;
24
- }
25
-
26
- export const services = new Hono<{ Variables: AppVariables }>();
27
-
28
- services.get("/", async (c) => {
29
- const claims = c.get("claims");
30
- const workspaceId = c.get("workspaceId");
31
-
32
- const authHeader = c.req.header("Authorization")!;
33
- const cognitoToken = authHeader.slice("Bearer ".length);
34
-
35
- let sessionToken: string;
36
- try {
37
- const session = await getGatewaySession(claims, workspaceId, cognitoToken);
38
- sessionToken = session.token;
39
- } catch (err) {
40
- if (err instanceof GatewaySessionError && err.status === 401) {
41
- return c.json({ error: "Gateway session setup failed" }, 401);
42
- }
43
- return c.json({ error: "Failed to connect to gateway" }, 502);
44
- }
45
-
46
- const gatewayUrl = process.env["GATEWAY_URL"]!.replace(/\/$/, "");
47
- const res = await fetch(`${gatewayUrl}/tools`, {
48
- headers: { Authorization: `Bearer ${sessionToken}` },
49
- });
50
-
51
- if (res.status === 401) {
52
- evictGatewaySession(claims.sub);
53
- return c.json({ error: "Gateway authentication failed" }, 502);
54
- }
55
-
56
- if (!res.ok) {
57
- return c.json({ error: "Gateway tools fetch failed" }, 502);
58
- }
59
-
60
- const data = await res.json() as {
61
- tools: Array<{
62
- provider: string;
63
- name: string;
64
- operation: string;
65
- description?: string;
66
- inputSchema?: unknown;
67
- }>;
68
- workspace_id: string;
69
- };
70
-
71
- const serviceList: ServiceInfo[] = data.tools.map((t) => ({
72
- namespace: t.provider,
73
- name: t.name,
74
- procedure: t.operation,
75
- description: t.description ?? "",
76
- parameters: t.inputSchema as Record<string, unknown> | undefined,
77
- }));
78
-
79
- const namespaces = Array.from(new Set(data.tools.map((t) => t.provider)));
80
-
81
- return c.json({ namespaces, services: serviceList });
82
- });