@cosmicdrift/kumiko-framework 0.318.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.
Files changed (46) hide show
  1. package/package.json +8 -4
  2. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  4. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  5. package/src/api/__tests__/client-ip.test.ts +139 -0
  6. package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
  7. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  8. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  9. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  10. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  11. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  12. package/src/api/api-constants.ts +2 -0
  13. package/src/api/auth-routes.ts +92 -95
  14. package/src/api/client-ip.ts +91 -0
  15. package/src/api/index.ts +15 -0
  16. package/src/api/pat-route-guard.ts +51 -0
  17. package/src/api/post-auth-landing.ts +105 -0
  18. package/src/api/request-context.ts +4 -2
  19. package/src/api/request-id-middleware.ts +44 -14
  20. package/src/api/server.ts +128 -40
  21. package/src/changes.json +73 -0
  22. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  23. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  24. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  25. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  26. package/src/engine/boot-validator/pii-retention.ts +18 -2
  27. package/src/engine/boot-validator/screens.ts +75 -18
  28. package/src/engine/index.ts +1 -0
  29. package/src/engine/project-app-schema-for-roles.ts +950 -0
  30. package/src/engine/types/handlers.ts +1 -1
  31. package/src/engine/types/index.ts +5 -1
  32. package/src/entrypoint/index.ts +14 -1
  33. package/src/files/__tests__/files.integration.test.ts +105 -1
  34. package/src/files/file-routes.ts +54 -2
  35. package/src/files/index.ts +1 -1
  36. package/src/http/index.ts +10 -2
  37. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  38. package/src/jobs/job-runner.ts +6 -1
  39. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  40. package/src/rate-limit/middleware.ts +32 -8
  41. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  42. package/src/stack/push-entity-projection-tables.ts +52 -7
  43. package/src/stack/table-helpers.ts +17 -9
  44. package/src/stack/test-stack.ts +28 -1
  45. package/src/testing/real-providers.ts +2 -0
  46. package/src/ui-types/index.ts +1 -1
@@ -0,0 +1,157 @@
1
+ // kumiko-framework#3314: GET /api/schema is the only way a client gets the
2
+ // AppSchema now — HTML never carries it. anonymousAccess is deliberately
3
+ // wired here (like http-route-entry.integration.test.ts): without it, a
4
+ // wrongly-mounted route (jwtGuard skipping instead of rejecting) would
5
+ // still 401 on a missing token and mask the "anonymous user reaches the
6
+ // schema" bug the anonymous-401 test exists to catch.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { createEntity, defineFeature } from "../../engine";
10
+ import type { TenantId } from "../../engine/types/identifiers";
11
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
12
+ import { Routes } from "../api-constants";
13
+ import { AUTH_COOKIE_NAME } from "../auth-middleware";
14
+
15
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
16
+ const SCHEMA_PATH = `/api${Routes.schema}`;
17
+
18
+ const schemaRouteFeature = defineFeature("schema-route-fixture", (r) => {
19
+ r.entity("widget", createEntity({ fields: {} }));
20
+ r.screen({
21
+ id: "widget-list",
22
+ type: "entityList",
23
+ entity: "widget",
24
+ columns: ["id"],
25
+ });
26
+ r.screen({
27
+ id: "admin-only",
28
+ type: "entityList",
29
+ entity: "widget",
30
+ columns: ["id"],
31
+ access: { roles: ["Admin"] },
32
+ });
33
+ r.nav({ id: "widget-list", label: "Widgets", screen: "widget-list" });
34
+ r.nav({ id: "admin-only", label: "Admin Only", screen: "admin-only" });
35
+ });
36
+
37
+ let stack: TestStack;
38
+
39
+ beforeAll(async () => {
40
+ stack = await setupTestStack({
41
+ features: [schemaRouteFeature],
42
+ anonymousAccess: { defaultTenantId: TENANT_ID },
43
+ });
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await stack.cleanup();
48
+ });
49
+
50
+ describe("GET /api/schema", () => {
51
+ test("anonymous → 401, no schema leaked", async () => {
52
+ const res = await stack.app.request(SCHEMA_PATH);
53
+ expect(res.status).toBe(401);
54
+ const body = (await res.json()) as { error: { code: string } };
55
+ expect(body.error.code).toBe("unauthenticated");
56
+ });
57
+
58
+ test("signed-in user via Bearer token → 200 with the registry's schema, strong ETag, private/no-cache", async () => {
59
+ const token = await stack.jwt.sign(TestUsers.user);
60
+ const res = await stack.app.request(SCHEMA_PATH, {
61
+ headers: { Authorization: `Bearer ${token}` },
62
+ });
63
+ expect(res.status).toBe(200);
64
+ expect(res.headers.get("content-type")).toContain("application/json");
65
+ const cacheControl = res.headers.get("cache-control") ?? "";
66
+ expect(cacheControl).toContain("private");
67
+ expect(cacheControl).toContain("no-cache");
68
+ expect(cacheControl).not.toContain("public");
69
+ expect(cacheControl).not.toContain("no-store");
70
+ const etag = res.headers.get("etag");
71
+ expect(etag).toMatch(/^"/);
72
+ expect(etag).not.toMatch(/^W\//);
73
+ const body = (await res.json()) as { features: Array<{ featureName: string }> };
74
+ expect(body.features.length).toBeGreaterThan(0);
75
+ expect(body.features.some((f) => f.featureName === "schema-route-fixture")).toBe(true);
76
+ });
77
+
78
+ test("signed-in user via kumiko_auth cookie → 200", async () => {
79
+ const token = await stack.jwt.sign(TestUsers.user);
80
+ const res = await stack.app.request(SCHEMA_PATH, {
81
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
82
+ });
83
+ expect(res.status).toBe(200);
84
+ const body = (await res.json()) as { features: Array<{ featureName: string }> };
85
+ expect(body.features.length).toBeGreaterThan(0);
86
+ });
87
+
88
+ test("matching If-None-Match → 304 with no body, ETag kept", async () => {
89
+ const token = await stack.jwt.sign(TestUsers.user);
90
+ const first = await stack.app.request(SCHEMA_PATH, {
91
+ headers: { Authorization: `Bearer ${token}` },
92
+ });
93
+ const etag = first.headers.get("etag");
94
+ expect(etag).toBeTruthy();
95
+
96
+ const second = await stack.app.request(SCHEMA_PATH, {
97
+ headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag ?? "" },
98
+ });
99
+ expect(second.status).toBe(304);
100
+ expect(await second.text()).toBe("");
101
+ expect(second.headers.get("etag")).toBe(etag);
102
+ });
103
+
104
+ test("anonymous with a valid If-None-Match → still 401, not 304", async () => {
105
+ const token = await stack.jwt.sign(TestUsers.user);
106
+ const authed = await stack.app.request(SCHEMA_PATH, {
107
+ headers: { Authorization: `Bearer ${token}` },
108
+ });
109
+ const etag = authed.headers.get("etag");
110
+ expect(etag).toBeTruthy();
111
+
112
+ const res = await stack.app.request(SCHEMA_PATH, {
113
+ headers: { "If-None-Match": etag ?? "" },
114
+ });
115
+ expect(res.status).toBe(401);
116
+ });
117
+
118
+ test("role projection: Admin sees the role-gated screen, User does not", async () => {
119
+ const adminToken = await stack.jwt.sign(TestUsers.admin);
120
+ const adminRes = await stack.app.request(SCHEMA_PATH, {
121
+ headers: { Authorization: `Bearer ${adminToken}` },
122
+ });
123
+ const adminBody = (await adminRes.json()) as {
124
+ features: Array<{ featureName: string; screens: Array<{ id: string }> }>;
125
+ };
126
+ const adminFixture = adminBody.features.find((f) => f.featureName === "schema-route-fixture");
127
+ expect(adminFixture?.screens.map((s) => s.id).sort()).toEqual(["admin-only", "widget-list"]);
128
+
129
+ const userToken = await stack.jwt.sign(TestUsers.user);
130
+ const userRes = await stack.app.request(SCHEMA_PATH, {
131
+ headers: { Authorization: `Bearer ${userToken}` },
132
+ });
133
+ const userBody = (await userRes.json()) as {
134
+ features: Array<{ featureName: string; screens: Array<{ id: string }> }>;
135
+ };
136
+ const userFixture = userBody.features.find((f) => f.featureName === "schema-route-fixture");
137
+ expect(userFixture?.screens.map((s) => s.id)).toEqual(["widget-list"]);
138
+
139
+ // Different bodies must carry different (strong) ETags.
140
+ expect(adminRes.headers.get("etag")).not.toBe(userRes.headers.get("etag"));
141
+ });
142
+
143
+ test("role projection: one role's ETag sent by a different role never 304s", async () => {
144
+ const adminToken = await stack.jwt.sign(TestUsers.admin);
145
+ const adminRes = await stack.app.request(SCHEMA_PATH, {
146
+ headers: { Authorization: `Bearer ${adminToken}` },
147
+ });
148
+ const adminEtag = adminRes.headers.get("etag");
149
+ expect(adminEtag).toBeTruthy();
150
+
151
+ const userToken = await stack.jwt.sign(TestUsers.user);
152
+ const userRes = await stack.app.request(SCHEMA_PATH, {
153
+ headers: { Authorization: `Bearer ${userToken}`, "If-None-Match": adminEtag ?? "" },
154
+ });
155
+ expect(userRes.status).toBe(200);
156
+ });
157
+ });
@@ -36,6 +36,7 @@ export const Routes = {
36
36
  authInviteSignupComplete: "/auth/invite-signup-complete",
37
37
  authInviteInfo: "/auth/invite-info",
38
38
  files: "/files",
39
+ schema: "/schema",
39
40
  } as const;
40
41
 
41
42
  // Routes that must be reachable WITHOUT a valid JWT.
@@ -87,6 +88,7 @@ export const NON_PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
87
88
  // invite-accept requires a JWT (Branch 1, see PUBLIC_API_PATHS above).
88
89
  `/api${Routes.authInviteAccept}`,
89
90
  `/api${Routes.files}`,
91
+ `/api${Routes.schema}`,
90
92
  ]);
91
93
 
92
94
  // Opt-out from the default request-body-size cap (registerBodyLimit applies
@@ -26,7 +26,14 @@ import {
26
26
  getUser,
27
27
  type TokenVerifier,
28
28
  } from "./auth-middleware";
29
+ import {
30
+ type ClientIpResolver,
31
+ clientIpSourceFromHonoContext,
32
+ createClientIpResolver,
33
+ } from "./client-ip";
29
34
  import type { JwtHelper } from "./jwt";
35
+ import type { PostAuthLandingArgs, PostAuthLandingResolver } from "./post-auth-landing";
36
+ import { resolvePostAuthLandingPath } from "./post-auth-landing";
30
37
  import { generateToken } from "./tokens";
31
38
 
32
39
  // Resolves the Secure cookie flag. Locked off in dev/test so Playwright
@@ -441,15 +448,26 @@ export type AuthRoutesConfig = {
441
448
  // `x-forwarded-for` (e.g. nginx `$proxy_add_x_forwarded_for`). Used to
442
449
  // derive the client IP for every auth rate-limiter (login, mfa-verify,
443
450
  // preauth-enable-start, preauth-confirm) and for requestMeta's session
444
- // IP — kumiko-framework#1539. Default 0 = legacy behavior, trust the
445
- // first XFF entry (or x-real-ip) unconditionally; spoofable by design,
446
- // kept as the default so unconfigured deployments don't regress into a
447
- // shared "unknown" bucket (mfa-verify/preauth-confirm key on bare IP, no
448
- // email composite — collapsing everyone into one bucket is a DoS worse
449
- // than the spoofing hole). Set this to your real proxy hop count (1 for
450
- // a single ingress/reverse-proxy, 2 for edge-LB + ingress, etc.) to close
451
- // the hole; see clientIpOf's doc comment for the extraction algorithm.
451
+ // IP via the shared resolveClientIp (see client-ip.ts for the extraction
452
+ // algorithm). Deprecated in favor of the
453
+ // top-level `trustedProxyHops` on ServerOptions/RunProdAppOptions/
454
+ // RunDevAppOptions, which wins when both are set — kept as a fallback so
455
+ // existing consumers that only set this don't regress. Default 0 = trust
456
+ // no proxy header, only the socket address (or "unknown") counts. Set
457
+ // this to your real proxy hop count (1 for a single ingress/reverse-
458
+ // proxy, 2 for edge-LB + ingress, etc.) when deployed behind a proxy.
452
459
  trustedProxyHops?: number;
460
+ // Server-computed post-auth redirect, consulted at login, mfa-verify,
461
+ // mfa-preauth-confirm, signup-confirm, and all three invite-accept branches.
462
+ // Result lands in the response as `landingPath`; an invalid path or a
463
+ // throwing resolver just omits the field (see post-auth-landing.ts).
464
+ postAuthLanding?: PostAuthLandingResolver;
465
+ // Shared resolver instance from buildServer — takes precedence over
466
+ // `trustedProxyHops` so the whole server shares exactly one resolver
467
+ // instance (and warn-once flag) instead of building a private one here.
468
+ // Only standalone callers of createAuthRoutes
469
+ // (no buildServer) should rely on `trustedProxyHops` instead.
470
+ resolver?: ClientIpResolver;
453
471
  };
454
472
 
455
473
  export type PasswordResetConfig = {
@@ -504,69 +522,6 @@ export type SignupConfig = {
504
522
  confirmHandler: string;
505
523
  };
506
524
 
507
- // Derives the caller IP from proxy headers, single source for the
508
- // rate-limiter keys below and requestMeta. kumiko-framework#1523/#1522/#1539:
509
- // `x-forwarded-for` is attacker-controlled unless we know how many trusted
510
- // proxy hops sit between the client and this process — `trustedProxyHops`
511
- // (AuthRoutesConfig) is that count.
512
- //
513
- // hops === 0 (default): legacy behavior — trust the first XFF entry (or
514
- // x-real-ip) at face value. Kept as the default so unconfigured
515
- // deployments don't regress: collapsing everyone into a single "unknown"
516
- // bucket would turn mfa-verify/preauth-confirm's pure-IP-keyed limiter
517
- // into a one-request-locks-out-everyone DoS, which is worse than the
518
- // spoofing hole this issue is about. Apps behind a proxy MUST set this to
519
- // close the hole — see AuthRoutesConfig.trustedProxyHops.
520
- //
521
- // hops >= 1: each trusted proxy appends (not overwrites) its peer address
522
- // to XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the last `hops`
523
- // entries are proxy-supplied and everything before them — including the
524
- // real client — sits at `entries[length - hops]`. Anything the client
525
- // itself prepended lands further left and is ignored. If the header has
526
- // fewer entries than `hops`, the proxy chain is shorter than configured
527
- // (misconfiguration or a bypassed hop) — fall back to x-real-ip when present,
528
- // else "unknown". x-real-ip has no hop-count semantics (so it isn't used for
529
- // hop math), but a shared "unknown" bucket is worse than trusting the
530
- // proxy-set header when XFF is absent (common nginx X-Real-IP-only setups).
531
- //
532
- // Callers that need a hard boundary regardless of this config
533
- // (preauth-enable-start) additionally key on something the caller can't
534
- // freely choose (see preauthTokenKeyOf below).
535
- let warnedUnknownClientIp = false;
536
- function clientIpOf(
537
- c: { req: { header(name: string): string | undefined } },
538
- trustedProxyHops = 0,
539
- ): string {
540
- if (trustedProxyHops <= 0) {
541
- return (
542
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
543
- c.req.header("x-real-ip") ??
544
- "unknown"
545
- );
546
- }
547
- const entries = (c.req.header("x-forwarded-for") ?? "")
548
- .split(",")
549
- .map((entry) => entry.trim())
550
- .filter((entry) => entry.length > 0);
551
- if (entries.length < trustedProxyHops) {
552
- // nginx often sets only X-Real-IP (no XFF). Prefer that over a shared
553
- // "unknown" rate-limit bucket (fw#1555) — hop-count math still doesn't
554
- // apply to X-Real-IP, but a shared bucket is worse than trusting the
555
- // proxy-set header when the XFF chain is shorter than configured.
556
- const realIp = c.req.header("x-real-ip")?.trim();
557
- if (realIp) return realIp;
558
- if (!warnedUnknownClientIp) {
559
- warnedUnknownClientIp = true;
560
- console.warn(
561
- "[kumiko] trustedProxyHops>=1 but XFF chain too short and no x-real-ip — " +
562
- 'all such clients share the "unknown" rate-limit bucket. Check proxy headers.',
563
- );
564
- }
565
- return "unknown";
566
- }
567
- return entries[entries.length - trustedProxyHops] ?? "unknown";
568
- }
569
-
570
525
  // Second rate-limit axis for preauth-enable-start: unlike the IP, a
571
526
  // preauthSetupToken isn't freely choosable by the attacker (it's minted by
572
527
  // login.write.ts), so hashing it into a bucket key still caps the replay
@@ -575,15 +530,12 @@ function preauthTokenKeyOf(token: string): string {
575
530
  return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
576
531
  }
577
532
 
578
- // Extract `ip` and `user-agent` for the sessionCreator.
579
- // Hono's `c.req.header(...)` returns undefined for missing headers; we coerce
580
- // them to "unknown" rather than throwing because auth-routes are a public
581
- // surface and we don't want header-sniffing bugs to break login.
582
- function requestMeta(
583
- c: { req: { header(name: string): string | undefined } },
584
- trustedProxyHops = 0,
585
- ): SessionMetadata {
586
- const ip = clientIpOf(c, trustedProxyHops);
533
+ // Extract `ip` and `user-agent` for the sessionCreator. Client-IP resolution
534
+ // (trustedProxyHops-aware) is the shared resolveClientIp (see client-ip.ts)
535
+ // — createAuthRoutes builds one resolver instance below and
536
+ // closes over it here and in getClientIp.
537
+ function requestMeta(c: Context, resolver: ClientIpResolver): SessionMetadata {
538
+ const ip = resolver.resolve(clientIpSourceFromHonoContext(c));
587
539
  const userAgent = c.req.header("user-agent") ?? "unknown";
588
540
  return { ip, userAgent };
589
541
  }
@@ -714,20 +666,14 @@ export function createAuthRoutes(
714
666
  const cookieSameSite = config.cookieSameSite ?? "lax";
715
667
  const cookieDomain = config.cookieDomain;
716
668
  // Single hop-count-aware IP getter for every rate-limit call site below —
717
- // see AuthRoutesConfig.trustedProxyHops / clientIpOf's doc comment. Fail
718
- // loud on a non-finite/negative value rather than letting it silently
719
- // reach clientIpOf, where NaN/negative behaves like "chain too short"
720
- // and collapses every request into the shared "unknown" bucket.
721
- if (
722
- config.trustedProxyHops !== undefined &&
723
- (!Number.isInteger(config.trustedProxyHops) || config.trustedProxyHops < 0)
724
- ) {
725
- throw new Error(
726
- `createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
727
- );
728
- }
729
- const trustedProxyHops = config.trustedProxyHops ?? 0;
730
- const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
669
+ // see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
670
+ // doc comment. createClientIpResolver fails loud on a non-finite/negative
671
+ // value rather than letting it silently collapse every request into the
672
+ // shared "unknown" bucket.
673
+ const clientIpResolver =
674
+ config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
675
+ const getClientIp = (c: Context): string =>
676
+ clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
731
677
 
732
678
  // Shared tail of every route that ends a request logged-in: create the
733
679
  // session record (if wired), sign the JWT, set the auth+csrf cookies. Was
@@ -737,7 +683,7 @@ export function createAuthRoutes(
737
683
  async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
738
684
  let sessionForJwt = session;
739
685
  if (config.sessionCreator) {
740
- const sid = await config.sessionCreator(session, requestMeta(c, trustedProxyHops));
686
+ const sid = await config.sessionCreator(session, requestMeta(c, clientIpResolver));
741
687
  sessionForJwt = { ...session, sid };
742
688
  }
743
689
  const token = await jwt.sign(sessionForJwt);
@@ -752,6 +698,13 @@ export function createAuthRoutes(
752
698
  return token;
753
699
  }
754
700
 
701
+ // Spreadable landingPath fragment for the 7 auth-response call sites below
702
+ // — absent (not `landingPath: undefined`) when unwired/invalid/throwing.
703
+ function landingPathFragment(args: PostAuthLandingArgs): { readonly landingPath?: string } {
704
+ const landingPath = resolvePostAuthLandingPath(config.postAuthLanding, args);
705
+ return landingPath === undefined ? {} : { landingPath };
706
+ }
707
+
755
708
  // POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
756
709
  // The configured login handler authenticates and returns a SessionUser;
757
710
  // the route signs the JWT and hands it back to the client.
@@ -836,6 +789,11 @@ export function createAuthRoutes(
836
789
  // that references it can be handed out, otherwise a fast client could
837
790
  // arrive at an auth-middleware check before the insert commits.
838
791
  const token = await mintSessionAndRespond(c, data.session);
792
+ const landingPath = landingPathFragment({
793
+ flow: "login",
794
+ roles: data.session.roles,
795
+ tenantId: data.session.tenantId,
796
+ });
839
797
 
840
798
  if (rateLimiter) {
841
799
  await rateLimiter.reset(rateLimitKey);
@@ -845,6 +803,7 @@ export function createAuthRoutes(
845
803
  isSuccess: true,
846
804
  token,
847
805
  user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
806
+ ...landingPath,
848
807
  });
849
808
  });
850
809
  }
@@ -907,6 +866,11 @@ export function createAuthRoutes(
907
866
  const data = result.data as { kind: "mfa-verify-success"; session: SessionUser };
908
867
 
909
868
  const token = await mintSessionAndRespond(c, data.session);
869
+ const landingPath = landingPathFragment({
870
+ flow: "login",
871
+ roles: data.session.roles,
872
+ tenantId: data.session.tenantId,
873
+ });
910
874
 
911
875
  if (rateLimiter) {
912
876
  await rateLimiter.reset(clientIp);
@@ -916,6 +880,7 @@ export function createAuthRoutes(
916
880
  isSuccess: true,
917
881
  token,
918
882
  user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
883
+ ...landingPath,
919
884
  });
920
885
  });
921
886
  }
@@ -1054,6 +1019,11 @@ export function createAuthRoutes(
1054
1019
  const data = result.data as { kind: "mfa-preauth-confirm-success"; session: SessionUser };
1055
1020
 
1056
1021
  const token = await mintSessionAndRespond(c, data.session);
1022
+ const landingPath = landingPathFragment({
1023
+ flow: "login",
1024
+ roles: data.session.roles,
1025
+ tenantId: data.session.tenantId,
1026
+ });
1057
1027
 
1058
1028
  if (rateLimiter) {
1059
1029
  await rateLimiter.reset(clientIp);
@@ -1063,6 +1033,7 @@ export function createAuthRoutes(
1063
1033
  isSuccess: true,
1064
1034
  token,
1065
1035
  user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
1036
+ ...landingPath,
1066
1037
  });
1067
1038
  });
1068
1039
  }
@@ -1169,6 +1140,13 @@ export function createAuthRoutes(
1169
1140
 
1170
1141
  // Session creation + JWT sign + cookies — see mintSessionAndRespond.
1171
1142
  const token = await mintSessionAndRespond(c, data.session);
1143
+ const landingPath = landingPathFragment({
1144
+ flow: "signup",
1145
+ roles: data.session.roles,
1146
+ tenantId: data.session.tenantId,
1147
+ tenantKey: data.tenantKey,
1148
+ ...(data.handover !== undefined && { handover: data.handover }),
1149
+ });
1172
1150
 
1173
1151
  return c.json({
1174
1152
  isSuccess: true,
@@ -1189,6 +1167,7 @@ export function createAuthRoutes(
1189
1167
  // absent, not null, so a client that doesn't know the field sees
1190
1168
  // nothing unusual.
1191
1169
  ...(data.handover !== undefined && { handover: data.handover }),
1170
+ ...landingPath,
1192
1171
  });
1193
1172
  });
1194
1173
  }
@@ -1222,11 +1201,17 @@ export function createAuthRoutes(
1222
1201
  role: string;
1223
1202
  alreadyMember: boolean;
1224
1203
  };
1204
+ const landingPath = landingPathFragment({
1205
+ flow: "invite",
1206
+ roles: [data.role],
1207
+ tenantId: data.tenantId,
1208
+ });
1225
1209
  return c.json({
1226
1210
  isSuccess: true,
1227
1211
  tenantId: data.tenantId,
1228
1212
  role: data.role,
1229
1213
  alreadyMember: data.alreadyMember,
1214
+ ...landingPath,
1230
1215
  });
1231
1216
  });
1232
1217
 
@@ -1269,6 +1254,11 @@ export function createAuthRoutes(
1269
1254
  }
1270
1255
 
1271
1256
  const token = await mintSessionAndRespond(c, data.session);
1257
+ const landingPath = landingPathFragment({
1258
+ flow: "invite",
1259
+ roles: data.session.roles,
1260
+ tenantId: data.tenantId,
1261
+ });
1272
1262
  return c.json({
1273
1263
  isSuccess: true,
1274
1264
  token,
@@ -1279,6 +1269,7 @@ export function createAuthRoutes(
1279
1269
  },
1280
1270
  tenantId: data.tenantId,
1281
1271
  role: data.role,
1272
+ ...landingPath,
1282
1273
  });
1283
1274
  });
1284
1275
 
@@ -1306,6 +1297,11 @@ export function createAuthRoutes(
1306
1297
  role: string;
1307
1298
  }; // @cast-boundary engine-payload
1308
1299
  const token = await mintSessionAndRespond(c, data.session);
1300
+ const landingPath = landingPathFragment({
1301
+ flow: "invite",
1302
+ roles: data.session.roles,
1303
+ tenantId: data.tenantId,
1304
+ });
1309
1305
  return c.json({
1310
1306
  isSuccess: true,
1311
1307
  token,
@@ -1316,6 +1312,7 @@ export function createAuthRoutes(
1316
1312
  },
1317
1313
  tenantId: data.tenantId,
1318
1314
  role: data.role,
1315
+ ...landingPath,
1319
1316
  });
1320
1317
  });
1321
1318
  }
@@ -0,0 +1,91 @@
1
+ import type { Context } from "hono";
2
+
3
+ // Single source for client-IP extraction, shared by every IP-keyed rate
4
+ // limit and audit field. Deriving the caller IP independently per call site
5
+ // makes it easy for one to silently trust the first XFF entry, which is
6
+ // attacker-controlled unless the exact number of trusted reverse proxies is
7
+ // known.
8
+ //
9
+ // Algorithm for `trustedProxyHops`:
10
+ // 0 (default): don't trust any proxy header — only the socket address
11
+ // counts. A spoofed XFF is ignored outright.
12
+ // >=1: each trusted proxy hop APPENDS (not overwrites) its peer address to
13
+ // XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the real client sits
14
+ // at `entries[length - hops]`. Anything a client itself prepended lands
15
+ // further left and is ignored. A chain shorter than `hops` falls back to
16
+ // `x-real-ip` (no hop-count semantics of its own), then the socket address.
17
+ // A bucket is never skipped: without any usable value the fallback is a
18
+ // fixed "unknown" string, which collapses affected callers into one shared
19
+ // bucket rather than letting them bypass the limit entirely.
20
+ export const UNKNOWN_CLIENT_IP = "unknown";
21
+
22
+ export type ClientIpHeaderSource = {
23
+ readonly header: (name: string) => string | undefined;
24
+ readonly socketAddress?: string;
25
+ };
26
+
27
+ export type ClientIpResolver = {
28
+ readonly resolve: (source: ClientIpHeaderSource) => string;
29
+ };
30
+
31
+ // The socket address rides in as Hono's `Env` (2nd `app.fetch` arg, read
32
+ // back via `c.env`) as a bare string, never the raw Bun `Server` — a
33
+ // `Server.requestIP()` only resolves for the exact Request instance Bun
34
+ // created, and a cloned/rebuilt Request (tryHonoFirst's req.clone(), etc.)
35
+ // would silently return null. The address is extracted once at the
36
+ // outermost Bun.serve fetch handler and threaded down as a plain value.
37
+ export function extractSocketAddress(env: unknown): string | undefined {
38
+ return typeof env === "string" && env.length > 0 ? env : undefined;
39
+ }
40
+
41
+ export function assertValidTrustedProxyHops(value: number | undefined, context: string): void {
42
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
43
+ throw new Error(`${context}: trustedProxyHops must be a non-negative integer, got ${value}.`);
44
+ }
45
+ }
46
+
47
+ // Instance-scoped, NOT a module singleton: the warn-once flag must reset
48
+ // per server boot (and per test), otherwise the first test to trigger it
49
+ // consumes the warning for the whole process and later tests can't observe
50
+ // it.
51
+ export function createClientIpResolver(
52
+ trustedProxyHops: number,
53
+ context = "resolveClientIp",
54
+ ): ClientIpResolver {
55
+ assertValidTrustedProxyHops(trustedProxyHops, context);
56
+ let warnedUnexpectedForwardedFor = false;
57
+
58
+ function resolve(source: ClientIpHeaderSource): string {
59
+ const xff = source.header("x-forwarded-for");
60
+ if (trustedProxyHops === 0) {
61
+ if (xff !== undefined && xff.trim().length > 0 && !warnedUnexpectedForwardedFor) {
62
+ warnedUnexpectedForwardedFor = true;
63
+ console.warn(
64
+ "[kumiko] received X-Forwarded-For but trustedProxyHops is 0 (no proxy trusted) — " +
65
+ "ignoring it. Running behind a proxy? Set trustedProxyHops, otherwise all clients " +
66
+ "share one rate-limit bucket.",
67
+ );
68
+ }
69
+ return source.socketAddress ?? UNKNOWN_CLIENT_IP;
70
+ }
71
+ const entries = (xff ?? "")
72
+ .split(",")
73
+ .map((entry) => entry.trim())
74
+ .filter((entry) => entry.length > 0);
75
+ if (entries.length >= trustedProxyHops) {
76
+ return entries[entries.length - trustedProxyHops] ?? UNKNOWN_CLIENT_IP;
77
+ }
78
+ const realIp = source.header("x-real-ip")?.trim();
79
+ if (realIp) return realIp;
80
+ return source.socketAddress ?? UNKNOWN_CLIENT_IP;
81
+ }
82
+
83
+ return { resolve };
84
+ }
85
+
86
+ export function clientIpSourceFromHonoContext(c: Context): ClientIpHeaderSource {
87
+ return {
88
+ header: (name) => c.req.header(name),
89
+ socketAddress: extractSocketAddress(c.env),
90
+ };
91
+ }
package/src/api/index.ts CHANGED
@@ -28,6 +28,14 @@ export {
28
28
  createInMemoryLoginRateLimiter,
29
29
  createRedisLoginRateLimiter,
30
30
  } from "./auth-routes";
31
+ export type { ClientIpHeaderSource, ClientIpResolver } from "./client-ip";
32
+ export {
33
+ assertValidTrustedProxyHops,
34
+ clientIpSourceFromHonoContext,
35
+ createClientIpResolver,
36
+ extractSocketAddress,
37
+ UNKNOWN_CLIENT_IP,
38
+ } from "./client-ip";
31
39
  export type {
32
40
  AnonymousExtraRoute,
33
41
  AnonymousExtraRouteDeps,
@@ -57,6 +65,12 @@ export {
57
65
  export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
58
66
  export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
59
67
  export { patAllows, qnMatches } from "./pat-scope";
68
+ export type {
69
+ PostAuthLandingArgs,
70
+ PostAuthLandingFlow,
71
+ PostAuthLandingResolver,
72
+ } from "./post-auth-landing";
73
+ export { isSafeLandingPath } from "./post-auth-landing";
60
74
  export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
61
75
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
62
76
  export { type RequestContextData, requestContext } from "./request-context";
@@ -64,6 +78,7 @@ export {
64
78
  buildRequestContextDataFromRequest,
65
79
  requestIdMiddleware,
66
80
  } from "./request-id-middleware";
81
+ export { DEFAULT_MAX_REQUEST_BYTES } from "./route-registrars";
67
82
  export { createApiRoutes } from "./routes";
68
83
  export type { KumikoServer, ServerOptions } from "./server";
69
84
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server";