@powerhousedao/switchboard 6.0.0-dev.21 → 6.0.0-dev.211

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/Auth.md +45 -27
  2. package/CHANGELOG.md +1642 -5
  3. package/README.md +13 -12
  4. package/dist/index.d.mts +1 -0
  5. package/dist/index.mjs +129 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/install-packages.d.mts +1 -0
  8. package/dist/install-packages.mjs +31 -0
  9. package/dist/install-packages.mjs.map +1 -0
  10. package/dist/migrate.d.mts +1 -0
  11. package/dist/migrate.mjs +55 -0
  12. package/dist/migrate.mjs.map +1 -0
  13. package/dist/server-8U7q7B7r.mjs +493 -0
  14. package/dist/server-8U7q7B7r.mjs.map +1 -0
  15. package/dist/server.d.mts +93 -0
  16. package/dist/server.d.mts.map +1 -0
  17. package/dist/server.mjs +4 -0
  18. package/dist/utils-DFl0ezBT.mjs +44 -0
  19. package/dist/utils-DFl0ezBT.mjs.map +1 -0
  20. package/dist/utils.d.mts +9 -0
  21. package/dist/utils.d.mts.map +1 -0
  22. package/dist/utils.mjs +2 -0
  23. package/package.json +54 -39
  24. package/test/attachments/auth.test.ts +219 -0
  25. package/test/attachments/index.test.ts +119 -0
  26. package/test/attachments/routes-integration.test.ts +103 -0
  27. package/test/attachments/routes.test.ts +501 -0
  28. package/test/metrics.test.ts +202 -0
  29. package/tsconfig.json +12 -3
  30. package/tsdown.config.ts +16 -0
  31. package/vitest.config.ts +11 -0
  32. package/Dockerfile +0 -86
  33. package/dist/src/clients/redis.d.ts +0 -5
  34. package/dist/src/clients/redis.d.ts.map +0 -1
  35. package/dist/src/clients/redis.js +0 -48
  36. package/dist/src/clients/redis.js.map +0 -1
  37. package/dist/src/config.d.ts +0 -12
  38. package/dist/src/config.d.ts.map +0 -1
  39. package/dist/src/config.js +0 -33
  40. package/dist/src/config.js.map +0 -1
  41. package/dist/src/connect-crypto.d.ts +0 -41
  42. package/dist/src/connect-crypto.d.ts.map +0 -1
  43. package/dist/src/connect-crypto.js +0 -127
  44. package/dist/src/connect-crypto.js.map +0 -1
  45. package/dist/src/feature-flags.d.ts +0 -2
  46. package/dist/src/feature-flags.d.ts.map +0 -1
  47. package/dist/src/feature-flags.js +0 -9
  48. package/dist/src/feature-flags.js.map +0 -1
  49. package/dist/src/index.d.ts +0 -3
  50. package/dist/src/index.d.ts.map +0 -1
  51. package/dist/src/index.js +0 -21
  52. package/dist/src/index.js.map +0 -1
  53. package/dist/src/install-packages.d.ts +0 -2
  54. package/dist/src/install-packages.d.ts.map +0 -1
  55. package/dist/src/install-packages.js +0 -36
  56. package/dist/src/install-packages.js.map +0 -1
  57. package/dist/src/migrate.d.ts +0 -3
  58. package/dist/src/migrate.d.ts.map +0 -1
  59. package/dist/src/migrate.js +0 -65
  60. package/dist/src/migrate.js.map +0 -1
  61. package/dist/src/profiler.d.ts +0 -4
  62. package/dist/src/profiler.d.ts.map +0 -1
  63. package/dist/src/profiler.js +0 -17
  64. package/dist/src/profiler.js.map +0 -1
  65. package/dist/src/server.d.ts +0 -6
  66. package/dist/src/server.d.ts.map +0 -1
  67. package/dist/src/server.js +0 -304
  68. package/dist/src/server.js.map +0 -1
  69. package/dist/src/types.d.ts +0 -64
  70. package/dist/src/types.d.ts.map +0 -1
  71. package/dist/src/types.js +0 -2
  72. package/dist/src/types.js.map +0 -1
  73. package/dist/src/utils.d.ts +0 -6
  74. package/dist/src/utils.d.ts.map +0 -1
  75. package/dist/src/utils.js +0 -92
  76. package/dist/src/utils.js.map +0 -1
  77. package/dist/tsconfig.tsbuildinfo +0 -1
  78. package/entrypoint.sh +0 -17
@@ -0,0 +1,103 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import {
3
+ AttachmentBuilder,
4
+ type AttachmentBuildResult,
5
+ createRemoteAttachmentService,
6
+ } from "@powerhousedao/reactor-attachments";
7
+ import type { API } from "@powerhousedao/reactor-api";
8
+ import { createHttpAdapter } from "@powerhousedao/reactor-api";
9
+ import { Kysely } from "kysely";
10
+ import { PGliteDialect } from "kysely-pglite-dialect";
11
+ import { mkdtemp, rm } from "node:fs/promises";
12
+ import type { Server } from "node:http";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
16
+ import { registerAttachmentRoutes } from "../../src/attachments/index.js";
17
+
18
+ // SHA-256 of the empty string. If body-parser drains the upload body, the
19
+ // handler hashes zero bytes and returns this value -- the silent-data-loss
20
+ // signature this test guards against.
21
+ const EMPTY_STRING_SHA256 =
22
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
23
+
24
+ describe("attachment routes through the real Express middleware stack", () => {
25
+ let attachments: AttachmentBuildResult;
26
+ let kysely: Kysely<unknown>;
27
+ let storagePath: string;
28
+ let server: Server;
29
+ let baseUrl: string;
30
+
31
+ beforeAll(async () => {
32
+ const pglite = new PGlite();
33
+ kysely = new Kysely<unknown>({ dialect: new PGliteDialect(pglite) });
34
+ storagePath = await mkdtemp(join(tmpdir(), "switchboard-attach-int-"));
35
+ attachments = await new AttachmentBuilder(kysely, storagePath).build();
36
+
37
+ const { adapter } = createHttpAdapter("express");
38
+ // Install bodyParser.json + cors -- the production middleware stack that
39
+ // drained JSON request bodies before the upload handler could read them.
40
+ adapter.setupMiddleware({});
41
+ registerAttachmentRoutes({
42
+ httpAdapter: adapter,
43
+ attachments,
44
+ authService: undefined,
45
+ } as unknown as API);
46
+
47
+ server = await adapter.listen(0);
48
+ const addr = server.address();
49
+ if (!addr || typeof addr === "string") throw new Error("no addr");
50
+ baseUrl = `http://127.0.0.1:${addr.port}`;
51
+ });
52
+
53
+ afterAll(async () => {
54
+ await new Promise<void>((resolve) => server.close(() => resolve()));
55
+ await kysely.destroy();
56
+ await rm(storagePath, { recursive: true, force: true });
57
+ });
58
+
59
+ it("RemoteAttachmentUpload.send() round-trips JSON payloads through the Express body-parser stack", async () => {
60
+ // application/json reservations were the failure case: the client used to
61
+ // PUT with Content-Type: application/json, which body-parser consumed
62
+ // before the route handler ran. The fix sends Content-Type:
63
+ // application/octet-stream regardless of the reserved mime type.
64
+ const service = createRemoteAttachmentService({ remoteUrl: baseUrl });
65
+ const upload = await service.reserve({
66
+ mimeType: "application/json",
67
+ fileName: "doc.json",
68
+ extension: "json",
69
+ });
70
+
71
+ const payload = '{"hello":"world","n":42}';
72
+ const bytes = new TextEncoder().encode(payload);
73
+ const stream = new ReadableStream<Uint8Array>({
74
+ start(controller) {
75
+ controller.enqueue(bytes);
76
+ controller.close();
77
+ },
78
+ });
79
+ const result = await upload.send(stream);
80
+
81
+ expect(result.hash).not.toBe(EMPTY_STRING_SHA256);
82
+ expect(result.header.sizeBytes).toBe(bytes.byteLength);
83
+
84
+ const got = await service.get(result.ref);
85
+ expect(got.header.sizeBytes).toBe(bytes.byteLength);
86
+ expect(got.header.mimeType).toBe("application/json");
87
+ const reader = got.body.getReader();
88
+ const chunks: Uint8Array[] = [];
89
+ for (;;) {
90
+ const { done, value } = await reader.read();
91
+ if (done) break;
92
+ chunks.push(value);
93
+ }
94
+ const total = chunks.reduce((n, c) => n + c.byteLength, 0);
95
+ const merged = new Uint8Array(total);
96
+ let off = 0;
97
+ for (const c of chunks) {
98
+ merged.set(c, off);
99
+ off += c.byteLength;
100
+ }
101
+ expect(new TextDecoder().decode(merged)).toBe(payload);
102
+ });
103
+ });
@@ -0,0 +1,501 @@
1
+ import { PGlite } from "@electric-sql/pglite";
2
+ import {
3
+ AttachmentBuilder,
4
+ type AttachmentBuildResult,
5
+ } from "@powerhousedao/reactor-attachments";
6
+ import { Kysely } from "kysely";
7
+ import { PGliteDialect } from "kysely-pglite-dialect";
8
+ import { mkdtemp, rm } from "node:fs/promises";
9
+ import type { IncomingMessage, ServerResponse } from "node:http";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { Readable, Writable } from "node:stream";
13
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
14
+ import {
15
+ buildContentDisposition,
16
+ makeDownloadHandler,
17
+ makeReserveHandler,
18
+ makeUploadHandler,
19
+ parseReserveOptions,
20
+ quoteFilename,
21
+ } from "../../src/attachments/routes.js";
22
+
23
+ type CapturedRes = ServerResponse & {
24
+ _headers: Record<string, string>;
25
+ _body: Buffer;
26
+ _done: Promise<void>;
27
+ };
28
+
29
+ function makeReq(opts: {
30
+ method: string;
31
+ url?: string;
32
+ body?: Buffer | string;
33
+ params?: Record<string, string>;
34
+ }): IncomingMessage {
35
+ const buf =
36
+ typeof opts.body === "string"
37
+ ? Buffer.from(opts.body, "utf8")
38
+ : (opts.body ?? Buffer.alloc(0));
39
+ const req = Readable.from(buf.length === 0 ? [] : [buf]) as Readable & {
40
+ method: string;
41
+ url?: string;
42
+ headers: Record<string, string>;
43
+ params?: Record<string, string>;
44
+ };
45
+ req.method = opts.method;
46
+ req.url = opts.url ?? "/";
47
+ req.headers = {};
48
+ if (opts.params) req.params = opts.params;
49
+ return req as unknown as IncomingMessage;
50
+ }
51
+
52
+ function makeRes(): CapturedRes {
53
+ const chunks: Buffer[] = [];
54
+ const headers: Record<string, string> = {};
55
+ const writable = new Writable({
56
+ write(chunk: string | Buffer, _encoding, callback) {
57
+ chunks.push(
58
+ typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
59
+ );
60
+ callback();
61
+ },
62
+ });
63
+ const done = new Promise<void>((resolve) => {
64
+ writable.once("finish", resolve);
65
+ });
66
+ Object.assign(writable, {
67
+ statusCode: 200,
68
+ _headers: headers,
69
+ setHeader(name: string, value: string | number | readonly string[]) {
70
+ headers[name.toLowerCase()] = String(value);
71
+ },
72
+ getHeader(name: string) {
73
+ return headers[name.toLowerCase()];
74
+ },
75
+ _done: done,
76
+ });
77
+ Object.defineProperty(writable, "_body", {
78
+ get(): Buffer {
79
+ return Buffer.concat(chunks);
80
+ },
81
+ });
82
+ return writable as unknown as CapturedRes;
83
+ }
84
+
85
+ async function waitFor(res: CapturedRes): Promise<void> {
86
+ await res._done;
87
+ }
88
+
89
+ describe("attachment routes", () => {
90
+ let attachments: AttachmentBuildResult;
91
+ let storagePath: string;
92
+ let kysely: Kysely<unknown>;
93
+ let cleanup: () => Promise<void>;
94
+
95
+ beforeEach(async () => {
96
+ const pglite = new PGlite();
97
+ kysely = new Kysely<unknown>({ dialect: new PGliteDialect(pglite) });
98
+ storagePath = await mkdtemp(join(tmpdir(), "switchboard-attach-"));
99
+ attachments = await new AttachmentBuilder(kysely, storagePath).build();
100
+ cleanup = async () => {
101
+ await kysely.destroy();
102
+ await rm(storagePath, { recursive: true, force: true });
103
+ };
104
+ });
105
+
106
+ afterEach(async () => {
107
+ await cleanup();
108
+ });
109
+
110
+ it("POST reserve returns 201 with reservationId for valid body", async () => {
111
+ const handler = makeReserveHandler(attachments);
112
+ const req = makeReq({
113
+ method: "POST",
114
+ body: JSON.stringify({ mimeType: "text/plain", fileName: "hello.txt" }),
115
+ });
116
+ const res = makeRes();
117
+ await handler(req, res);
118
+ await waitFor(res);
119
+ expect(res.statusCode).toBe(201);
120
+ const body = JSON.parse(res._body.toString("utf8")) as {
121
+ reservationId: string;
122
+ };
123
+ expect(body.reservationId).toMatch(/.+/);
124
+ });
125
+
126
+ it("POST reserve returns 400 for missing fields", async () => {
127
+ const handler = makeReserveHandler(attachments);
128
+ const req = makeReq({
129
+ method: "POST",
130
+ body: JSON.stringify({ mimeType: "text/plain" }),
131
+ });
132
+ const res = makeRes();
133
+ await handler(req, res);
134
+ await waitFor(res);
135
+ expect(res.statusCode).toBe(400);
136
+ });
137
+
138
+ it("PUT upload returns 404 for unknown reservation", async () => {
139
+ const handler = makeUploadHandler(attachments);
140
+ const req = makeReq({
141
+ method: "PUT",
142
+ params: { reservationId: "00000000-0000-0000-0000-000000000000" },
143
+ body: "hello",
144
+ });
145
+ const res = makeRes();
146
+ await handler(req, res);
147
+ await waitFor(res);
148
+ expect(res.statusCode).toBe(404);
149
+ });
150
+
151
+ it("full reserve -> upload -> download cycle round-trips bytes", async () => {
152
+ // Reserve
153
+ const reserveHandler = makeReserveHandler(attachments);
154
+ const reserveReq = makeReq({
155
+ method: "POST",
156
+ body: JSON.stringify({
157
+ mimeType: "text/plain",
158
+ fileName: "hello.txt",
159
+ extension: "txt",
160
+ }),
161
+ });
162
+ const reserveRes = makeRes();
163
+ await reserveHandler(reserveReq, reserveRes);
164
+ await waitFor(reserveRes);
165
+ expect(reserveRes.statusCode).toBe(201);
166
+ const { reservationId } = JSON.parse(reserveRes._body.toString("utf8")) as {
167
+ reservationId: string;
168
+ };
169
+
170
+ // Upload
171
+ const uploadHandler = makeUploadHandler(attachments);
172
+ const payload = "hello world";
173
+ const uploadReq = makeReq({
174
+ method: "PUT",
175
+ params: { reservationId },
176
+ body: payload,
177
+ });
178
+ const uploadRes = makeRes();
179
+ await uploadHandler(uploadReq, uploadRes);
180
+ await waitFor(uploadRes);
181
+ expect(uploadRes.statusCode).toBe(200);
182
+ const upload = JSON.parse(uploadRes._body.toString("utf8")) as {
183
+ hash: string;
184
+ ref: string;
185
+ header: { mimeType: string; fileName: string; sizeBytes: number };
186
+ };
187
+ expect(upload.hash).toMatch(/^[a-f0-9]{64}$/);
188
+ expect(upload.ref).toBe(`attachment://v1:${upload.hash}`);
189
+ expect(upload.header.sizeBytes).toBe(payload.length);
190
+
191
+ // Reservation should be cleaned up
192
+ await expect(attachments.reservations.get(reservationId)).rejects.toThrow();
193
+
194
+ // Download
195
+ const downloadHandler = makeDownloadHandler(attachments);
196
+ const downloadReq = makeReq({
197
+ method: "GET",
198
+ params: { hash: upload.hash },
199
+ });
200
+ const downloadRes = makeRes();
201
+ await downloadHandler(downloadReq, downloadRes);
202
+ await waitFor(downloadRes);
203
+ expect(downloadRes.statusCode).toBe(200);
204
+ expect(downloadRes._body.toString("utf8")).toBe(payload);
205
+ expect(downloadRes.getHeader("content-type")).toBe("text/plain");
206
+ expect(downloadRes.getHeader("content-length")).toBe(
207
+ String(payload.length),
208
+ );
209
+ expect(downloadRes.getHeader("content-disposition")).toContain("hello.txt");
210
+ const meta = JSON.parse(
211
+ downloadRes.getHeader("x-attachment-metadata") as string,
212
+ ) as { fileName: string; mimeType: string; sizeBytes: number };
213
+ expect(meta.fileName).toBe("hello.txt");
214
+ expect(meta.mimeType).toBe("text/plain");
215
+ expect(meta.sizeBytes).toBe(payload.length);
216
+ });
217
+
218
+ it("GET download returns 404 for unknown hash", async () => {
219
+ const handler = makeDownloadHandler(attachments);
220
+ const req = makeReq({
221
+ method: "GET",
222
+ params: { hash: "a".repeat(64) },
223
+ });
224
+ const res = makeRes();
225
+ await handler(req, res);
226
+ await waitFor(res);
227
+ expect(res.statusCode).toBe(404);
228
+ });
229
+
230
+ it("GET download returns 400 for malformed hash", async () => {
231
+ const handler = makeDownloadHandler(attachments);
232
+ const req = makeReq({
233
+ method: "GET",
234
+ params: { hash: "not-a-hash" },
235
+ });
236
+ const res = makeRes();
237
+ await handler(req, res);
238
+ await waitFor(res);
239
+ expect(res.statusCode).toBe(400);
240
+ });
241
+
242
+ it("GET download returns 400 for uppercase hash", async () => {
243
+ const handler = makeDownloadHandler(attachments);
244
+ const req = makeReq({
245
+ method: "GET",
246
+ params: { hash: "A".repeat(64) },
247
+ });
248
+ const res = makeRes();
249
+ await handler(req, res);
250
+ await waitFor(res);
251
+ expect(res.statusCode).toBe(400);
252
+ expect(JSON.parse(res._body.toString("utf8"))).toEqual({
253
+ error: "Invalid attachment hash",
254
+ });
255
+ });
256
+
257
+ it("PUT upload returns opaque 500 when reservation lookup throws an unmapped error", async () => {
258
+ const secret = "INTERNAL_DB_PATH=/var/secret/db.sock";
259
+ const originalGet = attachments.reservations.get.bind(
260
+ attachments.reservations,
261
+ );
262
+ attachments.reservations.get = () => {
263
+ throw new Error(secret);
264
+ };
265
+ try {
266
+ const handler = makeUploadHandler(attachments);
267
+ const req = makeReq({
268
+ method: "PUT",
269
+ params: { reservationId: "00000000-0000-0000-0000-000000000000" },
270
+ body: "hello",
271
+ });
272
+ const res = makeRes();
273
+ await handler(req, res);
274
+ await waitFor(res);
275
+ expect(res.statusCode).toBe(500);
276
+ const bodyText = res._body.toString("utf8");
277
+ expect(JSON.parse(bodyText)).toEqual({ error: "Internal error" });
278
+ expect(bodyText).not.toContain(secret);
279
+ } finally {
280
+ attachments.reservations.get = originalGet;
281
+ }
282
+ });
283
+
284
+ it("GET download returns opaque 500 when store throws an unmapped error", async () => {
285
+ const secret = "INTERNAL_FS_PATH=/var/secret/blobs";
286
+ const originalGet = attachments.store.get.bind(attachments.store);
287
+ attachments.store.get = () => {
288
+ throw new Error(secret);
289
+ };
290
+ try {
291
+ const handler = makeDownloadHandler(attachments);
292
+ const req = makeReq({
293
+ method: "GET",
294
+ params: { hash: "a".repeat(64) },
295
+ });
296
+ const res = makeRes();
297
+ await handler(req, res);
298
+ await waitFor(res);
299
+ expect(res.statusCode).toBe(500);
300
+ const bodyText = res._body.toString("utf8");
301
+ expect(JSON.parse(bodyText)).toEqual({ error: "Internal error" });
302
+ expect(bodyText).not.toContain(secret);
303
+ } finally {
304
+ attachments.store.get = originalGet;
305
+ }
306
+ });
307
+
308
+ it("identical uploads dedupe to the same hash", async () => {
309
+ const reserveHandler = makeReserveHandler(attachments);
310
+ const uploadHandler = makeUploadHandler(attachments);
311
+
312
+ const doRoundTrip = async (): Promise<string> => {
313
+ const r1 = makeReq({
314
+ method: "POST",
315
+ body: JSON.stringify({ mimeType: "text/plain", fileName: "x.txt" }),
316
+ });
317
+ const r1res = makeRes();
318
+ await reserveHandler(r1, r1res);
319
+ await waitFor(r1res);
320
+ const { reservationId } = JSON.parse(r1res._body.toString()) as {
321
+ reservationId: string;
322
+ };
323
+
324
+ const u1 = makeReq({
325
+ method: "PUT",
326
+ params: { reservationId },
327
+ body: "same content",
328
+ });
329
+ const u1res = makeRes();
330
+ await uploadHandler(u1, u1res);
331
+ await waitFor(u1res);
332
+ const { hash } = JSON.parse(u1res._body.toString()) as { hash: string };
333
+ return hash;
334
+ };
335
+
336
+ const h1 = await doRoundTrip();
337
+ const h2 = await doRoundTrip();
338
+ expect(h1).toBe(h2);
339
+ });
340
+
341
+ describe("validation and header encoding", () => {
342
+ it("parseReserveOptions rejects fileName with CR/LF", () => {
343
+ expect(
344
+ parseReserveOptions({
345
+ mimeType: "text/plain",
346
+ fileName: "evil\r\nX-Inj: foo",
347
+ }),
348
+ ).toBeNull();
349
+ });
350
+
351
+ it("parseReserveOptions rejects fileName with NUL", () => {
352
+ expect(
353
+ parseReserveOptions({
354
+ mimeType: "text/plain",
355
+ fileName: "a\x00b",
356
+ }),
357
+ ).toBeNull();
358
+ });
359
+
360
+ it("parseReserveOptions rejects oversized fileName", () => {
361
+ expect(
362
+ parseReserveOptions({
363
+ mimeType: "text/plain",
364
+ fileName: "x".repeat(256),
365
+ }),
366
+ ).toBeNull();
367
+ });
368
+
369
+ it("parseReserveOptions rejects empty fileName", () => {
370
+ expect(
371
+ parseReserveOptions({
372
+ mimeType: "text/plain",
373
+ fileName: "",
374
+ }),
375
+ ).toBeNull();
376
+ });
377
+
378
+ it("parseReserveOptions rejects malformed mimeType", () => {
379
+ for (const mimeType of ["", "plain", "text/plain\r\nX: y", "text/"]) {
380
+ expect(
381
+ parseReserveOptions({ mimeType, fileName: "ok.txt" }),
382
+ ).toBeNull();
383
+ }
384
+ });
385
+
386
+ it("parseReserveOptions accepts non-ASCII fileName", () => {
387
+ const opts = parseReserveOptions({
388
+ mimeType: "application/pdf",
389
+ fileName: "résumé.pdf",
390
+ });
391
+ expect(opts).toEqual({
392
+ mimeType: "application/pdf",
393
+ fileName: "résumé.pdf",
394
+ extension: null,
395
+ });
396
+ });
397
+
398
+ it("parseReserveOptions accepts mimeType with parameters", () => {
399
+ const opts = parseReserveOptions({
400
+ mimeType: "text/plain; charset=utf-8",
401
+ fileName: "ok.txt",
402
+ });
403
+ expect(opts?.mimeType).toBe("text/plain; charset=utf-8");
404
+ });
405
+
406
+ it("quoteFilename escapes backslash and double-quote", () => {
407
+ expect(quoteFilename(`a"b\\c`)).toBe(`"a\\"b\\\\c"`);
408
+ });
409
+
410
+ it("buildContentDisposition emits ASCII fallback and RFC 5987 form for non-ASCII", () => {
411
+ const value = buildContentDisposition("résumé.pdf");
412
+ expect(value).toMatch(
413
+ /^attachment; filename="[^"]*\.pdf"; filename\*=UTF-8''/,
414
+ );
415
+ expect(value).toContain("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf");
416
+ });
417
+
418
+ it("buildContentDisposition produces a header Node accepts even for CR/LF/NUL input", () => {
419
+ const res = makeRes();
420
+ for (const fileName of [
421
+ "evil\r\nX-Inj: foo",
422
+ "a\x00b.txt",
423
+ "name\twith\ttabs",
424
+ ]) {
425
+ expect(() =>
426
+ res.setHeader(
427
+ "Content-Disposition",
428
+ buildContentDisposition(fileName),
429
+ ),
430
+ ).not.toThrow();
431
+ }
432
+ });
433
+
434
+ it("buildContentDisposition encodes RFC-5987-reserved chars in the encoded form", () => {
435
+ const value = buildContentDisposition("a'b(c)*!.txt");
436
+ expect(value).toContain("filename*=UTF-8''a%27b%28c%29%2A%21.txt");
437
+ });
438
+
439
+ it("POST reserve with CRLF in fileName returns 400 and persists no row", async () => {
440
+ const handler = makeReserveHandler(attachments);
441
+ const req = makeReq({
442
+ method: "POST",
443
+ body: JSON.stringify({
444
+ mimeType: "text/plain",
445
+ fileName: "evil\r\nX-Inj: foo",
446
+ }),
447
+ });
448
+ const res = makeRes();
449
+ await handler(req, res);
450
+ await waitFor(res);
451
+ expect(res.statusCode).toBe(400);
452
+ });
453
+
454
+ it("download with non-ASCII fileName produces RFC 6266 Content-Disposition", async () => {
455
+ const reserveHandler = makeReserveHandler(attachments);
456
+ const uploadHandler = makeUploadHandler(attachments);
457
+ const downloadHandler = makeDownloadHandler(attachments);
458
+
459
+ const reserveReq = makeReq({
460
+ method: "POST",
461
+ body: JSON.stringify({
462
+ mimeType: "application/pdf",
463
+ fileName: "résumé.pdf",
464
+ }),
465
+ });
466
+ const reserveRes = makeRes();
467
+ await reserveHandler(reserveReq, reserveRes);
468
+ await waitFor(reserveRes);
469
+ expect(reserveRes.statusCode).toBe(201);
470
+ const { reservationId } = JSON.parse(
471
+ reserveRes._body.toString("utf8"),
472
+ ) as { reservationId: string };
473
+
474
+ const uploadReq = makeReq({
475
+ method: "PUT",
476
+ params: { reservationId },
477
+ body: "pdf-bytes",
478
+ });
479
+ const uploadRes = makeRes();
480
+ await uploadHandler(uploadReq, uploadRes);
481
+ await waitFor(uploadRes);
482
+ expect(uploadRes.statusCode).toBe(200);
483
+ const { hash } = JSON.parse(uploadRes._body.toString("utf8")) as {
484
+ hash: string;
485
+ };
486
+
487
+ const downloadReq = makeReq({ method: "GET", params: { hash } });
488
+ const downloadRes = makeRes();
489
+ await downloadHandler(downloadReq, downloadRes);
490
+ await waitFor(downloadRes);
491
+ expect(downloadRes.statusCode).toBe(200);
492
+ const cd = downloadRes.getHeader("content-disposition") as string;
493
+ expect(cd).toContain("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf");
494
+ expect(cd).toMatch(/filename="[^"]*\.pdf"/);
495
+ const meta = JSON.parse(
496
+ downloadRes.getHeader("x-attachment-metadata") as string,
497
+ ) as { fileName: string };
498
+ expect(meta.fileName).toBe("résumé.pdf");
499
+ });
500
+ });
501
+ });