@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
@@ -11,8 +11,6 @@ const config = {
11
11
  authTtlSeconds: 60,
12
12
  authLimit: 20,
13
13
  runtimeLimit: 1000,
14
- trustedProxyHops: 0,
15
- proxyHeader: "x-forwarded-for",
16
14
  storageTimeoutMs: 100,
17
15
  unavailableRetryAfterSeconds: 1,
18
16
  };
@@ -25,7 +23,7 @@ function context(path: string, method = "GET"): { value: RequestGuardContext; he
25
23
  headers,
26
24
  value: {
27
25
  request: new Request(`http://localhost${path}`, { method }),
28
- remoteAddress: "127.0.0.1",
26
+ clientAddress: "127.0.0.1",
29
27
  setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
30
28
  },
31
29
  };
@@ -50,11 +48,49 @@ describe("RateLimitRoutes", () => {
50
48
  expect(routes.resolve("GET", "/other")).toBeUndefined();
51
49
  });
52
50
 
51
+ test("matches a :name parameter against exactly one non-empty segment", () => {
52
+ const routes = new RateLimitRoutes(60);
53
+ const transfers = { name: "file-transfers", limit: 600 };
54
+ routes.register({ pathPrefix: "/hosts/:hostId/file-transfers", profile: transfers });
55
+
56
+ expect(routes.resolve("PUT", "/hosts/h1/file-transfers/t1/chunks/3")).toMatchObject(transfers);
57
+ expect(routes.resolve("GET", "/hosts/h1/file-transfers")).toMatchObject(transfers);
58
+ expect(routes.resolve("GET", "/hosts/h1/file-transfers/")).toMatchObject(transfers);
59
+ expect(routes.resolve("GET", "/hosts/h1/file-transfers-old")).toBeUndefined();
60
+ expect(routes.resolve("GET", "/hosts/file-transfers")).toBeUndefined();
61
+ expect(routes.resolve("GET", "/hosts//file-transfers")).toBeUndefined();
62
+ expect(routes.resolve("GET", "/hosts/h1/h2/file-transfers")).toBeUndefined();
63
+ });
64
+
65
+ test("prefers more segments, then a literal segment over a parameter from the left", () => {
66
+ const routes = new RateLimitRoutes(60);
67
+ routes.register({ pathPrefix: "/hosts/:hostId/:section/:id", profile: { name: "deep", limit: 1 } });
68
+ routes.register({ pathPrefix: "/hosts/:hostId/files", profile: { name: "files", limit: 2 } });
69
+ routes.register({ pathPrefix: "/hosts/local/:section", profile: { name: "local", limit: 3 } });
70
+ routes.register({ pathPrefix: "/hosts", profile: { name: "hosts", limit: 4 } });
71
+
72
+ expect(routes.resolve("GET", "/hosts/local/files/1")).toMatchObject({ name: "deep" });
73
+ expect(routes.resolve("GET", "/hosts/local/files")).toMatchObject({ name: "local" });
74
+ expect(routes.resolve("GET", "/hosts/h2/files")).toMatchObject({ name: "files" });
75
+ expect(routes.resolve("GET", "/hosts/h2/logs")).toMatchObject({ name: "hosts" });
76
+
77
+ // Equal specificity falls back to the method, then the latest registration.
78
+ routes.register({ method: "GET", pathPrefix: "/hosts/:a/logs", profile: "exempt" });
79
+ routes.register({ pathPrefix: "/hosts/:b/logs", profile: { name: "logs", limit: 5 } });
80
+ expect(routes.resolve("GET", "/hosts/h2/logs")).toBe("exempt");
81
+ expect(routes.resolve("POST", "/hosts/h2/logs")).toMatchObject({ name: "logs" });
82
+ });
83
+
53
84
  test("rejects invalid routes and conflicting budgets under one name", () => {
54
85
  const routes = new RateLimitRoutes(60);
55
86
  expect(() => routes.register({ pathPrefix: "uploads", profile: "exempt" })).toThrow("absolute path");
56
87
  expect(() => routes.register({ pathPrefix: "/a?b", profile: "exempt" })).toThrow("absolute path");
57
88
  expect(() => routes.register({ pathPrefix: "/a//b", profile: "exempt" })).toThrow("absolute path");
89
+ expect(() => routes.register({ pathPrefix: "/a/../b", profile: "exempt" })).toThrow('".." segments');
90
+ expect(() => routes.register({ pathPrefix: "/a/*", profile: "exempt" })).toThrow("not wildcards");
91
+ for (const parameter of [":", ":1st", ":host-id", ":a:b"]) {
92
+ expect(() => routes.register({ pathPrefix: `/hosts/${parameter}/x`, profile: "exempt" })).toThrow("parameter");
93
+ }
58
94
  expect(() => routes.register({ method: "GET /", pathPrefix: "/a", profile: "exempt" })).toThrow("method");
59
95
  expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a:b", limit: 1 } })).toThrow("name");
60
96
  expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a", limit: 0 } })).toThrow("limit");
@@ -83,6 +119,28 @@ describe("RateLimitRoutes", () => {
83
119
  });
84
120
 
85
121
  describe("RateLimiter route profiles", () => {
122
+ test("advertises the built-in budget of each default route", async () => {
123
+ const limiter = new RateLimiter(
124
+ config,
125
+ { resolve: async () => "session:test" } as unknown as RateLimitIdentity,
126
+ { increment: async () => ({ count: 1, retryAfterSeconds: 60 }) },
127
+ );
128
+ const ceiling = async (path: string, method = "GET"): Promise<string | undefined> => {
129
+ const request = context(path, method);
130
+ await limiter.enforce(request.value);
131
+ return request.headers["ratelimit-limit"];
132
+ };
133
+
134
+ expect(await ceiling("/api/auth/get-session")).toBe("1000");
135
+ expect(await ceiling("/site/settings")).toBe("1000");
136
+ expect(await ceiling("/api/auth/admin/list-users")).toBe("300");
137
+ expect(await ceiling("/api/auth/admin/ban-user", "POST")).toBe("20");
138
+ expect(await ceiling("/api/auth/sign-out", "POST")).toBe("20");
139
+ expect(await ceiling("/api/auth/sign-in/email", "POST")).toBe("20");
140
+ expect(await ceiling("/site/settings", "PUT")).toBe("300");
141
+ expect(await ceiling("/account/capabilities")).toBe("300");
142
+ });
143
+
86
144
  test("counts a registered route in its own bucket and skips exempt routes", async () => {
87
145
  const increment = mock(async (_key: string, _ttlSeconds: number) => ({ count: 1, retryAfterSeconds: 30 }));
88
146
  const limiter = new RateLimiter(
@@ -8,14 +8,18 @@ import type { RateLimitConfig } from "./rate-limit.config";
8
8
  /** A budget with its window resolved, or "exempt". */
9
9
  export type ResolvedRateLimitProfile = Required<RateLimitProfile> | "exempt";
10
10
 
11
+ /** A literal path segment, or null for a `:name` parameter. */
12
+ type Segment = string | null;
13
+
11
14
  interface RouteEntry {
12
15
  method?: string;
13
- prefix: string;
16
+ segments: Segment[];
14
17
  profile: ResolvedRateLimitProfile;
15
18
  }
16
19
 
17
20
  const PROFILE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
18
21
  const METHOD = /^[A-Z]+$/;
22
+ const PARAMETER = /^:[A-Za-z_][A-Za-z0-9_]*$/;
19
23
 
20
24
  function positiveInteger(value: number | undefined, label: string): void {
21
25
  if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
@@ -23,15 +27,47 @@ function positiveInteger(value: number | undefined, label: string): void {
23
27
  }
24
28
  }
25
29
 
26
- function normalizePrefix(prefix: string): string {
30
+ function parsePrefix(prefix: string): Segment[] {
27
31
  if (!prefix.startsWith("/") || /[?#\s]/.test(prefix) || prefix.includes("//")) {
28
32
  throw new Error(`Rate limit path prefix must be an absolute path: ${prefix}`);
29
33
  }
30
- return prefix.length > 1 && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
34
+ if (prefix.includes("*")) {
35
+ throw new Error(`Rate limit path prefix takes ":name" parameters, not wildcards: ${prefix}`);
36
+ }
37
+ const base = prefix.length > 1 && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
38
+ return base === "/"
39
+ ? []
40
+ : base.slice(1).split("/").map((segment) => {
41
+ if (segment === "." || segment === "..") {
42
+ throw new Error(`Rate limit path prefix must not contain "." or ".." segments: ${prefix}`);
43
+ }
44
+ if (!segment.startsWith(":")) return segment;
45
+ if (!PARAMETER.test(segment)) {
46
+ throw new Error(`Rate limit path parameter must be ":" and a name such as ":hostId": ${prefix}`);
47
+ }
48
+ return null;
49
+ });
50
+ }
51
+
52
+ /** Whole-segment prefix match; a parameter matches one non-empty segment. */
53
+ function covers(segments: Segment[], path: string[]): boolean {
54
+ return (
55
+ path.length >= segments.length &&
56
+ segments.every((segment, index) => (segment === null ? path[index] !== "" : segment === path[index]))
57
+ );
31
58
  }
32
59
 
33
- function covers(prefix: string, path: string): boolean {
34
- return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
60
+ /**
61
+ * Below zero when `a` is more specific: more segments, then, from the left, a
62
+ * literal segment before a parameter.
63
+ */
64
+ function compareSpecificity(a: Segment[], b: Segment[]): number {
65
+ if (a.length !== b.length) return b.length - a.length;
66
+ for (let index = 0; index < a.length; index += 1) {
67
+ const literalA = a[index] !== null;
68
+ if (literalA !== (b[index] !== null)) return literalA ? -1 : 1;
69
+ }
70
+ return 0;
35
71
  }
36
72
 
37
73
  /**
@@ -50,24 +86,21 @@ export class RateLimitRoutes implements RateLimitRouteRegistry {
50
86
  if (method !== undefined && !METHOD.test(method)) {
51
87
  throw new Error(`Rate limit route method is invalid: ${route.method}`);
52
88
  }
53
- const prefix = normalizePrefix(route.pathPrefix);
89
+ const segments = parsePrefix(route.pathPrefix);
54
90
  const profile = route.profile === "exempt" ? "exempt" : this.budget(route.profile);
55
- this.entries.push({ ...(method ? { method } : {}), prefix, profile });
91
+ this.entries.push({ ...(method ? { method } : {}), segments, profile });
56
92
  }
57
93
 
58
94
  /** The most specific registered profile for a request, if any route matches. */
59
95
  resolve(method: string, path: string): ResolvedRateLimitProfile | undefined {
96
+ const pathSegments = path.split("/").slice(1);
60
97
  let best: RouteEntry | undefined;
61
98
  for (const entry of this.entries) {
62
99
  if (entry.method !== undefined && entry.method !== method) continue;
63
- if (!covers(entry.prefix, path)) continue;
64
- // The longer prefix wins, then a route with a method, then the later one.
65
- if (
66
- !best ||
67
- entry.prefix.length > best.prefix.length ||
68
- (entry.prefix.length === best.prefix.length &&
69
- (entry.method !== undefined || best.method === undefined))
70
- ) {
100
+ if (!covers(entry.segments, pathSegments)) continue;
101
+ // The more specific prefix wins, then a route with a method, then the later one.
102
+ const order = best ? compareSpecificity(entry.segments, best.segments) : -1;
103
+ if (order < 0 || (order === 0 && (entry.method !== undefined || best?.method === undefined))) {
71
104
  best = entry;
72
105
  }
73
106
  }
@@ -1,4 +1,10 @@
1
- import { expect, test, type APIResponse } from "@playwright/test";
1
+ import {
2
+ expect,
3
+ test,
4
+ type APIRequestContext,
5
+ type APIResponse,
6
+ type PlaywrightWorkerArgs,
7
+ } from "@playwright/test";
2
8
 
3
9
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
10
  const origin = { origin: base };
@@ -19,20 +25,42 @@ const limit = Number(process.env.RATE_LIMIT_MAX ?? 300);
19
25
  const authLimit = Number(process.env.RATE_LIMIT_AUTH_MAX ?? 20);
20
26
  const runtimeLimit = Number(process.env.RATE_LIMIT_RUNTIME_MAX ?? 1000);
21
27
 
22
- async function session(
23
- playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"],
24
- ) {
25
- const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
26
- const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
27
- const signup = await ctx.post("/api/auth/sign-up/email", {
28
- data: {
29
- email,
30
- password: "Podokit3e-Str0ng!pw",
31
- name: "Rate Limit",
32
- },
28
+ type Playwright = PlaywrightWorkerArgs["playwright"];
29
+ const password = "Podokit3e-Str0ng!pw";
30
+
31
+ function uniqueEmail(): string {
32
+ return `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
33
+ }
34
+
35
+ // Sign-ups and sign-ins are authentication writes. From an anonymous client the
36
+ // limiter counts them against the per-address budget that the setup project's
37
+ // and every other spec's sign-ins share. Only this worker's first one is
38
+ // anonymous: the later ones carry its session cookie, so the limiter counts them
39
+ // against that session instead.
40
+ let carrier: string | undefined;
41
+
42
+ /** Run one authentication write and return a context holding the session it set. */
43
+ async function authenticated(
44
+ playwright: Playwright,
45
+ path: "/api/auth/sign-up/email" | "/api/auth/sign-in/email",
46
+ data: Record<string, string>,
47
+ ): Promise<APIRequestContext> {
48
+ const writer = await playwright.request.newContext({
49
+ baseURL: base,
50
+ extraHTTPHeaders: { ...origin, ...(carrier ? { cookie: carrier } : {}) },
33
51
  });
34
- expect(signup.ok()).toBeTruthy();
35
- return ctx;
52
+ const response = await writer.post(path, { data });
53
+ expect(response.ok(), `${path}: ${response.status()}`).toBeTruthy();
54
+ const storageState = await writer.storageState();
55
+ await writer.dispose();
56
+ const token = storageState.cookies.find((cookie) => cookie.name.endsWith("session_token"));
57
+ carrier ??= token ? `${token.name}=${token.value}` : undefined;
58
+ return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin, storageState });
59
+ }
60
+
61
+ /** A new account, signed in. */
62
+ async function session(playwright: Playwright, email = uniqueEmail()): Promise<APIRequestContext> {
63
+ return authenticated(playwright, "/api/auth/sign-up/email", { email, password, name: "Rate Limit" });
36
64
  }
37
65
 
38
66
  test("rate limit: health probes stay available while ordinary routes return 429 @smoke", async ({
@@ -70,32 +98,39 @@ test("rate limit: health probes stay available while ordinary routes return 429
70
98
  await ctx.dispose();
71
99
  });
72
100
 
73
- test("rate limit: each signed session has its own counter and forged sessions do not", async ({
101
+ test("rate limit: a user's sessions share one counter and forged sessions do not get one", async ({
74
102
  playwright,
75
103
  }) => {
76
- const email = `rate-limit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
77
- const password = "Podokit3e-Str0ng!pw";
78
- const first = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
79
- expect((await first.post("/api/auth/sign-up/email", { data: { email, password, name: "Rate Limit" } })).ok()).toBeTruthy();
104
+ const email = uniqueEmail();
105
+ const first = await session(playwright, email);
80
106
  const probe = await first.get("/api/cache/rate-limit-probe");
81
107
  test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
82
108
  const remaining = (response: APIResponse): number => Number(response.headers()["ratelimit-remaining"]);
83
109
  const ceiling = Number(probe.headers()["ratelimit-limit"]);
84
- expect(remaining(probe)).toBe(ceiling - 1);
85
110
 
86
- // The signed bearer form of the same session shares its counter.
111
+ // A second sign-in of the same user, and the signed bearer form of the first
112
+ // session. The probe requires a session, so each request below resolves it; a
113
+ // session counts under its user from the request after the API first resolves
114
+ // it (or from that request when authentication runs before the limiter).
115
+ const second = await authenticated(playwright, "/api/auth/sign-in/email", { email, password });
87
116
  const cookie = (await first.storageState()).cookies.find((c) => c.name.endsWith("session_token"));
88
117
  expect(cookie, "sign-up set a session cookie").toBeTruthy();
89
118
  const bearer = await playwright.request.newContext({
90
119
  baseURL: base,
91
120
  extraHTTPHeaders: { ...origin, authorization: `Bearer ${cookie?.value ?? ""}` },
92
121
  });
93
- expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 2);
122
+ await second.get("/api/cache/rate-limit-probe");
123
+
124
+ // Both sessions now draw from one budget: each request lowers what the next
125
+ // one, from either session, has left.
126
+ const fromFirst = remaining(await first.get("/api/cache/rate-limit-probe"));
127
+ expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 1);
128
+ expect(remaining(await bearer.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 2);
129
+ expect(remaining(await first.get("/api/cache/rate-limit-probe"))).toBe(fromFirst - 3);
94
130
 
95
- // Counters follow the session, not the user: signing in again starts a new one.
96
- const second = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
97
- expect((await second.post("/api/auth/sign-in/email", { data: { email, password } })).ok()).toBeTruthy();
98
- expect(remaining(await second.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
131
+ // Another user has a budget of their own.
132
+ const other = await session(playwright);
133
+ expect(remaining(await other.get("/api/cache/rate-limit-probe"))).toBe(ceiling - 1);
99
134
 
100
135
  // A session cookie without a valid signature is counted with anonymous traffic
101
136
  // from the same client address instead of receiving a fresh counter. The cache
@@ -113,12 +148,13 @@ test("rate limit: each signed session has its own counter and forged sessions do
113
148
  expect(remaining(after)).toBeLessThan(remaining(before));
114
149
  }).toPass({ timeout: 10_000 });
115
150
 
116
- for (const context of [first, bearer, second, forged, anonymous]) await context.dispose();
151
+ for (const context of [first, second, bearer, other, forged, anonymous]) await context.dispose();
117
152
  });
118
153
 
119
154
  test("rate limit: session reads do not consume the strict authentication budget", async ({
120
155
  playwright,
121
156
  }) => {
157
+ test.skip(authLimit > 1000, "the authentication budget is too large to exhaust here");
122
158
  const ctx = await session(playwright);
123
159
  for (let i = 0; i < authLimit + 5; i++) {
124
160
  const response = await ctx.get("/api/auth/get-session");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rate-limit",
3
3
  "manifestVersion": 3,
4
- "description": "Provider-neutral Elysia request limiting by validated API key, signed-in session, or trusted-proxy client address.",
4
+ "description": "Provider-neutral Elysia request limiting by validated API key, signed-in user, or trusted-proxy client address.",
5
5
  "requires": ["api-key-auth"],
6
6
  "requiresCapabilities": ["cache"],
7
7
  "targetApp": "api",
@@ -19,9 +19,6 @@
19
19
  "RATE_LIMIT_AUTH_MAX=20",
20
20
  "# higher ceiling for SSR runtime settings and session reads",
21
21
  "RATE_LIMIT_RUNTIME_MAX=1000",
22
- "# trust no proxy hop by default; set the exact production proxy depth",
23
- "RATE_LIMIT_TRUSTED_PROXY_HOPS=0",
24
- "RATE_LIMIT_PROXY_HEADER=x-forwarded-for",
25
22
  "RATE_LIMIT_STORAGE_TIMEOUT_MS=1000",
26
23
  "RATE_LIMIT_UNAVAILABLE_RETRY_AFTER=1"
27
24
  ],
@@ -40,20 +37,20 @@
40
37
  "file": "AGENTS.md",
41
38
  "marker": "<!-- podokit:end:agents-modules -->",
42
39
  "optional": true,
43
- "text": "### rate-limit\n- Every request counts against a per-caller budget (general, `/api/auth`, or runtime reads); exceeded limits return 429 `RATE_LIMIT_EXCEEDED` with `Retry-After`.\n- Give routes that legitimately send many requests, such as upload chunks, their own budget instead of raising `RATE_LIMIT_MAX`: `services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method: \"PUT\", pathPrefix: \"/uploads\", profile: { name: \"uploads\", limit: 3000 } })` (`RATE_LIMIT_ROUTES` from `@podosoft/podokit-runtime`). Register from a module's `plugin` factory or `configureServices()` in `app.extensions.ts`; the most specific prefix wins, and `profile: \"exempt\"` skips limiting.\n- Prefixes are API paths (`/uploads`, not the web proxy's `/api/uploads`); Better Auth routes keep `/api/auth`.\n"
40
+ "text": "### rate-limit\n- Every request counts against a per-caller budget (general, `/api/auth`, or runtime reads), keyed by API key, then signed-in user, then client address; exceeded limits return 429 `RATE_LIMIT_EXCEEDED` with `Retry-After`. A user's sessions share one budget once the API has resolved them for a request.\n- Give routes that legitimately send many requests, such as upload chunks, their own budget instead of raising `RATE_LIMIT_MAX`: `services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method: \"PUT\", pathPrefix: \"/uploads\", profile: { name: \"uploads\", limit: 3000 } })` (`RATE_LIMIT_ROUTES` from `@podosoft/podokit-runtime`). Register from a module's `plugin` factory or `configureServices()` in `app.extensions.ts`; the most specific prefix wins (more segments, then a literal segment over a `:name` parameter), and `profile: \"exempt\"` skips limiting.\n- Prefixes are API paths (`/uploads`, not the web proxy's `/api/uploads`); Better Auth routes keep `/api/auth`. A `:name` segment matches one path segment, as in `/hosts/:hostId/file-transfers`.\n"
44
41
  }
45
42
  ],
46
43
  "instructions": [
47
44
  "Configure the selected cache provider before enabling request limits.",
48
45
  "Tune the general, authentication, and runtime limits in .env.",
49
46
  "Give each deployment that shares Redis a distinct RATE_LIMIT_KEY_PREFIX.",
50
- "Set RATE_LIMIT_TRUSTED_PROXY_HOPS to the exact number of trusted reverse proxies; keep 0 for direct API access.",
51
- "Validated X-API-Key clients and signed-in sessions receive independent opaque counters; other traffic uses the trusted client address.",
52
- "Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read, so limits apply per session rather than per user.",
47
+ "Requests without a validated API key or session count by the client address the API resolves from TRUSTED_PROXY_HOPS and TRUSTED_PROXY_HEADER, which audit entries and Better Auth sessions use too.",
48
+ "Validated X-API-Key clients and signed-in users receive independent opaque counters; other traffic uses the resolved client address.",
49
+ "Sessions are recognized by verifying the Better Auth cookie or bearer signature with BETTER_AUTH_SECRET, without a database read, and count under their user once the API has resolved them for a request, so limits apply per user.",
53
50
  "Exceeded limits return RATE_LIMIT_EXCEEDED with HTTP 429 and Retry-After. Cache failures return RATE_LIMIT_UNAVAILABLE with HTTP 503.",
54
51
  "Register a RateLimitIdentityExtension under RATE_LIMIT_IDENTITY_EXTENSION in app.extensions.ts for another validated machine-key source.",
55
52
  "GET /health and GET /health/ready bypass request quotas so orchestrator probes remain reliable.",
56
- "Give a route its own budget or exempt it with services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method, pathPrefix, profile }) from a module's plugin factory or configureServices() in app.extensions.ts; the most specific prefix wins.",
53
+ "Give a route its own budget or exempt it with services.tryResolve(RATE_LIMIT_ROUTES)?.register({ method, pathPrefix, profile }) from a module's plugin factory or configureServices() in app.extensions.ts; a :name segment matches one path segment, and the most specific prefix wins.",
57
54
  "Redis counters are shared across replicas; memory counters require one API replica."
58
55
  ]
59
56
  }
@@ -6,7 +6,7 @@ const origin = { origin: base };
6
6
  // The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
7
7
  // spec also compiles in apps that do not install that module.
8
8
  const admin = {
9
- email: "admin@example.com",
9
+ email: process.env.E2E_ADMIN_EMAIL || "admin@example.com",
10
10
  password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
11
11
  };
12
12
 
@@ -10,8 +10,9 @@ A Bun 1.4 full-stack TypeScript monorepo ({{packageManager}} workspaces):
10
10
 
11
11
  - `apps/api` — **Elysia** backend (`{{projectName}}-api`), Bun.SQL + `{{databaseProvider}}`, merged OpenAPI at `/api-docs`.
12
12
  - `apps/web` — **SvelteKit** frontend (`{{projectName}}-web`), Tailwind v4 + shadcn-svelte + typesafe-i18n.
13
- - `infra/` — Docker Compose references for external services and example k3s manifests.
14
- - `.podokit/` — generation metadata plus application-owned development and deployment profiles.
13
+ - `infra/` — provider-aware Docker Compose and example k3s manifests.
14
+ - `.podokit/` — PodoKit generation metadata plus application-owned development and
15
+ deployment profiles (see "PodoKit tooling" below).
15
16
  - **Todo example**: `apps/api/src/todos/` (Elysia plugin, Bun.SQL repository, and migration) and the todo UI in `apps/web/src/routes/` show the end-to-end patterns to follow.
16
17
 
17
18
  ## Setup & commands
@@ -49,7 +50,32 @@ Merge added module settings from `.env.example` into an existing `.env`.
49
50
  managed lists. Cap a route's request body in the app-owned
50
51
  `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
52
  `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
- uploads larger than it must use a direct API path (`apiPaths`) instead.
53
+ uploads larger than it must use a direct API path (`apiPaths`) instead. Give a
54
+ route a longer, shorter, or no response deadline in the app-owned
55
+ `apps/web/src/lib/server/proxy-timeouts.ts` (`APP_PROXY_TIMEOUTS`, `null` for
56
+ none); never edit the managed deadline. Both lists take path prefixes with
57
+ `:name` parameters, such as `/api/hosts/:hostId/file-transfers`, and the most
58
+ specific prefix wins.
59
+ - Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
60
+ (2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
61
+ response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
62
+ `GET /health` stops answering too, calls throw `BackendUnavailableError` and the
63
+ proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
64
+ any non-`ApiError` failure in a load function as a temporary outage. The API
65
+ (`API_IDLE_TIMEOUT`) and the built web server (`CONNECTION_IDLE_TIMEOUT`) close
66
+ a connection that sends nothing for that many seconds (35 each): raise both
67
+ with any longer `BACKEND_PROXY_TIMEOUT_MS` or `APP_PROXY_TIMEOUTS` entry, more
68
+ than 4 above it, or set `0` when a route has no deadline. The proxy refuses to
69
+ load when `CONNECTION_IDLE_TIMEOUT` cannot cover a deadline.
70
+ - Read a caller's address in the API with `clientAddressOf(request.headers)` from
71
+ `apps/api/src/core/client-address.ts`, never from `X-Forwarded-For` directly. The
72
+ API resolves it once per request from `TRUSTED_PROXY_HOPS` (1: the web proxy) and
73
+ `TRUSTED_PROXY_HEADER`; Better Auth sessions, audit entries, and rate limits use
74
+ the same address.
75
+ - `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
76
+ health route for the Kubernetes probes, the Compose healthcheck, and the image
77
+ `HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
78
+ out of service.
53
79
  - When the root layout or a server hook fails, such as with 503 while the API is
54
80
  down, SvelteKit serves the app-owned `apps/web/src/error.html`. It is plain HTML
55
81
  with inline styles: no components, app CSS, scripts, or assets, and only the
@@ -61,7 +87,11 @@ Merge added module settings from `.env.example` into an existing `.env`.
61
87
  Better Auth schema before the application migrations. `{{apiRun}} migration:run` runs
62
88
  only the application migrations and never creates Better Auth tables;
63
89
  `{{apiRun}} migration:revert` reverts the latest application migration.
64
- - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`.
90
+ - e2e (Playwright, ships in `tests/`): `{{rootRun}} test:e2e`. Run the whole suite on more
91
+ workers with `E2E_WORKERS=2`, never `--workers`. Tag a test `{ tag: "@exclusive" }` when
92
+ it changes state other tests rely on (a server-wide setting, a seeded account's profile);
93
+ tagged tests run alone after the rest. Give each test its own mail and SMS recipients and
94
+ never empty the shared Mailpit inbox or SMS sink. See `tests/README.md`.
65
95
  - Playwright officially runs on Node, so `bunx playwright` respects its Node
66
96
  shebang. Node LTS is a test-tool dependency only; API, web, worker, migrations,
67
97
  builds, and unit tests run on Bun.
@@ -96,15 +126,25 @@ changes need an e2e/unit test.
96
126
  This project is managed by the `podo` CLI. Files have an ownership tier recorded
97
127
  in `.podokit/files.lock` — respect it:
98
128
 
99
- - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations.
129
+ - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` and `API_IDLE_TIMEOUT` apply when the export leaves them out.
100
130
  - **assembled** (e.g. `apps/api/src/app.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
101
131
  - **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
102
132
 
103
133
  Useful commands:
104
134
 
105
- - `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, storage, events, and jobs; set previews by default.
135
+ - `podo dev watch|up|ps|logs|exec|down` — run container development through the
136
+ shared loopback gateway. `.podokit/dev.json` owns the local/public hosts and the
137
+ exact API WebSocket allowlist; never use a prefix or wildcard for that allowlist.
138
+ `podo dev up` rebuilds the images from the current source so a recreated
139
+ container never runs code older than the files Compose Watch synced.
140
+ `podo dev down` also stops this project's running `podo dev watch`; do not
141
+ kill Compose processes by hand.
142
+ The containers read the untracked `.env.docker`; `podo dev` creates it from the
143
+ committed `.env.docker.example` with generated secrets when it is missing. Never
144
+ commit `.env.docker` or put secrets in the example.
106
145
  - `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
107
146
  - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
147
+ - `podo provider list` / `podo provider set <capability> <provider> [--apply]` — inspect or switch database, cache, object storage, events, and jobs. Set is dry-run by default and never migrates or deletes provider data. Local providers require one API process.
108
148
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
109
149
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
110
150
  - `podo deploy` — plan, apply, verify, or roll back an immutable release on Kubernetes or one Docker host. Production API WebSocket paths belong in the deployment profile's exact `exposure.webSocketPaths` allowlist, and API routes that take bodies above the web server's `BODY_SIZE_LIMIT` (file uploads) in `exposure.apiPaths` (and `.podokit/dev.json` `apiPaths` for `podo dev`; `podo add` and `podo deploy init` fill in the paths modules declare), which reach the API without the web proxy, so the API must check their size, session, and `Origin` itself; Compose trusts forwarding headers only from `exposure.trustedProxyCidrs`. Tag `vX.Y.Z` to build and push the images (`.github/workflows/release.yml`; check its runner — GitHub-hosted minutes are free for public repositories only). Use `.agents/skills/podokit-deploy`.
@@ -55,8 +55,9 @@ cp .env.example .env
55
55
  ```
56
56
 
57
57
  Open the URL printed by the command; a new project defaults to
58
- **http://{{projectName}}.localhost**. The committed `.podokit/dev.json` is the
59
- source of truth if you change the hostname. The first running PodoKit project starts
58
+ **http://{{devHostname}}**. The committed `.podokit/dev.json` is the
59
+ source of truth if you change the hostname: `podo dev` sets `CORS_ORIGIN` and
60
+ `BETTER_AUTH_URL` for the web and API containers from it. The first running PodoKit project starts
60
61
  one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
61
62
  route by that hostname. Even a single app uses
62
63
  the same topology, with one route and no project-specific host port.
@@ -164,5 +165,6 @@ PodoKit [deployment guide](https://github.com/podosoft-dev/podokit/blob/main/doc
164
165
  For a Docker Compose deployment with API WebSockets, list their exact paths under
165
166
  `exposure.webSocketPaths`. The driver keeps one published port, routes those paths
166
167
  to API, and sends every other request to web. Large-upload routes listed under
167
- `exposure.apiPaths` reach the API the same way, with `/api` removed. If an external reverse proxy supplies
168
- forwarding headers, list only its direct CIDR blocks in `exposure.trustedProxyCidrs`.
168
+ `exposure.apiPaths` reach the API the same way, with `/api` removed. Set `exposure.edgeProxies` to the
169
+ number of reverse proxies in front of the deployment, and with the gateway list only their direct CIDR
170
+ blocks in `exposure.trustedProxyCidrs`.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Line-based unified diff for `podo diff <path>`. It is dependency-free and
3
+ * meant for source files of a few thousand lines; above `MAX_CELLS` it reports
4
+ * the whole file as replaced instead of building the LCS table.
5
+ */
6
+ export interface UnifiedDiffOptions {
7
+ fromLabel: string;
8
+ toLabel: string;
9
+ context?: number;
10
+ }
11
+ /** Unified diff from `from` to `to`, or an empty string when they are equal. */
12
+ export declare function unifiedDiff(from: string, to: string, options: UnifiedDiffOptions): string;
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ /**
3
+ * Line-based unified diff for `podo diff <path>`. It is dependency-free and
4
+ * meant for source files of a few thousand lines; above `MAX_CELLS` it reports
5
+ * the whole file as replaced instead of building the LCS table.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.unifiedDiff = unifiedDiff;
9
+ const MAX_CELLS = 25_000_000;
10
+ function splitLines(text) {
11
+ if (text === "")
12
+ return [];
13
+ const lines = text.split("\n");
14
+ if (lines[lines.length - 1] === "")
15
+ lines.pop();
16
+ return lines;
17
+ }
18
+ function operations(a, b) {
19
+ let start = 0;
20
+ while (start < a.length && start < b.length && a[start] === b[start])
21
+ start += 1;
22
+ let endA = a.length;
23
+ let endB = b.length;
24
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
25
+ endA -= 1;
26
+ endB -= 1;
27
+ }
28
+ const head = a.slice(0, start).map((line) => ({ kind: " ", line }));
29
+ const tail = a.slice(endA).map((line) => ({ kind: " ", line }));
30
+ const midA = a.slice(start, endA);
31
+ const midB = b.slice(start, endB);
32
+ const m = midA.length;
33
+ const n = midB.length;
34
+ const middle = [];
35
+ if ((m + 1) * (n + 1) > MAX_CELLS) {
36
+ middle.push(...midA.map((line) => ({ kind: "-", line })));
37
+ middle.push(...midB.map((line) => ({ kind: "+", line })));
38
+ return [...head, ...middle, ...tail];
39
+ }
40
+ const width = n + 1;
41
+ const table = new Uint32Array((m + 1) * width);
42
+ for (let i = m - 1; i >= 0; i -= 1) {
43
+ for (let j = n - 1; j >= 0; j -= 1) {
44
+ table[i * width + j] = midA[i] === midB[j]
45
+ ? table[(i + 1) * width + j + 1] + 1
46
+ : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
47
+ }
48
+ }
49
+ let i = 0;
50
+ let j = 0;
51
+ while (i < m || j < n) {
52
+ if (i < m && j < n && midA[i] === midB[j]) {
53
+ middle.push({ kind: " ", line: midA[i] });
54
+ i += 1;
55
+ j += 1;
56
+ }
57
+ else if (i < m && (j === n || table[(i + 1) * width + j] >= table[i * width + j + 1])) {
58
+ middle.push({ kind: "-", line: midA[i] });
59
+ i += 1;
60
+ }
61
+ else {
62
+ middle.push({ kind: "+", line: midB[j] });
63
+ j += 1;
64
+ }
65
+ }
66
+ return [...head, ...middle, ...tail];
67
+ }
68
+ function range(start, count) {
69
+ if (count === 0)
70
+ return `${start},0`;
71
+ return count === 1 ? `${start + 1}` : `${start + 1},${count}`;
72
+ }
73
+ /** Unified diff from `from` to `to`, or an empty string when they are equal. */
74
+ function unifiedDiff(from, to, options) {
75
+ if (from === to)
76
+ return "";
77
+ const context = options.context ?? 3;
78
+ const ops = operations(splitLines(from), splitLines(to));
79
+ const changed = ops.flatMap((op, index) => (op.kind === " " ? [] : [index]));
80
+ const out = [`--- ${options.fromLabel}`, `+++ ${options.toLabel}`];
81
+ if (!changed.length) {
82
+ // Only the trailing newline differs.
83
+ out.push(from.endsWith("\n") ? "\\ the template ends with a newline" : "\\ the template has no newline at end of file", to.endsWith("\n") ? "\\ the file ends with a newline" : "\\ the file has no newline at end of file");
84
+ return `${out.join("\n")}\n`;
85
+ }
86
+ let hunkStart = 0;
87
+ while (hunkStart < changed.length) {
88
+ let hunkEnd = hunkStart;
89
+ while (hunkEnd + 1 < changed.length &&
90
+ changed[hunkEnd + 1] - changed[hunkEnd] <= context * 2 + 1) {
91
+ hunkEnd += 1;
92
+ }
93
+ const first = Math.max(0, changed[hunkStart] - context);
94
+ const last = Math.min(ops.length - 1, changed[hunkEnd] + context);
95
+ let fromLine = 0;
96
+ let toLine = 0;
97
+ for (let index = 0; index < first; index += 1) {
98
+ if (ops[index].kind !== "+")
99
+ fromLine += 1;
100
+ if (ops[index].kind !== "-")
101
+ toLine += 1;
102
+ }
103
+ const body = ops.slice(first, last + 1);
104
+ const fromCount = body.filter((op) => op.kind !== "+").length;
105
+ const toCount = body.filter((op) => op.kind !== "-").length;
106
+ out.push(`@@ -${range(fromLine, fromCount)} +${range(toLine, toCount)} @@`);
107
+ out.push(...body.map((op) => `${op.kind}${op.line}`));
108
+ hunkStart = hunkEnd + 1;
109
+ }
110
+ return `${out.join("\n")}\n`;
111
+ }