@podosoft/podokit 1.4.2 → 1.6.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 (245) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -17
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -6,19 +6,25 @@ import {
6
6
  backendReachable,
7
7
  bodyLimitFor,
8
8
  checkBackend,
9
+ checkIdleTimeout,
10
+ configuredPublicOrigins,
9
11
  envMilliseconds,
10
12
  IN_MEMORY_RESPONSE_BYTES,
11
13
  proxyRequest,
14
+ proxyTimeoutFor,
12
15
  reportBackendAnswer,
13
16
  rawSubpath,
14
17
  resolveClientIp,
15
18
  sortedBodyLimits,
19
+ sortedProxyTimeouts,
20
+ upstreamFetchOptions,
16
21
  withAppHeaders,
17
22
  } from "./backend-proxy";
18
23
 
19
24
  describe("backend proxy client address handling", () => {
20
25
  afterEach(() => {
21
26
  vi.unstubAllGlobals();
27
+ vi.unstubAllEnvs();
22
28
  });
23
29
 
24
30
  it("returns the normalized address from a trusted proxy chain", () => {
@@ -71,6 +77,48 @@ describe("backend proxy client address handling", () => {
71
77
  expect(upstreamHeaders?.get("x-forwarded-for")).toBe("203.0.113.9");
72
78
  });
73
79
 
80
+ it("forwards the public origin a request came in on when CORS_ORIGIN lists it", async () => {
81
+ let upstreamHeaders: Headers | undefined;
82
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
83
+ upstreamHeaders = new Headers(init?.headers);
84
+ return new Response(null, { status: 204 });
85
+ });
86
+ vi.stubEnv("CORS_ORIGIN", "https://example.com,https://mirror.example.com");
87
+
88
+ // The adapter builds the request URL from the gateway's forwarded host and
89
+ // protocol; a caller's own forwarded headers never reach the API.
90
+ const request = new Request("https://mirror.example.com/api/mcp/info", {
91
+ headers: { "x-forwarded-host": "attacker.example", "x-forwarded-proto": "http" },
92
+ });
93
+ await proxyRequest(request, "http://api:5002/mcp/info");
94
+
95
+ expect(upstreamHeaders?.get("x-forwarded-host")).toBe("mirror.example.com");
96
+ expect(upstreamHeaders?.get("x-forwarded-proto")).toBe("https");
97
+ });
98
+
99
+ it("forwards no origin that CORS_ORIGIN does not list", async () => {
100
+ let upstreamHeaders: Headers | undefined;
101
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
102
+ upstreamHeaders = new Headers(init?.headers);
103
+ return new Response(null, { status: 204 });
104
+ });
105
+ vi.stubEnv("CORS_ORIGIN", "https://example.com");
106
+
107
+ const request = new Request("http://attacker.example/api/mcp/info", {
108
+ headers: { "x-forwarded-host": "example.com", "x-forwarded-proto": "https" },
109
+ });
110
+ await proxyRequest(request, "http://api:5002/mcp/info");
111
+
112
+ expect(upstreamHeaders?.has("x-forwarded-host")).toBe(false);
113
+ expect(upstreamHeaders?.has("x-forwarded-proto")).toBe(false);
114
+ });
115
+
116
+ it("reads absolute CORS_ORIGIN entries as origins and skips patterns", () => {
117
+ expect([...configuredPublicOrigins(" https://example.com/ , *.example.com, not a url, http://localhost:5001")])
118
+ .toEqual(["https://example.com", "http://localhost:5001"]);
119
+ expect(configuredPublicOrigins("").size).toBe(0);
120
+ });
121
+
74
122
  // Not forwarding this does not leave the header unset: fetch substitutes its own
75
123
  // default, so the API records "node" for every session and the account page shows
76
124
  // that as the device for all of them.
@@ -550,53 +598,110 @@ describe("backend proxy application body limits", () => {
550
598
  expect(bodyLimitFor("/other", limits)).toBeUndefined();
551
599
  });
552
600
 
601
+ it("matches :name parameters against one non-empty segment, most specific rule first", () => {
602
+ const limits = sortedBodyLimits([
603
+ { pathPrefix: "/api/hosts", maxBytes: 100 },
604
+ { pathPrefix: "/api/hosts/:hostId/file-transfers", maxBytes: 1_048_576 },
605
+ { pathPrefix: "/api/hosts/local/:section", maxBytes: 10 },
606
+ { pathPrefix: "/api/hosts/:hostId/:section/:item", maxBytes: 20 },
607
+ ]);
608
+
609
+ expect(bodyLimitFor("/api/hosts/h1/file-transfers", limits)).toBe(1_048_576);
610
+ expect(bodyLimitFor("/api/hosts/h%31/file-transfers", limits)).toBe(1_048_576);
611
+ // More segments win, then a literal segment over a parameter from the left.
612
+ expect(bodyLimitFor("/api/hosts/h1/file-transfers/t1/chunk", limits)).toBe(20);
613
+ expect(bodyLimitFor("/api/hosts/local/file-transfers", limits)).toBe(10);
614
+ expect(bodyLimitFor("/api/hosts/h1/file-transfers-old", limits)).toBe(100);
615
+ expect(bodyLimitFor("/api/hosts/file-transfers", limits)).toBe(100);
616
+ expect(bodyLimitFor("/api/hosts//file-transfers", limits)).toBe(100);
617
+ expect(bodyLimitFor("/api/other", limits)).toBeUndefined();
618
+ });
619
+
620
+ it("caps the body of a route with a path parameter", async () => {
621
+ const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/hosts/:hostId/file-transfers", maxBytes: 8 }]);
622
+ const record = recordingFetch();
623
+
624
+ const capped = await proxy(
625
+ new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/chunk", { method: "POST", body: "0123456789" }),
626
+ "http://api:5002/hosts/h1/file-transfers/t1/chunk",
627
+ );
628
+ const other = await proxy(
629
+ new Request("http://app.localhost/api/hosts/h1/notes", { method: "POST", body: "0123456789" }),
630
+ "http://api:5002/hosts/h1/notes",
631
+ );
632
+
633
+ expect(capped.status).toBe(413);
634
+ expect(other.status).toBe(204);
635
+ expect(record.bodies.map((body) => body.byteLength)).toEqual([10]);
636
+ });
637
+
553
638
  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/"],
639
+ [[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a path under /api/"],
640
+ [[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a path under /api/"],
641
+ [[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a path under /api/"],
642
+ [[{ pathPrefix: "/api/files/../admin", maxBytes: 10 }], "must be a path under /api/"],
558
643
  [[{ pathPrefix: "/api/files", maxBytes: 0 }], "positive whole number"],
559
644
  [[{ pathPrefix: "/api/files", maxBytes: 1.5 }], "positive whole number"],
560
- [[{ pathPrefix: "/api/files", maxBytes: 10 }, { pathPrefix: "/api/files/", maxBytes: 20 }], "more than once"],
645
+ [[{ pathPrefix: "/apix/files", maxBytes: 10 }], "must be a path under /api/"],
646
+ [[{ pathPrefix: "/api/files/:", maxBytes: 10 }], "must be a path under /api/"],
647
+ [[{ pathPrefix: "/api/files/:1st", maxBytes: 10 }], "must be a path under /api/"],
648
+ [[{ pathPrefix: "/api/files/:file-id", maxBytes: 10 }], "must be a path under /api/"],
649
+ [[{ pathPrefix: "/api/files/:a:b", maxBytes: 10 }], "must be a path under /api/"],
650
+ [[{ pathPrefix: "/api/files", maxBytes: 10 }, { pathPrefix: "/api/files/", maxBytes: 20 }], "lists the same paths twice"],
651
+ [
652
+ [{ pathPrefix: "/api/hosts/:hostId", maxBytes: 10 }, { pathPrefix: "/api/hosts/:id/", maxBytes: 20 }],
653
+ 'lists the same paths twice: "/api/hosts/:hostId" and "/api/hosts/:id/"',
654
+ ],
561
655
  ])("rejects an invalid body limit list %#", (limits, message) => {
562
656
  expect(() => sortedBodyLimits(limits)).toThrow(message);
563
657
  });
564
658
 
565
659
  it("refuses to load with an invalid application body limit", async () => {
566
660
  await expect(proxyWithBodyLimits([{ pathPrefix: "/upload", maxBytes: 10 }])).rejects.toThrow(
567
- "APP_BODY_LIMITS pathPrefix must be a static path under /api/",
661
+ "APP_BODY_LIMITS pathPrefix must be a path under /api/",
568
662
  );
569
663
  });
570
664
  });
571
665
 
572
- describe("backend proxy outages", () => {
573
- type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
666
+ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
574
667
 
668
+ // Never answers on its own, like a blackholed connection or a hung API, and
669
+ // rejects when its signal aborts.
670
+ const hang: Fetch = (_input, init) =>
671
+ new Promise((_settle, reject) => {
672
+ init?.signal?.addEventListener("abort", () => reject(init.signal?.reason));
673
+ });
674
+ const refuse: Fetch = async () => {
675
+ throw new TypeError("Unable to connect. Is the computer able to access the url?");
676
+ };
677
+ // Answers after a delay unless its signal aborts first, like a slow endpoint.
678
+ const answerAfter =
679
+ (ms: number): Fetch =>
680
+ (_input, init) =>
681
+ new Promise((settle, reject) => {
682
+ const timer = setTimeout(() => settle(Response.json({ done: true })), ms);
683
+ init?.signal?.addEventListener("abort", () => {
684
+ clearTimeout(timer);
685
+ reject(init.signal?.reason);
686
+ });
687
+ });
688
+
689
+ // Routes the background GET /health check and the proxied request separately.
690
+ function stubApi(health: Fetch, upstream: Fetch): ReturnType<typeof vi.fn<Fetch>> {
691
+ const fetch = vi.fn<Fetch>((input, init) =>
692
+ String(input).endsWith("/health") ? health(input, init) : upstream(input, init),
693
+ );
694
+ vi.stubGlobal("fetch", fetch);
695
+ return fetch;
696
+ }
697
+
698
+ describe("backend proxy outages", () => {
575
699
  afterEach(() => {
576
700
  reportBackendAnswer();
577
701
  vi.unstubAllGlobals();
578
702
  vi.unstubAllEnvs();
579
703
  });
580
704
 
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
705
  async function takeApiDown(): Promise<void> {
601
706
  stubApi(refuse, refuse);
602
707
  await checkBackend();
@@ -645,25 +750,37 @@ describe("backend proxy outages", () => {
645
750
  expect(backendReachable()).toBe(false);
646
751
  });
647
752
 
648
- it("keeps waiting for a slow endpoint while the API answers its health check", async () => {
753
+ it("keeps waiting for a slow endpoint while the API answers its health checks", async () => {
649
754
  vi.stubEnv("BACKEND_INTERNAL_URL", "http://api:5002");
650
755
  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
- );
756
+ const fetch = stubApi(async () => new Response("ok"), answerAfter(100));
655
757
 
656
758
  const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
657
759
 
658
760
  expect(response.status).toBe(200);
659
761
  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
- ]);
762
+ const [first, ...checks] = fetch.mock.calls.map(([input]) => String(input));
763
+ expect(first).toBe("http://api:5002/report");
764
+ expect(checks.length).toBeGreaterThanOrEqual(2);
765
+ expect(new Set(checks)).toEqual(new Set(["http://api:5002/health"]));
664
766
  expect(backendReachable()).toBe(true);
665
767
  });
666
768
 
769
+ it("fails a waiting request when the API stops answering after an earlier check", async () => {
770
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
771
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "10000");
772
+ let checks = 0;
773
+ stubApi((input, init) => (++checks <= 2 ? Promise.resolve(new Response("ok")) : hang(input, init)), hang);
774
+
775
+ const started = Date.now();
776
+ const response = await proxyRequest(new Request("http://app.localhost/api/report"), "http://api:5002/report");
777
+
778
+ expect(response.status).toBe(503);
779
+ expect(await response.json()).toMatchObject({ error: { code: "BACKEND_UNAVAILABLE" } });
780
+ expect(checks).toBeGreaterThanOrEqual(3);
781
+ expect(Date.now() - started).toBeLessThan(5_000);
782
+ });
783
+
667
784
  it("answers at once without contacting the API while it is unreachable", async () => {
668
785
  await takeApiDown();
669
786
  const fetch = stubApi(refuse, async () => new Response(null, { status: 204 }));
@@ -739,3 +856,220 @@ describe("backend proxy outages", () => {
739
856
  expect(envMilliseconds("BACKEND_TIMEOUT_MS", 2_000)).toBe(1_500);
740
857
  });
741
858
  });
859
+
860
+ describe("backend proxy application timeouts", () => {
861
+ afterEach(() => {
862
+ vi.unstubAllGlobals();
863
+ vi.unstubAllEnvs();
864
+ vi.doUnmock("./proxy-timeouts");
865
+ vi.resetModules();
866
+ });
867
+
868
+ async function proxyWithTimeouts(
869
+ timeouts: ReadonlyArray<{ pathPrefix: string; timeoutMs: number | null }>,
870
+ ): Promise<typeof proxyRequest> {
871
+ vi.resetModules();
872
+ vi.doMock("./proxy-timeouts", () => ({ APP_PROXY_TIMEOUTS: timeouts }));
873
+ return (await import("./backend-proxy")).proxyRequest;
874
+ }
875
+
876
+ it("waits longer on a route with a longer deadline", async () => {
877
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
878
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/exports/", timeoutMs: 2_000 }]);
879
+ stubApi(async () => new Response("ok"), answerAfter(80));
880
+
881
+ const listed = await proxy(new Request("http://app.localhost/api/exports/42"), "http://api:5002/exports/42");
882
+ const other = await proxy(new Request("http://app.localhost/api/report"), "http://api:5002/report");
883
+
884
+ expect(listed.status).toBe(200);
885
+ expect(await listed.json()).toEqual({ done: true });
886
+ expect(other.status).toBe(504);
887
+ });
888
+
889
+ it("answers 504 at a route's shorter deadline", async () => {
890
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/quick", timeoutMs: 20 }]);
891
+ stubApi(async () => new Response("ok"), hang);
892
+
893
+ const started = Date.now();
894
+ const response = await proxy(new Request("http://app.localhost/api/quick/check"), "http://api:5002/quick/check");
895
+
896
+ expect(response.status).toBe(504);
897
+ expect(await response.json()).toMatchObject({ error: { code: "BACKEND_TIMEOUT", path: "/api/quick/check" } });
898
+ expect(Date.now() - started).toBeLessThan(5_000);
899
+ });
900
+
901
+ it("waits without a deadline on a null route until the API answers", async () => {
902
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
903
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
904
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }]);
905
+ stubApi(async () => new Response("ok"), answerAfter(150));
906
+
907
+ const response = await proxy(
908
+ new Request("http://app.localhost/api/file-transfers/7/commit", { method: "POST", body: "{}" }),
909
+ "http://api:5002/file-transfers/7/commit",
910
+ );
911
+
912
+ expect(response.status).toBe(200);
913
+ });
914
+
915
+ it("still answers 503 on a null route once the API is found unreachable", async () => {
916
+ vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
917
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }]);
918
+ let checks = 0;
919
+ stubApi((input, init) => (++checks <= 2 ? Promise.resolve(new Response("ok")) : hang(input, init)), hang);
920
+
921
+ const response = await proxy(
922
+ new Request("http://app.localhost/api/file-transfers/7/commit", { method: "POST", body: "{}" }),
923
+ "http://api:5002/file-transfers/7/commit",
924
+ );
925
+
926
+ expect(response.status).toBe(503);
927
+ expect(response.headers.get("retry-after")).toBe("2");
928
+ expect(await response.json()).toMatchObject({
929
+ error: { code: "BACKEND_UNAVAILABLE", path: "/api/file-transfers/7/commit" },
930
+ });
931
+ expect(checks).toBeGreaterThanOrEqual(3);
932
+ });
933
+
934
+ it("turns off Bun's fetch idle timeout on a route without a deadline only", async () => {
935
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
936
+ const fetch = stubApi(async () => new Response("ok"), async () => Response.json({ done: true }));
937
+
938
+ await proxy(
939
+ new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/commit", { method: "POST", body: "{}" }),
940
+ "http://api:5002/hosts/h1/file-transfers/t1/commit",
941
+ );
942
+ await proxy(new Request("http://app.localhost/api/hosts/h1/notes"), "http://api:5002/hosts/h1/notes");
943
+
944
+ const upstream = fetch.mock.calls.filter(([input]) => !String(input).endsWith("/health"));
945
+ const [commit, notes] = upstream.map(([, init]) => init as RequestInit & { timeout?: unknown });
946
+ expect(upstream).toHaveLength(2);
947
+ expect(commit?.timeout).toBe(false);
948
+ expect(notes).not.toHaveProperty("timeout");
949
+ });
950
+
951
+ it("turns off Bun's fetch idle timeout for a deadline it would cut first", async () => {
952
+ expect(upstreamFetchOptions(null)).toEqual({ timeout: false });
953
+ expect(upstreamFetchOptions(600_000)).toEqual({ timeout: false });
954
+ expect(upstreamFetchOptions(300_000)).toEqual({ timeout: false });
955
+ expect(upstreamFetchOptions(299_999)).toEqual({});
956
+ expect(upstreamFetchOptions(30_000)).toEqual({});
957
+
958
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "300000");
959
+ vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "0");
960
+ const proxy = await proxyWithTimeouts([]);
961
+ const fetch = stubApi(async () => new Response("ok"), async () => Response.json({ done: true }));
962
+
963
+ await proxy(new Request("http://app.localhost/api/report"), "http://api:5002/report");
964
+
965
+ expect(fetch.mock.calls[0]?.[1]).toHaveProperty("timeout", false);
966
+ });
967
+
968
+ it("waits without a deadline on a route with a path parameter", async () => {
969
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
970
+ const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
971
+ stubApi(async () => new Response("ok"), answerAfter(80));
972
+
973
+ const commit = await proxy(
974
+ new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/commit", { method: "POST", body: "{}" }),
975
+ "http://api:5002/hosts/h1/file-transfers/t1/commit",
976
+ );
977
+ const other = await proxy(new Request("http://app.localhost/api/hosts/h1/notes"), "http://api:5002/hosts/h1/notes");
978
+
979
+ expect(commit.status).toBe(200);
980
+ expect(other.status).toBe(504);
981
+ });
982
+
983
+ it("applies the longest matching prefix on the decoded path and the default elsewhere", () => {
984
+ vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "1234");
985
+ const timeouts = sortedProxyTimeouts([
986
+ { pathPrefix: "/api/", timeoutMs: 5_000 },
987
+ { pathPrefix: "/api/file-transfers/", timeoutMs: null },
988
+ { pathPrefix: "/api/file-transfers/status", timeoutMs: 100 },
989
+ ]);
990
+
991
+ expect(proxyTimeoutFor("/api/file-transfers/7/commit", timeouts)).toBeNull();
992
+ expect(proxyTimeoutFor("/api/file-transfers", timeouts)).toBeNull();
993
+ expect(proxyTimeoutFor("/api/file-transfers/status/7", timeouts)).toBe(100);
994
+ expect(proxyTimeoutFor("/api/%66ile-transfers/7", timeouts)).toBeNull();
995
+ expect(proxyTimeoutFor("/api/file-transfers-old/7", timeouts)).toBe(5_000);
996
+ expect(proxyTimeoutFor("/api/auth/get-session", sortedProxyTimeouts([]))).toBe(1234);
997
+ expect(proxyTimeoutFor("/other", timeouts)).toBe(1234);
998
+
999
+ const byHost = sortedProxyTimeouts([
1000
+ { pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null },
1001
+ { pathPrefix: "/api/hosts/local/file-transfers", timeoutMs: 60_000 },
1002
+ ]);
1003
+ expect(proxyTimeoutFor("/api/hosts/h1/file-transfers/t1/commit", byHost)).toBeNull();
1004
+ expect(proxyTimeoutFor("/api/hosts/local/file-transfers/t1/commit", byHost)).toBe(60_000);
1005
+ expect(proxyTimeoutFor("/api/hosts/file-transfers", byHost)).toBe(1234);
1006
+ });
1007
+
1008
+ it.each<[{ pathPrefix: string; timeoutMs: number | null }[], string]>([
1009
+ [[{ pathPrefix: "/exports/", timeoutMs: 10 }], "must be a path under /api/"],
1010
+ [[{ pathPrefix: "/api/exports/*", timeoutMs: 10 }], "must be a path under /api/"],
1011
+ [[{ pathPrefix: "/api/exports/../auth", timeoutMs: 10 }], "must be a path under /api/"],
1012
+ [[{ pathPrefix: "/api/exports", timeoutMs: 0 }], "must be null or a whole number of milliseconds"],
1013
+ [[{ pathPrefix: "/api/exports", timeoutMs: 1.5 }], "must be null or a whole number of milliseconds"],
1014
+ [[{ pathPrefix: "/api/exports", timeoutMs: 2_147_483_648 }], "must be null or a whole number of milliseconds"],
1015
+ [[{ pathPrefix: "/api/exports/:", timeoutMs: 10 }], "must be a path under /api/"],
1016
+ [[{ pathPrefix: "/api/exports", timeoutMs: null }, { pathPrefix: "/api/exports/", timeoutMs: 10 }], "lists the same paths twice"],
1017
+ [[{ pathPrefix: "/api/:a/x", timeoutMs: null }, { pathPrefix: "/api/:b/x", timeoutMs: 10 }], "lists the same paths twice"],
1018
+ ])("rejects an invalid timeout list %#", (timeouts, message) => {
1019
+ expect(() => sortedProxyTimeouts(timeouts)).toThrow(message);
1020
+ });
1021
+
1022
+ it.each([undefined, "0", "35", "soon"])("accepts CONNECTION_IDLE_TIMEOUT=%s with the default deadline", (idle) => {
1023
+ expect(() => checkIdleTimeout(idle, 30_000, [])).not.toThrow();
1024
+ });
1025
+
1026
+ it("requires the idle timeout to outlast the longest deadline by Bun's check interval", () => {
1027
+ expect(() => checkIdleTimeout("34", 30_000, [])).toThrow(
1028
+ "CONNECTION_IDLE_TIMEOUT=34 lets the web server close a silent connection before the /api proxy stops " +
1029
+ "waiting for response headers (30000 ms, BACKEND_PROXY_TIMEOUT_MS)",
1030
+ );
1031
+ expect(() => checkIdleTimeout("10", 30_000, [])).toThrow("Set CONNECTION_IDLE_TIMEOUT to at least 35, or to 0");
1032
+ expect(() => checkIdleTimeout("35", 30_500, [])).not.toThrow();
1033
+ expect(() => checkIdleTimeout("35", 31_000, [])).toThrow("at least 36");
1034
+
1035
+ const routes = sortedProxyTimeouts([
1036
+ { pathPrefix: "/api/quick/", timeoutMs: 5_000 },
1037
+ { pathPrefix: "/api/exports/", timeoutMs: 120_000 },
1038
+ ]);
1039
+ expect(() => checkIdleTimeout("35", 30_000, routes)).toThrow(
1040
+ "(120000 ms, APP_PROXY_TIMEOUTS for /api/exports); Bun closes idle connections up to 4 seconds early. " +
1041
+ "Set CONNECTION_IDLE_TIMEOUT to at least 125, or to 0 to turn the idle timeout off.",
1042
+ );
1043
+ expect(() => checkIdleTimeout("125", 30_000, routes)).not.toThrow();
1044
+ expect(() => checkIdleTimeout("0", 30_000, routes)).not.toThrow();
1045
+ });
1046
+
1047
+ it("requires the idle timeout off for a route without a deadline or beyond Bun's limit", () => {
1048
+ const unbounded = sortedProxyTimeouts([
1049
+ { pathPrefix: "/api/exports/", timeoutMs: 120_000 },
1050
+ { pathPrefix: "/api/file-transfers/", timeoutMs: null },
1051
+ ]);
1052
+ expect(() => checkIdleTimeout("255", 30_000, unbounded)).toThrow(
1053
+ "(no deadline, APP_PROXY_TIMEOUTS for /api/file-transfers); Bun closes idle connections up to 4 seconds " +
1054
+ "early. Set CONNECTION_IDLE_TIMEOUT to 0 to turn the idle timeout off, since Bun allows at most 255 seconds.",
1055
+ );
1056
+ expect(() => checkIdleTimeout("0", 30_000, unbounded)).not.toThrow();
1057
+ expect(() => checkIdleTimeout("255", 250_999, [])).not.toThrow();
1058
+ expect(() => checkIdleTimeout("255", 251_000, [])).toThrow("Set CONNECTION_IDLE_TIMEOUT to 0");
1059
+ });
1060
+
1061
+ it("refuses to load when CONNECTION_IDLE_TIMEOUT cannot cover a route's deadline", async () => {
1062
+ vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "35");
1063
+ await expect(proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }])).rejects.toThrow(
1064
+ "CONNECTION_IDLE_TIMEOUT=35 lets the web server close a silent connection",
1065
+ );
1066
+ vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "0");
1067
+ await expect(proxyWithTimeouts([{ pathPrefix: "/api/file-transfers/", timeoutMs: null }])).resolves.toBeDefined();
1068
+ });
1069
+
1070
+ it("refuses to load with an invalid application timeout", async () => {
1071
+ await expect(proxyWithTimeouts([{ pathPrefix: "/api/exports", timeoutMs: -1 }])).rejects.toThrow(
1072
+ "APP_PROXY_TIMEOUTS timeoutMs for /api/exports must be null",
1073
+ );
1074
+ });
1075
+ });