@cosmicdrift/kumiko-server-runtime 0.220.0 → 0.221.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.220.0",
3
+ "version": "0.221.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,8 +80,8 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.220.0",
84
- "@cosmicdrift/kumiko-framework": "0.220.0",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.221.0",
84
+ "@cosmicdrift/kumiko-framework": "0.221.0",
85
85
  "temporal-polyfill": "^0.3.2"
86
86
  },
87
87
  "publishConfig": {
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
+ import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
7
8
  import {
8
9
  buildStaticFallback,
9
10
  mimeTypeFor,
@@ -11,6 +12,15 @@ import {
11
12
  serveDiskFile,
12
13
  } from "../run-prod-app-static-files";
13
14
 
15
+ // Stand-in for a real Hono app's fetch when the test wants "no route
16
+ // matched" — must carry NO_ROUTE_MATCH_HEADER_NAME, same as buildServer's
17
+ // app.notFound() (framework/api/server.ts), otherwise tryHonoFirst treats
18
+ // the fake apiHandler's 404 as a matched route's own deliberate 404 (see
19
+ // kumiko-framework#2435) and never falls through to disk/SPA.
20
+ function noRouteMatchedResponse(): Response {
21
+ return new Response("404", { status: 404, headers: { [NO_ROUTE_MATCH_HEADER_NAME]: "1" } });
22
+ }
23
+
14
24
  describe("mimeTypeFor", () => {
15
25
  const cases: ReadonlyArray<readonly [string, string]> = [
16
26
  ["x.html", "text/html; charset=utf-8"],
@@ -94,7 +104,7 @@ describe("buildStaticFallback hostDispatch", () => {
94
104
 
95
105
  test("hostDispatch html pointing at missing file → 500", async () => {
96
106
  const handler = buildStaticFallback(
97
- () => new Response("api-404", { status: 404 }),
107
+ () => noRouteMatchedResponse(),
98
108
  tmp,
99
109
  "{}",
100
110
  () => ({ kind: "html", file: "gone.html" }),
@@ -106,7 +116,7 @@ describe("buildStaticFallback hostDispatch", () => {
106
116
 
107
117
  test("hostDispatch not-found → 404; redirect → 302", async () => {
108
118
  const notFound = buildStaticFallback(
109
- () => new Response("x", { status: 404 }),
119
+ () => noRouteMatchedResponse(),
110
120
  tmp,
111
121
  "{}",
112
122
  () => ({ kind: "not-found" }),
@@ -114,7 +124,7 @@ describe("buildStaticFallback hostDispatch", () => {
114
124
  expect((await notFound(new Request("http://t/"))).status).toBe(404);
115
125
 
116
126
  const redirect = buildStaticFallback(
117
- () => new Response("x", { status: 404 }),
127
+ () => noRouteMatchedResponse(),
118
128
  tmp,
119
129
  "{}",
120
130
  () => ({ kind: "redirect", to: "https://example.com/", status: 301 }),
@@ -133,7 +143,7 @@ describe("buildStaticFallback hostDispatch", () => {
133
143
 
134
144
  test("serves disk asset under staticDir", async () => {
135
145
  await writeFile(join(tmp, "logo.png"), "PNGDATA");
136
- const handler = buildStaticFallback(() => new Response("404", { status: 404 }), tmp, "{}");
146
+ const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp, "{}");
137
147
  const res = await handler(new Request("http://t/logo.png"));
138
148
  expect(res.status).toBe(200);
139
149
  expect(res.headers.get("content-type")).toBe("image/png");
@@ -143,7 +153,7 @@ describe("buildStaticFallback hostDispatch", () => {
143
153
  test("a request for a directory copied verbatim from public/ falls back to index.html instead of 500ing", async () => {
144
154
  await mkdir(join(tmp, "sub"));
145
155
  await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
146
- const handler = buildStaticFallback(() => new Response("404", { status: 404 }), tmp, "{}");
156
+ const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp, "{}");
147
157
  const res = await handler(new Request("http://t/sub"));
148
158
  expect(res.status).toBe(200);
149
159
  expect(await res.text()).toContain("spa-shell");
@@ -152,7 +162,7 @@ describe("buildStaticFallback hostDispatch", () => {
152
162
  test("hostDispatch html with CSP + Vary: Host", async () => {
153
163
  await writeFile(join(tmp, "tenant.html"), "<!doctype html><html><body>ok</body></html>");
154
164
  const handler = buildStaticFallback(
155
- () => new Response("404", { status: 404 }),
165
+ () => noRouteMatchedResponse(),
156
166
  tmp,
157
167
  '{"screens":[]}',
158
168
  () => ({
@@ -168,4 +178,28 @@ describe("buildStaticFallback hostDispatch", () => {
168
178
  expect(res.headers.get("content-security-policy")).toBe("default-src 'self'");
169
179
  expect(await res.text()).toContain("ok");
170
180
  });
181
+
182
+ test("a matched route's own deliberate 404 stays 404, not masked as the SPA shell (#2435)", async () => {
183
+ // An index.html exists on disk, so the OLD status-only heuristic would
184
+ // have served it with status 200 for ANY 404 — including one from a
185
+ // matched route (e.g. file-derivatives' public-variant default-deny)
186
+ // that never carries NO_ROUTE_MATCH_HEADER_NAME.
187
+ await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
188
+ const handler = buildStaticFallback(
189
+ () => new Response("not found", { status: 404 }),
190
+ tmp,
191
+ "{}",
192
+ );
193
+ const res = await handler(new Request("http://t/files/deadbeef/thumb"));
194
+ expect(res.status).toBe(404);
195
+ expect(await res.text()).toBe("not found");
196
+ });
197
+
198
+ test("an unknown SPA route with no matching handler still gets the SPA shell", async () => {
199
+ await writeFile(join(tmp, "index.html"), "<!doctype html><html><body>spa-shell</body></html>");
200
+ const handler = buildStaticFallback(() => noRouteMatchedResponse(), tmp, "{}");
201
+ const res = await handler(new Request("http://t/some/client-side/route"));
202
+ expect(res.status).toBe(200);
203
+ expect(await res.text()).toContain("spa-shell");
204
+ });
171
205
  });
@@ -20,6 +20,7 @@ import {
20
20
  userSessionEntity,
21
21
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
22
22
  import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
23
+ import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
23
24
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
24
25
  import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
25
26
  import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
@@ -412,6 +413,96 @@ describe("runProdApp", () => {
412
413
  expect(res.headers.get("etag")).toBeTruthy();
413
414
  });
414
415
 
416
+ test("static-fallback: a matched extraRoute's own deliberate 404 stays 404, not masked as the SPA shell (#2435)", async () => {
417
+ // kumiko-framework#2435: tryHonoFirst used to treat ANY 404 as "no
418
+ // route matched" and fall through to index.html with status 200 —
419
+ // masking a matched route's intentional 404 (e.g. default-deny reads
420
+ // like file-derivatives' public-variant route). An index.html exists
421
+ // here specifically to prove the SPA-fallback does NOT win.
422
+ const tmpStaticDir = await createTempStaticDir({
423
+ "index.html": "<html>SPA shell</html>",
424
+ });
425
+
426
+ const handle = await boot(undefined, {
427
+ staticDir: tmpStaticDir,
428
+ extraRoutes: (app) => {
429
+ app.get("/probe/:id", (c) => {
430
+ if (c.req.param("id") === "missing") return c.text("not found", 404);
431
+ return c.text(`probe:${c.req.param("id")}`, 200);
432
+ });
433
+ },
434
+ });
435
+
436
+ const res = await handle.fetch(new Request("http://test/probe/missing"));
437
+ expect(res.status).toBe(404);
438
+ expect(await res.text()).toBe("not found");
439
+ expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/);
440
+ expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
441
+ });
442
+
443
+ test("static-fallback: an unknown SPA route still gets the SPA shell alongside a route that 404s (#2435)", async () => {
444
+ const tmpStaticDir = await createTempStaticDir({
445
+ "index.html": "<html>SPA shell</html>",
446
+ });
447
+
448
+ const handle = await boot(undefined, {
449
+ staticDir: tmpStaticDir,
450
+ extraRoutes: (app) => {
451
+ app.get("/probe/:id", (c) => c.text("not found", 404));
452
+ },
453
+ });
454
+
455
+ const res = await handle.fetch(new Request("http://test/some/client-route"));
456
+ expect(res.status).toBe(200);
457
+ expect(await res.text()).toContain("SPA shell");
458
+ expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
459
+ });
460
+
461
+ test("static-fallback: an unmatched /api/* path stays a plain 404, no marker leak (#2435)", async () => {
462
+ // /api/* is a passthrough in buildStaticFallback (always Hono, never
463
+ // the SPA shell) — bypasses tryHonoFirst entirely, so the strip at
464
+ // that passthrough site is what's under test here. anonymousAccess is
465
+ // wired so this anonymous GET clears the auth middleware and actually
466
+ // reaches Hono's router (unauthenticated /api/* requests get a plain
467
+ // 401 from the auth guard before routing is even attempted, which
468
+ // would test the auth guard instead of the router-miss path).
469
+ const tmpStaticDir = await createTempStaticDir({
470
+ "index.html": "<html>SPA shell</html>",
471
+ });
472
+ const handle = await boot(undefined, {
473
+ staticDir: tmpStaticDir,
474
+ anonymousAccess: { defaultTenantId: TENANT_ID },
475
+ });
476
+
477
+ const res = await handle.fetch(new Request("http://test/api/totally-unknown-route"));
478
+ expect(res.status).toBe(404);
479
+ expect(await res.text()).not.toContain("SPA shell");
480
+ expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
481
+ });
482
+
483
+ test("no staticDir (split-deploy/API-only boot): a router-miss never leaks the internal marker header to the client (#2435)", async () => {
484
+ // Without staticDir, fetchHandler is app.fetch directly — it never goes
485
+ // through buildStaticFallback/tryHonoFirst, so the strip has to happen
486
+ // at this bypass site too (run-prod-app.ts's fetchHandler assembly).
487
+ const handle = await boot(undefined, {
488
+ extraRoutes: (app) => {
489
+ app.get("/probe/:id", (c) => {
490
+ if (c.req.param("id") === "missing") return c.text("not found", 404);
491
+ return c.text(`probe:${c.req.param("id")}`, 200);
492
+ });
493
+ },
494
+ });
495
+
496
+ const routerMiss = await handle.fetch(new Request("http://test/totally/unknown/path"));
497
+ expect(routerMiss.status).toBe(404);
498
+ expect(routerMiss.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
499
+
500
+ const deliberate404 = await handle.fetch(new Request("http://test/probe/missing"));
501
+ expect(deliberate404.status).toBe(404);
502
+ expect(await deliberate404.text()).toBe("not found");
503
+ expect(deliberate404.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
504
+ });
505
+
415
506
  test("static-fallback: If-None-Match → 304 on disk file", async () => {
416
507
  const tmpStaticDir = await createTempStaticDir({
417
508
  "robots.txt": "User-agent: *\nAllow: /",
@@ -1,12 +1,19 @@
1
- // Pure-function pin für tryHonoFirst. Trivial aber load-bearing:
2
- // Drift zwischen dev (createKumikoServer) und prod (runProdApp) hat
3
- // schon einen Bug verursacht (legal-pages funktionierten in prod aber
4
- // nicht in dev). Beide nutzen jetzt diesen helper wenn die Semantik
5
- // sich ändert (z.B. "matched" auch für 4xx anders als 404), MÜSSEN
6
- // beide Pfade synchron updaten.
1
+ // Pure-function pin for tryHonoFirst. Trivial but load-bearing: drift
2
+ // between dev (createKumikoServer) and prod (runProdApp) has already
3
+ // caused a bug before (legal-pages worked in prod but not in dev). Both
4
+ // now use this helper if the semantics change (e.g. "matched" treating
5
+ // other 4xx differently than 404), both paths MUST update in sync.
6
+ //
7
+ // kumiko-framework#2435: "matched" used to depend ONLY on the status code
8
+ // (404 = no match) — that masked any matched route's deliberate 404 as the
9
+ // SPA shell with status 200. The contract now: a router-miss is ONLY a 404
10
+ // that ADDITIONALLY carries NO_ROUTE_MATCH_HEADER_NAME (set by buildServer's
11
+ // app.notFound(), see framework/api/server.ts). A plain 404 without the
12
+ // marker counts as matched — the route answered deliberately.
7
13
 
8
14
  import { describe, expect, test } from "bun:test";
9
- import { type HonoLikeApp, tryHonoFirst } from "../try-hono-first";
15
+ import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
16
+ import { type HonoLikeApp, stripNoRouteMatchHeader, tryHonoFirst } from "../try-hono-first";
10
17
 
11
18
  function makeApp(response: Response): HonoLikeApp {
12
19
  return { fetch: () => response };
@@ -16,28 +23,47 @@ function makeAsyncApp(response: Response): HonoLikeApp {
16
23
  return { fetch: async () => response };
17
24
  }
18
25
 
26
+ function routerMissResponse(): Response {
27
+ return new Response("Not Found", {
28
+ status: 404,
29
+ headers: { [NO_ROUTE_MATCH_HEADER_NAME]: "1" },
30
+ });
31
+ }
32
+
19
33
  describe("tryHonoFirst", () => {
20
- test("matched=true bei 200 (Hono-route greift)", async () => {
34
+ test("matched=true on 200 (a Hono route handled it)", async () => {
21
35
  const app = makeApp(new Response("ok", { status: 200 }));
22
36
  const res = await tryHonoFirst(app, new Request("http://test/foo"));
23
37
  expect(res.matched).toBe(true);
24
38
  expect(res.response.status).toBe(200);
25
39
  });
26
40
 
27
- test("matched=false bei 404 (keine Route — caller fällt auf SPA-fallback)", async () => {
28
- const app = makeApp(new Response("not found", { status: 404 }));
41
+ test("matched=false on a router-miss (404 + NO_ROUTE_MATCH_HEADER_NAME — caller falls back to the SPA)", async () => {
42
+ const app = makeApp(routerMissResponse());
29
43
  const res = await tryHonoFirst(app, new Request("http://test/unknown"));
30
44
  expect(res.matched).toBe(false);
31
- // response wird trotzdem zurückgegeben — caller kann den 404 als
32
- // letztes Sicherheitsnetz nutzen wenn auch SPA-fallback nichts liefert.
45
+ // response is still returned — caller can use the 404 as a last-resort
46
+ // safety net if the SPA fallback doesn't deliver anything either.
47
+ expect(res.response.status).toBe(404);
48
+ });
49
+
50
+ test("matched=true for a matched route that deliberately answers 404 (no marker — bug #2435)", async () => {
51
+ // The actual bug: file-derivatives' public-variant route matches and
52
+ // answers `c.text("not found", 404)` for default-deny — NOT a
53
+ // router-miss. Without the marker header this used to be
54
+ // misinterpreted as "no match" and the caller would have served the
55
+ // SPA shell with status 200 instead of passing the real 404 through.
56
+ const app = makeApp(new Response("not found", { status: 404 }));
57
+ const res = await tryHonoFirst(app, new Request("http://test/files/x/thumb"));
58
+ expect(res.matched).toBe(true);
33
59
  expect(res.response.status).toBe(404);
34
60
  });
35
61
 
36
- test("matched=true bei 401/403/500 (Hono hat geantwortet kein SPA-fallback)", async () => {
37
- // Bug-Pin: matched darf NUR bei status=404 false sein. Wenn Hono
38
- // 401 (auth required) returnt, war die Route klar gefunden + hat
39
- // bewusst rejected — SPA-fallback würde das überschreiben und den
40
- // User auf eine SPA leiten statt die 401-message zu zeigen.
62
+ test("matched=true on 401/403/500 (Hono answeredno SPA fallback)", async () => {
63
+ // Bug pin: matched may ONLY be false on a router-miss. When Hono
64
+ // returns 401 (auth required), the route was clearly found and
65
+ // deliberately rejected — an SPA fallback would override that and
66
+ // redirect the user into the SPA instead of showing the 401 message.
41
67
  for (const status of [401, 403, 422, 500] as const) {
42
68
  const app = makeApp(new Response(null, { status }));
43
69
  const res = await tryHonoFirst(app, new Request("http://test/x"));
@@ -45,10 +71,10 @@ describe("tryHonoFirst", () => {
45
71
  }
46
72
  });
47
73
 
48
- test("akzeptiert sowohl sync als auch async fetch (Hono-Variation)", async () => {
49
- // Hono.app.fetch returnt Response | Promise<Response> abhängig vom
50
- // handler-mix. createApiEntrypoint's apiHandler dasselbe. Helper
51
- // muss beide schluckable.
74
+ test("accepts both sync and async fetch (Hono variation)", async () => {
75
+ // Hono.app.fetch returns Response | Promise<Response> depending on the
76
+ // handler mix. createApiEntrypoint's apiHandler does the same. The
77
+ // helper must accept both.
52
78
  const sync = await tryHonoFirst(
53
79
  makeApp(new Response("s", { status: 200 })),
54
80
  new Request("http://t/"),
@@ -60,4 +86,23 @@ describe("tryHonoFirst", () => {
60
86
  expect(sync.matched).toBe(true);
61
87
  expect(asyncRes.matched).toBe(true);
62
88
  });
89
+
90
+ test("strips NO_ROUTE_MATCH_HEADER_NAME from the returned response (never leaks to the client)", async () => {
91
+ const app = makeApp(routerMissResponse());
92
+ const res = await tryHonoFirst(app, new Request("http://test/unknown"));
93
+ expect(res.response.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("stripNoRouteMatchHeader", () => {
98
+ test("removes the header when present", () => {
99
+ const res = stripNoRouteMatchHeader(routerMissResponse());
100
+ expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
101
+ });
102
+
103
+ test("is a no-op when the header is absent", () => {
104
+ const res = stripNoRouteMatchHeader(new Response("ok", { status: 200 }));
105
+ expect(res.status).toBe(200);
106
+ expect(res.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
107
+ });
63
108
  });
@@ -1,7 +1,7 @@
1
1
  import { createJobRunLogger } from "@cosmicdrift/kumiko-bundled-features/jobs";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
- import type { JobRunIn } from "@cosmicdrift/kumiko-framework/engine/types";
4
+ import type { AppContext, JobRunIn } from "@cosmicdrift/kumiko-framework/engine/types";
5
5
  import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
6
6
 
7
7
  export function jobRunLoggerCallbacks(
@@ -16,7 +16,7 @@ export function jobRunLoggerCallbacks(
16
16
  export async function startDevJobRunners(opts: {
17
17
  readonly registry: Registry;
18
18
  readonly db: DbConnection;
19
- readonly context: Record<string, unknown>;
19
+ readonly context: AppContext;
20
20
  readonly redisUrl: string;
21
21
  }): Promise<{ readonly runners: readonly JobRunner[]; readonly stop: () => Promise<void> }> {
22
22
  const jobs = [...opts.registry.getAllJobs().values()];
@@ -7,7 +7,7 @@ import {
7
7
  import { ASSETS_DIR } from "./build-prod-bundle";
8
8
  import { injectSchema } from "./inject-schema";
9
9
  import type { HostDispatchFn } from "./run-prod-app";
10
- import { tryHonoFirst } from "./try-hono-first";
10
+ import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first";
11
11
 
12
12
  // Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
13
13
  // out of run-prod-app.ts (#1005, Welle 2) — mechanical relocation, these
@@ -201,9 +201,12 @@ export function buildStaticFallback(
201
201
 
202
202
  return async (req: Request): Promise<Response> => {
203
203
  const url = new URL(req.url);
204
- // /api/* and /health → always Hono (Dispatcher + Health-Probe).
204
+ // /api/* and /health → always Hono (Dispatcher + Health-Probe). Bypasses
205
+ // tryHonoFirst entirely, so the router-miss marker must be stripped
206
+ // here too — otherwise an unmatched /api/* path would leak it straight
207
+ // to the client (see try-hono-first.ts's header-hygiene note).
205
208
  if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
206
- return apiHandler(req);
209
+ return stripNoRouteMatchHeader(await apiHandler(req));
207
210
  }
208
211
 
209
212
  // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
@@ -154,6 +154,7 @@ import { buildStaticFallback } from "./run-prod-app-static-files";
154
154
  import { type SecurityHeadersOption, withSecurityHeaders } from "./security-headers";
155
155
  import { assertSessionBootInvariants } from "./session-boot-gate";
156
156
  import { shouldWireProdSessions } from "./session-wiring";
157
+ import { stripNoRouteMatchHeader } from "./try-hono-first";
157
158
 
158
159
  export { buildBunServeOptions } from "./bun-serve-options";
159
160
  export {
@@ -492,6 +493,9 @@ export type RunProdAppOptions = {
492
493
  * `({db, redis, registry}) => Record<string, unknown>` — gleiches
493
494
  * Pattern wie `anonymousAccess`. Eine App die einen dieser Keys selbst
494
495
  * setzt (z.B. eigener configResolver) überschreibt den Default. */
496
+ /** Boot default locale (BCP-47) for ctx.locale when the request carries
497
+ * no locale signal. Merged into AppContext before extraContext (app wins). */
498
+ readonly defaultLocale?: string;
495
499
  readonly extraContext?: ExtraContextOption;
496
500
  /** MasterKeyProvider für die auto-verdrahtete `ctx.secrets`. Default:
497
501
  * `createEnvMasterKeyProvider` (KEK aus `KUMIKO_SECRETS_MASTER_KEY_V<n>`).
@@ -927,10 +931,10 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
927
931
  crypto: bootCrypto,
928
932
  ...(options.kms && { kms: options.kms }),
929
933
  });
930
- const extraContext = addConfigAccessorFactory(
931
- { ...autoExtraContext, ...resolvedExtraContext },
932
- registry,
933
- );
934
+ const extraContext = {
935
+ ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
936
+ ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
937
+ };
934
938
  // @cast-boundary engine-bridge — searchAdapter is an optional context-extension
935
939
  // (SharedContextFields.searchAdapter), invisible to extraContext's loose
936
940
  // Record<string, unknown>-based typing. Presence check only, no need to
@@ -1237,7 +1241,12 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1237
1241
  appSchemaJson,
1238
1242
  options.hostDispatch,
1239
1243
  )
1240
- : entrypoint.app.fetch.bind(entrypoint.app),
1244
+ : // No staticDir (split-deploy / API-only container) → app.fetch's
1245
+ // response goes straight to the client, bypassing buildStaticFallback
1246
+ // (and with it tryHonoFirst) entirely. Must strip the router-miss
1247
+ // marker here too, same reason as the /api/* passthrough in
1248
+ // run-prod-app-static-files.ts (see try-hono-first.ts).
1249
+ async (req: Request) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req)),
1241
1250
  options.securityHeaders,
1242
1251
  );
1243
1252
 
@@ -1,46 +1,77 @@
1
- // Shared helper für die "Hono-first, SPA-fallback wenn 404"-Strategie.
2
- // Wird von dev (createKumikoServer.handleFetch) UND prod (runProdApp's
3
- // fetch-handler) verwendet identische Semantik, ein helper. Ohne den
4
- // shared-Helper drifteten die beiden Pfade silent (genau der Bug der
5
- // legal-pages im dev-server geshadowed hat — runProdApp's docs sagten
6
- // "Hono matched VOR fallback", dev-server tat das NICHT).
1
+ // Shared helper for the "Hono-first, SPA-fallback on 404" strategy. Used by
2
+ // both dev (createKumikoServer.handleFetch) AND prod (runProdApp's fetch
3
+ // handler) — identical semantics, one helper. Without the shared helper the
4
+ // two paths drifted silently before (the same bug class that shadowed
5
+ // legal-pages in the dev server — runProdApp's docs said "Hono matches
6
+ // BEFORE fallback", the dev server didn't).
7
7
  //
8
8
  // Pattern:
9
- // 1. Try app.fetch(req) — wenn Hono eine route matcht, greift sie.
10
- // 2. 404 vom Hono-stacknull returnen, caller macht SPA-fallback.
11
- // 3. Sonstige status (200, 401, 500, ...) → response durchreichen.
9
+ // 1. Try app.fetch(req) — if Hono matches a route, it wins.
10
+ // 2. Router-miss (404 WITH NO_ROUTE_MATCH_HEADER_NAME)matched=false,
11
+ // caller does the SPA fallback.
12
+ // 3. Any other status (200, 401, 500, ...) OR a matched route's own 404
13
+ // WITHOUT the marker → response passes through as-is.
12
14
  //
13
- // req.clone() weil downstream der req body nochmal lesbar sein muss
14
- // (POST/PUT/PATCH future-proof heute nur GET-routes betroffen).
15
+ // The status code alone used to be the only signal ("404 = no match") —
16
+ // that masked any matched route's deliberate 404 (e.g. default-deny reads)
17
+ // as the SPA shell with status 200 (kumiko-framework#2435). buildServer
18
+ // (framework/api/server.ts) registers app.notFound() LAST on the app object
19
+ // and marks exactly the "no handler found" case with
20
+ // NO_ROUTE_MATCH_HEADER_NAME — a handler that builds its own
21
+ // `c.text(..., 404)` never routes through this code path. The marker is
22
+ // process-internal only: stripNoRouteMatchHeader removes it from EVERY
23
+ // response before it leaves the process, otherwise a caller could
24
+ // distinguish "unknown path" from "known path, access denied" via the
25
+ // header's presence — exactly what routes like file-derivatives'
26
+ // public-variant deliberately hide behind a single status code.
27
+ //
28
+ // req.clone() because downstream needs to read the request body again
29
+ // (future-proofing for POST/PUT/PATCH — only GET routes today).
30
+
31
+ import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
15
32
 
16
33
  export type HonoLikeApp = {
17
- // Hono.app.fetch ist `(req) => Response | Promise<Response>` (sync wenn
18
- // alle Handler sync sind, sonst Promise). createApiEntrypoint's
19
- // apiHandler matcht dieselbe shape. Union accepts both — wir await
20
- // unten, das funktioniert für beide Fälle.
34
+ // Hono.app.fetch is `(req) => Response | Promise<Response>` (sync if all
35
+ // handlers are sync, otherwise a Promise). createApiEntrypoint's
36
+ // apiHandler matches the same shape. The union accepts both — we await
37
+ // below, which works for either case.
21
38
  readonly fetch: (req: Request) => Response | Promise<Response>;
22
39
  };
23
40
 
24
41
  export type HonoFirstResult = {
25
- /** True wenn Hono eine matchende Route hat (status !== 404).
26
- * Caller returnt dann response direkt.
27
- * False wenn keine Route matcht (status === 404). Caller macht den
28
- * SPA-/static-fallback; response enthält den 404 als final-fallback
29
- * falls auch der SPA-Pfad nichts liefert. */
42
+ /** True when Hono has a matching route (no router-miss).
43
+ * Caller returns the response directly.
44
+ * False when no route matches (router-miss, detected via 404 +
45
+ * NO_ROUTE_MATCH_HEADER_NAME). Caller does the SPA/static fallback;
46
+ * the response still carries the 404 as a final fallback in case the
47
+ * SPA path doesn't deliver anything either. */
30
48
  readonly matched: boolean;
31
49
  readonly response: Response;
32
50
  };
33
51
 
52
+ // Removes the internal router-miss marker from a response before it leaves
53
+ // the process. Needed at EVERY site that potentially passes app.fetch()'s
54
+ // response through to a real client — including the passthrough paths that
55
+ // never call tryHonoFirst at all (e.g. /api/* in runProdApp, dotted-paths/
56
+ // non-GET in the dev server). No no-op check needed: Headers.delete() on a
57
+ // missing key is harmless.
58
+ export function stripNoRouteMatchHeader(response: Response): Response {
59
+ response.headers.delete(NO_ROUTE_MATCH_HEADER_NAME);
60
+ return response;
61
+ }
62
+
34
63
  /**
35
- * Hono-first try: app.fetch ZUERST. Wenn matched (status !== 404), gibt
36
- * der caller den response direkt zurück. Wenn nicht matched, fällt der
37
- * caller in den eigenen SPA-/static-fallback zurück der response (404)
38
- * bleibt verfügbar als letztes Sicherheitsnetz.
64
+ * Hono-first try: app.fetch FIRST. If matched (no router-miss), the caller
65
+ * returns the response directly. If not matched, the caller falls back to
66
+ * its own SPA/static fallback — the response (404) stays available as a
67
+ * last-resort safety net.
39
68
  *
40
- * req.clone() weil downstream der req body nochmal lesbar sein muss
41
- * (POST/PUT/PATCH future-proof heute nur GET-routes betroffen).
69
+ * req.clone() because downstream needs to read the request body again
70
+ * (future-proofing for POST/PUT/PATCH — only GET routes today).
42
71
  */
43
72
  export async function tryHonoFirst(app: HonoLikeApp, req: Request): Promise<HonoFirstResult> {
44
73
  const response = await app.fetch(req.clone());
45
- return { matched: response.status !== 404, response };
74
+ const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
75
+ stripNoRouteMatchHeader(response);
76
+ return { matched: !isRouterMiss, response };
46
77
  }