@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
@@ -0,0 +1,79 @@
1
+ import {
2
+ CACHE,
3
+ EVENTS,
4
+ JOBS,
5
+ OBJECT_STORAGE,
6
+ PROVIDER_CAPABILITIES,
7
+ PROVIDER_NAMES,
8
+ type ProviderCapability,
9
+ type ProviderSelections,
10
+ type ServiceKey,
11
+ } from "@podosoft/podokit-runtime";
12
+
13
+ /** The variable that replaces each capability's generated provider at startup. */
14
+ export const PROVIDER_VARIABLES: Readonly<Record<ProviderCapability, string>> = {
15
+ database: "DATABASE_PROVIDER",
16
+ cache: "CACHE_PROVIDER",
17
+ "object-storage": "OBJECT_STORAGE_PROVIDER",
18
+ events: "EVENTS_PROVIDER",
19
+ jobs: "JOBS_PROVIDER",
20
+ };
21
+
22
+ export type ProviderEnvironment = Readonly<Record<string, string | undefined>>;
23
+
24
+ /** The capabilities whose provider a variable selects, for example in a desktop build. */
25
+ export function selectedFromEnvironment(env: ProviderEnvironment = process.env): ProviderCapability[] {
26
+ return PROVIDER_CAPABILITIES.filter((capability) => Boolean(env[PROVIDER_VARIABLES[capability]]?.trim()));
27
+ }
28
+
29
+ /**
30
+ * The generated selection, with a capability replaced when its variable is set,
31
+ * so one build can run, say, PostgreSQL on a server and SQLite in a desktop
32
+ * shell. The selected provider's module must be installed; an unknown name
33
+ * fails at startup.
34
+ */
35
+ export function providersFromEnvironment(
36
+ generated: ProviderSelections,
37
+ env: ProviderEnvironment = process.env,
38
+ ): ProviderSelections {
39
+ const selections: Record<string, string> = { ...generated };
40
+ for (const capability of selectedFromEnvironment(env)) {
41
+ const variable = PROVIDER_VARIABLES[capability];
42
+ const value = env[variable]?.trim().toLowerCase() ?? "";
43
+ const allowed: readonly string[] = PROVIDER_NAMES[capability];
44
+ if (!allowed.includes(value)) {
45
+ throw new Error(`Invalid environment: ${variable} must be one of ${allowed.join(", ")}`);
46
+ }
47
+ selections[capability] = value;
48
+ }
49
+ return selections as unknown as ProviderSelections;
50
+ }
51
+
52
+ // The service each provider module registers when its provider is selected.
53
+ // The database has no module: the core service serves PostgreSQL and SQLite.
54
+ const PROVIDER_SERVICES: Partial<Record<ProviderCapability, ServiceKey<unknown>>> = {
55
+ cache: CACHE,
56
+ "object-storage": OBJECT_STORAGE,
57
+ events: EVENTS,
58
+ jobs: JOBS,
59
+ };
60
+
61
+ /**
62
+ * Fails startup when a variable selects a provider whose module is not
63
+ * installed, which would otherwise fail the first request that needs it.
64
+ */
65
+ export function assertSelectedProvidersInstalled(
66
+ services: { tryResolve<T>(key: ServiceKey<T>): T | undefined },
67
+ providers: ProviderSelections,
68
+ env: ProviderEnvironment = process.env,
69
+ ): void {
70
+ const missing = selectedFromEnvironment(env).flatMap((capability) => {
71
+ const key = PROVIDER_SERVICES[capability];
72
+ return key && services.tryResolve(key) === undefined
73
+ ? [`${PROVIDER_VARIABLES[capability]}=${providers[capability]}`]
74
+ : [];
75
+ });
76
+ if (missing.length > 0) {
77
+ throw new Error(`Invalid environment: no installed module provides ${missing.join(", ")}`);
78
+ }
79
+ }
@@ -1,9 +1,15 @@
1
1
  import type { ProviderSelections } from "@podosoft/podokit-runtime";
2
+ import { providersFromEnvironment } from "./provider-selection";
2
3
 
3
- export const PROVIDERS = {
4
+ // The providers selected when the project was generated or by `podo provider
5
+ // set`. Typed with every provider name, not the selected ones: the module of a
6
+ // provider that stays installed after a switch compares against its own name.
7
+ // A <CAPABILITY>_PROVIDER variable replaces one at startup; see
8
+ // provider-selection.ts.
9
+ export const PROVIDERS: Readonly<ProviderSelections> = providersFromEnvironment({
4
10
  database: "{{databaseProvider}}",
5
11
  cache: "{{cacheProvider}}",
6
12
  "object-storage": "{{objectStorageProvider}}",
7
13
  events: "{{eventsProvider}}",
8
14
  jobs: "{{jobsProvider}}",
9
- } as const satisfies ProviderSelections;
15
+ });
@@ -4,26 +4,37 @@ import { validateEnv } from "./env.validation";
4
4
  import { apiServerOptions, DEFAULT_MAX_REQUEST_BODY_SIZE } from "./server-options";
5
5
 
6
6
  describe("apiServerOptions", () => {
7
- it("defaults to Bun's request body limit and leaves the idle timeout to Elysia", () => {
7
+ it("defaults to Bun's request body limit and an idle timeout that outlasts the web proxy", () => {
8
8
  const env = validateEnv({});
9
9
  expect(env.API_MAX_REQUEST_BODY_SIZE).toBe(DEFAULT_MAX_REQUEST_BODY_SIZE);
10
- expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE });
10
+ expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE, idleTimeout: 35 });
11
+ });
12
+
13
+ it("reads API_IDLE_TIMEOUT from 0 to 255 seconds and treats an empty value as unset", () => {
14
+ expect(validateEnv({ API_IDLE_TIMEOUT: "125" }).API_IDLE_TIMEOUT).toBe(125);
15
+ expect(validateEnv({ API_IDLE_TIMEOUT: "0" }).API_IDLE_TIMEOUT).toBe(0);
16
+ expect(validateEnv({ API_IDLE_TIMEOUT: "255" }).API_IDLE_TIMEOUT).toBe(255);
17
+ expect(validateEnv({ API_IDLE_TIMEOUT: "" }).API_IDLE_TIMEOUT).toBe(35);
18
+ for (const value of ["256", "-1", "1.5", "soon"]) {
19
+ expect(() => validateEnv({ API_IDLE_TIMEOUT: value })).toThrow("API_IDLE_TIMEOUT");
20
+ }
21
+ expect(apiServerOptions(validateEnv({ API_IDLE_TIMEOUT: "0" }), {}).idleTimeout).toBe(0);
11
22
  });
12
23
 
13
24
  it("reads API_MAX_REQUEST_BODY_SIZE and rejects values that are not byte counts", () => {
14
- expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}))
15
- .toEqual({ maxRequestBodySize: 1_073_741_824 });
25
+ expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}).maxRequestBodySize)
26
+ .toBe(1_073_741_824);
16
27
  for (const value of ["0", "-1", "1.5", "lots"]) {
17
28
  expect(() => validateEnv({ API_MAX_REQUEST_BODY_SIZE: value })).toThrow("API_MAX_REQUEST_BODY_SIZE");
18
29
  }
19
30
  });
20
31
 
21
32
  it("lets the application's options win over the environment", () => {
22
- const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024" });
33
+ const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024", API_IDLE_TIMEOUT: "60" });
23
34
  expect(apiServerOptions(env, { maxRequestBodySize: 4096, idleTimeout: 120 }))
24
35
  .toEqual({ maxRequestBodySize: 4096, idleTimeout: 120 });
25
36
  expect(apiServerOptions(env, (current) => ({ maxRequestBodySize: current.API_MAX_REQUEST_BODY_SIZE * 2 })))
26
- .toEqual({ maxRequestBodySize: 2048 });
37
+ .toEqual({ maxRequestBodySize: 2048, idleTimeout: 60 });
27
38
  expect(() => apiServerOptions(env, { maxRequestBodySize: 0 })).toThrow("maxRequestBodySize");
28
39
  expect(() => apiServerOptions(env, { idleTimeout: 256 })).toThrow("idleTimeout");
29
40
  expect(() => apiServerOptions(env, { idleTimeout: -1 })).toThrow("idleTimeout");
@@ -11,7 +11,7 @@ export interface ApiServerOptions {
11
11
  maxRequestBodySize?: number;
12
12
  /**
13
13
  * Seconds a connection may stay idle before it is closed, from 0 (never) to
14
- * 255. Defaults to Elysia's 30 seconds.
14
+ * 255. Defaults to `API_IDLE_TIMEOUT`.
15
15
  */
16
16
  idleTimeout?: number;
17
17
  }
@@ -31,18 +31,15 @@ const owned: { readonly [name: string]: unknown; readonly apiServerOptions?: Api
31
31
  export function apiServerOptions(
32
32
  env: AppEnv,
33
33
  source: ApiServerOptionsSource | undefined = owned.apiServerOptions,
34
- ): ApiServerOptions & { maxRequestBodySize: number } {
34
+ ): Required<ApiServerOptions> {
35
35
  const options = typeof source === "function" ? source(env) : source ?? {};
36
36
  const maxRequestBodySize = options.maxRequestBodySize ?? env.API_MAX_REQUEST_BODY_SIZE;
37
37
  if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize < 1) {
38
38
  throw new Error("apiServerOptions.maxRequestBodySize must be a positive integer of bytes");
39
39
  }
40
- const { idleTimeout } = options;
41
- if (
42
- idleTimeout !== undefined &&
43
- (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS)
44
- ) {
40
+ const idleTimeout = options.idleTimeout ?? env.API_IDLE_TIMEOUT;
41
+ if (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS) {
45
42
  throw new Error(`apiServerOptions.idleTimeout must be an integer from 0 to ${MAX_IDLE_TIMEOUT_SECONDS} seconds`);
46
43
  }
47
- return { maxRequestBodySize, ...(idleTimeout === undefined ? {} : { idleTimeout }) };
44
+ return { maxRequestBodySize, idleTimeout };
48
45
  }
@@ -0,0 +1,6 @@
1
+ // This owned file is never overwritten by `podo update`. List the operations
2
+ // the application's own API must serve, as "METHOD /path". `bun run contract`
3
+ // fails when one is not registered or missing from the OpenAPI document, for
4
+ // example after a route was renamed or hidden by mistake. Parameters may use
5
+ // either syntax: "GET /reports/{id}" or "GET /reports/:id".
6
+ export const APP_DOCUMENTED_ROUTES: readonly string[] = [];
@@ -1,10 +1,13 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  assertApiContract,
4
+ assertApplicationRoutes,
4
5
  assertConsistentRouteParameters,
5
6
  documentedApiRoutes,
6
7
  expectedApiRoutes,
7
8
  missingApiRoutes,
9
+ normalizeApiRoute,
10
+ registeredApiRoutes,
8
11
  } from "./api-contract";
9
12
 
10
13
  describe("API contract inventory", () => {
@@ -57,4 +60,30 @@ describe("API contract inventory", () => {
57
60
  { method: "DELETE", path: "/hosts/:hostId/services/:serviceId" },
58
61
  ])).not.toThrow();
59
62
  });
63
+
64
+ it("normalizes runtime and OpenAPI parameter syntax", () => {
65
+ expect(normalizeApiRoute("get /reports/:id/rows/:rowId")).toBe("GET /reports/{id}/rows/{rowId}");
66
+ expect(normalizeApiRoute("GET /reports/{id}")).toBe("GET /reports/{id}");
67
+ expect(registeredApiRoutes([
68
+ { method: "GET", path: "/reports/:id" },
69
+ { method: "POST", path: "/reports" },
70
+ { path: "/no-method" },
71
+ ])).toEqual(new Set(["GET /reports/{id}", "POST /reports"]));
72
+ });
73
+
74
+ it("requires every application route to be registered and documented", () => {
75
+ const document = { paths: { "/reports/{id}": { get: {} }, "/hidden": {} } };
76
+ const routes = [{ method: "GET", path: "/reports/:id" }, { method: "DELETE", path: "/hidden" }];
77
+
78
+ expect(() => assertApplicationRoutes(document, routes, ["GET /reports/:id", "GET /reports/{id}"])).not.toThrow();
79
+ expect(() => assertApplicationRoutes(document, routes, [])).not.toThrow();
80
+ expect(() => assertApplicationRoutes(document, routes, ["DELETE /hidden", "POST /reports"])).toThrow(
81
+ [
82
+ "Application API contract failed:",
83
+ "DELETE /hidden is missing from the OpenAPI document",
84
+ "POST /reports is not registered",
85
+ "POST /reports is missing from the OpenAPI document",
86
+ ].join("\n"),
87
+ );
88
+ });
60
89
  });
@@ -97,6 +97,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
97
97
  };
98
98
 
99
99
  interface RuntimeRoute {
100
+ method?: string;
100
101
  path: string;
101
102
  }
102
103
 
@@ -109,11 +110,47 @@ function record(value: unknown): Record<string, unknown> {
109
110
  function runtimeRoutes(value: unknown): RuntimeRoute[] {
110
111
  if (!Array.isArray(value)) return [];
111
112
  return value.flatMap((route) => {
112
- const path = record(route).path;
113
- return typeof path === "string" ? [{ path }] : [];
113
+ const { path, method } = record(route);
114
+ if (typeof path !== "string") return [];
115
+ return [typeof method === "string" ? { method, path } : { path }];
114
116
  });
115
117
  }
116
118
 
119
+ /** "METHOD /path" with OpenAPI parameters, so ":id" and "{id}" compare equal. */
120
+ export function normalizeApiRoute(route: string): string {
121
+ const [method = "", path = ""] = route.trim().split(/\s+/, 2);
122
+ const openApiPath = path
123
+ .split("/")
124
+ .map((segment) => segment.startsWith(":") ? `{${segment.slice(1).replace(/[?*]$/, "")}}` : segment)
125
+ .join("/");
126
+ return `${method.toUpperCase()} ${openApiPath}`;
127
+ }
128
+
129
+ /** The operations an Elysia app registers, as normalized "METHOD /path" entries. */
130
+ export function registeredApiRoutes(routes: unknown): Set<string> {
131
+ const registered = new Set<string>();
132
+ for (const route of runtimeRoutes(routes)) {
133
+ if (route.method) registered.add(normalizeApiRoute(`${route.method} ${route.path}`));
134
+ }
135
+ return registered;
136
+ }
137
+
138
+ /**
139
+ * Fails unless every application route is both registered and documented.
140
+ * `expected` comes from APP_DOCUMENTED_ROUTES in api-contract.extensions.ts.
141
+ */
142
+ export function assertApplicationRoutes(document: unknown, routes: unknown, expected: readonly string[]): void {
143
+ const documented = documentedApiRoutes(document);
144
+ const registered = registeredApiRoutes(routes);
145
+ const problems = [...new Set(expected.map(normalizeApiRoute))].sort().flatMap((route) => [
146
+ ...(registered.has(route) ? [] : [`${route} is not registered`]),
147
+ ...(documented.has(route) ? [] : [`${route} is missing from the OpenAPI document`]),
148
+ ]);
149
+ if (problems.length > 0) {
150
+ throw new Error(`Application API contract failed:\n${problems.join("\n")}`);
151
+ }
152
+ }
153
+
117
154
  /**
118
155
  * Memoirist stores one parameter name at each structural radix-tree position.
119
156
  * Routes such as `/hosts/:id` and `/hosts/:hostId/files` therefore cannot share
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { validateEnv } from "../config/env.validation";
3
+ import {
4
+ CLIENT_ADDRESS_HEADER,
5
+ clientAddressOf,
6
+ resolveClientAddress,
7
+ stampClientAddress,
8
+ } from "./client-address";
9
+
10
+ const forwarded = (value: string): Headers => new Headers({ "x-forwarded-for": value });
11
+ const config = (trustedProxyHops: number) => ({ trustedProxyHops, header: "x-forwarded-for" });
12
+
13
+ describe("resolveClientAddress", () => {
14
+ it("uses the socket peer and ignores a caller's header without trusted proxies", () => {
15
+ expect(resolveClientAddress(forwarded("198.51.100.66"), "10.0.0.9", config(0))).toBe("10.0.0.9");
16
+ expect(resolveClientAddress(forwarded("198.51.100.66"), "::ffff:10.0.0.9", config(0))).toBe("10.0.0.9");
17
+ expect(resolveClientAddress(new Headers(), undefined, config(0))).toBeUndefined();
18
+ });
19
+
20
+ it("takes the entry the trusted proxies appended, never one further left", () => {
21
+ // A caller sent "198.51.100.66"; the one trusted proxy appended the real client.
22
+ const chain = forwarded("198.51.100.66, 203.0.113.7");
23
+ expect(resolveClientAddress(chain, "10.0.0.9", config(1))).toBe("203.0.113.7");
24
+ expect(resolveClientAddress(chain, "10.0.0.9", config(2))).toBe("198.51.100.66");
25
+ expect(resolveClientAddress(forwarded("203.0.113.7"), "10.0.0.9", config(1))).toBe("203.0.113.7");
26
+ expect(
27
+ resolveClientAddress(new Headers({ "x-real-ip": "203.0.113.8" }), "10.0.0.9", {
28
+ trustedProxyHops: 1,
29
+ header: "x-real-ip",
30
+ }),
31
+ ).toBe("203.0.113.8");
32
+ });
33
+
34
+ it("falls back to the peer for a short or malformed chain", () => {
35
+ expect(resolveClientAddress(new Headers(), "10.0.0.9", config(1))).toBe("10.0.0.9");
36
+ expect(resolveClientAddress(forwarded("203.0.113.7"), "10.0.0.9", config(2))).toBe("10.0.0.9");
37
+ expect(resolveClientAddress(forwarded("attacker-controlled"), "10.0.0.9", config(1))).toBe("10.0.0.9");
38
+ expect(resolveClientAddress(forwarded("203.0.113.7, not-an-ip"), "10.0.0.9", config(1))).toBe("10.0.0.9");
39
+ });
40
+
41
+ it("reads the address the API set", () => {
42
+ expect(clientAddressOf(new Headers({ [CLIENT_ADDRESS_HEADER]: "203.0.113.7" }))).toBe("203.0.113.7");
43
+ expect(clientAddressOf(new Headers())).toBeUndefined();
44
+ expect(clientAddressOf(undefined)).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe("client address settings", () => {
49
+ it("validates the trusted proxy settings and refuses the rate limiter's former names", () => {
50
+ expect(validateEnv({})).toMatchObject({ TRUSTED_PROXY_HOPS: 0, TRUSTED_PROXY_HEADER: "x-forwarded-for" });
51
+ expect(validateEnv({ TRUSTED_PROXY_HOPS: "1", TRUSTED_PROXY_HEADER: "X-Real-IP" }))
52
+ .toMatchObject({ TRUSTED_PROXY_HOPS: 1, TRUSTED_PROXY_HEADER: "x-real-ip" });
53
+ expect(() => validateEnv({ TRUSTED_PROXY_HOPS: "-1" })).toThrow("TRUSTED_PROXY_HOPS");
54
+ expect(() => validateEnv({ TRUSTED_PROXY_HEADER: "bad header" })).toThrow("TRUSTED_PROXY_HEADER");
55
+ expect(() => validateEnv({ RATE_LIMIT_TRUSTED_PROXY_HOPS: "1" }))
56
+ .toThrow("RATE_LIMIT_TRUSTED_PROXY_HOPS was renamed to TRUSTED_PROXY_HOPS");
57
+ expect(() => validateEnv({ RATE_LIMIT_PROXY_HEADER: "x-forwarded-for" }))
58
+ .toThrow("RATE_LIMIT_PROXY_HEADER was renamed to TRUSTED_PROXY_HEADER");
59
+ });
60
+ });
61
+
62
+ describe("stampClientAddress", () => {
63
+ const request = (headers: Record<string, string>): Request => new Request("http://localhost/x", { headers });
64
+
65
+ it("replaces a caller's own address header and ignores its forwarded header without trusted proxies", () => {
66
+ const spoofed = request({ "x-forwarded-for": "198.51.100.66", [CLIENT_ADDRESS_HEADER]: "198.51.100.67" });
67
+ expect(stampClientAddress(spoofed, "::ffff:127.0.0.1", config(0))).toBe("127.0.0.1");
68
+ expect(clientAddressOf(spoofed.headers)).toBe("127.0.0.1");
69
+ });
70
+
71
+ it("records the rightmost forwarded entry behind one trusted proxy", () => {
72
+ const proxied = request({ "x-forwarded-for": "198.51.100.66, 203.0.113.7" });
73
+ expect(stampClientAddress(proxied, "10.0.0.9", config(1))).toBe("203.0.113.7");
74
+ expect(clientAddressOf(proxied.headers)).toBe("203.0.113.7");
75
+ });
76
+
77
+ it("removes a caller's value when no address can be resolved", () => {
78
+ const unknown = request({ [CLIENT_ADDRESS_HEADER]: "198.51.100.67" });
79
+ expect(stampClientAddress(unknown, undefined, config(0))).toBeUndefined();
80
+ expect(clientAddressOf(unknown.headers)).toBeUndefined();
81
+ });
82
+ });
@@ -0,0 +1,68 @@
1
+ import { isIP } from "node:net";
2
+
3
+ /**
4
+ * The request header that carries the client address the API resolved. The API
5
+ * sets it on every request before any handler runs, replacing whatever a caller
6
+ * sent, so Better Auth, audit entries, and application code read one address
7
+ * that a caller cannot choose.
8
+ */
9
+ export const CLIENT_ADDRESS_HEADER = "x-podokit-client-address";
10
+
11
+ export interface ClientAddressConfig {
12
+ /** Reverse proxies in front of the API whose forwarded header is trusted; 0 trusts none. */
13
+ trustedProxyHops: number;
14
+ /** The lowercase header those proxies append the client address to. */
15
+ header: string;
16
+ }
17
+
18
+ /** An IP address without its IPv4-mapped IPv6 prefix, or undefined when the value is not one. */
19
+ export function normalizeIp(value: string | undefined): string | undefined {
20
+ if (!value) return undefined;
21
+ const trimmed = value.trim();
22
+ const normalized = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed;
23
+ return isIP(normalized) === 0 ? undefined : normalized;
24
+ }
25
+
26
+ /**
27
+ * The caller's address. With no trusted proxy it is the socket peer. Otherwise
28
+ * each trusted proxy appended one entry to the header, so the client is the
29
+ * entry that many places left of the peer; entries further left were written
30
+ * by the caller and are ignored. A chain shorter than the trusted hops, or a
31
+ * malformed entry, falls back to the peer instead of trusting caller input.
32
+ */
33
+ export function resolveClientAddress(
34
+ headers: Headers,
35
+ remoteAddress: string | undefined,
36
+ config: ClientAddressConfig,
37
+ ): string | undefined {
38
+ const direct = normalizeIp(remoteAddress);
39
+ if (config.trustedProxyHops === 0) return direct;
40
+ const forwarded = headers.get(config.header)
41
+ ?.split(",")
42
+ .map((value) => value.trim())
43
+ .filter(Boolean) ?? [];
44
+ const chain = [...forwarded, direct ?? ""];
45
+ if (chain.length <= config.trustedProxyHops) return direct;
46
+ return normalizeIp(chain[chain.length - config.trustedProxyHops - 1]) ?? direct;
47
+ }
48
+
49
+ /**
50
+ * Resolves a request's client address and records it in CLIENT_ADDRESS_HEADER,
51
+ * replacing any value the caller sent. The API does this for every request
52
+ * before a guard, Better Auth, or a handler reads the request.
53
+ */
54
+ export function stampClientAddress(
55
+ request: Request,
56
+ remoteAddress: string | undefined,
57
+ config: ClientAddressConfig,
58
+ ): string | undefined {
59
+ const address = resolveClientAddress(request.headers, remoteAddress, config);
60
+ if (address) request.headers.set(CLIENT_ADDRESS_HEADER, address);
61
+ else request.headers.delete(CLIENT_ADDRESS_HEADER);
62
+ return address;
63
+ }
64
+
65
+ /** The address the API resolved for a request, as set in CLIENT_ADDRESS_HEADER. */
66
+ export function clientAddressOf(headers: Headers | undefined): string | undefined {
67
+ return headers?.get(CLIENT_ADDRESS_HEADER) ?? undefined;
68
+ }
@@ -6,6 +6,8 @@ import {
6
6
  } from "@podosoft/podokit-runtime";
7
7
  import type { AppEnv } from "../config/env.validation";
8
8
  import { Database } from "../database/database";
9
+ import { migrationSet } from "../database/migration-files";
10
+ import { MIGRATIONS_READINESS_CHECK, pendingMigrationsCheck } from "../database/migration-readiness";
9
11
  import { DATABASE_READINESS_CHECK, ReadinessService } from "../health/readiness.service";
10
12
 
11
13
  export type { ServiceKey } from "@podosoft/podokit-runtime";
@@ -280,7 +282,10 @@ export class AccessPolicy {
280
282
 
281
283
  export interface RequestGuardContext {
282
284
  request: Request;
285
+ /** The socket peer. */
283
286
  remoteAddress?: string;
287
+ /** The caller's address behind the trusted proxies; see core/client-address.ts. */
288
+ clientAddress?: string;
284
289
  setHeader: (name: string, value: string) => void;
285
290
  }
286
291
 
@@ -305,6 +310,31 @@ export interface RequestIdentity {
305
310
  activeOrganizationId(request: Request): Promise<string | undefined>;
306
311
  }
307
312
 
313
+ /** A session the API resolved while handling a request. */
314
+ export interface ResolvedSession {
315
+ /** The session token without its cookie signature. */
316
+ token: string;
317
+ userId: string;
318
+ expiresAt: Date;
319
+ }
320
+
321
+ /**
322
+ * Passes each session the API resolves for a request to the modules that listen,
323
+ * so they can use it without a session lookup of their own. The auth module
324
+ * reports; the rate limiter listens to count a signed-in caller per user.
325
+ */
326
+ export class ResolvedSessions {
327
+ private readonly listeners: Array<(session: ResolvedSession) => void> = [];
328
+
329
+ listen(listener: (session: ResolvedSession) => void): void {
330
+ this.listeners.push(listener);
331
+ }
332
+
333
+ report(session: ResolvedSession): void {
334
+ for (const listener of this.listeners) listener(session);
335
+ }
336
+ }
337
+
308
338
  export interface OpenApiContribution {
309
339
  document: Record<string, unknown>;
310
340
  pathPrefix?: string;
@@ -331,6 +361,7 @@ export const LOGGER = Symbol("logger") as ServiceKey<Logger>;
331
361
  export const ACCESS_POLICY = Symbol("access-policy") as ServiceKey<AccessPolicy>;
332
362
  export const REQUEST_GUARDS = Symbol("request-guards") as ServiceKey<RequestGuardRegistry>;
333
363
  export const REQUEST_IDENTITY = Symbol("request-identity") as ServiceKey<RequestIdentity>;
364
+ export const RESOLVED_SESSIONS = Symbol("resolved-sessions") as ServiceKey<ResolvedSessions>;
334
365
  export const OPENAPI = Symbol("openapi") as ServiceKey<OpenApiRegistry>;
335
366
 
336
367
  export interface AppContext {
@@ -381,6 +412,17 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
381
412
  });
382
413
  services.register(READINESS, readiness);
383
414
  readiness.register(DATABASE_READINESS_CHECK, () => services.resolve(DATABASE).ping());
415
+ if (env.NODE_ENV === "production") {
416
+ // Throws when the build output lacks its migration list, so the API does not
417
+ // start without knowing which migrations its schema needs.
418
+ const migrations = migrationSet({ production: true });
419
+ if (migrations.compiled) {
420
+ readiness.register(
421
+ MIGRATIONS_READINESS_CHECK,
422
+ pendingMigrationsCheck(() => services.resolve(DATABASE).sql, migrations),
423
+ );
424
+ }
425
+ }
384
426
  services.register(LOGGER, logger);
385
427
  services.register(ACCESS_POLICY, accessPolicy);
386
428
  services.register(REQUEST_GUARDS, requestGuards);
@@ -390,6 +432,7 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
390
432
  roles: async () => [],
391
433
  activeOrganizationId: async () => undefined,
392
434
  });
435
+ services.register(RESOLVED_SESSIONS, new ResolvedSessions());
393
436
  services.register(OPENAPI, openapiRegistry);
394
437
  return services;
395
438
  }
@@ -1,6 +1,17 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { Column, Entity, PrimaryColumn } from "typeorm";
3
4
  import { PROVIDERS } from "../config/providers";
5
+ import { APP_ENTITIES } from "./entities";
6
+
7
+ @Entity("probe_item")
8
+ class ProbeItem {
9
+ @PrimaryColumn()
10
+ id!: string;
11
+
12
+ @Column()
13
+ count!: number;
14
+ }
4
15
 
5
16
  function databaseProvider(): DatabaseProviderName {
6
17
  return PROVIDERS.database;
@@ -13,3 +24,19 @@ describe.skipIf(databaseProvider() !== "postgres")("migration data source", () =
13
24
  expect(dataSourceOptions.migrationsTransactionMode).toBe("each");
14
25
  });
15
26
  });
27
+
28
+ describe("application entities", () => {
29
+ // The TypeORM data source serves PostgreSQL; SQLite migrates without it.
30
+ it.skipIf(databaseProvider() !== "postgres")("loads the entities of the owned entities.ts", async () => {
31
+ const { dataSourceOptions } = await import("./data-source.js");
32
+ expect(dataSourceOptions.entities).toBe(APP_ENTITIES);
33
+ });
34
+
35
+ it("compiles TypeORM entity decorators with their design types", () => {
36
+ // TypeORM infers a column's type from this metadata.
37
+ const designType = (Reflect as unknown as {
38
+ getMetadata(key: string, target: object, property: string): unknown;
39
+ }).getMetadata("design:type", ProbeItem.prototype, "count");
40
+ expect(designType).toBe(Number);
41
+ });
42
+ });
@@ -1,20 +1,18 @@
1
1
  import "dotenv/config";
2
- import { existsSync } from "node:fs";
3
- import { join } from "node:path";
4
2
  import { DataSource, type DataSourceOptions } from "typeorm";
5
3
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
6
4
  import { PROVIDERS } from "../config/providers";
7
5
  import { validateEnv } from "../config/env.validation";
8
6
  import { databaseUrl, sqliteDatabasePath } from "./database";
7
+ import { APP_ENTITIES } from "./entities";
8
+ import { migrationPaths, migrationSet } from "./migration-files";
9
9
 
10
- const compiledMigrations = join(process.cwd(), "dist", "migrations");
11
- const migrations = existsSync(compiledMigrations)
12
- ? [join(compiledMigrations, "[0-9]*.js")]
13
- : [join(process.cwd(), "src", "migrations", "[0-9]*.ts")];
10
+ /** The migrations the data source loads; compare them with `assertMigrationsLoaded`. */
11
+ export const appMigrations = migrationSet();
14
12
 
15
13
  const commonOptions = {
16
- entities: [],
17
- migrations,
14
+ entities: APP_ENTITIES,
15
+ migrations: migrationPaths(appMigrations),
18
16
  // One transaction per migration, so a migration can set `transaction = false`
19
17
  // for statements such as CREATE INDEX CONCURRENTLY.
20
18
  migrationsTransactionMode: "each" as const,
@@ -44,6 +42,5 @@ export const dataSourceOptions: DataSourceOptions = databaseProvider() === "sqli
44
42
  ...commonOptions,
45
43
  };
46
44
 
47
- // Used by the TypeORM CLI for migrations (see package.json scripts).
45
+ // The TypeORM CLI (see package.json scripts) takes the file's only DataSource export.
48
46
  export const appDataSource = new DataSource(dataSourceOptions);
49
- export default appDataSource;
@@ -86,6 +86,18 @@ describe("database provider", () => {
86
86
  expect(() => databaseUrl({ ...env, DATABASE_URL })).toThrow("DATABASE_URL must use");
87
87
  });
88
88
 
89
+ it("puts the default SQLite database at SQLITE_PATH", () => {
90
+ const root = mkdtempSync(join(tmpdir(), "podokit-sqlite-"));
91
+ try {
92
+ const path = join(root, "state", "app.sqlite");
93
+ expect(databaseUrl({ ...env, SQLITE_PATH: path }, "sqlite")).toBe(`sqlite://${path}`);
94
+ expect(existsSync(dirname(path))).toBe(true);
95
+ expect(databaseUrl(env, "sqlite")).toBe(`sqlite://${join(process.cwd(), "data/podokit.sqlite")}`);
96
+ } finally {
97
+ rmSync(root, { recursive: true, force: true });
98
+ }
99
+ });
100
+
89
101
  it("creates the parent directory for an explicit SQLite database", () => {
90
102
  const root = mkdtempSync(join(tmpdir(), "podokit-sqlite-"));
91
103
  try {
@@ -14,8 +14,7 @@ function selectedDatabaseProvider(): DatabaseProviderName {
14
14
  return PROVIDERS.database;
15
15
  }
16
16
 
17
- export function databaseUrl(env: AppEnv): string {
18
- const provider = selectedDatabaseProvider();
17
+ export function databaseUrl(env: AppEnv, provider: DatabaseProviderName = selectedDatabaseProvider()): string {
19
18
  if (env.DATABASE_URL) {
20
19
  if (provider === "sqlite" && !isSqliteUrl(env.DATABASE_URL)) {
21
20
  throw new Error("DATABASE_URL must use sqlite:, file:, or :memory: for the SQLite provider");
@@ -26,7 +25,7 @@ export function databaseUrl(env: AppEnv): string {
26
25
  return env.DATABASE_URL;
27
26
  }
28
27
  if (provider === "sqlite") {
29
- const path = resolve("./data/podokit.sqlite");
28
+ const path = resolve(env.SQLITE_PATH);
30
29
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
31
30
  return `sqlite://${path}`;
32
31
  }
@@ -0,0 +1,12 @@
1
+ import type { DataSourceOptions } from "typeorm";
2
+
3
+ // This owned file is never overwritten by `podo update`. List the
4
+ // application's TypeORM entities here: the migration data source loads them,
5
+ // so `migration:generate` compares them with the database and code that uses
6
+ // `appDataSource` gets their repositories. For example:
7
+ //
8
+ // import { Invoice } from "../invoices/invoice.entity";
9
+ // export const APP_ENTITIES: AppEntities = [Invoice];
10
+ export type AppEntities = NonNullable<DataSourceOptions["entities"]>;
11
+
12
+ export const APP_ENTITIES: AppEntities = [];