@cosmicdrift/kumiko-server-runtime 0.319.0 → 0.320.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 +138 -38
- package/src/bun-serve-options.ts +41 -3
- package/src/changes.json +25 -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 +77 -56
- 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
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
ExtraRouteRejection,
|
|
26
26
|
isRedisSseBroker,
|
|
27
27
|
NO_ROUTE_MATCH_HEADER_NAME,
|
|
28
|
+
requestContext,
|
|
28
29
|
type SseEvent,
|
|
29
30
|
signatureRoute,
|
|
30
31
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
@@ -92,6 +93,17 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
|
|
|
92
93
|
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
93
94
|
handler: async () => ({ pong: true }),
|
|
94
95
|
});
|
|
96
|
+
// Echoes the resolved client IP — the only way to prove a real Bun.serve
|
|
97
|
+
// socket address reaches requestContext.ip end-to-end (fw#3260 addendum:
|
|
98
|
+
// every other test in this file drives fetchHandler directly, which never
|
|
99
|
+
// exercises server.requestIP()).
|
|
100
|
+
r.queryHandler({
|
|
101
|
+
name: "ip-probe",
|
|
102
|
+
schema: z.object({}),
|
|
103
|
+
access: { roles: ["anonymous"] },
|
|
104
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
105
|
+
handler: async () => ({ ip: requestContext.get()?.ip }),
|
|
106
|
+
});
|
|
95
107
|
r.queryHandler({
|
|
96
108
|
name: "kms-probe",
|
|
97
109
|
schema: z.object({}),
|
|
@@ -691,9 +703,10 @@ describe("runProdApp", () => {
|
|
|
691
703
|
});
|
|
692
704
|
|
|
693
705
|
test("hostDispatch: per-host html-Datei + Schema-Gating", async () => {
|
|
694
|
-
// Multi-
|
|
695
|
-
//
|
|
696
|
-
//
|
|
706
|
+
// Multi-app deployment: two HTML files for different hosts. Neither
|
|
707
|
+
// ever carries __KUMIKO_SCHEMA__ (kumiko-framework#3314) —
|
|
708
|
+
// createKumikoApp fetches the schema itself from GET /api/schema
|
|
709
|
+
// after the auth gates let it through, regardless of host.
|
|
697
710
|
const tmpStaticDir = await createTempStaticDir({
|
|
698
711
|
"index.html": "<html><body>PUBLIC</body><script src=/client.js></script></html>",
|
|
699
712
|
"admin.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
|
|
@@ -703,13 +716,12 @@ describe("runProdApp", () => {
|
|
|
703
716
|
staticDir: tmpStaticDir,
|
|
704
717
|
hostDispatch: ({ host }) => {
|
|
705
718
|
if (host.startsWith("admin.")) {
|
|
706
|
-
return { kind: "html", file: "admin.html"
|
|
719
|
+
return { kind: "html", file: "admin.html" };
|
|
707
720
|
}
|
|
708
|
-
return { kind: "html", file: "index.html"
|
|
721
|
+
return { kind: "html", file: "index.html" };
|
|
709
722
|
},
|
|
710
723
|
});
|
|
711
724
|
|
|
712
|
-
// Public host: index.html, KEIN schema-Tag.
|
|
713
725
|
const pubRes = await handle.fetch(
|
|
714
726
|
new Request("http://demo.example.test/", { headers: { host: "demo.example.test" } }),
|
|
715
727
|
);
|
|
@@ -718,57 +730,107 @@ describe("runProdApp", () => {
|
|
|
718
730
|
expect(pubBody).toContain("PUBLIC");
|
|
719
731
|
expect(pubBody).not.toContain("__KUMIKO_SCHEMA__");
|
|
720
732
|
|
|
721
|
-
// Admin host: admin.html MIT schema-Tag.
|
|
722
733
|
const adminRes = await handle.fetch(
|
|
723
734
|
new Request("http://admin.example.test/", { headers: { host: "admin.example.test" } }),
|
|
724
735
|
);
|
|
725
736
|
expect(adminRes.status).toBe(200);
|
|
726
737
|
const adminBody = await adminRes.text();
|
|
727
738
|
expect(adminBody).toContain("ADMIN");
|
|
728
|
-
expect(adminBody).toContain("__KUMIKO_SCHEMA__");
|
|
729
|
-
|
|
730
|
-
// #2062: this boot()'s widgetFeature never wires extraContext.searchAdapter,
|
|
731
|
-
// so run-prod-app's `searchAdapterMissing = !(extraContext).searchAdapter`
|
|
732
|
-
// must actually flip true and flow into the injected schema — proving
|
|
733
|
-
// the cast-boundary read isn't silently always-false.
|
|
734
|
-
const schemaMatch = adminBody.match(/window\.__KUMIKO_SCHEMA__=(\{.*?\});<\/script>/s);
|
|
735
|
-
expect(schemaMatch).not.toBeNull();
|
|
736
|
-
const injectedSchema = JSON.parse(schemaMatch?.[1] ?? "{}");
|
|
737
|
-
// Guards against a regex miss silently degrading to `?? "{}"` — an empty
|
|
738
|
-
// features array would make `.every(...)` below vacuously true too.
|
|
739
|
-
expect(injectedSchema.features.length).toBeGreaterThan(0);
|
|
740
|
-
const allFeaturesMissingAdapter = injectedSchema.features.every(
|
|
741
|
-
(f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true,
|
|
742
|
-
);
|
|
743
|
-
expect(allFeaturesMissingAdapter).toBe(true);
|
|
739
|
+
expect(adminBody).not.toContain("__KUMIKO_SCHEMA__");
|
|
744
740
|
});
|
|
745
741
|
|
|
746
|
-
test("
|
|
742
|
+
test("staticDir without hostDispatch: HTML never carries the schema; GET /api/schema needs a signed-in user (kumiko-framework#3314)", async () => {
|
|
743
|
+
// A real POST /api/auth/login round-trip needs the tenant/user bundled
|
|
744
|
+
// features' own projection tables (e.g. read_tenants), which this
|
|
745
|
+
// suite's boot() doesn't migrate (migrations: false, only the app's own
|
|
746
|
+
// entity tables are auto-created — see unsafeEnsureEntityTable above).
|
|
747
|
+
// Signing a token straight through the entrypoint's JwtHelper exercises
|
|
748
|
+
// the exact same cookie transport auth-middleware reads, without
|
|
749
|
+
// pulling in that migration surface just for this schema-fetch test.
|
|
747
750
|
const tmpStaticDir = await createTempStaticDir({
|
|
748
|
-
"index.html": "<html><body>
|
|
751
|
+
"index.html": "<html><body>SHELL</body><script src=/client.js></script></html>",
|
|
749
752
|
});
|
|
750
753
|
|
|
754
|
+
const handle = await boot(undefined, { staticDir: tmpStaticDir });
|
|
755
|
+
|
|
756
|
+
const rootRes = await handle.fetch(new Request("http://test/"));
|
|
757
|
+
expect(rootRes.status).toBe(200);
|
|
758
|
+
expect(await rootRes.text()).not.toContain("__KUMIKO_SCHEMA__");
|
|
759
|
+
|
|
760
|
+
const spaRes = await handle.fetch(new Request("http://test/some/spa/route"));
|
|
761
|
+
expect(spaRes.status).toBe(200);
|
|
762
|
+
expect(await spaRes.text()).not.toContain("__KUMIKO_SCHEMA__");
|
|
763
|
+
|
|
764
|
+
const anonSchemaRes = await handle.fetch(new Request("http://test/api/schema"));
|
|
765
|
+
expect(anonSchemaRes.status).toBe(401);
|
|
766
|
+
|
|
767
|
+
const token = await handle.entrypoint.jwt.sign({
|
|
768
|
+
id: "schema-fetch-user",
|
|
769
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
770
|
+
roles: ["User"],
|
|
771
|
+
});
|
|
772
|
+
const schemaRes = await handle.fetch(
|
|
773
|
+
new Request("http://test/api/schema", { headers: { Cookie: `kumiko_auth=${token}` } }),
|
|
774
|
+
);
|
|
775
|
+
expect(schemaRes.status).toBe(200);
|
|
776
|
+
const schema = (await schemaRes.json()) as { features: readonly unknown[] };
|
|
777
|
+
expect(schema.features.length).toBeGreaterThan(0);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("anonymousAccess wired → GET /api/schema still 401s for the synthesised anonymous user", async () => {
|
|
781
|
+
const handle = await boot(undefined, {
|
|
782
|
+
anonymousAccess: {
|
|
783
|
+
defaultTenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const res = await handle.entrypoint.app.fetch(new Request("http://test/api/schema"));
|
|
788
|
+
expect(res.status).toBe(401);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("extraContext.searchAdapter absent → GET /api/schema marks every feature searchAdapterMissing (#2062)", async () => {
|
|
792
|
+
const handle = await boot();
|
|
793
|
+
const token = await handle.entrypoint.jwt.sign({
|
|
794
|
+
id: "schema-probe-user",
|
|
795
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
796
|
+
roles: ["User"],
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const res = await handle.entrypoint.app.fetch(
|
|
800
|
+
new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
|
|
801
|
+
);
|
|
802
|
+
expect(res.status).toBe(200);
|
|
803
|
+
const schema = (await res.json()) as {
|
|
804
|
+
features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
|
|
805
|
+
};
|
|
806
|
+
// Guards against a vacuous pass — an empty features array would make
|
|
807
|
+
// `.every(...)` below vacuously true too.
|
|
808
|
+
expect(schema.features.length).toBeGreaterThan(0);
|
|
809
|
+
expect(schema.features.every((f) => f.searchAdapterMissing === true)).toBe(true);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("extraContext.searchAdapter wired → GET /api/schema omits searchAdapterMissing (#2062)", async () => {
|
|
751
813
|
const handle = await boot(undefined, {
|
|
752
|
-
staticDir: tmpStaticDir,
|
|
753
814
|
// Presence-only probe — run-prod-app's cast-boundary read only checks
|
|
754
815
|
// truthiness, so a minimal stand-in is enough without pulling in the
|
|
755
816
|
// real SearchAdapter shape.
|
|
756
817
|
extraContext: { searchAdapter: { configure: async () => {}, search: async () => [] } },
|
|
757
|
-
|
|
818
|
+
});
|
|
819
|
+
const token = await handle.entrypoint.jwt.sign({
|
|
820
|
+
id: "schema-probe-user",
|
|
821
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
822
|
+
roles: ["User"],
|
|
758
823
|
});
|
|
759
824
|
|
|
760
|
-
const res = await handle.fetch(
|
|
761
|
-
|
|
762
|
-
const schemaMatch = body.match(/window\.__KUMIKO_SCHEMA__=(\{.*?\});<\/script>/s);
|
|
763
|
-
expect(schemaMatch).not.toBeNull();
|
|
764
|
-
const injectedSchema = JSON.parse(schemaMatch?.[1] ?? "{}");
|
|
765
|
-
// Guards against a regex miss silently degrading to `?? "{}"` — an empty
|
|
766
|
-
// features array would make `.some(...)` below vacuously false too.
|
|
767
|
-
expect(injectedSchema.features.length).toBeGreaterThan(0);
|
|
768
|
-
const anyFeatureMissingAdapter = injectedSchema.features.some(
|
|
769
|
-
(f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true,
|
|
825
|
+
const res = await handle.entrypoint.app.fetch(
|
|
826
|
+
new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
|
|
770
827
|
);
|
|
771
|
-
expect(
|
|
828
|
+
expect(res.status).toBe(200);
|
|
829
|
+
const schema = (await res.json()) as {
|
|
830
|
+
features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
|
|
831
|
+
};
|
|
832
|
+
expect(schema.features.length).toBeGreaterThan(0);
|
|
833
|
+
expect(schema.features.some((f) => f.searchAdapterMissing === true)).toBe(false);
|
|
772
834
|
});
|
|
773
835
|
|
|
774
836
|
test("hostDispatch: redirect-Modus", async () => {
|
|
@@ -983,6 +1045,37 @@ describe("runProdApp", () => {
|
|
|
983
1045
|
});
|
|
984
1046
|
});
|
|
985
1047
|
|
|
1048
|
+
describe("runProdApp — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
|
|
1049
|
+
// Every other test in this file drives handle.fetch()/entrypoint.app.fetch()
|
|
1050
|
+
// directly, which never goes through Bun.serve's real TCP socket — so none
|
|
1051
|
+
// of them can catch a regression where the socket address gets lost before
|
|
1052
|
+
// reaching requestContext.ip (see client-ip.ts: falls back to "unknown"
|
|
1053
|
+
// rather than throwing, so a wiring bug here stays silent otherwise).
|
|
1054
|
+
test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
|
|
1055
|
+
const handle = await boot(undefined, {
|
|
1056
|
+
autoListen: true,
|
|
1057
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
1058
|
+
});
|
|
1059
|
+
const port = handle.server?.port;
|
|
1060
|
+
if (port === undefined) throw new Error("expected handle.server to be listening");
|
|
1061
|
+
|
|
1062
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
|
|
1063
|
+
method: "POST",
|
|
1064
|
+
headers: { "content-type": "application/json" },
|
|
1065
|
+
body: JSON.stringify({ type: "prod-probe:query:ip-probe", payload: {} }),
|
|
1066
|
+
});
|
|
1067
|
+
expect(res.status).toBe(200);
|
|
1068
|
+
const body = (await res.json()) as { data?: { ip?: string } };
|
|
1069
|
+
// Exact loopback notation (bare "127.0.0.1" vs. the IPv4-mapped IPv6
|
|
1070
|
+
// form "::ffff:127.0.0.1") depends on the platform's dual-stack
|
|
1071
|
+
// listener behavior, not on the code under test — matching the
|
|
1072
|
+
// family instead of a fixed literal keeps the assertion about what
|
|
1073
|
+
// this test actually proves: a real socket address reached
|
|
1074
|
+
// requestContext.ip instead of the "unknown" fallback.
|
|
1075
|
+
expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
|
|
1076
|
+
});
|
|
1077
|
+
});
|
|
1078
|
+
|
|
986
1079
|
describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
|
|
987
1080
|
// Regression für den 2026-06-11-Incident: runProdApp baute den
|
|
988
1081
|
// Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
|
|
@@ -1238,6 +1331,13 @@ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
|
|
|
1238
1331
|
const handle = await boot(undefined, {
|
|
1239
1332
|
rateLimit: { global: { limit: 1, windowSeconds: 60 } },
|
|
1240
1333
|
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
1334
|
+
// handle.entrypoint.app.fetch has no real socket, and the client-ip
|
|
1335
|
+
// resolver ignores X-Forwarded-For without a trusted hop — without
|
|
1336
|
+
// this, every call below (and every other untrusted-XFF call this
|
|
1337
|
+
// suite makes) resolves to the same shared "unknown" bucket, so the
|
|
1338
|
+
// probeIp uniqueness below is silently defeated by whichever test
|
|
1339
|
+
// happens to run first.
|
|
1340
|
+
trustedProxyHops: 1,
|
|
1241
1341
|
});
|
|
1242
1342
|
|
|
1243
1343
|
// Unique per test run: the L1 limiter's Redis bucket is keyed only on
|
package/src/bun-serve-options.ts
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
import { DEFAULT_MAX_REQUEST_BYTES } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine/types";
|
|
3
|
+
import {
|
|
4
|
+
readFilesRouteOptions,
|
|
5
|
+
resolveMaxUploadBodyBytes,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
7
|
+
|
|
8
|
+
// Fallback for callers that skip resolveDerivedMaxRequestBodySize below —
|
|
9
|
+
// Bun's own default is 128 MiB, buffered per request before any app-level
|
|
10
|
+
// body-limit middleware runs.
|
|
11
|
+
export const DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
// Keeps Bun's cap above the files route's own derived bodyLimit, so that
|
|
14
|
+
// route's JSON 413 fires before Bun's bare rejection would.
|
|
15
|
+
const BUN_BODY_LIMIT_SAFETY_MARGIN_BYTES = 8 * 1024;
|
|
16
|
+
|
|
17
|
+
// Derives Bun's request-body ceiling from the same registry-configured
|
|
18
|
+
// maxUploadSize/field maxSize the upload route itself enforces.
|
|
19
|
+
export function resolveDerivedMaxRequestBodySize(registry: Registry): number {
|
|
20
|
+
const filesRouteOptions = readFilesRouteOptions(registry);
|
|
21
|
+
return (
|
|
22
|
+
Math.max(
|
|
23
|
+
DEFAULT_MAX_REQUEST_BYTES,
|
|
24
|
+
resolveMaxUploadBodyBytes({ registry, maxUploadSize: filesRouteOptions.maxUploadSize }),
|
|
25
|
+
) + BUN_BODY_LIMIT_SAFETY_MARGIN_BYTES
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
1
29
|
/**
|
|
2
30
|
* Bun.serve-Options für Production.
|
|
3
31
|
*
|
|
@@ -12,11 +40,21 @@
|
|
|
12
40
|
*/
|
|
13
41
|
export function buildBunServeOptions(
|
|
14
42
|
port: number,
|
|
15
|
-
fetchHandler: (req: Request) => Response | Promise<Response>,
|
|
43
|
+
fetchHandler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
|
|
44
|
+
maxRequestBodySize: number = DEFAULT_MAX_REQUEST_BODY_SIZE_BYTES,
|
|
16
45
|
): {
|
|
17
46
|
readonly port: number;
|
|
18
|
-
readonly fetch: (req: Request) => Response | Promise<Response>;
|
|
47
|
+
readonly fetch: (req: Request, server: Bun.Server<unknown>) => Response | Promise<Response>;
|
|
19
48
|
readonly idleTimeout: number;
|
|
49
|
+
readonly maxRequestBodySize: number;
|
|
20
50
|
} {
|
|
21
|
-
|
|
51
|
+
// `server.requestIP(req)` only resolves for the exact Request instance
|
|
52
|
+
// Bun created — extracted here, once, before any downstream req.clone()
|
|
53
|
+
// (tryHonoFirst et al.) can invalidate it.
|
|
54
|
+
return {
|
|
55
|
+
port,
|
|
56
|
+
fetch: (req, server) => fetchHandler(req, server.requestIP(req)?.address),
|
|
57
|
+
idleTimeout: 0,
|
|
58
|
+
maxRequestBodySize,
|
|
59
|
+
};
|
|
22
60
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "runProdApp threads the Bun socket address and a trustedProxyHops option through to the client-IP resolver",
|
|
6
|
+
"detail": "`buildBunServeOptions` now extracts the client socket address once via\n`server.requestIP(req)` at the outermost Bun.serve fetch callback (a\ncloned/rebuilt Request loses that ability) and threads it through\n`tryHonoFirst`/`withSecurityHeaders`/`buildStaticFallback` as Hono's\n`env`. `RunProdAppOptions` gained a top-level `trustedProxyHops` (falls\nback to the deprecated `auth.trustedProxyHops`, then\n`KUMIKO_TRUSTED_PROXY_HOPS`), forwarded into `buildServer` and the\nstatic-fallback's own client-IP resolver.",
|
|
7
|
+
"migration": "Same as the framework entry — set `trustedProxyHops` on `runProdApp` when\ndeployed behind a reverse-proxy/ingress."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.320.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Static fallback no longer injects __KUMIKO_SCHEMA__ into HTML",
|
|
13
|
+
"migration": "`HostDispatchResult.injectSchema` is deprecated and ignored — HTML never\ncarries the schema anymore, in prod or dev. `createKumikoApp` now fetches\nthe schema itself from the authenticated `GET /api/schema` after its\nclientFeature gates (e.g. an auth gate) let rendering through, so a\nsigned-in admin app keeps working without changes. Anything that read\n`window.__KUMIKO_SCHEMA__` directly (custom clients, e2e fixtures) must\nswitch to fetching `/api/schema` instead. An anonymously reachable page\nthat used to render schema-based screens needs `createPublicSurface`,\nwhich never carries a schema. The `@cosmicdrift/kumiko-server-runtime/inject-schema`\nsubpath export is removed."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.320.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "requireEnv is now part of the public package export",
|
|
19
|
+
"detail": "`packages/server-runtime/src/index.ts` now re-exports `requireEnv`\nalongside `runProdApp`, so `import { requireEnv } from\n\"@cosmicdrift/kumiko-server-runtime\"` resolves without reaching into the\ninternal `run-prod-app.ts` module."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.320.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "resolveAuthMail builds locale-aware appUrls and adds a dedicated auth.mail.hmacSecret",
|
|
25
|
+
"detail": "resolveAuthMail (shared by runProdApp and runDevApp) now builds the passwordReset/emailVerification/signup/invite appUrl from a locale-aware AuthPath, so a function-valued auth.mail.paths entry produces a locale-aware appUrl for every flow, symmetric to the appUrl-as-function support each flow's options already had.\nAuthMailOptions also gains an optional hmacSecret: when set, passwordReset/emailVerification tokens sign with it instead of the hmacSecret resolveAuthMail is called with (JWT_SECRET), so JWT_SECRET can rotate without invalidating in-flight reset/verify tokens. hmacSecret is now optional on the passwordReset/emailVerification wrapper options too — an app overriding just appUrl no longer has to also thread its own secret; resolveAuthMail backfills the resolved secret onto that explicit block. An explicitly supplied hmacSecret still wins."
|
|
26
|
+
},
|
|
2
27
|
{
|
|
3
28
|
"version": "0.298.0",
|
|
4
29
|
"type": "breaking",
|
package/src/compose-features.ts
CHANGED
|
@@ -110,8 +110,8 @@ export function composeFeatures(
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** Shape of any run{Prod,Dev}App auth block that can carry a
|
|
113
|
-
* passwordReset/emailVerification config
|
|
114
|
-
*
|
|
113
|
+
* passwordReset/emailVerification config, once resolveAuthMail has resolved
|
|
114
|
+
* the wrapper's optional hmacSecret into the feature API's required one —
|
|
115
115
|
* a structural-typed lookup on the auth-only subset is enough. Lets
|
|
116
116
|
* buildComposeAuthOptions be called with both RunProd- and
|
|
117
117
|
* RunDev-AuthOptions without building the helper twice. */
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,12 @@ import {
|
|
|
2
2
|
AUDIT_FEATURE,
|
|
3
3
|
createEscapeHatchAuditSink,
|
|
4
4
|
} from "@cosmicdrift/kumiko-bundled-features/audit";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type AuthPath,
|
|
7
|
+
type EmailVerificationOptions,
|
|
8
|
+
makeAuthPaths,
|
|
9
|
+
type PasswordResetOptions,
|
|
10
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
6
11
|
import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
7
12
|
import {
|
|
8
13
|
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
@@ -182,18 +187,53 @@ type AuthMailNormalizable = {
|
|
|
182
187
|
readonly invite?: InviteSetup;
|
|
183
188
|
};
|
|
184
189
|
|
|
190
|
+
function buildAuthPathAppUrl(
|
|
191
|
+
baseUrl: string,
|
|
192
|
+
path: AuthPath,
|
|
193
|
+
): string | ((locale: string) => string) {
|
|
194
|
+
return typeof path === "function"
|
|
195
|
+
? (locale: string) => `${baseUrl}${path(locale)}`
|
|
196
|
+
: `${baseUrl}${path}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
185
199
|
// accountUnlock (#1266) deliberately does NOT join this convenience block —
|
|
186
200
|
// unlike reset/verify/signup/invite it's only meaningful paired with
|
|
187
201
|
// `accountLockout` (kumiko-framework#1627), and apps set both explicitly
|
|
188
202
|
// alongside each other (same shape as `passwordReset`), so `mail` alone
|
|
189
203
|
// can't silently expose a new public endpoint an app didn't ask for.
|
|
190
204
|
|
|
205
|
+
function backfillTokenSecrets<T extends AuthMailNormalizable>(auth: T, tokenSecret: string) {
|
|
206
|
+
return {
|
|
207
|
+
...auth,
|
|
208
|
+
...(auth.passwordReset && {
|
|
209
|
+
passwordReset: {
|
|
210
|
+
...auth.passwordReset,
|
|
211
|
+
hmacSecret: auth.passwordReset.hmacSecret ?? tokenSecret,
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
...(auth.emailVerification && {
|
|
215
|
+
emailVerification: {
|
|
216
|
+
...auth.emailVerification,
|
|
217
|
+
hmacSecret: auth.emailVerification.hmacSecret ?? tokenSecret,
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
191
223
|
export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
192
224
|
auth: T,
|
|
193
225
|
hmacSecret: string,
|
|
194
226
|
envSource: Record<string, string | undefined>,
|
|
195
|
-
): T {
|
|
196
|
-
|
|
227
|
+
): T & {
|
|
228
|
+
readonly passwordReset?: PasswordResetOptions;
|
|
229
|
+
readonly emailVerification?: EmailVerificationOptions;
|
|
230
|
+
} {
|
|
231
|
+
// Runs before the mail/SMTP guards below so an app-supplied block without
|
|
232
|
+
// hmacSecret is backfilled even without a `mail` block; an explicit secret still wins.
|
|
233
|
+
const tokenSecret = auth.mail?.hmacSecret ?? hmacSecret;
|
|
234
|
+
const withResolvedSecrets = backfillTokenSecrets(auth, tokenSecret);
|
|
235
|
+
|
|
236
|
+
if (!auth.mail) return withResolvedSecrets;
|
|
197
237
|
// SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
|
|
198
238
|
// blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
|
|
199
239
|
// (channel-email), nicht über diesen Transport — er ist nur der Detektor
|
|
@@ -201,7 +241,7 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
|
201
241
|
if (
|
|
202
242
|
!createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
|
|
203
243
|
) {
|
|
204
|
-
return
|
|
244
|
+
return withResolvedSecrets;
|
|
205
245
|
}
|
|
206
246
|
const paths = makeAuthPaths(auth.mail.paths);
|
|
207
247
|
// appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
|
|
@@ -210,26 +250,26 @@ export function resolveAuthMail<T extends AuthMailNormalizable>(
|
|
|
210
250
|
...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
|
|
211
251
|
};
|
|
212
252
|
return {
|
|
213
|
-
...
|
|
214
|
-
passwordReset:
|
|
215
|
-
hmacSecret,
|
|
216
|
-
appUrl:
|
|
253
|
+
...withResolvedSecrets,
|
|
254
|
+
passwordReset: withResolvedSecrets.passwordReset ?? {
|
|
255
|
+
hmacSecret: tokenSecret,
|
|
256
|
+
appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.resetPassword),
|
|
217
257
|
...mailPresentation,
|
|
218
258
|
},
|
|
219
|
-
emailVerification:
|
|
220
|
-
hmacSecret,
|
|
221
|
-
appUrl:
|
|
259
|
+
emailVerification: withResolvedSecrets.emailVerification ?? {
|
|
260
|
+
hmacSecret: tokenSecret,
|
|
261
|
+
appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.verifyEmail),
|
|
222
262
|
...(auth.mail.emailVerificationMode !== undefined && {
|
|
223
263
|
mode: auth.mail.emailVerificationMode,
|
|
224
264
|
}),
|
|
225
265
|
...mailPresentation,
|
|
226
266
|
},
|
|
227
|
-
signup:
|
|
228
|
-
appUrl:
|
|
267
|
+
signup: withResolvedSecrets.signup ?? {
|
|
268
|
+
appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.signupComplete),
|
|
229
269
|
...mailPresentation,
|
|
230
270
|
},
|
|
231
|
-
invite:
|
|
232
|
-
appUrl:
|
|
271
|
+
invite: withResolvedSecrets.invite ?? {
|
|
272
|
+
appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.inviteAccept),
|
|
233
273
|
...mailPresentation,
|
|
234
274
|
},
|
|
235
275
|
};
|
|
@@ -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
|
|