@podosoft/podokit 1.4.2 → 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 (66) hide show
  1. package/dist/deploy-compose-profile.js +2 -2
  2. package/dist/deploy-compose-render.js +1 -0
  3. package/dist/deploy-profile.js +2 -2
  4. package/dist/deploy-render.js +2 -0
  5. package/dist/deploy-schema.d.ts +7 -0
  6. package/dist/deploy-schema.js +40 -0
  7. package/dist/lockfile.js +3 -1
  8. package/dist/provider.d.ts +1 -0
  9. package/dist/provider.js +5 -2
  10. package/dist/templates/fullstack/AGENTS.md +18 -5
  11. package/dist/templates/fullstack/apps/api/package.json +1 -1
  12. package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
  13. package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
  14. package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
  15. package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
  16. package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
  17. package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
  18. package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
  19. package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
  20. package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
  21. package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
  22. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
  23. package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
  24. package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
  25. package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
  26. package/dist/templates/fullstack/dot-env.docker.example +2 -0
  27. package/dist/templates/fullstack/dot-env.example +17 -1
  28. package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
  39. package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
  40. package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
  41. package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
  42. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
  46. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
  47. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
  48. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
  49. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
  50. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
  51. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
  52. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
  53. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
  54. package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
  55. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
  56. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
  57. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
  58. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
  59. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
  60. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
  61. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
  62. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
  63. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
  64. package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
  65. package/dist/templates/todo/AGENTS.md +41 -5
  66. 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
@@ -220,13 +290,15 @@ export function backendBaseUrl(): string {
220
290
 
221
291
  /**
222
292
  * Milliseconds one server-side API call may take, response body included, and
223
- * how long the proxy waits for response headers before it checks whether the
224
- * API still answers at all.
293
+ * how often the proxy checks whether the API still answers at all while it waits
294
+ * for response headers.
225
295
  */
226
296
  const DEFAULT_BACKEND_TIMEOUT_MS = 2_000;
227
297
  /**
228
- * Milliseconds the proxy waits for the API's response headers, matching the
229
- * API's default idle timeout. Streamed bodies are not cut off once they start.
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.
230
302
  */
231
303
  const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
232
304
  /** Seconds between checks while the API is unreachable, also sent as Retry-After. */
@@ -259,10 +331,84 @@ function backendProxyTimeoutMs(): number {
259
331
  backendTimeoutMs();
260
332
  backendProxyTimeoutMs();
261
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
+
262
407
  // The API counts as unreachable only when GET /health gets no HTTP answer
263
- // within BACKEND_TIMEOUT_MS. It is checked when a request gets no answer, or
264
- // when the proxy waits longer than that for response headers, so one slow
265
- // endpoint does not fail every request in the process. Once the API is found
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
266
412
  // unreachable, requests still waiting for it fail, new ones fail at once instead
267
413
  // of each waiting for a refused connection, a DNS lookup, or a hung server, and
268
414
  // a background check runs at most every BACKEND_RETRY_AFTER_SECONDS. Any HTTP
@@ -303,6 +449,11 @@ export function checkBackend(): Promise<void> {
303
449
  return pendingCheck;
304
450
  }
305
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
+
306
457
  /** Record an HTTP answer from the API, whatever its status. */
307
458
  export function reportBackendAnswer(): void {
308
459
  answers += 1;
@@ -400,15 +551,19 @@ export async function proxyRequest(
400
551
  body = limited;
401
552
  }
402
553
  }
403
- // The deadline, the health check, and the outage signal cover only the wait
554
+ // The deadline, the health checks, and the outage signal cover only the wait
404
555
  // for response headers, so streamed bodies such as server-sent events are not
405
556
  // cut off.
406
557
  const waiting = new AbortController();
407
- const deadline = setTimeout(
408
- () => waiting.abort(new DOMException("The API did not respond in time", "TimeoutError")),
409
- backendProxyTimeoutMs(),
410
- );
411
- const healthCheck = setTimeout(() => void checkBackend(), backendTimeoutMs());
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());
412
567
  const outage = backendOutageSignal();
413
568
  const failOnOutage = (): void => waiting.abort(outage.reason);
414
569
  outage.addEventListener("abort", failOnOutage);
@@ -432,7 +587,7 @@ export async function proxyRequest(
432
587
  return backendFailure(pathname, waiting.signal.aborted ? 504 : 503);
433
588
  } finally {
434
589
  clearTimeout(deadline);
435
- clearTimeout(healthCheck);
590
+ clearInterval(healthChecks);
436
591
  outage.removeEventListener("abort", failOnOutage);
437
592
  }
438
593
  reportBackendAnswer();
@@ -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 }> = [];
@@ -12,6 +12,8 @@ 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
15
17
  # Web: milliseconds per server-side API call, and the /api proxy's wait for
16
18
  # response headers.
17
19
  # BACKEND_TIMEOUT_MS=2000
@@ -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).
@@ -39,13 +51,17 @@ BACKEND_INTERNAL_URL=http://localhost:5002
39
51
  # waits for response headers (504 BACKEND_TIMEOUT after the second value). When
40
52
  # the API's GET /health does not answer within BACKEND_TIMEOUT_MS either, API
41
53
  # calls fail at once until it does. Raise BACKEND_PROXY_TIMEOUT_MS together with
42
- # the API's apiServerOptions.idleTimeout.
54
+ # API_IDLE_TIMEOUT above and CONNECTION_IDLE_TIMEOUT below.
43
55
  # BACKEND_TIMEOUT_MS=2000
44
56
  # BACKEND_PROXY_TIMEOUT_MS=30000
45
57
 
46
58
  CORS_ORIGIN=http://localhost:5001
47
59
  # Web request limit; leave room for a 2 MiB multipart upload
48
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
49
65
 
50
66
  # ── Optional / development ───────────────────────────────────────────
51
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"
@@ -0,0 +1,152 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
2
+ import { SQL } from "bun";
3
+ import { AppException } from "@podosoft/podokit-contracts";
4
+ import { Elysia } from "elysia";
5
+ import type { AuthService, AuthSession } from "../auth/auth.service";
6
+ import { AdminSessionsService } from "../admin-sessions/admin-sessions.service";
7
+ import {
8
+ accountSessionRoutes,
9
+ type AccountSessionPage,
10
+ type OwnSessionRevoker,
11
+ } from "./account-sessions.routes";
12
+
13
+ // More sessions than Better Auth's /list-sessions returns.
14
+ const OTHER_SESSIONS = 150;
15
+ const DAY_MS = 86_400_000;
16
+
17
+ // Callers name their session with a test header; its row says whose it is.
18
+ function authFor(db: SQL): Pick<AuthService, "requireSession"> {
19
+ return {
20
+ requireSession: async (request: Request): Promise<AuthSession> => {
21
+ const id = request.headers.get("x-test-session");
22
+ const [row] = id
23
+ ? await db<Array<{ userId: string; createdAt: string }>>`
24
+ SELECT "userId", "createdAt" FROM "session" WHERE "id" = ${id}
25
+ `
26
+ : [];
27
+ if (!row) throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
28
+ return { user: { id: row.userId }, session: { id, createdAt: row.createdAt } };
29
+ },
30
+ };
31
+ }
32
+
33
+ describe("account session routes", () => {
34
+ let db: SQL;
35
+ let revoke: Mock<OwnSessionRevoker>;
36
+ let freshAge: number;
37
+ let handle: (request: Request) => Promise<Response>;
38
+ const now = Date.now();
39
+
40
+ beforeEach(async () => {
41
+ db = new SQL(":memory:");
42
+ await db`CREATE TABLE "user" ("id" TEXT PRIMARY KEY, "email" TEXT NOT NULL, "name" TEXT NOT NULL)`;
43
+ await db`
44
+ CREATE TABLE "session" (
45
+ "id" TEXT PRIMARY KEY, "userId" TEXT NOT NULL, "token" TEXT NOT NULL,
46
+ "ipAddress" TEXT, "userAgent" TEXT, "impersonatedBy" TEXT,
47
+ "createdAt" TEXT NOT NULL, "expiresAt" TEXT NOT NULL
48
+ )
49
+ `;
50
+ await db`INSERT INTO "user" VALUES ('user-1', 'one@example.com', 'One'), ('user-2', 'two@example.com', 'Two')`;
51
+ const expires = new Date(now + DAY_MS).toISOString();
52
+ // The current session is the oldest, so newest-first paging would put it last.
53
+ await db`
54
+ INSERT INTO "session" VALUES ('current', 'user-1', 'secret-current', '203.0.113.7', 'Agent',
55
+ NULL, ${new Date(now - 60_000).toISOString()}, ${expires})
56
+ `;
57
+ for (let index = 0; index < OTHER_SESSIONS; index += 1) {
58
+ await db`
59
+ INSERT INTO "session" VALUES (${`other-${index}`}, 'user-1', ${`secret-${index}`}, NULL, NULL, NULL,
60
+ ${new Date(now - 30_000 + index).toISOString()}, ${expires})
61
+ `;
62
+ }
63
+ await db`
64
+ INSERT INTO "session" VALUES ('foreign', 'user-2', 'secret-foreign', NULL, NULL, NULL,
65
+ ${new Date(now).toISOString()}, ${expires})
66
+ `;
67
+ revoke = mock<OwnSessionRevoker>(async () => undefined);
68
+ freshAge = 86_400;
69
+ const app = new Elysia()
70
+ .onError(({ code, error, set }) => {
71
+ if (error instanceof AppException) {
72
+ set.status = error.statusCode;
73
+ return { code: error.code };
74
+ }
75
+ if (code === "VALIDATION") {
76
+ set.status = 400;
77
+ return { code: "VALIDATION_ERROR" };
78
+ }
79
+ return undefined;
80
+ })
81
+ .use(accountSessionRoutes({
82
+ auth: authFor(db),
83
+ sessions: new AdminSessionsService(db, "sqlite"),
84
+ revoke,
85
+ freshAgeSeconds: async () => freshAge,
86
+ }));
87
+ handle = (request) => app.handle(request);
88
+ });
89
+
90
+ afterEach(async () => {
91
+ await db.close();
92
+ });
93
+
94
+ async function send(path: string, init: RequestInit = {}, session: string | null = "current"): Promise<Response> {
95
+ const headers = new Headers(init.headers);
96
+ if (session) headers.set("x-test-session", session);
97
+ return handle(new Request(`http://localhost${path}`, { ...init, headers }));
98
+ }
99
+
100
+ test("returns the current session on every page and pages through every other one", async () => {
101
+ const seen: string[] = [];
102
+ let cursor: string | null = null;
103
+ let pages = 0;
104
+ do {
105
+ const response = await send(`/account/sessions?limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
106
+ expect(response.status).toBe(200);
107
+ const text = await response.text();
108
+ expect(text).not.toContain("secret-");
109
+ const page = JSON.parse(text) as AccountSessionPage;
110
+ expect(page.current).toMatchObject({ id: "current", ipAddress: "203.0.113.7", userAgent: "Agent" });
111
+ expect(page.total).toBe(OTHER_SESSIONS + 1);
112
+ seen.push(...page.items.map((item) => item.id));
113
+ cursor = page.nextCursor;
114
+ pages += 1;
115
+ } while (cursor);
116
+
117
+ expect(pages).toBe(2);
118
+ expect(seen).toHaveLength(OTHER_SESSIONS);
119
+ expect(new Set(seen).size).toBe(OTHER_SESSIONS);
120
+ expect(seen).not.toContain("current");
121
+ expect(seen).not.toContain("foreign");
122
+ expect(seen[0]).toBe(`other-${OTHER_SESSIONS - 1}`);
123
+ });
124
+
125
+ test("refuses anonymous callers and a session too old to list sessions", async () => {
126
+ expect((await send("/account/sessions", {}, null)).status).toBe(401);
127
+ await db`UPDATE "session" SET "createdAt" = ${new Date(now - 2 * DAY_MS).toISOString()} WHERE "id" = 'current'`;
128
+ const stale = await send("/account/sessions");
129
+ expect(stale.status).toBe(403);
130
+ expect(await stale.json()).toEqual({ code: "SESSION_NOT_FRESH" });
131
+ freshAge = 0;
132
+ expect((await send("/account/sessions")).status).toBe(200);
133
+ });
134
+
135
+ test("revokes one of the caller's sessions by id with the token looked up on the server", async () => {
136
+ const response = await send("/account/sessions/other-3", { method: "DELETE", headers: { cookie: "session=1" } });
137
+
138
+ expect(response.status).toBe(204);
139
+ expect(revoke).toHaveBeenCalledTimes(1);
140
+ const [token, headers] = revoke.mock.calls[0] ?? [];
141
+ expect(token).toBe("secret-3");
142
+ expect(headers?.get("cookie")).toBe("session=1");
143
+ });
144
+
145
+ test("does not reveal or revoke another user's session", async () => {
146
+ const response = await send("/account/sessions/foreign", { method: "DELETE" });
147
+
148
+ expect(response.status).toBe(404);
149
+ expect(await response.json()).toEqual({ code: "ACCOUNT_SESSION_NOT_FOUND" });
150
+ expect(revoke).not.toHaveBeenCalled();
151
+ });
152
+ });
@@ -0,0 +1,112 @@
1
+ import { AppException } from "@podosoft/podokit-contracts";
2
+ import { Elysia, t } from "elysia";
3
+ import type { AuthService, AuthSession } from "../auth/auth.service";
4
+ import type { ElysiaPlugin } from "../core/services";
5
+ import {
6
+ ADMIN_SESSIONS_MAX_LIMIT,
7
+ type AdminSessionsService,
8
+ type AdminSessionView,
9
+ } from "../admin-sessions/admin-sessions.service";
10
+
11
+ export const ACCOUNT_SESSION_NOT_FOUND = "ACCOUNT_SESSION_NOT_FOUND";
12
+ /** Better Auth's code for a session older than `session.freshAge`. */
13
+ export const SESSION_NOT_FRESH = "SESSION_NOT_FRESH";
14
+
15
+ /** One of the caller's own sessions. The bearer token never leaves the server. */
16
+ export interface AccountSessionView {
17
+ id: string;
18
+ ipAddress: string | null;
19
+ userAgent: string | null;
20
+ createdAt: string;
21
+ expiresAt: string;
22
+ }
23
+
24
+ export interface AccountSessionPage {
25
+ /** The session making the request, on every page. */
26
+ current: AccountSessionView | null;
27
+ /** The caller's other active sessions, newest first. */
28
+ items: AccountSessionView[];
29
+ nextCursor: string | null;
30
+ /** Every active session of the caller, the current one included. */
31
+ total: number;
32
+ }
33
+
34
+ /** Revokes one of the caller's sessions by its bearer token. */
35
+ export type OwnSessionRevoker = (sessionToken: string, headers: Headers) => Promise<void>;
36
+
37
+ export interface AccountSessionRoutesOptions {
38
+ auth: Pick<AuthService, "requireSession">;
39
+ sessions: AdminSessionsService;
40
+ revoke: OwnSessionRevoker;
41
+ /** Better Auth's `session.freshAge` in seconds; 0 turns the check off. */
42
+ freshAgeSeconds: () => Promise<number>;
43
+ }
44
+
45
+ function accountView(session: AdminSessionView): AccountSessionView {
46
+ return {
47
+ id: session.id,
48
+ ipAddress: session.ipAddress,
49
+ userAgent: session.userAgent,
50
+ createdAt: session.createdAt,
51
+ expiresAt: session.expiresAt,
52
+ };
53
+ }
54
+
55
+ // Better Auth lists sessions only for a fresh session; this list keeps that rule.
56
+ function requireFresh(session: AuthSession, freshAgeSeconds: number): void {
57
+ if (freshAgeSeconds === 0) return;
58
+ const createdAt = new Date(session.session.createdAt as string | Date).getTime();
59
+ if (Date.now() - createdAt >= freshAgeSeconds * 1000) {
60
+ throw new AppException(SESSION_NOT_FRESH, "Session is not fresh", 403);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The signed-in user's own sessions, paged on the server. Better Auth's
66
+ * `/list-sessions` returns at most 100 sessions, which could leave out the
67
+ * current one.
68
+ */
69
+ export function accountSessionRoutes({
70
+ auth,
71
+ sessions,
72
+ revoke,
73
+ freshAgeSeconds,
74
+ }: AccountSessionRoutesOptions): ElysiaPlugin {
75
+ return new Elysia({ name: "podokit.account.sessions" })
76
+ .get("/account/sessions", async ({ request, query }) => {
77
+ const session = await auth.requireSession(request);
78
+ requireFresh(session, await freshAgeSeconds());
79
+ const currentId = String(session.session.id);
80
+ const [page, current] = await Promise.all([
81
+ sessions.list({ ...query, userId: session.user.id, excludeId: currentId }),
82
+ sessions.find(currentId),
83
+ ]);
84
+ const result: AccountSessionPage = {
85
+ current: current ? accountView(current) : null,
86
+ items: page.items.map(accountView),
87
+ nextCursor: page.nextCursor,
88
+ total: page.total + (current ? 1 : 0),
89
+ };
90
+ return result;
91
+ }, {
92
+ query: t.Object({
93
+ cursor: t.Optional(t.String({ minLength: 1, maxLength: 512 })),
94
+ limit: t.Optional(t.Numeric({ minimum: 1, maximum: ADMIN_SESSIONS_MAX_LIMIT })),
95
+ }),
96
+ detail: {
97
+ tags: ["account"],
98
+ summary: "List the current user's active sessions",
99
+ description: "`current` is the session making the request; `items` are the other sessions, newest first, keyset-paged with `cursor`. Session tokens are never returned. A session older than Better Auth's `session.freshAge` gets 403 `SESSION_NOT_FRESH`, as with `/api/auth/list-sessions`.",
100
+ },
101
+ })
102
+ .delete("/account/sessions/:id", async ({ request, params, set }) => {
103
+ const session = await auth.requireSession(request);
104
+ const token = await sessions.tokenOf(params.id, session.user.id);
105
+ if (!token) throw new AppException(ACCOUNT_SESSION_NOT_FOUND, "Session not found.", 404);
106
+ await revoke(token, request.headers);
107
+ set.status = 204;
108
+ }, {
109
+ params: t.Object({ id: t.String({ minLength: 1, maxLength: 255 }) }),
110
+ detail: { tags: ["account"], summary: "Revoke one of the current user's sessions by id" },
111
+ });
112
+ }