@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
@@ -0,0 +1,73 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>%sveltekit.status% %sveltekit.error.message%</title>
8
+ <!-- SvelteKit serves this page when the root layout or a server hook fails,
9
+ so it cannot use the app's styles, scripts, assets, or components. It
10
+ fills in only the status and the HTML-escaped error message. -->
11
+ <style>
12
+ :root {
13
+ --background: oklch(1 0 0);
14
+ --foreground: oklch(0.141 0.005 285.823);
15
+ --muted-foreground: oklch(0.552 0.016 285.938);
16
+ --border: oklch(0.92 0.004 286.32);
17
+ }
18
+
19
+ @media (prefers-color-scheme: dark) {
20
+ :root {
21
+ --background: oklch(0.141 0.005 285.823);
22
+ --foreground: oklch(0.985 0 0);
23
+ --muted-foreground: oklch(0.705 0.015 286.067);
24
+ --border: oklch(1 0 0 / 15%);
25
+ }
26
+ }
27
+
28
+ body {
29
+ box-sizing: border-box;
30
+ display: grid;
31
+ min-height: 100vh;
32
+ margin: 0;
33
+ padding: 1.5rem;
34
+ place-items: center;
35
+ background: var(--background);
36
+ color: var(--foreground);
37
+ font-family: ui-sans-serif, system-ui, sans-serif;
38
+ line-height: 1.5;
39
+ }
40
+
41
+ main {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 1rem;
45
+ max-width: 32rem;
46
+ }
47
+
48
+ .status {
49
+ margin: 0;
50
+ color: var(--muted-foreground);
51
+ font-size: 2.25rem;
52
+ font-variant-numeric: tabular-nums;
53
+ font-weight: 600;
54
+ line-height: 1;
55
+ }
56
+
57
+ h1 {
58
+ margin: 0;
59
+ padding-left: 1rem;
60
+ border-left: 1px solid var(--border);
61
+ font-size: 1rem;
62
+ font-weight: 500;
63
+ overflow-wrap: anywhere;
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <main>
69
+ <p class="status">%sveltekit.status%</p>
70
+ <h1>%sveltekit.error.message%</h1>
71
+ </main>
72
+ </body>
73
+ </html>
@@ -0,0 +1,7 @@
1
+ import Root from "./textarea.svelte";
2
+
3
+ export {
4
+ Root,
5
+ //
6
+ Root as Textarea,
7
+ };
@@ -0,0 +1,22 @@
1
+ <script lang="ts">
2
+ import { cn, type WithElementRef, type WithoutChildren } from "#lib/utils.js";
3
+ import type { HTMLTextareaAttributes } from "svelte/elements";
4
+
5
+ let {
6
+ ref = $bindable(null),
7
+ value = $bindable(),
8
+ class: className,
9
+ "data-slot": dataSlot = "textarea",
10
+ ...restProps
11
+ }: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
12
+ </script>
13
+
14
+ <textarea
15
+ bind:this={ref}
16
+ data-slot={dataSlot}
17
+ class={cn(
18
+ "border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm flex field-sizing-content min-h-16 w-full outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
19
+ className
20
+ )}
21
+ bind:value
22
+ {...restProps}></textarea>
@@ -2,7 +2,15 @@ import { createServer } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
3
  import { gzipSync } from "node:zlib";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { proxyRequest, rawSubpath, resolveClientIp } from "./backend-proxy";
5
+ import {
6
+ bodyLimitFor,
7
+ IN_MEMORY_RESPONSE_BYTES,
8
+ proxyRequest,
9
+ rawSubpath,
10
+ resolveClientIp,
11
+ sortedBodyLimits,
12
+ withAppHeaders,
13
+ } from "./backend-proxy";
6
14
 
7
15
  describe("backend proxy client address handling", () => {
8
16
  afterEach(() => {
@@ -173,6 +181,47 @@ describe("backend proxy client address handling", () => {
173
181
  expect(await response.text()).toBe("0123456789");
174
182
  });
175
183
 
184
+ it("reads a body of known, modest size before relaying it, so Bun keeps its length", async () => {
185
+ let pulls = 0;
186
+ vi.stubGlobal("fetch", async () => new Response(new ReadableStream<Uint8Array>({
187
+ pull(controller): void {
188
+ pulls += 1;
189
+ controller.enqueue(new TextEncoder().encode("0123456789"));
190
+ controller.close();
191
+ },
192
+ }, { highWaterMark: 0 }), { headers: { "content-length": "10" } }));
193
+
194
+ const response = await proxyRequest(
195
+ new Request("http://app.localhost/api/profile-images/a.png"),
196
+ "http://api:5002/profile-images/a.png",
197
+ );
198
+
199
+ expect(pulls).toBe(1);
200
+ expect(response.headers.get("content-length")).toBe("10");
201
+ expect(await response.text()).toBe("0123456789");
202
+ });
203
+
204
+ it("streams a larger body through without reading it first", async () => {
205
+ const size = IN_MEMORY_RESPONSE_BYTES + 1;
206
+ let pulls = 0;
207
+ vi.stubGlobal("fetch", async () => new Response(new ReadableStream<Uint8Array>({
208
+ pull(controller): void {
209
+ pulls += 1;
210
+ controller.enqueue(new Uint8Array(size));
211
+ controller.close();
212
+ },
213
+ }, { highWaterMark: 0 }), { headers: { "content-length": String(size) } }));
214
+
215
+ const response = await proxyRequest(
216
+ new Request("http://app.localhost/api/storage/large"),
217
+ "http://api:5002/storage/large",
218
+ );
219
+
220
+ expect(pulls).toBe(0);
221
+ expect(response.headers.get("content-length")).toBe(String(size));
222
+ expect((await response.arrayBuffer()).byteLength).toBe(size);
223
+ });
224
+
176
225
  it("drops content-length when the upstream body was compressed", async () => {
177
226
  vi.stubGlobal("fetch", async () => new Response("decoded body", {
178
227
  headers: { "content-encoding": "gzip", "content-length": "4" },
@@ -289,3 +338,229 @@ describe("backend proxy raw path", () => {
289
338
  expect(rawSubpath(new URL("http://localhost/other"), "/api/")).toBe("/other");
290
339
  });
291
340
  });
341
+
342
+ describe("backend proxy application header lists", () => {
343
+ afterEach(() => {
344
+ vi.unstubAllGlobals();
345
+ vi.doUnmock("./proxy-headers");
346
+ vi.resetModules();
347
+ });
348
+
349
+ async function proxyWithAppHeaders(request: string[], response: string[]): Promise<typeof proxyRequest> {
350
+ vi.resetModules();
351
+ vi.doMock("./proxy-headers", () => ({
352
+ APP_FORWARDED_REQUEST_HEADERS: request,
353
+ APP_RELAYED_RESPONSE_HEADERS: response,
354
+ }));
355
+ return (await import("./backend-proxy")).proxyRequest;
356
+ }
357
+
358
+ it("forwards and relays the headers an application lists", async () => {
359
+ const proxy = await proxyWithAppHeaders(["X-API-Key", "mcp-session-id"], ["X-Checksum-SHA256", "www-authenticate"]);
360
+ let upstreamHeaders: Headers | undefined;
361
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
362
+ upstreamHeaders = new Headers(init?.headers);
363
+ return new Response(null, {
364
+ status: 401,
365
+ headers: {
366
+ "x-checksum-sha256": "abc123",
367
+ "www-authenticate": "Bearer",
368
+ "x-internal-trace": "hidden",
369
+ },
370
+ });
371
+ });
372
+
373
+ const response = await proxy(
374
+ new Request("http://app.localhost/api/documents/1", {
375
+ headers: { "x-api-key": "key", "mcp-session-id": "s1", "x-unlisted": "dropped" },
376
+ }),
377
+ "http://api:5002/documents/1",
378
+ );
379
+
380
+ expect(upstreamHeaders?.get("x-api-key")).toBe("key");
381
+ expect(upstreamHeaders?.get("mcp-session-id")).toBe("s1");
382
+ expect(upstreamHeaders?.get("x-unlisted")).toBeNull();
383
+ expect(response.headers.get("x-checksum-sha256")).toBe("abc123");
384
+ expect(response.headers.get("www-authenticate")).toBe("Bearer");
385
+ expect(response.headers.get("x-internal-trace")).toBeNull();
386
+ });
387
+
388
+ it("drops application headers that no list names", async () => {
389
+ const proxy = await proxyWithAppHeaders([], []);
390
+ let upstreamHeaders: Headers | undefined;
391
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
392
+ upstreamHeaders = new Headers(init?.headers);
393
+ return new Response(null, { status: 204, headers: { "x-checksum-sha256": "abc123" } });
394
+ });
395
+
396
+ const response = await proxy(
397
+ new Request("http://app.localhost/api/documents/1", { headers: { "x-api-key": "key" } }),
398
+ "http://api:5002/documents/1",
399
+ );
400
+
401
+ expect(upstreamHeaders?.get("x-api-key")).toBeNull();
402
+ expect(response.headers.get("x-checksum-sha256")).toBeNull();
403
+ });
404
+
405
+ it("refuses to load with a reserved or invalid application header", async () => {
406
+ await expect(proxyWithAppHeaders(["x-forwarded-for"], [])).rejects.toThrow(
407
+ 'APP_FORWARDED_REQUEST_HEADERS cannot contain "x-forwarded-for"',
408
+ );
409
+ await expect(proxyWithAppHeaders([], ["Set-Cookie"])).rejects.toThrow(
410
+ 'APP_RELAYED_RESPONSE_HEADERS cannot contain "set-cookie"',
411
+ );
412
+ });
413
+
414
+ it("lowercases, deduplicates, and validates application names", () => {
415
+ const reserved = new Set(["host", "transfer-encoding"]);
416
+ expect(withAppHeaders(["accept", "etag"], ["ETag", "X-Trace", "x-trace"], reserved, "LIST")).toEqual([
417
+ "accept",
418
+ "etag",
419
+ "x-trace",
420
+ ]);
421
+ expect(() => withAppHeaders([], ["Transfer-Encoding"], reserved, "LIST")).toThrow(
422
+ 'LIST cannot contain "transfer-encoding"',
423
+ );
424
+ expect(() => withAppHeaders([], ["x trace"], reserved, "LIST")).toThrow(
425
+ 'LIST contains an invalid header name: "x trace"',
426
+ );
427
+ expect(() => withAppHeaders([], [""], reserved, "LIST")).toThrow("invalid header name");
428
+ });
429
+ });
430
+
431
+ describe("backend proxy application body limits", () => {
432
+ afterEach(() => {
433
+ vi.unstubAllGlobals();
434
+ vi.doUnmock("./body-limits");
435
+ vi.resetModules();
436
+ });
437
+
438
+ async function proxyWithBodyLimits(
439
+ limits: ReadonlyArray<{ pathPrefix: string; maxBytes: number }>,
440
+ ): Promise<typeof proxyRequest> {
441
+ vi.resetModules();
442
+ vi.doMock("./body-limits", () => ({ APP_BODY_LIMITS: limits }));
443
+ return (await import("./backend-proxy")).proxyRequest;
444
+ }
445
+
446
+ function recordingFetch(): { bodies: Uint8Array[] } {
447
+ const record = { bodies: [] as Uint8Array[] };
448
+ vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
449
+ record.bodies.push(new Uint8Array(await new Response(init?.body).arrayBuffer()));
450
+ return new Response(null, { status: 204 });
451
+ });
452
+ return record;
453
+ }
454
+
455
+ // A body without content-length, as a chunked upload arrives.
456
+ function streamedRequest(url: string, chunks: number, chunkSize: number): { request: Request; pulled: () => number } {
457
+ let pulled = 0;
458
+ const stream = new ReadableStream<Uint8Array>({
459
+ pull(controller) {
460
+ if (pulled === chunks) {
461
+ controller.close();
462
+ return;
463
+ }
464
+ pulled += 1;
465
+ controller.enqueue(new Uint8Array(chunkSize).fill(pulled));
466
+ },
467
+ });
468
+ const init = { method: "POST", body: stream, duplex: "half" } as RequestInit;
469
+ return { request: new Request(url, init), pulled: () => pulled };
470
+ }
471
+
472
+ it("rejects a declared body above the route limit before contacting the API", async () => {
473
+ const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback/", maxBytes: 8 }]);
474
+ const record = recordingFetch();
475
+
476
+ const response = await proxy(
477
+ new Request("http://app.localhost/api/feedback/new", { method: "POST", body: "0123456789" }),
478
+ "http://api:5002/feedback/new",
479
+ );
480
+
481
+ expect(response.status).toBe(413);
482
+ const envelope = (await response.json()) as {
483
+ success: boolean;
484
+ error: { code: string; statusCode: number; path: string; details: { maxBytes: number } };
485
+ };
486
+ expect(envelope.success).toBe(false);
487
+ expect(envelope.error).toMatchObject({
488
+ code: "PAYLOAD_TOO_LARGE",
489
+ statusCode: 413,
490
+ path: "/api/feedback/new",
491
+ details: { maxBytes: 8 },
492
+ });
493
+ expect(record.bodies).toEqual([]);
494
+ });
495
+
496
+ it("stops reading a streamed body once it passes the limit", async () => {
497
+ const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback", maxBytes: 10 }]);
498
+ const record = recordingFetch();
499
+ const { request, pulled } = streamedRequest("http://app.localhost/api/feedback", 100, 4);
500
+
501
+ const response = await proxy(request, "http://api:5002/feedback");
502
+
503
+ expect(response.status).toBe(413);
504
+ expect(record.bodies).toEqual([]);
505
+ expect(pulled()).toBeLessThan(10);
506
+ });
507
+
508
+ it("forwards a body within the limit byte-for-byte", async () => {
509
+ const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback/", maxBytes: 12 }]);
510
+ const record = recordingFetch();
511
+ const { request } = streamedRequest("http://app.localhost/api/feedback/new", 3, 4);
512
+
513
+ const response = await proxy(request, "http://api:5002/feedback/new");
514
+
515
+ expect(response.status).toBe(204);
516
+ expect([...(record.bodies[0] ?? [])]).toEqual([1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3]);
517
+ });
518
+
519
+ it("leaves routes without a limit to the server-wide ceiling", async () => {
520
+ const proxy = await proxyWithBodyLimits([{ pathPrefix: "/api/feedback/", maxBytes: 1 }]);
521
+ const record = recordingFetch();
522
+
523
+ const response = await proxy(
524
+ new Request("http://app.localhost/api/feedbacks", { method: "POST", body: "0123456789" }),
525
+ "http://api:5002/feedbacks",
526
+ );
527
+
528
+ expect(response.status).toBe(204);
529
+ expect(record.bodies[0]?.byteLength).toBe(10);
530
+ });
531
+
532
+ it("applies the longest matching prefix on the decoded path", () => {
533
+ const limits = sortedBodyLimits([
534
+ { pathPrefix: "/api/", maxBytes: 100 },
535
+ { pathPrefix: "/api/files/uploads/", maxBytes: 10 },
536
+ { pathPrefix: "/api/files", maxBytes: 50 },
537
+ ]);
538
+
539
+ expect(bodyLimitFor("/api/files/uploads/u1/parts/2", limits)).toBe(10);
540
+ expect(bodyLimitFor("/api/files/uploads", limits)).toBe(10);
541
+ expect(bodyLimitFor("/api/files/u1", limits)).toBe(50);
542
+ expect(bodyLimitFor("/api/filesystem", limits)).toBe(100);
543
+ expect(bodyLimitFor("/api/%66iles/uploads/u1", limits)).toBe(10);
544
+ expect(bodyLimitFor("/api/files%2Fuploads/u1", limits)).toBe(10);
545
+ expect(bodyLimitFor("/api/files/%E0%A4%A", limits)).toBe(50);
546
+ expect(bodyLimitFor("/other", limits)).toBeUndefined();
547
+ });
548
+
549
+ it.each<[{ pathPrefix: string; maxBytes: number }[], string]>([
550
+ [[{ pathPrefix: "/feedback/", maxBytes: 10 }], "must be a static path under /api/"],
551
+ [[{ pathPrefix: "/api/files/*", maxBytes: 10 }], "must be a static path under /api/"],
552
+ [[{ pathPrefix: "/api//files", maxBytes: 10 }], "must be a static path under /api/"],
553
+ [[{ pathPrefix: "/api/files/../admin", maxBytes: 10 }], "must be a static path under /api/"],
554
+ [[{ pathPrefix: "/api/files", maxBytes: 0 }], "positive whole number"],
555
+ [[{ pathPrefix: "/api/files", maxBytes: 1.5 }], "positive whole number"],
556
+ [[{ pathPrefix: "/api/files", maxBytes: 10 }, { pathPrefix: "/api/files/", maxBytes: 20 }], "more than once"],
557
+ ])("rejects an invalid body limit list %#", (limits, message) => {
558
+ expect(() => sortedBodyLimits(limits)).toThrow(message);
559
+ });
560
+
561
+ it("refuses to load with an invalid application body limit", async () => {
562
+ await expect(proxyWithBodyLimits([{ pathPrefix: "/upload", maxBytes: 10 }])).rejects.toThrow(
563
+ "APP_BODY_LIMITS pathPrefix must be a static path under /api/",
564
+ );
565
+ });
566
+ });
@@ -1,6 +1,10 @@
1
+ import { APP_BODY_LIMITS } from "./body-limits";
2
+ import { APP_FORWARDED_REQUEST_HEADERS, APP_RELAYED_RESPONSE_HEADERS } from "./proxy-headers";
3
+
1
4
  // Server-side proxy boundary. The browser never talks to the API directly:
2
5
  // requests go through SvelteKit server routes, which forward an allowlist of
3
6
  // headers to BACKEND_INTERNAL_URL and relay the response (including Set-Cookie).
7
+ // Applications add their own header names in the app-owned proxy-headers.ts.
4
8
  //
5
9
  // `user-agent` is on the list for the same reason the client IP is forwarded
6
10
  // below: better-auth records it on the session and the account page shows it as
@@ -39,6 +43,171 @@ const RELAYED_RESPONSE_HEADERS = [
39
43
  "x-next-cursor",
40
44
  ];
41
45
 
46
+ // Hop-by-hop headers describe a single connection, and the proxy sets framing,
47
+ // cookies, and the client address itself, so application lists cannot add these.
48
+ const HOP_BY_HOP_HEADERS = [
49
+ "connection",
50
+ "keep-alive",
51
+ "proxy-authenticate",
52
+ "proxy-authorization",
53
+ "proxy-connection",
54
+ "te",
55
+ "trailer",
56
+ "transfer-encoding",
57
+ "upgrade",
58
+ ];
59
+ const RESERVED_REQUEST_HEADERS = new Set([
60
+ ...HOP_BY_HOP_HEADERS,
61
+ "host",
62
+ "content-length",
63
+ "forwarded",
64
+ "x-forwarded-for",
65
+ "x-forwarded-host",
66
+ "x-forwarded-port",
67
+ "x-forwarded-proto",
68
+ "x-real-ip",
69
+ ]);
70
+ const RESERVED_RESPONSE_HEADERS = new Set([
71
+ ...HOP_BY_HOP_HEADERS,
72
+ "content-length",
73
+ "content-encoding",
74
+ "set-cookie",
75
+ ]);
76
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
77
+ /** Larger response bodies stream through; smaller ones are relayed with their length. */
78
+ export const IN_MEMORY_RESPONSE_BYTES = 2 * 1024 * 1024;
79
+
80
+ /** Append an application's header names to a built-in list, lowercased and
81
+ * without duplicates. Throws on an invalid or reserved name so a mistake fails
82
+ * the build instead of silently dropping the header. */
83
+ export function withAppHeaders(
84
+ builtIn: readonly string[],
85
+ app: readonly string[],
86
+ reserved: ReadonlySet<string>,
87
+ listName: string,
88
+ ): string[] {
89
+ const names = new Set(builtIn);
90
+ for (const entry of app) {
91
+ const name = entry.toLowerCase();
92
+ if (!HEADER_NAME.test(name)) {
93
+ throw new Error(`${listName} contains an invalid header name: ${JSON.stringify(entry)}`);
94
+ }
95
+ if (reserved.has(name)) {
96
+ throw new Error(`${listName} cannot contain "${name}": the proxy handles that header itself.`);
97
+ }
98
+ names.add(name);
99
+ }
100
+ return [...names];
101
+ }
102
+
103
+ const forwardedHeaders = withAppHeaders(
104
+ FORWARDED_HEADERS,
105
+ APP_FORWARDED_REQUEST_HEADERS,
106
+ RESERVED_REQUEST_HEADERS,
107
+ "APP_FORWARDED_REQUEST_HEADERS",
108
+ );
109
+ const relayedResponseHeaders = withAppHeaders(
110
+ RELAYED_RESPONSE_HEADERS,
111
+ APP_RELAYED_RESPONSE_HEADERS,
112
+ RESERVED_RESPONSE_HEADERS,
113
+ "APP_RELAYED_RESPONSE_HEADERS",
114
+ );
115
+
116
+ export interface BodyLimit {
117
+ pathPrefix: string;
118
+ maxBytes: number;
119
+ }
120
+
121
+ const BODY_LIMIT_PREFIX = /^\/api(?:\/[A-Za-z0-9._~!$&'()+,;=:@-]+)*\/?$/;
122
+
123
+ /** Validate application body limits and order them so the longest prefix
124
+ * matches first. Prefixes are stored without a trailing slash. */
125
+ export function sortedBodyLimits(limits: ReadonlyArray<BodyLimit>): BodyLimit[] {
126
+ const seen = new Set<string>();
127
+ const sorted = limits.map(({ pathPrefix, maxBytes }) => {
128
+ if (
129
+ !BODY_LIMIT_PREFIX.test(pathPrefix) ||
130
+ pathPrefix.split("/").some((segment) => segment === "." || segment === "..")
131
+ ) {
132
+ throw new Error(
133
+ `APP_BODY_LIMITS pathPrefix must be a static path under /api/, such as "/api/feedback/": ${JSON.stringify(pathPrefix)}`,
134
+ );
135
+ }
136
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
137
+ throw new Error(`APP_BODY_LIMITS maxBytes for ${pathPrefix} must be a positive whole number of bytes.`);
138
+ }
139
+ const base = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
140
+ if (seen.has(base)) throw new Error(`APP_BODY_LIMITS lists ${pathPrefix} more than once.`);
141
+ seen.add(base);
142
+ return { pathPrefix: base, maxBytes };
143
+ });
144
+ return sorted.sort((left, right) => right.pathPrefix.length - left.pathPrefix.length);
145
+ }
146
+
147
+ const bodyLimits = sortedBodyLimits(APP_BODY_LIMITS);
148
+
149
+ /** The body limit for a request path, matched on the decoded path so an encoded
150
+ * segment cannot step around a prefix. */
151
+ export function bodyLimitFor(pathname: string, limits: readonly BodyLimit[] = bodyLimits): number | undefined {
152
+ let path = pathname;
153
+ try {
154
+ path = decodeURIComponent(pathname);
155
+ } catch {
156
+ // Keep the raw path when it holds a malformed escape.
157
+ }
158
+ return limits.find(
159
+ ({ pathPrefix }) => path === pathPrefix || path.startsWith(`${pathPrefix}/`),
160
+ )?.maxBytes;
161
+ }
162
+
163
+ /** Read a request body, or return null as soon as it exceeds maxBytes. The rest
164
+ * of an oversized body is never buffered. */
165
+ async function readLimitedBody(request: Request, maxBytes: number): Promise<ArrayBuffer | null> {
166
+ const declared = request.headers.get("content-length");
167
+ if (declared !== null && Number(declared) > maxBytes) {
168
+ request.body?.cancel().catch(() => undefined);
169
+ return null;
170
+ }
171
+ if (!request.body) return new ArrayBuffer(0);
172
+ const reader = request.body.getReader();
173
+ const chunks: Uint8Array[] = [];
174
+ let size = 0;
175
+ for (;;) {
176
+ const { done, value } = await reader.read();
177
+ if (done) break;
178
+ size += value.byteLength;
179
+ if (size > maxBytes) {
180
+ reader.cancel().catch(() => undefined);
181
+ return null;
182
+ }
183
+ chunks.push(value);
184
+ }
185
+ const body = new Uint8Array(size);
186
+ let offset = 0;
187
+ for (const chunk of chunks) {
188
+ body.set(chunk, offset);
189
+ offset += chunk.byteLength;
190
+ }
191
+ return body.buffer;
192
+ }
193
+
194
+ function payloadTooLarge(path: string, maxBytes: number): Response {
195
+ return Response.json(
196
+ {
197
+ success: false,
198
+ error: {
199
+ code: "PAYLOAD_TOO_LARGE",
200
+ message: `Request body exceeds the ${maxBytes}-byte limit for this route`,
201
+ statusCode: 413,
202
+ path,
203
+ timestamp: new Date().toISOString(),
204
+ details: { maxBytes },
205
+ },
206
+ },
207
+ { status: 413 },
208
+ );
209
+ }
210
+
42
211
  // SvelteKit decodes rest params, so an encoded "%2F" or "%3F" inside a path
43
212
  // segment would reach the API as "/" or "?". Forward the raw pathname instead.
44
213
  export function rawSubpath(url: URL, prefix: string): string {
@@ -75,7 +244,7 @@ export async function proxyRequest(
75
244
  clientAddress?: string,
76
245
  ): Promise<Response> {
77
246
  const headers = new Headers();
78
- for (const name of FORWARDED_HEADERS) {
247
+ for (const name of forwardedHeaders) {
79
248
  const value = request.headers.get(name);
80
249
  if (value) headers.set(name, value);
81
250
  }
@@ -84,12 +253,24 @@ export async function proxyRequest(
84
253
  if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
85
254
 
86
255
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
256
+ let body: ArrayBuffer | undefined;
257
+ if (hasBody) {
258
+ // Preserve multipart uploads and other binary bodies byte-for-byte. Reading
259
+ // them as text corrupts bytes that are not valid UTF-8 before forwarding.
260
+ const { pathname } = new URL(request.url);
261
+ const maxBytes = bodyLimitFor(pathname);
262
+ if (maxBytes === undefined) {
263
+ body = await request.arrayBuffer();
264
+ } else {
265
+ const limited = await readLimitedBody(request, maxBytes);
266
+ if (limited === null) return payloadTooLarge(pathname, maxBytes);
267
+ body = limited;
268
+ }
269
+ }
87
270
  const upstream = await fetch(targetUrl, {
88
271
  method: request.method,
89
272
  headers,
90
- // Preserve multipart uploads and other binary bodies byte-for-byte. Reading
91
- // them as text corrupts bytes that are not valid UTF-8 before forwarding.
92
- body: hasBody ? await request.arrayBuffer() : undefined,
273
+ body,
93
274
  redirect: "manual",
94
275
  // Abort the upstream request when the browser disconnects. Without this, every
95
276
  // closed EventSource or aborted request keeps its API socket open until Bun's
@@ -98,20 +279,24 @@ export async function proxyRequest(
98
279
  });
99
280
 
100
281
  const responseHeaders = new Headers();
101
- for (const name of RELAYED_RESPONSE_HEADERS) {
282
+ for (const name of relayedResponseHeaders) {
102
283
  const value = upstream.headers.get(name);
103
284
  if (value) responseHeaders.set(name, value);
104
285
  }
105
286
  // Bun's fetch decodes a compressed body but keeps the upstream content-encoding
106
287
  // and compressed content-length, which would truncate the decoded body.
107
288
  const contentLength = upstream.headers.get("content-length");
108
- if (contentLength && !upstream.headers.has("content-encoding")) {
109
- responseHeaders.set("content-length", contentLength);
110
- }
289
+ const relayedLength = contentLength && !upstream.headers.has("content-encoding") ? contentLength : null;
290
+ if (relayedLength) responseHeaders.set("content-length", relayedLength);
111
291
  // Relay Set-Cookie so auth session cookies reach the browser.
112
292
  for (const cookie of upstream.headers.getSetCookie()) {
113
293
  responseHeaders.append("set-cookie", cookie);
114
294
  }
115
295
 
116
- return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
296
+ // Bun sends a streamed body with chunked encoding and drops its content-length
297
+ // unless the body is in memory, so a body of modest, known size is read first.
298
+ const relayedBody = relayedLength && upstream.body && Number(relayedLength) <= IN_MEMORY_RESPONSE_BYTES
299
+ ? await upstream.arrayBuffer()
300
+ : upstream.body;
301
+ return new Response(relayedBody, { status: upstream.status, headers: responseHeaders });
117
302
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Application-owned request body limits for /api requests that the web server
3
+ * proxies to the API. An entry caps the body of every request whose path is
4
+ * pathPrefix or below it, for example
5
+ * { pathPrefix: "/api/feedback/", maxBytes: 64 * 1024 }
6
+ * and the longest matching prefix wins. Above the limit the proxy stops reading
7
+ * and answers 413 PAYLOAD_TOO_LARGE without contacting the API.
8
+ *
9
+ * BODY_SIZE_LIMIT remains the ceiling for every web route: Bun rejects a larger
10
+ * body before this code runs, so a maxBytes above it has no effect. Routes that
11
+ * need more belong in the gateway's direct API paths (apiPaths in
12
+ * .podokit/dev.json and the deployment profile), which bypass the web server.
13
+ * PodoKit creates this file once and never overwrites it.
14
+ */
15
+ export const APP_BODY_LIMITS: ReadonlyArray<{ pathPrefix: string; maxBytes: number }> = [];
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Application-owned header names that the managed /api proxy (backend-proxy.ts)
3
+ * passes through in addition to its built-in lists. Names are case-insensitive.
4
+ * Request headers listed here are forwarded from the browser to the API, such as
5
+ * "x-api-key" for machine clients; response headers are relayed from the API to
6
+ * the browser, such as "x-checksum-sha256". The proxy handles hop-by-hop headers,
7
+ * host, content-length, content-encoding, set-cookie, and the forwarding headers
8
+ * that carry the client address itself, so a listed one fails the build.
9
+ * PodoKit creates this file once and never overwrites it.
10
+ */
11
+ export const APP_FORWARDED_REQUEST_HEADERS: readonly string[] = [];
12
+ export const APP_RELAYED_RESPONSE_HEADERS: readonly string[] = [];