@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
|
@@ -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({}),
|
|
@@ -347,6 +359,27 @@ describe("runProdApp", () => {
|
|
|
347
359
|
expect(body).toContain('<probe ok="true" />');
|
|
348
360
|
});
|
|
349
361
|
|
|
362
|
+
test("handle.fetch threads the socket address into deps.clientIp, ignoring a spoofed X-Forwarded-For at the default trustedProxyHops (0)", async () => {
|
|
363
|
+
const handle = await boot(undefined, {
|
|
364
|
+
extraRoutes: [
|
|
365
|
+
{
|
|
366
|
+
method: "GET",
|
|
367
|
+
path: "/client-ip-probe",
|
|
368
|
+
entry: "anonymous",
|
|
369
|
+
handler: (c, deps) => c.body(deps.clientIp, 200),
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
const req = () =>
|
|
374
|
+
new Request("http://test/client-ip-probe", { headers: { "x-forwarded-for": "1.2.3.4" } });
|
|
375
|
+
|
|
376
|
+
const withSocket = await handle.fetch(req(), "198.51.100.7");
|
|
377
|
+
expect(await withSocket.text()).toBe("198.51.100.7");
|
|
378
|
+
|
|
379
|
+
const withoutSocket = await handle.fetch(req());
|
|
380
|
+
expect(await withoutSocket.text()).toBe("unknown");
|
|
381
|
+
});
|
|
382
|
+
|
|
350
383
|
test("extraRoutes: entry:signature dispatchSystemWrite schreibt als SystemAdmin des Ziel-Tenants, registry verfügbar", async () => {
|
|
351
384
|
// Wiring for provider webhook routes (billing-foundation
|
|
352
385
|
// createSubscriptionWebhookRoute): the route authenticates via
|
|
@@ -691,9 +724,10 @@ describe("runProdApp", () => {
|
|
|
691
724
|
});
|
|
692
725
|
|
|
693
726
|
test("hostDispatch: per-host html-Datei + Schema-Gating", async () => {
|
|
694
|
-
// Multi-
|
|
695
|
-
//
|
|
696
|
-
//
|
|
727
|
+
// Multi-app deployment: two HTML files for different hosts. Neither
|
|
728
|
+
// ever carries __KUMIKO_SCHEMA__ (kumiko-framework#3314) —
|
|
729
|
+
// createKumikoApp fetches the schema itself from GET /api/schema
|
|
730
|
+
// after the auth gates let it through, regardless of host.
|
|
697
731
|
const tmpStaticDir = await createTempStaticDir({
|
|
698
732
|
"index.html": "<html><body>PUBLIC</body><script src=/client.js></script></html>",
|
|
699
733
|
"admin.html": "<html><body>ADMIN</body><script src=/client.js></script></html>",
|
|
@@ -703,13 +737,12 @@ describe("runProdApp", () => {
|
|
|
703
737
|
staticDir: tmpStaticDir,
|
|
704
738
|
hostDispatch: ({ host }) => {
|
|
705
739
|
if (host.startsWith("admin.")) {
|
|
706
|
-
return { kind: "html", file: "admin.html"
|
|
740
|
+
return { kind: "html", file: "admin.html" };
|
|
707
741
|
}
|
|
708
|
-
return { kind: "html", file: "index.html"
|
|
742
|
+
return { kind: "html", file: "index.html" };
|
|
709
743
|
},
|
|
710
744
|
});
|
|
711
745
|
|
|
712
|
-
// Public host: index.html, KEIN schema-Tag.
|
|
713
746
|
const pubRes = await handle.fetch(
|
|
714
747
|
new Request("http://demo.example.test/", { headers: { host: "demo.example.test" } }),
|
|
715
748
|
);
|
|
@@ -718,57 +751,107 @@ describe("runProdApp", () => {
|
|
|
718
751
|
expect(pubBody).toContain("PUBLIC");
|
|
719
752
|
expect(pubBody).not.toContain("__KUMIKO_SCHEMA__");
|
|
720
753
|
|
|
721
|
-
// Admin host: admin.html MIT schema-Tag.
|
|
722
754
|
const adminRes = await handle.fetch(
|
|
723
755
|
new Request("http://admin.example.test/", { headers: { host: "admin.example.test" } }),
|
|
724
756
|
);
|
|
725
757
|
expect(adminRes.status).toBe(200);
|
|
726
758
|
const adminBody = await adminRes.text();
|
|
727
759
|
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);
|
|
760
|
+
expect(adminBody).not.toContain("__KUMIKO_SCHEMA__");
|
|
744
761
|
});
|
|
745
762
|
|
|
746
|
-
test("
|
|
763
|
+
test("staticDir without hostDispatch: HTML never carries the schema; GET /api/schema needs a signed-in user (kumiko-framework#3314)", async () => {
|
|
764
|
+
// A real POST /api/auth/login round-trip needs the tenant/user bundled
|
|
765
|
+
// features' own projection tables (e.g. read_tenants), which this
|
|
766
|
+
// suite's boot() doesn't migrate (migrations: false, only the app's own
|
|
767
|
+
// entity tables are auto-created — see unsafeEnsureEntityTable above).
|
|
768
|
+
// Signing a token straight through the entrypoint's JwtHelper exercises
|
|
769
|
+
// the exact same cookie transport auth-middleware reads, without
|
|
770
|
+
// pulling in that migration surface just for this schema-fetch test.
|
|
747
771
|
const tmpStaticDir = await createTempStaticDir({
|
|
748
|
-
"index.html": "<html><body>
|
|
772
|
+
"index.html": "<html><body>SHELL</body><script src=/client.js></script></html>",
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const handle = await boot(undefined, { staticDir: tmpStaticDir });
|
|
776
|
+
|
|
777
|
+
const rootRes = await handle.fetch(new Request("http://test/"));
|
|
778
|
+
expect(rootRes.status).toBe(200);
|
|
779
|
+
expect(await rootRes.text()).not.toContain("__KUMIKO_SCHEMA__");
|
|
780
|
+
|
|
781
|
+
const spaRes = await handle.fetch(new Request("http://test/some/spa/route"));
|
|
782
|
+
expect(spaRes.status).toBe(200);
|
|
783
|
+
expect(await spaRes.text()).not.toContain("__KUMIKO_SCHEMA__");
|
|
784
|
+
|
|
785
|
+
const anonSchemaRes = await handle.fetch(new Request("http://test/api/schema"));
|
|
786
|
+
expect(anonSchemaRes.status).toBe(401);
|
|
787
|
+
|
|
788
|
+
const token = await handle.entrypoint.jwt.sign({
|
|
789
|
+
id: "schema-fetch-user",
|
|
790
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
791
|
+
roles: ["User"],
|
|
749
792
|
});
|
|
793
|
+
const schemaRes = await handle.fetch(
|
|
794
|
+
new Request("http://test/api/schema", { headers: { Cookie: `kumiko_auth=${token}` } }),
|
|
795
|
+
);
|
|
796
|
+
expect(schemaRes.status).toBe(200);
|
|
797
|
+
const schema = (await schemaRes.json()) as { features: readonly unknown[] };
|
|
798
|
+
expect(schema.features.length).toBeGreaterThan(0);
|
|
799
|
+
});
|
|
750
800
|
|
|
801
|
+
test("anonymousAccess wired → GET /api/schema still 401s for the synthesised anonymous user", async () => {
|
|
802
|
+
const handle = await boot(undefined, {
|
|
803
|
+
anonymousAccess: {
|
|
804
|
+
defaultTenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const res = await handle.entrypoint.app.fetch(new Request("http://test/api/schema"));
|
|
809
|
+
expect(res.status).toBe(401);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("extraContext.searchAdapter absent → GET /api/schema marks every feature searchAdapterMissing (#2062)", async () => {
|
|
813
|
+
const handle = await boot();
|
|
814
|
+
const token = await handle.entrypoint.jwt.sign({
|
|
815
|
+
id: "schema-probe-user",
|
|
816
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
817
|
+
roles: ["User"],
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
const res = await handle.entrypoint.app.fetch(
|
|
821
|
+
new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
|
|
822
|
+
);
|
|
823
|
+
expect(res.status).toBe(200);
|
|
824
|
+
const schema = (await res.json()) as {
|
|
825
|
+
features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
|
|
826
|
+
};
|
|
827
|
+
// Guards against a vacuous pass — an empty features array would make
|
|
828
|
+
// `.every(...)` below vacuously true too.
|
|
829
|
+
expect(schema.features.length).toBeGreaterThan(0);
|
|
830
|
+
expect(schema.features.every((f) => f.searchAdapterMissing === true)).toBe(true);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("extraContext.searchAdapter wired → GET /api/schema omits searchAdapterMissing (#2062)", async () => {
|
|
751
834
|
const handle = await boot(undefined, {
|
|
752
|
-
staticDir: tmpStaticDir,
|
|
753
835
|
// Presence-only probe — run-prod-app's cast-boundary read only checks
|
|
754
836
|
// truthiness, so a minimal stand-in is enough without pulling in the
|
|
755
837
|
// real SearchAdapter shape.
|
|
756
838
|
extraContext: { searchAdapter: { configure: async () => {}, search: async () => [] } },
|
|
757
|
-
|
|
839
|
+
});
|
|
840
|
+
const token = await handle.entrypoint.jwt.sign({
|
|
841
|
+
id: "schema-probe-user",
|
|
842
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
843
|
+
roles: ["User"],
|
|
758
844
|
});
|
|
759
845
|
|
|
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,
|
|
846
|
+
const res = await handle.entrypoint.app.fetch(
|
|
847
|
+
new Request("http://test/api/schema", { headers: { Authorization: `Bearer ${token}` } }),
|
|
770
848
|
);
|
|
771
|
-
expect(
|
|
849
|
+
expect(res.status).toBe(200);
|
|
850
|
+
const schema = (await res.json()) as {
|
|
851
|
+
features: ReadonlyArray<{ searchAdapterMissing?: boolean }>;
|
|
852
|
+
};
|
|
853
|
+
expect(schema.features.length).toBeGreaterThan(0);
|
|
854
|
+
expect(schema.features.some((f) => f.searchAdapterMissing === true)).toBe(false);
|
|
772
855
|
});
|
|
773
856
|
|
|
774
857
|
test("hostDispatch: redirect-Modus", async () => {
|
|
@@ -983,6 +1066,37 @@ describe("runProdApp", () => {
|
|
|
983
1066
|
});
|
|
984
1067
|
});
|
|
985
1068
|
|
|
1069
|
+
describe("runProdApp — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
|
|
1070
|
+
// Every other test in this file drives handle.fetch()/entrypoint.app.fetch()
|
|
1071
|
+
// directly, which never goes through Bun.serve's real TCP socket — so none
|
|
1072
|
+
// of them can catch a regression where the socket address gets lost before
|
|
1073
|
+
// reaching requestContext.ip (see client-ip.ts: falls back to "unknown"
|
|
1074
|
+
// rather than throwing, so a wiring bug here stays silent otherwise).
|
|
1075
|
+
test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
|
|
1076
|
+
const handle = await boot(undefined, {
|
|
1077
|
+
autoListen: true,
|
|
1078
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
1079
|
+
});
|
|
1080
|
+
const port = handle.server?.port;
|
|
1081
|
+
if (port === undefined) throw new Error("expected handle.server to be listening");
|
|
1082
|
+
|
|
1083
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
|
|
1084
|
+
method: "POST",
|
|
1085
|
+
headers: { "content-type": "application/json" },
|
|
1086
|
+
body: JSON.stringify({ type: "prod-probe:query:ip-probe", payload: {} }),
|
|
1087
|
+
});
|
|
1088
|
+
expect(res.status).toBe(200);
|
|
1089
|
+
const body = (await res.json()) as { data?: { ip?: string } };
|
|
1090
|
+
// Exact loopback notation (bare "127.0.0.1" vs. the IPv4-mapped IPv6
|
|
1091
|
+
// form "::ffff:127.0.0.1") depends on the platform's dual-stack
|
|
1092
|
+
// listener behavior, not on the code under test — matching the
|
|
1093
|
+
// family instead of a fixed literal keeps the assertion about what
|
|
1094
|
+
// this test actually proves: a real socket address reached
|
|
1095
|
+
// requestContext.ip instead of the "unknown" fallback.
|
|
1096
|
+
expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
|
|
986
1100
|
describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
|
|
987
1101
|
// Regression für den 2026-06-11-Incident: runProdApp baute den
|
|
988
1102
|
// Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
|
|
@@ -1238,6 +1352,13 @@ describe("runProdApp job-lane wiring (runSingleInstance)", () => {
|
|
|
1238
1352
|
const handle = await boot(undefined, {
|
|
1239
1353
|
rateLimit: { global: { limit: 1, windowSeconds: 60 } },
|
|
1240
1354
|
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
1355
|
+
// handle.entrypoint.app.fetch has no real socket, and the client-ip
|
|
1356
|
+
// resolver ignores X-Forwarded-For without a trusted hop — without
|
|
1357
|
+
// this, every call below (and every other untrusted-XFF call this
|
|
1358
|
+
// suite makes) resolves to the same shared "unknown" bucket, so the
|
|
1359
|
+
// probeIp uniqueness below is silently defeated by whichever test
|
|
1360
|
+
// happens to run first.
|
|
1361
|
+
trustedProxyHops: 1,
|
|
1241
1362
|
});
|
|
1242
1363
|
|
|
1243
1364
|
// 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,43 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.321.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "runProdApp's KUMIKO_TRUSTED_PROXY_HOPS parsing now shares framework's parseTrustedProxyHopsEnv",
|
|
6
|
+
"detail": "The inline digits-only + non-negative-integer validation moved out of\n`run-prod-app.ts` into `parseTrustedProxyHopsEnv`\n(`@cosmicdrift/kumiko-framework/api`); same validation and error message\nas before, no behavioral change.",
|
|
7
|
+
"migration": "No action needed."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.321.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "ProdAppHandle.fetch's type now declares its optional socket-address parameter",
|
|
13
|
+
"detail": "`fetch: (req: Request, socketAddress?: string) => Promise<Response> |\nResponse`. The doc comment now explains that an app with `autoListen:\nfalse` running its own `Bun.serve` must pass\n`server.requestIP(req)?.address`, extracted at the outermost Bun fetch\ncallback — `requestIP()` only resolves for Bun's original Request\ninstance, not a cloned/rebuilt one. `RunProdAppAuthOptions\n.trustedProxyHops`'s doc comment also dropped the stale \"pre-#1539\nspoofable default\" wording; 0 hops is the safe socket-only default.",
|
|
14
|
+
"migration": "No action needed for the default `autoListen: true` boot. Apps with\n`autoListen: false` can now type-check passing the socket address to\n`handle.fetch`; passing nothing keeps the previous (unresolved) behavior."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.320.0",
|
|
18
|
+
"type": "breaking",
|
|
19
|
+
"title": "runProdApp threads the Bun socket address and a trustedProxyHops option through to the client-IP resolver",
|
|
20
|
+
"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.",
|
|
21
|
+
"migration": "Same as the framework entry — set `trustedProxyHops` on `runProdApp` when\ndeployed behind a reverse-proxy/ingress."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"version": "0.320.0",
|
|
25
|
+
"type": "breaking",
|
|
26
|
+
"title": "Static fallback no longer injects __KUMIKO_SCHEMA__ into HTML",
|
|
27
|
+
"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."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "0.320.0",
|
|
31
|
+
"type": "improvement",
|
|
32
|
+
"title": "requireEnv is now part of the public package export",
|
|
33
|
+
"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."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "0.320.0",
|
|
37
|
+
"type": "improvement",
|
|
38
|
+
"title": "resolveAuthMail builds locale-aware appUrls and adds a dedicated auth.mail.hmacSecret",
|
|
39
|
+
"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."
|
|
40
|
+
},
|
|
2
41
|
{
|
|
3
42
|
"version": "0.298.0",
|
|
4
43
|
"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
|
};
|