@cosmicdrift/kumiko-framework 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.
Files changed (71) hide show
  1. package/package.json +8 -4
  2. package/src/__tests__/schema-cli.integration.test.ts +11 -0
  3. package/src/__tests__/upgrade-cli.test.ts +50 -3
  4. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  6. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  7. package/src/api/__tests__/client-ip.test.ts +164 -0
  8. package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
  9. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  10. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  11. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  12. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  13. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  14. package/src/api/api-constants.ts +2 -0
  15. package/src/api/auth-routes.ts +92 -95
  16. package/src/api/client-ip.ts +118 -0
  17. package/src/api/extra-route.ts +3 -0
  18. package/src/api/index.ts +17 -0
  19. package/src/api/pat-route-guard.ts +51 -0
  20. package/src/api/post-auth-landing.ts +105 -0
  21. package/src/api/request-context.ts +4 -2
  22. package/src/api/request-id-middleware.ts +44 -14
  23. package/src/api/server.ts +130 -40
  24. package/src/changes.json +121 -0
  25. package/src/db/queries/event-store-transfer.ts +73 -0
  26. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  27. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  28. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  29. package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
  30. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  31. package/src/engine/boot-validator/pii-retention.ts +18 -2
  32. package/src/engine/boot-validator/screens.ts +75 -18
  33. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
  35. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
  36. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
  37. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
  38. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
  39. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
  40. package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
  41. package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
  42. package/src/engine/feature-ast/extractors/index.ts +4 -0
  43. package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
  44. package/src/engine/feature-ast/extractors/shared.ts +26 -19
  45. package/src/engine/feature-ast/parse.ts +13 -20
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/project-app-schema-for-roles.ts +950 -0
  48. package/src/engine/steps/webhook-send.ts +4 -2
  49. package/src/engine/types/handlers.ts +1 -1
  50. package/src/engine/types/index.ts +5 -1
  51. package/src/entrypoint/index.ts +14 -1
  52. package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
  53. package/src/event-store/index.ts +6 -0
  54. package/src/event-store/transfer.ts +84 -0
  55. package/src/files/__tests__/files.integration.test.ts +105 -1
  56. package/src/files/file-routes.ts +54 -2
  57. package/src/files/index.ts +1 -1
  58. package/src/http/index.ts +10 -2
  59. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  60. package/src/jobs/job-runner.ts +6 -1
  61. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  62. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  63. package/src/rate-limit/middleware.ts +32 -8
  64. package/src/schema-cli.ts +5 -1
  65. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  66. package/src/stack/push-entity-projection-tables.ts +52 -7
  67. package/src/stack/table-helpers.ts +17 -9
  68. package/src/stack/test-stack.ts +28 -1
  69. package/src/testing/real-providers.ts +2 -0
  70. package/src/ui-types/index.ts +1 -1
  71. package/src/upgrade-cli.ts +58 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.319.0",
3
+ "version": "0.321.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.319.0",
202
- "@cosmicdrift/kumiko-types": "0.319.0",
205
+ "@cosmicdrift/kumiko-http": "0.321.0",
206
+ "@cosmicdrift/kumiko-types": "0.321.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.319.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.321.0",
219
223
  "bun-types": "^1.3.13",
220
224
  "pino-pretty": "^13.1.3"
221
225
  },
@@ -54,6 +54,17 @@ describe("runSchemaCli — no-DB paths", () => {
54
54
  expect(cap.err).toHaveLength(0);
55
55
  });
56
56
 
57
+ test.each(["--help", "-h", "help"])(
58
+ "%s prints the same usage as no subcommand and exits 0",
59
+ async (helpArg) => {
60
+ const cap = captureOut();
61
+ const code = await runSchemaCli([helpArg], appCwd, cap.out);
62
+ expect(code).toBe(0);
63
+ expect(cap.log.join("\n")).toContain("Subcommands:");
64
+ expect(cap.err).toHaveLength(0);
65
+ },
66
+ );
67
+
57
68
  test("unknown subcommand exits 1 and reports it on stderr", async () => {
58
69
  const cap = captureOut();
59
70
  const code = await runSchemaCli(["cutover"], appCwd, cap.out);
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import {
16
16
  findCodemodScriptsRoot,
17
- findCoreChangelogFile,
18
17
  findFeaturesDirs,
18
+ findPackageChangelogFiles,
19
19
  resolveCodemodScript,
20
20
  runUpgradeCli,
21
21
  type UpgradeCliOut,
@@ -92,6 +92,23 @@ async function runJson(cwd: string, from: string): Promise<{ pending: Array<{ ti
92
92
  return JSON.parse(spy.logs.join("\n"));
93
93
  }
94
94
 
95
+ describe("upgrade command — --help", () => {
96
+ test("--help prints usage and exits 0 without touching the filesystem", async () => {
97
+ const spy = makeSpyOutput();
98
+ const exit = await runUpgradeCli(["--help"], "/nonexistent-cwd", spy.out);
99
+ expect(exit).toBe(0);
100
+ expect(spy.logs.join("\n")).toContain("kumiko-upgrade");
101
+ expect(spy.errs).toEqual([]);
102
+ });
103
+
104
+ test("-h behaves the same as --help", async () => {
105
+ const spy = makeSpyOutput();
106
+ const exit = await runUpgradeCli(["-h"], "/nonexistent-cwd", spy.out);
107
+ expect(exit).toBe(0);
108
+ expect(spy.logs.join("\n")).toContain("--apply");
109
+ });
110
+ });
111
+
95
112
  describe("upgrade command — framework core changelog", () => {
96
113
  test("collects core changes.json from the framework repo layout", async () => {
97
114
  const cwd = tmp({
@@ -198,6 +215,37 @@ describe("upgrade command — framework core changelog", () => {
198
215
  });
199
216
  });
200
217
 
218
+ describe("upgrade command — every package's changelog, not just framework core", () => {
219
+ const SERVER_RUNTIME_ENTRY = JSON.stringify([
220
+ { version: "0.168.0", type: "breaking", title: "server-runtime breaking change" },
221
+ ]);
222
+
223
+ test("a hoisted non-framework, non-bundled-features package's changes.json shows up in pending", async () => {
224
+ const cwd = tmp({
225
+ "node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
226
+ "apps/web/package.json": "{}",
227
+ });
228
+
229
+ const result = await runJson(`${cwd}/apps/web`, "0.165.0");
230
+
231
+ expect(result.pending.map((e) => e.title)).toEqual(["server-runtime breaking change"]);
232
+ });
233
+
234
+ test("bundled-features is never double-counted alongside findFeaturesDirs's own collection", async () => {
235
+ const cwd = tmp({
236
+ "node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
237
+ "node_modules/@cosmicdrift/kumiko-bundled-features/src/user/changes.json": FEATURE_ENTRY,
238
+ "apps/web/package.json": "{}",
239
+ });
240
+
241
+ const result = await runJson(`${cwd}/apps/web`, "0.165.0");
242
+
243
+ expect(result.pending.map((e) => e.title).sort()).toEqual(
244
+ ["feature fix", "server-runtime breaking change"].sort(),
245
+ );
246
+ });
247
+ });
248
+
201
249
  describe("upgrade command — enterprise package layout", () => {
202
250
  // Layout is detected by presence of changes.json, not an "ai-" name
203
251
  // prefix — the old heuristic silently dropped every enterprise package
@@ -641,9 +689,8 @@ describe("changes.json codemod fields resolve to real published scripts", () =>
641
689
  }
642
690
 
643
691
  test("every codemod field is a scripts/codemod/*.ts path that resolves to an existing published script", () => {
644
- const coreChangelog = findCoreChangelogFile(REAL_REPO_ROOT);
645
692
  const changesJsonFiles = [
646
- ...(coreChangelog ? [coreChangelog] : []),
693
+ ...findPackageChangelogFiles(REAL_REPO_ROOT),
647
694
  ...findFeaturesDirs(REAL_REPO_ROOT).flatMap((dir) => findChangesJsonFiles(dir)),
648
695
  ];
649
696
  expect(changesJsonFiles.length).toBeGreaterThan(0);
@@ -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,164 @@
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
+ parseTrustedProxyHopsEnv,
13
+ } from "../client-ip";
14
+
15
+ function sourceOf(headers: Record<string, string>, socketAddress?: string): ClientIpHeaderSource {
16
+ return {
17
+ header: (name) => headers[name],
18
+ ...(socketAddress !== undefined ? { socketAddress } : {}),
19
+ };
20
+ }
21
+
22
+ describe("createClientIpResolver", () => {
23
+ describe("hops = 0 (default): trust no proxy header", () => {
24
+ test("ignores x-forwarded-for and falls back to the socket address", () => {
25
+ const resolver = createClientIpResolver(0);
26
+ const ip = resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }, "10.1.1.1"));
27
+ expect(ip).toBe("10.1.1.1");
28
+ });
29
+
30
+ test("falls back to 'unknown' when there's no socket address either", () => {
31
+ const resolver = createClientIpResolver(0);
32
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }))).toBe("unknown");
33
+ });
34
+
35
+ test("ignores x-real-ip too — hops=0 trusts nothing but the socket", () => {
36
+ const resolver = createClientIpResolver(0);
37
+ expect(resolver.resolve(sourceOf({ "x-real-ip": "203.0.113.7" }, "10.1.1.1"))).toBe(
38
+ "10.1.1.1",
39
+ );
40
+ });
41
+
42
+ describe("warns exactly once per resolver instance when XFF arrives", () => {
43
+ let warnSpy: ReturnType<typeof spyOn>;
44
+
45
+ afterEach(() => {
46
+ warnSpy?.mockRestore();
47
+ });
48
+
49
+ test("logs a single console.warn across repeated requests with XFF", () => {
50
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
51
+ const resolver = createClientIpResolver(0);
52
+ resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }));
53
+ resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.8" }));
54
+ resolver.resolve(sourceOf({ "x-forwarded-for": "203.0.113.9" }));
55
+ expect(warnSpy).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ test("a fresh resolver instance warns again — the flag isn't a module singleton", () => {
59
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
60
+ createClientIpResolver(0).resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }));
61
+ createClientIpResolver(0).resolve(sourceOf({ "x-forwarded-for": "203.0.113.7" }));
62
+ expect(warnSpy).toHaveBeenCalledTimes(2);
63
+ });
64
+
65
+ test("no warning when XFF is absent or blank", () => {
66
+ warnSpy = spyOn(console, "warn").mockImplementation(() => {});
67
+ const resolver = createClientIpResolver(0);
68
+ resolver.resolve(sourceOf({}));
69
+ resolver.resolve(sourceOf({ "x-forwarded-for": " " }));
70
+ expect(warnSpy).not.toHaveBeenCalled();
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("hops = 1: last XFF entry is the real client", () => {
76
+ test("picks the last entry, ignoring a spoofed leading entry", () => {
77
+ const resolver = createClientIpResolver(1);
78
+ const ip = resolver.resolve(sourceOf({ "x-forwarded-for": "attacker-claim, 9.9.9.9" }));
79
+ expect(ip).toBe("9.9.9.9");
80
+ });
81
+
82
+ test("a different spoofed leading entry doesn't change the resolved IP", () => {
83
+ const resolver = createClientIpResolver(1);
84
+ const first = resolver.resolve(sourceOf({ "x-forwarded-for": "1.2.3.4, 9.9.9.9" }));
85
+ const second = resolver.resolve(sourceOf({ "x-forwarded-for": "5.6.7.8, 9.9.9.9" }));
86
+ expect(first).toBe("9.9.9.9");
87
+ expect(second).toBe("9.9.9.9");
88
+ });
89
+
90
+ test("a single-entry chain resolves to that entry directly", () => {
91
+ const resolver = createClientIpResolver(1);
92
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": "9.9.9.9" }))).toBe("9.9.9.9");
93
+ });
94
+
95
+ test("no XFF: falls back to x-real-ip", () => {
96
+ const resolver = createClientIpResolver(1);
97
+ expect(resolver.resolve(sourceOf({ "x-real-ip": "203.0.113.10" }))).toBe("203.0.113.10");
98
+ });
99
+
100
+ test("no XFF and no x-real-ip: falls back to the socket address", () => {
101
+ const resolver = createClientIpResolver(1);
102
+ expect(resolver.resolve(sourceOf({}, "10.2.2.2"))).toBe("10.2.2.2");
103
+ });
104
+
105
+ test("no XFF, no x-real-ip, no socket address: falls back to 'unknown', never skips", () => {
106
+ const resolver = createClientIpResolver(1);
107
+ expect(resolver.resolve(sourceOf({}))).toBe("unknown");
108
+ });
109
+
110
+ test("empty/whitespace-only XFF is treated as absent, not as a blank entry", () => {
111
+ const resolver = createClientIpResolver(1);
112
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": " " }, "10.3.3.3"))).toBe("10.3.3.3");
113
+ });
114
+ });
115
+
116
+ describe("hops = 2: chain shorter than configured hops falls back", () => {
117
+ test("a single-entry chain (shorter than 2 hops) falls back to x-real-ip", () => {
118
+ const resolver = createClientIpResolver(2);
119
+ expect(
120
+ resolver.resolve(sourceOf({ "x-forwarded-for": "9.9.9.9", "x-real-ip": "203.0.113.11" })),
121
+ ).toBe("203.0.113.11");
122
+ });
123
+
124
+ test("a two-entry chain resolves to the leftmost (first) trusted-hop entry", () => {
125
+ const resolver = createClientIpResolver(2);
126
+ expect(resolver.resolve(sourceOf({ "x-forwarded-for": "6.6.6.6, 7.7.7.7" }))).toBe("6.6.6.6");
127
+ });
128
+ });
129
+
130
+ test("assertValidTrustedProxyHops rejects negative and non-integer values", () => {
131
+ expect(() => assertValidTrustedProxyHops(-1, "test")).toThrow(/non-negative integer/);
132
+ expect(() => assertValidTrustedProxyHops(1.5, "test")).toThrow(/non-negative integer/);
133
+ expect(() => assertValidTrustedProxyHops(0, "test")).not.toThrow();
134
+ expect(() => assertValidTrustedProxyHops(undefined, "test")).not.toThrow();
135
+ });
136
+
137
+ test("createClientIpResolver fails loud on an invalid trustedProxyHops instead of silently degrading", () => {
138
+ expect(() => createClientIpResolver(-1, "test")).toThrow(/non-negative integer/);
139
+ });
140
+ });
141
+
142
+ describe("parseTrustedProxyHopsEnv", () => {
143
+ test("undefined -> undefined (no env set)", () => {
144
+ expect(parseTrustedProxyHopsEnv(undefined, "test")).toBeUndefined();
145
+ });
146
+
147
+ test("valid digits-only string -> parsed integer", () => {
148
+ expect(parseTrustedProxyHopsEnv("1", "test")).toBe(1);
149
+ expect(parseTrustedProxyHopsEnv("0", "test")).toBe(0);
150
+ expect(parseTrustedProxyHopsEnv("42", "test")).toBe(42);
151
+ });
152
+
153
+ test("hex/exponent/negative notation throws instead of silently coercing", () => {
154
+ expect(() => parseTrustedProxyHopsEnv("0x10", "test")).toThrow(/non-negative integer/);
155
+ expect(() => parseTrustedProxyHopsEnv("1e3", "test")).toThrow(/non-negative integer/);
156
+ expect(() => parseTrustedProxyHopsEnv("-1", "test")).toThrow(/non-negative integer/);
157
+ });
158
+
159
+ test("error message carries the context prefix and the raw value", () => {
160
+ expect(() => parseTrustedProxyHopsEnv("x", "runProdApp")).toThrow(
161
+ 'runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "x".',
162
+ );
163
+ });
164
+ });
@@ -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());
@@ -578,6 +585,48 @@ describe("extraRoutes: entry:signature", () => {
578
585
  });
579
586
  });
580
587
 
588
+ describe("extraRoutes: entry:anonymous deps.clientIp", () => {
589
+ const clientIpRoute: AnonymousExtraRoute = {
590
+ method: "GET",
591
+ path: "/public/client-ip-probe",
592
+ entry: "anonymous",
593
+ handler: async (c, deps) => c.json({ clientIp: deps.clientIp }),
594
+ };
595
+
596
+ test("default trustedProxyHops (0): a spoofed X-Forwarded-For is ignored, socket address wins", async () => {
597
+ const stack = await setupTestStack({
598
+ features: [probeFeature],
599
+ extraRoutes: [clientIpRoute],
600
+ });
601
+ try {
602
+ const res = await stack.app.request(
603
+ "/public/client-ip-probe",
604
+ { headers: { "x-forwarded-for": "203.0.113.10" } },
605
+ "198.51.100.9",
606
+ );
607
+ expect(await res.json()).toEqual({ clientIp: "198.51.100.9" });
608
+ } finally {
609
+ await stack.cleanup();
610
+ }
611
+ });
612
+
613
+ test("trustedProxyHops: 1 — the rightmost X-Forwarded-For entry is trusted", async () => {
614
+ const stack = await setupTestStack({
615
+ features: [probeFeature],
616
+ extraRoutes: [clientIpRoute],
617
+ trustedProxyHops: 1,
618
+ });
619
+ try {
620
+ const res = await stack.app.request("/public/client-ip-probe", {
621
+ headers: { "x-forwarded-for": "203.0.113.10" },
622
+ });
623
+ expect(await res.json()).toEqual({ clientIp: "203.0.113.10" });
624
+ } finally {
625
+ await stack.cleanup();
626
+ }
627
+ });
628
+ });
629
+
581
630
  // Type-level contract for ExtraRouteDefinition (kumiko-framework#3050): the
582
631
  // bodies below are never invoked — tsc checks them, bun:test does not run
583
632
  // them. Each `@ts-expect-error` turns into an "unused directive" compile
@@ -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
+ });