@podosoft/podokit 1.5.0 → 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 (211) hide show
  1. package/README.md +2 -1
  2. package/dist/app-proxy-timeouts.d.ts +27 -0
  3. package/dist/app-proxy-timeouts.js +180 -0
  4. package/dist/assemble.d.ts +5 -2
  5. package/dist/assemble.js +14 -4
  6. package/dist/deploy-checks.d.ts +44 -0
  7. package/dist/deploy-checks.js +267 -0
  8. package/dist/deploy-compose-profile.d.ts +6 -2
  9. package/dist/deploy-compose-profile.js +15 -6
  10. package/dist/deploy-compose-render.d.ts +12 -4
  11. package/dist/deploy-compose-render.js +47 -18
  12. package/dist/deploy-compose.d.ts +2 -5
  13. package/dist/deploy-compose.js +7 -2
  14. package/dist/deploy-profile.d.ts +6 -2
  15. package/dist/deploy-profile.js +11 -7
  16. package/dist/deploy-render.d.ts +4 -1
  17. package/dist/deploy-render.js +27 -21
  18. package/dist/deploy-schema.d.ts +56 -2
  19. package/dist/deploy-schema.js +130 -14
  20. package/dist/deploy.d.ts +2 -5
  21. package/dist/deploy.js +5 -2
  22. package/dist/dev.d.ts +21 -0
  23. package/dist/dev.js +78 -3
  24. package/dist/eject.js +2 -0
  25. package/dist/index.js +85 -5
  26. package/dist/lib.d.ts +2 -0
  27. package/dist/lib.js +5 -2
  28. package/dist/lockfile.d.ts +24 -0
  29. package/dist/lockfile.js +74 -4
  30. package/dist/provider.js +10 -5
  31. package/dist/remove.js +1 -1
  32. package/dist/template-files.d.ts +42 -0
  33. package/dist/template-files.js +102 -0
  34. package/dist/templates/fullstack/AGENTS.md +28 -4
  35. package/dist/templates/fullstack/README.md +8 -7
  36. package/dist/templates/fullstack/apps/api/package.json +4 -4
  37. package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
  38. package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
  39. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
  40. package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
  41. package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
  42. package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
  43. package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
  44. package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
  45. package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
  46. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
  47. package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
  48. package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
  49. package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
  50. package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
  51. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
  52. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
  53. package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
  54. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
  55. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
  56. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
  57. package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
  58. package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
  59. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
  60. package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
  61. package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
  62. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
  63. package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
  64. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
  65. package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
  66. package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
  67. package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
  68. package/dist/templates/fullstack/apps/web/package.json +1 -1
  69. package/dist/templates/fullstack/apps/web/src/app.css +3 -0
  70. package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
  71. package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
  72. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
  73. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
  74. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
  75. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
  76. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
  77. package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
  78. package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
  79. package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
  80. package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
  81. package/dist/templates/fullstack/compose.dev.yaml +7 -4
  82. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
  83. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
  84. package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
  85. package/dist/templates/fullstack/dot-env.docker.example +5 -2
  86. package/dist/templates/fullstack/dot-env.example +4 -0
  87. package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
  88. package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
  89. package/dist/templates/fullstack/tests/README.md +40 -1
  90. package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
  91. package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
  92. package/dist/templates/fullstack/tests/package.json +2 -2
  93. package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
  94. package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
  95. package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
  96. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
  97. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
  98. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
  99. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
  100. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
  101. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
  102. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
  103. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
  104. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
  105. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
  106. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
  107. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
  108. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
  109. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
  110. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
  111. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
  112. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
  113. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
  114. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
  115. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
  116. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
  117. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
  118. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
  119. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
  120. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
  121. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
  122. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
  123. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
  124. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
  125. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
  126. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
  127. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
  128. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
  129. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
  130. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
  131. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
  132. package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
  133. package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
  134. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
  135. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
  136. package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
  137. package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
  138. package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
  139. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
  140. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
  141. package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
  142. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
  143. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
  144. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
  145. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
  146. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
  147. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
  148. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
  149. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
  150. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
  151. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
  152. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
  153. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
  154. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
  155. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
  156. package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
  157. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
  158. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
  159. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
  160. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
  161. package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
  162. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
  163. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
  164. package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
  165. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
  166. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
  167. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
  168. package/dist/templates/modules/audit-log/module.manifest.json +15 -3
  169. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
  170. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
  171. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
  172. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
  173. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
  174. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
  175. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
  176. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
  177. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
  178. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
  179. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
  180. package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
  181. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
  182. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
  183. package/dist/templates/modules/auth/module.manifest.json +8 -2
  184. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
  185. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
  186. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
  187. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
  188. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
  189. package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
  190. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  191. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
  192. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
  193. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
  194. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  195. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
  196. package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
  197. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  198. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
  199. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
  200. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
  201. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
  202. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  203. package/dist/templates/todo/AGENTS.md +5 -1
  204. package/dist/templates/todo/README.md +6 -4
  205. package/dist/text-diff.d.ts +12 -0
  206. package/dist/text-diff.js +111 -0
  207. package/dist/update.d.ts +61 -3
  208. package/dist/update.js +152 -27
  209. package/package.json +2 -2
  210. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
  211. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
@@ -48,6 +48,7 @@ services:
48
48
  - { action: sync, path: ./apps/web/src, target: /app/apps/web/src }
49
49
  - { action: sync, path: ./apps/web/static, target: /app/apps/web/static }
50
50
  - { action: sync+restart, path: ./apps/web/vite.config.ts, target: /app/apps/web/vite.config.ts }
51
+ - { action: sync+restart, path: ./apps/web/vite.extensions.ts, target: /app/apps/web/vite.extensions.ts }
51
52
  - { action: rebuild, path: ./apps/web/package.json }
52
53
 
53
54
  api:
@@ -121,7 +122,7 @@ services:
121
122
  mem_limit: ${MINIO_MEM_LIMIT:-512m}
122
123
  memswap_limit: ${MINIO_MEM_LIMIT:-512m}
123
124
  profiles: [storage]
124
- image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z
125
+ image: pgsty/silo:RELEASE.2026-09-16T00-00-00Z@sha256:635197cb9f36d01bee221d34d1c7d7960f6a95c48b0b6c01d99cd13bdae51a46
125
126
  command: server /data --console-address ":9001"
126
127
  environment:
127
128
  MINIO_ROOT_USER: podokit
@@ -134,15 +135,17 @@ services:
134
135
  mem_limit: ${MINIO_INIT_MEM_LIMIT:-128m}
135
136
  memswap_limit: ${MINIO_INIT_MEM_LIMIT:-128m}
136
137
  profiles: [storage]
137
- image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z
138
+ image: pgsty/mc:RELEASE.2026-09-16T00-00-00Z@sha256:cfc83108c3abb371f8fb84d99c1fdc88f8c237e022409b0081fb7c0a3be634dd
138
139
  depends_on:
139
140
  minio: { condition: service_started }
141
+ # -e stops at the first failing command, so a bucket that cannot be
142
+ # created fails this service instead of leaving uploads to fail later.
140
143
  entrypoint:
141
144
  - sh
142
- - -c
145
+ - -ec
143
146
  - |
144
147
  until mc alias set loc http://minio:9000 podokit podokitsecret; do sleep 1; done
145
- mc mb -p loc/podokit || true
148
+ mc mb --ignore-existing loc/podokit
146
149
  networks: [podokit]
147
150
 
148
151
  networks:
@@ -16,9 +16,9 @@ Read the profile's `driver` first and say which one you are operating.
16
16
  1. Run `{{packageExecutor}} @podosoft/podokit status`, `{{packageExecutor}} @podosoft/podokit diff`, and `{{packageExecutor}} @podosoft/podokit doctor`. Stop if managed-file conflicts or unsupported framework versions could make the image unreliable.
17
17
  2. Read [references/profile.md](references/profile.md) before creating or changing a deployment profile.
18
18
  3. Make sure the images exist for that tag. Read [references/release.md](references/release.md): tagging `vX.Y.Z` runs the release workflow, which builds and pushes both images and stops there. Never apply a release whose images were not published.
19
- 4. Run `{{packageExecutor}} @podosoft/podokit deploy doctor --profile <name>`. Resolve every failure without printing secret values.
19
+ 4. Run `{{packageExecutor}} @podosoft/podokit deploy doctor --profile <name>`. Resolve every `FAIL` line and show the user every `warn` line, without printing secret values.
20
20
  5. Run `{{packageExecutor}} @podosoft/podokit deploy plan --profile <name> --release <stable-semver> --json`.
21
- 6. Show the user the driver, the target (context plus namespace and Helm release, or Docker context plus Compose project), the fingerprint result, the exact image digests, the current revision, the actions, the migration warning, and the plan hash.
21
+ 6. Show the user the driver, the target (context plus namespace and Helm release, or Docker context plus Compose project), the fingerprint result, the exact image digests, the current revision, the actions, every plan warning, and the plan hash.
22
22
  7. Do not apply until the user explicitly approves that exact plan.
23
23
  8. Apply with `{{packageExecutor}} @podosoft/podokit deploy apply --profile <name> --release <stable-semver> --confirm <plan-hash>`.
24
24
  9. Run `{{packageExecutor}} @podosoft/podokit deploy status --profile <name> --json` and `{{packageExecutor}} @podosoft/podokit deploy verify --profile <name> --json`.
@@ -24,9 +24,16 @@ Review these fields before planning:
24
24
  `stripPrefix` Middleware sends them to API without `/api`. It needs a
25
25
  Traefik-served IngressClass, and `/api/auth` is rejected. `podo deploy init`
26
26
  and `podo add` fill in the paths installed modules declare (file-upload:
27
- `/api/files/uploads/`); keep them.
27
+ `/api/files/uploads/`); keep them. `edgeProxies` counts the reverse proxies in
28
+ front of the Ingress controller or NodePort (default 0); the web server then
29
+ trusts that many plus the controller in `X-Forwarded-For`. Never set
30
+ `XFF_DEPTH`, `ADDRESS_HEADER`, `TRUSTED_PROXY_HOPS`, or `TRUSTED_PROXY_HEADER`
31
+ in `runtimeConfig`; the profile refuses them.
28
32
  - `workloads`: API/web replicas and resources plus an optional worker
29
- - `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage
33
+ - `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage.
34
+ PostgreSQL also takes `maxConnections` (default 100) and `appPools` for pools the
35
+ application opens itself; the doctor and the plan check the connection budget
36
+ against them, so lower `DB_POOL_MAX`/`DB_AUTH_POOL_MAX` or replicas when they warn.
30
37
  - `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
31
38
  - `runtimeConfig`: non-sensitive environment values only
32
39
  - `verification`: HTTPS origin and checks with exact statuses and optional JSON fields
@@ -72,7 +79,9 @@ The fields that differ from the Kubernetes driver:
72
79
  it, sends exact WebSocket paths to API, sends `apiPaths` prefixes to API with
73
80
  `/api` removed, and sends every other request to web. Forwarding headers are trusted
74
81
  only from the direct proxy CIDRs in `trustedProxyCidrs`; leave it empty without an
75
- external proxy. The bind address defaults to `127.0.0.1`, because the expected
82
+ external proxy. `edgeProxies` counts the reverse proxies in front of the published
83
+ port (default 1); with the gateway it must match `trustedProxyCidrs`, which the
84
+ doctor checks. The bind address defaults to `127.0.0.1`, because the expected
76
85
  front door is a trusted reverse proxy — binding every interface publishes the app
77
86
  before TLS exists.
78
87
  - `workloads`: replicas plus Compose `cpus` and `memory` limits
@@ -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
@@ -18,11 +18,14 @@ TRUSTED_PROXY_HOPS=1
18
18
  # response headers.
19
19
  # BACKEND_TIMEOUT_MS=2000
20
20
  # BACKEND_PROXY_TIMEOUT_MS=30000
21
- 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}}
22
25
  REDIS_HOST=redis
23
26
  REDIS_PORT=6379
24
27
  BETTER_AUTH_SECRET=
25
- BETTER_AUTH_URL=http://{{projectName}}.localhost
28
+ BETTER_AUTH_URL={{devAuthUrl}}
26
29
  ADMIN_EMAILS=admin@example.com
27
30
  AUDIT_LOG_ENABLED=true
28
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
@@ -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
  }
@@ -16,3 +16,4 @@ data:
16
16
  HOST_HEADER: "x-forwarded-host"
17
17
  ADDRESS_HEADER: "x-forwarded-for"
18
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
  });
@@ -1,5 +1,6 @@
1
1
  import { ApiError } from "@podosoft/podokit-api-client";
2
2
  import type { RequestEvent } from "@sveltejs/kit";
3
+ import type { Handle } from "@sveltejs/kit/hooks";
3
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
5
  import { BackendUnavailableError } from "#lib/server/api.js";
5
6
  import { handle } from "./hooks.server";
@@ -14,12 +15,18 @@ const client = vi.hoisted(() => ({
14
15
  getSession: vi.fn<() => Promise<SessionResult>>(),
15
16
  }));
16
17
 
18
+ // Stands in for the application's own hook in the app-owned app-hooks.ts.
19
+ const appHook = vi.hoisted(() => ({
20
+ handle: vi.fn<Handle>(({ event, resolve }) => resolve(event)),
21
+ }));
22
+ vi.mock("#lib/server/app-hooks.js", () => ({ appHandle: appHook.handle }));
23
+
17
24
  vi.mock("#lib/server/api.js", async (importOriginal) => ({
18
25
  ...(await importOriginal<typeof import("#lib/server/api.js")>()),
19
26
  serverApiClient: () => ({ get: client.get, auth: { getSession: client.getSession } }),
20
27
  }));
21
28
 
22
- const resolve = vi.fn(async (): Promise<Response> => new Response("ok"));
29
+ const resolve = vi.fn(async (_event: RequestEvent): Promise<Response> => new Response("ok"));
23
30
 
24
31
  function requestEvent(pathname: string, cookies: Record<string, string> = {}): RequestEvent {
25
32
  return {
@@ -40,6 +47,7 @@ describe("handle", () => {
40
47
  client.get.mockReset();
41
48
  client.getSession.mockReset();
42
49
  resolve.mockClear();
50
+ appHook.handle.mockClear();
43
51
  });
44
52
 
45
53
  it("requests the session without waiting for site settings", async () => {
@@ -144,4 +152,32 @@ describe("handle", () => {
144
152
  expect(client.getSession).not.toHaveBeenCalled();
145
153
  expect(resolve).toHaveBeenCalledTimes(1);
146
154
  });
155
+
156
+ it("runs the application hook with the loaded runtime context", async () => {
157
+ client.get.mockResolvedValue({ locale: null });
158
+ client.getSession.mockResolvedValue(signedIn);
159
+ let seenUser: unknown;
160
+ appHook.handle.mockImplementationOnce(({ event, resolve: next }) => {
161
+ seenUser = event.locals.user;
162
+ event.locals.user = { id: "local", email: "local@localhost", name: "Local", role: "admin" };
163
+ return next(event);
164
+ });
165
+ const event = requestEvent("/account");
166
+
167
+ const response = await handle({ event, resolve });
168
+
169
+ expect(seenUser).toEqual({ id: "user-1" });
170
+ expect(resolve).toHaveBeenCalledTimes(1);
171
+ expect(resolve.mock.calls[0]?.[0].locals.user).toMatchObject({ id: "local", role: "admin" });
172
+ expect(response.headers.get("x-robots-tag")).toBe("noindex, nofollow");
173
+ });
174
+
175
+ it("lets the application hook answer a request itself", async () => {
176
+ appHook.handle.mockImplementationOnce(() => new Response("teapot", { status: 418 }));
177
+
178
+ const response = await handle({ event: requestEvent("/api/site/settings"), resolve });
179
+
180
+ expect(response.status).toBe(418);
181
+ expect(resolve).not.toHaveBeenCalled();
182
+ });
147
183
  });
@@ -3,6 +3,7 @@ import type { Handle } from "@sveltejs/kit/hooks";
3
3
  import { building } from "$app/env";
4
4
  import { routes } from "$app/manifest";
5
5
  import { serverApiClient } from "#lib/server/api.js";
6
+ import { appHandle } from "#lib/server/app-hooks.js";
6
7
  import { resolveLocale } from "#lib/i18n/messages.js";
7
8
  import { applySearchIndexingHeaders } from "#lib/server/search-indexing.js";
8
9
  import { isPublicPath } from "#lib/server/guards.js";
@@ -91,12 +92,20 @@ export const handle: Handle = async ({ event, resolve }) => {
91
92
  } else {
92
93
  setEmptyRuntimeContext(event);
93
94
  }
94
- const response = await resolve(event, {
95
- transformPageChunk: ({ html }) =>
96
- html.replace(
97
- /(<html\b[^>]*?\slang=)(["'])[^"']*\2/i,
98
- `$1"${event.locals.locale}"`,
99
- ),
95
+ // The application's own hook from the app-owned app-hooks.ts runs next, with
96
+ // the runtime context loaded. Its page transform runs before the language one.
97
+ const response = await appHandle({
98
+ event,
99
+ resolve: (appEvent, options) =>
100
+ resolve(appEvent, {
101
+ ...options,
102
+ transformPageChunk: async ({ html, done }) => {
103
+ const transformed = options?.transformPageChunk
104
+ ? ((await options.transformPageChunk({ html, done })) ?? "")
105
+ : html;
106
+ return transformed.replace(/(<html\b[^>]*?\slang=)(["'])[^"']*\2/i, `$1"${event.locals.locale}"`);
107
+ },
108
+ }),
100
109
  });
101
110
  return applySearchIndexingHeaders(response, event.url.pathname);
102
111
  };
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { Messages } from "#lib/i18n/messages.js";
3
+ import { appFlagCards } from "./app-flag-settings";
4
+
5
+ // Stands in for the application's own list in the app-owned app-flags.ts.
6
+ vi.mock("./app-flags", () => ({
7
+ APP_FLAG_SETTINGS: [
8
+ { flag: "reportsEnabled", label: () => "Reports", description: () => "Show the reports page." },
9
+ { flag: "betaSearch", label: () => "Beta search" },
10
+ ],
11
+ }));
12
+
13
+ const t = {} as Messages;
14
+
15
+ describe("application flag settings", () => {
16
+ it("shows each listed flag with the value the API reports", () => {
17
+ expect(appFlagCards(t, { appFlags: { reportsEnabled: true, betaSearch: false, hidden: true } })).toEqual([
18
+ { flag: "reportsEnabled", name: "Reports", description: "Show the reports page.", available: true, on: true },
19
+ { flag: "betaSearch", name: "Beta search", description: "", available: true, on: false },
20
+ ]);
21
+ });
22
+
23
+ it("marks a flag the API does not report as unavailable", () => {
24
+ expect(appFlagCards(t, { appFlags: { reportsEnabled: false } })[1]).toMatchObject({
25
+ flag: "betaSearch",
26
+ available: false,
27
+ on: false,
28
+ });
29
+ expect(appFlagCards(t, undefined).every((card) => !card.available)).toBe(true);
30
+ });
31
+ });
@@ -0,0 +1,31 @@
1
+ import type { Capabilities } from "@podosoft/podokit-api-client";
2
+ import type { Messages } from "#lib/i18n/messages.js";
3
+ import { APP_FLAG_SETTINGS } from "./app-flags";
4
+ import type { AppFlagSetting } from "./registry.svelte.js";
5
+
6
+ export type AppFlagCard = {
7
+ flag: string;
8
+ name: string;
9
+ description: string;
10
+ /** False when the API does not report the flag, for example a name missing from APP_FLAG_DEFAULTS. */
11
+ available: boolean;
12
+ on: boolean;
13
+ };
14
+
15
+ /** One card per listed flag, with the value `GET /account/capabilities` reports in `appFlags`. */
16
+ export function appFlagCards(
17
+ t: Messages,
18
+ capabilities: Pick<Capabilities, "appFlags"> | undefined,
19
+ settings: readonly AppFlagSetting[] = APP_FLAG_SETTINGS,
20
+ ): AppFlagCard[] {
21
+ return settings.map(({ flag, label, description }) => {
22
+ const value = capabilities?.appFlags?.[flag];
23
+ return {
24
+ flag,
25
+ name: label(t),
26
+ description: description?.(t) ?? "",
27
+ available: typeof value === "boolean",
28
+ on: value === true,
29
+ };
30
+ });
31
+ }
@@ -0,0 +1,12 @@
1
+ import type { AppFlagSetting } from "./registry.svelte.js";
2
+
3
+ /**
4
+ * Application flags the admin Settings page shows, one switch each, on an
5
+ * Application tab after PodoKit's tabs. `flag` is a key of `APP_FLAG_DEFAULTS`
6
+ * in the API's owned `apps/api/src/settings/app-flags.ts`; the label and
7
+ * description come from the app-owned `i18n/catalogs/app/*.json`, for example
8
+ * `{ flag: "reportsEnabled", label: (t) => t.reports.flag, description: (t) => t.reports.flagDescription }`.
9
+ * The tab is hidden while the list is empty. PodoKit creates this file once and
10
+ * never overwrites it.
11
+ */
12
+ export const APP_FLAG_SETTINGS: readonly AppFlagSetting[] = [];
@@ -0,0 +1,11 @@
1
+ import type { AdminNavEntry } from "./registry.svelte.js";
2
+
3
+ /**
4
+ * Application-owned admin sidebar entries. The sidebar shows them after PodoKit's
5
+ * own pages and before module entries and Settings, for example
6
+ * `{ href: "/admin/reports", label: (t) => t.reports.nav, icon: ChartColumnIcon, adminOnly: true }`
7
+ * with the label in the app-owned `i18n/catalogs/app/*.json` and the icon from
8
+ * `@lucide/svelte/icons/*`. `adminOnly` hides an entry from other roles.
9
+ * PodoKit creates this file once and never overwrites it.
10
+ */
11
+ export const APP_ADMIN_NAV: readonly AdminNavEntry[] = [];