@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.318.0",
3
+ "version": "0.320.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -179,6 +179,10 @@
179
179
  "types": "./src/testing/handler-context.ts",
180
180
  "default": "./src/testing/handler-context.ts"
181
181
  },
182
+ "./testing/real-providers": {
183
+ "types": "./src/testing/real-providers.ts",
184
+ "default": "./src/testing/real-providers.ts"
185
+ },
182
186
  "./testing/e2e-generator": {
183
187
  "types": "./src/testing/e2e-generator.ts",
184
188
  "default": "./src/testing/e2e-generator.ts"
@@ -198,8 +202,8 @@
198
202
  "./package.json": "./package.json"
199
203
  },
200
204
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.318.0",
202
- "@cosmicdrift/kumiko-types": "0.318.0",
205
+ "@cosmicdrift/kumiko-http": "0.320.0",
206
+ "@cosmicdrift/kumiko-types": "0.320.0",
203
207
  "bullmq": "^5.76.7",
204
208
  "bun-types": "^1.3.13",
205
209
  "hono": "^4.13.1",
@@ -215,7 +219,7 @@
215
219
  "zod": "^4.4.3"
216
220
  },
217
221
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.318.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.320.0",
219
223
  "bun-types": "^1.3.13",
220
224
  "pino-pretty": "^13.1.3"
221
225
  },
@@ -218,9 +218,15 @@ describe("POST /auth/mfa/preauth-enable-start", () => {
218
218
  // x-forwarded-for. The second check keys on a sha256 of the
219
219
  // preauthSetupToken — prove rotating IPs still hit 429 on the same
220
220
  // token, and that a different token does not share that bucket.
221
+ // trustedProxyHops: 1 makes the single-entry XFF values below resolve to
222
+ // genuinely distinct client IPs — the default
223
+ // hops=0 ignores x-forwarded-for outright and would collapse every
224
+ // request onto one shared "unknown" IP-axis bucket, masking exactly the
225
+ // token-axis independence this test exists to prove.
221
226
  test("preauthSetupToken rate-limit axis survives x-forwarded-for rotation", async () => {
222
227
  const { app } = await buildApp({
223
228
  mfaPreauthEnableStartRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
229
+ trustedProxyHops: 1,
224
230
  });
225
231
  const attempt = (token: string, forwardedFor: string) =>
226
232
  app.request(
@@ -144,6 +144,17 @@ describe("POST /auth/mfa/verify", () => {
144
144
  expect(cookies.get(CSRF_COOKIE_NAME)).toBeDefined();
145
145
  });
146
146
 
147
+ test("postAuthLanding wired → response carries landingPath", async () => {
148
+ const { app } = await buildApp({
149
+ postAuthLanding: (args) => (args.flow === "login" ? "/a/profile" : undefined),
150
+ });
151
+ const res = await app.request(
152
+ verifyRequest({ challengeToken: "opaque-challenge-token", code: "123456" }),
153
+ );
154
+ const body = (await res.json()) as { landingPath?: string };
155
+ expect(body.landingPath).toBe("/a/profile");
156
+ });
157
+
147
158
  test("a handler failure maps through mfaVerifyErrorStatusMap", async () => {
148
159
  const dispatcher = createStubDispatcher({
149
160
  async write(): Promise<WriteResult> {
@@ -112,16 +112,20 @@ describe("AuthRoutesConfig.trustedProxyHops", () => {
112
112
  expect((await clientB()).status).toBe(422);
113
113
  });
114
114
 
115
- test("hops=0 (default): legacy behavior — trusts the first XFF entry unconditionally", async () => {
115
+ // hops=0 trusts NO proxy header at all, so a client-controlled XFF (any
116
+ // entry) is ignored outright and every request collapses into the shared
117
+ // "unknown" bucket (no socket address available via app.request()).
118
+ test("hops=0 (default): ignores x-forwarded-for entirely — distinct first entries share one bucket", async () => {
116
119
  const app = await buildApp();
117
- // No override: two "different" first-entries mean two different buckets,
118
- // even though both requests carry the same real backend hop.
119
120
  const clientA = () => app.request(verifyRequest("1.1.1.1, 9.9.9.9"));
120
121
  const clientB = () => app.request(verifyRequest("2.2.2.2, 9.9.9.9"));
121
122
  expect((await clientA()).status).toBe(422);
122
123
  expect((await clientA()).status).toBe(422);
123
124
  expect((await clientA()).status).toBe(429);
124
- expect((await clientB()).status).toBe(422);
125
+ // client B "looks" different (different first XFF entry) but hops=0
126
+ // ignores XFF outright — same "unknown" bucket as client A, already
127
+ // exhausted above.
128
+ expect((await clientB()).status).toBe(429);
125
129
  });
126
130
 
127
131
  test("hops=2 with a shorter XFF chain than configured collapses distinct clients into the unknown bucket", async () => {
@@ -0,0 +1,139 @@
1
+ // Unit tests for the single client-IP resolver shared by requestIdMiddleware,
2
+ // rate-limit/middleware.ts (L1/L2) and auth-routes.ts.
3
+ // No Hono/HTTP plumbing here — see request-id-middleware.test.ts,
4
+ // auth-routes-trusted-proxy.test.ts and rate-limit/__tests__/
5
+ // middleware.integration.test.ts for the end-to-end behavior.
6
+
7
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
8
+ import {
9
+ assertValidTrustedProxyHops,
10
+ type ClientIpHeaderSource,
11
+ createClientIpResolver,
12
+ } from "../client-ip";
13
+
14
+ function sourceOf(headers: Record<string, string>, socketAddress?: string): ClientIpHeaderSource {
15
+ return {
16
+ header: (name) => headers[name],
17
+ ...(socketAddress !== undefined ? { socketAddress } : {}),
18
+ };
19
+ }
20
+
21
+ describe("createClientIpResolver", () => {
22
+ describe("hops = 0 (default): trust no proxy header", () => {
23
+ test("ignores x-forwarded-for and falls back to the socket address", () => {
24
+ const resolver = createClientIpResolver(0);
25
+ const ip = resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }, "10.1.1.1"));
26
+ expect(ip).toBe("10.1.1.1");
27
+ });
28
+
29
+ test("falls back to 'unknown' when there's no socket address either", () => {
30
+ const resolver = createClientIpResolver(0);
31
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }))).toBe("unknown");
32
+ });
33
+
34
+ test("ignores x-real-ip too — hops=0 trusts nothing but the socket", () => {
35
+ const resolver = createClientIpResolver(0);
36
+ expect(resolver.resolve(sourceOf({ "x-real-ip": "203.0.113.7" }, "10.1.1.1"))).toBe(
37
+ "10.1.1.1",
38
+ );
39
+ });
40
+
41
+ describe("warns exactly once per resolver instance when XFF arrives", () => {
42
+ let warnSpy: ReturnType<typeof spyOn>;
43
+
44
+ afterEach(() => {
45
+ warnSpy?.mockRestore();
46
+ });
47
+
48
+ test("logs a single console.warn across repeated requests with XFF", () => {
49
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
50
+ const resolver = createClientIpResolver(0);
51
+ resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }));
52
+ resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.8" }));
53
+ resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.9" }));
54
+ expect(warnSpy).toHaveBeenCalledTimes(1);
55
+ });
56
+
57
+ test("a fresh resolver instance warns again — the flag isn't a module singleton", () => {
58
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
59
+ createClientIpResolver(0).resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }));
60
+ createClientIpResolver(0).resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }));
61
+ expect(warnSpy).toHaveBeenCalledTimes(2);
62
+ });
63
+
64
+ test("no warning when XFF is absent or blank", () => {
65
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
66
+ const resolver = createClientIpResolver(0);
67
+ resolver.resolve(sourceOf({}));
68
+ resolver.resolve(sourceOf({ "x-forwarded-for": " " }));
69
+ expect(warnSpy).not.toHaveBeenCalled();
70
+ });
71
+ });
72
+ });
73
+
74
+ describe("hops = 1: last XFF entry is the real client", () => {
75
+ test("picks the last entry, ignoring a spoofed leading entry", () => {
76
+ const resolver = createClientIpResolver(1);
77
+ const ip = resolver.resolve(sourceOf({ "x-forwarded-for": "attacker-claim, 9.9.9.9" }));
78
+ expect(ip).toBe("9.9.9.9");
79
+ });
80
+
81
+ test("a different spoofed leading entry doesn't change the resolved IP", () => {
82
+ const resolver = createClientIpResolver(1);
83
+ const first = resolver.resolve(sourceOf({ "x-forwarded-for": "1.2.3.4, 9.9.9.9" }));
84
+ const second = resolver.resolve(sourceOf({ "x-forwarded-for": "5.6.7.8, 9.9.9.9" }));
85
+ expect(first).toBe("9.9.9.9");
86
+ expect(second).toBe("9.9.9.9");
87
+ });
88
+
89
+ test("a single-entry chain resolves to that entry directly", () => {
90
+ const resolver = createClientIpResolver(1);
91
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": "9.9.9.9" }))).toBe("9.9.9.9");
92
+ });
93
+
94
+ test("no XFF: falls back to x-real-ip", () => {
95
+ const resolver = createClientIpResolver(1);
96
+ expect(resolver.resolve(sourceOf({ "x-real-ip": "203.0.113.10" }))).toBe("203.0.113.10");
97
+ });
98
+
99
+ test("no XFF and no x-real-ip: falls back to the socket address", () => {
100
+ const resolver = createClientIpResolver(1);
101
+ expect(resolver.resolve(sourceOf({}, "10.2.2.2"))).toBe("10.2.2.2");
102
+ });
103
+
104
+ test("no XFF, no x-real-ip, no socket address: falls back to 'unknown', never skips", () => {
105
+ const resolver = createClientIpResolver(1);
106
+ expect(resolver.resolve(sourceOf({}))).toBe("unknown");
107
+ });
108
+
109
+ test("empty/whitespace-only XFF is treated as absent, not as a blank entry", () => {
110
+ const resolver = createClientIpResolver(1);
111
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": " " }, "10.3.3.3"))).toBe("10.3.3.3");
112
+ });
113
+ });
114
+
115
+ describe("hops = 2: chain shorter than configured hops falls back", () => {
116
+ test("a single-entry chain (shorter than 2 hops) falls back to x-real-ip", () => {
117
+ const resolver = createClientIpResolver(2);
118
+ expect(
119
+ resolver.resolve(sourceOf({ "x-forwarded-for": "9.9.9.9", "x-real-ip": "203.0.113.11" })),
120
+ ).toBe("203.0.113.11");
121
+ });
122
+
123
+ test("a two-entry chain resolves to the leftmost (first) trusted-hop entry", () => {
124
+ const resolver = createClientIpResolver(2);
125
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": "6.6.6.6, 7.7.7.7" }))).toBe("6.6.6.6");
126
+ });
127
+ });
128
+
129
+ test("assertValidTrustedProxyHops rejects negative and non-integer values", () => {
130
+ expect(() => assertValidTrustedProxyHops(-1, "test")).toThrow(/non-negative integer/);
131
+ expect(() => assertValidTrustedProxyHops(1.5, "test")).toThrow(/non-negative integer/);
132
+ expect(() => assertValidTrustedProxyHops(0, "test")).not.toThrow();
133
+ expect(() => assertValidTrustedProxyHops(undefined, "test")).not.toThrow();
134
+ });
135
+
136
+ test("createClientIpResolver fails loud on an invalid trustedProxyHops instead of silently degrading", () => {
137
+ expect(() => createClientIpResolver(-1, "test")).toThrow(/non-negative integer/);
138
+ });
139
+ });
@@ -105,7 +105,14 @@ describe("extraRoutes: entry:anonymous", () => {
105
105
  let stack: TestStack;
106
106
 
107
107
  beforeAll(async () => {
108
- stack = await setupTestStack({ features: [probeFeature], extraRoutes: [pingRoute] });
108
+ // trustedProxyHops: 1 — the default hops=0 ignores x-forwarded-for and
109
+ // would collapse both tests below onto one
110
+ // shared "unknown" per-ip bucket, bleeding rate-limit state between them.
111
+ stack = await setupTestStack({
112
+ features: [probeFeature],
113
+ extraRoutes: [pingRoute],
114
+ trustedProxyHops: 1,
115
+ });
109
116
  });
110
117
 
111
118
  afterAll(() => stack.cleanup());
@@ -4,7 +4,16 @@
4
4
  // systemQuery wiring. Proves the fix: repeated calls through the same
5
5
  // httpRoute, same client IP, DO hit the limit.
6
6
 
7
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import {
8
+ afterAll,
9
+ afterEach,
10
+ beforeAll,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ spyOn,
15
+ test,
16
+ } from "bun:test";
8
17
  import * as z from "zod";
9
18
  import { createEntity, createTextField, defineFeature } from "../../engine";
10
19
  import type { TenantId } from "../../engine/types/identifiers";
@@ -44,7 +53,10 @@ const ipLimitedFeature = defineFeature("rl-http", (r) => {
44
53
  let stack: TestStack;
45
54
 
46
55
  beforeAll(async () => {
47
- stack = await setupTestStack({ features: [ipLimitedFeature] });
56
+ // trustedProxyHops: 1 — this suite's whole point is isolating buckets by
57
+ // the literal x-forwarded-for value below; the
58
+ // new hops=0 default ignores that header outright.
59
+ stack = await setupTestStack({ features: [ipLimitedFeature], trustedProxyHops: 1 });
48
60
  });
49
61
 
50
62
  afterAll(async () => {
@@ -74,4 +86,63 @@ describe("r.httpRoute → systemQuery propagates requestContext for per-ip rate
74
86
 
75
87
  expect((await callAs("9.9.9.3")).status).toBe(200);
76
88
  });
89
+
90
+ // An attacker rotating the untrusted, client-facing XFF prefix must still
91
+ // land in the real client's bucket — only the
92
+ // trusted-proxy-appended LAST entry counts at hops=1.
93
+ test("hops=1: different spoofed leading XFF entry, same trailing entry → same bucket", async () => {
94
+ const attempt = (leading: string) =>
95
+ stack.app.request("/ping", { headers: { "x-forwarded-for": `${leading}, 9.9.9.4` } });
96
+
97
+ expect((await attempt("attacker-claim-a")).status).toBe(200);
98
+ expect((await attempt("attacker-claim-b")).status).toBe(200);
99
+ const third = await attempt("attacker-claim-c");
100
+ expect(third.status).toBe(429);
101
+ });
102
+ });
103
+
104
+ describe("r.httpRoute → systemQuery: default trustedProxyHops=0 never fail-opens", () => {
105
+ let defaultHopsStack: TestStack;
106
+
107
+ beforeAll(async () => {
108
+ defaultHopsStack = await setupTestStack({ features: [ipLimitedFeature] });
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await defaultHopsStack.cleanup();
113
+ });
114
+
115
+ beforeEach(async () => {
116
+ await defaultHopsStack.redis.flushNamespace();
117
+ });
118
+
119
+ test("no x-forwarded-for at all: the shared 'unknown' bucket still enforces the limit", async () => {
120
+ const call = () => defaultHopsStack.app.request("/ping");
121
+
122
+ expect((await call()).status).toBe(200);
123
+ expect((await call()).status).toBe(200);
124
+ expect((await call()).status).toBe(429);
125
+ });
126
+
127
+ describe("one-time warning when hops=0 but the client sends x-forwarded-for anyway", () => {
128
+ let warnSpy: ReturnType<typeof spyOn>;
129
+
130
+ afterEach(() => {
131
+ warnSpy?.mockRestore();
132
+ });
133
+
134
+ test("warns exactly once across multiple requests, not once per request", async () => {
135
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
136
+ const call = () =>
137
+ defaultHopsStack.app.request("/ping", { headers: { "x-forwarded-for": "203.0.113.50" } });
138
+
139
+ await call();
140
+ await call();
141
+
142
+ const proxyWarnings = warnSpy.mock.calls.filter((args: unknown[]) =>
143
+ String(args[0]).includes("trustedProxyHops is 0"),
144
+ );
145
+ expect(proxyWarnings.length).toBe(1);
146
+ });
147
+ });
77
148
  });
@@ -0,0 +1,126 @@
1
+ // PAT route-scoping (fail-closed): a Personal Access Token must only reach
2
+ // the dispatcher routes (write/batch/query/command/stream) — every other
3
+ // /api/* surface, and every non-anonymous r.httpRoute regardless of where
4
+ // it's mounted, is off-limits no matter what the token's granted scopes are.
5
+ // Full HTTP loop via setupTestStack (real buildServer wiring), never
6
+ // createTestDispatcher — this proves the guard is actually mounted, not just
7
+ // that the guard function works in isolation.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import * as z from "zod";
11
+ import { defineFeature, type SessionUser } from "../../engine";
12
+ import type { TenantId } from "../../engine/types/identifiers";
13
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
14
+ import type { TokenVerifier } from "../auth-middleware";
15
+ import type { UserExtraRoute } from "../extra-route";
16
+
17
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
18
+ const PAT_TEST_TOKEN = "kpat_route-guard-test-token";
19
+
20
+ const patTestUser: SessionUser = {
21
+ id: "pat-route-guard-test-user",
22
+ tenantId: TENANT_ID,
23
+ roles: [],
24
+ pat: {
25
+ tokenId: "pat-route-guard-test-token-id",
26
+ scopes: ["ping:read"],
27
+ allowedQns: ["pat-route-guard-probe:query:ping"],
28
+ },
29
+ };
30
+
31
+ const fakeTokenVerifier: TokenVerifier = async (raw) =>
32
+ raw === PAT_TEST_TOKEN ? patTestUser : null;
33
+
34
+ const probeFeature = defineFeature("pat-route-guard-probe", (r) => {
35
+ r.queryHandler({
36
+ name: "ping",
37
+ schema: z.object({}),
38
+ access: { openToAll: { reason: "test handler callable by any authenticated caller" } },
39
+ handler: async () => ({ pong: true }),
40
+ });
41
+ r.httpRoute({
42
+ method: "GET",
43
+ path: "/pat-guard-private",
44
+ anonymous: false,
45
+ handler: async (c) => c.json({ ok: true }),
46
+ });
47
+ });
48
+
49
+ const userExtraRoute: UserExtraRoute = {
50
+ method: "GET",
51
+ path: "/api/pat-guard-extra",
52
+ entry: "user",
53
+ handler: async (c) => c.json({ ok: true }),
54
+ };
55
+
56
+ let stack: TestStack;
57
+
58
+ beforeAll(async () => {
59
+ stack = await setupTestStack({
60
+ features: [probeFeature],
61
+ extraRoutes: [userExtraRoute],
62
+ authConfig: {
63
+ // Never actually dispatched in these tests (login/tenant-switch aren't
64
+ // exercised here) — only tokenVerifier matters for the PAT path.
65
+ membershipQuery: "pat-route-guard-probe:query:nonexistent",
66
+ tokenVerifier: fakeTokenVerifier,
67
+ },
68
+ });
69
+ });
70
+
71
+ afterAll(() => stack.cleanup());
72
+
73
+ function patHeaders(): Record<string, string> {
74
+ return { Authorization: `Bearer ${PAT_TEST_TOKEN}` };
75
+ }
76
+
77
+ describe("PAT route guard — dispatcher routes only", () => {
78
+ test("PAT with a matching scope still reaches /api/query (regression)", async () => {
79
+ const res = await stack.app.request("/api/query", {
80
+ method: "POST",
81
+ headers: { ...patHeaders(), "Content-Type": "application/json" },
82
+ body: JSON.stringify({ type: "pat-route-guard-probe:query:ping", payload: {} }),
83
+ });
84
+ expect(res.status).toBe(200);
85
+ });
86
+
87
+ test("PAT → GET /api/sse → 403 before the stream ever opens", async () => {
88
+ const res = await stack.app.request("/api/sse", { headers: patHeaders() });
89
+ expect(res.status).toBe(403);
90
+ const body = (await res.json()) as {
91
+ error: { code: string; details?: { reason?: string } };
92
+ };
93
+ expect(body.error.code).toBe("access_denied");
94
+ expect(body.error.details?.reason).toBe("pat_dispatcher_routes_only");
95
+ });
96
+
97
+ test("PAT → non-anonymous r.httpRoute → 403", async () => {
98
+ const res = await stack.app.request("/pat-guard-private", { headers: patHeaders() });
99
+ expect(res.status).toBe(403);
100
+ const body = (await res.json()) as { error: { code: string } };
101
+ expect(body.error.code).toBe("access_denied");
102
+ });
103
+
104
+ test("JWT user still reaches the same r.httpRoute (regression)", async () => {
105
+ const token = await stack.jwt.sign(TestUsers.user);
106
+ const res = await stack.app.request("/pat-guard-private", {
107
+ headers: { Authorization: `Bearer ${token}` },
108
+ });
109
+ expect(res.status).toBe(200);
110
+ });
111
+
112
+ test('PAT → entry:"user" extraRoute → 403', async () => {
113
+ const res = await stack.app.request("/api/pat-guard-extra", { headers: patHeaders() });
114
+ expect(res.status).toBe(403);
115
+ const body = (await res.json()) as { error: { code: string } };
116
+ expect(body.error.code).toBe("access_denied");
117
+ });
118
+
119
+ test('JWT user still reaches the entry:"user" extraRoute (regression)', async () => {
120
+ const token = await stack.jwt.sign(TestUsers.user);
121
+ const res = await stack.app.request("/api/pat-guard-extra", {
122
+ headers: { Authorization: `Bearer ${token}` },
123
+ });
124
+ expect(res.status).toBe(200);
125
+ });
126
+ });
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ isSafeLandingPath,
4
+ type PostAuthLandingArgs,
5
+ type PostAuthLandingResolver,
6
+ resolvePostAuthLandingPath,
7
+ } from "../post-auth-landing";
8
+
9
+ describe("isSafeLandingPath", () => {
10
+ const rejected: unknown[] = [
11
+ "//evil.com",
12
+ "/\\evil.com",
13
+ "/a\\b",
14
+ "/\t/evil.com",
15
+ "/\n/evil.com",
16
+ " /a",
17
+ "https://evil.com",
18
+ "javascript:alert(1)",
19
+ "",
20
+ "a/b",
21
+ 42,
22
+ undefined,
23
+ "/.//evil.com",
24
+ ];
25
+ for (const candidate of rejected) {
26
+ test(`rejects ${JSON.stringify(candidate)}`, () => {
27
+ expect(isSafeLandingPath(candidate)).toBe(false);
28
+ });
29
+ }
30
+
31
+ const accepted: string[] = ["/", "/a/profile", "/a/vehicle-detail/abc?x=1#h"];
32
+ for (const candidate of accepted) {
33
+ test(`accepts ${JSON.stringify(candidate)}`, () => {
34
+ expect(isSafeLandingPath(candidate)).toBe(true);
35
+ });
36
+ }
37
+ });
38
+
39
+ describe("resolvePostAuthLandingPath", () => {
40
+ const args: PostAuthLandingArgs = { flow: "login", roles: ["User"], tenantId: "t1" };
41
+ const realWarn = console.warn;
42
+
43
+ beforeEach(() => {
44
+ console.warn = mock(() => {});
45
+ });
46
+ afterEach(() => {
47
+ console.warn = realWarn;
48
+ });
49
+
50
+ test("undefined resolver → undefined, no warning", () => {
51
+ expect(resolvePostAuthLandingPath(undefined, args)).toBeUndefined();
52
+ expect(console.warn).not.toHaveBeenCalled();
53
+ });
54
+
55
+ test("resolver returns undefined → undefined, no warning", () => {
56
+ expect(resolvePostAuthLandingPath(() => undefined, args)).toBeUndefined();
57
+ expect(console.warn).not.toHaveBeenCalled();
58
+ });
59
+
60
+ test("throwing resolver → undefined, logs a warning without the thrown value", () => {
61
+ const result = resolvePostAuthLandingPath(() => {
62
+ throw new Error("boom");
63
+ }, args);
64
+ expect(result).toBeUndefined();
65
+ expect(console.warn).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ test("throwing resolver → warning carries the error class, not the consumer message", () => {
69
+ resolvePostAuthLandingPath(() => {
70
+ throw new Error("secret-token-123");
71
+ }, args);
72
+ const loggedArgs = (console.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
73
+ expect(JSON.stringify(loggedArgs)).not.toContain("secret-token-123");
74
+ });
75
+
76
+ test("rejecting async resolver → undefined, async warning, rejection observed", async () => {
77
+ // @cast-boundary test — simulates a JS consumer bypassing the sync signature
78
+ const asyncResolver = (async () => {
79
+ throw new Error("boom");
80
+ }) as unknown as PostAuthLandingResolver;
81
+ expect(resolvePostAuthLandingPath(asyncResolver, args)).toBeUndefined();
82
+ await Promise.resolve();
83
+ expect(console.warn).toHaveBeenCalledTimes(1);
84
+ const loggedArgs = (console.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
85
+ expect(String(loggedArgs?.[0])).toContain("async resolver not supported");
86
+ });
87
+
88
+ test("unsafe path → undefined, warns without leaking the candidate", () => {
89
+ const result = resolvePostAuthLandingPath(() => "//evil.com", args);
90
+ expect(result).toBeUndefined();
91
+ expect(console.warn).toHaveBeenCalledTimes(1);
92
+ const loggedArgs = (console.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
93
+ expect(JSON.stringify(loggedArgs)).not.toContain("evil.com");
94
+ });
95
+
96
+ test("valid path → the path, no warning", () => {
97
+ expect(resolvePostAuthLandingPath(() => "/a/profile", args)).toBe("/a/profile");
98
+ expect(console.warn).not.toHaveBeenCalled();
99
+ });
100
+ });
@@ -71,7 +71,11 @@ describe("requestIdMiddleware — signal propagation", () => {
71
71
  });
72
72
  });
73
73
  describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
74
- test("populates ip from x-forwarded-for and userAgent from the User-Agent header", async () => {
74
+ // Default trustedProxyHops=0 trusts no proxy header at all — the resolver
75
+ // ignores x-forwarded-for outright (a
76
+ // spoofable, client-controlled header) and falls back to the socket
77
+ // address, "unknown" here since app.request() has none.
78
+ test("default (trustedProxyHops=0): ignores x-forwarded-for, falls back to unknown", async () => {
75
79
  let captured: { ip: string | undefined; userAgent: string | undefined } = {
76
80
  ip: undefined,
77
81
  userAgent: undefined,
@@ -96,7 +100,36 @@ describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
96
100
  );
97
101
 
98
102
  expect(res.status).toBe(200);
99
- expect(captured.ip).toBe("203.0.113.7");
103
+ expect(captured.ip).toBe("unknown");
104
+ expect(captured.userAgent).toBe("Mozilla/5.0 (probe-test)");
105
+ });
106
+
107
+ test("trustedProxyHops=1: populates ip from the last (trusted-proxy-appended) x-forwarded-for entry", async () => {
108
+ let captured: { ip: string | undefined; userAgent: string | undefined } = {
109
+ ip: undefined,
110
+ userAgent: undefined,
111
+ };
112
+
113
+ const app = new Hono();
114
+ app.use("/probe", requestIdMiddleware({ trustedProxyHops: 1 }));
115
+ app.get("/probe", (c) => {
116
+ const ctx = requestContext.get();
117
+ captured = { ip: ctx?.ip, userAgent: ctx?.userAgent };
118
+ return c.text("ok");
119
+ });
120
+
121
+ const res = await app.request(
122
+ new Request("http://test.local/probe", {
123
+ method: "GET",
124
+ headers: {
125
+ "x-forwarded-for": "203.0.113.7, 10.0.0.1",
126
+ "user-agent": "Mozilla/5.0 (probe-test)",
127
+ },
128
+ }),
129
+ );
130
+
131
+ expect(res.status).toBe(200);
132
+ expect(captured.ip).toBe("10.0.0.1");
100
133
  expect(captured.userAgent).toBe("Mozilla/5.0 (probe-test)");
101
134
  });
102
135