@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
@@ -3,12 +3,21 @@ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
3
  import { validateEnv } from "../config/env.validation";
4
4
  import { PROVIDERS } from "../config/providers";
5
5
  import { databaseUrl, sqliteDatabasePath } from "./database";
6
+ import { assertMigrationsLoaded, loadMigrations, migrationName, migrationSet } from "./migration-files";
6
7
  import { revertLatestSqliteMigration, runSqliteMigrations } from "./sqlite-migrator";
7
8
 
8
9
  function databaseProvider(): DatabaseProviderName {
9
10
  return PROVIDERS.database;
10
11
  }
11
12
 
13
+ /** Prints the migrations this build runs, one name per line, without a database. */
14
+ async function list(): Promise<void> {
15
+ const set = migrationSet();
16
+ const loaded = await loadMigrations(set);
17
+ for (const { migration } of loaded) process.stdout.write(`${migrationName(migration)}\n`);
18
+ process.stderr.write(`${loaded.length} migrations in ${set.directory}\n`);
19
+ }
20
+
12
21
  async function migrate(): Promise<void> {
13
22
  if (databaseProvider() === "sqlite") {
14
23
  const env = validateEnv(process.env);
@@ -21,9 +30,10 @@ async function migrate(): Promise<void> {
21
30
  }
22
31
  return;
23
32
  }
24
- const { appDataSource: dataSource } = await import("./data-source.js");
33
+ const { appDataSource: dataSource, appMigrations } = await import("./data-source.js");
25
34
  await dataSource.initialize();
26
35
  try {
36
+ assertMigrationsLoaded(dataSource.migrations, appMigrations);
27
37
  if (process.argv.includes("--revert")) await dataSource.undoLastMigration({ transaction: "all" });
28
38
  else await dataSource.runMigrations();
29
39
  } finally {
@@ -31,7 +41,7 @@ async function migrate(): Promise<void> {
31
41
  }
32
42
  }
33
43
 
34
- void migrate().catch((error: unknown) => {
44
+ void (process.argv.includes("--list") ? list() : migrate()).catch((error: unknown) => {
35
45
  const message = error instanceof Error ? error.message : String(error);
36
46
  process.stderr.write(`Migration failed: ${message}\n`);
37
47
  process.exitCode = 1;
@@ -0,0 +1,116 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import type { MigrationInterface } from "typeorm";
6
+ import {
7
+ assertMigrationsLoaded,
8
+ loadMigrations,
9
+ MIGRATION_LIST,
10
+ migrationPaths,
11
+ migrationSet,
12
+ } from "./migration-files";
13
+
14
+ const roots: string[] = [];
15
+
16
+ afterEach(() => {
17
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
18
+ });
19
+
20
+ function apiRoot(files: Record<string, string>): string {
21
+ const root = mkdtempSync(join(tmpdir(), "migration-files-"));
22
+ roots.push(root);
23
+ for (const [path, content] of Object.entries(files)) {
24
+ mkdirSync(dirname(join(root, path)), { recursive: true });
25
+ writeFileSync(join(root, path), content);
26
+ }
27
+ return root;
28
+ }
29
+
30
+ const migrationSource = (name: string): string =>
31
+ `export class ${name} { name = "${name}"; async up() {} async down() {} }\n`;
32
+
33
+ const loaded = (...names: string[]): MigrationInterface[] =>
34
+ names.map((name) => ({ name, up: async () => undefined, down: async () => undefined }));
35
+
36
+ describe("migration files", () => {
37
+ it("lists source migrations in timestamp order outside production", () => {
38
+ const root = apiRoot({
39
+ "src/migrations/1700000000002-Second.ts": migrationSource("Second1700000000002"),
40
+ "src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
41
+ "src/migrations/helpers.ts": "export {};\n",
42
+ "dist/migrations/1690000000000-Stale.js": migrationSource("Stale1690000000000"),
43
+ });
44
+ const set = migrationSet({ root, production: false });
45
+ expect(set).toEqual({
46
+ directory: join(root, "src/migrations"),
47
+ compiled: false,
48
+ files: [{ file: "1700000000001-First.ts" }, { file: "1700000000002-Second.ts" }],
49
+ });
50
+ expect(migrationPaths(set)).toEqual([
51
+ join(root, "src/migrations/1700000000001-First.ts"),
52
+ join(root, "src/migrations/1700000000002-Second.ts"),
53
+ ]);
54
+ });
55
+
56
+ it("reads the build's list in production", () => {
57
+ const list = [{ file: "1700000000001-First.js", name: "First1700000000001" }];
58
+ const root = apiRoot({
59
+ [`dist/migrations/${MIGRATION_LIST}`]: JSON.stringify(list),
60
+ "dist/migrations/1700000000001-First.js": migrationSource("First1700000000001"),
61
+ });
62
+ expect(migrationSet({ root, production: true })).toEqual({
63
+ directory: join(root, "dist/migrations"),
64
+ compiled: true,
65
+ files: list,
66
+ });
67
+ });
68
+
69
+ it("refuses a build output without its list instead of running no migrations", () => {
70
+ const root = apiRoot({ "dist/main.js": "", "dist/migrations/src/migrations/1700000000001-First.js": "" });
71
+ expect(() => migrationSet({ root, production: true })).toThrow(`${MIGRATION_LIST} is missing`);
72
+ });
73
+
74
+ it("refuses a list whose files are missing", () => {
75
+ const root = apiRoot({
76
+ [`dist/migrations/${MIGRATION_LIST}`]: JSON.stringify([{ file: "1700000000001-First.js", name: "First1700000000001" }]),
77
+ });
78
+ expect(() => migrationSet({ root, production: true })).toThrow("Compiled migrations are missing");
79
+ });
80
+
81
+ it("runs source migrations when production code runs without a build", () => {
82
+ const root = apiRoot({ "src/migrations/1700000000001-First.ts": migrationSource("First1700000000001") });
83
+ expect(migrationSet({ root, production: true })).toMatchObject({ compiled: false, files: [{ file: "1700000000001-First.ts" }] });
84
+ });
85
+
86
+ it("fails when a runner loaded fewer migrations than the set holds", () => {
87
+ const root = apiRoot({
88
+ "src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
89
+ "src/migrations/1700000000002-Second.ts": migrationSource("Second1700000000002"),
90
+ });
91
+ const set = migrationSet({ root, production: false });
92
+ expect(() => assertMigrationsLoaded([], set)).toThrow("Loaded 0 of the 2 migrations");
93
+ expect(() => assertMigrationsLoaded(loaded("First1700000000001", "Second1700000000002"), set)).not.toThrow();
94
+ });
95
+
96
+ it("fails when the build's listed names did not load", () => {
97
+ const set = {
98
+ directory: "dist/migrations",
99
+ compiled: true,
100
+ files: [{ file: "1700000000001-First.js", name: "First1700000000001" }],
101
+ };
102
+ expect(() => assertMigrationsLoaded(loaded("Other1700000000001"), set)).toThrow("did not load: First1700000000001");
103
+ });
104
+
105
+ it("loads one migration class from each file", async () => {
106
+ const root = apiRoot({
107
+ "src/migrations/1700000000001-First.ts": migrationSource("First1700000000001"),
108
+ "src/migrations/1700000000002-Two.ts": `${migrationSource("A1700000000002")}${migrationSource("B1700000000002")}`,
109
+ });
110
+ const set = migrationSet({ root, production: false });
111
+ await expect(loadMigrations({ ...set, files: set.files.slice(0, 1) })).resolves.toMatchObject([
112
+ { file: "1700000000001-First.ts", timestamp: 1700000000001, migration: { name: "First1700000000001" } },
113
+ ]);
114
+ await expect(loadMigrations(set)).rejects.toThrow("must export exactly one migration class");
115
+ });
116
+ });
@@ -0,0 +1,134 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { MigrationInterface } from "typeorm";
5
+
6
+ /** The list `scripts/build.mjs` writes next to the compiled migrations. */
7
+ export const MIGRATION_LIST = "migrations.json";
8
+
9
+ const SOURCE_FILE = /^\d+-.+\.ts$/;
10
+
11
+ export interface MigrationFile {
12
+ file: string;
13
+ /** The migration's name, recorded by the build; unknown for source files until loaded. */
14
+ name?: string;
15
+ }
16
+
17
+ export interface MigrationSet {
18
+ directory: string;
19
+ /** True for the build output, which lists its migrations in `migrations.json`. */
20
+ compiled: boolean;
21
+ /** In timestamp order. */
22
+ files: MigrationFile[];
23
+ }
24
+
25
+ export interface MigrationSetOptions {
26
+ /** The API directory. Defaults to the working directory. */
27
+ root?: string;
28
+ /** Reads the build output. Defaults to NODE_ENV=production. */
29
+ production?: boolean;
30
+ }
31
+
32
+ function readList(path: string): MigrationFile[] {
33
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
34
+ if (
35
+ !Array.isArray(parsed) ||
36
+ !parsed.every((entry: unknown) =>
37
+ typeof entry === "object" && entry !== null &&
38
+ typeof (entry as Record<string, unknown>).file === "string" &&
39
+ typeof (entry as Record<string, unknown>).name === "string"
40
+ )
41
+ ) {
42
+ throw new Error(`${path} must list { file, name } entries; rebuild the API`);
43
+ }
44
+ return parsed as MigrationFile[];
45
+ }
46
+
47
+ /**
48
+ * The migrations this process runs. In production that is the build output's
49
+ * list; a build output without its list fails instead of running none.
50
+ * Otherwise it is every `<timestamp>-<name>.ts` file in `src/migrations`.
51
+ */
52
+ export function migrationSet(options: MigrationSetOptions = {}): MigrationSet {
53
+ const root = options.root ?? process.cwd();
54
+ const production = options.production ?? process.env.NODE_ENV === "production";
55
+ const compiled = join(root, "dist", "migrations");
56
+ const source = join(root, "src", "migrations");
57
+ if (production && existsSync(join(root, "dist"))) {
58
+ const list = join(compiled, MIGRATION_LIST);
59
+ if (!existsSync(list)) {
60
+ throw new Error(`${list} is missing; build the API with scripts/build.mjs before running it`);
61
+ }
62
+ const files = readList(list);
63
+ const missing = files.filter((entry) => !existsSync(join(compiled, entry.file)));
64
+ if (missing.length > 0) {
65
+ throw new Error(`Compiled migrations are missing from ${compiled}: ${missing.map((entry) => entry.file).join(", ")}`);
66
+ }
67
+ return { directory: compiled, compiled: true, files };
68
+ }
69
+ const files = existsSync(source)
70
+ ? readdirSync(source).filter((file) => SOURCE_FILE.test(file)).sort().map((file) => ({ file }))
71
+ : [];
72
+ return { directory: source, compiled: false, files };
73
+ }
74
+
75
+ export function migrationPaths(set: MigrationSet): string[] {
76
+ return set.files.map((entry) => join(set.directory, entry.file));
77
+ }
78
+
79
+ type MigrationConstructor = new () => MigrationInterface;
80
+
81
+ function isMigration(value: unknown): value is MigrationConstructor {
82
+ if (typeof value !== "function") return false;
83
+ const prototype = (value as { prototype?: unknown }).prototype;
84
+ if (!prototype || typeof prototype !== "object") return false;
85
+ const record = prototype as Record<string, unknown>;
86
+ return typeof record.up === "function" && typeof record.down === "function";
87
+ }
88
+
89
+ /** TypeORM's name for a migration: its `name` property, else its class name. */
90
+ export function migrationName(migration: MigrationInterface): string {
91
+ return migration.name || migration.constructor.name;
92
+ }
93
+
94
+ export interface LoadedMigration {
95
+ file: string;
96
+ timestamp: number;
97
+ migration: MigrationInterface;
98
+ }
99
+
100
+ /** Imports every file of the set; each must export exactly one migration class. */
101
+ export async function loadMigrations(set: MigrationSet): Promise<LoadedMigration[]> {
102
+ const loaded: LoadedMigration[] = [];
103
+ for (const entry of set.files) {
104
+ const module = await import(pathToFileURL(join(set.directory, entry.file)).href) as Record<string, unknown>;
105
+ const constructors = Object.values(module).filter(isMigration);
106
+ const Constructor = constructors[0];
107
+ if (constructors.length !== 1 || !Constructor) {
108
+ throw new Error(`Migration ${entry.file} must export exactly one migration class`);
109
+ }
110
+ const timestamp = Number(entry.file.slice(0, entry.file.indexOf("-")));
111
+ if (!Number.isSafeInteger(timestamp)) throw new Error(`Migration ${entry.file} has an invalid timestamp`);
112
+ loaded.push({ file: entry.file, timestamp, migration: new Constructor() });
113
+ }
114
+ return loaded;
115
+ }
116
+
117
+ /**
118
+ * Fails unless the migrations a runner loaded are the set: TypeORM loads
119
+ * nothing, without an error, from a path that matches no file.
120
+ */
121
+ export function assertMigrationsLoaded(loaded: readonly MigrationInterface[], set: MigrationSet): void {
122
+ if (loaded.length !== set.files.length) {
123
+ throw new Error(
124
+ `Loaded ${loaded.length} of the ${set.files.length} migrations in ${set.directory}; ` +
125
+ "each migration file must export exactly one migration class",
126
+ );
127
+ }
128
+ if (!set.compiled) return;
129
+ const names = new Set(loaded.map(migrationName));
130
+ const absent = set.files.filter((entry) => entry.name && !names.has(entry.name));
131
+ if (absent.length > 0) {
132
+ throw new Error(`Migrations listed in ${MIGRATION_LIST} did not load: ${absent.map((entry) => entry.name).join(", ")}`);
133
+ }
134
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { pendingMigrationsCheck } from "./migration-readiness";
4
+
5
+ const set = {
6
+ directory: "dist/migrations",
7
+ compiled: true,
8
+ files: [
9
+ { file: "1700000000001-First.js", name: "First1700000000001" },
10
+ { file: "1700000000002-Second.js", name: "Second1700000000002" },
11
+ ],
12
+ };
13
+
14
+ describe("pending migrations readiness", () => {
15
+ it("is down until every listed migration is applied, then stops querying", async () => {
16
+ const sql = new SQL(":memory:");
17
+ try {
18
+ const check = pendingMigrationsCheck(() => sql, set);
19
+ await expect(check()).rejects.toThrow();
20
+ await sql`CREATE TABLE "migrations" ("id" integer PRIMARY KEY, "timestamp" bigint, "name" text)`;
21
+ await sql`INSERT INTO "migrations" ("timestamp", "name") VALUES (1700000000001, 'First1700000000001')`;
22
+ await expect(check()).rejects.toThrow("1 migrations are pending; run migrate:all: Second1700000000002");
23
+ await sql`INSERT INTO "migrations" ("timestamp", "name") VALUES (1700000000002, 'Second1700000000002')`;
24
+ await expect(check()).resolves.toBeUndefined();
25
+ await sql`DROP TABLE "migrations"`;
26
+ await expect(check()).resolves.toBeUndefined();
27
+ } finally {
28
+ await sql.close();
29
+ }
30
+ });
31
+
32
+ it("is up without a query when the build has no migrations", async () => {
33
+ const check = pendingMigrationsCheck(() => { throw new Error("no database"); }, { ...set, files: [] });
34
+ await expect(check()).resolves.toBeUndefined();
35
+ });
36
+ });
@@ -0,0 +1,25 @@
1
+ import type { SQL } from "bun";
2
+ import type { ReadinessCheck } from "../health/readiness.service";
3
+ import type { MigrationSet } from "./migration-files";
4
+
5
+ /** Reported down while the database lacks a migration of this build. */
6
+ export const MIGRATIONS_READINESS_CHECK = "migrations";
7
+
8
+ /**
9
+ * Compares the migrations table with the build's list. Once every migration is
10
+ * applied the check stays up without a query: this process never runs one.
11
+ */
12
+ export function pendingMigrationsCheck(sql: () => SQL, set: MigrationSet): ReadinessCheck {
13
+ const expected = set.files.flatMap((entry) => entry.name ? [entry.name] : []);
14
+ let applied = expected.length === 0;
15
+ return async (): Promise<void> => {
16
+ if (applied) return;
17
+ const rows = await sql()<Array<{ name: string }>>`SELECT "name" FROM "migrations"`;
18
+ const done = new Set(rows.map((row) => row.name));
19
+ const pending = expected.filter((name) => !done.has(name));
20
+ if (pending.length > 0) {
21
+ throw new Error(`${pending.length} migrations are pending; run migrate:all: ${pending.join(", ")}`);
22
+ }
23
+ applied = true;
24
+ };
25
+ }
@@ -15,6 +15,8 @@ interface LaunchOptions {
15
15
  /** Starts the launcher from the directory above the API, like a repository root. */
16
16
  fromParent?: boolean;
17
17
  files?: Record<string, string>;
18
+ /** Arguments after the launcher, such as --list. */
19
+ args?: string[];
18
20
  }
19
21
 
20
22
  /** Runs the migrate:all launcher in an API layout whose entries print their own path. */
@@ -32,7 +34,8 @@ async function launch(entries: string[], nodeEnv: string, body = "", options: La
32
34
  mkdirSync(dirname(join(root, path)), { recursive: true });
33
35
  writeFileSync(join(root, path), content);
34
36
  }
35
- const child = Bun.spawn([process.execPath, options.fromParent ? "api/scripts/run-migrations.mjs" : "scripts/run-migrations.mjs"], {
37
+ const launcher = options.fromParent ? "api/scripts/run-migrations.mjs" : "scripts/run-migrations.mjs";
38
+ const child = Bun.spawn([process.execPath, launcher, ...(options.args ?? [])], {
36
39
  cwd: options.fromParent ? parent : root,
37
40
  env: { ...process.env, NODE_ENV: nodeEnv },
38
41
  stdout: "pipe",
@@ -68,6 +71,14 @@ describe("migrate:all launcher", () => {
68
71
  .toMatchObject({ code: 0, stdout: "dist/migrate.js" });
69
72
  });
70
73
 
74
+ it("lists migrations through the application migrator, which needs no auth", async () => {
75
+ const body = "console.log(process.argv.slice(2).join(' '));\n";
76
+ expect(await launch(["src/migrate.ts", "src/database/migrate.ts"], "development", body, { args: ["--list"] }))
77
+ .toMatchObject({ code: 0, stdout: "src/database/migrate.ts\n--list" });
78
+ expect(await launch(["dist/migrate.js", "dist/database/migrate.js"], "production", body, { args: ["--list"] }))
79
+ .toMatchObject({ code: 0, stdout: "dist/database/migrate.js\n--list" });
80
+ });
81
+
71
82
  it("exits with the migration's status", async () => {
72
83
  const result = await launch(["src/database/migrate.ts"], "development", "process.exit(3);\n");
73
84
  expect(result.code).toBe(3);
@@ -1,8 +1,6 @@
1
1
  import { Database as SqliteDatabase } from "bun:sqlite";
2
- import { existsSync, readdirSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
2
  import type { MigrationInterface, QueryRunner } from "typeorm";
3
+ import { loadMigrations, migrationSet } from "./migration-files";
6
4
 
7
5
  export interface SqliteMigrationDefinition {
8
6
  timestamp: number;
@@ -15,40 +13,8 @@ interface AppliedMigration {
15
13
  name: string;
16
14
  }
17
15
 
18
- type MigrationConstructor = new () => MigrationInterface;
19
-
20
- function isMigration(value: unknown): value is MigrationConstructor {
21
- if (typeof value !== "function") return false;
22
- const prototype = (value as { prototype?: unknown }).prototype;
23
- if (!prototype || typeof prototype !== "object") return false;
24
- const record = prototype as Record<string, unknown>;
25
- return typeof record.up === "function" && typeof record.down === "function";
26
- }
27
-
28
16
  async function discoverMigrations(): Promise<SqliteMigrationDefinition[]> {
29
- const compiled = join(process.cwd(), "dist", "migrations");
30
- const source = join(process.cwd(), "src", "migrations");
31
- const directory = process.env.NODE_ENV === "production" && existsSync(compiled)
32
- ? compiled
33
- : source;
34
- if (!existsSync(directory)) return [];
35
- const files = readdirSync(directory)
36
- .filter((file) => /^\d+-.+\.(?:js|ts)$/.test(file))
37
- .sort();
38
- const definitions: SqliteMigrationDefinition[] = [];
39
- for (const file of files) {
40
- const loaded = await import(pathToFileURL(join(directory, file)).href) as Record<string, unknown>;
41
- const constructors = Object.values(loaded).filter(isMigration);
42
- if (constructors.length !== 1) {
43
- throw new Error(`SQLite migration ${file} must export exactly one migration class`);
44
- }
45
- const timestamp = Number(file.slice(0, file.indexOf("-")));
46
- if (!Number.isSafeInteger(timestamp)) throw new Error(`SQLite migration ${file} has an invalid timestamp`);
47
- const Constructor = constructors[0];
48
- if (!Constructor) throw new Error(`SQLite migration ${file} has no migration class`);
49
- definitions.push({ timestamp, migration: new Constructor() });
50
- }
51
- return definitions;
17
+ return loadMigrations(migrationSet());
52
18
  }
53
19
 
54
20
  function queryRunner(database: SqliteDatabase): QueryRunner {
@@ -10,8 +10,8 @@ const app = createApp({ env, services });
10
10
  async function bootstrap(): Promise<void> {
11
11
  try {
12
12
  await services.start();
13
- app.listen({ ...apiServerOptions(env), hostname: "0.0.0.0", port: env.PORT });
14
- process.stdout.write(`API listening on http://0.0.0.0:${env.PORT}\n`);
13
+ app.listen({ ...apiServerOptions(env), hostname: env.HOST, port: env.PORT });
14
+ process.stdout.write(`API listening on http://${env.HOST}:${env.PORT}\n`);
15
15
  } catch (error) {
16
16
  await services.close();
17
17
  throw error;
@@ -6,6 +6,8 @@
6
6
  "outDir": "dist",
7
7
  "rootDir": "src",
8
8
  "declaration": false,
9
+ "experimentalDecorators": true,
10
+ "emitDecoratorMetadata": true,
9
11
  "strict": true,
10
12
  "noUnusedLocals": true,
11
13
  "noUnusedParameters": true,
@@ -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
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "@internationalized/date": "^3.12.2",
18
18
  "@lucide/svelte": "^1.23.0",
19
- "@podosoft/podokit-api-client": "^0.9.0",
19
+ "@podosoft/podokit-api-client": "^0.9.1",
20
20
  "bits-ui": "^2.18.1",
21
21
  "clsx": "^2.1.1",
22
22
  "mode-watcher": "^1.1.0",
@@ -121,3 +121,6 @@
121
121
  color: var(--sidebar-foreground);
122
122
  font-weight: 400;
123
123
  }
124
+
125
+ /* Application styles: @source paths, theme overrides, and base styles. */
126
+ @import "./app.extensions.css";
@@ -0,0 +1,11 @@
1
+ /*
2
+ * Application-owned styles. The managed app.css imports this file last, so rules
3
+ * here come after PodoKit's theme variables and base styles. Add `@source` for
4
+ * packages whose built components use Tailwind classes, override variables in
5
+ * `:root` and `.dark`, or add `@theme` entries and base styles, for example:
6
+ *
7
+ * @source "../../../node_modules/@example/ui/dist";
8
+ * :root { --radius: 0.5rem; }
9
+ *
10
+ * PodoKit creates this file once and never overwrites it.
11
+ */
@@ -4,4 +4,6 @@
4
4
  import { ModeWatcher } from "mode-watcher";
5
5
  </script>
6
6
 
7
- <ModeWatcher />
7
+ <!-- `.light` on <html> in light mode lets component libraries that follow a `.dark`
8
+ or `.light` class, and fall back to the OS setting otherwise, match the theme. -->
9
+ <ModeWatcher lightClassNames={["light"]} />
@@ -9,6 +9,7 @@ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
9
9
  function requestEvent(fetch: Fetch): RequestEvent {
10
10
  return {
11
11
  request: new Request("http://app.localhost/services", { headers: { cookie: "session=abc" } }),
12
+ url: new URL("http://app.localhost/services"),
12
13
  getClientAddress: () => "203.0.113.9",
13
14
  fetch,
14
15
  } as unknown as RequestEvent;
@@ -50,6 +51,24 @@ describe("serverApiClient", () => {
50
51
  expect(headers?.get("x-forwarded-for")).toBe("203.0.113.9");
51
52
  });
52
53
 
54
+ it("forwards the public origin only when CORS_ORIGIN lists it", async () => {
55
+ let headers: Headers | undefined;
56
+ const api = serverApiClient(requestEvent(async (_input, init) => {
57
+ headers = new Headers(init?.headers);
58
+ return Response.json({ ok: true });
59
+ }));
60
+
61
+ vi.stubEnv("CORS_ORIGIN", "https://example.com, http://app.localhost");
62
+ await api.get("/site/settings");
63
+ expect(headers?.get("x-forwarded-host")).toBe("app.localhost");
64
+ expect(headers?.get("x-forwarded-proto")).toBe("http");
65
+
66
+ vi.stubEnv("CORS_ORIGIN", "https://example.com");
67
+ await api.get("/site/settings");
68
+ expect(headers?.has("x-forwarded-host")).toBe(false);
69
+ expect(headers?.has("x-forwarded-proto")).toBe(false);
70
+ });
71
+
53
72
  it("gives up on a call that outlasts BACKEND_TIMEOUT_MS", async () => {
54
73
  vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
55
74
  stubHealth(async () => new Response("ok"));
@@ -8,6 +8,7 @@ import {
8
8
  reportBackendAnswer,
9
9
  reportBackendFailure,
10
10
  resolveClientIp,
11
+ setForwardedOrigin,
11
12
  } from "./backend-proxy";
12
13
 
13
14
  /** Thrown without contacting the API while it is known to be unreachable. */
@@ -19,7 +20,8 @@ export class BackendUnavailableError extends Error {
19
20
  }
20
21
 
21
22
  // Server-side client for load functions and hooks: points at the internal
22
- // backend URL and forwards the incoming request's cookies. Each call is bounded
23
+ // backend URL and forwards the incoming request's cookies, client address, and
24
+ // public origin as the /api proxy does. Each call is bounded
23
25
  // by BACKEND_TIMEOUT_MS, so a page renders its fallbacks instead of waiting for
24
26
  // an API that does not answer.
25
27
  export function serverApiClient(event: RequestEvent): ApiClient {
@@ -31,6 +33,7 @@ export function serverApiClient(event: RequestEvent): ApiClient {
31
33
  const headers = new Headers(init?.headers);
32
34
  if (cookie) headers.set("cookie", cookie);
33
35
  if (clientIp) headers.set("x-forwarded-for", clientIp);
36
+ setForwardedOrigin(headers, event.url);
34
37
  const outage = backendOutageSignal();
35
38
  const signals = [AbortSignal.timeout(backendTimeoutMs()), outage];
36
39
  if (init?.signal) signals.push(init.signal);