@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
@@ -357,6 +357,8 @@ function buildDefaultComposeProfile(projectName, options) {
357
357
  CORS_ORIGIN: `https://${host}`,
358
358
  ADDRESS_HEADER: "x-forwarded-for",
359
359
  XFF_DEPTH: "1",
360
+ TRUSTED_PROXY_HOPS: "1",
361
+ TRUSTED_PROXY_HEADER: "x-forwarded-for",
360
362
  },
361
363
  verification: {
362
364
  baseUrl: `https://${host}`,
@@ -418,8 +420,6 @@ function initializeComposeProfile(projectRoot, name, options) {
418
420
  RATE_LIMIT_AUTH_TTL: "60",
419
421
  RATE_LIMIT_AUTH_MAX: "20",
420
422
  RATE_LIMIT_RUNTIME_MAX: "1000",
421
- RATE_LIMIT_TRUSTED_PROXY_HOPS: "1",
422
- RATE_LIMIT_PROXY_HEADER: "x-forwarded-for",
423
423
  RATE_LIMIT_STORAGE_TIMEOUT_MS: "1000",
424
424
  RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "1",
425
425
  }
@@ -37,6 +37,7 @@ function derivedRuntimeConfig(profile) {
37
37
  const result = {
38
38
  PORT: String(CONTAINER_PORT),
39
39
  BACKEND_INTERNAL_URL: `http://${serviceName(profile, "api")}:${CONTAINER_PORT}`,
40
+ ...(0, deploy_schema_1.idleTimeoutConfig)(profile.runtimeConfig),
40
41
  ...profile.runtimeConfig,
41
42
  };
42
43
  if (profile.dependencies.postgres.mode === "managed") {
@@ -397,6 +397,8 @@ function initializeDeploymentProfile(projectRoot, name, options) {
397
397
  CORS_ORIGIN: `https://${host}`,
398
398
  ADDRESS_HEADER: "x-forwarded-for",
399
399
  XFF_DEPTH: "1",
400
+ TRUSTED_PROXY_HOPS: "1",
401
+ TRUSTED_PROXY_HEADER: "x-forwarded-for",
400
402
  ...(usesAuth ? { BETTER_AUTH_URL: `https://${host}` } : {}),
401
403
  ...(modules.has("rate-limit")
402
404
  ? {
@@ -406,8 +408,6 @@ function initializeDeploymentProfile(projectRoot, name, options) {
406
408
  RATE_LIMIT_AUTH_TTL: "60",
407
409
  RATE_LIMIT_AUTH_MAX: "20",
408
410
  RATE_LIMIT_RUNTIME_MAX: "1000",
409
- RATE_LIMIT_TRUSTED_PROXY_HOPS: "1",
410
- RATE_LIMIT_PROXY_HEADER: "x-forwarded-for",
411
411
  RATE_LIMIT_STORAGE_TIMEOUT_MS: "1000",
412
412
  RATE_LIMIT_UNAVAILABLE_RETRY_AFTER: "1",
413
413
  }
@@ -6,6 +6,7 @@ const node_crypto_1 = require("node:crypto");
6
6
  const node_path_1 = require("node:path");
7
7
  const deploy_profile_1 = require("./deploy-profile");
8
8
  const api_paths_1 = require("./api-paths");
9
+ const deploy_schema_1 = require("./deploy-schema");
9
10
  const lockfile_1 = require("./lockfile");
10
11
  const toolchain_1 = require("./toolchain");
11
12
  function quote(value) {
@@ -356,6 +357,7 @@ function derivedRuntimeConfig(profile) {
356
357
  BACKEND_INTERNAL_URL: `http://${profile.target.release}-api:3000`,
357
358
  PROTOCOL_HEADER: "x-forwarded-proto",
358
359
  HOST_HEADER: "x-forwarded-host",
360
+ ...(0, deploy_schema_1.idleTimeoutConfig)(profile.runtimeConfig),
359
361
  ...profile.runtimeConfig,
360
362
  };
361
363
  if (profile.dependencies.postgres.mode === "inCluster") {
@@ -50,6 +50,13 @@ export declare function validateImage(value: string, field: string): void;
50
50
  export declare function validateImageRepository(value: string, field: string): void;
51
51
  export declare function parseReleaseProfile(release: Record<string, unknown>): ReleaseProfile;
52
52
  export declare function parseRuntimeConfig(runtimeConfig: Record<string, unknown>): Record<string, string>;
53
+ /**
54
+ * The idle timeouts of the web server (CONNECTION_IDLE_TIMEOUT) and the API
55
+ * (API_IDLE_TIMEOUT), both run by Bun. Each is the profile's own value, or else
56
+ * the fewest seconds that keep a silent connection open past the /api proxy's
57
+ * BACKEND_PROXY_TIMEOUT_MS deadline: 35 for the default 30000 ms.
58
+ */
59
+ export declare function idleTimeoutConfig(runtimeConfig: Record<string, string>): Record<string, string>;
53
60
  export declare function validateHttpsOrigin(value: string): void;
54
61
  export declare function parseVerification(verification: Record<string, unknown>): VerificationProfile;
55
62
  export declare function profileDirectory(projectRoot: string): string;
@@ -17,6 +17,7 @@ exports.validateImage = validateImage;
17
17
  exports.validateImageRepository = validateImageRepository;
18
18
  exports.parseReleaseProfile = parseReleaseProfile;
19
19
  exports.parseRuntimeConfig = parseRuntimeConfig;
20
+ exports.idleTimeoutConfig = idleTimeoutConfig;
20
21
  exports.validateHttpsOrigin = validateHttpsOrigin;
21
22
  exports.parseVerification = parseVerification;
22
23
  exports.profileDirectory = profileDirectory;
@@ -35,6 +36,11 @@ exports.MANAGED_RUNTIME_CONFIG_KEYS = [
35
36
  "PROTOCOL_HEADER",
36
37
  "HOST_HEADER",
37
38
  ];
39
+ /** Keys the generated API no longer reads; it refuses to start while they are set. */
40
+ const RENAMED_RUNTIME_CONFIG_KEYS = {
41
+ RATE_LIMIT_TRUSTED_PROXY_HOPS: "TRUSTED_PROXY_HOPS",
42
+ RATE_LIMIT_PROXY_HEADER: "TRUSTED_PROXY_HEADER",
43
+ };
38
44
  function containsUrlCredentials(value) {
39
45
  if (!value.includes("://"))
40
46
  return false;
@@ -183,6 +189,10 @@ function parseRuntimeConfig(runtimeConfig) {
183
189
  if (exports.MANAGED_RUNTIME_CONFIG_KEYS.includes(key)) {
184
190
  throw new Error(`Deployment runtimeConfig key "${key}" is managed by PodoKit.`);
185
191
  }
192
+ const renamed = RENAMED_RUNTIME_CONFIG_KEYS[key];
193
+ if (renamed) {
194
+ throw new Error(`Deployment runtimeConfig key "${key}" was renamed to "${renamed}".`);
195
+ }
186
196
  if (typeof entry !== "string") {
187
197
  throw new Error(`Deployment runtimeConfig value "${key}" must be a string.`);
188
198
  }
@@ -193,6 +203,36 @@ function parseRuntimeConfig(runtimeConfig) {
193
203
  }
194
204
  return parsed;
195
205
  }
206
+ /** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
207
+ const IDLE_TICK_SECONDS = 4;
208
+ const MAX_IDLE_TIMEOUT_SECONDS = 255;
209
+ const DEFAULT_BACKEND_PROXY_TIMEOUT_MS = 30_000;
210
+ /**
211
+ * The idle timeouts of the web server (CONNECTION_IDLE_TIMEOUT) and the API
212
+ * (API_IDLE_TIMEOUT), both run by Bun. Each is the profile's own value, or else
213
+ * the fewest seconds that keep a silent connection open past the /api proxy's
214
+ * BACKEND_PROXY_TIMEOUT_MS deadline: 35 for the default 30000 ms.
215
+ */
216
+ function idleTimeoutConfig(runtimeConfig) {
217
+ const proxyTimeout = runtimeConfig.BACKEND_PROXY_TIMEOUT_MS?.trim();
218
+ const proxyMs = proxyTimeout && /^\d+$/.test(proxyTimeout) ? Number(proxyTimeout) : DEFAULT_BACKEND_PROXY_TIMEOUT_MS;
219
+ const derived = Math.floor(proxyMs / 1000) + IDLE_TICK_SECONDS + 1;
220
+ const config = {};
221
+ for (const name of ["CONNECTION_IDLE_TIMEOUT", "API_IDLE_TIMEOUT"]) {
222
+ const configured = runtimeConfig[name];
223
+ if (configured !== undefined) {
224
+ config[name] = configured;
225
+ }
226
+ else if (derived > MAX_IDLE_TIMEOUT_SECONDS) {
227
+ throw new Error(`Deployment runtimeConfig BACKEND_PROXY_TIMEOUT_MS ${proxyMs} outlasts the longest idle timeout ` +
228
+ `(${MAX_IDLE_TIMEOUT_SECONDS} seconds); set ${name} to "0" to turn it off.`);
229
+ }
230
+ else {
231
+ config[name] = String(derived);
232
+ }
233
+ }
234
+ return config;
235
+ }
196
236
  function validateHttpsOrigin(value) {
197
237
  let parsed;
198
238
  try {
package/dist/lockfile.js CHANGED
@@ -55,9 +55,11 @@ exports.DEFAULT_OWNED_GLOBS = [
55
55
  "apps/web/src/lib/i18n/catalogs/app/**",
56
56
  // Owned Elysia extension slot — register application services and plugins here.
57
57
  "apps/api/src/app.extensions.ts",
58
- // Owned additions to the managed /api proxy's header lists and body limits.
58
+ // Owned additions to the managed /api proxy's header lists, body limits, and
59
+ // response deadlines.
59
60
  "apps/web/src/lib/server/proxy-headers.ts",
60
61
  "apps/web/src/lib/server/body-limits.ts",
62
+ "apps/web/src/lib/server/proxy-timeouts.ts",
61
63
  // Owned fallback page SvelteKit serves when the root layout or a hook fails.
62
64
  "apps/web/src/error.html",
63
65
  // AI agent guidance — yours to customize; update never touches it.
@@ -18,6 +18,7 @@ export interface ApplyProviderChangeOptions {
18
18
  modulesDir?: string;
19
19
  podokitVersion?: string;
20
20
  }
21
+ /** The managed provider source; it must render byte for byte like the template's copy. */
21
22
  export declare function providerSource(providers: ProviderSelections): string;
22
23
  export declare function listProviders(projectRoot: string): ProviderListItem[];
23
24
  export declare function planProviderChange(projectRoot: string, capabilityInput: string, provider: string, modulesDir?: string): ProviderChangePlan;
package/dist/provider.js CHANGED
@@ -27,15 +27,18 @@ function assertProvider(capability, provider) {
27
27
  }
28
28
  return capability;
29
29
  }
30
+ /** The managed provider source; it must render byte for byte like the template's copy. */
30
31
  function providerSource(providers) {
31
32
  return `import type { ProviderSelections } from "@podosoft/podokit-runtime";\n\n` +
32
- `export const PROVIDERS = {\n` +
33
+ `// Typed with every provider name, not the selected ones: the module of a\n` +
34
+ `// provider that stays installed after a switch compares against its own name.\n` +
35
+ `export const PROVIDERS: Readonly<ProviderSelections> = {\n` +
33
36
  ` database: "${providers.database}",\n` +
34
37
  ` cache: "${providers.cache}",\n` +
35
38
  ` "object-storage": "${providers["object-storage"]}",\n` +
36
39
  ` events: "${providers.events}",\n` +
37
40
  ` jobs: "${providers.jobs}",\n` +
38
- `} as const satisfies ProviderSelections;\n`;
41
+ `};\n`;
39
42
  }
40
43
  function listProviders(projectRoot) {
41
44
  const manifest = requireManifest(projectRoot);
@@ -49,15 +49,28 @@ Merge added module settings from `.env.example` into an existing `.env`.
49
49
  managed lists. Cap a route's request body in the app-owned
50
50
  `apps/web/src/lib/server/body-limits.ts` (`APP_BODY_LIMITS`, 413
51
51
  `PAYLOAD_TOO_LARGE`). `BODY_SIZE_LIMIT` is the ceiling for every web route, so
52
- uploads larger than it must use a direct API path (`apiPaths`) instead.
52
+ uploads larger than it must use a direct API path (`apiPaths`) instead. Give a
53
+ route a longer, shorter, or no response deadline in the app-owned
54
+ `apps/web/src/lib/server/proxy-timeouts.ts` (`APP_PROXY_TIMEOUTS`, `null` for
55
+ none); never edit the managed deadline. Both lists take path prefixes with
56
+ `:name` parameters, such as `/api/hosts/:hostId/file-transfers`, and the most
57
+ specific prefix wins.
53
58
  - Server-side API calls (`serverApiClient`) give up after `BACKEND_TIMEOUT_MS`
54
59
  (2000), and the proxy answers 504 `BACKEND_TIMEOUT` when the API sends no
55
60
  response headers within `BACKEND_PROXY_TIMEOUT_MS` (30000). Once the API's
56
61
  `GET /health` stops answering too, calls throw `BackendUnavailableError` and the
57
62
  proxy answers 503 `BACKEND_UNAVAILABLE` at once until it answers again. Handle
58
- any non-`ApiError` failure in a load function as a temporary outage. Raising
59
- the API's `apiServerOptions.idleTimeout` needs a matching
60
- `BACKEND_PROXY_TIMEOUT_MS`.
63
+ any non-`ApiError` failure in a load function as a temporary outage. The API
64
+ (`API_IDLE_TIMEOUT`) and the built web server (`CONNECTION_IDLE_TIMEOUT`) close
65
+ a connection that sends nothing for that many seconds (35 each): raise both
66
+ with any longer `BACKEND_PROXY_TIMEOUT_MS` or `APP_PROXY_TIMEOUTS` entry, more
67
+ than 4 above it, or set `0` when a route has no deadline. The proxy refuses to
68
+ load when `CONNECTION_IDLE_TIMEOUT` cannot cover a deadline.
69
+ - Read a caller's address in the API with `clientAddressOf(request.headers)` from
70
+ `apps/api/src/core/client-address.ts`, never from `X-Forwarded-For` directly. The
71
+ API resolves it once per request from `TRUSTED_PROXY_HOPS` (1: the web proxy) and
72
+ `TRUSTED_PROXY_HEADER`; Better Auth sessions, audit entries, and rate limits use
73
+ the same address.
61
74
  - `GET /healthz` (`apps/web/src/routes/healthz/+server.ts`) is the web server's own
62
75
  health route for the Kubernetes probes, the Compose healthcheck, and the image
63
76
  `HEALTHCHECK`. Keep it free of API calls, so an API outage does not take the web
@@ -108,7 +121,7 @@ changes need an e2e/unit test.
108
121
  This project is managed by the `podo` CLI. Files have an ownership tier recorded
109
122
  in `.podokit/files.lock` — respect it:
110
123
 
111
- - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` sets the body limit when the export leaves it out.
124
+ - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. Add application-owned services or module descriptors through `app.extensions.ts` instead of changing toolkit registrations. Set Bun server options (`maxRequestBodySize`, `idleTimeout`) with its `apiServerOptions` export rather than editing `main.ts`; `API_MAX_REQUEST_BODY_SIZE` and `API_IDLE_TIMEOUT` apply when the export leaves them out.
112
125
  - **assembled** (e.g. `apps/api/src/app.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
113
126
  - **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
114
127
 
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@podosoft/podokit-contracts": "^0.5.0",
20
- "@podosoft/podokit-runtime": "^0.4.0",
20
+ "@podosoft/podokit-runtime": "^0.4.1",
21
21
  "@elysia/cors": "^1.4.2",
22
22
  "@elysia/openapi": "^1.4.15",
23
23
  "dotenv": "^16.4.5",
@@ -12,8 +12,11 @@ export const extensionModules: PodokitModule[] = [];
12
12
 
13
13
  // Bun server options such as a larger request body limit for uploads, for
14
14
  // example { maxRequestBodySize: 1024 ** 3, idleTimeout: 120 }. Values set here
15
- // win over API_MAX_REQUEST_BODY_SIZE; a function receives the validated
16
- // environment instead: (env) => ({ maxRequestBodySize: ... }). A longer
17
- // idleTimeout also needs BACKEND_PROXY_TIMEOUT_MS on the web server, which stops
18
- // waiting for response headers after 30 seconds by default.
15
+ // win over API_MAX_REQUEST_BODY_SIZE and API_IDLE_TIMEOUT; a function receives
16
+ // the validated environment instead: (env) => ({ maxRequestBodySize: ... }). A
17
+ // longer idleTimeout also needs a longer proxy deadline on the web server, which
18
+ // stops waiting for response headers after 30 seconds by default:
19
+ // BACKEND_PROXY_TIMEOUT_MS, or one route's entry in APP_PROXY_TIMEOUTS
20
+ // (apps/web/src/lib/server/proxy-timeouts.ts), and the web server's
21
+ // CONNECTION_IDLE_TIMEOUT (35 seconds).
19
22
  export const apiServerOptions: ApiServerOptionsSource = {};
@@ -4,6 +4,7 @@ import { AppException, type ErrorEnvelope } from "@podosoft/podokit-contracts";
4
4
  import { Elysia } from "elysia";
5
5
  import { configureServices, extensionModules } from "./app.extensions";
6
6
  import { validateEnv, type AppEnv } from "./config/env.validation";
7
+ import { stampClientAddress } from "./core/client-address";
7
8
  import {
8
9
  appContext,
9
10
  createCoreServices,
@@ -137,6 +138,7 @@ const buildApp = (options: CreateAppOptions = {}) => {
137
138
  services.freeze();
138
139
  const context = appContext(env, services);
139
140
  const corsOrigin = env.CORS_ORIGIN?.split(",").map((origin) => origin.trim());
141
+ const clientAddressConfig = { trustedProxyHops: env.TRUSTED_PROXY_HOPS, header: env.TRUSTED_PROXY_HEADER };
140
142
 
141
143
  const app = new Elysia({ name: "podokit" })
142
144
  .use(cors({ origin: corsOrigin ?? true, credentials: true }))
@@ -157,11 +159,12 @@ const buildApp = (options: CreateAppOptions = {}) => {
157
159
  .onRequest(async ({ request, server, set }) => {
158
160
  const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
159
161
  set.headers["x-request-id"] = requestId;
162
+ const remoteAddress = server?.requestIP(request)?.address;
163
+ const clientAddress = stampClientAddress(request, remoteAddress, clientAddressConfig);
160
164
  await context.requestGuards.run({
161
165
  request,
162
- ...(server?.requestIP(request)?.address
163
- ? { remoteAddress: server.requestIP(request)?.address }
164
- : {}),
166
+ ...(remoteAddress ? { remoteAddress } : {}),
167
+ ...(clientAddress ? { clientAddress } : {}),
165
168
  setHeader: (name, value) => { set.headers[name] = value; },
166
169
  });
167
170
  })
@@ -6,6 +6,22 @@ const schema = z.object({
6
6
  PORT: z.coerce.number().default(5002),
7
7
  // Bytes one request body may hold; 128 MiB is Bun's default.
8
8
  API_MAX_REQUEST_BODY_SIZE: z.coerce.number().int().positive().default(128 * 1024 * 1024),
9
+ // Seconds a connection may stay silent, 0 (never closed) to 255. Bun closes idle
10
+ // connections on a 4-second sweep, so 35 outlasts the web proxy's 30-second
11
+ // deadline. An empty value means unset rather than 0.
12
+ API_IDLE_TIMEOUT: z.preprocess(
13
+ (value) => (value === "" ? undefined : value),
14
+ z.coerce.number().int().min(0).max(255).default(35),
15
+ ),
16
+ // Reverse proxies in front of the API whose forwarded header names the client;
17
+ // the generated web proxy is one. 0 uses the socket peer.
18
+ TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().default(0),
19
+ TRUSTED_PROXY_HEADER: z
20
+ .string()
21
+ .trim()
22
+ .toLowerCase()
23
+ .regex(/^[a-z0-9!#$%&'*+.^_`|~-]+$/, "must be an HTTP header name")
24
+ .default("x-forwarded-for"),
9
25
  CORS_ORIGIN: z.string().optional(),
10
26
  DATABASE_URL: z.string().optional(),
11
27
  POSTGRES_HOST: z.string().default("localhost"),
@@ -24,7 +40,19 @@ const schema = z.object({
24
40
 
25
41
  export type AppEnv = z.infer<typeof schema>;
26
42
 
43
+ // Renamed settings fail loudly: falling back to the default would count and
44
+ // record every browser request under the web server's address.
45
+ const RENAMED: Record<string, string> = {
46
+ RATE_LIMIT_TRUSTED_PROXY_HOPS: "TRUSTED_PROXY_HOPS",
47
+ RATE_LIMIT_PROXY_HEADER: "TRUSTED_PROXY_HEADER",
48
+ };
49
+
27
50
  export function validateEnv(config: Record<string, unknown>): AppEnv {
51
+ for (const [previous, current] of Object.entries(RENAMED)) {
52
+ if (config[previous] !== undefined) {
53
+ throw new Error(`Invalid environment: ${previous} was renamed to ${current}.`);
54
+ }
55
+ }
28
56
  const parsed = schema.safeParse(config);
29
57
  if (!parsed.success) {
30
58
  throw new Error(`Invalid environment:\n${parsed.error.toString()}`);
@@ -1,9 +1,11 @@
1
1
  import type { ProviderSelections } from "@podosoft/podokit-runtime";
2
2
 
3
- export const PROVIDERS = {
3
+ // Typed with every provider name, not the selected ones: the module of a
4
+ // provider that stays installed after a switch compares against its own name.
5
+ export const PROVIDERS: Readonly<ProviderSelections> = {
4
6
  database: "{{databaseProvider}}",
5
7
  cache: "{{cacheProvider}}",
6
8
  "object-storage": "{{objectStorageProvider}}",
7
9
  events: "{{eventsProvider}}",
8
10
  jobs: "{{jobsProvider}}",
9
- } as const satisfies ProviderSelections;
11
+ };
@@ -4,26 +4,37 @@ import { validateEnv } from "./env.validation";
4
4
  import { apiServerOptions, DEFAULT_MAX_REQUEST_BODY_SIZE } from "./server-options";
5
5
 
6
6
  describe("apiServerOptions", () => {
7
- it("defaults to Bun's request body limit and leaves the idle timeout to Elysia", () => {
7
+ it("defaults to Bun's request body limit and an idle timeout that outlasts the web proxy", () => {
8
8
  const env = validateEnv({});
9
9
  expect(env.API_MAX_REQUEST_BODY_SIZE).toBe(DEFAULT_MAX_REQUEST_BODY_SIZE);
10
- expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE });
10
+ expect(apiServerOptions(env, {})).toEqual({ maxRequestBodySize: DEFAULT_MAX_REQUEST_BODY_SIZE, idleTimeout: 35 });
11
+ });
12
+
13
+ it("reads API_IDLE_TIMEOUT from 0 to 255 seconds and treats an empty value as unset", () => {
14
+ expect(validateEnv({ API_IDLE_TIMEOUT: "125" }).API_IDLE_TIMEOUT).toBe(125);
15
+ expect(validateEnv({ API_IDLE_TIMEOUT: "0" }).API_IDLE_TIMEOUT).toBe(0);
16
+ expect(validateEnv({ API_IDLE_TIMEOUT: "255" }).API_IDLE_TIMEOUT).toBe(255);
17
+ expect(validateEnv({ API_IDLE_TIMEOUT: "" }).API_IDLE_TIMEOUT).toBe(35);
18
+ for (const value of ["256", "-1", "1.5", "soon"]) {
19
+ expect(() => validateEnv({ API_IDLE_TIMEOUT: value })).toThrow("API_IDLE_TIMEOUT");
20
+ }
21
+ expect(apiServerOptions(validateEnv({ API_IDLE_TIMEOUT: "0" }), {}).idleTimeout).toBe(0);
11
22
  });
12
23
 
13
24
  it("reads API_MAX_REQUEST_BODY_SIZE and rejects values that are not byte counts", () => {
14
- expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}))
15
- .toEqual({ maxRequestBodySize: 1_073_741_824 });
25
+ expect(apiServerOptions(validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1073741824" }), {}).maxRequestBodySize)
26
+ .toBe(1_073_741_824);
16
27
  for (const value of ["0", "-1", "1.5", "lots"]) {
17
28
  expect(() => validateEnv({ API_MAX_REQUEST_BODY_SIZE: value })).toThrow("API_MAX_REQUEST_BODY_SIZE");
18
29
  }
19
30
  });
20
31
 
21
32
  it("lets the application's options win over the environment", () => {
22
- const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024" });
33
+ const env = validateEnv({ API_MAX_REQUEST_BODY_SIZE: "1024", API_IDLE_TIMEOUT: "60" });
23
34
  expect(apiServerOptions(env, { maxRequestBodySize: 4096, idleTimeout: 120 }))
24
35
  .toEqual({ maxRequestBodySize: 4096, idleTimeout: 120 });
25
36
  expect(apiServerOptions(env, (current) => ({ maxRequestBodySize: current.API_MAX_REQUEST_BODY_SIZE * 2 })))
26
- .toEqual({ maxRequestBodySize: 2048 });
37
+ .toEqual({ maxRequestBodySize: 2048, idleTimeout: 60 });
27
38
  expect(() => apiServerOptions(env, { maxRequestBodySize: 0 })).toThrow("maxRequestBodySize");
28
39
  expect(() => apiServerOptions(env, { idleTimeout: 256 })).toThrow("idleTimeout");
29
40
  expect(() => apiServerOptions(env, { idleTimeout: -1 })).toThrow("idleTimeout");
@@ -11,7 +11,7 @@ export interface ApiServerOptions {
11
11
  maxRequestBodySize?: number;
12
12
  /**
13
13
  * Seconds a connection may stay idle before it is closed, from 0 (never) to
14
- * 255. Defaults to Elysia's 30 seconds.
14
+ * 255. Defaults to `API_IDLE_TIMEOUT`.
15
15
  */
16
16
  idleTimeout?: number;
17
17
  }
@@ -31,18 +31,15 @@ const owned: { readonly [name: string]: unknown; readonly apiServerOptions?: Api
31
31
  export function apiServerOptions(
32
32
  env: AppEnv,
33
33
  source: ApiServerOptionsSource | undefined = owned.apiServerOptions,
34
- ): ApiServerOptions & { maxRequestBodySize: number } {
34
+ ): Required<ApiServerOptions> {
35
35
  const options = typeof source === "function" ? source(env) : source ?? {};
36
36
  const maxRequestBodySize = options.maxRequestBodySize ?? env.API_MAX_REQUEST_BODY_SIZE;
37
37
  if (!Number.isSafeInteger(maxRequestBodySize) || maxRequestBodySize < 1) {
38
38
  throw new Error("apiServerOptions.maxRequestBodySize must be a positive integer of bytes");
39
39
  }
40
- const { idleTimeout } = options;
41
- if (
42
- idleTimeout !== undefined &&
43
- (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS)
44
- ) {
40
+ const idleTimeout = options.idleTimeout ?? env.API_IDLE_TIMEOUT;
41
+ if (!Number.isInteger(idleTimeout) || idleTimeout < 0 || idleTimeout > MAX_IDLE_TIMEOUT_SECONDS) {
45
42
  throw new Error(`apiServerOptions.idleTimeout must be an integer from 0 to ${MAX_IDLE_TIMEOUT_SECONDS} seconds`);
46
43
  }
47
- return { maxRequestBodySize, ...(idleTimeout === undefined ? {} : { idleTimeout }) };
44
+ return { maxRequestBodySize, idleTimeout };
48
45
  }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { validateEnv } from "../config/env.validation";
3
+ import {
4
+ CLIENT_ADDRESS_HEADER,
5
+ clientAddressOf,
6
+ resolveClientAddress,
7
+ stampClientAddress,
8
+ } from "./client-address";
9
+
10
+ const forwarded = (value: string): Headers => new Headers({ "x-forwarded-for": value });
11
+ const config = (trustedProxyHops: number) => ({ trustedProxyHops, header: "x-forwarded-for" });
12
+
13
+ describe("resolveClientAddress", () => {
14
+ it("uses the socket peer and ignores a caller's header without trusted proxies", () => {
15
+ expect(resolveClientAddress(forwarded("198.51.100.66"), "10.0.0.9", config(0))).toBe("10.0.0.9");
16
+ expect(resolveClientAddress(forwarded("198.51.100.66"), "::ffff:10.0.0.9", config(0))).toBe("10.0.0.9");
17
+ expect(resolveClientAddress(new Headers(), undefined, config(0))).toBeUndefined();
18
+ });
19
+
20
+ it("takes the entry the trusted proxies appended, never one further left", () => {
21
+ // A caller sent "198.51.100.66"; the one trusted proxy appended the real client.
22
+ const chain = forwarded("198.51.100.66, 203.0.113.7");
23
+ expect(resolveClientAddress(chain, "10.0.0.9", config(1))).toBe("203.0.113.7");
24
+ expect(resolveClientAddress(chain, "10.0.0.9", config(2))).toBe("198.51.100.66");
25
+ expect(resolveClientAddress(forwarded("203.0.113.7"), "10.0.0.9", config(1))).toBe("203.0.113.7");
26
+ expect(
27
+ resolveClientAddress(new Headers({ "x-real-ip": "203.0.113.8" }), "10.0.0.9", {
28
+ trustedProxyHops: 1,
29
+ header: "x-real-ip",
30
+ }),
31
+ ).toBe("203.0.113.8");
32
+ });
33
+
34
+ it("falls back to the peer for a short or malformed chain", () => {
35
+ expect(resolveClientAddress(new Headers(), "10.0.0.9", config(1))).toBe("10.0.0.9");
36
+ expect(resolveClientAddress(forwarded("203.0.113.7"), "10.0.0.9", config(2))).toBe("10.0.0.9");
37
+ expect(resolveClientAddress(forwarded("attacker-controlled"), "10.0.0.9", config(1))).toBe("10.0.0.9");
38
+ expect(resolveClientAddress(forwarded("203.0.113.7, not-an-ip"), "10.0.0.9", config(1))).toBe("10.0.0.9");
39
+ });
40
+
41
+ it("reads the address the API set", () => {
42
+ expect(clientAddressOf(new Headers({ [CLIENT_ADDRESS_HEADER]: "203.0.113.7" }))).toBe("203.0.113.7");
43
+ expect(clientAddressOf(new Headers())).toBeUndefined();
44
+ expect(clientAddressOf(undefined)).toBeUndefined();
45
+ });
46
+ });
47
+
48
+ describe("client address settings", () => {
49
+ it("validates the trusted proxy settings and refuses the rate limiter's former names", () => {
50
+ expect(validateEnv({})).toMatchObject({ TRUSTED_PROXY_HOPS: 0, TRUSTED_PROXY_HEADER: "x-forwarded-for" });
51
+ expect(validateEnv({ TRUSTED_PROXY_HOPS: "1", TRUSTED_PROXY_HEADER: "X-Real-IP" }))
52
+ .toMatchObject({ TRUSTED_PROXY_HOPS: 1, TRUSTED_PROXY_HEADER: "x-real-ip" });
53
+ expect(() => validateEnv({ TRUSTED_PROXY_HOPS: "-1" })).toThrow("TRUSTED_PROXY_HOPS");
54
+ expect(() => validateEnv({ TRUSTED_PROXY_HEADER: "bad header" })).toThrow("TRUSTED_PROXY_HEADER");
55
+ expect(() => validateEnv({ RATE_LIMIT_TRUSTED_PROXY_HOPS: "1" }))
56
+ .toThrow("RATE_LIMIT_TRUSTED_PROXY_HOPS was renamed to TRUSTED_PROXY_HOPS");
57
+ expect(() => validateEnv({ RATE_LIMIT_PROXY_HEADER: "x-forwarded-for" }))
58
+ .toThrow("RATE_LIMIT_PROXY_HEADER was renamed to TRUSTED_PROXY_HEADER");
59
+ });
60
+ });
61
+
62
+ describe("stampClientAddress", () => {
63
+ const request = (headers: Record<string, string>): Request => new Request("http://localhost/x", { headers });
64
+
65
+ it("replaces a caller's own address header and ignores its forwarded header without trusted proxies", () => {
66
+ const spoofed = request({ "x-forwarded-for": "198.51.100.66", [CLIENT_ADDRESS_HEADER]: "198.51.100.67" });
67
+ expect(stampClientAddress(spoofed, "::ffff:127.0.0.1", config(0))).toBe("127.0.0.1");
68
+ expect(clientAddressOf(spoofed.headers)).toBe("127.0.0.1");
69
+ });
70
+
71
+ it("records the rightmost forwarded entry behind one trusted proxy", () => {
72
+ const proxied = request({ "x-forwarded-for": "198.51.100.66, 203.0.113.7" });
73
+ expect(stampClientAddress(proxied, "10.0.0.9", config(1))).toBe("203.0.113.7");
74
+ expect(clientAddressOf(proxied.headers)).toBe("203.0.113.7");
75
+ });
76
+
77
+ it("removes a caller's value when no address can be resolved", () => {
78
+ const unknown = request({ [CLIENT_ADDRESS_HEADER]: "198.51.100.67" });
79
+ expect(stampClientAddress(unknown, undefined, config(0))).toBeUndefined();
80
+ expect(clientAddressOf(unknown.headers)).toBeUndefined();
81
+ });
82
+ });
@@ -0,0 +1,68 @@
1
+ import { isIP } from "node:net";
2
+
3
+ /**
4
+ * The request header that carries the client address the API resolved. The API
5
+ * sets it on every request before any handler runs, replacing whatever a caller
6
+ * sent, so Better Auth, audit entries, and application code read one address
7
+ * that a caller cannot choose.
8
+ */
9
+ export const CLIENT_ADDRESS_HEADER = "x-podokit-client-address";
10
+
11
+ export interface ClientAddressConfig {
12
+ /** Reverse proxies in front of the API whose forwarded header is trusted; 0 trusts none. */
13
+ trustedProxyHops: number;
14
+ /** The lowercase header those proxies append the client address to. */
15
+ header: string;
16
+ }
17
+
18
+ /** An IP address without its IPv4-mapped IPv6 prefix, or undefined when the value is not one. */
19
+ export function normalizeIp(value: string | undefined): string | undefined {
20
+ if (!value) return undefined;
21
+ const trimmed = value.trim();
22
+ const normalized = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed;
23
+ return isIP(normalized) === 0 ? undefined : normalized;
24
+ }
25
+
26
+ /**
27
+ * The caller's address. With no trusted proxy it is the socket peer. Otherwise
28
+ * each trusted proxy appended one entry to the header, so the client is the
29
+ * entry that many places left of the peer; entries further left were written
30
+ * by the caller and are ignored. A chain shorter than the trusted hops, or a
31
+ * malformed entry, falls back to the peer instead of trusting caller input.
32
+ */
33
+ export function resolveClientAddress(
34
+ headers: Headers,
35
+ remoteAddress: string | undefined,
36
+ config: ClientAddressConfig,
37
+ ): string | undefined {
38
+ const direct = normalizeIp(remoteAddress);
39
+ if (config.trustedProxyHops === 0) return direct;
40
+ const forwarded = headers.get(config.header)
41
+ ?.split(",")
42
+ .map((value) => value.trim())
43
+ .filter(Boolean) ?? [];
44
+ const chain = [...forwarded, direct ?? ""];
45
+ if (chain.length <= config.trustedProxyHops) return direct;
46
+ return normalizeIp(chain[chain.length - config.trustedProxyHops - 1]) ?? direct;
47
+ }
48
+
49
+ /**
50
+ * Resolves a request's client address and records it in CLIENT_ADDRESS_HEADER,
51
+ * replacing any value the caller sent. The API does this for every request
52
+ * before a guard, Better Auth, or a handler reads the request.
53
+ */
54
+ export function stampClientAddress(
55
+ request: Request,
56
+ remoteAddress: string | undefined,
57
+ config: ClientAddressConfig,
58
+ ): string | undefined {
59
+ const address = resolveClientAddress(request.headers, remoteAddress, config);
60
+ if (address) request.headers.set(CLIENT_ADDRESS_HEADER, address);
61
+ else request.headers.delete(CLIENT_ADDRESS_HEADER);
62
+ return address;
63
+ }
64
+
65
+ /** The address the API resolved for a request, as set in CLIENT_ADDRESS_HEADER. */
66
+ export function clientAddressOf(headers: Headers | undefined): string | undefined {
67
+ return headers?.get(CLIENT_ADDRESS_HEADER) ?? undefined;
68
+ }
@@ -280,7 +280,10 @@ export class AccessPolicy {
280
280
 
281
281
  export interface RequestGuardContext {
282
282
  request: Request;
283
+ /** The socket peer. */
283
284
  remoteAddress?: string;
285
+ /** The caller's address behind the trusted proxies; see core/client-address.ts. */
286
+ clientAddress?: string;
284
287
  setHeader: (name: string, value: string) => void;
285
288
  }
286
289
 
@@ -305,6 +308,31 @@ export interface RequestIdentity {
305
308
  activeOrganizationId(request: Request): Promise<string | undefined>;
306
309
  }
307
310
 
311
+ /** A session the API resolved while handling a request. */
312
+ export interface ResolvedSession {
313
+ /** The session token without its cookie signature. */
314
+ token: string;
315
+ userId: string;
316
+ expiresAt: Date;
317
+ }
318
+
319
+ /**
320
+ * Passes each session the API resolves for a request to the modules that listen,
321
+ * so they can use it without a session lookup of their own. The auth module
322
+ * reports; the rate limiter listens to count a signed-in caller per user.
323
+ */
324
+ export class ResolvedSessions {
325
+ private readonly listeners: Array<(session: ResolvedSession) => void> = [];
326
+
327
+ listen(listener: (session: ResolvedSession) => void): void {
328
+ this.listeners.push(listener);
329
+ }
330
+
331
+ report(session: ResolvedSession): void {
332
+ for (const listener of this.listeners) listener(session);
333
+ }
334
+ }
335
+
308
336
  export interface OpenApiContribution {
309
337
  document: Record<string, unknown>;
310
338
  pathPrefix?: string;
@@ -331,6 +359,7 @@ export const LOGGER = Symbol("logger") as ServiceKey<Logger>;
331
359
  export const ACCESS_POLICY = Symbol("access-policy") as ServiceKey<AccessPolicy>;
332
360
  export const REQUEST_GUARDS = Symbol("request-guards") as ServiceKey<RequestGuardRegistry>;
333
361
  export const REQUEST_IDENTITY = Symbol("request-identity") as ServiceKey<RequestIdentity>;
362
+ export const RESOLVED_SESSIONS = Symbol("resolved-sessions") as ServiceKey<ResolvedSessions>;
334
363
  export const OPENAPI = Symbol("openapi") as ServiceKey<OpenApiRegistry>;
335
364
 
336
365
  export interface AppContext {
@@ -390,6 +419,7 @@ export function createCoreServices(env: AppEnv): ServiceRegistry {
390
419
  roles: async () => [],
391
420
  activeOrganizationId: async () => undefined,
392
421
  });
422
+ services.register(RESOLVED_SESSIONS, new ResolvedSessions());
393
423
  services.register(OPENAPI, openapiRegistry);
394
424
  return services;
395
425
  }