@podosoft/podokit 1.1.6 → 1.3.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 (203) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/deploy-render.js +5 -0
  4. package/dist/index.js +8 -0
  5. package/dist/lib.d.ts +2 -2
  6. package/dist/lib.js +3 -2
  7. package/dist/lockfile.d.ts +3 -1
  8. package/dist/lockfile.js +17 -6
  9. package/dist/templates/fullstack/apps/api/package.json +2 -2
  10. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  11. package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
  12. package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
  13. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -0
  14. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +10 -1
  15. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +30 -0
  16. package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
  17. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  18. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  19. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
  20. package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
  21. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  22. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
  23. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
  24. package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
  25. package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
  26. package/dist/templates/fullstack/apps/web/package.json +1 -1
  27. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
  29. package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
  30. package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
  31. package/dist/templates/fullstack/compose.dev.yaml +21 -0
  32. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  33. package/dist/templates/fullstack/dot-env.docker +3 -0
  34. package/dist/templates/fullstack/dot-env.example +14 -0
  35. package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
  36. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  38. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  39. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  46. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  47. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +22 -28
  50. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  51. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
  53. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
  54. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
  55. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
  56. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
  57. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
  58. package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
  59. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
  60. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
  61. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
  62. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
  63. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
  64. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +102 -0
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +141 -0
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
  69. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
  70. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
  75. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
  76. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
  77. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
  78. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  79. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  80. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
  81. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  82. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
  83. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
  85. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
  86. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +79 -37
  87. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
  88. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
  89. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
  90. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
  91. package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
  92. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
  93. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  94. package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
  95. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  96. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +35 -1
  97. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
  98. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
  99. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
  100. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  101. package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
  102. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
  103. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  104. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  105. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +16 -5
  106. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +45 -0
  107. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
  108. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +29 -0
  109. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
  110. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +32 -1
  111. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  112. package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
  113. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
  114. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
  115. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +126 -3
  116. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
  117. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
  118. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
  119. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
  120. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
  121. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +57 -0
  122. package/dist/templates/modules/audit-log/module.manifest.json +7 -4
  123. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
  124. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
  125. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
  126. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
  127. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
  128. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
  129. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
  130. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
  131. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
  132. package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
  133. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
  134. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
  135. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
  136. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
  137. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  138. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  139. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +21 -37
  140. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
  141. package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
  142. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  143. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  144. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
  145. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
  146. package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
  147. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  148. package/dist/templates/modules/auth/module.manifest.json +2 -2
  149. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
  150. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
  151. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
  152. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
  153. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
  154. package/dist/templates/modules/file-upload/module.manifest.json +9 -3
  155. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
  156. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
  157. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
  158. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
  159. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
  160. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
  161. package/dist/templates/modules/job-progress/module.manifest.json +3 -3
  162. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
  163. package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
  164. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  165. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
  166. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
  167. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
  168. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
  169. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
  170. package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
  171. package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
  172. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
  173. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
  174. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
  175. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
  176. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
  177. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
  178. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
  179. package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
  180. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
  181. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
  182. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
  183. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
  184. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
  185. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
  186. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
  187. package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
  188. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
  189. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
  190. package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
  191. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
  192. package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
  193. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +250 -0
  194. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +121 -15
  195. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +407 -18
  196. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +153 -40
  197. package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
  198. package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
  199. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +227 -70
  200. package/dist/templates/modules/sse/module.manifest.json +20 -5
  201. package/dist/update.d.ts +17 -0
  202. package/dist/update.js +33 -8
  203. package/package.json +2 -2
@@ -0,0 +1,167 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { AppException } from "@podosoft/podokit-contracts";
6
+ import { LocalObjectStore, type ObjectData } from "@podosoft/podokit-runtime";
7
+ import { Elysia } from "elysia";
8
+ import type { Logger } from "pino";
9
+ import { ProfileImageService } from "../profile-image/profile-image.service";
10
+ import type { SiteFavicon } from "../site-settings/site-settings.service";
11
+ import { faviconCacheControl, IMMUTABLE_CACHE_CONTROL, siteAssetRoutes } from "./site-assets.routes";
12
+
13
+ const PNG = Buffer.from(
14
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADAQMAAACDJEzCAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURWYzmf///129H+IAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gcVCSUTzXBDTwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wNy0yMVQwOTozNzoxOSswMDowMOuehLIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDctMjFUMDk6Mzc6MTkrMDA6MDCawzwOAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTA3LTIxVDA5OjM3OjE5KzAwOjAwzdYd0QAAAAtJREFUCNdjYAABAAAGAAFm9MlsAAAAAElFTkSuQmCC",
15
+ "base64",
16
+ );
17
+
18
+ const directories: string[] = [];
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
22
+ });
23
+
24
+ async function localStore(): Promise<LocalObjectStore> {
25
+ const directory = await mkdtemp(join(tmpdir(), "podokit-site-assets-"));
26
+ directories.push(directory);
27
+ return new LocalObjectStore({ rootDirectory: directory });
28
+ }
29
+
30
+ interface TrackedObject {
31
+ object: ObjectData;
32
+ read: () => boolean;
33
+ }
34
+
35
+ function trackedObject(content: string): TrackedObject {
36
+ let read = false;
37
+ const bytes = Buffer.from(content);
38
+ return {
39
+ object: {
40
+ key: "site/favicon",
41
+ size: bytes.byteLength,
42
+ contentType: "application/octet-stream",
43
+ etag: "\"favicon-1\"",
44
+ lastModified: new Date("2026-01-02T03:04:05Z"),
45
+ body: new ReadableStream<Uint8Array>({
46
+ pull(controller): void {
47
+ read = true;
48
+ controller.enqueue(bytes);
49
+ controller.close();
50
+ },
51
+ }, { highWaterMark: 0 }),
52
+ },
53
+ read: () => read,
54
+ };
55
+ }
56
+
57
+ type Get = (path: string, headers?: Record<string, string>) => Promise<Response>;
58
+
59
+ function app(
60
+ favicon: () => SiteFavicon | null,
61
+ profileImages: Pick<ProfileImageService, "get">,
62
+ ): Get {
63
+ const target = new Elysia()
64
+ .onError(({ error, set }) => {
65
+ if (!(error instanceof AppException)) return undefined;
66
+ set.status = error.statusCode;
67
+ return { code: error.code };
68
+ })
69
+ .use(siteAssetRoutes({ getFavicon: async () => favicon() }, profileImages));
70
+ return async (path, headers = {}) => target.handle(new Request(`http://localhost${path}`, { headers }));
71
+ }
72
+
73
+ const noProfileImages: Pick<ProfileImageService, "get"> = {
74
+ get: () => Promise.reject(new Error("unused")),
75
+ };
76
+
77
+ describe("GET /site/favicon", () => {
78
+ test("streams the favicon with the validated type and its validators", async () => {
79
+ const icon = trackedObject("<svg/>");
80
+ const get = app(() => ({ object: icon.object, contentType: "image/svg+xml", version: "1700" }), noProfileImages);
81
+ const response = await get("/site/favicon");
82
+
83
+ expect(response.status).toBe(200);
84
+ expect(response.headers.get("content-type")).toBe("image/svg+xml");
85
+ expect(response.headers.get("content-length")).toBe("6");
86
+ expect(response.headers.get("etag")).toBe("\"favicon-1\"");
87
+ expect(response.headers.get("last-modified")).toBe("Fri, 02 Jan 2026 03:04:05 GMT");
88
+ expect(await response.text()).toBe("<svg/>");
89
+ });
90
+
91
+ test("answers a current copy with 304 without reading storage", async () => {
92
+ const icon = trackedObject("<svg/>");
93
+ const get = app(() => ({ object: icon.object, contentType: "image/svg+xml", version: "1700" }), noProfileImages);
94
+ const response = await get("/site/favicon", { "if-none-match": "\"favicon-1\"" });
95
+
96
+ expect(response.status).toBe(304);
97
+ expect(await response.text()).toBe("");
98
+ expect(icon.read()).toBe(false);
99
+ });
100
+
101
+ test("caches the current version for good and revalidates any other URL", async () => {
102
+ const get = (path: string, headers: Record<string, string> = {}): Promise<Response> => {
103
+ const icon = trackedObject("<svg/>");
104
+ return app(() => ({ object: icon.object, contentType: "image/svg+xml", version: "1700" }), noProfileImages)(
105
+ path,
106
+ headers,
107
+ );
108
+ };
109
+
110
+ const current = await get("/site/favicon?v=1700");
111
+ expect(current.status).toBe(200);
112
+ expect(current.headers.get("cache-control")).toBe(IMMUTABLE_CACHE_CONTROL);
113
+
114
+ for (const path of ["/site/favicon?v=1600", "/site/favicon?v=", "/site/favicon"]) {
115
+ const response = await get(path);
116
+ expect(response.status, path).toBe(200);
117
+ expect(response.headers.get("cache-control"), path).toBe("no-cache");
118
+ expect(response.headers.get("etag"), path).toBe("\"favicon-1\"");
119
+ }
120
+
121
+ const revalidated = await get("/site/favicon?v=1600", { "if-none-match": "\"favicon-1\"" });
122
+ expect(revalidated.status).toBe(304);
123
+ expect(revalidated.headers.get("cache-control")).toBe("no-cache");
124
+ expect(faviconCacheControl(null, "")).toBe("no-cache");
125
+ });
126
+
127
+ test("returns 404 when no favicon is set", async () => {
128
+ const response = await app(() => null, noProfileImages)("/site/favicon");
129
+ expect(response.status).toBe(404);
130
+ expect(await response.json()).toEqual({ code: "FAVICON_NOT_FOUND" });
131
+ });
132
+ });
133
+
134
+ describe("GET /profile-images/:fileName", () => {
135
+ test("streams exactly the uploaded bytes as an immutable resource", async () => {
136
+ const store = await localStore();
137
+ const logger = { warn: mock(() => undefined) } as unknown as Logger;
138
+ const images = new ProfileImageService(store, logger, async () => undefined);
139
+ const { image } = await images.upload(null, { buffer: PNG, mimetype: "image/png", size: PNG.byteLength }, new Headers());
140
+ const path = image?.replace(/^\/api/, "") ?? "";
141
+ const get = app(() => null, images);
142
+
143
+ const response = await get(path);
144
+ expect(response.status).toBe(200);
145
+ expect(response.headers.get("content-type")).toBe("image/png");
146
+ expect(response.headers.get("cache-control")).toBe(IMMUTABLE_CACHE_CONTROL);
147
+ expect(response.headers.get("content-length")).toBe(String(PNG.byteLength));
148
+ expect(Buffer.from(await response.arrayBuffer()).equals(PNG)).toBe(true);
149
+
150
+ const revalidated = await get(path, { "if-none-match": response.headers.get("etag") ?? "" });
151
+ expect(revalidated.status).toBe(304);
152
+ expect(revalidated.headers.get("cache-control")).toBe(IMMUTABLE_CACHE_CONTROL);
153
+
154
+ await images.remove(image ?? null, new Headers());
155
+ expect((await get(path)).status).toBe(404);
156
+ });
157
+
158
+ test("returns 404 for names that are not managed images", async () => {
159
+ const store = await localStore();
160
+ const logger = { warn: mock(() => undefined) } as unknown as Logger;
161
+ const get = app(() => null, new ProfileImageService(store, logger, async () => undefined));
162
+
163
+ for (const name of ["missing.png", "123e4567-e89b-42d3-a456-426614174000.png", "..%2Fsite%2Ffavicon"]) {
164
+ expect((await get(`/profile-images/${name}`)).status).toBe(404);
165
+ }
166
+ });
167
+ });
@@ -0,0 +1,50 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { objectResponse } from "@podosoft/podokit-runtime";
3
+ import { Elysia, t } from "elysia";
4
+ import type { ElysiaPlugin } from "../core/services";
5
+ import type { ProfileImageService } from "../profile-image/profile-image.service";
6
+ import type { SiteSettingsService } from "../site-settings/site-settings.service";
7
+
8
+ export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
9
+
10
+ /**
11
+ * Pages link the favicon as `?v=<version>`, and a new upload changes the
12
+ * version, so that URL can be cached for good. Any other URL revalidates.
13
+ */
14
+ export function faviconCacheControl(current: string | null, requested: string | undefined): string {
15
+ return current !== null && requested === current ? IMMUTABLE_CACHE_CONTROL : "no-cache";
16
+ }
17
+
18
+ /** Public images streamed from object storage with validators for 304 replies. */
19
+ export function siteAssetRoutes(
20
+ site: Pick<SiteSettingsService, "getFavicon">,
21
+ profileImages: Pick<ProfileImageService, "get">,
22
+ ): ElysiaPlugin {
23
+ return new Elysia({ name: "podokit.admin.site-assets" })
24
+ .get("/site/favicon", async ({ request, query }) => {
25
+ const icon = await site.getFavicon();
26
+ if (!icon) throw new AppException("FAVICON_NOT_FOUND", "No favicon set", 404);
27
+ return objectResponse(request, icon.object, {
28
+ contentType: icon.contentType,
29
+ cacheControl: faviconCacheControl(icon.version, query.v),
30
+ });
31
+ }, {
32
+ query: t.Object({ v: t.Optional(t.String()) }),
33
+ detail: {
34
+ tags: ["site"],
35
+ summary: "Get the site favicon",
36
+ description: "Cached permanently when `v` is the current `faviconVersion`; revalidated otherwise.",
37
+ },
38
+ })
39
+ .get("/profile-images/:fileName", async ({ request, params }) => {
40
+ const image = await profileImages.get(params.fileName);
41
+ // Every upload gets a new random file name, so a name never changes content.
42
+ return objectResponse(request, image.object, {
43
+ contentType: image.contentType,
44
+ cacheControl: IMMUTABLE_CACHE_CONTROL,
45
+ });
46
+ }, {
47
+ params: t.Object({ fileName: t.String({ minLength: 1 }) }),
48
+ detail: { tags: ["account"], summary: "Get a public profile image" },
49
+ });
50
+ }
@@ -0,0 +1,158 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import type { ObjectData, ObjectStore } from "@podosoft/podokit-runtime";
4
+ import { SiteSettingsService } from "./site-settings.service";
5
+
6
+ const favicon: ObjectData = {
7
+ key: "site/favicon",
8
+ size: 1,
9
+ etag: "\"icon\"",
10
+ lastModified: new Date(0),
11
+ body: new ReadableStream<Uint8Array>(),
12
+ };
13
+ const storage = {
14
+ put: async (): Promise<void> => undefined,
15
+ get: async (key: string): Promise<ObjectData | null> => (key === "site/favicon" ? favicon : null),
16
+ } as unknown as ObjectStore;
17
+
18
+ interface Harness {
19
+ service: SiteSettingsService;
20
+ reads: () => number;
21
+ holdNextRead: () => () => void;
22
+ advance: (milliseconds: number) => void;
23
+ failNextWrite: () => void;
24
+ }
25
+
26
+ describe("SiteSettingsService", () => {
27
+ let db: SQL;
28
+ let harness: Harness;
29
+
30
+ beforeEach(async () => {
31
+ db = new SQL(":memory:");
32
+ await db`CREATE TABLE "app_setting" ("key" TEXT PRIMARY KEY, "value" TEXT NOT NULL, "updatedAt" TEXT)`;
33
+ await db`INSERT INTO "app_setting" ("key", "value") VALUES ('site.name', 'Before'), ('require2fa', 'false')`;
34
+
35
+ let reads = 0;
36
+ let clock = 1_000;
37
+ let hold: Promise<void> | null = null;
38
+ let failWrite = false;
39
+ // Counts reads and can hold a read's result to interleave it with a write.
40
+ const counted = async (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown> => {
41
+ reads += 1;
42
+ const gate = hold;
43
+ hold = null;
44
+ const rows: unknown = await db(strings, ...values);
45
+ if (gate) await gate;
46
+ return rows;
47
+ };
48
+ const sql = Object.assign(counted, {
49
+ begin: <T>(work: (tx: SQL) => Promise<T>): Promise<T> =>
50
+ db.begin(async (tx) => {
51
+ const result = await work(tx as unknown as SQL);
52
+ if (failWrite) {
53
+ failWrite = false;
54
+ throw new Error("write failed");
55
+ }
56
+ return result;
57
+ }),
58
+ });
59
+ harness = {
60
+ service: new SiteSettingsService(sql as unknown as SQL, storage, () => clock),
61
+ reads: () => reads,
62
+ holdNextRead: () => {
63
+ let release: () => void = () => undefined;
64
+ hold = new Promise((resolve) => { release = resolve; });
65
+ return release;
66
+ },
67
+ advance: (milliseconds) => { clock += milliseconds; },
68
+ failNextWrite: () => { failWrite = true; },
69
+ };
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await db.close();
74
+ });
75
+
76
+ test("serves concurrent and repeated reads from one query", async () => {
77
+ const results = await Promise.all([
78
+ harness.service.getAll(),
79
+ harness.service.getAll(),
80
+ harness.service.get("name"),
81
+ harness.service.faviconVersion(),
82
+ ]);
83
+ await harness.service.getAll();
84
+
85
+ expect(harness.reads()).toBe(1);
86
+ expect(results[0]).toEqual({ name: "Before" });
87
+ expect(results[2]).toBe("Before");
88
+ expect(results[3]).toBeNull();
89
+ });
90
+
91
+ test("reads again after the snapshot expires", async () => {
92
+ await harness.service.getAll();
93
+ harness.advance(2_999);
94
+ await harness.service.getAll();
95
+ expect(harness.reads()).toBe(1);
96
+
97
+ harness.advance(1);
98
+ await harness.service.getAll();
99
+ expect(harness.reads()).toBe(2);
100
+ });
101
+
102
+ test("returns a write to the next read immediately", async () => {
103
+ await harness.service.getAll();
104
+
105
+ const saved = await harness.service.setMany({ name: "After", locale: "ko" });
106
+
107
+ expect(saved).toEqual({ name: "After", locale: "ko" });
108
+ expect(await harness.service.getAll()).toEqual({ name: "After", locale: "ko" });
109
+ });
110
+
111
+ test("writes every key in one transaction", async () => {
112
+ harness.failNextWrite();
113
+
114
+ await expect(harness.service.setMany({ name: "After", locale: "ko" })).rejects.toThrow("write failed");
115
+
116
+ expect(await harness.service.getAll()).toEqual({ name: "Before" });
117
+ });
118
+
119
+ test("does not keep a read that raced a write", async () => {
120
+ const release = harness.holdNextRead();
121
+ const stale = harness.service.getAll();
122
+
123
+ await harness.service.setMany({ name: "After" });
124
+ release();
125
+
126
+ expect(await stale).toEqual({ name: "Before" });
127
+ expect(await harness.service.getAll()).toEqual({ name: "After" });
128
+ });
129
+
130
+ test("derives the favicon fields from the same snapshot", async () => {
131
+ await harness.service.setFavicon(new Uint8Array([1]), "image/png");
132
+ const reads = harness.reads();
133
+
134
+ const all = await harness.service.getAll();
135
+
136
+ expect(all.faviconContentType).toBe("image/png");
137
+ expect(await harness.service.faviconVersion()).toBe(all.faviconUpdatedAt ?? "missing");
138
+ expect(harness.reads()).toBe(reads);
139
+ });
140
+
141
+ test("returns the favicon unread with its type and version from the snapshot", async () => {
142
+ expect(await harness.service.getFavicon()).toBeNull();
143
+ await harness.service.setFavicon(new Uint8Array([1]), "image/png");
144
+ const reads = harness.reads();
145
+ const version = await harness.service.faviconVersion();
146
+
147
+ expect(await harness.service.getFavicon()).toEqual({ object: favicon, contentType: "image/png", version });
148
+ expect(version).toMatch(/^\d+$/);
149
+ expect(harness.reads()).toBe(reads);
150
+ });
151
+
152
+ test("copies the snapshot for each caller", async () => {
153
+ const first = await harness.service.getAll();
154
+ first.name = "Changed";
155
+
156
+ expect(await harness.service.getAll()).toEqual({ name: "Before" });
157
+ });
158
+ });
@@ -27,46 +27,63 @@ interface SiteSettingRow {
27
27
  }
28
28
 
29
29
  export interface SiteFavicon {
30
- body: Buffer;
30
+ object: ObjectData;
31
+ /** The type validated on upload. */
31
32
  contentType: string;
33
+ /** The `faviconUpdatedAt` setting that pages put in the favicon URL. */
34
+ version: string | null;
32
35
  }
33
36
 
34
- async function objectBuffer(object: ObjectData): Promise<Buffer> {
35
- const chunks: Uint8Array[] = [];
36
- for await (const chunk of object.body) chunks.push(chunk);
37
- return Buffer.concat(chunks);
37
+ // Every page render reads the site settings, so one short-lived snapshot serves
38
+ // concurrent renders. Writes on this replica clear it immediately; another
39
+ // replica sees the change within this window.
40
+ const SNAPSHOT_TTL_MS = 3_000;
41
+
42
+ interface Snapshot {
43
+ values: Record<string, string>;
44
+ expiresAt: number;
38
45
  }
39
46
 
40
47
  export class SiteSettingsService {
48
+ private snapshot: Snapshot | null = null;
49
+ private pending: Promise<Record<string, string>> | null = null;
50
+ private generation = 0;
51
+
41
52
  constructor(
42
53
  private readonly sql: SQL,
43
54
  private readonly storage: ObjectStore,
55
+ private readonly now: () => number = Date.now,
44
56
  ) {}
45
57
 
58
+ /** Every `site.*` setting, keyed without the prefix. */
46
59
  async getAll(): Promise<Record<string, string>> {
47
- const rows = await this.sql<SiteSettingRow[]>`
48
- SELECT "key", "value" FROM "app_setting" WHERE "key" LIKE 'site.%'
49
- `;
50
- const result: Record<string, string> = {};
51
- for (const row of rows) result[row.key.slice(PREFIX.length)] = row.value;
52
- return result;
60
+ const snapshot = this.snapshot;
61
+ if (snapshot && snapshot.expiresAt > this.now()) return { ...snapshot.values };
62
+ return { ...(await this.load()) };
53
63
  }
54
64
 
55
65
  async get(key: string): Promise<string | null> {
56
- const rows = await this.sql<SiteSettingRow[]>`
57
- SELECT "key", "value" FROM "app_setting" WHERE "key" = ${PREFIX + key}
58
- `;
59
- return rows[0]?.value ?? null;
66
+ return (await this.getAll())[key] ?? null;
60
67
  }
61
68
 
62
69
  async setMany(update: Record<string, string>): Promise<Record<string, string>> {
63
- for (const [key, value] of Object.entries(update)) {
64
- await this.sql`
65
- INSERT INTO "app_setting" ("key", "value", "updatedAt")
66
- VALUES (${PREFIX + key}, ${value}, CURRENT_TIMESTAMP)
67
- ON CONFLICT ("key") DO UPDATE
68
- SET "value" = EXCLUDED."value", "updatedAt" = CURRENT_TIMESTAMP
69
- `;
70
+ const entries = Object.entries(update);
71
+ if (entries.length > 0) {
72
+ try {
73
+ await this.sql.begin(async (tx) => {
74
+ for (const [key, value] of entries) {
75
+ await tx`
76
+ INSERT INTO "app_setting" ("key", "value", "updatedAt")
77
+ VALUES (${PREFIX + key}, ${value}, CURRENT_TIMESTAMP)
78
+ ON CONFLICT ("key") DO UPDATE
79
+ SET "value" = EXCLUDED."value", "updatedAt" = CURRENT_TIMESTAMP
80
+ `;
81
+ }
82
+ });
83
+ } finally {
84
+ // Also after a failure: the outcome of an interrupted commit is unknown.
85
+ this.invalidate();
86
+ }
70
87
  }
71
88
  return this.getAll();
72
89
  }
@@ -79,17 +96,50 @@ export class SiteSettingsService {
79
96
  });
80
97
  }
81
98
 
99
+ /** The favicon with its content unread, or null when none is set. */
82
100
  async getFavicon(): Promise<SiteFavicon | null> {
83
- const contentType = await this.get("faviconContentType");
101
+ const settings = await this.getAll();
102
+ const contentType = settings.faviconContentType;
84
103
  if (!contentType) return null;
85
- try {
86
- return { body: await objectBuffer(await this.storage.get(FAVICON_KEY)), contentType };
87
- } catch {
88
- return null;
89
- }
104
+ const object = await this.storage.get(FAVICON_KEY);
105
+ return object ? { object, contentType, version: settings.faviconUpdatedAt ?? null } : null;
90
106
  }
91
107
 
92
108
  faviconVersion(): Promise<string | null> {
93
109
  return this.get("faviconUpdatedAt");
94
110
  }
111
+
112
+ /** One query at a time; concurrent readers share it. */
113
+ private load(): Promise<Record<string, string>> {
114
+ if (this.pending) return this.pending;
115
+ const generation = this.generation;
116
+ const pending = this.query()
117
+ .then((values) => {
118
+ // A write that finished meanwhile may not be in these rows.
119
+ if (generation === this.generation) {
120
+ this.snapshot = { values, expiresAt: this.now() + SNAPSHOT_TTL_MS };
121
+ }
122
+ return values;
123
+ })
124
+ .finally(() => {
125
+ if (this.pending === pending) this.pending = null;
126
+ });
127
+ this.pending = pending;
128
+ return pending;
129
+ }
130
+
131
+ private invalidate(): void {
132
+ this.generation += 1;
133
+ this.snapshot = null;
134
+ this.pending = null;
135
+ }
136
+
137
+ private async query(): Promise<Record<string, string>> {
138
+ const rows = await this.sql<SiteSettingRow[]>`
139
+ SELECT "key", "value" FROM "app_setting" WHERE "key" LIKE 'site.%'
140
+ `;
141
+ const result: Record<string, string> = {};
142
+ for (const row of rows) result[row.key.slice(PREFIX.length)] = row.value;
143
+ return result;
144
+ }
95
145
  }
@@ -1,3 +1,4 @@
1
+ import type { Capabilities } from "@podosoft/podokit-api-client";
1
2
  import type { Locale } from "#lib/i18n/messages.js";
2
3
  import type { SiteSettings } from "#lib/site.svelte.js";
3
4
 
@@ -24,6 +25,8 @@ declare global {
24
25
  locale: Locale;
25
26
  site: SiteSettings | null;
26
27
  siteUnavailable: boolean;
28
+ /** Per-request memo; read it through `getCapabilities()` from `#lib/server/capabilities.js`. */
29
+ capabilities?: Promise<Capabilities>;
27
30
  }
28
31
  }
29
32
  }
@@ -0,0 +1,124 @@
1
+ import { ApiError } from "@podosoft/podokit-api-client";
2
+ import type { RequestEvent } from "@sveltejs/kit";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { handle } from "./hooks.server";
5
+
6
+ type SessionResult = {
7
+ data: { user: { id: string }; session: { id: string; impersonatedBy?: string | null } } | null;
8
+ error: { status: number } | null;
9
+ };
10
+
11
+ const client = vi.hoisted(() => ({
12
+ get: vi.fn<(path: string) => Promise<unknown>>(),
13
+ getSession: vi.fn<() => Promise<SessionResult>>(),
14
+ }));
15
+
16
+ vi.mock("#lib/server/api.js", () => ({
17
+ serverApiClient: () => ({ get: client.get, auth: { getSession: client.getSession } }),
18
+ }));
19
+
20
+ const resolve = vi.fn(async (): Promise<Response> => new Response("ok"));
21
+
22
+ function requestEvent(pathname: string, cookies: Record<string, string> = {}): RequestEvent {
23
+ return {
24
+ route: { id: pathname },
25
+ url: new URL(`http://localhost${pathname}`),
26
+ cookies: { get: (name: string) => cookies[name] },
27
+ locals: {},
28
+ } as unknown as RequestEvent;
29
+ }
30
+
31
+ const signedIn: SessionResult = {
32
+ data: { user: { id: "user-1" }, session: { id: "session-1", impersonatedBy: "admin-1" } },
33
+ error: null,
34
+ };
35
+
36
+ describe("handle", () => {
37
+ beforeEach(() => {
38
+ client.get.mockReset();
39
+ client.getSession.mockReset();
40
+ resolve.mockClear();
41
+ });
42
+
43
+ it("requests the session without waiting for site settings", async () => {
44
+ let releaseSite: (value: unknown) => void = () => undefined;
45
+ client.get.mockReturnValue(new Promise((settle) => { releaseSite = settle; }));
46
+ client.getSession.mockResolvedValue(signedIn);
47
+ const event = requestEvent("/account");
48
+
49
+ const pending = handle({ event, resolve });
50
+ await vi.waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1));
51
+ releaseSite({ locale: "ko" });
52
+ await pending;
53
+
54
+ expect(client.get).toHaveBeenCalledWith("/site/settings");
55
+ expect(event.locals).toMatchObject({
56
+ site: { locale: "ko" },
57
+ siteUnavailable: false,
58
+ authUnavailable: false,
59
+ locale: "ko",
60
+ user: { id: "user-1" },
61
+ session: { id: "session-1", impersonatedBy: "admin-1" },
62
+ });
63
+ });
64
+
65
+ it("treats a site settings client error as a reachable backend", async () => {
66
+ client.get.mockRejectedValue(new ApiError("TWO_FACTOR_REQUIRED", "Two-factor enrolment is required.", 403));
67
+ client.getSession.mockResolvedValue(signedIn);
68
+ const event = requestEvent("/account", { locale: "en" });
69
+
70
+ await handle({ event, resolve });
71
+
72
+ expect(event.locals).toMatchObject({ site: null, siteUnavailable: false, locale: "en" });
73
+ expect(resolve).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it("fails closed on protected pages when site settings are unavailable", async () => {
77
+ client.get.mockRejectedValue(new TypeError("fetch failed"));
78
+ client.getSession.mockResolvedValue(signedIn);
79
+
80
+ await expect(handle({ event: requestEvent("/account"), resolve })).rejects.toMatchObject({ status: 503 });
81
+ expect(resolve).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("keeps public pages available with fallbacks when the backend fails", async () => {
85
+ client.get.mockRejectedValue(new ApiError("UNAVAILABLE", "Unavailable", 503));
86
+ client.getSession.mockRejectedValue(new TypeError("fetch failed"));
87
+ const event = requestEvent("/login");
88
+
89
+ await handle({ event, resolve });
90
+
91
+ expect(event.locals).toMatchObject({
92
+ site: null,
93
+ siteUnavailable: true,
94
+ authUnavailable: true,
95
+ user: null,
96
+ session: null,
97
+ locale: "en",
98
+ });
99
+ });
100
+
101
+ it("confirms an anonymous visitor on a 401 session response", async () => {
102
+ client.get.mockResolvedValue({ locale: null });
103
+ client.getSession.mockResolvedValue({ data: null, error: { status: 401 } });
104
+ const event = requestEvent("/");
105
+
106
+ await handle({ event, resolve });
107
+
108
+ expect(event.locals).toMatchObject({ authUnavailable: false, user: null, session: null });
109
+ });
110
+
111
+ it("fails closed on protected pages when the session cannot be checked", async () => {
112
+ client.get.mockResolvedValue({ locale: null });
113
+ client.getSession.mockResolvedValue({ data: null, error: { status: 500 } });
114
+
115
+ await expect(handle({ event: requestEvent("/account"), resolve })).rejects.toMatchObject({ status: 503 });
116
+ });
117
+
118
+ it("skips runtime lookups for API proxy requests", async () => {
119
+ await handle({ event: requestEvent("/api/site/settings"), resolve });
120
+
121
+ expect(client.get).not.toHaveBeenCalled();
122
+ expect(client.getSession).not.toHaveBeenCalled();
123
+ });
124
+ });