@podosoft/podokit 1.3.0 → 1.4.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 (145) hide show
  1. package/dist/add.d.ts +10 -1
  2. package/dist/add.js +20 -7
  3. package/dist/api-paths.d.ts +34 -0
  4. package/dist/api-paths.js +103 -0
  5. package/dist/deploy-compose-profile.d.ts +4 -0
  6. package/dist/deploy-compose-profile.js +13 -2
  7. package/dist/deploy-compose-render.d.ts +1 -1
  8. package/dist/deploy-compose-render.js +23 -7
  9. package/dist/deploy-compose.js +2 -1
  10. package/dist/deploy-profile.d.ts +3 -0
  11. package/dist/deploy-profile.js +12 -4
  12. package/dist/deploy-render.js +54 -8
  13. package/dist/deploy.js +21 -1
  14. package/dist/dev.d.ts +14 -0
  15. package/dist/dev.js +152 -10
  16. package/dist/index.js +15 -2
  17. package/dist/lib.d.ts +2 -0
  18. package/dist/lib.js +4 -2
  19. package/dist/lockfile.d.ts +3 -0
  20. package/dist/lockfile.js +9 -0
  21. package/dist/module-api-paths.d.ts +52 -0
  22. package/dist/module-api-paths.js +189 -0
  23. package/dist/remove.d.ts +3 -0
  24. package/dist/remove.js +12 -3
  25. package/dist/templates/fullstack/AGENTS.md +17 -2
  26. package/dist/templates/fullstack/README.md +19 -5
  27. package/dist/templates/fullstack/apps/api/package.json +1 -1
  28. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  29. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  30. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  31. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  32. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  33. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  34. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  35. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  36. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  37. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  38. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  39. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  40. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
  41. package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
  42. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  43. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  44. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  45. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  46. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  47. package/dist/templates/fullstack/apps/web/components.json +3 -1
  48. package/dist/templates/fullstack/apps/web/package.json +1 -1
  49. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  50. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  51. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  52. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  53. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  55. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  56. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  57. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  58. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  59. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  60. package/dist/templates/fullstack/dot-env.example +10 -0
  61. package/dist/templates/fullstack/dot-gitignore +1 -0
  62. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  63. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  64. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  65. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  67. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  68. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  69. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  70. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  71. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  72. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  73. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  74. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  75. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  76. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  87. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  88. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  89. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  90. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  91. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  92. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  93. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  94. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  95. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  96. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  97. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  98. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  99. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  100. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  101. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  102. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  103. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  104. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  105. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
  106. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  107. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  108. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  109. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  110. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  111. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  112. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  113. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  114. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  115. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  116. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  117. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  118. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  119. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  120. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  121. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  122. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  124. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  125. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  126. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  127. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  128. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  129. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  130. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  131. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  132. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  133. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  134. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  135. package/dist/templates/modules/redis/module.manifest.json +4 -0
  136. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  137. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  138. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  139. package/dist/templates/todo/AGENTS.md +9 -1
  140. package/dist/templates/todo/README.md +18 -5
  141. package/dist/update.d.ts +5 -0
  142. package/dist/update.js +35 -0
  143. package/package.json +2 -2
  144. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  145. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -16,11 +16,18 @@ POSTGRES_DB=podokit
16
16
  # queries, plus DB_AUTH_POOL_MAX for the auth module when it is installed. Keep
17
17
  # replicas x (DB_POOL_MAX + DB_AUTH_POOL_MAX), plus a few for migrations, below
18
18
  # the server's max_connections (100 by default). The timeouts are milliseconds.
19
+ # DB_QUERY_TIMEOUT_MS bounds how long a query waits for a server that stopped
20
+ # answering; migrations and maintenance commands are not bounded.
19
21
  # DB_POOL_MAX=20
20
22
  # DB_CONNECT_TIMEOUT_MS=10000
21
23
  # DB_IDLE_TIMEOUT_MS=30000
24
+ # DB_QUERY_TIMEOUT_MS=10000
22
25
  # DB_AUTH_POOL_MAX=10
23
26
 
27
+ # Largest API request body in bytes (Bun's default is 128 MiB). Requests through
28
+ # the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
29
+ # API_MAX_REQUEST_BODY_SIZE=134217728
30
+
24
31
  # Readiness (GET /health/ready): the longest one dependency check may run, and how
25
32
  # long a result is reused across probes (0 checks on every request).
26
33
  # READINESS_CHECK_TIMEOUT_MS=800
@@ -44,3 +51,6 @@ REDIS_DB=0
44
51
  # REDIS_USERNAME=
45
52
  # REDIS_PASSWORD=
46
53
  REDIS_TLS=false
54
+ # Longest wait in milliseconds for Redis to answer one command. A command that
55
+ # gets no reply in time fails, and its connection is closed and reopened.
56
+ # REDIS_COMMAND_TIMEOUT_MS=1000
@@ -5,6 +5,7 @@ build/
5
5
  .env
6
6
  .env.*
7
7
  !.env.example
8
+ !.env.docker.example
8
9
  .DS_Store
9
10
  *.log
10
11
 
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "hostname": "{{projectName}}.localhost",
4
- "webSocketPaths": []
4
+ "webSocketPaths": [],
5
+ "apiPaths": []
5
6
  }
@@ -20,4 +20,6 @@ branch tags, and empty tags, and so should you.
20
20
  For a hand-managed API WebSocket endpoint, add a `pathType: Exact` Ingress path to
21
21
  the API service before the existing `/` web path. Do not route a prefix or the root
22
22
  to API. Deployment profiles express the same rule with
23
- `exposure.webSocketPaths` and validate it before rendering.
23
+ `exposure.webSocketPaths` and validate it before rendering. A large-upload route
24
+ under `/api/` needs `/api` removed on the way to the API; `exposure.apiPaths`
25
+ renders a separate Ingress with a Traefik `stripPrefix` Middleware for that.
@@ -3,7 +3,7 @@
3
3
  // The command runs against a migrated schema and reports a missing table from its
4
4
  // own query. Better Auth's asynchronous startup check would only race the
5
5
  // connection the command closes when it finishes.
6
- import "./auth/skip-schema-validation";
6
+ import "./auth/maintenance-command";
7
7
  import { verifyPassword } from "better-auth/crypto";
8
8
  import { auth } from "./auth/auth";
9
9
  import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
@@ -1,9 +1,12 @@
1
- import { error } from "@sveltejs/kit";
1
+ import { error, type RequestEvent } from "@sveltejs/kit";
2
2
  import type { Handle } from "@sveltejs/kit/hooks";
3
+ import { building } from "$app/env";
4
+ import { routes } from "$app/manifest";
3
5
  import { serverApiClient } from "#lib/server/api.js";
4
6
  import { resolveLocale } from "#lib/i18n/messages.js";
5
7
  import { applySearchIndexingHeaders } from "#lib/server/search-indexing.js";
6
8
  import { isPublicPath } from "#lib/server/guards.js";
9
+ import { needsRuntimeContext } from "#lib/server/runtime-context.js";
7
10
  import type { SiteSettings } from "#lib/site.svelte.js";
8
11
  import { ApiError } from "@podosoft/podokit-api-client";
9
12
 
@@ -12,15 +15,18 @@ function isTemporaryServiceFailure(cause: unknown): boolean {
12
15
  return cause.statusCode === 408 || cause.statusCode === 429 || cause.statusCode >= 500;
13
16
  }
14
17
 
15
- export const handle: Handle = async ({ event, resolve }) => {
16
- // Static assets and API proxy requests do not render application layouts.
17
- // Avoid multiplying settings/session API calls for every asset and proxied
18
- // request; page and data routes still receive the complete runtime context.
19
- if (event.route.id === null || event.url.pathname.startsWith("/api/")) {
20
- const response = await resolve(event);
21
- return applySearchIndexingHeaders(response, event.url.pathname);
22
- }
18
+ // An anonymous visitor with no site settings, for responses that render no
19
+ // layout. The locale still follows the visitor's cookie.
20
+ function setEmptyRuntimeContext(event: RequestEvent): void {
21
+ event.locals.site = null;
22
+ event.locals.siteUnavailable = false;
23
+ event.locals.user = null;
24
+ event.locals.session = null;
25
+ event.locals.authUnavailable = false;
26
+ event.locals.locale = resolveLocale(event.cookies.get("locale"));
27
+ }
23
28
 
29
+ async function loadRuntimeContext(event: RequestEvent): Promise<void> {
24
30
  // Public site settings (branding, locale/timezone defaults, maintenance, …)
25
31
  // and the session are independent, so both requests run at once. The
26
32
  // settings are loaded once here and reused by the layout load. The async
@@ -73,6 +79,18 @@ export const handle: Handle = async ({ event, resolve }) => {
73
79
  ) {
74
80
  error(503, "Service temporarily unavailable");
75
81
  }
82
+ }
83
+
84
+ export const handle: Handle = async ({ event, resolve }) => {
85
+ // Listed asset endpoints, missing subresources, the API proxy, and
86
+ // prerendering skip the two API lookups. Pages, their data, form actions,
87
+ // remote functions, and the 404 page get the complete runtime context.
88
+ const route = routes.find(({ id }) => id === event.route.id);
89
+ if (needsRuntimeContext(event, { building, route })) {
90
+ await loadRuntimeContext(event);
91
+ } else {
92
+ setEmptyRuntimeContext(event);
93
+ }
76
94
  const response = await resolve(event, {
77
95
  transformPageChunk: ({ html }) =>
78
96
  html.replace(
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Application-owned prefixes for `+server.ts` endpoints that serve files, feeds,
3
+ * or other responses needing neither the session nor the site settings (for
4
+ * example "/docs" when an endpoint serves documentation files). A listed path
5
+ * also covers its subpaths. When an endpoint under a listed prefix answers a
6
+ * request, hooks.server.ts skips its two API lookups and the endpoint sees an
7
+ * anonymous `locals.user`. A `+page` under a listed prefix still gets the
8
+ * session and site settings, so it keeps its sign-in and maintenance checks.
9
+ * PodoKit creates this file once and never overwrites it.
10
+ */
11
+ export const APP_ASSET_PATHS: readonly string[] = [];
@@ -0,0 +1,125 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ // Stands in for the application's own list in the app-owned asset-paths.ts.
4
+ vi.mock("./asset-paths", () => ({ APP_ASSET_PATHS: ["/docs", "/feed.xml"] }));
5
+
6
+ const { needsRuntimeContext, prefersHtml } = await import("./runtime-context");
7
+
8
+ const NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
9
+ const IMAGE = "image/avif,image/webp,image/apng,*/*;q=0.8";
10
+
11
+ interface RequestOptions {
12
+ routeId?: string | null;
13
+ method?: string;
14
+ headers?: Record<string, string>;
15
+ isDataRequest?: boolean;
16
+ isRemoteRequest?: boolean;
17
+ }
18
+
19
+ function request(path: string, options: RequestOptions = {}) {
20
+ const url = new URL(path, "http://app.localhost");
21
+ return {
22
+ url,
23
+ route: { id: options.routeId === undefined ? url.pathname : options.routeId },
24
+ request: new Request(url, { method: options.method ?? "GET", headers: options.headers }),
25
+ isDataRequest: options.isDataRequest ?? false,
26
+ isRemoteRequest: options.isRemoteRequest ?? false,
27
+ };
28
+ }
29
+
30
+ describe("needsRuntimeContext", () => {
31
+ it("loads the session and site settings for pages and their data", () => {
32
+ expect(needsRuntimeContext(request("/account", { headers: { accept: NAVIGATION } }))).toBe(true);
33
+ expect(needsRuntimeContext(request("/account", { headers: { accept: "*/*" } }))).toBe(true);
34
+ expect(needsRuntimeContext(request("/account", { isDataRequest: true }))).toBe(true);
35
+ });
36
+
37
+ it("loads them for an unmatched route because the 404 page renders the root layout", () => {
38
+ // Without the session a signed-in visitor was sent to /login, which sent
39
+ // them straight back: a redirect loop on every missing page.
40
+ expect(
41
+ needsRuntimeContext(
42
+ request("/missing", { routeId: null, headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
43
+ ),
44
+ ).toBe(true);
45
+ expect(needsRuntimeContext(request("/missing", { routeId: null, headers: { accept: "*/*" } }))).toBe(true);
46
+ expect(needsRuntimeContext(request("/missing", { routeId: null, isDataRequest: true }))).toBe(true);
47
+ });
48
+
49
+ it("skips them for a missing subresource that SvelteKit answers without a layout", () => {
50
+ for (const destination of ["image", "script", "style", "font"]) {
51
+ expect(
52
+ needsRuntimeContext(request("/missing.png", { routeId: null, headers: { "sec-fetch-dest": destination } })),
53
+ ).toBe(false);
54
+ }
55
+ });
56
+
57
+ it("skips them for the backend proxy", () => {
58
+ expect(needsRuntimeContext(request("/api", { routeId: "/api/[...path]" }))).toBe(false);
59
+ expect(needsRuntimeContext(request("/api/site/settings", { routeId: "/api/[...path]" }))).toBe(false);
60
+ expect(needsRuntimeContext(request("/api/auth/get-session", { routeId: "/api/auth/[...all]" }))).toBe(false);
61
+ expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
62
+ });
63
+
64
+ it("skips them while prerendering", () => {
65
+ expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
66
+ });
67
+
68
+ it("loads them for remote functions, which may read the session without a route", () => {
69
+ expect(needsRuntimeContext(request("/_app/remote/abc/list", { routeId: null, isRemoteRequest: true }))).toBe(true);
70
+ });
71
+
72
+ it("skips them when an endpoint under an application asset path answers", () => {
73
+ const files = { page: false, endpoint: true };
74
+ expect(
75
+ needsRuntimeContext(
76
+ request("/docs/guide/diagram.png", { routeId: "/docs/[...path]", headers: { accept: IMAGE, "sec-fetch-dest": "image" } }),
77
+ { route: files },
78
+ ),
79
+ ).toBe(false);
80
+ // Opening a file in a tab is a navigation, but no page renders.
81
+ expect(
82
+ needsRuntimeContext(
83
+ request("/docs/manual.pdf", { routeId: "/docs/[...path]", headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
84
+ { route: files },
85
+ ),
86
+ ).toBe(false);
87
+ expect(needsRuntimeContext(request("/feed.xml", { headers: { accept: "application/rss+xml" } }), { route: files })).toBe(false);
88
+ expect(needsRuntimeContext(request("/documents", { headers: { accept: "*/*" } }), { route: files })).toBe(true);
89
+ });
90
+
91
+ it("keeps them when a page under an application asset path answers", () => {
92
+ const page = { page: true, endpoint: false };
93
+ expect(needsRuntimeContext(request("/docs", { headers: { accept: "*/*" } }), { route: page })).toBe(true);
94
+ expect(needsRuntimeContext(request("/docs", { headers: { accept: NAVIGATION } }), { route: page })).toBe(true);
95
+ });
96
+
97
+ it("follows SvelteKit's page or endpoint choice when an asset route has both", () => {
98
+ const both = { page: true, endpoint: true };
99
+ const docs = (options: RequestOptions) =>
100
+ needsRuntimeContext(request("/docs/intro", { routeId: "/docs/[slug]", ...options }), { route: both });
101
+ expect(docs({ headers: { accept: NAVIGATION } })).toBe(true);
102
+ expect(docs({ isDataRequest: true })).toBe(true);
103
+ expect(docs({ method: "POST", headers: { accept: "application/json", "x-sveltekit-action": "true" } })).toBe(true);
104
+ expect(docs({ headers: { accept: IMAGE } })).toBe(false);
105
+ expect(docs({ headers: { accept: "*/*" } })).toBe(false);
106
+ expect(docs({ method: "PUT", headers: { accept: NAVIGATION } })).toBe(false);
107
+ // Without route information the same negotiation applies.
108
+ expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: IMAGE } }))).toBe(false);
109
+ expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: NAVIGATION } }))).toBe(true);
110
+ });
111
+ });
112
+
113
+ describe("prefersHtml", () => {
114
+ it("follows SvelteKit's page and endpoint negotiation", () => {
115
+ expect(prefersHtml(NAVIGATION)).toBe(true);
116
+ expect(prefersHtml("text/*")).toBe(true);
117
+ expect(prefersHtml("*/*")).toBe(false);
118
+ expect(prefersHtml(null)).toBe(false);
119
+ expect(prefersHtml(IMAGE)).toBe(false);
120
+ expect(prefersHtml("application/json, text/html;q=0.5")).toBe(true);
121
+ expect(prefersHtml("application/json, */*;q=0.9, text/html;q=0.5")).toBe(false);
122
+ expect(prefersHtml("*/*;q=0.9, text/html")).toBe(true);
123
+ expect(prefersHtml("text/html;q=0, */*")).toBe(false);
124
+ });
125
+ });
@@ -0,0 +1,107 @@
1
+ import { APP_ASSET_PATHS } from "./asset-paths";
2
+
3
+ /** The request fields that decide whether a response can use the session and site settings. */
4
+ export interface RuntimeContextRequest {
5
+ url: URL;
6
+ route: { id: string | null };
7
+ request: Request;
8
+ isDataRequest: boolean;
9
+ isRemoteRequest: boolean;
10
+ }
11
+
12
+ // Fetch destinations that SvelteKit answers with a plain-text 404, without the
13
+ // root layout, when no route matches.
14
+ const SUBRESOURCE_DESTINATIONS = new Set([
15
+ "audio",
16
+ "audioworklet",
17
+ "font",
18
+ "image",
19
+ "json",
20
+ "manifest",
21
+ "paintworklet",
22
+ "report",
23
+ "script",
24
+ "serviceworker",
25
+ "sharedworker",
26
+ "style",
27
+ "track",
28
+ "video",
29
+ "webidentity",
30
+ "worker",
31
+ "xslt",
32
+ ]);
33
+
34
+ function underPrefix(pathname: string, prefix: string): boolean {
35
+ return pathname === prefix || (prefix !== "/" && pathname.startsWith(`${prefix}/`));
36
+ }
37
+
38
+ /**
39
+ * True when an Accept header ranks HTML ahead of the wildcard. This mirrors how
40
+ * SvelteKit chooses between a page and an endpoint on the same route.
41
+ */
42
+ export function prefersHtml(accept: string | null): boolean {
43
+ const ranges = (accept ?? "*/*")
44
+ .split(",")
45
+ .map((part, index) => {
46
+ const [range = "", ...params] = part.trim().toLowerCase().split(";");
47
+ const quality = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
48
+ return { range: range.trim(), q: quality ? Number(quality.slice(2)) : 1, index };
49
+ })
50
+ .filter(({ range, q }) => range.includes("/") && q > 0)
51
+ .sort((a, b) => b.q - a.q || specificity(b.range) - specificity(a.range) || a.index - b.index);
52
+ const html = ranges.findIndex(({ range }) => range === "text/html" || range === "text/*");
53
+ const wildcard = ranges.findIndex(({ range }) => range === "*/*");
54
+ return html !== -1 && (wildcard === -1 || html < wildcard);
55
+ }
56
+
57
+ function specificity(range: string): number {
58
+ if (range === "*/*") return 0;
59
+ return range.endsWith("/*") ? 1 : 2;
60
+ }
61
+
62
+ /** Which files the matched route has, from `routes` in `$app/manifest`. */
63
+ export interface RouteFiles {
64
+ page: boolean;
65
+ endpoint: boolean;
66
+ }
67
+
68
+ /** True when SvelteKit answers the request with the route's `+page` rather than its `+server`. */
69
+ function answeredByPage(event: RuntimeContextRequest, route: RouteFiles | undefined): boolean {
70
+ if (route && !route.page) return false;
71
+ if (route && !route.endpoint) return true;
72
+ if (event.isDataRequest) return true;
73
+ const { headers, method } = event.request;
74
+ if (method !== "GET" && method !== "HEAD" && method !== "POST") return false;
75
+ if (method === "POST" && headers.get("x-sveltekit-action") === "true") return true;
76
+ return prefersHtml(headers.get("accept"));
77
+ }
78
+
79
+ /**
80
+ * Decide whether hooks.server.ts must load the session and the site settings
81
+ * before SvelteKit answers the request. It skips them only when the response
82
+ * cannot render a layout, or when the application listed the path in
83
+ * APP_ASSET_PATHS and a `+server` endpoint, not a `+page`, answers it.
84
+ */
85
+ export function needsRuntimeContext(
86
+ event: RuntimeContextRequest,
87
+ options: { building?: boolean; route?: RouteFiles } = {},
88
+ ): boolean {
89
+ // Prerendering runs at build time without the API, and the stored HTML must
90
+ // not depend on one visitor's session or on runtime site settings.
91
+ if (options.building) return false;
92
+ const { pathname } = event.url;
93
+ // The backend proxy forwards the cookies; the API resolves the session itself.
94
+ if (underPrefix(pathname, "/api")) return false;
95
+ // Remote functions run application code that may read the session.
96
+ if (event.isRemoteRequest) return true;
97
+ if (event.route.id === null) {
98
+ // SvelteKit renders its 404 page with the root layout, which redirects or
99
+ // greets the visitor, unless the browser fetched a subresource.
100
+ const destination = event.request.headers.get("sec-fetch-dest") ?? "";
101
+ return !SUBRESOURCE_DESTINATIONS.has(destination);
102
+ }
103
+ if (APP_ASSET_PATHS.some((prefix) => underPrefix(pathname, prefix))) {
104
+ return answeredByPage(event, options.route);
105
+ }
106
+ return true;
107
+ }
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN } from "../helpers/accounts";
3
+ import { expectObjectLength } from "../helpers/object-response";
3
4
 
4
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
6
  const origin = { origin: base };
@@ -48,7 +49,7 @@ test("profile image upload, replacement, public read, and removal", async ({ pla
48
49
  expect(firstImage.ok()).toBeTruthy();
49
50
  expect(firstImage.headers()["content-type"]).toContain("image/png");
50
51
  expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
51
- expect(firstImage.headers()["content-length"]).toBe(String(PNG.byteLength));
52
+ expectObjectLength(firstImage.headers(), PNG.byteLength);
52
53
  expect((await firstImage.body()).equals(PNG)).toBe(true);
53
54
  const etag = firstImage.headers()["etag"];
54
55
  expect(etag).toMatch(/^"[^"]+"$/);
@@ -1,5 +1,6 @@
1
1
  import { expect, test } from "@playwright/test";
2
2
  import { ADMIN } from "../helpers/accounts";
3
+ import { expectObjectLength } from "../helpers/object-response";
3
4
 
4
5
  const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
5
6
  const origin = { origin: base };
@@ -101,7 +102,7 @@ test("site favicon: the versioned URL is cached for good and other URLs revalida
101
102
  expect(current.status()).toBe(200);
102
103
  expect(current.headers()["cache-control"]).toBe(IMMUTABLE);
103
104
  expect(current.headers()["content-type"]).toContain("image/png");
104
- expect(current.headers()["content-length"]).toBe(String(ICON.byteLength));
105
+ expectObjectLength(current.headers(), ICON.byteLength);
105
106
  expect((await current.body()).equals(ICON)).toBe(true);
106
107
  const etag = current.headers()["etag"] ?? "";
107
108
  expect(etag).toMatch(/^"[^"]+"$/);
@@ -35,16 +35,16 @@ async function readCreatedUserId(response: CreatedUserResponse): Promise<string>
35
35
  export const test = base.extend<{ disposableUsers: DisposableUsers }>({
36
36
  disposableUsers: async ({ context, playwright }, use, testInfo) => {
37
37
  // API projects do not inherit the UI project's admin storageState, while a
38
- // second admin sign-in can invalidate the shared UI session. Authenticate an
39
- // isolated request context only for API tests and reuse browser cookies for UI.
40
- let request = context.request;
41
- let ownsRequest = false;
38
+ // second admin sign-in can invalidate the shared UI session. API tests sign in
39
+ // an isolated request context. UI tests copy the browser's admin cookies into
40
+ // their own context once, so a test that later signs its page in as another
41
+ // user does not turn cleanup into that user's (forbidden) requests.
42
+ const request = await playwright.request.newContext({
43
+ baseURL,
44
+ extraHTTPHeaders: origin,
45
+ ...(testInfo.project.name === "api" ? {} : { storageState: await context.storageState() }),
46
+ });
42
47
  if (testInfo.project.name === "api") {
43
- request = await playwright.request.newContext({
44
- baseURL,
45
- extraHTTPHeaders: origin,
46
- });
47
- ownsRequest = true;
48
48
  const signIn = await request.post("/api/auth/sign-in/email", {
49
49
  data: { email: ADMIN.email, password: ADMIN.password },
50
50
  });
@@ -94,7 +94,7 @@ export const test = base.extend<{ disposableUsers: DisposableUsers }>({
94
94
  }
95
95
  }
96
96
  } finally {
97
- if (ownsRequest) await request.dispose();
97
+ await request.dispose();
98
98
  }
99
99
  }
100
100
  },
@@ -0,0 +1,12 @@
1
+ import { expect } from "@playwright/test";
2
+
3
+ // IN_MEMORY_READ_BYTES of @podosoft/podokit-runtime. Both object storage
4
+ // providers and the web proxy keep the Content-Length of objects up to this
5
+ // size; Bun may send larger ones chunked.
6
+ const IN_MEMORY_READ_BYTES = 2 * 1024 * 1024;
7
+
8
+ export function expectObjectLength(headers: Record<string, string>, size: number): void {
9
+ const length = headers["content-length"];
10
+ if (length === undefined && size > IN_MEMORY_READ_BYTES) return;
11
+ expect(length).toBe(String(size));
12
+ }
@@ -14,7 +14,8 @@
14
14
  "ownedGlobs": [
15
15
  "apps/web/src/lib/i18n/locales/**",
16
16
  "apps/web/src/lib/i18n/catalogs/app/**",
17
- "apps/web/src/lib/server/public-paths.ts"
17
+ "apps/web/src/lib/server/public-paths.ts",
18
+ "apps/web/src/lib/server/asset-paths.ts"
18
19
  ],
19
20
  "managedOverrides": [
20
21
  "apps/web/src/lib/components/ui/input/input.svelte",
@@ -118,7 +119,7 @@
118
119
  "file": "AGENTS.md",
119
120
  "marker": "<!-- podokit:end:agents-modules -->",
120
121
  "optional": true,
121
- "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
122
+ "text": "### admin-dashboard\n- The `(admin)` route group and `admin-sidebar.svelte` are the admin-console shell for `/admin/*` only. Put signed-in product pages in `(app)` or in a separate route group with its own layout; do not place them under `(admin)`.\n- Admin console lives at `/admin/*` (users, sessions, organizations, audit, settings, account). Set `ADMIN_EMAILS`, then create or verify the initial account with `{{apiRun}} admin:bootstrap{{apiWorkspaceArg}}`; never commit or persist `ADMIN_BOOTSTRAP_PASSWORD`. When sign-up approval is enabled, self-registered users stay pending until an administrator approves them on `/admin/users`; administrator-created users bypass the queue.\n- Signed-in users can upload, replace, or remove their profile image from `/account` or `/admin/account`. Keep the shared policy visible in custom account UI: PNG, JPEG, or WebP; at most 2 MB and 2048 by 2048 pixels. Use the managed `/api/account/profile-image` endpoints instead of writing directly to storage or `user.image`.\n- `sidebar-user-menu.svelte` is specific to the admin sidebar. For a product shell outside `Sidebar.Provider`, use the managed `<AccountMenu {user} variant=\"identity\" side=\"top\" />` component.\n- Custom protected server layouts must call `requireBackendAvailable(locals)` from `#lib/server/guards.js` before redirecting anonymous users. This preserves sessions and returns 503 when authentication or maintenance policy cannot be checked.\n- Every page except the sign-in flow, `/`, and module pages requires a session, and fails closed with 503 when the API or site settings are unavailable. List the application's public pages (marketing, pricing, legal, `robots.txt`) in `APP_PUBLIC_PATHS` in the app-owned `apps/web/src/lib/server/public-paths.ts`; a prefix covers its subpaths. Do not edit the `PUBLIC_PATHS` list in the managed `guards.ts`.\n- `hooks.server.ts` loads the site settings and the session for pages, page data, form actions, remote functions, and the 404 page. When a `+server.ts` endpoint serves files or feeds that need neither, list its prefix in `APP_ASSET_PATHS` in the app-owned `apps/web/src/lib/server/asset-paths.ts` to skip both API calls; such endpoints see an anonymous `locals.user`, and page requests under the prefix still get the full context.\n- **Every list/table MUST use the shared `#lib/components/data-table.svelte` (`DataTable`)** \u2014 it gives sortable headers + a footer pagination. Add search/filters with `#lib/components/table-toolbar.svelte`. Never assemble `Table.Root`/`Table.Header`/`Table.Body` by hand. For responsive columns, use `hideBelow` and apply the row context's `cellClass(columnKey)` to the matching cell.\n"
122
123
  },
123
124
  {
124
125
  "file": "apps/api/src/app.ts",
@@ -0,0 +1,26 @@
1
+ import type { SQL } from "bun";
2
+ import { describe, expect, it } from "bun:test";
3
+ import { validateEnv } from "../config/env.validation";
4
+ import { refreshForRequest } from "./auth-provider";
5
+ import { configureAuthQueryDatabase } from "./db";
6
+
7
+ describe("refreshForRequest", () => {
8
+ it("stops waiting for a configuration refresh the database does not answer", async () => {
9
+ let queries = 0;
10
+ // Every query waits forever, like one on a paused server.
11
+ const silent = (): Promise<never> => {
12
+ queries += 1;
13
+ return new Promise(() => undefined);
14
+ };
15
+ configureAuthQueryDatabase(silent as unknown as SQL);
16
+
17
+ const waitMs = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
18
+ const started = Date.now();
19
+ await refreshForRequest();
20
+ const waited = Date.now() - started;
21
+
22
+ expect(queries).toBeGreaterThan(0);
23
+ expect(waited).toBeGreaterThanOrEqual(waitMs - 10);
24
+ expect(waited).toBeLessThan(waitMs + 500);
25
+ });
26
+ });
@@ -1,7 +1,12 @@
1
1
  import type { AuthConfig } from "@podosoft/podokit-auth";
2
+ import { validateEnv } from "../config/env.validation";
2
3
  import { auth as bootstrapAuth, buildAuth } from "./auth";
3
4
  import { authConfigStore } from "./auth-config-store";
4
5
  import { publishConfigChanged } from "./config-events";
6
+ import { answersWithin, checkAuthResponse } from "./database-outage";
7
+ import { authDatabaseReachable } from "./db";
8
+
9
+ const REFRESH_WAIT_MS = validateEnv(process.env).READINESS_CHECK_TIMEOUT_MS;
5
10
 
6
11
  // Runtime auth instance that rebuilds when the DB config changes, so admin edits
7
12
  // (OAuth credentials, SMTP, server-enforced toggles) apply WITHOUT a restart.
@@ -52,10 +57,20 @@ export async function refreshAuthNow(): Promise<void> {
52
57
  await publishConfigChanged();
53
58
  }
54
59
 
60
+ /**
61
+ * Refreshes before a request is served. While PostgreSQL does not answer, the
62
+ * refresh lasts as long as a query may wait, and the request would wait for
63
+ * Better Auth's own queries after it. Past the readiness check timeout the
64
+ * current instance serves the request, and the refresh finishes in the background.
65
+ */
66
+ export async function refreshForRequest(): Promise<void> {
67
+ await answersWithin(refreshIfStale, REFRESH_WAIT_MS);
68
+ }
69
+
55
70
  // Stable handler reference the adapter captures once; redispatches per request.
56
71
  const dynamicHandler: typeof bootstrapAuth.handler = async (request) => {
57
- await refreshIfStale();
58
- return current.handler(request);
72
+ await refreshForRequest();
73
+ return checkAuthResponse(await current.handler(request), authDatabaseReachable);
59
74
  };
60
75
 
61
76
  export const authRuntime: typeof bootstrapAuth = new Proxy(bootstrapAuth, {
@@ -1,8 +1,9 @@
1
1
  import { AppException } from "@podosoft/podokit-contracts";
2
2
  import type { AccessPolicy, RequestIdentity } from "../core/services";
3
3
  import type { SettingsService } from "../settings/settings.service";
4
- import { getAuth, refreshIfStale } from "./auth-provider";
5
- import { closeAuthDatabase } from "./db";
4
+ import { getAuth, refreshForRequest } from "./auth-provider";
5
+ import { rethrowAuthError } from "./database-outage";
6
+ import { authDatabaseReachable, closeAuthDatabase } from "./db";
6
7
 
7
8
  export interface AuthUser extends Record<string, unknown> {
8
9
  id: string;
@@ -18,9 +19,12 @@ export interface AuthSession {
18
19
  export type SessionResolver = (headers: Headers) => Promise<AuthSession | null>;
19
20
 
20
21
  function defaultSessionResolver(headers: Headers): Promise<AuthSession | null> {
21
- return refreshIfStale()
22
+ return refreshForRequest()
22
23
  .then(() => getAuth().api.getSession({ headers }))
23
- .then((session) => session as AuthSession | null);
24
+ .then(
25
+ (session) => session as AuthSession | null,
26
+ (error: unknown) => rethrowAuthError(error, authDatabaseReachable),
27
+ );
24
28
  }
25
29
 
26
30
  export function rolesOf(user: AuthUser): string[] {
@@ -14,7 +14,7 @@ import { oauthProvider } from "@better-auth/oauth-provider";
14
14
  import { SIGNUP_APPROVAL_REQUIRED } from "@podosoft/podokit-contracts";
15
15
  import { runUserDeletedHandlers } from "./user-delete-handlers";
16
16
  import { authBaseUrl, authSecret } from "./auth-environment";
17
- import { authSchemaValidation } from "./schema-validation";
17
+ import { isMaintenanceCommand } from "./maintenance";
18
18
  // podokit:begin:auth-imports
19
19
  // podokit:end:auth-imports
20
20
 
@@ -233,7 +233,7 @@ export function buildAuth(config: AuthConfig) {
233
233
  // resolved client IP as x-forwarded-for; trust it so sessions record an IP.
234
234
  ipAddressHeaders: ["x-forwarded-for"],
235
235
  },
236
- ...(authSchemaValidation() ? {} : { database: { validateSchema: false } }),
236
+ ...(isMaintenanceCommand() ? { database: { validateSchema: false } } : {}),
237
237
  },
238
238
  user: {
239
239
  additionalFields: {