@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.
- package/dist/deploy-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +1 -0
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +2 -0
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.js +3 -1
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +18 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/dot-env.docker.example +2 -0
- package/dist/templates/fullstack/dot-env.example +17 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- 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") {
|
package/dist/deploy-profile.js
CHANGED
|
@@ -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
|
}
|
package/dist/deploy-render.js
CHANGED
|
@@ -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") {
|
package/dist/deploy-schema.d.ts
CHANGED
|
@@ -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;
|
package/dist/deploy-schema.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/provider.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`}
|
|
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.
|
|
59
|
-
the
|
|
60
|
-
|
|
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`
|
|
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
|
|
|
@@ -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
|
|
16
|
-
// environment instead: (env) => ({ maxRequestBodySize: ... }). A
|
|
17
|
-
// idleTimeout also needs
|
|
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
|
-
...(
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
.
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
}
|