@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
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from "../helpers/disposable-users";
2
+ import { ADMIN, USER } from "../helpers/accounts";
2
3
  import { ready } from "../helpers/hydration";
3
4
 
4
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
@@ -34,13 +35,13 @@ test("admin sees the user list and can search", async ({ page }) => {
34
35
  await ready(page, "/admin/users");
35
36
  await expect(page.getByRole("heading", { name: "Users" })).toBeVisible();
36
37
  // Search by exact address — robust to pagination as the user count grows.
37
- await page.locator("#toolbar-search").fill("admin@example.com");
38
+ await page.locator("#toolbar-search").fill(ADMIN.email);
38
39
  await page.getByRole("button", { name: "Search", exact: true }).click();
39
- await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
40
- await page.locator("#toolbar-search").fill("user@example.com");
40
+ await expect(page.getByRole("cell", { name: ADMIN.email })).toBeVisible();
41
+ await page.locator("#toolbar-search").fill(USER.email);
41
42
  await page.getByRole("button", { name: "Search", exact: true }).click();
42
- await expect(page.getByRole("cell", { name: "user@example.com" })).toBeVisible();
43
- await expect(page.getByRole("cell", { name: "admin@example.com" })).toHaveCount(0);
43
+ await expect(page.getByRole("cell", { name: USER.email })).toBeVisible();
44
+ await expect(page.getByRole("cell", { name: ADMIN.email })).toHaveCount(0);
44
45
  });
45
46
 
46
47
  test("the user list asks the server for one page at a time", async ({ page }) => {
@@ -63,7 +64,7 @@ test("the user list asks the server for one page at a time", async ({ page }) =>
63
64
  expect(query.get("search")).toBe("Admin@Example.com");
64
65
  expect(query.get("searchField")).toBe("email");
65
66
  expect(query.get("offset")).toBe("0");
66
- await expect(page.getByRole("cell", { name: "admin@example.com" })).toBeVisible();
67
+ await expect(page.getByRole("cell", { name: ADMIN.email })).toBeVisible();
67
68
  expect(requested.filter((url) => url.includes("/api/auth/admin/list-users"))).toEqual([]);
68
69
  });
69
70
 
@@ -78,12 +79,12 @@ test("row menu exposes admin actions", async ({ page }) => {
78
79
  const searched = page.waitForResponse((response) => {
79
80
  const url = new URL(response.url());
80
81
  return url.pathname === "/api/admin/users" &&
81
- url.searchParams.get("search") === "user@example.com";
82
+ url.searchParams.get("search") === USER.email;
82
83
  });
83
- await page.locator("#toolbar-search").fill("user@example.com");
84
+ await page.locator("#toolbar-search").fill(USER.email);
84
85
  await page.getByRole("button", { name: "Search", exact: true }).click();
85
86
  expect((await searched).ok()).toBeTruthy();
86
- const row = page.getByRole("row", { name: /user@example.com/ });
87
+ const row = page.getByRole("row", { name: USER.email });
87
88
  await row.getByRole("button").click();
88
89
  await expect(page.getByRole("menuitem", { name: "Manage" })).toBeVisible();
89
90
  await expect(page.getByRole("menuitem", { name: "Impersonate" })).toBeVisible();
@@ -15,7 +15,12 @@
15
15
  "apps/web/src/lib/i18n/locales/**",
16
16
  "apps/web/src/lib/i18n/catalogs/app/**",
17
17
  "apps/web/src/lib/server/public-paths.ts",
18
- "apps/web/src/lib/server/asset-paths.ts"
18
+ "apps/web/src/lib/server/asset-paths.ts",
19
+ "tests/helpers/app-accounts.ts",
20
+ "apps/web/src/lib/server/app-hooks.ts",
21
+ "apps/web/src/lib/server/non-indexable-paths.ts",
22
+ "apps/web/src/lib/admin/app-nav.ts",
23
+ "apps/web/src/lib/admin/app-flags.ts"
19
24
  ],
20
25
  "managedOverrides": [
21
26
  "apps/web/src/lib/components/ui/input/input.svelte",
@@ -119,7 +124,7 @@
119
124
  "file": "AGENTS.md",
120
125
  "marker": "<!-- podokit:end:agents-modules -->",
121
126
  "optional": true,
122
- "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
127
+ "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- For the signed-in user block in a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />`; `sidebar-user-menu.svelte` renders the same menu there and the sidebar menu item inside a `Sidebar.Provider`.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` is managed: put request handling of your own (adjusting `event.locals`, extra headers) in `appHandle` of the app-owned `apps/web/src/lib/server/app-hooks.ts`, which runs after the runtime context is loaded. Add admin sidebar entries to `APP_ADMIN_NAV` in the app-owned `apps/web/src/lib/admin/app-nav.ts`, Settings switches for the API's `APP_FLAG_DEFAULTS` to `APP_FLAG_SETTINGS` in the app-owned `apps/web/src/lib/admin/app-flags.ts`, and private page prefixes that must send `X-Robots-Tag: noindex` to `APP_NON_INDEXABLE_PATHS` in the app-owned `apps/web/src/lib/server/non-indexable-paths.ts`; never edit `admin-sidebar.svelte` or `search-indexing.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\n- `routes/account`, `routes/setup-2fa`, `routes/(auth)/login`, `routes/(auth)/signup`, and the `routes/(admin)` pages are owned, but their `+page.server.ts` (and the `(admin)` layout load) stay managed. Keep the owned page passing `data` to the managed component (for example `<AccountPage {data} />`) and keep its own chrome; never move a managed load's fields into the owned page or edit the managed load.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
123
128
  },
124
129
  {
125
130
  "file": "apps/api/src/app.ts",
@@ -1,4 +1,5 @@
1
1
  import { createAuthMiddleware } from "better-auth/api";
2
+ import { clientAddressOf } from "../core/client-address";
2
3
  import { recordAudit } from "./audit-events";
3
4
  import { auditEnabled } from "./audit-enabled";
4
5
 
@@ -71,6 +72,6 @@ export const auditAfterHook = createAuthMiddleware(async (ctx) => {
71
72
  actorEmail: actor?.email ?? null,
72
73
  targetId,
73
74
  targetLabel,
74
- ip: ctx.headers?.get("x-forwarded-for") ?? null,
75
+ ip: clientAddressOf(ctx.headers) ?? null,
75
76
  });
76
77
  });
@@ -29,7 +29,11 @@ export const auditModule: PodokitModule = {
29
29
  retention: { days: parseRetentionDays(process.env.AUDIT_LOG_RETENTION_DAYS) },
30
30
  });
31
31
  services.register(AUDIT, audit, () => audit.close());
32
- services.onStart(() => audit.connect());
32
+ services.onStart(() => {
33
+ audit.connect();
34
+ // Not awaited: the toggle read may wait for the database, which startup does not.
35
+ void audit.warnIfDisabled();
36
+ });
33
37
  },
34
38
  plugin: auditPlugin,
35
39
  };
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
2
2
  import { SQL } from "bun";
3
3
  import type { QueryRunner } from "typeorm";
4
4
  import type { AuthSession } from "../auth/auth.service";
5
+ import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
5
6
  import { InitAuditLogs1720300000000 } from "../migrations/1720300000000-InitAuditLogs";
6
7
  import { recordAudit, recordAuditLater } from "./audit-events";
7
8
  import { AppException } from "@podosoft/podokit-contracts";
@@ -176,6 +177,38 @@ describe("AuditService", () => {
176
177
  }
177
178
  });
178
179
 
180
+ test("records the address the API resolved, not the caller's forwarded header", async () => {
181
+ const capture = sqlCapture();
182
+ const service = new AuditService(capture.sql, { dialect: "postgres", enabled: on });
183
+ const session: AuthSession = { user: { id: "user-1" }, session: {} };
184
+ const headers = {
185
+ "x-forwarded-for": "198.51.100.66, 198.51.100.67",
186
+ [CLIENT_ADDRESS_HEADER]: "203.0.113.7",
187
+ };
188
+ await service.recordRequest("todo.create", new Request("http://localhost/todos", { headers }), session);
189
+ await service.recordRequest("todo.delete", new Request("http://localhost/todos", {
190
+ headers: { "x-forwarded-for": "198.51.100.66" },
191
+ }), session);
192
+ expect(capture.batches.map((batch) => batch[0]?.ip)).toEqual(["203.0.113.7", null]);
193
+ });
194
+
195
+ test("warns once at startup when the toggle is off", async () => {
196
+ const warnings: string[] = [];
197
+ const logger: AuditLogger = { warn: (_details, message) => warnings.push(message) };
198
+ const sql = await sqliteDatabase();
199
+ await new AuditService(sql, { dialect: "sqlite", enabled: on, logger }).warnIfDisabled();
200
+ await new AuditService(sql, {
201
+ dialect: "sqlite",
202
+ enabled: () => Promise.reject(new Error("store down")),
203
+ logger,
204
+ }).warnIfDisabled();
205
+ expect(warnings).toEqual([]);
206
+ await new AuditService(sql, { dialect: "sqlite", enabled: off, logger }).warnIfDisabled();
207
+ expect(warnings).toEqual([
208
+ "Audit log is off; no audit entries are recorded until it is turned on in the admin Settings",
209
+ ]);
210
+ });
211
+
179
212
  test("a failing toggle read never breaks the caller", async () => {
180
213
  const sql = await sqliteDatabase();
181
214
  const service = new AuditService(sql, {
@@ -2,6 +2,7 @@ import type { SQL } from "bun";
2
2
  import { AppException } from "@podosoft/podokit-contracts";
3
3
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
4
4
  import type { AuthSession } from "../auth/auth.service";
5
+ import { clientAddressOf } from "../core/client-address";
5
6
  import { setAuditRecorder, type AuditEntry } from "./audit-events";
6
7
 
7
8
  export interface AuditLogView extends AuditEntry {
@@ -165,6 +166,24 @@ export class AuditService {
165
166
  if (this.retention.days > 0 && !this.pruneTimer && !this.pruning) this.schedulePrune(0);
166
167
  }
167
168
 
169
+ /**
170
+ * Logs one warning when the toggle is off, because every write is then skipped
171
+ * without a trace. A failing toggle read counts as on, as it does for writes.
172
+ */
173
+ async warnIfDisabled(): Promise<void> {
174
+ let enabled: boolean;
175
+ try {
176
+ enabled = await this.enabled();
177
+ } catch {
178
+ return;
179
+ }
180
+ if (enabled) return;
181
+ this.logger?.warn(
182
+ { AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED ?? null },
183
+ "Audit log is off; no audit entries are recorded until it is turned on in the admin Settings",
184
+ );
185
+ }
186
+
168
187
  /** Stops pruning and the registry, then writes the queue. */
169
188
  async close(): Promise<void> {
170
189
  this.closed = true;
@@ -412,7 +431,7 @@ export class AuditService {
412
431
  targetId: target.id ?? null,
413
432
  targetLabel: target.label ?? null,
414
433
  metadata: target.metadata ?? null,
415
- ip: request.headers.get("x-forwarded-for") ?? null,
434
+ ip: clientAddressOf(request.headers) ?? null,
416
435
  };
417
436
  if (options.defer) this.recordLater(entry);
418
437
  else await this.record(entry);
@@ -4,8 +4,9 @@
4
4
  "requires": ["auth"],
5
5
  "targetApp": "api",
6
6
  "env": [
7
- "# audit-log: managed on the admin Settings page, stored in the DB,",
8
- "# and applied without restart. This environment value is the fallback.",
7
+ "# audit-log: on unless switched off. Managed on the admin Settings page,",
8
+ "# stored in the DB, and applied without restart; this value applies until an",
9
+ "# administrator saves the setting. Set it to false to start with the log off.",
9
10
  "AUDIT_LOG_ENABLED=true",
10
11
  "# audit-log: delete entries older than this many days. Unset or 0 keeps them",
11
12
  "# forever; set it only when your retention policy requires deleting them.",
@@ -31,6 +32,17 @@
31
32
  "file": "apps/api/src/auth/auth.ts",
32
33
  "marker": "// podokit:end:auth-hooks",
33
34
  "text": "hooks.after = auditAfterHook;"
35
+ },
36
+ {
37
+ "file": "infra/k3s/configmap.yaml",
38
+ "marker": "# podokit:end:runtime-config",
39
+ "text": "AUDIT_LOG_ENABLED: \"true\"",
40
+ "optional": true
41
+ },
42
+ {
43
+ "file": "apps/api/src/auth/auth-config-store.ts",
44
+ "marker": "// podokit:end:auth-config-defaults",
45
+ "text": "auditLog: true,"
34
46
  }
35
47
  ],
36
48
  "instructions": [
@@ -39,6 +51,6 @@
39
51
  "List entries newest first (admin session required): curl -i -b cookies.txt 'localhost:5002/audit-logs?limit=50' and pass the X-Next-Cursor header back as ?cursor= for the next page"
40
52
  ],
41
53
  "dependencies": {
42
- "@podosoft/podokit-auth": "^0.5.0"
54
+ "@podosoft/podokit-auth": "^0.6.0"
43
55
  }
44
56
  }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { envAuthConfig } from "@podosoft/podokit-auth";
3
+ import { buildAuth } from "./auth";
4
+
5
+ interface PluginWithOptions {
6
+ id: string;
7
+ options?: { rpName?: string; issuer?: string };
8
+ }
9
+
10
+ function plugin(instance: ReturnType<typeof buildAuth>, id: string): PluginWithOptions | undefined {
11
+ return (instance.options.plugins as PluginWithOptions[]).find((candidate) => candidate.id === id);
12
+ }
13
+
14
+ describe("auth app name", () => {
15
+ test("names the app in Better Auth, the TOTP issuer, and the passkey prompt", () => {
16
+ const instance = buildAuth({ ...envAuthConfig(), appName: "Acme Cloud" });
17
+ expect(instance.options.appName).toBe("Acme Cloud");
18
+ expect(plugin(instance, "two-factor")?.options?.issuer).toBe("Acme Cloud");
19
+ expect(plugin(instance, "passkey")?.options?.rpName).toBe("Acme Cloud");
20
+ });
21
+ });
@@ -1,14 +1,26 @@
1
- import { createConfigStore, type ConfigStore } from "@podosoft/podokit-auth";
1
+ import { createConfigStore, type AuthConfigDefaults, type ConfigStore } from "@podosoft/podokit-auth";
2
2
  import { flagsFrom } from "../settings/flag-defaults";
3
3
  import { readAppSettings, readAuthConfig, readConfigVersions } from "./db";
4
4
  import { signupOpenFrom } from "./feature-gate";
5
5
 
6
+ /** Values used when neither the auth_config table nor the environment sets one.
7
+ * Other PodoKit modules add theirs below the marker. */
8
+ export const AUTH_CONFIG_DEFAULTS: AuthConfigDefaults = {
9
+ appName: "{{projectName}}",
10
+ // podokit:begin:auth-config-defaults
11
+ // podokit:end:auth-config-defaults
12
+ };
13
+
6
14
  /** A store over this app's auth_config and app_setting tables. */
7
15
  export function createAuthConfigStore(): ConfigStore {
8
16
  return createConfigStore({
9
17
  list: readAuthConfig,
10
18
  listSettings: readAppSettings,
11
19
  versions: readConfigVersions,
20
+ }, {
21
+ defaults: AUTH_CONFIG_DEFAULTS,
22
+ // The site name from the admin Settings, when the admin dashboard stores one.
23
+ appNameSetting: "site.name",
12
24
  });
13
25
  }
14
26
 
@@ -13,6 +13,11 @@ export function authSecret(): string {
13
13
  return resolved;
14
14
  }
15
15
 
16
+ /** A host that only this machine reaches, where plain HTTP exposes nothing. */
17
+ function isLoopbackHost(hostname: string): boolean {
18
+ return hostname === "localhost" || hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
19
+ }
20
+
16
21
  export function authBaseUrl(): string {
17
22
  const value = process.env.BETTER_AUTH_URL?.trim();
18
23
  if (process.env.NODE_ENV === "production" && !value) {
@@ -25,8 +30,10 @@ export function authBaseUrl(): string {
25
30
  } catch {
26
31
  throw new Error("BETTER_AUTH_URL must be a valid URL");
27
32
  }
28
- if (process.env.NODE_ENV === "production" && url.protocol !== "https:") {
29
- throw new Error("BETTER_AUTH_URL must use HTTPS in production");
33
+ // A loopback URL may stay HTTP, for example an API that a desktop shell runs
34
+ // on 127.0.0.1 in production mode.
35
+ if (process.env.NODE_ENV === "production" && url.protocol !== "https:" && !isLoopbackHost(url.hostname)) {
36
+ throw new Error("BETTER_AUTH_URL must use HTTPS in production unless it is a loopback address");
30
37
  }
31
38
  return resolved;
32
39
  }
@@ -1,10 +1,11 @@
1
- import type { AuthConfig } from "@podosoft/podokit-auth";
1
+ import { envAuthConfig, type AuthConfig } from "@podosoft/podokit-auth";
2
2
  import { validateEnv } from "../config/env.validation";
3
3
  import { auth as bootstrapAuth, buildAuth } from "./auth";
4
- import { authConfigStore } from "./auth-config-store";
4
+ import { AUTH_CONFIG_DEFAULTS, authConfigStore } from "./auth-config-store";
5
5
  import { publishConfigChanged } from "./config-events";
6
6
  import { answersWithin, checkAuthResponse } from "./database-outage";
7
7
  import { authDatabaseReachable } from "./db";
8
+ import { createSchemaRecheck } from "./schema-recheck";
8
9
 
9
10
  const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
10
11
 
@@ -16,8 +17,10 @@ const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
16
17
  // Auth instance. Session tokens remain valid because BETTER_AUTH_SECRET is stable.
17
18
 
18
19
  let current = bootstrapAuth;
20
+ let currentConfig: AuthConfig = envAuthConfig(AUTH_CONFIG_DEFAULTS); // the bootstrap instance's
19
21
  let currentVersion = "env"; // matches envAuthConfig() bootstrap
20
22
  let failedVersion: string | null = null;
23
+ const recheckSchema = createSchemaRecheck();
21
24
 
22
25
  export function getAuth(): typeof bootstrapAuth {
23
26
  return current;
@@ -38,6 +41,7 @@ export async function refreshIfStale(): Promise<void> {
38
41
  if (config.version === currentVersion || config.version === failedVersion) return;
39
42
  try {
40
43
  current = buildAuth(config);
44
+ currentConfig = config;
41
45
  currentVersion = config.version;
42
46
  failedVersion = null;
43
47
  } catch (err) {
@@ -64,7 +68,19 @@ export async function refreshAuthNow(): Promise<void> {
64
68
  * current instance serves the request, and the refresh finishes in the background.
65
69
  */
66
70
  export async function refreshForRequest(): Promise<void> {
67
- await answersWithin(refreshIfStale, REFRESH_WAIT_MS);
71
+ await answersWithin(async () => {
72
+ await refreshIfStale();
73
+ await recheckSchema(current, rebuildCurrent);
74
+ }, REFRESH_WAIT_MS);
75
+ }
76
+
77
+ /** A new instance of the current configuration, which checks the schema again. */
78
+ function rebuildCurrent(): void {
79
+ try {
80
+ current = buildAuth(currentConfig);
81
+ } catch (err) {
82
+ console.error("[auth-config] rebuild failed; keeping last-good auth instance:", err instanceof Error ? err.message : err);
83
+ }
68
84
  }
69
85
 
70
86
  // Stable handler reference the adapter captures once; redispatches per request.
@@ -8,6 +8,7 @@ import {
8
8
  OPENAPI,
9
9
  REQUEST_GUARDS,
10
10
  REQUEST_IDENTITY,
11
+ RESOLVED_SESSIONS,
11
12
  type AppPlugin,
12
13
  type PodokitModule,
13
14
  type ServiceKey,
@@ -16,6 +17,7 @@ import {
16
17
  AuthConfigService,
17
18
  type AuthConfigUpdate,
18
19
  } from "../auth-config/auth-config.service";
20
+ import { APP_FLAGS } from "../settings/flag-defaults";
19
21
  import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
20
22
  import { authConfigStore } from "./auth-config-store";
21
23
  import { getAuth, authRuntime, primeAuth } from "./auth-provider";
@@ -100,6 +102,7 @@ const accountPlugin: AppPlugin = ({ services }) => {
100
102
  auditLog: snapshot.auditLog,
101
103
  sessionIdleTimeoutMinutes: snapshot.sessionIdleTimeoutMinutes,
102
104
  roles: ROLE_NAMES,
105
+ appFlags: Object.fromEntries(APP_FLAGS.map((flag) => [flag, flags[flag]])),
103
106
  };
104
107
  }, {
105
108
  detail: { tags: ["account"], summary: "Get authentication capabilities" },
@@ -158,7 +161,9 @@ export const authModule: PodokitModule = {
158
161
  setMailConfigStore(authConfigStore);
159
162
  const accessPolicy = services.resolve(ACCESS_POLICY);
160
163
  const settings = new SettingsService(database.sql, authConfigStore, publishConfigChanged);
161
- const authService = new AuthService(settings, accessPolicy);
164
+ const authService = new AuthService(settings, accessPolicy, {
165
+ resolvedSessions: services.resolve(RESOLVED_SESSIONS),
166
+ });
162
167
  services.register(SETTINGS, settings);
163
168
  services.register(AUTH_CONFIG, new AuthConfigService(database.sql, database.provider));
164
169
  services.register(AUTH, authService, () => authService.close());
@@ -29,7 +29,17 @@ describe("authentication production environment", () => {
29
29
  expect(() => authBaseUrl()).toThrow("must be set");
30
30
  process.env.BETTER_AUTH_URL = "http://app.example.com";
31
31
  expect(() => authBaseUrl()).toThrow("must use HTTPS");
32
+ process.env.BETTER_AUTH_URL = "http://127.0.0.1.example.com";
33
+ expect(() => authBaseUrl()).toThrow("must use HTTPS");
32
34
  process.env.BETTER_AUTH_URL = "https://app.example.com";
33
35
  expect(authBaseUrl()).toBe("https://app.example.com");
34
36
  });
37
+
38
+ test("lets a loopback production origin stay HTTP", () => {
39
+ process.env.NODE_ENV = "production";
40
+ for (const url of ["http://127.0.0.1:5002", "http://localhost:5002", "http://[::1]:5002"]) {
41
+ process.env.BETTER_AUTH_URL = url;
42
+ expect(authBaseUrl()).toBe(url);
43
+ }
44
+ });
35
45
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { AccessPolicy, type RequestIdentity } from "../core/services";
2
+ import { AccessPolicy, ResolvedSessions, type RequestIdentity, type ResolvedSession } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
4
  import {
5
5
  AuthService,
@@ -21,7 +21,7 @@ function session(
21
21
  }
22
22
 
23
23
  function identityFor(resolver: SessionResolver): RequestIdentity {
24
- return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), resolver));
24
+ return createRequestIdentity(new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver }));
25
25
  }
26
26
 
27
27
  describe("AuthService", () => {
@@ -33,12 +33,12 @@ describe("AuthService", () => {
33
33
  test("keeps explicitly public routes unauthenticated", async () => {
34
34
  const policy = new AccessPolicy();
35
35
  policy.register("GET", "/public", "public");
36
- const service = new AuthService(settings(false), policy, async () => null);
36
+ const service = new AuthService(settings(false), policy, { resolveSession: async () => null });
37
37
  await expect(service.guard(new Request("http://localhost/public"))).resolves.toBeUndefined();
38
38
  });
39
39
 
40
40
  test("requires a session by default", async () => {
41
- const service = new AuthService(settings(false), new AccessPolicy(), async () => null);
41
+ const service = new AuthService(settings(false), new AccessPolicy(), { resolveSession: async () => null });
42
42
  const result = service.guard(new Request("http://localhost/private"));
43
43
  await expect(result).rejects.toMatchObject({ code: "AUTH_REQUIRED", statusCode: 401 });
44
44
  });
@@ -47,7 +47,7 @@ describe("AuthService", () => {
47
47
  const service = new AuthService(
48
48
  settings(true),
49
49
  new AccessPolicy(),
50
- async () => session({ twoFactorEnabled: false }),
50
+ { resolveSession: async () => session({ twoFactorEnabled: false }) },
51
51
  );
52
52
  const result = service.guard(new Request("http://localhost/private"));
53
53
  await expect(result).rejects.toMatchObject({ code: "TWO_FACTOR_REQUIRED", statusCode: 403 });
@@ -57,13 +57,61 @@ describe("AuthService", () => {
57
57
  const service = new AuthService(
58
58
  settings(false),
59
59
  new AccessPolicy(),
60
- async () => session({ role: "user" }),
60
+ { resolveSession: async () => session({ role: "user" }) },
61
61
  );
62
62
  await expect(service.requireAdmin(new Request("http://localhost/account/settings")))
63
63
  .rejects.toMatchObject({ code: "ADMIN_REQUIRED", statusCode: 403 });
64
64
  });
65
65
  });
66
66
 
67
+ describe("AuthService resolved sessions", () => {
68
+ const expiresAt = new Date("2030-01-02T03:04:05.000Z");
69
+
70
+ function reporting(resolver: SessionResolver): { service: AuthService; reported: ResolvedSession[] } {
71
+ const resolvedSessions = new ResolvedSessions();
72
+ const reported: ResolvedSession[] = [];
73
+ resolvedSessions.listen((resolved) => reported.push(resolved));
74
+ return {
75
+ service: new AuthService(settings(false), new AccessPolicy(), { resolveSession: resolver, resolvedSessions }),
76
+ reported,
77
+ };
78
+ }
79
+
80
+ test("reports each resolved session once per request, before callers continue", async () => {
81
+ const resolver = mock(async () => session({}, { token: "session-token", expiresAt }));
82
+ const { service, reported } = reporting(resolver);
83
+ const request = new Request("http://localhost/private");
84
+
85
+ await service.requireSession(request);
86
+ expect(reported).toEqual([{ token: "session-token", userId: "user-1", expiresAt }]);
87
+ await service.session(request);
88
+ expect(resolver).toHaveBeenCalledTimes(1);
89
+ expect(reported).toHaveLength(1);
90
+
91
+ // A serialized expiry is accepted too.
92
+ const serialized = reporting(async () => session({}, { token: "t2", expiresAt: expiresAt.toISOString() }));
93
+ await serialized.service.session(new Request("http://localhost/private"));
94
+ expect(serialized.reported).toEqual([{ token: "t2", userId: "user-1", expiresAt }]);
95
+ });
96
+
97
+ test("reports nothing without a session, a token, or a valid expiry, and keeps lookup errors", async () => {
98
+ for (const resolver of [
99
+ async () => null,
100
+ async () => session({}, { expiresAt }),
101
+ async () => session({}, { token: "session-token", expiresAt: "soon" }),
102
+ ]) {
103
+ const { service, reported } = reporting(resolver);
104
+ await service.session(new Request("http://localhost/private"));
105
+ expect(reported).toEqual([]);
106
+ }
107
+ const failing = reporting(async () => {
108
+ throw new Error("database down");
109
+ });
110
+ await expect(failing.service.session(new Request("http://localhost/private"))).rejects.toThrow("database down");
111
+ expect(failing.reported).toEqual([]);
112
+ });
113
+ });
114
+
67
115
  describe("createRequestIdentity", () => {
68
116
  const request = (): Request => new Request("http://localhost/events");
69
117
 
@@ -1,5 +1,5 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
- import type { AccessPolicy, RequestIdentity } from "../core/services";
2
+ import type { AccessPolicy, RequestIdentity, ResolvedSessions } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
4
  import { getAuth, refreshForRequest } from "./auth-provider";
5
5
  import { rethrowAuthError } from "./database-outage";
@@ -37,19 +37,34 @@ function activeOrganizationOf(session: AuthSession): string | undefined {
37
37
  return typeof value === "string" && value !== "" ? value : undefined;
38
38
  }
39
39
 
40
+ export interface AuthServiceOptions {
41
+ /** Looks a request's session up; Better Auth's getSession by default. */
42
+ resolveSession?: SessionResolver;
43
+ /** Receives every session this service resolves, such as for the rate limiter. */
44
+ resolvedSessions?: ResolvedSessions;
45
+ }
46
+
40
47
  export class AuthService {
41
48
  private readonly sessions = new WeakMap<Request, Promise<AuthSession | null>>();
49
+ private readonly resolveSession: SessionResolver;
50
+ private readonly resolvedSessions: ResolvedSessions | undefined;
42
51
 
43
52
  constructor(
44
53
  private readonly settings: SettingsService,
45
54
  private readonly accessPolicy: AccessPolicy,
46
- private readonly resolveSession: SessionResolver = defaultSessionResolver,
47
- ) {}
55
+ options: AuthServiceOptions = {},
56
+ ) {
57
+ this.resolveSession = options.resolveSession ?? defaultSessionResolver;
58
+ this.resolvedSessions = options.resolvedSessions;
59
+ }
48
60
 
49
61
  session(request: Request): Promise<AuthSession | null> {
50
62
  const cached = this.sessions.get(request);
51
63
  if (cached) return cached;
52
- const result = this.resolveSession(request.headers);
64
+ const result = this.resolveSession(request.headers).then((session) => {
65
+ if (session) this.report(session);
66
+ return session;
67
+ });
53
68
  this.sessions.set(request, result);
54
69
  return result;
55
70
  }
@@ -84,6 +99,13 @@ export class AuthService {
84
99
  async close(): Promise<void> {
85
100
  await closeAuthDatabase();
86
101
  }
102
+
103
+ private report(session: AuthSession): void {
104
+ const { token, expiresAt } = session.session;
105
+ const expires = expiresAt instanceof Date ? expiresAt : new Date(String(expiresAt));
106
+ if (!this.resolvedSessions || typeof token !== "string" || Number.isNaN(expires.getTime())) return;
107
+ this.resolvedSessions.report({ token, userId: session.user.id, expiresAt: expires });
108
+ }
87
109
  }
88
110
 
89
111
  /** Answers identity questions for other modules from the one session lookup memoized per request. */