1import { expect, it } from "@effect/vitest";
2import { describe, vi } from "vite-plus/test";
3import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
4import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as Context from "effect/Context";
7import * as DateTime from "effect/DateTime";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Queue from "effect/Queue";
15import * as Tracer from "effect/Tracer";
16import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http";
17import { openMediaFile } from "./assets/MediaFile.ts";
18
19import {
20 injectMcpAppCsp,
21 injectMcpAppCookieBootstrap,
22 injectMcpAppThemeBootstrap,
23 MCP_APP_MAX_HTML_BYTES,
24} from "@t3tools/shared/mcpApp";
25import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
26
27import * as ServerConfig from "./config.ts";
28
29import {
30 assetResponseHeaders,
31 assetFileResponse,
32 downloadContentDisposition,
33 isLoopbackHostname,
34 resolveDevRedirectUrl,
35 withUntracedRequests,
36} from "./http.ts";
37import * as ServerHttp from "./http.ts";
38
39describe("MCP app asset responses", () => {
40 it.effect(
41 "transforms existing snapshots only with explicit render intent and fresh metadata",
42 () =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-" });
47 const file = path.join(directory, "app.html");
48 const html = injectMcpAppCsp(
49 "<html><head><script>vendor()</script></head><body>☃</body></html>",
50 undefined,
51 );
52 yield* fs.writeFileString(file, html);
53 for (const method of ["GET", "HEAD"] as const) {
54 const response = HttpServerResponse.toWeb(
55 yield* assetFileResponse(
56 { path: file, mimeType: "text/html", renderIntent: "mcp-app" },
57 "bytes=0-1",
58 undefined,
59 method,
60 ),
61 );
62 const expected = injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(html));
63 expect(response.status).toBe(200);
64 expect(response.headers.get("content-length")).toBe(
65 String(new TextEncoder().encode(expected).byteLength),
66 );
67 expect(response.headers.get("etag")).toBeNull();
68 expect(response.headers.get("last-modified")).toBeNull();
69 expect(response.headers.get("content-security-policy")).toBe(
70 "sandbox allow-scripts allow-forms allow-popups allow-downloads",
71 );
72 expect(yield* Effect.promise(() => response.text())).toBe(
73 method === "HEAD" ? "" : expected,
74 );
75 }
76 for (const download of [false, true]) {
77 const response = HttpServerResponse.toWeb(
78 yield* assetFileResponse({
79 path: file,
80 mimeType: "text/html",
81 download,
82 ...(download ? { renderIntent: "mcp-app" as const } : {}),
83 }),
84 );
85 expect(yield* Effect.promise(() => response.text())).toBe(html);
86 }
87 }).pipe(Effect.provide(layerFileResponse)),
88 );
89
90 it.effect("rejects oversized captures before transforming them", () =>
91 Effect.gen(function* () {
92 const fs = yield* FileSystem.FileSystem;
93 const path = yield* Path.Path;
94 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-size-" });
95 const file = path.join(directory, "app.html");
96 yield* fs.writeFileString(file, "x".repeat(MCP_APP_MAX_HTML_BYTES + 128 * 1024 + 1));
97 const response = yield* assetFileResponse({ path: file, renderIntent: "mcp-app" });
98 expect(response.status).toBe(413);
99 }).pipe(Effect.provide(layerFileResponse)),
100 );
101});
102
103describe("untraced requests", () => {
104 it.effect("drops the HTTP server span for browser trace exports, query string included", () => {
105 const spanNames: Array<string> = [];
106 return Effect.gen(function* () {
107 const layerRoutes = Layer.effectDiscard(
108 Effect.gen(function* () {
109 const router = yield* HttpRouter.HttpRouter;
110 yield* router.add("POST", "/api/observability/v1/traces", HttpServerResponse.empty());
111 yield* router.add("GET", "/api/environment", HttpServerResponse.empty());
112 }),
113 );
114 const services = yield* Layer.build(
115 withUntracedRequests(HttpRouter.serve(layerRoutes, { disableListenLog: true })).pipe(
116 Layer.provideMerge(NodeHttpServer.layerTest),
117 ),
118 );
119 const client = Context.get(services, HttpClient.HttpClient);
120
121 yield* client.post("/api/observability/v1/traces");
122 yield* client.post("/api/observability/v1/traces?x=1");
123 expect(spanNames).toEqual([]);
124
125 yield* client.get("/api/environment");
126 expect(spanNames).toEqual(["GET"]);
127 }).pipe(
128 Effect.scoped,
129 Effect.provideService(
130 Tracer.Tracer,
131 Tracer.make({
132 span: (options) => {
133 if (options.kind === "server") spanNames.push(options.name);
134 return new Tracer.NativeSpan(options);
135 },
136 }),
137 ),
138 );
139 });
140});
141
142describe("browser API CORS", () => {
143 it("accepts protocol negotiation with authenticated browser headers", async () => {
144 const layerRoute = Layer.effectDiscard(
145 Effect.gen(function* () {
146 const router = yield* HttpRouter.HttpRouter;
147 yield* router.add("GET", "/api/environment", HttpServerResponse.empty());
148 }),
149 );
150 const layerApp = Layer.merge(layerRoute, ServerHttp.layerBrowserApiCors).pipe(
151 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "http-cors-test-" })),
152 Layer.provide(NodeServices.layer),
153 );
154 const { handler, dispose } = HttpRouter.toWebHandler(layerApp, { disableLogger: true });
155
156 try {
157 const response = await handler(
158 new Request("https://backend.example/api/environment", {
159 method: "OPTIONS",
160 headers: {
161 origin: "https://app.t3.codes",
162 "access-control-request-method": "GET",
163 "access-control-request-headers": [
164 ORCHESTRATION_PROTOCOL_HEADER,
165 "authorization",
166 "dpop",
167 ].join(", "),
168 },
169 }),
170 );
171
172 expect(response.status).toBe(204);
173 expect(response.headers.get("access-control-allow-origin")).toBe("*");
174 const allowedHeaders = new Set(
175 (response.headers.get("access-control-allow-headers") ?? "")
176 .split(",")
177 .map((header) => header.trim().toLowerCase()),
178 );
179 expect(allowedHeaders.has(ORCHESTRATION_PROTOCOL_HEADER)).toBe(true);
180 expect(allowedHeaders.has("authorization")).toBe(true);
181 expect(allowedHeaders.has("dpop")).toBe(true);
182 } finally {
183 await dispose();
184 }
185 });
186});
187
188const layerFileResponse = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer);
189
190const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (staticDir: string) {
191 const config = yield* ServerConfig.ServerConfig;
192 const fileSystem = yield* FileSystem.FileSystem;
193 const layerApp = Layer.merge(
194 ServerHttp.layerStaticAndDevRoute,
195 ServerHttp.layerHttpCompression,
196 ).pipe(Layer.provideMerge(NodeHttpPlatform.layer));
197 // HttpRouter.serve reuses services from the layers around it before the
198 // app's own, and NodeHttpServer.layerTest brings a real FileSystem, so the
199 // caller's FileSystem and static directory go between the two.
200 const services = yield* Layer.build(
201 HttpRouter.serve(layerApp, { disableListenLog: true }).pipe(
202 Layer.provideMerge(ServerConfig.layer({ ...config, staticDir })),
203 Layer.provideMerge(Layer.succeed(FileSystem.FileSystem, fileSystem)),
204 Layer.provideMerge(NodeHttpServer.layerTest),
205 ),
206 );
207 const client = Context.get(services, HttpClient.HttpClient);
208 return (resource: string, options?: HttpClientRequest.Options) =>
209 client.execute(HttpClientRequest.make(options?.method ?? "GET")(resource, options));
210});
211
212it.layer(
213 ServerConfig.layerTest(process.cwd(), { prefix: "t3-static-http-test-" }).pipe(
214 Layer.provideMerge(NodeServices.layer),
215 ),
216)("static HTTP responses", (it) => {
217 it.effect("revalidates non-HTML files and returns changed contents", () =>
218 Effect.gen(function* () {
219 const fs = yield* FileSystem.FileSystem;
220 const path = yield* Path.Path;
221 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-cache-" });
222 const assetPath = path.join(staticDir, "app.js");
223 yield* fs.writeFileString(assetPath, 'export const build = "first";');
224 const request = yield* makeStaticRequest(staticDir);
225
226 const initial = yield* request("/app.js");
227 expect(initial.status).toBe(200);
228 expect(yield* initial.text).toContain("first");
229 const etag = initial.headers["etag"]!;
230 const lastModified = initial.headers["last-modified"]!;
231 expect(etag).toBeTruthy();
232 expect(lastModified).toBeTruthy();
233 for (const headers of [
234 { "if-none-match": etag },
235 { "if-none-match": etag.replace(/^W\//, "") },
236 { "if-none-match": `"other", ${etag}` },
237 { "if-none-match": "*" },
238 { "if-modified-since": lastModified },
239 ]) {
240 const response = yield* request("/app.js", { headers });
241 expect(response.status).toBe(304);
242 expect(response.headers["etag"]).toBe(etag);
243 expect(response.headers["cache-control"]).toBe("no-cache");
244 expect(yield* response.text).toBe("");
245 }
246 const mismatched = yield* request("/app.js", {
247 headers: { "if-none-match": '"another-build"', "if-modified-since": lastModified },
248 });
249 expect(mismatched.status).toBe(200);
250 expect(yield* mismatched.text).toContain("first");
251
252 yield* fs.writeFileString(assetPath, 'export const build = "the next build";');
253 const changed = yield* request("/app.js", { headers: { "if-none-match": etag } });
254 expect(changed.status).toBe(200);
255 expect(changed.headers["etag"]).not.toBe(etag);
256 expect(yield* changed.text).toContain("next build");
257 }),
258 );
259
260 it.effect("serves changed HTML when deployments preserve its size and timestamp", () =>
261 Effect.gen(function* () {
262 const fs = yield* FileSystem.FileSystem;
263 const path = yield* Path.Path;
264 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-html-" });
265 const indexPath = path.join(staticDir, "index.html");
266 const modifiedAt = DateTime.toDateUtc(DateTime.makeUnsafe("1985-10-26T08:15:00.000Z"));
267 yield* fs.writeFileString(indexPath, "<html>old build</html>");
268 yield* fs.utimes(indexPath, modifiedAt, modifiedAt);
269 const request = yield* makeStaticRequest(staticDir);
270 const initial = yield* request("/");
271 expect(yield* initial.text).toBe("<html>old build</html>");
272 const previousEtag = initial.headers["etag"] ?? '"previous-html"';
273 const nextHtml = "<html>new build</html>";
274 yield* fs.writeFileString(indexPath, nextHtml);
275 yield* fs.utimes(indexPath, modifiedAt, modifiedAt);
276
277 for (const [resource, headers] of [
278 ["/", { "if-none-match": previousEtag }],
279 ["/threads/example", { "if-modified-since": modifiedAt.toUTCString() }],
280 ["/", { "if-none-match": "*" }],
281 ] as const) {
282 const response = yield* request(resource, { headers });
283 expect(response.status).toBe(200);
284 expect(yield* response.text).toBe(nextHtml);
285 expect(response.headers["cache-control"]).toBe("no-cache");
286 expect(response.headers["etag"]).toBeUndefined();
287 expect(response.headers["last-modified"]).toBeUndefined();
288 }
289 const head = yield* request("/", {
290 method: "HEAD",
291 headers: { "if-none-match": previousEtag, "accept-encoding": "identity" },
292 });
293 expect(head.status).toBe(200);
294 expect(head.headers["content-length"]).toBe(String(Buffer.byteLength(nextHtml)));
295 expect(yield* head.text).toBe("");
296 }),
297 );
298
299 it.effect("closes static descriptors after GET, HEAD, 304, and request cancellation", () =>
300 Effect.gen(function* () {
301 const fs = yield* FileSystem.FileSystem;
302 const path = yield* Path.Path;
303 const staticDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-static-close-" });
304 const filePath = path.join(staticDir, "app.txt");
305 const body = "file content\n".repeat(1024);
306 yield* fs.writeFileString(filePath, body);
307 const closed = yield* Queue.unbounded<FileSystem.File>();
308 const blocked = yield* Deferred.make<void>();
309 const active = new Set<FileSystem.File>();
310 let blockAfterOpen = false;
311 let bodyReads = 0;
312 const trackedFileSystem = FileSystem.FileSystem.of({
313 ...fs,
314 open: (candidate, options) =>
315 Effect.gen(function* () {
316 if (candidate !== filePath) return yield* fs.open(candidate, options);
317 let opened: FileSystem.File | undefined;
318 yield* Effect.addFinalizer(() =>
319 Effect.gen(function* () {
320 if (opened === undefined) return;
321 active.delete(opened);
322 yield* Queue.offer(closed, opened);
323 }),
324 );
325 const file = yield* fs.open(candidate, options);
326 opened = file;
327 active.add(file);
328 if (blockAfterOpen) {
329 yield* Deferred.succeed(blocked, undefined);
330 return yield* Effect.never;
331 }
332 return new Proxy(file, {
333 get(target, key) {
334 if (key === "readAlloc") {
335 return (size: number) => {
336 bodyReads += 1;
337 return target.readAlloc(size);
338 };
339 }
340 return Reflect.get(target, key, target);
341 },
342 });
343 }),
344 });
345 const request = yield* makeStaticRequest(staticDir).pipe(
346 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
347 );
348
349 const get = yield* request("/app.txt");
350 expect(yield* get.text).toBe(body);
351 yield* Queue.take(closed);
352 expect(active.size).toBe(0);
353 expect(bodyReads).toBeGreaterThan(0);
354 const readsAfterGet = bodyReads;
355 const head = yield* request("/app.txt", {
356 method: "HEAD",
357 headers: { "accept-encoding": "gzip" },
358 });
359 expect(head.status).toBe(200);
360 expect(head.headers["content-encoding"]).toBe("gzip");
361 expect(yield* head.text).toBe("");
362 yield* Queue.take(closed);
363 expect(active.size).toBe(0);
364 expect(bodyReads).toBe(readsAfterGet);
365 const unchanged = yield* request("/app.txt", {
366 headers: { "if-none-match": get.headers["etag"]! },
367 });
368 expect(unchanged.status).toBe(304);
369 yield* Queue.take(closed);
370 expect(active.size).toBe(0);
371 expect(bodyReads).toBe(readsAfterGet);
372
373 blockAfterOpen = true;
374 const cancelled = yield* request("/app.txt").pipe(Effect.forkChild);
375 yield* Deferred.await(blocked);
376 expect(active.size).toBe(1);
377 yield* Fiber.interrupt(cancelled);
378 yield* Queue.take(closed);
379 expect(active.size).toBe(0);
380 }),
381 );
382});
383
384describe("video asset byte ranges", () => {
385 it.effect("uses current descriptor metadata after an in-place truncate or extension", () =>
386 Effect.gen(function* () {
387 const fs = yield* FileSystem.FileSystem;
388 const path = yield* Path.Path;
389 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-current-stat-" });
390 const filePath = path.join(directory, "clip.mp4");
391 for (const [contents, range, method, expected, status, contentRange] of [
392 ["1234", undefined, "GET", "1234", 200, null],
393 ["0123456789abcdef", undefined, "GET", "0123456789abcdef", 200, null],
394 ["1234", "bytes=4-", "GET", "", 416, "bytes */4"],
395 ["1234", "bytes=1-20", "GET", "234", 206, "bytes 1-3/4"],
396 ["0123456789abcdef", "bytes=10-", "GET", "abcdef", 206, "bytes 10-15/16"],
397 ["0123456789abcdef", undefined, "HEAD", "", 200, null],
398 ["", undefined, "GET", "", 200, null],
399 ["", "bytes=0-1", "GET", "", 416, "bytes */0"],
400 ] as const) {
401 yield* fs.writeFileString(filePath, "0123456789");
402 const canonicalPath = yield* fs.realPath(filePath);
403 const file = yield* openMediaFile(canonicalPath);
404 if (!file) throw new Error("Expected an opened media file");
405 yield* fs.writeFileString(filePath, contents);
406 const response = HttpServerResponse.toWeb(
407 yield* assetFileResponse(
408 { path: canonicalPath, file, mimeType: "video/mp4" },
409 range,
410 undefined,
411 method,
412 ),
413 );
414 expect(response.status).toBe(status);
415 expect(response.headers.get("content-range")).toBe(contentRange);
416 if (status !== 416) {
417 expect(response.headers.get("content-length")).toBe(
418 String(method === "HEAD" ? contents.length : expected.length),
419 );
420 }
421 expect(yield* Effect.promise(() => response.text())).toBe(expected);
422 }
423 }).pipe(Effect.provide(layerFileResponse)),
424 );
425
426 it.effect(
427 "rejects unaddressable ranges before streaming and preserves small ranges on large files",
428 () =>
429 Effect.gen(function* () {
430 const fs = yield* FileSystem.FileSystem;
431 const path = yield* Path.Path;
432 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-offset-limit-" });
433 const filePath = path.join(directory, "clip.mp4");
434 yield* fs.writeFileString(filePath, "0123456789");
435 const canonicalPath = yield* fs.realPath(filePath);
436 const unsafeOffset = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
437 const size = unsafeOffset + 32n;
438 for (const [range, status] of [
439 [`bytes=${unsafeOffset}-${unsafeOffset}`, 416],
440 [`bytes=0-${unsafeOffset}`, 416],
441 ["bytes=-1", 416],
442 [undefined, 413],
443 ["bytes=0-1", 206],
444 ] as const) {
445 const file = yield* openMediaFile(canonicalPath);
446 if (!file) throw new Error("Expected an opened media file");
447 // Model a sparse file beyond the native stream's numeric addressing limit.
448 const info = yield* Effect.promise(() => file.handle.stat({ bigint: true }));
449 info.size = size;
450 const statSpy = vi.spyOn(file.handle, "stat").mockResolvedValue(info);
451 yield* Effect.addFinalizer(() => Effect.sync(() => statSpy.mockRestore()));
452 const response = HttpServerResponse.toWeb(
453 yield* assetFileResponse({ path: canonicalPath, file, mimeType: "video/mp4" }, range),
454 );
455 expect(response.status).toBe(status);
456 if (status === 416) {
457 expect(response.headers.get("content-range")).toBe(`bytes */${size}`);
458 expect(yield* Effect.promise(() => response.text())).toBe("");
459 } else if (status === 206) {
460 expect(response.headers.get("content-range")).toBe(`bytes 0-1/${size}`);
461 expect(yield* Effect.promise(() => response.text())).toBe("01");
462 } else {
463 expect(yield* Effect.promise(() => response.text())).toBe(
464 "File is too large to preview.",
465 );
466 }
467 }
468 }).pipe(Effect.provide(layerFileResponse)),
469 );
470
471 it.effect("streams guarded file ranges, including suffixes and conditional requests", () =>
472 Effect.gen(function* () {
473 const fs = yield* FileSystem.FileSystem;
474 const path = yield* Path.Path;
475 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-range-" });
476 const filePath = path.join(directory, "clip.mp4");
477 yield* fs.writeFileString(filePath, "0123456789");
478 const canonicalPath = yield* fs.realPath(filePath);
479 for (const [range, ifRange, expected, status, contentRange] of [
480 [undefined, undefined, "0123456789", 200, null],
481 ["bytes=0-1", undefined, "01", 206, "bytes 0-1/10"],
482 ["bytes=4-", undefined, "456789", 206, "bytes 4-9/10"],
483 ["bytes=-3", undefined, "789", 206, "bytes 7-9/10"],
484 ["bytes=-999999999999999999999999", undefined, "0123456789", 206, "bytes 0-9/10"],
485 ["bytes=10-", undefined, "", 416, "bytes */10"],
486 ["bytes=0-1", '"old-etag"', "0123456789", 200, null],
487 ["bytes=0-1", "", "0123456789", 200, null],
488 ] as const) {
489 const file = yield* openMediaFile(canonicalPath);
490 if (!file) throw new Error("Expected an opened media file");
491 const response = HttpServerResponse.toWeb(
492 yield* assetFileResponse(
493 { path: canonicalPath, file, mimeType: "video/mp4" },
494 range,
495 ifRange,
496 ),
497 );
498 expect(response.status).toBe(status);
499 expect(response.headers.get("accept-ranges")).toBe("bytes");
500 expect(response.headers.get("content-range")).toBe(contentRange);
501 expect(response.headers.get("cache-control")).toBe("private, no-store");
502 expect(response.headers.get("etag")).toBeNull();
503 expect(response.headers.get("last-modified")).toBeNull();
504 if (status !== 416)
505 expect(response.headers.get("content-length")).toBe(String(expected.length));
506 expect(yield* Effect.promise(() => response.text())).toBe(expected);
507 }
508 }).pipe(Effect.provide(layerFileResponse)),
509 );
510
511 it.effect("keeps attachment media out of the cache once its signed URL expires", () =>
512 Effect.gen(function* () {
513 const fs = yield* FileSystem.FileSystem;
514 const path = yield* Path.Path;
515 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-attachment-media-" });
516 const filePath = path.join(directory, "audio.wav");
517 yield* fs.writeFileString(filePath, "RIFF");
518 const canonicalPath = yield* fs.realPath(filePath);
519 // An attachment is read straight from disk, so it carries no opened host file. Its URL is
520 // signed and short-lived; a cached copy would outlive the grant that served it.
521 const response = HttpServerResponse.toWeb(
522 yield* assetFileResponse({ path: canonicalPath, mimeType: "audio/wav" }),
523 );
524 expect(response.headers.get("cache-control")).toBe("private, no-store");
525 expect(response.headers.get("accept-ranges")).toBe("bytes");
526 }).pipe(Effect.provide(layerFileResponse)),
527 );
528
529 it.effect("closes guarded descriptors after full, HEAD, rejected, and cancelled responses", () =>
530 Effect.gen(function* () {
531 const fs = yield* FileSystem.FileSystem;
532 const path = yield* Path.Path;
533 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-guarded-cleanup-" });
534 const filePath = path.join(directory, "clip.mp4");
535 const bytes = new Uint8Array(1024 * 1024).fill(42);
536 yield* fs.writeFile(filePath, bytes);
537 const canonicalPath = yield* fs.realPath(filePath);
538 for (const mode of ["full", "HEAD", "rejected", "cancelled"] as const) {
539 const file = yield* Effect.scoped(
540 Effect.gen(function* () {
541 const file = yield* openMediaFile(canonicalPath);
542 if (!file) throw new Error("Expected an opened media file");
543 const response = HttpServerResponse.toWeb(
544 yield* assetFileResponse(
545 { path: canonicalPath, file, mimeType: "video/mp4" },
546 mode === "rejected" ? `bytes=${bytes.length}-` : "bytes=0-",
547 undefined,
548 mode === "HEAD" ? "HEAD" : "GET",
549 ),
550 );
551 if (mode === "HEAD") {
552 expect(response.status).toBe(200);
553 expect(response.headers.get("content-length")).toBe(String(bytes.length));
554 expect(response.headers.get("content-range")).toBeNull();
555 expect(yield* Effect.promise(() => response.text())).toBe("");
556 } else if (mode === "rejected") {
557 expect(response.status).toBe(416);
558 expect(yield* Effect.promise(() => response.text())).toBe("");
559 } else if (mode === "cancelled") {
560 const reader = response.body!.getReader();
561 const first = yield* Effect.promise(() => reader.read());
562 expect(first.done).toBe(false);
563 expect(first.value!.byteLength).toBeLessThan(bytes.length);
564 yield* Effect.promise(() => reader.cancel());
565 } else {
566 expect(yield* Effect.promise(() => response.arrayBuffer())).toEqual(bytes.buffer);
567 }
568 return file;
569 }),
570 );
571 expect(file.handle.fd).toBe(-1);
572 }
573 }).pipe(Effect.provide(layerFileResponse)),
574 );
575
576 it.effect("streams exactly the requested bytes and leaves full downloads intact", () =>
577 Effect.gen(function* () {
578 const fs = yield* FileSystem.FileSystem;
579 const path = yield* Path.Path;
580 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" });
581 const file = path.join(directory, "clip.mp4");
582 yield* fs.writeFileString(file, "0123456789");
583 const asset = { path: file, mimeType: "video/mp4" };
584 for (const [header, expected, contentRange] of [
585 ["bytes=0-1", "01", "bytes 0-1/10"],
586 ["bytes=4-", "456789", "bytes 4-9/10"],
587 ["bytes=-3", "789", "bytes 7-9/10"],
588 ["bytes=-999999999999999999999999", "0123456789", "bytes 0-9/10"],
589 ["bytes=8-999999999999999999999999", "89", "bytes 8-9/10"],
590 ] as const) {
591 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header));
592 expect(response.status).toBe(206);
593 expect(response.headers.get("accept-ranges")).toBe("bytes");
594 expect(response.headers.get("content-range")).toBe(contentRange);
595 expect(response.headers.get("content-length")).toBe(String(expected.length));
596 expect(yield* Effect.promise(() => response.text())).toBe(expected);
597 }
598 for (const header of [
599 undefined,
600 "items=0-1",
601 "bytes=0-1,4-5",
602 "bytes=8-2",
603 "bytes=-",
604 "bytes=bad",
605 ]) {
606 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header));
607 expect(response.status).toBe(200);
608 expect(yield* Effect.promise(() => response.text())).toBe("0123456789");
609 }
610 const conditional = HttpServerResponse.toWeb(
611 yield* assetFileResponse(asset, "bytes=0-1", '"old-etag"'),
612 );
613 expect(conditional.status).toBe(200);
614 expect(yield* Effect.promise(() => conditional.text())).toBe("0123456789");
615 const uppercase = HttpServerResponse.toWeb(
616 yield* assetFileResponse({ ...asset, mimeType: "Video/MP4" }, "bytes=0-1"),
617 );
618 expect(uppercase.status).toBe(206);
619 expect(yield* Effect.promise(() => uppercase.text())).toBe("01");
620 const image = HttpServerResponse.toWeb(
621 yield* assetFileResponse({ path: file, mimeType: "image/png" }, "bytes=0-1"),
622 );
623 expect(image.status).toBe(200);
624 expect(image.headers.has("accept-ranges")).toBe(false);
625 expect(yield* Effect.promise(() => image.text())).toBe("0123456789");
626 }).pipe(Effect.provide(layerFileResponse)),
627 );
628
629 it.effect(
630 "supports native audio header probes and seeking without changing explicit downloads",
631 () =>
632 Effect.gen(function* () {
633 const fs = yield* FileSystem.FileSystem;
634 const path = yield* Path.Path;
635 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-audio-range-" });
636 const file = path.join(directory, "recording.wav");
637 yield* fs.writeFileString(file, "0123456789");
638 const asset = { path: file, mimeType: "audio/wav" };
639 for (const [header, expected] of [
640 ["bytes=0-1", "01"],
641 ["bytes=5-", "56789"],
642 ] as const) {
643 const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, header));
644 expect(response.status).toBe(206);
645 expect(response.headers.get("content-type")).toBe("audio/wav");
646 expect(response.headers.get("accept-ranges")).toBe("bytes");
647 expect(response.headers.get("content-length")).toBe(String(expected.length));
648 expect(yield* Effect.promise(() => response.text())).toBe(expected);
649 }
650 const download = HttpServerResponse.toWeb(
651 yield* assetFileResponse({ ...asset, download: true, fileName: "recording.wav" }),
652 );
653 expect(download.status).toBe(200);
654 expect(download.headers.get("content-disposition")).toContain("attachment;");
655 expect(yield* Effect.promise(() => download.text())).toBe("0123456789");
656 }).pipe(Effect.provide(layerFileResponse)),
657 );
658
659 it.effect("rejects ranges outside the file, including empty files", () =>
660 Effect.gen(function* () {
661 const fs = yield* FileSystem.FileSystem;
662 const path = yield* Path.Path;
663 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-video-range-" });
664 const file = path.join(directory, "clip.mp4");
665 yield* fs.writeFileString(file, "0123456789");
666 for (const header of ["bytes=10-", "bytes=-0", "bytes=999999999999999999999999-"]) {
667 const response = HttpServerResponse.toWeb(
668 yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, header),
669 );
670 expect(response.status).toBe(416);
671 expect(response.headers.get("content-range")).toBe("bytes */10");
672 expect(yield* Effect.promise(() => response.text())).toBe("");
673 }
674 yield* fs.writeFileString(file, "");
675 const empty = HttpServerResponse.toWeb(
676 yield* assetFileResponse({ path: file, mimeType: "video/mp4" }, "bytes=0-1"),
677 );
678 expect(empty.status).toBe(416);
679 expect(empty.headers.get("content-range")).toBe("bytes */0");
680 }).pipe(Effect.provide(layerFileResponse)),
681 );
682});
683
684describe("http dev routing", () => {
685 it("treats localhost and loopback addresses as local", () => {
686 expect(isLoopbackHostname("127.0.0.1")).toBe(true);
687 expect(isLoopbackHostname("localhost")).toBe(true);
688 expect(isLoopbackHostname("::1")).toBe(true);
689 expect(isLoopbackHostname("[::1]")).toBe(true);
690 });
691
692 it("does not treat LAN addresses as local", () => {
693 expect(isLoopbackHostname("192.168.86.35")).toBe(false);
694 expect(isLoopbackHostname("10.0.0.24")).toBe(false);
695 expect(isLoopbackHostname("example.local")).toBe(false);
696 });
697
698 it("preserves path and query when redirecting to the dev server", () => {
699 const devUrl = new URL("http://127.0.0.1:5173/");
700 const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token");
701
702 expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe(
703 "http://127.0.0.1:5173/pair?token=test-token",
704 );
705 });
706});
707
708describe("assetResponseHeaders", () => {
709 it("sandboxes SVG assets", () => {
710 expect(assetResponseHeaders("/attachments/user-image.svg")).toMatchObject({
711 "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
712 "X-Content-Type-Options": "nosniff",
713 });
714 expect(assetResponseHeaders("/attachments/user-image.SVG")).toHaveProperty(
715 "Content-Security-Policy",
716 );
717 });
718
719 it("does not apply document policy to raster images", () => {
720 expect(assetResponseHeaders("/attachments/user-image.png")).toEqual({
721 "Cache-Control": "private, max-age=3600",
722 "X-Content-Type-Options": "nosniff",
723 });
724 });
725
726 it("serves inline videos with their declared mime type", () => {
727 expect(
728 assetResponseHeaders("/attachments/demo.bin", {
729 mimeType: 'video/mp4; codecs="avc1.42E01E"',
730 }),
731 ).toEqual({
732 "Cache-Control": "private, max-age=3600",
733 "Content-Type": "video/mp4",
734 "X-Content-Type-Options": "nosniff",
735 });
736 });
737 it("serves inline attachment documents with their declared mime type", () => {
738 expect(
739 assetResponseHeaders("/attachments/upload.bin", { mimeType: "application/pdf" }),
740 ).toMatchObject({
741 "Content-Type": "application/pdf",
742 });
743 expect(
744 assetResponseHeaders("/attachments/upload.bin", { mimeType: "text/html" }),
745 ).toMatchObject({
746 "Content-Type": "text/html; charset=utf-8",
747 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads",
748 });
749 });
750 it("serves HTML assets as utf-8 inside a sandboxed origin", () => {
751 for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) {
752 expect(assetResponseHeaders(path)).toMatchObject({
753 "Content-Type": "text/html; charset=utf-8",
754 "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-downloads",
755 });
756 }
757 });
758
759 it("downloads uploaded documents without executing their content", () => {
760 expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({
761 "Content-Disposition": "attachment",
762 "Content-Security-Policy": "default-src 'none'; sandbox",
763 "Content-Type": "application/octet-stream",
764 });
765 });
766
767 it("serves the real filename and mime type when the claims carry them", () => {
768 expect(
769 assetResponseHeaders("/attachments/thread-1-abc-pdf.pdf", {
770 download: true,
771 fileName: "Q3 report.pdf",
772 mimeType: "application/pdf",
773 }),
774 ).toMatchObject({
775 "Content-Disposition": 'attachment; filename="Q3 report.pdf"',
776 "Content-Security-Policy": "default-src 'none'; sandbox",
777 "Content-Type": "application/pdf",
778 });
779 });
780
781 it("keeps renderable mime types as octet-stream downloads", () => {
782 for (const mimeType of [
783 "text/html",
784 "text/xml",
785 "image/svg+xml",
786 "application/xhtml+xml",
787 "application/rss+xml",
788 "APPLICATION/XML",
789 "IMAGE/SVG+XML",
790 "application/xml-dtd",
791 "application/xml-external-parsed-entity",
792 "not a mime",
793 ]) {
794 expect(
795 assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }),
796 ).toHaveProperty("Content-Type", "application/octet-stream");
797 }
798 });
799
800 it("preserves official Office Open XML mime types", () => {
801 for (const mimeType of [
802 "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
803 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
804 "application/vnd.openxmlformats-officedocument.presentationml.presentation",
805 ]) {
806 expect(
807 assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }),
808 ).toHaveProperty("Content-Type", mimeType);
809 }
810 });
811});
812
813describe("downloadContentDisposition", () => {
814 it("quotes plain names and strips quotes and control characters", () => {
815 expect(downloadContentDisposition("report.pdf")).toBe('attachment; filename="report.pdf"');
816 expect(downloadContentDisposition('we"ird\n.pdf')).toBe('attachment; filename="we_ird_.pdf"');
817 });
818
819 it("adds an RFC 5987 encoded name for non-ASCII filenames", () => {
820 expect(downloadContentDisposition("répört.pdf")).toBe(
821 `attachment; filename="r_p_rt.pdf"; filename*=UTF-8''r%C3%A9p%C3%B6rt.pdf`,
822 );
823 expect(downloadContentDisposition("résumé'(*).pdf")).toBe(
824 `attachment; filename="r_sum_'(*).pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%27%28%2A%29.pdf`,
825 );
826 });
827
828 it("does not throw on unpaired surrogates in the filename", () => {
829 expect(downloadContentDisposition("bad\ud800name.pdf")).toBe(
830 `attachment; filename="bad_name.pdf"; filename*=UTF-8''bad%EF%BF%BDname.pdf`,
831 );
832 });
833});