@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
@@ -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";
@@ -15,6 +15,8 @@ import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
15
15
  import { runUserDeletedHandlers } from "./user-delete-handlers";
16
16
  import { authBaseUrl, authSecret } from "./auth-environment";
17
17
  import { isMaintenanceCommand } from "./maintenance";
18
+ import { CLIENT_ADDRESS_HEADER } from "../core/client-address";
19
+ import { revokeEveryOtherSession } from "./revoke-other-sessions";
18
20
  // podokit:begin:auth-imports
19
21
  // podokit:end:auth-imports
20
22
 
@@ -55,7 +57,10 @@ export function buildAuth(config: AuthConfig) {
55
57
  // First, so a disabled feature answers 404 before another plugin's onRequest
56
58
  // (such as the OIDC provider's discovery metadata) can answer.
57
59
  featureGatePlugin(gateSettings),
58
- twoFactor(),
60
+ // "Sign out other sessions" revokes every other session, not only the first 100.
61
+ revokeEveryOtherSession(),
62
+ // The issuer is the account name authenticator apps show.
63
+ twoFactor({ issuer: config.appName }),
59
64
  magicLink({
60
65
  sendMagicLink: async ({ email, url }: { email: string; url: string; token: string }) => {
61
66
  await sendMail({
@@ -91,8 +96,8 @@ export function buildAuth(config: AuthConfig) {
91
96
  apiKey(),
92
97
  // Passwordless sign-in with WebAuthn passkeys. The ceremony runs at the web
93
98
  // origin, so expected origins are the CORS origins and rpID is their host.
94
- // Change rpName to your product name (shown in the browser passkey prompt).
95
- 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 }),
96
101
  // Multi-tenant organizations: teams with members, roles, and email invitations.
97
102
  organization({
98
103
  // Custom member roles (adds "manager") and a parent-organization link so orgs
@@ -197,6 +202,7 @@ export function buildAuth(config: AuthConfig) {
197
202
  const session = sessionIdleOptions(config.sessionIdleTimeoutMinutes);
198
203
 
199
204
  return betterAuth({
205
+ appName: config.appName,
200
206
  database: authDatabase,
201
207
  emailAndPassword: {
202
208
  enabled: true,
@@ -235,9 +241,10 @@ export function buildAuth(config: AuthConfig) {
235
241
  baseURL: authBaseUrl(),
236
242
  advanced: {
237
243
  ipAddress: {
238
- // The API sits behind the SvelteKit server proxy, which forwards the
239
- // resolved client IP as x-forwarded-for; trust it so sessions record an IP.
240
- ipAddressHeaders: ["x-forwarded-for"],
244
+ // The address the API resolved behind its trusted proxies, which it sets
245
+ // on every request, so sessions and Better Auth's own rate limits use the
246
+ // same client address as the rest of the API.
247
+ ipAddressHeaders: [CLIENT_ADDRESS_HEADER],
241
248
  },
242
249
  ...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
243
250
  },
@@ -292,4 +299,4 @@ export function buildAuth(config: AuthConfig) {
292
299
  // Bootstrap instance from environment config. Used by the better-auth CLI
293
300
  // (`--config auth.ts`) and as the guaranteed last-good fallback. The runtime
294
301
  // mount (auth-provider.ts) rebuilds from DB config on top of this.
295
- export const auth = buildAuth(envAuthConfig());
302
+ export const auth = buildAuth(envAuthConfig(AUTH_CONFIG_DEFAULTS));
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { betterAuth, type BetterAuthPlugin } from "better-auth";
3
+ import { memoryAdapter } from "better-auth/adapters/memory";
4
+ import { revokeEveryOtherSession } from "./revoke-other-sessions";
5
+
6
+ type Row = Record<string, unknown>;
7
+
8
+ // A user signed in once more than Better Auth's default findMany limit of 100
9
+ // sessions per list allows, plus the 50 extra the endpoint used to leave behind.
10
+ const EXTRA_SESSIONS = 150;
11
+
12
+ async function signedInWithManySessions(plugins: BetterAuthPlugin[]): Promise<{
13
+ revokeOthers: () => Promise<unknown>;
14
+ sessions: () => Row[];
15
+ currentToken: string;
16
+ }> {
17
+ const db: Record<string, Row[]> = { user: [], session: [], account: [], verification: [] };
18
+ const auth = betterAuth({
19
+ database: memoryAdapter(db),
20
+ secret: "revoke-other-sessions-test-secret-0123456789",
21
+ baseURL: "http://localhost:5002",
22
+ emailAndPassword: { enabled: true },
23
+ plugins,
24
+ });
25
+ const response = await auth.api.signUpEmail({
26
+ body: { email: "many@example.com", password: "Podokit3e-Str0ng!pw", name: "Many" },
27
+ asResponse: true,
28
+ });
29
+ const cookie = response.headers.getSetCookie().map((value) => value.split(";")[0]).join("; ");
30
+ const context = await auth.$context;
31
+ const userId = String(db.user[0]?.id);
32
+ for (let index = 0; index < EXTRA_SESSIONS; index += 1) {
33
+ await context.internalAdapter.createSession(userId);
34
+ }
35
+ const currentToken = String((await auth.api.getSession({ headers: new Headers({ cookie }) }))?.session.token);
36
+ return {
37
+ revokeOthers: () => auth.api.revokeOtherSessions({ headers: new Headers({ cookie }) }),
38
+ sessions: () => db.session ?? [],
39
+ currentToken,
40
+ };
41
+ }
42
+
43
+ describe("revokeEveryOtherSession", () => {
44
+ test("Better Auth alone revokes only the first 100 other sessions", async () => {
45
+ const user = await signedInWithManySessions([]);
46
+ expect(user.sessions()).toHaveLength(EXTRA_SESSIONS + 1);
47
+ await user.revokeOthers();
48
+ expect(user.sessions().length).toBeGreaterThan(1);
49
+ });
50
+
51
+ test("revokes every other session and keeps the current one", async () => {
52
+ const user = await signedInWithManySessions([revokeEveryOtherSession()]);
53
+ await user.revokeOthers();
54
+ expect(user.sessions().map((session) => session.token)).toEqual([user.currentToken]);
55
+ });
56
+ });
@@ -0,0 +1,45 @@
1
+ import type { BetterAuthPlugin } from "better-auth";
2
+ import { createAuthMiddleware } from "better-auth/api";
3
+
4
+ /** Sessions read and revoked per pass. */
5
+ const BATCH_SIZE = 500;
6
+
7
+ /**
8
+ * Makes `/revoke-other-sessions` revoke every other session of the user.
9
+ * Better Auth's endpoint lists the sessions through the adapter's default
10
+ * findMany limit of 100 and revokes only those, so a user with more sessions
11
+ * kept the rest after "sign out other sessions". This hook runs after the
12
+ * endpoint and revokes the remainder in batches through the internal adapter,
13
+ * which also clears their secondary-storage entries when one is configured.
14
+ */
15
+ export function revokeEveryOtherSession(): BetterAuthPlugin {
16
+ return {
17
+ id: "podokit-revoke-every-other-session",
18
+ hooks: {
19
+ after: [
20
+ {
21
+ matcher: (context) => context.path === "/revoke-other-sessions",
22
+ handler: createAuthMiddleware(async (ctx) => {
23
+ const current = ctx.context.session;
24
+ if (!current) return;
25
+ let first: string | undefined;
26
+ for (;;) {
27
+ const others = await ctx.context.adapter.findMany<{ token: string }>({
28
+ model: "session",
29
+ where: [
30
+ { field: "userId", value: current.user.id },
31
+ { field: "token", value: current.session.token, operator: "ne" },
32
+ ],
33
+ limit: BATCH_SIZE,
34
+ });
35
+ // Stop when nothing is left, or when a pass removed nothing.
36
+ if (others.length === 0 || others[0]?.token === first) return;
37
+ first = others[0]?.token;
38
+ await ctx.context.internalAdapter.deleteSessions(others.map((session) => session.token));
39
+ }
40
+ }),
41
+ },
42
+ ],
43
+ },
44
+ };
45
+ }
@@ -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
+ });
@@ -0,0 +1,44 @@
1
+ /** The shortest time between two rebuilds while Better Auth reports a schema mismatch. */
2
+ export const SCHEMA_RECHECK_INTERVAL_MS = 2_000;
3
+
4
+ export interface SchemaCheckedAuth {
5
+ $context: Promise<{ checkSchema?: (() => Promise<void> | undefined) | undefined }>;
6
+ }
7
+
8
+ export interface SchemaRecheckOptions {
9
+ intervalMs?: number;
10
+ now?: () => number;
11
+ }
12
+
13
+ export function isSchemaMismatch(error: unknown): boolean {
14
+ return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "SCHEMA_MISMATCH";
15
+ }
16
+
17
+ /**
18
+ * Better Auth keeps a schema mismatch it found until a migration run by the
19
+ * same process clears it. An API that started before `migrate:all` ran in
20
+ * another process, such as a separate container, therefore failed every
21
+ * sign-in until it restarted. The returned function calls `rebuild` when
22
+ * `auth` holds a mismatch, at most once per interval; the new instance checks
23
+ * the database again, so the first request after the migration succeeds.
24
+ */
25
+ export function createSchemaRecheck(
26
+ options: SchemaRecheckOptions = {},
27
+ ): (auth: SchemaCheckedAuth, rebuild: () => void) => Promise<void> {
28
+ const intervalMs = options.intervalMs ?? SCHEMA_RECHECK_INTERVAL_MS;
29
+ const now = options.now ?? Date.now;
30
+ let rebuiltAt = Number.NEGATIVE_INFINITY;
31
+ return async (auth, rebuild) => {
32
+ // Nothing is pending once the schema is known to be clean.
33
+ const pending = (await auth.$context).checkSchema?.();
34
+ if (!pending) return;
35
+ try {
36
+ await pending;
37
+ } catch (error) {
38
+ // A failure to reach the database is not kept, so only a mismatch needs a rebuild.
39
+ if (!isSchemaMismatch(error) || now() - rebuiltAt < intervalMs) return;
40
+ rebuiltAt = now();
41
+ rebuild();
42
+ }
43
+ };
44
+ }
@@ -11,6 +11,7 @@ import {
11
11
  SUPPORTED_SOCIAL_PROVIDERS,
12
12
  } from "@podosoft/podokit-auth";
13
13
  import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
14
+ import { AUTH_CONFIG_DEFAULTS } from "../auth/auth-config-store";
14
15
  import { refreshAuthNow } from "../auth/auth-provider";
15
16
  import {
16
17
  readAuthConfigRow,
@@ -123,7 +124,7 @@ export class AuthConfigService {
123
124
  async describe(): Promise<AuthConfigView> {
124
125
  const rows = await this.rows();
125
126
  const byKey = new Map(rows.map((row) => [row.key, row]));
126
- const environment = envAuthConfig();
127
+ const environment = envAuthConfig(AUTH_CONFIG_DEFAULTS);
127
128
  const social: Record<string, SocialProviderView> = {};
128
129
 
129
130
  for (const row of rows) {
@@ -10,6 +10,7 @@ import {
10
10
  migrateSqliteLegacyAccountIssuers,
11
11
  postgresAccountIssuerMigrationDatabase,
12
12
  } from "./auth/account-issuer-migration";
13
+ import { assertMigrationsLoaded } from "./database/migration-files";
13
14
  import { runSqliteMigrations } from "./database/sqlite-migrator";
14
15
 
15
16
  async function runMigrations(): Promise<void> {
@@ -24,9 +25,10 @@ async function runMigrations(): Promise<void> {
24
25
  if (sqliteDatabase) {
25
26
  await runSqliteMigrations(sqliteDatabase);
26
27
  } else {
27
- const { appDataSource: dataSource } = await import("./database/data-source.js");
28
+ const { appDataSource: dataSource, appMigrations } = await import("./database/data-source.js");
28
29
  await dataSource.initialize();
29
30
  try {
31
+ assertMigrationsLoaded(dataSource.migrations, appMigrations);
30
32
  await dataSource.runMigrations();
31
33
  } finally {
32
34
  await dataSource.destroy();
@@ -0,0 +1,9 @@
1
+ // This owned file is never overwritten by `podo update`. Declare the
2
+ // application's on/off settings and their defaults here. They are stored with
3
+ // PodoKit's feature flags in app_setting: administrators change them through
4
+ // PUT /account/settings, the API reads them with SettingsService.getBool(), and
5
+ // GET /account/capabilities returns them as `appFlags`, so do not put anything
6
+ // secret in a name. A missing row keeps the default. For example:
7
+ //
8
+ // export const APP_FLAG_DEFAULTS = { reportsEnabled: true } satisfies Record<string, boolean>;
9
+ export const APP_FLAG_DEFAULTS = {} satisfies Record<string, boolean>;
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { FLAG_DEFAULTS, mergeFlagDefaults } from "./flag-defaults";
5
+
6
+ describe("feature flag defaults", () => {
7
+ test("adds the application's flags after PodoKit's", () => {
8
+ const merged = mergeFlagDefaults(FLAG_DEFAULTS, { reportsEnabled: true, betaSearch: false });
9
+ expect(merged).toMatchObject({ twoFactor: true, phoneNumber: false, reportsEnabled: true, betaSearch: false });
10
+ });
11
+
12
+ test("rejects an application flag that reuses a PodoKit name", () => {
13
+ expect(() => mergeFlagDefaults(FLAG_DEFAULTS, { passkey: false })).toThrow(
14
+ "app-flags.ts redefines PodoKit feature flags: passkey",
15
+ );
16
+ });
17
+
18
+ test("reads stored application flags and keeps their defaults otherwise", async () => {
19
+ // A copy next to an app-flags.ts of the test's own, in the API directory.
20
+ const root = mkdtempSync(join(__dirname, ".app-flags-"));
21
+ try {
22
+ copyFileSync(join(__dirname, "flag-defaults.ts"), join(root, "flag-defaults.ts"));
23
+ writeFileSync(
24
+ join(root, "app-flags.ts"),
25
+ "export const APP_FLAG_DEFAULTS = { reportsEnabled: true, betaSearch: false };\n",
26
+ );
27
+ writeFileSync(join(root, "check.ts"), [
28
+ 'import { APP_FLAGS, FEATURE_FLAGS, flagsFrom } from "./flag-defaults";',
29
+ 'const flags = flagsFrom(new Map([["betaSearch", "true"], ["magicLink", "false"]]));',
30
+ "console.log(JSON.stringify([APP_FLAGS, FEATURE_FLAGS.includes(\"reportsEnabled\"), flags.reportsEnabled, flags.betaSearch, flags.magicLink]));",
31
+ "",
32
+ ].join("\n"));
33
+ const child = Bun.spawn([process.execPath, join(root, "check.ts")], { stdout: "pipe", stderr: "pipe" });
34
+ const [stdout, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
35
+ expect({ code, stdout: stdout.trim() }).toEqual({
36
+ code: 0,
37
+ stdout: '[["reportsEnabled","betaSearch"],true,true,true,false]',
38
+ });
39
+ } finally {
40
+ rmSync(root, { recursive: true, force: true });
41
+ }
42
+ });
43
+ });
@@ -1,14 +1,22 @@
1
- /** Admin-managed auth feature flags and their shipped defaults.
1
+ import { APP_FLAG_DEFAULTS } from "./app-flags";
2
+
3
+ /** Admin-managed auth feature flags and their shipped defaults, followed by the
4
+ * application's flags from the owned app-flags.ts.
2
5
  *
3
6
  * Kept free of framework/ORM imports so both SettingsService and the Better
4
7
  * Auth feature gate (loaded by auth.ts, including when
5
8
  * the better-auth CLI evaluates auth.ts for migrations) can import it safely.
6
9
  *
7
- * Must match the rows seeded by the InitAppSettings migration. phoneNumber is
8
- * off by default because real delivery needs an SMS provider. */
9
- export type FeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider" | "require2fa";
10
+ * The PodoKit defaults must match the rows seeded by the InitAppSettings
11
+ * migration. phoneNumber is off by default because real delivery needs an SMS
12
+ * provider. */
13
+ export type PodokitFeatureFlag = "twoFactor" | "magicLink" | "emailOtp" | "username" | "multiSession" | "phoneNumber" | "apiKey" | "passkey" | "organization" | "oidcProvider" | "require2fa";
14
+
15
+ export type AppFlag = keyof typeof APP_FLAG_DEFAULTS;
10
16
 
11
- export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
17
+ export type FeatureFlag = PodokitFeatureFlag | AppFlag;
18
+
19
+ const PODOKIT_FLAG_DEFAULTS: Record<PodokitFeatureFlag, boolean> = {
12
20
  twoFactor: true,
13
21
  magicLink: true,
14
22
  emailOtp: true,
@@ -26,8 +34,25 @@ export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = {
26
34
  require2fa: false,
27
35
  };
28
36
 
37
+ /** PodoKit's defaults with the application's; an application flag may not reuse a PodoKit name. */
38
+ export function mergeFlagDefaults<App extends Record<string, boolean>>(
39
+ podokit: Record<PodokitFeatureFlag, boolean>,
40
+ app: App,
41
+ ): Record<PodokitFeatureFlag | keyof App, boolean> {
42
+ const redefined = Object.keys(app).filter((flag) => flag in podokit);
43
+ if (redefined.length > 0) {
44
+ throw new Error(`app-flags.ts redefines PodoKit feature flags: ${redefined.join(", ")}`);
45
+ }
46
+ return { ...podokit, ...app };
47
+ }
48
+
49
+ export const FLAG_DEFAULTS: Record<FeatureFlag, boolean> = mergeFlagDefaults(PODOKIT_FLAG_DEFAULTS, APP_FLAG_DEFAULTS);
50
+
29
51
  export const FEATURE_FLAGS = Object.keys(FLAG_DEFAULTS) as FeatureFlag[];
30
52
 
53
+ /** The application's flags, which the capabilities endpoint returns as `appFlags`. */
54
+ export const APP_FLAGS = Object.keys(APP_FLAG_DEFAULTS) as AppFlag[];
55
+
31
56
  /** Stored values override the shipped defaults; a missing row keeps the default. */
32
57
  export function flagsFrom(settings: ReadonlyMap<string, string>): Record<FeatureFlag, boolean> {
33
58
  const flags = { ...FLAG_DEFAULTS };
@@ -6,9 +6,12 @@
6
6
  "managedOverrides": [
7
7
  ".claude/skills/podokit-configure-auth/**"
8
8
  ],
9
+ "ownedGlobs": [
10
+ "apps/api/src/settings/app-flags.ts"
11
+ ],
9
12
  "dependencies": {
10
- "@podosoft/podokit-auth": "^0.5.0",
11
- "@podosoft/podokit-contracts": "^0.5.0",
13
+ "@podosoft/podokit-auth": "^0.6.0",
14
+ "@podosoft/podokit-contracts": "^0.6.0",
12
15
  "better-auth": "^1.7.7",
13
16
  "pg": "^8.12.0",
14
17
  "@better-auth/api-key": "^1.7.7",
@@ -28,6 +31,9 @@
28
31
  "# makes previously saved DB secrets undecryptable.",
29
32
  "BETTER_AUTH_SECRET=change-me-in-production-min-32-characters",
30
33
  "BETTER_AUTH_URL=http://localhost:5002",
34
+ "# The name authenticator apps and passkey prompts show. Unset, it is the site",
35
+ "# name from the admin Settings, then the project name.",
36
+ "# AUTH_APP_NAME=",
31
37
  "# Everything below is optional. OAuth providers, SMTP, and server-enforced",
32
38
  "# toggles are configured on the admin Settings page and applied without restart.",
33
39
  "# Email/SMTP is provided by the mailer module (see its .env block).",
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import type { ConnectionOptions } from "bullmq";
2
+ import { Queue, type ConnectionOptions } from "bullmq";
3
3
  import { BullMqJobQueue, externalJobId, parseExternalJobId } from "./bullmq-job.queue";
4
4
  import { producerConnection, reconnectDelay, redisConnection } from "./queue";
5
5
 
@@ -128,3 +128,59 @@ describe("BullMqJobQueue while Redis is unavailable", () => {
128
128
  }
129
129
  });
130
130
  });
131
+
132
+ describe("BullMqJobQueue schedules", () => {
133
+ it("rejects an invalid schedule before keeping it", async () => {
134
+ const queue = new BullMqJobQueue(producerAt(closedPort()));
135
+ try {
136
+ await expect(queue.schedule("bad", "report", {}, { cron: "61 * * * *" })).rejects.toThrow();
137
+ await expect(queue.schedule("bad", "report", {}, { cron: "0 3 * * *", timezone: "Mars/Olympus" })).rejects.toThrow();
138
+ await expect(queue.schedule("bad", "report", {}, { everyMs: 10 })).rejects.toThrow("at least 1000");
139
+ } finally {
140
+ await queue.close();
141
+ }
142
+ });
143
+
144
+ it("keeps a schedule while Redis is unreachable instead of failing", async () => {
145
+ const queue = new BullMqJobQueue(producerAt(closedPort()));
146
+ try {
147
+ await expect(queue.schedule("nightly", "report", {}, { cron: "0 3 * * *" })).resolves.toBeUndefined();
148
+ await expect(queue.unschedule("nightly")).resolves.toBeUndefined();
149
+ } finally {
150
+ await queue.close();
151
+ }
152
+ });
153
+ });
154
+
155
+ // Runs against the Redis the app's environment names (REDIS_URL or REDIS_HOST, as
156
+ // in .env); skipped when neither is set.
157
+ describe.skipIf(!process.env.REDIS_URL && !process.env.REDIS_HOST)("BullMqJobQueue schedules in Redis", () => {
158
+ const REDIS = process.env;
159
+ it("applies schedules on connect, again after Redis lost them, and removes them", async () => {
160
+ const name = `schedule-test-${crypto.randomUUID()}`;
161
+ const queue = new BullMqJobQueue(producerConnection(REDIS));
162
+ const inspect = new Queue(name, { connection: redisConnection(REDIS) });
163
+ try {
164
+ await queue.schedule("hourly", name, { scope: "all" }, { everyMs: 3_600_000 });
165
+ await queue.schedule("nightly", name, {}, { cron: "0 3 * * *", timezone: "Asia/Seoul" });
166
+ expect(await inspect.getJobScheduler("hourly")).toBeUndefined();
167
+
168
+ await queue.connect();
169
+ expect(await inspect.getJobScheduler("hourly")).toMatchObject({ every: 3_600_000, name });
170
+ expect(await inspect.getJobScheduler("nightly")).toMatchObject({ pattern: "0 3 * * *", tz: "Asia/Seoul" });
171
+
172
+ // A Redis that lost its data gets the schedules back on the next connect.
173
+ await inspect.removeJobScheduler("hourly");
174
+ await queue.connect();
175
+ expect(await inspect.getJobScheduler("hourly")).toMatchObject({ every: 3_600_000 });
176
+
177
+ await queue.unschedule("nightly");
178
+ expect(await inspect.getJobScheduler("nightly")).toBeUndefined();
179
+ expect(await inspect.getJobSchedulersCount()).toBe(1);
180
+ } finally {
181
+ await queue.close();
182
+ await inspect.obliterate({ force: true });
183
+ await inspect.close();
184
+ }
185
+ });
186
+ });