@podosoft/podokit 1.4.1 → 1.5.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 (97) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +3 -1
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +16 -8
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.d.ts +8 -1
  8. package/dist/lockfile.js +23 -4
  9. package/dist/provider.d.ts +1 -0
  10. package/dist/provider.js +5 -2
  11. package/dist/templates/fullstack/AGENTS.md +27 -2
  12. package/dist/templates/fullstack/README.md +1 -1
  13. package/dist/templates/fullstack/apps/api/package.json +1 -1
  14. package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +4 -0
  15. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -2
  16. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  17. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  18. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  19. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  20. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  21. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  22. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  23. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  24. package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +66 -0
  25. package/dist/templates/fullstack/apps/api/src/database/database.ts +34 -11
  26. package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +54 -6
  27. package/dist/templates/fullstack/apps/web/Dockerfile +5 -1
  28. package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +116 -0
  29. package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +34 -4
  30. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +436 -6
  31. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +352 -38
  32. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  33. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  34. package/dist/templates/fullstack/apps/web/src/routes/healthz/+server.ts +13 -0
  35. package/dist/templates/fullstack/dot-env.docker.example +6 -0
  36. package/dist/templates/fullstack/dot-env.example +23 -0
  37. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  38. package/dist/templates/fullstack/infra/k3s/web-deployment.yaml +6 -3
  39. package/dist/templates/fullstack/tests/api/health.api.spec.ts +8 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  45. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +24 -1
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +6 -0
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +5 -0
  53. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +167 -154
  55. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +94 -57
  56. package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +75 -0
  57. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +49 -39
  58. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  59. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +80 -0
  61. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +54 -47
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +15 -11
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +35 -31
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +90 -76
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +19 -15
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +17 -3
  69. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +15 -6
  70. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +71 -64
  71. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +89 -79
  72. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  73. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +54 -50
  74. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  75. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  76. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  77. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  78. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  79. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  80. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +19 -8
  81. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +83 -0
  82. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +51 -7
  83. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  84. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  85. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  86. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  87. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  88. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  89. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  90. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  91. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  92. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  93. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  94. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  95. package/dist/templates/todo/AGENTS.md +41 -5
  96. package/dist/templates/todo/README.md +1 -1
  97. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { APP_BODY_LIMITS } from "./body-limits";
2
2
  import { APP_FORWARDED_REQUEST_HEADERS, APP_RELAYED_RESPONSE_HEADERS } from "./proxy-headers";
3
+ import { APP_PROXY_TIMEOUTS } from "./proxy-timeouts";
3
4
 
4
5
  // Server-side proxy boundary. The browser never talks to the API directly:
5
6
  // requests go through SvelteKit server routes, which forward an allowlist of
@@ -118,46 +119,115 @@ export interface BodyLimit {
118
119
  maxBytes: number;
119
120
  }
120
121
 
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
- ) {
122
+ /** A literal path segment, or null for a `:name` parameter. */
123
+ type PathSegment = string | null;
124
+
125
+ /** An application path rule with its prefix split into segments. */
126
+ export type PathRule<T extends { pathPrefix: string }> = T & { readonly segments: readonly PathSegment[] };
127
+
128
+ const LITERAL_SEGMENT = /^[A-Za-z0-9._~!$&'()+,;=:@-]+$/;
129
+ const PARAMETER_SEGMENT = /^:[A-Za-z_][A-Za-z0-9_]*$/;
130
+
131
+ /** The segments of a prefix under /api/, or undefined when it is not one. */
132
+ function parsePathPrefix(pathPrefix: string): PathSegment[] | undefined {
133
+ const base = pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix;
134
+ const [root, ...segments] = base.split("/");
135
+ if (root !== "" || segments[0] !== "api") return undefined;
136
+ const parsed: PathSegment[] = [];
137
+ for (const segment of segments) {
138
+ if (PARAMETER_SEGMENT.test(segment)) {
139
+ parsed.push(null);
140
+ } else if (LITERAL_SEGMENT.test(segment) && !segment.startsWith(":") && segment !== "." && segment !== "..") {
141
+ parsed.push(segment);
142
+ } else {
143
+ return undefined;
144
+ }
145
+ }
146
+ return parsed;
147
+ }
148
+
149
+ /**
150
+ * Below zero when `a` is more specific: more segments, then, from the left, a
151
+ * literal segment before a parameter.
152
+ */
153
+ function compareSpecificity(a: readonly PathSegment[], b: readonly PathSegment[]): number {
154
+ if (a.length !== b.length) return b.length - a.length;
155
+ for (let index = 0; index < a.length; index += 1) {
156
+ const literal = a[index] !== null;
157
+ if (literal !== (b[index] !== null)) return literal ? -1 : 1;
158
+ }
159
+ return 0;
160
+ }
161
+
162
+ /** Validate an application list of /api path prefixes and order it so the
163
+ * most specific prefix matches first. Prefixes are stored without a trailing
164
+ * slash. */
165
+ function sortedByPrefix<T extends { pathPrefix: string }>(
166
+ entries: ReadonlyArray<T>,
167
+ listName: string,
168
+ example: string,
169
+ check: (entry: T) => void,
170
+ ): PathRule<T>[] {
171
+ const seen = new Map<string, string>();
172
+ const rules = entries.map((entry) => {
173
+ const { pathPrefix } = entry;
174
+ const segments = parsePathPrefix(pathPrefix);
175
+ if (!segments) {
132
176
  throw new Error(
133
- `APP_BODY_LIMITS pathPrefix must be a static path under /api/, such as "/api/feedback/": ${JSON.stringify(pathPrefix)}`,
177
+ `${listName} pathPrefix must be a path under /api/ made of literal segments and ":name" parameters, ` +
178
+ `such as "${example}": ${JSON.stringify(pathPrefix)}`,
134
179
  );
135
180
  }
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.`);
181
+ check(entry);
182
+ // Parameter names do not change which paths a prefix covers.
183
+ const key = segments.map((segment) => segment ?? ":").join("/");
184
+ const listed = seen.get(key);
185
+ if (listed !== undefined) {
186
+ throw new Error(
187
+ `${listName} lists the same paths twice: ${JSON.stringify(listed)} and ${JSON.stringify(pathPrefix)}.`,
188
+ );
138
189
  }
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 };
190
+ seen.set(key, pathPrefix);
191
+ return { ...entry, pathPrefix: pathPrefix.endsWith("/") ? pathPrefix.slice(0, -1) : pathPrefix, segments };
143
192
  });
144
- return sorted.sort((left, right) => right.pathPrefix.length - left.pathPrefix.length);
193
+ return rules.sort((left, right) => compareSpecificity(left.segments, right.segments));
145
194
  }
146
195
 
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 {
196
+ /** The most specific rule for a request path. Prefixes match whole segments of
197
+ * the decoded path, so an encoded segment cannot step around them, and a
198
+ * parameter matches exactly one non-empty segment. */
199
+ function entryFor<T extends { pathPrefix: string }>(
200
+ pathname: string,
201
+ rules: readonly PathRule<T>[],
202
+ ): PathRule<T> | undefined {
152
203
  let path = pathname;
153
204
  try {
154
205
  path = decodeURIComponent(pathname);
155
206
  } catch {
156
207
  // Keep the raw path when it holds a malformed escape.
157
208
  }
158
- return limits.find(
159
- ({ pathPrefix }) => path === pathPrefix || path.startsWith(`${pathPrefix}/`),
160
- )?.maxBytes;
209
+ const segments = path.split("/").slice(1);
210
+ return rules.find(
211
+ (rule) =>
212
+ rule.segments.length <= segments.length &&
213
+ rule.segments.every((segment, index) =>
214
+ segment === null ? segments[index] !== "" : segment === segments[index],
215
+ ),
216
+ );
217
+ }
218
+
219
+ export function sortedBodyLimits(limits: ReadonlyArray<BodyLimit>): PathRule<BodyLimit>[] {
220
+ return sortedByPrefix(limits, "APP_BODY_LIMITS", "/api/feedback/", ({ pathPrefix, maxBytes }) => {
221
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
222
+ throw new Error(`APP_BODY_LIMITS maxBytes for ${pathPrefix} must be a positive whole number of bytes.`);
223
+ }
224
+ });
225
+ }
226
+
227
+ const bodyLimits = sortedBodyLimits(APP_BODY_LIMITS);
228
+
229
+ export function bodyLimitFor(pathname: string, limits: readonly PathRule<BodyLimit>[] = bodyLimits): number | undefined {
230
+ return entryFor(pathname, limits)?.maxBytes;
161
231
  }
162
232
 
163
233
  /** Read a request body, or return null as soon as it exceeds maxBytes. The rest
@@ -218,6 +288,216 @@ export function backendBaseUrl(): string {
218
288
  return process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002";
219
289
  }
220
290
 
291
+ /**
292
+ * Milliseconds one server-side API call may take, response body included, and
293
+ * how often the proxy checks whether the API still answers at all while it waits
294
+ * for response headers.
295
+ */
296
+ const DEFAULT_BACKEND_TIMEOUT_MS = 2_000;
297
+ /**
298
+ * Milliseconds the proxy waits for the API's response headers, unless
299
+ * APP_PROXY_TIMEOUTS lists the path. The API's default idle timeout of 35
300
+ * seconds outlasts it.
301
+ * Streamed bodies are not cut off once they start.
302
+ */
303
+ const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
304
+ /** Seconds between checks while the API is unreachable, also sent as Retry-After. */
305
+ const BACKEND_RETRY_AFTER_SECONDS = 2;
306
+ const MAX_TIMER_MS = 2_147_483_647;
307
+
308
+ /** A whole number of milliseconds from the environment, or the default when unset or empty. */
309
+ export function envMilliseconds(name: string, fallback: number): number {
310
+ const raw = process.env[name]?.trim();
311
+ if (!raw) return fallback;
312
+ const value = Number(raw);
313
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_TIMER_MS) {
314
+ throw new Error(
315
+ `${name} must be a whole number of milliseconds from 1 to ${MAX_TIMER_MS}: ${JSON.stringify(raw)}`,
316
+ );
317
+ }
318
+ return value;
319
+ }
320
+
321
+ export function backendTimeoutMs(): number {
322
+ return envMilliseconds("BACKEND_TIMEOUT_MS", DEFAULT_BACKEND_TIMEOUT_MS);
323
+ }
324
+
325
+ function backendProxyTimeoutMs(): number {
326
+ return envMilliseconds("BACKEND_PROXY_TIMEOUT_MS", DEFAULT_BACKEND_PROXY_TIMEOUT_MS);
327
+ }
328
+
329
+ // An invalid value stops the server when this module loads instead of failing
330
+ // every request that reads it.
331
+ backendTimeoutMs();
332
+ backendProxyTimeoutMs();
333
+
334
+ export interface ProxyTimeout {
335
+ pathPrefix: string;
336
+ /** Milliseconds to wait for response headers, or null for no deadline. */
337
+ timeoutMs: number | null;
338
+ }
339
+
340
+ export function sortedProxyTimeouts(timeouts: ReadonlyArray<ProxyTimeout>): PathRule<ProxyTimeout>[] {
341
+ return sortedByPrefix(timeouts, "APP_PROXY_TIMEOUTS", "/api/exports/", ({ pathPrefix, timeoutMs }) => {
342
+ if (timeoutMs !== null && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMER_MS)) {
343
+ throw new Error(
344
+ `APP_PROXY_TIMEOUTS timeoutMs for ${pathPrefix} must be null or a whole number of milliseconds from 1 to ${MAX_TIMER_MS}.`,
345
+ );
346
+ }
347
+ });
348
+ }
349
+
350
+ const proxyTimeouts = sortedProxyTimeouts(APP_PROXY_TIMEOUTS);
351
+
352
+ /** How long the proxy waits for response headers on a request path, or null for no deadline. */
353
+ export function proxyTimeoutFor(
354
+ pathname: string,
355
+ timeouts: readonly PathRule<ProxyTimeout>[] = proxyTimeouts,
356
+ ): number | null {
357
+ const entry = entryFor(pathname, timeouts);
358
+ return entry ? entry.timeoutMs : backendProxyTimeoutMs();
359
+ }
360
+
361
+ /** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
362
+ const IDLE_TICK_SECONDS = 4;
363
+ /** The longest idle timeout Bun accepts; 0 turns it off. */
364
+ const MAX_IDLE_TIMEOUT_SECONDS = 255;
365
+
366
+ /**
367
+ * Throw unless the web server keeps a silent connection open longer than the
368
+ * proxy waits for response headers. adapter-bun takes its idle timeout from
369
+ * CONNECTION_IDLE_TIMEOUT (Bun's 10 seconds when unset) and closes a connection
370
+ * that sends nothing for that long without a response, so neither the API's late
371
+ * answer nor the proxy's 504 would reach the browser.
372
+ */
373
+ export function checkIdleTimeout(
374
+ idleTimeout: string | undefined,
375
+ defaultTimeoutMs: number,
376
+ timeouts: readonly ProxyTimeout[],
377
+ ): void {
378
+ // adapter-bun rejects a malformed value itself when it starts.
379
+ if (idleTimeout === undefined || !/^\d+$/.test(idleTimeout) || Number(idleTimeout) === 0) return;
380
+ let longest: { timeoutMs: number | null; source: string } = {
381
+ timeoutMs: defaultTimeoutMs,
382
+ source: "BACKEND_PROXY_TIMEOUT_MS",
383
+ };
384
+ for (const { pathPrefix, timeoutMs } of timeouts) {
385
+ if (longest.timeoutMs !== null && (timeoutMs === null || timeoutMs > longest.timeoutMs)) {
386
+ longest = { timeoutMs, source: `APP_PROXY_TIMEOUTS for ${pathPrefix}` };
387
+ }
388
+ }
389
+ const seconds = Number(idleTimeout);
390
+ if (longest.timeoutMs !== null && (seconds - IDLE_TICK_SECONDS) * 1000 > longest.timeoutMs) return;
391
+ const minimum =
392
+ longest.timeoutMs === null ? Infinity : Math.floor(longest.timeoutMs / 1000) + IDLE_TICK_SECONDS + 1;
393
+ const wait = longest.timeoutMs === null ? "no deadline" : `${longest.timeoutMs} ms`;
394
+ const fix =
395
+ minimum <= MAX_IDLE_TIMEOUT_SECONDS
396
+ ? `at least ${minimum}, or to 0 to turn the idle timeout off`
397
+ : `0 to turn the idle timeout off, since Bun allows at most ${MAX_IDLE_TIMEOUT_SECONDS} seconds`;
398
+ throw new Error(
399
+ `CONNECTION_IDLE_TIMEOUT=${seconds} lets the web server close a silent connection before the /api proxy ` +
400
+ `stops waiting for response headers (${wait}, ${longest.source}); Bun closes idle connections up to ` +
401
+ `${IDLE_TICK_SECONDS} seconds early. Set CONNECTION_IDLE_TIMEOUT to ${fix}.`,
402
+ );
403
+ }
404
+
405
+ checkIdleTimeout(process.env.CONNECTION_IDLE_TIMEOUT, backendProxyTimeoutMs(), proxyTimeouts);
406
+
407
+ // The API counts as unreachable only when GET /health gets no HTTP answer
408
+ // within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer, and
409
+ // every BACKEND_TIMEOUT_MS while the proxy waits for response headers, so one
410
+ // slow endpoint does not fail every request in the process, while an API that
411
+ // stops answering still ends a long or unbounded wait. Once the API is found
412
+ // unreachable, requests still waiting for it fail, new ones fail at once instead
413
+ // of each waiting for a refused connection, a DNS lookup, or a hung server, and
414
+ // a background check runs at most every BACKEND_RETRY_AFTER_SECONDS. Any HTTP
415
+ // answer, including an error status, means the API is reachable.
416
+ let unreachable = false;
417
+ let answers = 0;
418
+ let lastCheckEnded = 0;
419
+ let pendingCheck: Promise<void> | undefined;
420
+ let outage = new AbortController();
421
+
422
+ /** Aborts when the API is found unreachable, failing the requests that still wait for it. */
423
+ export function backendOutageSignal(): AbortSignal {
424
+ return outage.signal;
425
+ }
426
+
427
+ /** Check whether the API answers at all. Concurrent callers share one check. */
428
+ export function checkBackend(): Promise<void> {
429
+ if (!pendingCheck) {
430
+ const answersBefore = answers;
431
+ pendingCheck = fetch(`${backendBaseUrl()}/health`, { signal: AbortSignal.timeout(backendTimeoutMs()) })
432
+ .then(
433
+ (response) => {
434
+ reportBackendAnswer();
435
+ return response.body?.cancel().catch(() => undefined);
436
+ },
437
+ () => {
438
+ if (answers !== answersBefore) return;
439
+ unreachable = true;
440
+ outage.abort(new DOMException("The API is unavailable", "AbortError"));
441
+ outage = new AbortController();
442
+ },
443
+ )
444
+ .finally(() => {
445
+ lastCheckEnded = Date.now();
446
+ pendingCheck = undefined;
447
+ });
448
+ }
449
+ return pendingCheck;
450
+ }
451
+
452
+ /** Check the API unless a check is running or one ended within BACKEND_TIMEOUT_MS. */
453
+ function recheckBackend(now = Date.now()): void {
454
+ if (!pendingCheck && now - lastCheckEnded >= backendTimeoutMs()) void checkBackend();
455
+ }
456
+
457
+ /** Record an HTTP answer from the API, whatever its status. */
458
+ export function reportBackendAnswer(): void {
459
+ answers += 1;
460
+ unreachable = false;
461
+ }
462
+
463
+ /** Record a request that got no HTTP answer, and check the API unless it is already known to be down. */
464
+ export function reportBackendFailure(): Promise<void> {
465
+ return unreachable ? Promise.resolve() : checkBackend();
466
+ }
467
+
468
+ /**
469
+ * False while the API is known to be unreachable. Such a call also starts a
470
+ * background check when none ran in the last BACKEND_RETRY_AFTER_SECONDS.
471
+ */
472
+ export function backendReachable(now = Date.now()): boolean {
473
+ if (!unreachable) return true;
474
+ if (!pendingCheck && now - lastCheckEnded >= BACKEND_RETRY_AFTER_SECONDS * 1000) void checkBackend();
475
+ return false;
476
+ }
477
+
478
+ function backendFailure(path: string, statusCode: 503 | 504): Response {
479
+ const timedOut = statusCode === 504;
480
+ return Response.json(
481
+ {
482
+ success: false,
483
+ error: {
484
+ code: timedOut ? "BACKEND_TIMEOUT" : "BACKEND_UNAVAILABLE",
485
+ message: timedOut ? "The API did not respond in time" : "The API is unavailable",
486
+ statusCode,
487
+ path,
488
+ timestamp: new Date().toISOString(),
489
+ },
490
+ },
491
+ {
492
+ status: statusCode,
493
+ headers: {
494
+ "cache-control": "no-store",
495
+ ...(timedOut ? {} : { "retry-after": String(BACKEND_RETRY_AFTER_SECONDS) }),
496
+ },
497
+ },
498
+ );
499
+ }
500
+
221
501
  // Normalize the resolved client IP for the forwarded header. better-auth stores
222
502
  // IPv4 as-is but anonymizes IPv6 (masks the host bits), so an IPv6 loopback would
223
503
  // be recorded as "::" — map loopback to 127.0.0.1 for a clean local value, and
@@ -252,12 +532,16 @@ export async function proxyRequest(
252
532
  const forwardedIp = normalizeClientIp(clientAddress);
253
533
  if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
254
534
 
535
+ const { pathname } = new URL(request.url);
536
+ if (!backendReachable()) {
537
+ request.body?.cancel().catch(() => undefined);
538
+ return backendFailure(pathname, 503);
539
+ }
255
540
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
256
541
  let body: ArrayBuffer | undefined;
257
542
  if (hasBody) {
258
543
  // Preserve multipart uploads and other binary bodies byte-for-byte. Reading
259
544
  // them as text corrupts bytes that are not valid UTF-8 before forwarding.
260
- const { pathname } = new URL(request.url);
261
545
  const maxBytes = bodyLimitFor(pathname);
262
546
  if (maxBytes === undefined) {
263
547
  body = await request.arrayBuffer();
@@ -267,16 +551,46 @@ export async function proxyRequest(
267
551
  body = limited;
268
552
  }
269
553
  }
270
- const upstream = await fetch(targetUrl, {
271
- method: request.method,
272
- headers,
273
- body,
274
- redirect: "manual",
275
- // Abort the upstream request when the browser disconnects. Without this, every
276
- // closed EventSource or aborted request keeps its API socket open until Bun's
277
- // fetch pool is exhausted and the web process stops serving requests.
278
- signal: request.signal,
279
- });
554
+ // The deadline, the health checks, and the outage signal cover only the wait
555
+ // for response headers, so streamed bodies such as server-sent events are not
556
+ // cut off.
557
+ const waiting = new AbortController();
558
+ const timeoutMs = proxyTimeoutFor(pathname);
559
+ const deadline =
560
+ timeoutMs === null
561
+ ? undefined
562
+ : setTimeout(
563
+ () => waiting.abort(new DOMException("The API did not respond in time", "TimeoutError")),
564
+ timeoutMs,
565
+ );
566
+ const healthChecks = setInterval(() => recheckBackend(), backendTimeoutMs());
567
+ const outage = backendOutageSignal();
568
+ const failOnOutage = (): void => waiting.abort(outage.reason);
569
+ outage.addEventListener("abort", failOnOutage);
570
+ let upstream: Response;
571
+ try {
572
+ upstream = await fetch(targetUrl, {
573
+ method: request.method,
574
+ headers,
575
+ body,
576
+ redirect: "manual",
577
+ // Abort the upstream request when the browser disconnects. Without this, every
578
+ // closed EventSource or aborted request keeps its API socket open until Bun's
579
+ // fetch pool is exhausted and the web process stops serving requests.
580
+ signal: AbortSignal.any([request.signal, waiting.signal]),
581
+ });
582
+ } catch (cause: unknown) {
583
+ // Nobody waits for an answer to a request the browser abandoned.
584
+ if (request.signal.aborted) throw cause;
585
+ if (outage.aborted) return backendFailure(pathname, 503);
586
+ void reportBackendFailure();
587
+ return backendFailure(pathname, waiting.signal.aborted ? 504 : 503);
588
+ } finally {
589
+ clearTimeout(deadline);
590
+ clearInterval(healthChecks);
591
+ outage.removeEventListener("abort", failOnOutage);
592
+ }
593
+ reportBackendAnswer();
280
594
 
281
595
  const responseHeaders = new Headers();
282
596
  for (const name of relayedResponseHeaders) {
@@ -3,8 +3,11 @@
3
3
  * proxies to the API. An entry caps the body of every request whose path is
4
4
  * pathPrefix or below it, for example
5
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.
6
+ * { pathPrefix: "/api/hosts/:hostId/uploads", maxBytes: 1024 * 1024 }
7
+ * where a :name segment matches exactly one non-empty path segment. The most
8
+ * specific entry wins: more segments, then a literal segment over a parameter
9
+ * from the left. Above the limit the proxy stops reading and answers 413
10
+ * PAYLOAD_TOO_LARGE without contacting the API.
8
11
  *
9
12
  * BODY_SIZE_LIMIT remains the ceiling for every web route: Bun rejects a larger
10
13
  * body before this code runs, so a maxBytes above it has no effect. Routes that
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Application-owned deadlines for /api requests that the web server proxies to
3
+ * the API. An entry replaces BACKEND_PROXY_TIMEOUT_MS for every request whose
4
+ * path is pathPrefix or below it, for example
5
+ * { pathPrefix: "/api/exports/", timeoutMs: 120_000 }
6
+ * { pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }
7
+ * where a :name segment matches exactly one non-empty path segment. The most
8
+ * specific entry wins: more segments, then a literal segment over a parameter
9
+ * from the left. timeoutMs is how long the proxy waits for the API's response
10
+ * headers before it answers 504 BACKEND_TIMEOUT; null waits as long as the API
11
+ * takes. Either way the proxy answers 503 BACKEND_UNAVAILABLE once the API's
12
+ * GET /health stops answering, and the deadline ends when the response headers
13
+ * arrive.
14
+ *
15
+ * Bun closes a silent connection up to 4 seconds before its idle timeout, so a
16
+ * longer wait also needs the API's API_IDLE_TIMEOUT and the web server's
17
+ * CONNECTION_IDLE_TIMEOUT (35 seconds each by default) more than 4 seconds above
18
+ * the deadline, or 0 for null or anything above 250 seconds. The proxy refuses
19
+ * to load when CONNECTION_IDLE_TIMEOUT cannot cover an entry here.
20
+ * PodoKit creates this file once and never overwrites it.
21
+ */
22
+ export const APP_PROXY_TIMEOUTS: ReadonlyArray<{ pathPrefix: string; timeoutMs: number | null }> = [];
@@ -0,0 +1,13 @@
1
+ import type { RequestHandler } from "@sveltejs/kit";
2
+
3
+ // Liveness and readiness of the web server itself, for the Kubernetes probes,
4
+ // the Compose healthcheck, and the image HEALTHCHECK. It never calls the API, so
5
+ // an API outage does not take the web server out of service.
6
+ export const GET: RequestHandler = () =>
7
+ new Response("ok\n", {
8
+ headers: {
9
+ "content-type": "text/plain; charset=utf-8",
10
+ "cache-control": "no-store",
11
+ "x-robots-tag": "noindex, nofollow",
12
+ },
13
+ });
@@ -12,6 +12,12 @@ POSTGRES_USER=podokit
12
12
  POSTGRES_PASSWORD=podokit
13
13
  POSTGRES_DB=podokit
14
14
  BACKEND_INTERNAL_URL=http://api:5002
15
+ # API: the web container and the gateway are the one proxy in front of it.
16
+ TRUSTED_PROXY_HOPS=1
17
+ # Web: milliseconds per server-side API call, and the /api proxy's wait for
18
+ # response headers.
19
+ # BACKEND_TIMEOUT_MS=2000
20
+ # BACKEND_PROXY_TIMEOUT_MS=30000
15
21
  CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
16
22
  REDIS_HOST=redis
17
23
  REDIS_PORT=6379
@@ -27,6 +27,18 @@ POSTGRES_DB=podokit
27
27
  # Largest API request body in bytes (Bun's default is 128 MiB). Requests through
28
28
  # the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
29
29
  # API_MAX_REQUEST_BODY_SIZE=134217728
30
+ # Seconds the API keeps a silent connection open (0 to 255, 0 turns it off). Keep
31
+ # it, like CONNECTION_IDLE_TIMEOUT below, more than 4 above the longest /api proxy
32
+ # deadline in seconds.
33
+ # API_IDLE_TIMEOUT=35
34
+
35
+ # Reverse proxies in front of the API whose X-Forwarded-For names the caller. The
36
+ # web app's /api proxy is one, so 1 records the browser's address for sessions,
37
+ # audit entries, and rate limits; 0 uses the socket peer. Count only proxies that
38
+ # every request passes: with 1, a caller that reaches the API directly chooses
39
+ # its own recorded address.
40
+ TRUSTED_PROXY_HOPS=1
41
+ # TRUSTED_PROXY_HEADER=x-forwarded-for
30
42
 
31
43
  # Readiness (GET /health/ready): the longest one dependency check may run, and how
32
44
  # long a result is reused across probes (0 checks on every request).
@@ -35,10 +47,21 @@ POSTGRES_DB=podokit
35
47
 
36
48
  # Web -> API boundary (server-side only; not exposed to the browser)
37
49
  BACKEND_INTERNAL_URL=http://localhost:5002
50
+ # Milliseconds one server-side API call may take, and how long the /api proxy
51
+ # waits for response headers (504 BACKEND_TIMEOUT after the second value). When
52
+ # the API's GET /health does not answer within BACKEND_TIMEOUT_MS either, API
53
+ # calls fail at once until it does. Raise BACKEND_PROXY_TIMEOUT_MS together with
54
+ # API_IDLE_TIMEOUT above and CONNECTION_IDLE_TIMEOUT below.
55
+ # BACKEND_TIMEOUT_MS=2000
56
+ # BACKEND_PROXY_TIMEOUT_MS=30000
38
57
 
39
58
  CORS_ORIGIN=http://localhost:5001
40
59
  # Web request limit; leave room for a 2 MiB multipart upload
41
60
  BODY_SIZE_LIMIT=3M
61
+ # Seconds the built web server keeps a silent connection open (0 to 255, 0 turns
62
+ # it off). Keep it more than 4 above the longest /api proxy deadline in seconds,
63
+ # BACKEND_PROXY_TIMEOUT_MS or APP_PROXY_TIMEOUTS; the proxy refuses to load otherwise.
64
+ CONNECTION_IDLE_TIMEOUT=35
42
65
 
43
66
  # ── Optional / development ───────────────────────────────────────────
44
67
  # Redis — only needed by cache/queue modules (redis, bullmq, rate-limit,
@@ -9,6 +9,9 @@ data:
9
9
  CORS_ORIGIN: "https://example.com"
10
10
  BACKEND_INTERNAL_URL: "http://api:3000"
11
11
  BODY_SIZE_LIMIT: "3M"
12
+ CONNECTION_IDLE_TIMEOUT: "35"
13
+ API_IDLE_TIMEOUT: "35"
14
+ TRUSTED_PROXY_HOPS: "1"
12
15
  PROTOCOL_HEADER: "x-forwarded-proto"
13
16
  HOST_HEADER: "x-forwarded-host"
14
17
  ADDRESS_HEADER: "x-forwarded-for"
@@ -21,10 +21,13 @@ spec:
21
21
  envFrom:
22
22
  - configMapRef: { name: app-config }
23
23
  # Without these a rolling update sends traffic to a process that has not
24
- # bound its port yet.
24
+ # bound its port yet. /healthz never calls the API, so an API outage does
25
+ # not take the web pods out of service.
25
26
  readinessProbe:
26
- httpGet: { path: /, port: 3000 }
27
+ httpGet: { path: /healthz, port: 3000 }
27
28
  initialDelaySeconds: 5
29
+ timeoutSeconds: 2
28
30
  livenessProbe:
29
- httpGet: { path: /, port: 3000 }
31
+ httpGet: { path: /healthz, port: 3000 }
30
32
  initialDelaySeconds: 15
33
+ timeoutSeconds: 2
@@ -12,3 +12,11 @@ test("GET /health/ready reports the database", async ({ request }) => {
12
12
  expect(res.status()).toBe(200);
13
13
  expect((await res.json()).status).toBe("ready");
14
14
  });
15
+
16
+ test("GET /healthz answers from the web server without caching or indexing @smoke", async ({ request }) => {
17
+ const res = await request.get("/healthz");
18
+ expect(res.status()).toBe(200);
19
+ expect(await res.text()).toBe("ok\n");
20
+ expect(res.headers()["cache-control"]).toBe("no-store");
21
+ expect(res.headers()["x-robots-tag"]).toBe("noindex, nofollow");
22
+ });