@podosoft/podokit 1.4.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -17
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +28 -20
  18. package/dist/deploy-schema.d.ts +61 -0
  19. package/dist/deploy-schema.js +157 -1
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +77 -5
  30. package/dist/provider.d.ts +1 -0
  31. package/dist/provider.js +12 -4
  32. package/dist/remove.js +1 -1
  33. package/dist/template-files.d.ts +42 -0
  34. package/dist/template-files.js +102 -0
  35. package/dist/templates/fullstack/AGENTS.md +44 -7
  36. package/dist/templates/fullstack/README.md +8 -7
  37. package/dist/templates/fullstack/apps/api/package.json +4 -4
  38. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  39. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  40. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  41. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  42. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  43. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  44. package/dist/templates/fullstack/apps/api/src/app.ts +47 -28
  45. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  47. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  48. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +46 -3
  49. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  50. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  51. package/dist/templates/fullstack/apps/api/src/config/providers.ts +8 -2
  52. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  53. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  54. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  55. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  56. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  57. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  58. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  59. package/dist/templates/fullstack/apps/api/src/core/services.ts +43 -0
  60. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  61. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  62. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  63. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  64. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  65. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  66. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  67. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  68. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  69. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  70. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  71. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  72. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  73. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  74. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  75. package/dist/templates/fullstack/apps/web/package.json +1 -1
  76. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  77. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  78. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  79. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  80. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  81. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +370 -36
  82. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +250 -41
  83. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  84. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +23 -0
  85. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  86. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  87. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  88. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  89. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  90. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  91. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  92. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  93. package/dist/templates/fullstack/dot-env.docker.example +7 -2
  94. package/dist/templates/fullstack/dot-env.example +21 -1
  95. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  96. package/dist/templates/fullstack/infra/k3s/configmap.yaml +4 -0
  97. package/dist/templates/fullstack/tests/README.md +40 -1
  98. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  99. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  100. package/dist/templates/fullstack/tests/package.json +2 -2
  101. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  102. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  103. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  104. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  106. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  107. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  108. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  109. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +73 -39
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  124. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  125. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  126. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  127. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  128. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  129. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  130. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  131. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  133. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  134. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  135. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  136. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  137. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  138. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  139. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  140. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  141. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  143. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  144. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  145. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  146. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  147. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  148. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  149. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +29 -1
  150. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  151. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  152. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +55 -0
  153. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  154. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  155. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  156. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  157. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +62 -59
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  164. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  165. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  166. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  167. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  168. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  169. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  170. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  171. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  172. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  173. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  174. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  175. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  176. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  177. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  178. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  179. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  180. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +33 -7
  181. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  182. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  183. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  184. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  185. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  186. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  187. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +33 -0
  188. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +20 -1
  189. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  190. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  191. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  192. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  193. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  194. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +6 -1
  195. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  196. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  197. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  198. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -8
  199. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  200. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  201. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  202. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  203. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  204. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  205. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  206. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  207. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  208. package/dist/templates/modules/auth/module.manifest.json +8 -2
  209. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  210. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  211. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  212. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  213. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  214. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  215. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  216. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  217. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  218. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  219. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  220. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  221. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  222. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  223. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  224. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  225. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  226. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  227. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  228. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  229. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  230. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  231. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  232. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  233. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  234. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +63 -27
  235. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  236. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  237. package/dist/templates/todo/AGENTS.md +46 -6
  238. package/dist/templates/todo/README.md +6 -4
  239. package/dist/text-diff.d.ts +12 -0
  240. package/dist/text-diff.js +111 -0
  241. package/dist/update.d.ts +61 -3
  242. package/dist/update.js +152 -27
  243. package/package.json +2 -2
  244. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  245. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: podokit-update
3
- description: Use when the user wants to update a Bun-based project to a newer PodoKit v1 version, pull in template/module improvements, check local edits, or take ownership of a managed file. Covers podo status/diff/doctor/update/eject.
3
+ description: Use when the user wants to update a Bun-based project to a newer PodoKit v1 version, pull in template/module improvements, check local edits, or take ownership of a managed file. Covers podo status/diff/doctor/update/eject/restore.
4
4
  ---
5
5
 
6
6
  # Keep a PodoKit project up to date
@@ -11,15 +11,18 @@ its own files while keeping the user's changes.
11
11
  ```bash
12
12
  podo status # version, modules, file tiers, and how many managed files were edited
13
13
  podo diff # list the PodoKit-managed files you've edited (owned files ignored)
14
+ podo diff <path> # line diff of one file against the template (owned files too)
14
15
  podo doctor # check framework versions vs. the supported ranges
15
16
  podo update # dry-run: preview what a newer version would change (writes nothing)
16
17
  podo update --apply # apply it — clean updates are written; your edits are 3-way merged
17
18
  podo eject <path> # take ownership of a managed file so update skips it
19
+ podo restore <path> # rewrite a managed file from the template; re-manages an ejected file
18
20
  ```
19
21
 
20
22
  Rules to respect:
21
23
  - **owned** files (routes, your components, `AGENTS.md`, shadcn `ui/**`) are never
22
- touched by `update`.
24
+ touched by `update`. When their template changes, the plan lists them as
25
+ `review`; compare with `podo diff <path>` and copy what the app needs.
23
26
  - **assembled** files are recomputed from the module set — keep edits **outside**
24
27
  the `// podokit:begin…end` fences.
25
28
  - Run `podo update` (dry-run) first and read the plan before `--apply`. Commit or
@@ -12,15 +12,20 @@ POSTGRES_USER=podokit
12
12
  POSTGRES_PASSWORD=podokit
13
13
  POSTGRES_DB=podokit
14
14
  BACKEND_INTERNAL_URL=http://api:5002
15
+ # API: the web container and the gateway are the one proxy in front of it.
16
+ TRUSTED_PROXY_HOPS=1
15
17
  # Web: milliseconds per server-side API call, and the /api proxy's wait for
16
18
  # response headers.
17
19
  # BACKEND_TIMEOUT_MS=2000
18
20
  # BACKEND_PROXY_TIMEOUT_MS=30000
19
- CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
21
+ # podo dev sets CORS_ORIGIN and BETTER_AUTH_URL for web and api from
22
+ # .podokit/dev.json. These two serve compose.dev.yaml run without podo dev,
23
+ # such as from the devcontainer.
24
+ CORS_ORIGIN={{devCorsOrigin}}
20
25
  REDIS_HOST=redis
21
26
  REDIS_PORT=6379
22
27
  BETTER_AUTH_SECRET=
23
- BETTER_AUTH_URL=http://{{projectName}}.localhost
28
+ BETTER_AUTH_URL={{devAuthUrl}}
24
29
  ADMIN_EMAILS=admin@example.com
25
30
  AUDIT_LOG_ENABLED=true
26
31
  RATE_LIMIT_TTL=60
@@ -3,9 +3,13 @@
3
3
  # ── Required (runtime) ───────────────────────────────────────────────
4
4
  NODE_ENV=development
5
5
  PORT=5002
6
+ # Address the API listens on (default 0.0.0.0); 127.0.0.1 keeps it off the network.
7
+ # HOST=0.0.0.0
6
8
 
7
9
  # Canonical database connection. Set this for PostgreSQL or SQLite; POSTGRES_* remains a fallback.
8
10
  # DATABASE_URL=postgres://podokit:podokit@localhost:5432/podokit
11
+ # With SQLite and no DATABASE_URL, the database file, relative to apps/api.
12
+ # SQLITE_PATH=./data/podokit.sqlite
9
13
  POSTGRES_HOST=localhost
10
14
  POSTGRES_PORT=5432
11
15
  POSTGRES_USER=podokit
@@ -27,6 +31,18 @@ POSTGRES_DB=podokit
27
31
  # Largest API request body in bytes (Bun's default is 128 MiB). Requests through
28
32
  # the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
29
33
  # API_MAX_REQUEST_BODY_SIZE=134217728
34
+ # Seconds the API keeps a silent connection open (0 to 255, 0 turns it off). Keep
35
+ # it, like CONNECTION_IDLE_TIMEOUT below, more than 4 above the longest /api proxy
36
+ # deadline in seconds.
37
+ # API_IDLE_TIMEOUT=35
38
+
39
+ # Reverse proxies in front of the API whose X-Forwarded-For names the caller. The
40
+ # web app's /api proxy is one, so 1 records the browser's address for sessions,
41
+ # audit entries, and rate limits; 0 uses the socket peer. Count only proxies that
42
+ # every request passes: with 1, a caller that reaches the API directly chooses
43
+ # its own recorded address.
44
+ TRUSTED_PROXY_HOPS=1
45
+ # TRUSTED_PROXY_HEADER=x-forwarded-for
30
46
 
31
47
  # Readiness (GET /health/ready): the longest one dependency check may run, and how
32
48
  # long a result is reused across probes (0 checks on every request).
@@ -39,13 +55,17 @@ BACKEND_INTERNAL_URL=http://localhost:5002
39
55
  # waits for response headers (504 BACKEND_TIMEOUT after the second value). When
40
56
  # the API's GET /health does not answer within BACKEND_TIMEOUT_MS either, API
41
57
  # calls fail at once until it does. Raise BACKEND_PROXY_TIMEOUT_MS together with
42
- # the API's apiServerOptions.idleTimeout.
58
+ # API_IDLE_TIMEOUT above and CONNECTION_IDLE_TIMEOUT below.
43
59
  # BACKEND_TIMEOUT_MS=2000
44
60
  # BACKEND_PROXY_TIMEOUT_MS=30000
45
61
 
46
62
  CORS_ORIGIN=http://localhost:5001
47
63
  # Web request limit; leave room for a 2 MiB multipart upload
48
64
  BODY_SIZE_LIMIT=3M
65
+ # Seconds the built web server keeps a silent connection open (0 to 255, 0 turns
66
+ # it off). Keep it more than 4 above the longest /api proxy deadline in seconds,
67
+ # BACKEND_PROXY_TIMEOUT_MS or APP_PROXY_TIMEOUTS; the proxy refuses to load otherwise.
68
+ CONNECTION_IDLE_TIMEOUT=35
49
69
 
50
70
  # ── Optional / development ───────────────────────────────────────────
51
71
  # Redis — only needed by cache/queue modules (redis, bullmq, rate-limit,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "hostname": "{{projectName}}.localhost",
3
+ "hostname": "{{devHostname}}",
4
4
  "webSocketPaths": [],
5
5
  "apiPaths": []
6
6
  }
@@ -9,7 +9,11 @@ data:
9
9
  CORS_ORIGIN: "https://example.com"
10
10
  BACKEND_INTERNAL_URL: "http://api:3000"
11
11
  BODY_SIZE_LIMIT: "3M"
12
+ CONNECTION_IDLE_TIMEOUT: "35"
13
+ API_IDLE_TIMEOUT: "35"
14
+ TRUSTED_PROXY_HOPS: "1"
12
15
  PROTOCOL_HEADER: "x-forwarded-proto"
13
16
  HOST_HEADER: "x-forwarded-host"
14
17
  ADDRESS_HEADER: "x-forwarded-for"
15
18
  XFF_DEPTH: "1"
19
+ # podokit:end:runtime-config
@@ -13,9 +13,48 @@ The suite runs against a live stack (web on `E2E_BASE_URL`, proxying `/api/*` to
13
13
  the API). With the recommended containerized loop, start `podo dev watch` and run:
14
14
 
15
15
  ```bash
16
- E2E_BASE_URL=http://{{projectName}}.localhost {{rootRun}} test:e2e
16
+ E2E_BASE_URL=http://{{devHostname}} {{rootRun}} test:e2e
17
17
  ```
18
18
 
19
19
  The default `http://localhost:5001` is for the alternative `{{rootRun}} dev`
20
20
  host-process loop. Files ending in `*.api.spec.ts` run in the request-only `api`
21
21
  project; `*.ui.spec.ts` run in the chromium `ui` project.
22
+
23
+ ## Workers and exclusive tests
24
+
25
+ The suite runs on one worker unless `E2E_WORKERS` says otherwise. For the whole
26
+ suite, more workers finish sooner:
27
+
28
+ ```bash
29
+ E2E_WORKERS=2 {{rootRun}} test:e2e
30
+ ```
31
+
32
+ Set the count with `E2E_WORKERS`, not `--workers`: the global setup refuses more
33
+ than one worker when `E2E_WORKERS` is unset.
34
+
35
+ Every test shares one API, database, mail catcher, and SMS sink. A test that
36
+ changes state other tests rely on takes the `@exclusive` tag:
37
+
38
+ ```ts
39
+ test("maintenance mode holds visitors back", { tag: "@exclusive" }, async ({ page }) => {
40
+ // ...
41
+ });
42
+ ```
43
+
44
+ Tag a test when it changes a server-wide setting (a feature flag, the
45
+ authentication configuration such as SMTP, social providers, or sign-up
46
+ approval, the site settings, or a module's settings) or the profile of a seeded
47
+ account. Each project has an `<name>-exclusive` twin, such as `ui-exclusive`,
48
+ that runs only the tagged tests, one at a time. With more than one worker the
49
+ twins wait for every other test, so an exclusive test never runs alongside
50
+ another one. Never empty the shared mail catcher or SMS sink in a test: send to
51
+ an address or number of the test's own instead.
52
+
53
+ More than one worker suits the whole suite. When a run selects exclusive tests
54
+ with a file name, `--grep`, or `--project`, Playwright also runs every other test
55
+ first, because the twins depend on them, and a test that fails there marks the
56
+ exclusive tests as not run. Use the default single worker for a few files, and
57
+ do not combine `--no-deps` with `E2E_WORKERS` above 1.
58
+
59
+ Application-owned projects in `playwright.projects.cjs` get twins too. A project
60
+ that another project depends on is a setup project and keeps a single copy.
@@ -0,0 +1,18 @@
1
+ import type { AppPaths } from "./paths";
2
+
3
+ /**
4
+ * Where this application's screens differ from the generated ones, for the
5
+ * managed e2e specs. PodoKit creates this file once and never overwrites it.
6
+ * Set only what differs; `paths.ts` documents each entry and its default. For
7
+ * example, an app whose first screen is a signed-in workspace without a
8
+ * heading, and which shows no public footer:
9
+ *
10
+ * ```ts
11
+ * export const appPaths: Partial<AppPaths> = {
12
+ * homeReady: (page) => page.getByTestId("workspace"),
13
+ * indexable: [],
14
+ * footer: null,
15
+ * };
16
+ * ```
17
+ */
18
+ export const appPaths: Partial<AppPaths> = {};
@@ -0,0 +1,33 @@
1
+ import type { Locator, Page } from "@playwright/test";
2
+ import { appPaths } from "./app-paths";
3
+
4
+ /**
5
+ * The application screens managed specs visit. The defaults describe the
6
+ * generated pages; `app-paths.ts`, which the application owns, overrides any
7
+ * of them, so a managed spec never needs editing for an app's own screens.
8
+ */
9
+ export interface AppPaths {
10
+ /** The first screen a visitor opens. */
11
+ home: string;
12
+ /** Visible once the first screen has rendered. */
13
+ homeReady: (page: Page) => Locator;
14
+ /** Public pages search engines may index: they carry no `X-Robots-Tag`. */
15
+ indexable: string[];
16
+ /** A public page that renders the managed `SiteFooter`, or null when none does. */
17
+ footer: string | null;
18
+ /**
19
+ * A page that renders the managed `LanguageSwitch`, `ThemeToggle`, and
20
+ * `AccountMenu` for a signed-in user, or null when none does.
21
+ */
22
+ accountMenu: string | null;
23
+ }
24
+
25
+ const defaults: AppPaths = {
26
+ home: "/",
27
+ homeReady: (page) => page.getByRole("heading", { level: 1 }),
28
+ indexable: ["/"],
29
+ footer: "/login",
30
+ accountMenu: "/",
31
+ };
32
+
33
+ export const paths: AppPaths = { ...defaults, ...appPaths };
@@ -4,8 +4,8 @@
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "test:e2e": "bunx playwright test",
7
- "test:e2e:ui": "bunx playwright test --project=ui",
8
- "test:e2e:api": "bunx playwright test --project=api",
7
+ "test:e2e:ui": "bunx playwright test --project=ui --project=ui-exclusive",
8
+ "test:e2e:api": "bunx playwright test --project=api --project=api-exclusive",
9
9
  "test:e2e:report": "bunx playwright show-report"
10
10
  },
11
11
  "devDependencies": {
@@ -1,11 +1,14 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
- import { loadPlaywrightProjects } from "./playwright.extensions";
2
+ import { e2eWorkers, loadPlaywrightProjects, withExclusivePhase } from "./playwright.extensions";
3
3
 
4
4
  // The suite runs against a live stack: web on E2E_BASE_URL (proxying /api/* to
5
5
  // the API). Start it first (see docs/testing.md) — locally via the dev harness,
6
6
  // in CI via scripts/e2e-ci.mjs. Two projects: `api` (request-only, backend e2e)
7
7
  // and `ui` (chromium, pages). Files are routed by suffix (*.api/*.ui.spec.ts).
8
+ // E2E_WORKERS sets the worker count (default 1); tests tagged @exclusive run in
9
+ // `<project>-exclusive` twins after the others (see playwright.extensions.ts).
8
10
  const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5001";
11
+ const workers = e2eWorkers();
9
12
  const coreProjects = [
10
13
  { name: "api", testMatch: /.*\.api\.spec\.ts/ },
11
14
  { name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } },
@@ -16,12 +19,13 @@ export default defineConfig({
16
19
  fullyParallel: true,
17
20
  forbidOnly: !!process.env.CI,
18
21
  retries: process.env.CI ? 2 : 0,
19
- workers: process.env.CI ? 1 : undefined,
22
+ workers,
23
+ globalSetup: "./playwright.extensions.ts",
20
24
  reporter: [["list"], ["html", { open: "never" }]],
21
25
  use: {
22
26
  baseURL,
23
27
  trace: "on-first-retry",
24
28
  screenshot: "only-on-failure",
25
29
  },
26
- projects: [...coreProjects, ...loadPlaywrightProjects(coreProjects)],
30
+ projects: withExclusivePhase([...coreProjects, ...loadPlaywrightProjects(coreProjects)], workers),
27
31
  });
@@ -1,17 +1,33 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { join } from "node:path";
4
- import type { PlaywrightTestConfig } from "@playwright/test";
4
+ import type { FullConfig, PlaywrightTestConfig } from "@playwright/test";
5
5
 
6
6
  type Projects = NonNullable<PlaywrightTestConfig["projects"]>;
7
+ type Project = Projects[number];
7
8
 
8
9
  const requireFromTests = createRequire(__filename);
9
10
 
11
+ // A test that changes state the other tests rely on, such as a server-wide
12
+ // setting or the profile of a seeded account, takes the `@exclusive` tag:
13
+ //
14
+ // test("maintenance mode holds visitors back", { tag: "@exclusive" }, async ({ page }) => { ... });
15
+ //
16
+ // Playwright matches project grep patterns against the title path followed by
17
+ // the test's tags, separated by spaces.
18
+ const TAGGED = /(?:^|\s)@exclusive(?=\s|$)/;
19
+ const UNTAGGED = /^(?![\s\S]*(?:^|\s)@exclusive(?=\s|$))/;
20
+
10
21
  function projectName(project: unknown): string | null {
11
22
  if (typeof project !== "object" || project === null || !("name" in project)) return null;
12
23
  return typeof project.name === "string" && project.name.length > 0 ? project.name : null;
13
24
  }
14
25
 
26
+ function patterns(value: Project["grepInvert"]): RegExp[] {
27
+ if (value === undefined) return [];
28
+ return Array.isArray(value) ? value : [value];
29
+ }
30
+
15
31
  /**
16
32
  * Merge application-owned Playwright projects into PodoKit's managed config.
17
33
  * The optional CommonJS file stays owned so applications can add devices and
@@ -42,3 +58,74 @@ export function loadPlaywrightProjects(coreProjects: Projects): Projects {
42
58
 
43
59
  return loaded as Projects;
44
60
  }
61
+
62
+ /** The number of Playwright workers: `E2E_WORKERS`, or 1 when it is unset. */
63
+ export function e2eWorkers(value = process.env.E2E_WORKERS): number {
64
+ if (value === undefined || value.trim() === "") return 1;
65
+ const workers = Number(value);
66
+ if (!Number.isInteger(workers) || workers < 1) {
67
+ throw new Error(`E2E_WORKERS must be a whole number of at least 1, got "${value}".`);
68
+ }
69
+ return workers;
70
+ }
71
+
72
+ /**
73
+ * Splits each test project into one that skips tests tagged `@exclusive` and an
74
+ * `<name>-exclusive` twin that runs only them on one worker. Projects that
75
+ * another project depends on, or that tear one down, are setup and cleanup
76
+ * projects and stay as they are.
77
+ *
78
+ * With more than one worker the twins depend on every other test project, and
79
+ * each twin on the one before it, so an exclusive test never runs alongside
80
+ * another test. A dependency runs in full, though: a run that selects only
81
+ * exclusive tests, with a file, `--grep`, or `--project`, then also runs every
82
+ * other test first, and a test that fails there skips the exclusive tests. Use
83
+ * more than one worker for the whole suite. With one worker no dependency is
84
+ * needed, so filtered runs stay small.
85
+ */
86
+ export function withExclusivePhase(projects: Projects, workers: number): Projects {
87
+ const support = new Set(
88
+ projects.flatMap((project) => [...(project.dependencies ?? []), ...(project.teardown ? [project.teardown] : [])]),
89
+ );
90
+ const tested = projects.filter((project) => !support.has(projectName(project) ?? ""));
91
+ const shared = new Map(
92
+ tested.map((project) => [project, { ...project, grepInvert: [...patterns(project.grepInvert), TAGGED] }]),
93
+ );
94
+
95
+ let before = [...shared.values()].map((project) => projectName(project) ?? "");
96
+ const twins = tested.map((project) => {
97
+ const name = `${projectName(project) ?? ""}-exclusive`;
98
+ const twin: Project = {
99
+ ...project,
100
+ name,
101
+ grepInvert: [...patterns(project.grepInvert), UNTAGGED],
102
+ workers: 1,
103
+ dependencies: workers > 1 ? [...(project.dependencies ?? []), ...before] : project.dependencies,
104
+ };
105
+ before = [name];
106
+ return twin;
107
+ });
108
+
109
+ const result = [...projects.map((project) => shared.get(project) ?? project), ...twins];
110
+ const names = new Set<string>();
111
+ for (const project of result) {
112
+ const name = projectName(project) ?? "";
113
+ if (names.has(name)) throw new Error(`Duplicate Playwright project name: ${name}`);
114
+ names.add(name);
115
+ }
116
+ return result;
117
+ }
118
+
119
+ /**
120
+ * Global setup: refuses more workers than `E2E_WORKERS` allows for. `--workers`
121
+ * changes the count after the projects were built, so with it the exclusive
122
+ * twins would run alongside the other tests.
123
+ */
124
+ export default function checkWorkers(config: FullConfig): void {
125
+ if (config.workers > 1 && e2eWorkers() === 1) {
126
+ throw new Error(
127
+ `Set the worker count with E2E_WORKERS=${config.workers} instead of --workers. ` +
128
+ "Tests tagged @exclusive run after the others only when E2E_WORKERS is above 1.",
129
+ );
130
+ }
131
+ }
@@ -1,9 +1,10 @@
1
1
  import { expect, test } from "@playwright/test";
2
+ import { paths } from "../helpers/paths";
2
3
 
3
- // Module-agnostic: the app responds at "/" (a page or a redirect to it).
4
+ // Module-agnostic: the app's first screen responds (a page or a redirect to it)
5
+ // and renders. Its locator comes from tests/helpers/app-paths.ts.
4
6
  test("app is reachable @smoke", async ({ page }) => {
5
- const res = await page.goto("/");
7
+ const res = await page.goto(paths.home);
6
8
  expect(res?.status()).toBeLessThan(400);
7
- await expect(page.locator("main")).toBeVisible();
8
- await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
9
+ await expect(paths.homeReady(page)).toBeVisible();
9
10
  });
@@ -0,0 +1,152 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { AppException } from "@podosoft/podokit-contracts";
4
+ import { Elysia } from "elysia";
5
+ import type { AuthService, AuthSession } from "../auth/auth.service";
6
+ import { AdminSessionsService } from "../admin-sessions/admin-sessions.service";
7
+ import {
8
+ accountSessionRoutes,
9
+ type AccountSessionPage,
10
+ type OwnSessionRevoker,
11
+ } from "./account-sessions.routes";
12
+
13
+ // More sessions than Better Auth's /list-sessions returns.
14
+ const OTHER_SESSIONS = 150;
15
+ const DAY_MS = 86_400_000;
16
+
17
+ // Callers name their session with a test header; its row says whose it is.
18
+ function authFor(db: SQL): Pick<AuthService, "requireSession"> {
19
+ return {
20
+ requireSession: async (request: Request): Promise<AuthSession> => {
21
+ const id = request.headers.get("x-test-session");
22
+ const [row] = id
23
+ ? await db<Array<{ userId: string; createdAt: string }>>`
24
+ SELECT "userId", "createdAt" FROM "session" WHERE "id" = ${id}
25
+ `
26
+ : [];
27
+ if (!row) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
28
+ return { user: { id: row.userId }, session: { id, createdAt: row.createdAt } };
29
+ },
30
+ };
31
+ }
32
+
33
+ describe("account session routes", () => {
34
+ let db: SQL;
35
+ let revoke: Mock<OwnSessionRevoker>;
36
+ let freshAge: number;
37
+ let handle: (request: Request) => Promise<Response>;
38
+ const now = Date.now();
39
+
40
+ beforeEach(async () => {
41
+ db = new SQL(":memory:");
42
+ await db`CREATE TABLE "user" ("id" TEXT PRIMARY KEY, "email" TEXT NOT NULL, "name" TEXT NOT NULL)`;
43
+ await db`
44
+ CREATE TABLE "session" (
45
+ "id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "token" TEXT NOT NULL,
46
+ "ipAddress" TEXT, "userAgent" TEXT, "impersonatedBy" TEXT,
47
+ "createdAt" TEXT NOT NULL, "expiresAt" TEXT NOT NULL
48
+ )
49
+ `;
50
+ await db`INSERT INTO "user" VALUES ('user-1', 'one@example.com', 'One'), ('user-2', 'two@example.com', 'Two')`;
51
+ const expires = new Date(now + DAY_MS).toISOString();
52
+ // The current session is the oldest, so newest-first paging would put it last.
53
+ await db`
54
+ INSERT INTO "session" VALUES ('current', 'user-1', 'secret-current', '203.0.113.7', 'Agent',
55
+ NULL, ${new Date(now - 60_000).toISOString()}, ${expires})
56
+ `;
57
+ for (let index = 0; index < OTHER_SESSIONS; index += 1) {
58
+ await db`
59
+ INSERT INTO "session" VALUES (${`other-${index}`}, 'user-1', ${`secret-${index}`}, NULL, NULL, NULL,
60
+ ${new Date(now - 30_000 + index).toISOString()}, ${expires})
61
+ `;
62
+ }
63
+ await db`
64
+ INSERT INTO "session" VALUES ('foreign', 'user-2', 'secret-foreign', NULL, NULL, NULL,
65
+ ${new Date(now).toISOString()}, ${expires})
66
+ `;
67
+ revoke = mock<OwnSessionRevoker>(async () => undefined);
68
+ freshAge = 86_400;
69
+ const app = new Elysia()
70
+ .onError(({ code, error, set }) => {
71
+ if (error instanceof AppException) {
72
+ set.status = error.statusCode;
73
+ return { code: error.code };
74
+ }
75
+ if (code === "VALIDATION") {
76
+ set.status = 400;
77
+ return { code: "VALIDATION_ERROR" };
78
+ }
79
+ return undefined;
80
+ })
81
+ .use(accountSessionRoutes({
82
+ auth: authFor(db),
83
+ sessions: new AdminSessionsService(db, "sqlite"),
84
+ revoke,
85
+ freshAgeSeconds: async () => freshAge,
86
+ }));
87
+ handle = (request) => app.handle(request);
88
+ });
89
+
90
+ afterEach(async () => {
91
+ await db.close();
92
+ });
93
+
94
+ async function send(path: string, init: RequestInit = {}, session: string | null = "current"): Promise<Response> {
95
+ const headers = new Headers(init.headers);
96
+ if (session) headers.set("x-test-session", session);
97
+ return handle(new Request(`http://localhost${path}`, { ...init, headers }));
98
+ }
99
+
100
+ test("returns the current session on every page and pages through every other one", async () => {
101
+ const seen: string[] = [];
102
+ let cursor: string | null = null;
103
+ let pages = 0;
104
+ do {
105
+ const response = await send(`/account/sessions?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
106
+ expect(response.status).toBe(200);
107
+ const text = await response.text();
108
+ expect(text).not.toContain("secret-");
109
+ const page = JSON.parse(text) as AccountSessionPage;
110
+ expect(page.current).toMatchObject({ id: "current", ipAddress: "203.0.113.7", userAgent: "Agent" });
111
+ expect(page.total).toBe(OTHER_SESSIONS + 1);
112
+ seen.push(...page.items.map((item) => item.id));
113
+ cursor = page.nextCursor;
114
+ pages += 1;
115
+ } while (cursor);
116
+
117
+ expect(pages).toBe(2);
118
+ expect(seen).toHaveLength(OTHER_SESSIONS);
119
+ expect(new Set(seen).size).toBe(OTHER_SESSIONS);
120
+ expect(seen).not.toContain("current");
121
+ expect(seen).not.toContain("foreign");
122
+ expect(seen[0]).toBe(`other-${OTHER_SESSIONS - 1}`);
123
+ });
124
+
125
+ test("refuses anonymous callers and a session too old to list sessions", async () => {
126
+ expect((await send("/account/sessions", {}, null)).status).toBe(401);
127
+ await db`UPDATE "session" SET "createdAt" = ${new Date(now - 2 * DAY_MS).toISOString()} WHERE "id" = 'current'`;
128
+ const stale = await send("/account/sessions");
129
+ expect(stale.status).toBe(403);
130
+ expect(await stale.json()).toEqual({ code: "SESSION_NOT_FRESH" });
131
+ freshAge = 0;
132
+ expect((await send("/account/sessions")).status).toBe(200);
133
+ });
134
+
135
+ test("revokes one of the caller's sessions by id with the token looked up on the server", async () => {
136
+ const response = await send("/account/sessions/other-3", { method: "DELETE", headers: { cookie: "session=1" } });
137
+
138
+ expect(response.status).toBe(204);
139
+ expect(revoke).toHaveBeenCalledTimes(1);
140
+ const [token, headers] = revoke.mock.calls[0] ?? [];
141
+ expect(token).toBe("secret-3");
142
+ expect(headers?.get("cookie")).toBe("session=1");
143
+ });
144
+
145
+ test("does not reveal or revoke another user's session", async () => {
146
+ const response = await send("/account/sessions/foreign", { method: "DELETE" });
147
+
148
+ expect(response.status).toBe(404);
149
+ expect(await response.json()).toEqual({ code: "ACCOUNT_SESSION_NOT_FOUND" });
150
+ expect(revoke).not.toHaveBeenCalled();
151
+ });
152
+ });
@@ -0,0 +1,112 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { Elysia, t } from "elysia";
3
+ import type { AuthService, AuthSession } from "../auth/auth.service";
4
+ import type { ElysiaPlugin } from "../core/services";
5
+ import {
6
+ ADMIN_SESSIONS_MAX_LIMIT,
7
+ type AdminSessionsService,
8
+ type AdminSessionView,
9
+ } from "../admin-sessions/admin-sessions.service";
10
+
11
+ export const ACCOUNT_SESSION_NOT_FOUND = "ACCOUNT_SESSION_NOT_FOUND";
12
+ /** Better Auth's code for a session older than `session.freshAge`. */
13
+ export const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
14
+
15
+ /** One of the caller's own sessions. The bearer token never leaves the server. */
16
+ export interface AccountSessionView {
17
+ id: string;
18
+ ipAddress: string | null;
19
+ userAgent: string | null;
20
+ createdAt: string;
21
+ expiresAt: string;
22
+ }
23
+
24
+ export interface AccountSessionPage {
25
+ /** The session making the request, on every page. */
26
+ current: AccountSessionView | null;
27
+ /** The caller's other active sessions, newest first. */
28
+ items: AccountSessionView[];
29
+ nextCursor: string | null;
30
+ /** Every active session of the caller, the current one included. */
31
+ total: number;
32
+ }
33
+
34
+ /** Revokes one of the caller's sessions by its bearer token. */
35
+ export type OwnSessionRevoker = (sessionToken: string, headers: Headers) => Promise<void>;
36
+
37
+ export interface AccountSessionRoutesOptions {
38
+ auth: Pick<AuthService, "requireSession">;
39
+ sessions: AdminSessionsService;
40
+ revoke: OwnSessionRevoker;
41
+ /** Better Auth's `session.freshAge` in seconds; 0 turns the check off. */
42
+ freshAgeSeconds: () => Promise<number>;
43
+ }
44
+
45
+ function accountView(session: AdminSessionView): AccountSessionView {
46
+ return {
47
+ id: session.id,
48
+ ipAddress: session.ipAddress,
49
+ userAgent: session.userAgent,
50
+ createdAt: session.createdAt,
51
+ expiresAt: session.expiresAt,
52
+ };
53
+ }
54
+
55
+ // Better Auth lists sessions only for a fresh session; this list keeps that rule.
56
+ function requireFresh(session: AuthSession, freshAgeSeconds: number): void {
57
+ if (freshAgeSeconds === 0) return;
58
+ const createdAt = new Date(session.session.createdAt as string | Date).getTime();
59
+ if (Date.now() - createdAt >= freshAgeSeconds * 1000) {
60
+ throw new AppException(SESSION_NOT_FRESH, "Session is not fresh", 403);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The signed-in user's own sessions, paged on the server. Better Auth's
66
+ * `/list-sessions` returns at most 100 sessions, which could leave out the
67
+ * current one.
68
+ */
69
+ export function accountSessionRoutes({
70
+ auth,
71
+ sessions,
72
+ revoke,
73
+ freshAgeSeconds,
74
+ }: AccountSessionRoutesOptions): ElysiaPlugin {
75
+ return new Elysia({ name: "podokit.account.sessions" })
76
+ .get("/account/sessions", async ({ request, query }) => {
77
+ const session = await auth.requireSession(request);
78
+ requireFresh(session, await freshAgeSeconds());
79
+ const currentId = String(session.session.id);
80
+ const [page, current] = await Promise.all([
81
+ sessions.list({ ...query, userId: session.user.id, excludeId: currentId }),
82
+ sessions.find(currentId),
83
+ ]);
84
+ const result: AccountSessionPage = {
85
+ current: current ? accountView(current) : null,
86
+ items: page.items.map(accountView),
87
+ nextCursor: page.nextCursor,
88
+ total: page.total + (current ? 1 : 0),
89
+ };
90
+ return result;
91
+ }, {
92
+ query: t.Object({
93
+ cursor: t.Optional(t.String({ minLength: 1, maxLength: 512 })),
94
+ limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_SESSIONS_MAX_LIMIT })),
95
+ }),
96
+ detail: {
97
+ tags: ["account"],
98
+ summary: "List the current user's active sessions",
99
+ description: "`current` is the session making the request; `items` are the other sessions, newest first, keyset-paged with `cursor`. Session tokens are never returned. A session older than Better Auth's `session.freshAge` gets 403 `SESSION_NOT_FRESH`, as with `/api/auth/list-sessions`.",
100
+ },
101
+ })
102
+ .delete("/account/sessions/:id", async ({ request, params, set }) => {
103
+ const session = await auth.requireSession(request);
104
+ const token = await sessions.tokenOf(params.id, session.user.id);
105
+ if (!token) throw new AppException(ACCOUNT_SESSION_NOT_FOUND, "Session not found.", 404);
106
+ await revoke(token, request.headers);
107
+ set.status = 204;
108
+ }, {
109
+ params: t.Object({ id: t.String({ minLength: 1, maxLength: 255 }) }),
110
+ detail: { tags: ["account"], summary: "Revoke one of the current user's sessions by id" },
111
+ });
112
+ }