@podosoft/podokit 1.5.0 → 1.6.1

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 (215) 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 -18
  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 +27 -21
  18. package/dist/deploy-schema.d.ts +60 -2
  19. package/dist/deploy-schema.js +141 -14
  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 +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +30 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +7 -2
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +121 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +72 -4
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +78 -6
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +13 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +50 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/helpers/seeded-admin.ts +15 -0
  93. package/dist/templates/fullstack/tests/package.json +3 -3
  94. package/dist/templates/fullstack/tests/playwright.config.ts +10 -4
  95. package/dist/templates/fullstack/tests/playwright.extensions.ts +149 -1
  96. package/dist/templates/fullstack/tests/run-e2e.ts +28 -0
  97. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +53 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/theme-toggle.svelte +1 -1
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  127. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  132. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  133. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  134. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  138. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  139. package/dist/templates/modules/admin-dashboard/files/tests/helpers/hydration.ts +13 -4
  140. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  141. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  142. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +14 -6
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +35 -0
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  168. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  169. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  170. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  171. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  172. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  179. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  180. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  183. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  184. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  185. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  186. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  187. package/dist/templates/modules/auth/module.manifest.json +8 -2
  188. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +107 -1
  189. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +113 -9
  190. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  191. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  192. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  193. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  194. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  195. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  196. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +174 -1
  197. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +150 -3
  198. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  199. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  200. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  201. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  202. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +3 -6
  203. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  204. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +3 -6
  205. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  206. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +3 -6
  207. package/dist/templates/todo/AGENTS.md +5 -1
  208. package/dist/templates/todo/README.md +6 -4
  209. package/dist/text-diff.d.ts +12 -0
  210. package/dist/text-diff.js +111 -0
  211. package/dist/update.d.ts +61 -3
  212. package/dist/update.js +152 -27
  213. package/package.json +2 -2
  214. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  215. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -1,7 +1,6 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
2
  import { ADMIN, USER, anonState } from "../helpers/accounts";
3
3
  import { totpCode } from "../helpers/totp";
4
- import { exclusive } from "../helpers/exclusive";
5
4
 
6
5
  test.use({ storageState: anonState });
7
6
 
@@ -85,62 +84,60 @@ test("two-factor: sign in with an authenticator code from the login page", async
85
84
  await expectAccountPage(page);
86
85
  });
87
86
 
88
- test("require-2fa: a new sign-up is forced through the enrolment page", async ({ page, playwright }) => {
87
+ test("require-2fa: a new sign-up is forced through the enrolment page", { tag: "@exclusive" }, async ({ page, playwright }) => {
89
88
  test.slow();
90
- await exclusive("require2fa", async () => {
91
- const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
92
- const caps = await (await admin.get("/api/account/capabilities")).json();
93
- test.skip(!caps?.twoFactor, "two-factor not enabled");
94
- await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
95
-
96
- const email = `rq-ui-${Date.now()}@example.com`;
97
- const pw = "Podokit3e-Str0ng!pw";
98
- try {
99
- await admin.put("/api/account/settings", { data: { require2fa: true } });
100
- // A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
89
+ const admin = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
90
+ const caps = await (await admin.get("/api/account/capabilities")).json();
91
+ test.skip(!caps?.twoFactor, "two-factor not enabled");
92
+ await admin.post("/api/auth/sign-in/email", { data: { email: ADMIN.email, password: ADMIN.password } });
93
+
94
+ const email = `rq-ui-${Date.now()}@example.com`;
95
+ const pw = "Podokit3e-Str0ng!pw";
96
+ try {
97
+ await admin.put("/api/account/settings", { data: { require2fa: true } });
98
+ // A fresh un-enrolled user; poll (via API) until the guard cache picks up the policy.
99
+ const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
100
+ await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
101
+ await expect(async () => {
102
+ expect((await probe.get("/api/account/me")).status()).toBe(403);
103
+ }).toPass({ timeout: 8000 });
104
+ await probe.dispose();
105
+
106
+ // Signing in toward a protected route lands on the mandatory enrolment page.
107
+ await openHydratedLogin(page, "/account");
108
+ await page.getByLabel("Email").fill(email);
109
+ await page.getByLabel("Password").fill(pw);
110
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
111
+ await expect(page).toHaveURL(/\/setup-2fa/);
112
+
113
+ // Complete enrolment entirely through the UI: password → QR + backup codes →
114
+ // authenticator code → activate. The code is computed from the on-page URI.
115
+ await page.getByLabel("Confirm your password").fill(pw);
116
+ await page.getByRole("button", { name: "Continue" }).click();
117
+ await expect(page.getByTestId("backup-codes")).toBeVisible();
118
+ await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
119
+ const uri = (await page.locator("code").first().innerText()).trim();
120
+ await page.getByRole("checkbox").click();
121
+ await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
122
+ await page.getByRole("button", { name: "Activate and continue" }).click();
123
+
124
+ // Enrolled → the gate lets the regular user into their account page.
125
+ await expectAccountPage(page);
126
+
127
+ // An enrolled user who revisits setup is returned to the same non-admin page.
128
+ await page.goto("/setup-2fa");
129
+ await expectAccountPage(page);
130
+ } finally {
131
+ await admin.put("/api/account/settings", { data: { require2fa: false } });
132
+ await expect(async () => {
101
133
  const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
102
- await probe.post("/api/auth/sign-up/email", { data: { email, password: pw, name: "RQUI" } });
103
- await expect(async () => {
104
- expect((await probe.get("/api/account/me")).status()).toBe(403);
105
- }).toPass({ timeout: 8000 });
134
+ await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
135
+ const status = (await probe.get("/api/account/me")).status();
106
136
  await probe.dispose();
107
-
108
- // Signing in toward a protected route lands on the mandatory enrolment page.
109
- await openHydratedLogin(page, "/account");
110
- await page.getByLabel("Email").fill(email);
111
- await page.getByLabel("Password").fill(pw);
112
- await page.getByRole("button", { name: "Sign in", exact: true }).click();
113
- await expect(page).toHaveURL(/\/setup-2fa/);
114
-
115
- // Complete enrolment entirely through the UI: password → QR + backup codes →
116
- // authenticator code → activate. The code is computed from the on-page URI.
117
- await page.getByLabel("Confirm your password").fill(pw);
118
- await page.getByRole("button", { name: "Continue" }).click();
119
- await expect(page.getByTestId("backup-codes")).toBeVisible();
120
- await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
121
- const uri = (await page.locator("code").first().innerText()).trim();
122
- await page.getByRole("checkbox").click();
123
- await page.getByLabel("3. Enter the 6-digit code").fill(totpCode(uri));
124
- await page.getByRole("button", { name: "Activate and continue" }).click();
125
-
126
- // Enrolled → the gate lets the regular user into their account page.
127
- await expectAccountPage(page);
128
-
129
- // An enrolled user who revisits setup is returned to the same non-admin page.
130
- await page.goto("/setup-2fa");
131
- await expectAccountPage(page);
132
- } finally {
133
- await admin.put("/api/account/settings", { data: { require2fa: false } });
134
- await expect(async () => {
135
- const probe = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
136
- await probe.post("/api/auth/sign-in/email", { data: { email: USER.email, password: USER.password } });
137
- const status = (await probe.get("/api/account/me")).status();
138
- await probe.dispose();
139
- expect(status).toBe(200);
140
- }).toPass({ timeout: 8000 });
141
- await admin.dispose();
142
- }
143
- });
137
+ expect(status).toBe(200);
138
+ }).toPass({ timeout: 8000 });
139
+ await admin.dispose();
140
+ }
144
141
  });
145
142
 
146
143
  test("account: regenerate backup codes shows a fresh set", async ({ page, playwright }) => {
@@ -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",
@@ -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
  };
@@ -192,6 +192,23 @@ describe("AuditService", () => {
192
192
  expect(capture.batches.map((batch) => batch[0]?.ip)).toEqual(["203.0.113.7", null]);
193
193
  });
194
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
+
195
212
  test("a failing toggle read never breaks the caller", async () => {
196
213
  const sql = await sqliteDatabase();
197
214
  const service = new AuditService(sql, {
@@ -166,6 +166,24 @@ export class AuditService {
166
166
  if (this.retention.days > 0 && !this.pruneTimer && !this.pruning) this.schedulePrune(0);
167
167
  }
168
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
+
169
187
  /** Stops pruning and the registry, then writes the queue. */
170
188
  async close(): Promise<void> {
171
189
  this.closed = true;
@@ -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.
@@ -17,6 +17,7 @@ import {
17
17
  AuthConfigService,
18
18
  type AuthConfigUpdate,
19
19
  } from "../auth-config/auth-config.service";
20
+ import { APP_FLAGS } from "../settings/flag-defaults";
20
21
  import { FEATURE_FLAGS, SettingsService, type FeatureFlag } from "../settings/settings.service";
21
22
  import { authConfigStore } from "./auth-config-store";
22
23
  import { getAuth, authRuntime, primeAuth } from "./auth-provider";
@@ -101,6 +102,7 @@ const accountPlugin: AppPlugin = ({ services }) => {
101
102
  auditLog: snapshot.auditLog,
102
103
  sessionIdleTimeoutMinutes: snapshot.sessionIdleTimeoutMinutes,
103
104
  roles: ROLE_NAMES,
105
+ appFlags: Object.fromEntries(APP_FLAGS.map((flag) => [flag, flags[flag]])),
104
106
  };
105
107
  }, {
106
108
  detail: { tags: ["account"], summary: "Get authentication capabilities" },
@@ -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
  });
@@ -4,7 +4,7 @@ import { twoFactor, haveIBeenPwned, magicLink, emailOTP, username, multiSession,
4
4
  import { actionEmail, sendMail } from "../mail/mailer";
5
5
  import { sendSms } from "../sms/sms";
6
6
  import { assertUserCreationAllowed, createFeatureGate, featureGatePlugin, readSignupOpen, type FeatureGateSettings } from "./feature-gate";
7
- import { featureFlags, isSignupOpen } from "./auth-config-store";
7
+ import { AUTH_CONFIG_DEFAULTS, featureFlags, isSignupOpen } from "./auth-config-store";
8
8
  import { orgAc, orgRoles } from "./org-permissions";
9
9
  import { authDatabase } from "./db";
10
10
  import { type AuthConfig, envAuthConfig, sessionIdleOptions, SUPPORTED_PROVIDER_IDS } from "@podosoft/podokit-auth";
@@ -59,7 +59,8 @@ export function buildAuth(config: AuthConfig) {
59
59
  featureGatePlugin(gateSettings),
60
60
  // "Sign out other sessions" revokes every other session, not only the first 100.
61
61
  revokeEveryOtherSession(),
62
- twoFactor(),
62
+ // The issuer is the account name authenticator apps show.
63
+ twoFactor({ issuer: config.appName }),
63
64
  magicLink({
64
65
  sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
65
66
  await sendMail({
@@ -95,8 +96,8 @@ export function buildAuth(config: AuthConfig) {
95
96
  apiKey(),
96
97
  // Passwordless sign-in with WebAuthn passkeys. The ceremony runs at the web
97
98
  // origin, so expected origins are the CORS origins and rpID is their host.
98
- // Change rpName to your product name (shown in the browser passkey prompt).
99
- passkey({ rpName: "PodoKit", rpID: new URL(webOrigins[0]).hostname, origin: webOrigins }),
99
+ // rpName is the name the browser shows in the passkey prompt.
100
+ passkey({ rpName: config.appName, rpID: new URL(webOrigins[0]).hostname, origin: webOrigins }),
100
101
  // Multi-tenant organizations: teams with members, roles, and email invitations.
101
102
  organization({
102
103
  // Custom member roles (adds "manager") and a parent-organization link so orgs
@@ -201,6 +202,7 @@ export function buildAuth(config: AuthConfig) {
201
202
  const session = sessionIdleOptions(config.sessionIdleTimeoutMinutes);
202
203
 
203
204
  return betterAuth({
205
+ appName: config.appName,
204
206
  database: authDatabase,
205
207
  emailAndPassword: {
206
208
  enabled: true,
@@ -297,4 +299,4 @@ export function buildAuth(config: AuthConfig) {
297
299
  // Bootstrap instance from environment config. Used by the better-auth CLI
298
300
  // (`--config auth.ts`) and as the guaranteed last-good fallback. The runtime
299
301
  // mount (auth-provider.ts) rebuilds from DB config on top of this.
300
- export const auth = buildAuth(envAuthConfig());
302
+ export const auth = buildAuth(envAuthConfig(AUTH_CONFIG_DEFAULTS));
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { betterAuth, type BetterAuthOptions } from "better-auth";
7
+ import { getMigrations } from "better-auth/db/migration";
8
+ import { createSchemaRecheck, isSchemaMismatch, type SchemaCheckedAuth } from "./schema-recheck";
9
+
10
+ const roots: string[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
14
+ });
15
+
16
+ function options(database: Database): BetterAuthOptions {
17
+ return {
18
+ database,
19
+ secret: "schema-recheck-test-secret-with-32-characters",
20
+ baseURL: "http://localhost:5002",
21
+ emailAndPassword: { enabled: true },
22
+ logger: { disabled: true },
23
+ };
24
+ }
25
+
26
+ async function signUp(auth: ReturnType<typeof betterAuth>): Promise<unknown> {
27
+ try {
28
+ await auth.api.signUpEmail({
29
+ body: { email: `${crypto.randomUUID()}@example.com`, password: "password123", name: "A" },
30
+ });
31
+ return "signed up";
32
+ } catch (error) {
33
+ return error;
34
+ }
35
+ }
36
+
37
+ function fakeAuth(check: () => Promise<void> | undefined): SchemaCheckedAuth {
38
+ return { $context: Promise.resolve({ checkSchema: check }) };
39
+ }
40
+
41
+ describe("schema recheck", () => {
42
+ it("lets an API that started before the migrations sign users up once another process ran them", async () => {
43
+ const root = mkdtempSync(join(tmpdir(), "schema-recheck-"));
44
+ roots.push(root);
45
+ const file = join(root, "auth.sqlite");
46
+ const api = new Database(file, { create: true });
47
+ try {
48
+ let auth = betterAuth(options(api));
49
+ expect(isSchemaMismatch(await signUp(auth))).toBe(true);
50
+
51
+ // migrate:all runs in a process of its own, with a connection of its own.
52
+ const migrator = new Database(file);
53
+ await (await getMigrations(options(migrator))).runMigrations();
54
+ migrator.close();
55
+ // Better Auth keeps the mismatch it found.
56
+ expect(isSchemaMismatch(await signUp(auth))).toBe(true);
57
+
58
+ await createSchemaRecheck()(auth, () => { auth = betterAuth(options(api)); });
59
+ expect(await signUp(auth)).toBe("signed up");
60
+ } finally {
61
+ api.close();
62
+ }
63
+ });
64
+
65
+ it("rebuilds at most once per interval while the mismatch lasts", async () => {
66
+ let clock = 0;
67
+ let rebuilds = 0;
68
+ const recheck = createSchemaRecheck({ intervalMs: 2_000, now: () => clock });
69
+ const mismatched = fakeAuth(() => Promise.reject(Object.assign(new Error("mismatch"), { code: "SCHEMA_MISMATCH" })));
70
+ await recheck(mismatched, () => { rebuilds += 1; });
71
+ clock = 1_999;
72
+ await recheck(mismatched, () => { rebuilds += 1; });
73
+ expect(rebuilds).toBe(1);
74
+ clock = 2_000;
75
+ await recheck(mismatched, () => { rebuilds += 1; });
76
+ expect(rebuilds).toBe(2);
77
+ });
78
+
79
+ it("leaves a clean schema and an unreachable database alone", async () => {
80
+ let rebuilds = 0;
81
+ const recheck = createSchemaRecheck();
82
+ await recheck(fakeAuth(() => undefined), () => { rebuilds += 1; });
83
+ await recheck(fakeAuth(() => Promise.reject(new Error("connect ECONNREFUSED"))), () => { rebuilds += 1; });
84
+ expect(rebuilds).toBe(0);
85
+ });
86
+ });