@cosmicdrift/kumiko-server-runtime 0.319.0 → 0.321.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/package.json +4 -8
- package/src/__tests__/compose-features-wiring.integration.test.ts +159 -1
- package/src/__tests__/require-env.test.ts +5 -0
- package/src/__tests__/resolve-auth-mail.test.ts +75 -0
- package/src/__tests__/run-prod-app-spec.test.ts +56 -3
- package/src/__tests__/run-prod-app-static-files.test.ts +5 -19
- package/src/__tests__/run-prod-app.integration.test.ts +159 -38
- package/src/bun-serve-options.ts +41 -3
- package/src/changes.json +39 -0
- package/src/compose-features.ts +2 -2
- package/src/index.ts +1 -1
- package/src/run-prod-app-boot-context.ts +55 -15
- package/src/run-prod-app-static-files.ts +40 -43
- package/src/run-prod-app.ts +92 -84
- package/src/security-headers.ts +4 -4
- package/src/try-hono-first.ts +12 -6
- package/src/__tests__/inject-schema.test.ts +0 -100
- package/src/inject-schema.ts +0 -33
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildRequestContextDataFromRequest,
|
|
3
3
|
type CachePolicy,
|
|
4
|
+
type ClientIpResolver,
|
|
4
5
|
cachedResponse,
|
|
5
6
|
computeStrongEtag,
|
|
6
7
|
computeWeakEtag,
|
|
8
|
+
createClientIpResolver,
|
|
7
9
|
requestContext,
|
|
8
10
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
9
11
|
import { createAnonymousUser, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
12
|
import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-headless/apex";
|
|
11
13
|
import { ASSETS_DIR } from "./build-prod-bundle";
|
|
12
|
-
import { injectSchema } from "./inject-schema";
|
|
13
14
|
import type { HostDispatchFn, PageHeadResolver, PageHeadSystemQuery } from "./run-prod-app";
|
|
14
15
|
import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first";
|
|
15
16
|
|
|
@@ -127,9 +128,8 @@ export type PageHeadOptions = {
|
|
|
127
128
|
};
|
|
128
129
|
|
|
129
130
|
export function buildStaticFallback(
|
|
130
|
-
apiHandler: (req: Request) => Response | Promise<Response>,
|
|
131
|
+
apiHandler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
|
|
131
132
|
staticDir: string,
|
|
132
|
-
appSchemaJson: string,
|
|
133
133
|
hostDispatch?: HostDispatchFn,
|
|
134
134
|
pageHead?: PageHeadOptions,
|
|
135
135
|
/** Backs hostDispatch's `systemQuery` — required whenever `hostDispatch`
|
|
@@ -137,38 +137,31 @@ export function buildStaticFallback(
|
|
|
137
137
|
* throw). Separate from `pageHead.dispatcher` since resolvePageHead is
|
|
138
138
|
* optional independently of hostDispatch. */
|
|
139
139
|
hostDispatchDispatcher?: QueryDispatcher,
|
|
140
|
-
|
|
140
|
+
/** trustedProxyHops for the systemQuery request-context builds below —
|
|
141
|
+
* 1:1 with runProdApp's effective value. */
|
|
142
|
+
trustedProxyHops = 0,
|
|
143
|
+
): (req: Request, socketAddress?: string) => Promise<Response> {
|
|
141
144
|
const indexHtml = `${staticDir}/index.html`;
|
|
145
|
+
const clientIpResolver: ClientIpResolver = createClientIpResolver(
|
|
146
|
+
trustedProxyHops,
|
|
147
|
+
"buildStaticFallback",
|
|
148
|
+
);
|
|
142
149
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
// Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
|
|
147
|
-
// produzieren keinen doppelten Tag.
|
|
150
|
+
// Reads an HTML file from disk. No schema injection — createKumikoApp
|
|
151
|
+
// fetches it itself from the authenticated GET /api/schema, so the HTML
|
|
152
|
+
// is identical for every host.
|
|
148
153
|
async function readHtmlFile(
|
|
149
154
|
path: string,
|
|
150
|
-
injectSchemaInto: boolean,
|
|
151
155
|
): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number } | null> {
|
|
152
156
|
const file = await readStaticFile(path);
|
|
153
157
|
if (!file) return null;
|
|
154
|
-
if (!injectSchemaInto) {
|
|
155
|
-
return {
|
|
156
|
-
bytes: file.bytes.buffer.slice(
|
|
157
|
-
file.bytes.byteOffset,
|
|
158
|
-
file.bytes.byteOffset + file.bytes.byteLength,
|
|
159
|
-
) as ArrayBuffer,
|
|
160
|
-
mime: file.mime,
|
|
161
|
-
etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
|
|
162
|
-
mtimeMs: file.mtimeMs,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
const text = new TextDecoder().decode(file.bytes);
|
|
166
|
-
const injected = injectSchema(text, appSchemaJson);
|
|
167
|
-
const bytes = new TextEncoder().encode(injected).buffer as ArrayBuffer;
|
|
168
158
|
return {
|
|
169
|
-
bytes
|
|
159
|
+
bytes: file.bytes.buffer.slice(
|
|
160
|
+
file.bytes.byteOffset,
|
|
161
|
+
file.bytes.byteOffset + file.bytes.byteLength,
|
|
162
|
+
) as ArrayBuffer,
|
|
170
163
|
mime: file.mime,
|
|
171
|
-
etag:
|
|
164
|
+
etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
|
|
172
165
|
mtimeMs: file.mtimeMs,
|
|
173
166
|
};
|
|
174
167
|
}
|
|
@@ -203,13 +196,16 @@ export function buildStaticFallback(
|
|
|
203
196
|
async function applyPageHead(
|
|
204
197
|
req: Request,
|
|
205
198
|
html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
|
|
199
|
+
socketAddress?: string,
|
|
206
200
|
): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number }> {
|
|
207
201
|
if (!pageHead) return html;
|
|
208
202
|
const url = new URL(req.url);
|
|
209
203
|
const host = req.headers.get("host") ?? url.host;
|
|
210
204
|
const systemQuery: PageHeadSystemQuery = (type, payload, tenantId) =>
|
|
211
|
-
requestContext.run(
|
|
212
|
-
|
|
205
|
+
requestContext.run(
|
|
206
|
+
requestContext.get() ??
|
|
207
|
+
buildRequestContextDataFromRequest(req, { resolver: clientIpResolver, socketAddress }),
|
|
208
|
+
() => pageHead.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
213
209
|
);
|
|
214
210
|
const text = new TextDecoder().decode(html.bytes);
|
|
215
211
|
const injected = await resolveAndInjectPageHead(text, pageHead.resolvePageHead, {
|
|
@@ -231,7 +227,7 @@ export function buildStaticFallback(
|
|
|
231
227
|
// HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
|
|
232
228
|
// resolved Response (redirect/404/html) oder null wenn der Default-
|
|
233
229
|
// Pfad weiterlaufen soll.
|
|
234
|
-
async function tryHostDispatch(req: Request): Promise<Response | null> {
|
|
230
|
+
async function tryHostDispatch(req: Request, socketAddress?: string): Promise<Response | null> {
|
|
235
231
|
if (!hostDispatch) return null;
|
|
236
232
|
const url = new URL(req.url);
|
|
237
233
|
const host = req.headers.get("host") ?? url.host;
|
|
@@ -242,7 +238,8 @@ export function buildStaticFallback(
|
|
|
242
238
|
);
|
|
243
239
|
}
|
|
244
240
|
return requestContext.run(
|
|
245
|
-
requestContext.get() ??
|
|
241
|
+
requestContext.get() ??
|
|
242
|
+
buildRequestContextDataFromRequest(req, { resolver: clientIpResolver, socketAddress }),
|
|
246
243
|
() => hostDispatchDispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
247
244
|
);
|
|
248
245
|
};
|
|
@@ -261,28 +258,28 @@ export function buildStaticFallback(
|
|
|
261
258
|
}
|
|
262
259
|
// result.kind === "html"
|
|
263
260
|
const filePath = `${staticDir}/${result.file}`;
|
|
264
|
-
const html = await readHtmlFile(filePath
|
|
261
|
+
const html = await readHtmlFile(filePath);
|
|
265
262
|
if (!html) {
|
|
266
263
|
// Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
|
|
267
264
|
// Liefer 500 statt silent-404 damit der Bug schnell auffällt.
|
|
268
265
|
return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
|
|
269
266
|
}
|
|
270
|
-
// Per-
|
|
271
|
-
//
|
|
267
|
+
// Per-host body (hostDispatch picks the file by host) → Vary: Host,
|
|
268
|
+
// otherwise a shared cache could serve Host A's HTML to Host B.
|
|
272
269
|
const extraHeaders: Record<string, string> = { vary: "Host" };
|
|
273
270
|
if (result.csp) extraHeaders["content-security-policy"] = result.csp;
|
|
274
|
-
const withHead = await applyPageHead(req, html);
|
|
271
|
+
const withHead = await applyPageHead(req, html, socketAddress);
|
|
275
272
|
return serveHtmlFile(req, "/index.html", withHead, extraHeaders);
|
|
276
273
|
}
|
|
277
274
|
|
|
278
|
-
return async (req: Request): Promise<Response> => {
|
|
275
|
+
return async (req: Request, socketAddress?: string): Promise<Response> => {
|
|
279
276
|
const url = new URL(req.url);
|
|
280
277
|
// /api/* and /health → always Hono (Dispatcher + Health-Probe). Bypasses
|
|
281
278
|
// tryHonoFirst entirely, so the router-miss marker must be stripped
|
|
282
279
|
// here too — otherwise an unmatched /api/* path would leak it straight
|
|
283
280
|
// to the client (see try-hono-first.ts's header-hygiene note).
|
|
284
281
|
if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
|
|
285
|
-
return stripNoRouteMatchHeader(await apiHandler(req));
|
|
282
|
+
return stripNoRouteMatchHeader(await apiHandler(req, socketAddress));
|
|
286
283
|
}
|
|
287
284
|
|
|
288
285
|
// Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
|
|
@@ -290,7 +287,7 @@ export function buildStaticFallback(
|
|
|
290
287
|
// dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
|
|
291
288
|
// unbekannte Pfade als index.html. Shared mit dev-server's
|
|
292
289
|
// createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
|
|
293
|
-
const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
|
|
290
|
+
const honoTry = await tryHonoFirst({ fetch: apiHandler }, req, socketAddress);
|
|
294
291
|
if (honoTry.matched) {
|
|
295
292
|
return honoTry.response;
|
|
296
293
|
}
|
|
@@ -317,15 +314,15 @@ export function buildStaticFallback(
|
|
|
317
314
|
}
|
|
318
315
|
}
|
|
319
316
|
|
|
320
|
-
// Root
|
|
321
|
-
//
|
|
322
|
-
const dispatched = await tryHostDispatch(req);
|
|
317
|
+
// Root or SPA route — hostDispatch applies here when set.
|
|
318
|
+
// Without hostDispatch: the old single-app path (index.html).
|
|
319
|
+
const dispatched = await tryHostDispatch(req, socketAddress);
|
|
323
320
|
if (dispatched) return dispatched;
|
|
324
321
|
|
|
325
|
-
// Default
|
|
326
|
-
const index = await readHtmlFile(indexHtml
|
|
322
|
+
// Default single-app path: index.html, no schema injection.
|
|
323
|
+
const index = await readHtmlFile(indexHtml);
|
|
327
324
|
if (index) {
|
|
328
|
-
const withHead = await applyPageHead(req, index);
|
|
325
|
+
const withHead = await applyPageHead(req, index, socketAddress);
|
|
329
326
|
return serveHtmlFile(req, "/index.html", withHead);
|
|
330
327
|
}
|
|
331
328
|
|
package/src/run-prod-app.ts
CHANGED
|
@@ -75,8 +75,11 @@ import {
|
|
|
75
75
|
type ExtraRouteDefinition,
|
|
76
76
|
type LoginRateLimiter,
|
|
77
77
|
loadJwtSecretOrKeyring,
|
|
78
|
+
type PostAuthLandingResolver,
|
|
79
|
+
parseTrustedProxyHopsEnv,
|
|
78
80
|
type SseBroker,
|
|
79
81
|
type TokenVerifier,
|
|
82
|
+
TRUSTED_PROXY_HOPS_ENV,
|
|
80
83
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
81
84
|
import {
|
|
82
85
|
configureBlindIndexKey,
|
|
@@ -90,7 +93,6 @@ import {
|
|
|
90
93
|
type DbRunner,
|
|
91
94
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
92
95
|
import {
|
|
93
|
-
buildAppSchema,
|
|
94
96
|
collectWriteHandlerQns,
|
|
95
97
|
createRegistry,
|
|
96
98
|
type EffectiveFeaturesResolver,
|
|
@@ -145,7 +147,7 @@ import Redis from "ioredis";
|
|
|
145
147
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
146
148
|
import { resolveBootCrypto } from "./boot/boot-crypto";
|
|
147
149
|
import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
|
|
148
|
-
import { buildBunServeOptions } from "./bun-serve-options";
|
|
150
|
+
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options";
|
|
149
151
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
150
152
|
import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
|
|
151
153
|
import { assertPiiBootInvariants } from "./pii-boot-gate";
|
|
@@ -248,17 +250,24 @@ function makeDryRunHandle(): ProdAppHandle {
|
|
|
248
250
|
};
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
/** Wrapper
|
|
253
|
+
/** Wrapper API for the password-reset flow.
|
|
252
254
|
*
|
|
253
|
-
*
|
|
254
|
-
* (+ appName/locale)
|
|
255
|
-
* sendResetEmail
|
|
256
|
-
* resolveAuthMail
|
|
257
|
-
|
|
255
|
+
* Since the delivery migration PasswordResetOptions carries `appUrl`
|
|
256
|
+
* (+ appName/locale) itself and the handler mails via ctx.notify — no
|
|
257
|
+
* sendResetEmail callback anymore. Apps pass `auth.mail` (convenience,
|
|
258
|
+
* resolveAuthMail builds the appUrl) OR an explicit block.
|
|
259
|
+
*
|
|
260
|
+
* hmacSecret is optional here (unlike PasswordResetOptions): resolveAuthMail
|
|
261
|
+
* backfills it, so overriding just appUrl needs no secret of its own. */
|
|
262
|
+
export type PasswordResetSetup = Omit<PasswordResetOptions, "hmacSecret"> & {
|
|
263
|
+
readonly hmacSecret?: string;
|
|
264
|
+
};
|
|
258
265
|
|
|
259
|
-
/** Wrapper
|
|
260
|
-
* PasswordResetSetup
|
|
261
|
-
export type EmailVerificationSetup = EmailVerificationOptions
|
|
266
|
+
/** Wrapper API for the email-verification flow, symmetric to
|
|
267
|
+
* PasswordResetSetup (appUrl via delivery, optional hmacSecret). */
|
|
268
|
+
export type EmailVerificationSetup = Omit<EmailVerificationOptions, "hmacSecret"> & {
|
|
269
|
+
readonly hmacSecret?: string;
|
|
270
|
+
};
|
|
262
271
|
|
|
263
272
|
/** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
|
|
264
273
|
* Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
|
|
@@ -303,6 +312,9 @@ export type AuthMailOptions = {
|
|
|
303
312
|
readonly from?: string;
|
|
304
313
|
/** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
|
|
305
314
|
readonly paths?: Partial<AuthPaths>;
|
|
315
|
+
/** Reset/verify token secret, decoupled from JWT_SECRET rotation.
|
|
316
|
+
* Falls back to resolveAuthMail's call-site secret when unset. */
|
|
317
|
+
readonly hmacSecret?: string;
|
|
306
318
|
};
|
|
307
319
|
|
|
308
320
|
export type RunProdAppAuthOptions = {
|
|
@@ -370,9 +382,15 @@ export type RunProdAppAuthOptions = {
|
|
|
370
382
|
* process for client-IP derivation (see AuthRoutesConfig.trustedProxyHops,
|
|
371
383
|
* kumiko-framework#1539) — closes the X-Forwarded-For spoofing hole on
|
|
372
384
|
* the auth rate-limiters. Falls back to the `KUMIKO_TRUSTED_PROXY_HOPS`
|
|
373
|
-
* env var when unset; both unset means the
|
|
374
|
-
* (
|
|
385
|
+
* env var when unset; both unset means 0 hops, the safe socket-only
|
|
386
|
+
* default (only the socket address counts, XFF ignored). Deprecated in
|
|
387
|
+
* favor of the top-level `trustedProxyHops` option, which wins over
|
|
388
|
+
* this and the env var. */
|
|
375
389
|
readonly trustedProxyHops?: number;
|
|
390
|
+
/** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
|
|
391
|
+
* Result lands in the login/signup/invite response as `landingPath`; an
|
|
392
|
+
* invalid path or a throwing resolver just omits the field. */
|
|
393
|
+
readonly postAuthLanding?: PostAuthLandingResolver;
|
|
376
394
|
};
|
|
377
395
|
|
|
378
396
|
/** Hook for app-specific seeding — runs after the admin (when auth is
|
|
@@ -403,23 +421,25 @@ export type ExtraContextOption =
|
|
|
403
421
|
| Record<string, unknown>
|
|
404
422
|
| ((deps: RunProdAppDeps) => Record<string, unknown>);
|
|
405
423
|
|
|
406
|
-
/** Per-
|
|
407
|
-
* hostDispatch
|
|
408
|
-
* - "html":
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* - "redirect": 301/302
|
|
413
|
-
* - "not-found":
|
|
424
|
+
/** Per-host routing decision for the staticDir fallback, returned from
|
|
425
|
+
* hostDispatch. Three modes:
|
|
426
|
+
* - "html": serve a specific HTML file (relative to staticDir), optionally
|
|
427
|
+
* with CSP. HTML never carries a schema — createKumikoApp fetches it
|
|
428
|
+
* itself from the authenticated GET /api/schema, after the
|
|
429
|
+
* clientFeature gates (auth) let it through.
|
|
430
|
+
* - "redirect": 301/302 to the given location.
|
|
431
|
+
* - "not-found": reject outright (e.g. unknown subdomain).
|
|
414
432
|
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
433
|
+
* Only consulted when the path would otherwise fall through to the HTML
|
|
434
|
+
* fallback — i.e. for "/", "/index.html", or SPA routes that neither Hono
|
|
435
|
+
* matches nor a concrete disk file. Asset paths (/assets/*) and API paths
|
|
436
|
+
* run independent of the host. */
|
|
419
437
|
export type HostDispatchResult =
|
|
420
438
|
| {
|
|
421
439
|
readonly kind: "html";
|
|
422
440
|
readonly file: string;
|
|
441
|
+
/** @deprecated Ignored — HTML never carries the schema; createKumikoApp
|
|
442
|
+
* fetches it from the authenticated GET /api/schema. */
|
|
423
443
|
readonly injectSchema?: boolean;
|
|
424
444
|
readonly csp?: string;
|
|
425
445
|
}
|
|
@@ -458,6 +478,12 @@ export type RunProdAppOptions = {
|
|
|
458
478
|
readonly validateBootOptions?: ValidateBootOptions;
|
|
459
479
|
/** Listen-Port. Default 3000 (or $PORT). */
|
|
460
480
|
readonly port?: number;
|
|
481
|
+
/** Ceiling Bun.serve buffers a request body to before any app-level
|
|
482
|
+
* body-limit middleware runs. Default is derived from `createFilesFeature`'s
|
|
483
|
+
* configured `maxUploadSize`/field `maxSize` (see
|
|
484
|
+
* resolveDerivedMaxRequestBodySize in bun-serve-options.ts) — override
|
|
485
|
+
* only for a custom ceiling unrelated to that config. */
|
|
486
|
+
readonly maxRequestBodySize?: number;
|
|
461
487
|
/** Auth-mode: standard features + routes wired, admin seeded. */
|
|
462
488
|
readonly auth?: RunProdAppAuthOptions;
|
|
463
489
|
/** Custom seed functions, run after the admin seed (when auth-mode). */
|
|
@@ -481,21 +507,19 @@ export type RunProdAppOptions = {
|
|
|
481
507
|
* for any path that doesn't match an /api/ handler. Use this for the
|
|
482
508
|
* public status page HTML, embed widget JS, etc. */
|
|
483
509
|
readonly staticDir?: string;
|
|
484
|
-
/** Host-aware
|
|
485
|
-
* (
|
|
486
|
-
* `admin.publicstatus.eu` (
|
|
487
|
-
*
|
|
510
|
+
/** Host-aware routing hook for multi-tenant + multi-app deployments
|
|
511
|
+
* (e.g. publicstatus's `<sub>.publicstatus.eu` (public page) +
|
|
512
|
+
* `admin.publicstatus.eu` (admin UI) + `publicstatus.eu` (apex/
|
|
513
|
+
* marketing) in the SAME container).
|
|
488
514
|
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
515
|
+
* Called when the staticDir fallback would generate an HTML response
|
|
516
|
+
* (root or SPA route). Default behavior without hostDispatch: index.html
|
|
517
|
+
* (single-app), no schema.
|
|
492
518
|
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* werden. CSP-Header pro Host können zusätzlich Asset-Pfade
|
|
498
|
-
* einschränken. */
|
|
519
|
+
* Schema safety: HTML (per-host as well as single-app) never carries an
|
|
520
|
+
* AppSchema — createKumikoApp fetches it via GET /api/schema, which sits
|
|
521
|
+
* behind the auth middleware and only serves non-anonymous, signed-in
|
|
522
|
+
* users. Per-host CSP headers can additionally restrict asset paths. */
|
|
499
523
|
readonly hostDispatch?: HostDispatchFn;
|
|
500
524
|
/** Per-request head-metadata resolver (Open-Graph/title/description) for
|
|
501
525
|
* the static-fallback HTML shell — see `PageHeadResolver`. Consulted on
|
|
@@ -664,6 +688,12 @@ export type RunProdAppOptions = {
|
|
|
664
688
|
* overridden. `false` disables the whole block; per-header overrides
|
|
665
689
|
* via the object form — see SecurityHeadersOption. */
|
|
666
690
|
readonly securityHeaders?: SecurityHeadersOption;
|
|
691
|
+
/** Number of trusted reverse-proxy hops for client-IP resolution — 1:1
|
|
692
|
+
* durchgereicht an `buildServer`s
|
|
693
|
+
* top-level `ServerOptions.trustedProxyHops`. Wins over the deprecated
|
|
694
|
+
* `auth.trustedProxyHops` and `KUMIKO_TRUSTED_PROXY_HOPS`. Default 0 =
|
|
695
|
+
* trust no proxy header, only the socket address (or "unknown") counts. */
|
|
696
|
+
readonly trustedProxyHops?: number;
|
|
667
697
|
};
|
|
668
698
|
|
|
669
699
|
export type ProdAppHandle = {
|
|
@@ -674,8 +704,12 @@ export type ProdAppHandle = {
|
|
|
674
704
|
* `process.exit(0)` before returning a handle. */
|
|
675
705
|
readonly entrypoint: ApiEntrypoint | AllInOneEntrypoint;
|
|
676
706
|
/** The fetch-handler — wired into Bun.serve in production, called
|
|
677
|
-
* directly in tests. Composes Hono + static-fallback.
|
|
678
|
-
|
|
707
|
+
* directly in tests. Composes Hono + static-fallback. Apps with
|
|
708
|
+
* `autoListen: false` that call `Bun.serve` themselves must pass the
|
|
709
|
+
* 2nd arg from `server.requestIP(req)?.address`, extracted in their
|
|
710
|
+
* outermost Bun fetch callback — `requestIP` only resolves for Bun's
|
|
711
|
+
* original Request instance, never a cloned/rebuilt one. */
|
|
712
|
+
readonly fetch: (req: Request, socketAddress?: string) => Promise<Response> | Response;
|
|
679
713
|
/** Active Bun-server (only set when listen() was called — tests skip
|
|
680
714
|
* listen() because Bun.serve isn't available under vitest/node). */
|
|
681
715
|
server?: ReturnType<typeof Bun.serve>;
|
|
@@ -792,30 +826,10 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
792
826
|
// kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
|
|
793
827
|
// to the env var so ops can close the XFF-spoofing hole per-deployment
|
|
794
828
|
// without a code change (mirrors instanceId's env-first pattern above).
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
// deployment — a self-inflicted DoS, worse than staying on the default.
|
|
800
|
-
const trustedProxyHopsFromEnv = readEnv("KUMIKO_TRUSTED_PROXY_HOPS", envSource);
|
|
801
|
-
const trustedProxyHops = ((): number | undefined => {
|
|
802
|
-
if (options.auth?.trustedProxyHops !== undefined) return options.auth.trustedProxyHops;
|
|
803
|
-
if (trustedProxyHopsFromEnv === undefined) return undefined;
|
|
804
|
-
// Digits-only — parseInt("0x10")/("1e3")/("2x") would silently coerce
|
|
805
|
-
// and land on the spoofable hops=0 path for values like "0x10".
|
|
806
|
-
if (!/^\d+$/.test(trustedProxyHopsFromEnv)) {
|
|
807
|
-
throw new Error(
|
|
808
|
-
`runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "${trustedProxyHopsFromEnv}".`,
|
|
809
|
-
);
|
|
810
|
-
}
|
|
811
|
-
const parsed = Number.parseInt(trustedProxyHopsFromEnv, 10);
|
|
812
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
813
|
-
throw new Error(
|
|
814
|
-
`runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "${trustedProxyHopsFromEnv}".`,
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
return parsed;
|
|
818
|
-
})();
|
|
829
|
+
const trustedProxyHops =
|
|
830
|
+
options.trustedProxyHops ??
|
|
831
|
+
options.auth?.trustedProxyHops ??
|
|
832
|
+
parseTrustedProxyHopsEnv(readEnv(TRUSTED_PROXY_HOPS_ENV, envSource), "runProdApp");
|
|
819
833
|
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
820
834
|
|
|
821
835
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
@@ -989,11 +1003,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
989
1003
|
...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
|
|
990
1004
|
...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
|
|
991
1005
|
};
|
|
992
|
-
// @cast-boundary engine-bridge — searchAdapter is an optional context-extension
|
|
993
|
-
// (SharedContextFields.searchAdapter), invisible to extraContext's loose
|
|
994
|
-
// Record<string, unknown>-based typing. Presence check only, no need to
|
|
995
|
-
// pull in the SearchAdapter type here.
|
|
996
|
-
const searchAdapterMissing = !(extraContext as { searchAdapter?: unknown }).searchAdapter;
|
|
997
1006
|
const baseAnonymousAccess =
|
|
998
1007
|
typeof options.anonymousAccess === "function"
|
|
999
1008
|
? options.anonymousAccess(deps)
|
|
@@ -1075,6 +1084,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1075
1084
|
...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
|
|
1076
1085
|
...(options.metrics && { metrics: options.metrics }),
|
|
1077
1086
|
...(options.rateLimit && { rateLimit: options.rateLimit }),
|
|
1087
|
+
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
1078
1088
|
...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
|
|
1079
1089
|
...(effectiveAuth && {
|
|
1080
1090
|
auth: {
|
|
@@ -1101,6 +1111,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1101
1111
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
1102
1112
|
}),
|
|
1103
1113
|
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
1114
|
+
...(effectiveAuth.postAuthLanding !== undefined && {
|
|
1115
|
+
postAuthLanding: effectiveAuth.postAuthLanding,
|
|
1116
|
+
}),
|
|
1104
1117
|
...sessionAuthFragment,
|
|
1105
1118
|
...patAuthFragment,
|
|
1106
1119
|
...(mfaFeature && {
|
|
@@ -1222,18 +1235,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1222
1235
|
});
|
|
1223
1236
|
}
|
|
1224
1237
|
|
|
1225
|
-
// 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
|
|
1226
|
-
// in die index.html injiziert damit createKumikoApp() im Browser
|
|
1227
|
-
// `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
|
|
1228
|
-
// wie im dev-server, damit der Client-Code keine Sonderfall-
|
|
1229
|
-
// Branch zwischen dev/prod braucht. Boot-once weil Features
|
|
1230
|
-
// nach dem Start nicht mehr ändern.
|
|
1231
|
-
// TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
|
|
1232
|
-
// pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
|
|
1233
|
-
// Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
|
|
1234
|
-
// statt eines fixed JSON-Strings. Heute: registry-static, also OK.
|
|
1235
|
-
const appSchemaJson = JSON.stringify(buildAppSchema(registry, { searchAdapterMissing }));
|
|
1236
|
-
|
|
1237
1238
|
// `extraRoutes` itself is already mounted — buildServer wires it inside
|
|
1238
1239
|
// createApiEntrypoint/createAllInOneEntrypoint, before this function ever
|
|
1239
1240
|
// runs. `wire` is the remaining escape-hatch for app-wired co-running
|
|
@@ -1301,19 +1302,20 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1301
1302
|
? buildStaticFallback(
|
|
1302
1303
|
entrypoint.app.fetch.bind(entrypoint.app),
|
|
1303
1304
|
options.staticDir,
|
|
1304
|
-
appSchemaJson,
|
|
1305
1305
|
options.hostDispatch,
|
|
1306
1306
|
options.resolvePageHead
|
|
1307
1307
|
? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
|
|
1308
1308
|
: undefined,
|
|
1309
1309
|
entrypoint.dispatcher,
|
|
1310
|
+
trustedProxyHops ?? 0,
|
|
1310
1311
|
)
|
|
1311
1312
|
: // No staticDir (split-deploy / API-only container) → app.fetch's
|
|
1312
1313
|
// response goes straight to the client, bypassing buildStaticFallback
|
|
1313
1314
|
// (and with it tryHonoFirst) entirely. Must strip the router-miss
|
|
1314
1315
|
// marker here too, same reason as the /api/* passthrough in
|
|
1315
1316
|
// run-prod-app-static-files.ts (see try-hono-first.ts).
|
|
1316
|
-
async (req: Request) =>
|
|
1317
|
+
async (req: Request, socketAddress?: string) =>
|
|
1318
|
+
stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)),
|
|
1317
1319
|
options.securityHeaders,
|
|
1318
1320
|
);
|
|
1319
1321
|
|
|
@@ -1338,7 +1340,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1338
1340
|
"Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
|
|
1339
1341
|
);
|
|
1340
1342
|
}
|
|
1341
|
-
handle.server = Bun.serve(
|
|
1343
|
+
handle.server = Bun.serve(
|
|
1344
|
+
buildBunServeOptions(
|
|
1345
|
+
listenPort,
|
|
1346
|
+
fetchHandler,
|
|
1347
|
+
options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry),
|
|
1348
|
+
),
|
|
1349
|
+
);
|
|
1342
1350
|
|
|
1343
1351
|
// SIGTERM/SIGINT — graceful shutdown. Only registered when we
|
|
1344
1352
|
// actually own a Bun-server, otherwise the test process picks up
|
package/src/security-headers.ts
CHANGED
|
@@ -43,13 +43,13 @@ export function resolveSecurityHeaders(
|
|
|
43
43
|
// Sets each header only when absent so per-response values (e.g. the
|
|
44
44
|
// per-host CSP from hostDispatch) always win over the runtime default.
|
|
45
45
|
export function withSecurityHeaders(
|
|
46
|
-
handler: (req: Request) => Response | Promise<Response>,
|
|
46
|
+
handler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
|
|
47
47
|
option: SecurityHeadersOption | undefined,
|
|
48
|
-
): (req: Request) => Response | Promise<Response> {
|
|
48
|
+
): (req: Request, socketAddress?: string) => Response | Promise<Response> {
|
|
49
49
|
const defaults = resolveSecurityHeaders(option);
|
|
50
50
|
if (defaults.length === 0) return handler;
|
|
51
|
-
return async (req: Request): Promise<Response> => {
|
|
52
|
-
const res = await handler(req);
|
|
51
|
+
return async (req: Request, socketAddress?: string): Promise<Response> => {
|
|
52
|
+
const res = await handler(req, socketAddress);
|
|
53
53
|
try {
|
|
54
54
|
for (const [name, value] of defaults) {
|
|
55
55
|
if (!res.headers.has(name)) res.headers.set(name, value);
|
package/src/try-hono-first.ts
CHANGED
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
32
32
|
|
|
33
33
|
export type HonoLikeApp = {
|
|
34
|
-
// Hono.app.fetch is `(req) => Response | Promise<Response>` (sync if
|
|
35
|
-
// handlers are sync, otherwise a Promise). createApiEntrypoint's
|
|
34
|
+
// Hono.app.fetch is `(req, env?) => Response | Promise<Response>` (sync if
|
|
35
|
+
// all handlers are sync, otherwise a Promise). createApiEntrypoint's
|
|
36
36
|
// apiHandler matches the same shape. The union accepts both — we await
|
|
37
|
-
// below, which works for either case.
|
|
38
|
-
|
|
37
|
+
// below, which works for either case. `socketAddress` rides in as `env`
|
|
38
|
+
// so requestIdMiddleware/rate-limit can read it via `c.env` without Hono
|
|
39
|
+
// ever seeing a raw Bun `Server`.
|
|
40
|
+
readonly fetch: (req: Request, socketAddress?: string) => Response | Promise<Response>;
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
export type HonoFirstResult = {
|
|
@@ -69,8 +71,12 @@ export function stripNoRouteMatchHeader(response: Response): Response {
|
|
|
69
71
|
* req.clone() because downstream needs to read the request body again
|
|
70
72
|
* (future-proofing for POST/PUT/PATCH — only GET routes today).
|
|
71
73
|
*/
|
|
72
|
-
export async function tryHonoFirst(
|
|
73
|
-
|
|
74
|
+
export async function tryHonoFirst(
|
|
75
|
+
app: HonoLikeApp,
|
|
76
|
+
req: Request,
|
|
77
|
+
socketAddress?: string,
|
|
78
|
+
): Promise<HonoFirstResult> {
|
|
79
|
+
const response = await app.fetch(req.clone(), socketAddress);
|
|
74
80
|
const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
|
|
75
81
|
stripNoRouteMatchHeader(response);
|
|
76
82
|
return { matched: !isRouterMiss, response };
|