@podosoft/podokit 1.3.0 → 1.4.1

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 (154) hide show
  1. package/README.md +1 -1
  2. package/dist/add.d.ts +10 -1
  3. package/dist/add.js +20 -7
  4. package/dist/api-paths.d.ts +34 -0
  5. package/dist/api-paths.js +103 -0
  6. package/dist/deploy-compose-profile.d.ts +4 -0
  7. package/dist/deploy-compose-profile.js +13 -2
  8. package/dist/deploy-compose-render.d.ts +1 -1
  9. package/dist/deploy-compose-render.js +23 -7
  10. package/dist/deploy-compose.js +2 -1
  11. package/dist/deploy-profile.d.ts +3 -0
  12. package/dist/deploy-profile.js +12 -4
  13. package/dist/deploy-render.js +54 -8
  14. package/dist/deploy.js +21 -1
  15. package/dist/dev.d.ts +14 -0
  16. package/dist/dev.js +152 -10
  17. package/dist/index.js +15 -2
  18. package/dist/lib.d.ts +2 -0
  19. package/dist/lib.js +4 -2
  20. package/dist/lockfile.d.ts +3 -0
  21. package/dist/lockfile.js +11 -0
  22. package/dist/module-api-paths.d.ts +52 -0
  23. package/dist/module-api-paths.js +189 -0
  24. package/dist/remove.d.ts +3 -0
  25. package/dist/remove.js +12 -3
  26. package/dist/templates/fullstack/AGENTS.md +27 -5
  27. package/dist/templates/fullstack/README.md +28 -11
  28. package/dist/templates/fullstack/apps/api/package.json +2 -2
  29. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
  30. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
  31. package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
  32. package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
  33. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
  34. package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
  35. package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
  36. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
  37. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
  38. package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
  39. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
  40. package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
  41. package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
  42. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
  43. package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
  44. package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
  45. package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
  46. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
  47. package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
  48. package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
  49. package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
  50. package/dist/templates/fullstack/apps/web/components.json +3 -1
  51. package/dist/templates/fullstack/apps/web/package.json +1 -1
  52. package/dist/templates/fullstack/apps/web/src/error.html +73 -0
  53. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
  54. package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
  55. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
  56. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
  57. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
  58. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
  59. package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
  60. package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
  61. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
  62. package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
  63. package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
  64. package/dist/templates/fullstack/dot-env.example +10 -0
  65. package/dist/templates/fullstack/dot-gitignore +1 -0
  66. package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
  67. package/dist/templates/fullstack/infra/k3s/README.md +3 -1
  68. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
  69. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
  70. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
  71. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
  72. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
  73. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
  74. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
  75. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
  76. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
  77. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
  78. package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
  79. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  80. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
  85. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
  86. package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
  87. package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
  88. package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
  89. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
  90. package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
  91. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
  92. package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
  93. package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
  94. package/dist/templates/modules/auth/module.manifest.json +0 -1
  95. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
  96. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
  97. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
  98. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
  99. package/dist/templates/modules/bullmq/module.manifest.json +5 -1
  100. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
  101. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
  102. package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
  103. package/dist/templates/modules/events-redis/module.manifest.json +4 -0
  104. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
  105. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
  106. package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
  107. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
  108. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
  109. package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
  110. package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
  111. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
  112. package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
  113. package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
  114. package/dist/templates/modules/file-upload/module.manifest.json +34 -6
  115. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
  116. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
  117. package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
  118. package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
  119. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
  120. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
  121. package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
  122. package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
  123. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
  124. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
  125. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
  126. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
  127. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
  128. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
  129. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
  130. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
  131. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
  132. package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
  133. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
  134. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
  135. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
  136. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
  137. package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
  138. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
  139. package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
  140. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
  141. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
  142. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
  143. package/dist/templates/modules/redis/module.manifest.json +4 -0
  144. package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
  145. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
  146. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
  147. package/dist/templates/todo/AGENTS.md +19 -4
  148. package/dist/templates/todo/README.md +27 -12
  149. package/dist/update.d.ts +5 -0
  150. package/dist/update.js +35 -0
  151. package/package.json +2 -2
  152. package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
  153. package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
  154. package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
@@ -19,6 +19,12 @@ Review these fields before planning:
19
19
  - `exposure`: Ingress or NodePort. Ingress routes `/` to web and routes only the
20
20
  exact paths in `webSocketPaths` directly to API. The list is empty by default;
21
21
  root, prefixes, patterns, queries, encoded separators, and traversal are invalid.
22
+ `apiPaths` lists static prefixes under `/api/` (such as `/api/files/uploads/`)
23
+ for bodies above the web `BODY_SIZE_LIMIT`; a second Ingress with a Traefik
24
+ `stripPrefix` Middleware sends them to API without `/api`. It needs a
25
+ Traefik-served IngressClass, and `/api/auth` is rejected. `podo deploy init`
26
+ and `podo add` fill in the paths installed modules declare (file-upload:
27
+ `/api/files/uploads/`); keep them.
22
28
  - `workloads`: API/web replicas and resources plus an optional worker
23
29
  - `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage
24
30
  - `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
@@ -61,9 +67,10 @@ The fields that differ from the Kubernetes driver:
61
67
 
62
68
  - `target`: Docker context, endpoint fingerprint, Compose project name
63
69
  - `exposure`: `bindAddress` and `port`, plus an exact `webSocketPaths` allowlist,
64
- `trustedProxyCidrs`, and `gatewayImage`. With an empty allowlist, web owns the
65
- published port. With paths, an internal gateway owns it, sends only exact listed
66
- paths to API, and sends every other request to web. Forwarding headers are trusted
70
+ an `apiPaths` prefix list, `trustedProxyCidrs`, and `gatewayImage`. With both
71
+ lists empty, web owns the published port. With paths, an internal gateway owns
72
+ it, sends exact WebSocket paths to API, sends `apiPaths` prefixes to API with
73
+ `/api` removed, and sends every other request to web. Forwarding headers are trusted
67
74
  only from the direct proxy CIDRs in `trustedProxyCidrs`; leave it empty without an
68
75
  external proxy. The bind address defaults to `127.0.0.1`, because the expected
69
76
  front door is a trusted reverse proxy — binding every interface publishes the app
@@ -19,8 +19,13 @@ description: Use when adding or changing an Elysia REST endpoint in apps/api. Co
19
19
  `{ success: false, error: { code, message, statusCode, path, timestamp } }`.
20
20
  4. APIs are authenticated by default when auth is installed. Register an
21
21
  explicit `public`, `session`, `admin`, or `api-key` policy for every route.
22
+ A `GET` policy also covers `HEAD`, which Elysia answers with the GET handler.
22
23
  5. Add route detail and response schemas so the endpoint is present in the
23
24
  merged OpenAPI document at `/api-docs-json`.
25
+ Browsers reach the endpoint through the web `/api` proxy, which drops
26
+ headers it does not know. When the endpoint reads a custom request header or
27
+ returns one the browser needs, add its name to the app-owned
28
+ `apps/web/src/lib/server/proxy-headers.ts`.
24
29
  6. Add a `bun:test` unit or integration test beside the feature. Verify with
25
30
  `bun run lint`, `bun test`, `bun run build`, and
26
31
  `bun run --cwd apps/api contract` without starting a dev server. The contract
@@ -26,5 +26,11 @@ description: Use when adding or changing a SvelteKit page/route in apps/web —
26
26
  app-owned `apps/web/src/lib/server/public-paths.ts`. A prefix covers its
27
27
  subpaths. Give such a page's loader a fallback for when the API is down
28
28
  instead of throwing. Never edit `PUBLIC_PATHS` in the managed `guards.ts`.
29
- 7. **Verify**: `{{webRun}} build` (includes `svelte-check`)
29
+ 7. **File endpoints** (with the admin-dashboard module): `hooks.server.ts` loads
30
+ the session and site settings before every page and endpoint. For a
31
+ `+server.ts` that serves files or feeds and never reads `locals.user`, add
32
+ its prefix to `APP_ASSET_PATHS` in the app-owned
33
+ `apps/web/src/lib/server/asset-paths.ts` to skip both API calls. Pages under
34
+ that prefix still get the full context.
35
+ 8. **Verify**: `{{webRun}} build` (includes `svelte-check`)
30
36
  and add/adjust a Playwright test in `tests/`.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "{{projectName}} (containerized dev)",
3
3
  "dockerComposeFile": ["../compose.dev.yaml"],
4
+ "initializeCommand": "{{packageExecutor}} @podosoft/podokit dev env",
4
5
  "service": "api",
5
6
  "workspaceFolder": "/app",
6
7
  "runServices": ["traefik", "web", "api", "worker", "postgres", "redis", "minio", "minio-init"],
@@ -1,6 +1,9 @@
1
- # Container environment for compose.dev.yaml. Compose does not interpolate
2
- # from this file: set container memory limits such as API_MEM_LIMIT=3g in the
3
- # shell or the project's .env file instead.
1
+ # Container environment for compose.dev.yaml. Commit this example with
2
+ # development defaults only. podo dev copies it to the untracked .env.docker
3
+ # when that file is missing and fills every empty *_SECRET value with a random
4
+ # one. Put real credentials, such as OAuth or SMTP secrets, in .env.docker.
5
+ # Compose does not interpolate from these files: set container memory limits
6
+ # such as API_MEM_LIMIT=3g in the shell or the project's .env file instead.
4
7
  NODE_ENV=development
5
8
  PORT=5002
6
9
  POSTGRES_HOST=postgres
@@ -12,7 +15,7 @@ BACKEND_INTERNAL_URL=http://api:5002
12
15
  CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
13
16
  REDIS_HOST=redis
14
17
  REDIS_PORT=6379
15
- BETTER_AUTH_SECRET=change-me-in-container-min-32-characters
18
+ BETTER_AUTH_SECRET=
16
19
  BETTER_AUTH_URL=http://{{projectName}}.localhost
17
20
  ADMIN_EMAILS=admin@example.com
18
21
  AUDIT_LOG_ENABLED=true
@@ -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.
@@ -4,11 +4,16 @@ import type { QueryRunner } from "typeorm";
4
4
  import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
5
5
  import { IndexSessionCreatedAt1792060000000 } from "../migrations/1792060000000-IndexSessionCreatedAt";
6
6
 
7
- function runner(type: "postgres" | "better-sqlite3", queries: string[], invalid = false): QueryRunner {
7
+ function runner(
8
+ type: "postgres" | "better-sqlite3",
9
+ queries: string[],
10
+ { invalid = false, session = true }: { invalid?: boolean; session?: boolean } = {},
11
+ ): QueryRunner {
8
12
  return {
9
13
  connection: { options: { type } },
10
14
  query: (sql: string): Promise<unknown[]> => {
11
15
  queries.push(sql);
16
+ if (sql.includes("IS NOT NULL")) return Promise.resolve([{ exists: session }]);
12
17
  return Promise.resolve(invalid && sql.includes("indisvalid") ? [{ invalid: 1 }] : []);
13
18
  },
14
19
  } as unknown as QueryRunner;
@@ -25,6 +30,7 @@ function rows<Row>(database: Database, sql: string): Row[] {
25
30
  }
26
31
 
27
32
  const CREATE = 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_session_createdAt_id" ON "session" ("createdAt", "id")';
33
+ const SESSION_EXISTS = 'SELECT to_regclass($1) IS NOT NULL AS "exists"';
28
34
 
29
35
  describe("admin session list index migration", () => {
30
36
  it("builds the PostgreSQL index concurrently outside a transaction", async () => {
@@ -33,6 +39,7 @@ describe("admin session list index migration", () => {
33
39
  const up: string[] = [];
34
40
  await migration.up(runner("postgres", up));
35
41
  expect(up).toEqual([
42
+ SESSION_EXISTS,
36
43
  "SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid",
37
44
  CREATE,
38
45
  ]);
@@ -44,8 +51,30 @@ describe("admin session list index migration", () => {
44
51
 
45
52
  it("rebuilds an invalid index left by an interrupted build", async () => {
46
53
  const up: string[] = [];
47
- await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, true));
48
- expect(up.slice(1)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
54
+ await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { invalid: true }));
55
+ expect(up.slice(2)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
56
+ });
57
+
58
+ // `migration:run` runs only the application migrations, so on a new database
59
+ // Better Auth has not created its tables yet.
60
+ it("fails with the command that creates the Better Auth tables on PostgreSQL", async () => {
61
+ const up: string[] = [];
62
+ await expect(new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { session: false })))
63
+ .rejects.toThrow('Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"');
64
+ expect(up).toEqual([SESSION_EXISTS]);
65
+ });
66
+
67
+ it("fails with the command that creates the Better Auth tables on SQLite and records nothing", async () => {
68
+ const database = new Database(":memory:");
69
+ try {
70
+ const migrations = [{ timestamp: 1792060000000, migration: new IndexSessionCreatedAt1792060000000() }];
71
+ await expect(runSqliteMigrations(database, migrations)).rejects.toThrow(
72
+ 'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"',
73
+ );
74
+ expect(rows(database, 'SELECT "name" FROM "migrations"')).toEqual([]);
75
+ } finally {
76
+ database.close();
77
+ }
49
78
  });
50
79
 
51
80
  it("serves the newest-first session page on SQLite and reverts", async () => {
@@ -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";
@@ -5,6 +5,12 @@ import type { MigrationInterface, QueryRunner } from "typeorm";
5
5
  // so without this index every page sorts the whole session table.
6
6
  const INDEX = "IDX_session_createdAt_id";
7
7
 
8
+ // Better Auth creates "session" in its own schema step. `migrate:all` runs that
9
+ // step before the application migrations; `migration:run` does not.
10
+ const MISSING_SESSION_TABLE =
11
+ 'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all", ' +
12
+ "which applies the Better Auth schema before the application migrations.";
13
+
8
14
  export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
9
15
  name = "IndexSessionCreatedAt1792060000000";
10
16
  // CONCURRENTLY cannot run inside a transaction block. The runner therefore
@@ -13,9 +19,21 @@ export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
13
19
 
14
20
  async up(queryRunner: QueryRunner): Promise<void> {
15
21
  if (queryRunner.connection.options.type !== "postgres") {
16
- await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
22
+ try {
23
+ await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
24
+ } catch (error) {
25
+ if (error instanceof Error && /no such table: (?:main\.)?session$/.test(error.message)) {
26
+ throw new Error(MISSING_SESSION_TABLE, { cause: error });
27
+ }
28
+ throw error;
29
+ }
17
30
  return;
18
31
  }
32
+ const [session] = await queryRunner.query(
33
+ `SELECT to_regclass($1) IS NOT NULL AS "exists"`,
34
+ [`"session"`],
35
+ ) as Array<{ exists: boolean } | undefined>;
36
+ if (!session?.exists) throw new Error(MISSING_SESSION_TABLE);
19
37
  // Sign-ins keep writing sessions while the index builds. An interrupted
20
38
  // build leaves an invalid index that IF NOT EXISTS would keep.
21
39
  const invalid = await queryRunner.query(
@@ -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
+ }