@podosoft/podokit 1.4.2 → 1.5.0

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 (66) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +1 -0
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +2 -0
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.js +3 -1
  8. package/dist/provider.d.ts +1 -0
  9. package/dist/provider.js +5 -2
  10. package/dist/templates/fullstack/AGENTS.md +18 -5
  11. package/dist/templates/fullstack/apps/api/package.json +1 -1
  12. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  13. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  14. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  15. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  16. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  17. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  18. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  19. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  20. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  21. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  22. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
  23. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
  24. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  25. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  26. package/dist/templates/fullstack/dot-env.docker.example +2 -0
  27. package/dist/templates/fullstack/dot-env.example +17 -1
  28. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  39. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  40. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  42. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  48. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  55. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  56. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  57. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  58. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  59. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  60. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  61. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  62. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  63. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  64. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  65. package/dist/templates/todo/AGENTS.md +41 -5
  66. package/package.json +2 -2
@@ -32,6 +32,10 @@ FROM oven/bun:1.4.0-alpine AS runtime
32
32
  WORKDIR /app
33
33
  ENV NODE_ENV=production
34
34
  ENV BODY_SIZE_LIMIT=3M
35
+ # Seconds a connection may stay silent, such as while the /api proxy waits for the
36
+ # API: more than 4 above BACKEND_PROXY_TIMEOUT_MS (30 s), since Bun closes idle
37
+ # connections on a 4-second tick.
38
+ ENV CONNECTION_IDLE_TIMEOUT=35
35
39
  COPY --from=build /app/apps/web/build ./apps/web/build
36
40
  WORKDIR /app/apps/web
37
41
  USER bun
@@ -6,13 +6,16 @@ import {
6
6
  backendReachable,
7
7
  bodyLimitFor,
8
8
  checkBackend,
9
+ checkIdleTimeout,
9
10
  envMilliseconds,
10
11
  IN_MEMORY_RESPONSE_BYTES,
11
12
  proxyRequest,
13
+ proxyTimeoutFor,
12
14
  reportBackendAnswer,
13
15
  rawSubpath,
14
16
  resolveClientIp,
15
17
  sortedBodyLimits,
18
+ sortedProxyTimeouts,
16
19
  withAppHeaders,
17
20
  } from "./backend-proxy";
18
21
 
@@ -550,53 +553,110 @@ describe("backend proxy application body limits", () => {
550
553
  expect(bodyLimitFor("/other", limits)).toBeUndefined();
551
554
  });
552
555
 
556
+ it("matches :name parameters against one non-empty segment, most specific rule first", () => {
557
+ const limits = sortedBodyLimits([
558
+ { pathPrefix: "/api/hosts", maxBytes: 100 },
559
+ { pathPrefix: "/api/hosts/:hostId/file-transfers", maxBytes: 1_048_576 },
560
+ { pathPrefix: "/api/hosts/local/:section", maxBytes: 10 },
561
+ { pathPrefix: "/api/hosts/:hostId/:section/:item", maxBytes: 20 },
562
+ ]);
563
+
564
+ expect(bodyLimitFor("/api/hosts/h1/file-transfers", limits)).toBe(1_048_576);
565
+ expect(bodyLimitFor("/api/hosts/h%31/file-transfers", limits)).toBe(1_048_576);
566
+ // More segments win, then a literal segment over a parameter from the left.
567
+ expect(bodyLimitFor("/api/hosts/h1/file-transfers/t1/chunk", limits)).toBe(20);
568
+ expect(bodyLimitFor("/api/hosts/local/file-transfers", limits)).toBe(10);
569
+ expect(bodyLimitFor("/api/hosts/h1/file-transfers-old", limits)).toBe(100);
570
+ expect(bodyLimitFor("/api/hosts/file-transfers", limits)).toBe(100);
571
+ expect(bodyLimitFor("/api/hosts//file-transfers", limits)).toBe(100);
572
+ expect(bodyLimitFor("/api/other", limits)).toBeUndefined();
573
+ });
574
+
575
+ it("caps the body of a route with a path parameter", async () => {
576
+ const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/hosts/:hostId/file-transfers", maxBytes: 8 }]);
577
+ const record = recordingFetch();
578
+
579
+ const capped = await proxy(
580
+ new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/chunk", { method: "POST", body: "0123456789" }),
581
+ "http://api:5002/hosts/h1/file-transfers/t1/chunk",
582
+ );
583
+ const other = await proxy(
584
+ new Request("http://app.localhost/api/hosts/h1/notes", { method: "POST", body: "0123456789" }),
585
+ "http://api:5002/hosts/h1/notes",
586
+ );
587
+
588
+ expect(capped.status).toBe(413);
589
+ expect(other.status).toBe(204);
590
+ expect(record.bodies.map((body) => body.byteLength)).toEqual([10]);
591
+ });
592
+
553
593
  it.each<[{ pathPrefix: string; maxBytes: number }[], string]>([
554
- [[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a static path under /api/"],
555
- [[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a static path under /api/"],
556
- [[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a static path under /api/"],
557
- [[{ pathPrefix: "/api/files/../admin", maxBytes: 10 }], "must be a static path under /api/"],
594
+ [[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a path under /api/"],
595
+ [[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a path under /api/"],
596
+ [[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a path under /api/"],
597
+ [[{ pathPrefix: "/api/files/../admin", maxBytes: 10 }], "must be a path under /api/"],
558
598
  [[{ pathPrefix: "/api/files", maxBytes: 0 }], "positive whole number"],
559
599
  [[{ pathPrefix: "/api/files", maxBytes: 1.5 }], "positive whole number"],
560
- [[{ pathPrefix: "/api/files", maxBytes: 10 }, { pathPrefix: "/api/files/", maxBytes: 20 }], "more than once"],
600
+ [[{ pathPrefix: "/apix/files", maxBytes: 10 }], "must be a path under /api/"],
601
+ [[{ pathPrefix: "/api/files/:", maxBytes: 10 }], "must be a path under /api/"],
602
+ [[{ pathPrefix: "/api/files/:1st", maxBytes: 10 }], "must be a path under /api/"],
603
+ [[{ pathPrefix: "/api/files/:file-id", maxBytes: 10 }], "must be a path under /api/"],
604
+ [[{ pathPrefix: "/api/files/:a:b", maxBytes: 10 }], "must be a path under /api/"],
605
+ [[{ pathPrefix: "/api/files", maxBytes: 10 }, { pathPrefix: "/api/files/", maxBytes: 20 }], "lists the same paths twice"],
606
+ [
607
+ [{ pathPrefix: "/api/hosts/:hostId", maxBytes: 10 }, { pathPrefix: "/api/hosts/:id/", maxBytes: 20 }],
608
+ 'lists the same paths twice: "/api/hosts/:hostId" and "/api/hosts/:id/"',
609
+ ],
561
610
  ])("rejects an invalid body limit list %#", (limits, message) => {
562
611
  expect(() => sortedBodyLimits(limits)).toThrow(message);
563
612
  });
564
613
 
565
614
  it("refuses to load with an invalid application body limit", async () => {
566
615
  await expect(proxyWithBodyLimits([{ pathPrefix: "/upload", maxBytes: 10 }])).rejects.toThrow(
567
- "APP_BODY_LIMITS pathPrefix must be a static path under /api/",
616
+ "APP_BODY_LIMITS pathPrefix must be a path under /api/",
568
617
  );
569
618
  });
570
619
  });
571
620
 
572
- describe("backend proxy outages", () => {
573
- type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
621
+ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
622
+
623
+ // Never answers on its own, like a blackholed connection or a hung API, and
624
+ // rejects when its signal aborts.
625
+ const hang: Fetch = (_input, init) =>
626
+ new Promise((_settle, reject) => {
627
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
628
+ });
629
+ const refuse: Fetch = async () => {
630
+ throw new TypeError("Unable to connect. Is the computer able to access the url?");
631
+ };
632
+ // Answers after a delay unless its signal aborts first, like a slow endpoint.
633
+ const answerAfter =
634
+ (ms: number): Fetch =>
635
+ (_input, init) =>
636
+ new Promise((settle, reject) => {
637
+ const timer = setTimeout(() => settle(Response.json({ done: true })), ms);
638
+ init?.signal?.addEventListener("abort", () => {
639
+ clearTimeout(timer);
640
+ reject(init.signal?.reason);
641
+ });
642
+ });
643
+
644
+ // Routes the background GET /health check and the proxied request separately.
645
+ function stubApi(health: Fetch, upstream: Fetch): ReturnType<typeof vi.fn<Fetch>> {
646
+ const fetch = vi.fn<Fetch>((input, init) =>
647
+ String(input).endsWith("/health") ? health(input, init) : upstream(input, init),
648
+ );
649
+ vi.stubGlobal("fetch", fetch);
650
+ return fetch;
651
+ }
574
652
 
653
+ describe("backend proxy outages", () => {
575
654
  afterEach(() => {
576
655
  reportBackendAnswer();
577
656
  vi.unstubAllGlobals();
578
657
  vi.unstubAllEnvs();
579
658
  });
580
659
 
581
- // Never answers on its own, like a blackholed connection or a hung API, and
582
- // rejects when its signal aborts.
583
- const hang: Fetch = (_input, init) =>
584
- new Promise((_settle, reject) => {
585
- init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
586
- });
587
- const refuse: Fetch = async () => {
588
- throw new TypeError("Unable to connect. Is the computer able to access the url?");
589
- };
590
-
591
- // Routes the background GET /health check and the proxied request separately.
592
- function stubApi(health: Fetch, upstream: Fetch): ReturnType<typeof vi.fn<Fetch>> {
593
- const fetch = vi.fn<Fetch>((input, init) =>
594
- String(input).endsWith("/health") ? health(input, init) : upstream(input, init),
595
- );
596
- vi.stubGlobal("fetch", fetch);
597
- return fetch;
598
- }
599
-
600
660
  async function takeApiDown(): Promise<void> {
601
661
  stubApi(refuse, refuse);
602
662
  await checkBackend();
@@ -645,25 +705,37 @@ describe("backend proxy outages", () => {
645
705
  expect(backendReachable()).toBe(false);
646
706
  });
647
707
 
648
- it("keeps waiting for a slow endpoint while the API answers its health check", async () => {
708
+ it("keeps waiting for a slow endpoint while the API answers its health checks", async () => {
649
709
  vi.stubEnv("BACKEND_INTERNAL_URL", "http://api:5002");
650
710
  vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
651
- const fetch = stubApi(
652
- async () => new Response("ok"),
653
- () => new Promise((settle) => setTimeout(() => settle(Response.json({ done: true })), 80)),
654
- );
711
+ const fetch = stubApi(async () => new Response("ok"), answerAfter(100));
655
712
 
656
713
  const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
657
714
 
658
715
  expect(response.status).toBe(200);
659
716
  expect(await response.json()).toEqual({ done: true });
660
- expect(fetch.mock.calls.map(([input]) => String(input))).toEqual([
661
- "http://api:5002/report",
662
- "http://api:5002/health",
663
- ]);
717
+ const [first, ...checks] = fetch.mock.calls.map(([input]) => String(input));
718
+ expect(first).toBe("http://api:5002/report");
719
+ expect(checks.length).toBeGreaterThanOrEqual(2);
720
+ expect(new Set(checks)).toEqual(new Set(["http://api:5002/health"]));
664
721
  expect(backendReachable()).toBe(true);
665
722
  });
666
723
 
724
+ it("fails a waiting request when the API stops answering after an earlier check", async () => {
725
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
726
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "10000");
727
+ let checks = 0;
728
+ stubApi((input, init) => (++checks <= 2 ? Promise.resolve(new Response("ok")) : hang(input, init)), hang);
729
+
730
+ const started = Date.now();
731
+ const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
732
+
733
+ expect(response.status).toBe(503);
734
+ expect(await response.json()).toMatchObject({ error: { code: "BACKEND_UNAVAILABLE" } });
735
+ expect(checks).toBeGreaterThanOrEqual(3);
736
+ expect(Date.now() - started).toBeLessThan(5_000);
737
+ });
738
+
667
739
  it("answers at once without contacting the API while it is unreachable", async () => {
668
740
  await takeApiDown();
669
741
  const fetch = stubApi(refuse, async () => new Response(null, { status: 204 }));
@@ -739,3 +811,186 @@ describe("backend proxy outages", () => {
739
811
  expect(envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toBe(1_500);
740
812
  });
741
813
  });
814
+
815
+ describe("backend proxy application timeouts", () => {
816
+ afterEach(() => {
817
+ vi.unstubAllGlobals();
818
+ vi.unstubAllEnvs();
819
+ vi.doUnmock("./proxy-timeouts");
820
+ vi.resetModules();
821
+ });
822
+
823
+ async function proxyWithTimeouts(
824
+ timeouts: ReadonlyArray<{ pathPrefix: string; timeoutMs: number | null }>,
825
+ ): Promise<typeof proxyRequest> {
826
+ vi.resetModules();
827
+ vi.doMock("./proxy-timeouts", () => ({ APP_PROXY_TIMEOUTS: timeouts }));
828
+ return (await import("./backend-proxy")).proxyRequest;
829
+ }
830
+
831
+ it("waits longer on a route with a longer deadline", async () => {
832
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
833
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/exports/", timeoutMs: 2_000 }]);
834
+ stubApi(async () => new Response("ok"), answerAfter(80));
835
+
836
+ const listed = await proxy(new Request("http://app.localhost/api/exports/42"), "http://api:5002/exports/42");
837
+ const other = await proxy(new Request("http://app.localhost/api/report"), "http://api:5002/report");
838
+
839
+ expect(listed.status).toBe(200);
840
+ expect(await listed.json()).toEqual({ done: true });
841
+ expect(other.status).toBe(504);
842
+ });
843
+
844
+ it("answers 504 at a route's shorter deadline", async () => {
845
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/quick", timeoutMs: 20 }]);
846
+ stubApi(async () => new Response("ok"), hang);
847
+
848
+ const started = Date.now();
849
+ const response = await proxy(new Request("http://app.localhost/api/quick/check"), "http://api:5002/quick/check");
850
+
851
+ expect(response.status).toBe(504);
852
+ expect(await response.json()).toMatchObject({ error: { code: "BACKEND_TIMEOUT", path: "/api/quick/check" } });
853
+ expect(Date.now() - started).toBeLessThan(5_000);
854
+ });
855
+
856
+ it("waits without a deadline on a null route until the API answers", async () => {
857
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
858
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
859
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }]);
860
+ stubApi(async () => new Response("ok"), answerAfter(150));
861
+
862
+ const response = await proxy(
863
+ new Request("http://app.localhost/api/file-transfers/7/commit", { method: "POST", body: "{}" }),
864
+ "http://api:5002/file-transfers/7/commit",
865
+ );
866
+
867
+ expect(response.status).toBe(200);
868
+ });
869
+
870
+ it("still answers 503 on a null route once the API is found unreachable", async () => {
871
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
872
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }]);
873
+ let checks = 0;
874
+ stubApi((input, init) => (++checks <= 2 ? Promise.resolve(new Response("ok")) : hang(input, init)), hang);
875
+
876
+ const response = await proxy(
877
+ new Request("http://app.localhost/api/file-transfers/7/commit", { method: "POST", body: "{}" }),
878
+ "http://api:5002/file-transfers/7/commit",
879
+ );
880
+
881
+ expect(response.status).toBe(503);
882
+ expect(response.headers.get("retry-after")).toBe("2");
883
+ expect(await response.json()).toMatchObject({
884
+ error: { code: "BACKEND_UNAVAILABLE", path: "/api/file-transfers/7/commit" },
885
+ });
886
+ expect(checks).toBeGreaterThanOrEqual(3);
887
+ });
888
+
889
+ it("waits without a deadline on a route with a path parameter", async () => {
890
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
891
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
892
+ stubApi(async () => new Response("ok"), answerAfter(80));
893
+
894
+ const commit = await proxy(
895
+ new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/commit", { method: "POST", body: "{}" }),
896
+ "http://api:5002/hosts/h1/file-transfers/t1/commit",
897
+ );
898
+ const other = await proxy(new Request("http://app.localhost/api/hosts/h1/notes"), "http://api:5002/hosts/h1/notes");
899
+
900
+ expect(commit.status).toBe(200);
901
+ expect(other.status).toBe(504);
902
+ });
903
+
904
+ it("applies the longest matching prefix on the decoded path and the default elsewhere", () => {
905
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "1234");
906
+ const timeouts = sortedProxyTimeouts([
907
+ { pathPrefix: "/api/", timeoutMs: 5_000 },
908
+ { pathPrefix: "/api/file-transfers/", timeoutMs: null },
909
+ { pathPrefix: "/api/file-transfers/status", timeoutMs: 100 },
910
+ ]);
911
+
912
+ expect(proxyTimeoutFor("/api/file-transfers/7/commit", timeouts)).toBeNull();
913
+ expect(proxyTimeoutFor("/api/file-transfers", timeouts)).toBeNull();
914
+ expect(proxyTimeoutFor("/api/file-transfers/status/7", timeouts)).toBe(100);
915
+ expect(proxyTimeoutFor("/api/%66ile-transfers/7", timeouts)).toBeNull();
916
+ expect(proxyTimeoutFor("/api/file-transfers-old/7", timeouts)).toBe(5_000);
917
+ expect(proxyTimeoutFor("/api/auth/get-session", sortedProxyTimeouts([]))).toBe(1234);
918
+ expect(proxyTimeoutFor("/other", timeouts)).toBe(1234);
919
+
920
+ const byHost = sortedProxyTimeouts([
921
+ { pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null },
922
+ { pathPrefix: "/api/hosts/local/file-transfers", timeoutMs: 60_000 },
923
+ ]);
924
+ expect(proxyTimeoutFor("/api/hosts/h1/file-transfers/t1/commit", byHost)).toBeNull();
925
+ expect(proxyTimeoutFor("/api/hosts/local/file-transfers/t1/commit", byHost)).toBe(60_000);
926
+ expect(proxyTimeoutFor("/api/hosts/file-transfers", byHost)).toBe(1234);
927
+ });
928
+
929
+ it.each<[{ pathPrefix: string; timeoutMs: number | null }[], string]>([
930
+ [[{ pathPrefix: "/exports/", timeoutMs: 10 }], "must be a path under /api/"],
931
+ [[{ pathPrefix: "/api/exports/*", timeoutMs: 10 }], "must be a path under /api/"],
932
+ [[{ pathPrefix: "/api/exports/../auth", timeoutMs: 10 }], "must be a path under /api/"],
933
+ [[{ pathPrefix: "/api/exports", timeoutMs: 0 }], "must be null or a whole number of milliseconds"],
934
+ [[{ pathPrefix: "/api/exports", timeoutMs: 1.5 }], "must be null or a whole number of milliseconds"],
935
+ [[{ pathPrefix: "/api/exports", timeoutMs: 2_147_483_648 }], "must be null or a whole number of milliseconds"],
936
+ [[{ pathPrefix: "/api/exports/:", timeoutMs: 10 }], "must be a path under /api/"],
937
+ [[{ pathPrefix: "/api/exports", timeoutMs: null }, { pathPrefix: "/api/exports/", timeoutMs: 10 }], "lists the same paths twice"],
938
+ [[{ pathPrefix: "/api/:a/x", timeoutMs: null }, { pathPrefix: "/api/:b/x", timeoutMs: 10 }], "lists the same paths twice"],
939
+ ])("rejects an invalid timeout list %#", (timeouts, message) => {
940
+ expect(() => sortedProxyTimeouts(timeouts)).toThrow(message);
941
+ });
942
+
943
+ it.each([undefined, "0", "35", "soon"])("accepts CONNECTION_IDLE_TIMEOUT=%s with the default deadline", (idle) => {
944
+ expect(() => checkIdleTimeout(idle, 30_000, [])).not.toThrow();
945
+ });
946
+
947
+ it("requires the idle timeout to outlast the longest deadline by Bun's check interval", () => {
948
+ expect(() => checkIdleTimeout("34", 30_000, [])).toThrow(
949
+ "CONNECTION_IDLE_TIMEOUT=34 lets the web server close a silent connection before the /api proxy stops " +
950
+ "waiting for response headers (30000 ms, BACKEND_PROXY_TIMEOUT_MS)",
951
+ );
952
+ expect(() => checkIdleTimeout("10", 30_000, [])).toThrow("Set CONNECTION_IDLE_TIMEOUT to at least 35, or to 0");
953
+ expect(() => checkIdleTimeout("35", 30_500, [])).not.toThrow();
954
+ expect(() => checkIdleTimeout("35", 31_000, [])).toThrow("at least 36");
955
+
956
+ const routes = sortedProxyTimeouts([
957
+ { pathPrefix: "/api/quick/", timeoutMs: 5_000 },
958
+ { pathPrefix: "/api/exports/", timeoutMs: 120_000 },
959
+ ]);
960
+ expect(() => checkIdleTimeout("35", 30_000, routes)).toThrow(
961
+ "(120000 ms, APP_PROXY_TIMEOUTS for /api/exports); Bun closes idle connections up to 4 seconds early. " +
962
+ "Set CONNECTION_IDLE_TIMEOUT to at least 125, or to 0 to turn the idle timeout off.",
963
+ );
964
+ expect(() => checkIdleTimeout("125", 30_000, routes)).not.toThrow();
965
+ expect(() => checkIdleTimeout("0", 30_000, routes)).not.toThrow();
966
+ });
967
+
968
+ it("requires the idle timeout off for a route without a deadline or beyond Bun's limit", () => {
969
+ const unbounded = sortedProxyTimeouts([
970
+ { pathPrefix: "/api/exports/", timeoutMs: 120_000 },
971
+ { pathPrefix: "/api/file-transfers/", timeoutMs: null },
972
+ ]);
973
+ expect(() => checkIdleTimeout("255", 30_000, unbounded)).toThrow(
974
+ "(no deadline, APP_PROXY_TIMEOUTS for /api/file-transfers); Bun closes idle connections up to 4 seconds " +
975
+ "early. Set CONNECTION_IDLE_TIMEOUT to 0 to turn the idle timeout off, since Bun allows at most 255 seconds.",
976
+ );
977
+ expect(() => checkIdleTimeout("0", 30_000, unbounded)).not.toThrow();
978
+ expect(() => checkIdleTimeout("255", 250_999, [])).not.toThrow();
979
+ expect(() => checkIdleTimeout("255", 251_000, [])).toThrow("Set CONNECTION_IDLE_TIMEOUT to 0");
980
+ });
981
+
982
+ it("refuses to load when CONNECTION_IDLE_TIMEOUT cannot cover a route's deadline", async () => {
983
+ vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "35");
984
+ await expect(proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }])).rejects.toThrow(
985
+ "CONNECTION_IDLE_TIMEOUT=35 lets the web server close a silent connection",
986
+ );
987
+ vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "0");
988
+ await expect(proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }])).resolves.toBeDefined();
989
+ });
990
+
991
+ it("refuses to load with an invalid application timeout", async () => {
992
+ await expect(proxyWithTimeouts([{ pathPrefix: "/api/exports", timeoutMs: -1 }])).rejects.toThrow(
993
+ "APP_PROXY_TIMEOUTS timeoutMs for /api/exports must be null",
994
+ );
995
+ });
996
+ });