@cosmicdrift/kumiko-framework 0.297.0 → 0.304.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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/api.test.ts +3 -2
  3. package/src/api/__tests__/body-limit.test.ts +4 -4
  4. package/src/api/__tests__/extra-routes.integration.test.ts +584 -0
  5. package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
  6. package/src/api/auth-routes.ts +53 -24
  7. package/src/api/extra-route.ts +146 -0
  8. package/src/api/index.ts +16 -1
  9. package/src/api/server.ts +376 -72
  10. package/src/changes.json +37 -0
  11. package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
  12. package/src/engine/__tests__/membership-roles.test.ts +13 -4
  13. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
  14. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
  15. package/src/engine/boot-validator/access-declarations.ts +59 -7
  16. package/src/engine/boot-validator/entity-handler.ts +20 -0
  17. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  18. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  19. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
  20. package/src/engine/feature-ast/extractors/hooks.ts +3 -1
  21. package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
  22. package/src/engine/feature-ast/patcher.ts +2 -2
  23. package/src/engine/feature-ast/patterns.ts +1 -1
  24. package/src/engine/feature-ast/render.ts +1 -1
  25. package/src/engine/feature-ui-extensions.ts +6 -0
  26. package/src/engine/index.ts +2 -0
  27. package/src/engine/membership-roles.ts +20 -4
  28. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  29. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  30. package/src/engine/system-user.ts +5 -5
  31. package/src/engine/types/index.ts +2 -0
  32. package/src/entrypoint/index.ts +2 -0
  33. package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
  34. package/src/observability/index.ts +5 -0
  35. package/src/observability/metrics-wiring.ts +32 -0
  36. package/src/stack/test-stack.ts +8 -2
  37. package/src/testing/handler-context.ts +3 -1
  38. package/src/ui-types/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.297.0",
3
+ "version": "0.304.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>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.297.0",
202
- "@cosmicdrift/kumiko-types": "0.297.0",
201
+ "@cosmicdrift/kumiko-http": "0.304.0",
202
+ "@cosmicdrift/kumiko-types": "0.304.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.297.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.304.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -851,6 +851,7 @@ describe("feature-declared HTTP routes (r.httpRoute)", () => {
851
851
  r.httpRoute({
852
852
  method: "GET",
853
853
  path: "/api/forbidden",
854
+ anonymous: true,
854
855
  handler: (c) => c.text("nope"),
855
856
  });
856
857
  }),
@@ -860,8 +861,8 @@ describe("feature-declared HTTP routes (r.httpRoute)", () => {
860
861
  test("Boot-Validator: doppelte method+path-Combo wird abgelehnt", () => {
861
862
  expect(() =>
862
863
  defineFeature("dup", (r) => {
863
- r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("a") });
864
- r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("b") });
864
+ r.httpRoute({ method: "GET", path: "/x", anonymous: true, handler: (c) => c.text("a") });
865
+ r.httpRoute({ method: "GET", path: "/x", anonymous: true, handler: (c) => c.text("b") });
865
866
  }),
866
867
  ).toThrow(/already registered/);
867
868
  });
@@ -164,10 +164,10 @@ describe("default coverage sweep — proves the default, not a hand-maintained l
164
164
  });
165
165
 
166
166
  // Regression for the DoD: "neue Route ohne Eintrag in irgendeiner Liste
167
- // bekommt automatisch ein Limit". Mounts a route the same way an app-owner's
168
- // `extraRoutes` callback would — after buildServer, with zero Routes/
169
- // opt-out entries — and proves it inherits the cap AND still serves a
170
- // small body correctly (not an accidental always-413).
167
+ // bekommt automatisch ein Limit". Mounts a route directly on the app
168
+ // after buildServer, with zero Routes/opt-out entries — and proves it
169
+ // inherits the cap AND still serves a small body correctly (not an
170
+ // accidental always-413).
171
171
  test("a route mounted after buildServer with no list entry anywhere still inherits the default limit", async () => {
172
172
  const app = buildApp();
173
173
  app.post("/api/totally-new-route-nobody-listed", async (c) => c.json({ ok: true }));
@@ -0,0 +1,584 @@
1
+ // Full-stack proof for the declarative extraRoutes API (kumiko-framework#3050):
2
+ // each `entry` tier gets the matching guard + deps from buildServer, driven
3
+ // via real HTTP (setupTestStack + app.request), never createTestDispatcher.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { createHmac } from "node:crypto";
7
+ import { z } from "zod";
8
+ import { createRegistry, defineFeature, type TenantId } from "../../engine";
9
+ import { RateLimitError } from "../../errors";
10
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
11
+ import {
12
+ type AnonymousExtraRoute,
13
+ type ExtraRouteDefinition,
14
+ ExtraRouteRejection,
15
+ type SignatureExtraRoute,
16
+ signatureRoute,
17
+ type UserExtraRoute,
18
+ } from "../extra-route";
19
+ import { buildServer } from "../server";
20
+
21
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
22
+ const JWT_SECRET = "test-extra-routes-secret-32-chars-min!!";
23
+
24
+ const probeFeature = defineFeature("extra-route-probe", (r) => {
25
+ r.queryHandler({
26
+ name: "ping",
27
+ schema: z.object({}),
28
+ access: { roles: ["anonymous"] },
29
+ rateLimit: { per: "ip", limit: 2, windowSeconds: 60 },
30
+ handler: async () => ({ pong: true }),
31
+ });
32
+ r.writeHandler({
33
+ name: "self-write",
34
+ schema: z.object({ note: z.string() }),
35
+ access: { roles: ["User", "Admin"] },
36
+ handler: async (event) => ({
37
+ isSuccess: true as const,
38
+ data: { userId: event.user.id, note: event.payload.note },
39
+ }),
40
+ });
41
+ r.writeHandler({
42
+ name: "admin-only-write",
43
+ schema: z.object({}),
44
+ access: { roles: ["Admin"] },
45
+ handler: async () => ({ isSuccess: true as const, data: { ok: true as const } }),
46
+ });
47
+ r.writeHandler({
48
+ name: "hmac-write",
49
+ schema: z.object({ note: z.string() }),
50
+ access: { roles: ["SystemAdmin"] },
51
+ handler: async (event) => ({
52
+ isSuccess: true as const,
53
+ data: { tenantSeen: event.user.tenantId, note: event.payload.note },
54
+ }),
55
+ });
56
+ });
57
+
58
+ // Module-level store — proves deps.write ran via a follow-up query, no DB needed.
59
+ const anonymousWriteStore = new Map<string, string[]>();
60
+
61
+ const anonymousWriteFeature = defineFeature("anonymous-write-probe", (r) => {
62
+ r.writeHandler({
63
+ name: "anon-write",
64
+ schema: z.object({ note: z.string() }),
65
+ access: { roles: ["anonymous"] },
66
+ handler: async (event) => {
67
+ const notes = anonymousWriteStore.get(event.user.tenantId) ?? [];
68
+ notes.push(event.payload.note);
69
+ anonymousWriteStore.set(event.user.tenantId, notes);
70
+ return { isSuccess: true as const, data: { tenantSeen: event.user.tenantId } };
71
+ },
72
+ });
73
+ r.writeHandler({
74
+ name: "anon-gated-write",
75
+ schema: z.object({}),
76
+ access: { roles: ["Admin"] },
77
+ handler: async () => ({ isSuccess: true as const, data: { ok: true as const } }),
78
+ });
79
+ r.queryHandler({
80
+ name: "anon-notes",
81
+ schema: z.object({}),
82
+ access: { roles: ["anonymous"] },
83
+ handler: async (event) => ({ notes: anonymousWriteStore.get(event.user.tenantId) ?? [] }),
84
+ });
85
+ });
86
+
87
+ describe("extraRoutes: entry:anonymous", () => {
88
+ const pingRoute: AnonymousExtraRoute = {
89
+ method: "GET",
90
+ path: "/public/ping",
91
+ entry: "anonymous",
92
+ handler: async (c, deps) => {
93
+ try {
94
+ const result = await deps.systemQuery("extra-route-probe:query:ping", {}, TENANT_ID);
95
+ return c.json(result as { pong: boolean }); // @cast-boundary engine-payload
96
+ } catch (e) {
97
+ if (e instanceof RateLimitError) {
98
+ return c.json({ error: { code: e.code } }, e.httpStatus);
99
+ }
100
+ throw e;
101
+ }
102
+ },
103
+ };
104
+
105
+ let stack: TestStack;
106
+
107
+ beforeAll(async () => {
108
+ stack = await setupTestStack({ features: [probeFeature], extraRoutes: [pingRoute] });
109
+ });
110
+
111
+ afterAll(() => stack.cleanup());
112
+
113
+ test("reachable without any session, outside /api", async () => {
114
+ const res = await stack.app.request("/public/ping", {
115
+ headers: { "x-forwarded-for": "203.0.113.10" },
116
+ });
117
+ expect(res.status).toBe(200);
118
+ expect(await res.json()).toEqual({ pong: true });
119
+ });
120
+
121
+ test("systemQuery wraps requestContext so a handler's per-ip rateLimit actually fires", async () => {
122
+ const ip = "203.0.113.20";
123
+ for (let i = 0; i < 2; i++) {
124
+ const res = await stack.app.request("/public/ping", { headers: { "x-forwarded-for": ip } });
125
+ expect(res.status).toBe(200);
126
+ }
127
+ const limited = await stack.app.request("/public/ping", { headers: { "x-forwarded-for": ip } });
128
+ expect(limited.status).toBe(429);
129
+ const body = (await limited.json()) as { error: { code: string } };
130
+ expect(body.error.code).toBe("rate_limited");
131
+ });
132
+ });
133
+
134
+ describe("extraRoutes: entry:anonymous under /api/* (kumiko-framework#3050 bypass fix)", () => {
135
+ const apiPingRoute: AnonymousExtraRoute = {
136
+ method: "GET",
137
+ path: "/api/public-ping",
138
+ entry: "anonymous",
139
+ handler: async (c) => c.json({ pong: true }),
140
+ };
141
+
142
+ test("without anonymousAccess wired, the jwtGuard still 401s — anonymous does NOT bypass /api/*", async () => {
143
+ const stack = await setupTestStack({ features: [probeFeature], extraRoutes: [apiPingRoute] });
144
+ try {
145
+ const res = await stack.app.request("/api/public-ping");
146
+ expect(res.status).toBe(401);
147
+ } finally {
148
+ await stack.cleanup();
149
+ }
150
+ });
151
+
152
+ test("with anonymousAccess wired, the request clears the guard exactly like any other anonymous /api/* call", async () => {
153
+ const stack = await setupTestStack({
154
+ features: [probeFeature],
155
+ extraRoutes: [apiPingRoute],
156
+ anonymousAccess: { defaultTenantId: TENANT_ID },
157
+ });
158
+ try {
159
+ const res = await stack.app.request("/api/public-ping");
160
+ expect(res.status).toBe(200);
161
+ } finally {
162
+ await stack.cleanup();
163
+ }
164
+ });
165
+ });
166
+
167
+ describe("extraRoutes: entry:anonymous deps.write (kumiko-framework#3050 anonymous write)", () => {
168
+ const writeRoute: AnonymousExtraRoute = {
169
+ method: "POST",
170
+ path: "/api/anon-write-probe",
171
+ entry: "anonymous",
172
+ handler: async (c, deps) => {
173
+ const result = await deps.write("anonymous-write-probe:write:anon-write", {
174
+ note: "from-anon",
175
+ });
176
+ return c.json(result);
177
+ },
178
+ };
179
+ const gatedWriteRoute: AnonymousExtraRoute = {
180
+ method: "POST",
181
+ path: "/api/anon-gated-write-probe",
182
+ entry: "anonymous",
183
+ handler: async (c, deps) => {
184
+ const result = await deps.write("anonymous-write-probe:write:anon-gated-write", {});
185
+ return c.json(result);
186
+ },
187
+ };
188
+ // Catches the thrown error itself and surfaces its message — proves what
189
+ // deps.write actually throws, not just that *something* threw.
190
+ const outsideApiWriteRoute: AnonymousExtraRoute = {
191
+ method: "POST",
192
+ path: "/public/anon-write-probe",
193
+ entry: "anonymous",
194
+ handler: async (c, deps) => {
195
+ try {
196
+ const result = await deps.write("anonymous-write-probe:write:anon-write", {
197
+ note: "should-not-persist",
198
+ });
199
+ return c.json(result);
200
+ } catch (e) {
201
+ return c.json({ error: { message: e instanceof Error ? e.message : String(e) } }, 500);
202
+ }
203
+ },
204
+ };
205
+
206
+ const OTHER_TENANT_ID = "00000000-0000-4000-8000-000000000099" as TenantId;
207
+ let stack: TestStack;
208
+
209
+ beforeAll(async () => {
210
+ anonymousWriteStore.clear();
211
+ stack = await setupTestStack({
212
+ features: [anonymousWriteFeature],
213
+ extraRoutes: [writeRoute, gatedWriteRoute, outsideApiWriteRoute],
214
+ // No defaultTenantId: X-Tenant picks the tenant per request, so the cross-tenant test below is real.
215
+ anonymousAccess: {
216
+ tenantExists: async (id) => id === TENANT_ID || id === OTHER_TENANT_ID,
217
+ },
218
+ });
219
+ });
220
+
221
+ afterAll(() => stack.cleanup());
222
+
223
+ test("under /api/ with anonymousAccess wired, deps.write runs against a handler allowing roles:['anonymous'] and actually persists", async () => {
224
+ const res = await stack.app.request("/api/anon-write-probe", {
225
+ method: "POST",
226
+ headers: { "X-Tenant": TENANT_ID },
227
+ });
228
+ expect(res.status).toBe(200);
229
+ const body = (await res.json()) as { isSuccess: boolean; data: { tenantSeen: string } };
230
+ expect(body.isSuccess).toBe(true);
231
+ expect(body.data.tenantSeen).toBe(TENANT_ID);
232
+
233
+ const notesRes = await stack.app.request("/api/query", {
234
+ method: "POST",
235
+ headers: { "content-type": "application/json", "X-Tenant": TENANT_ID },
236
+ body: JSON.stringify({ type: "anonymous-write-probe:query:anon-notes", payload: {} }),
237
+ });
238
+ expect(notesRes.status).toBe(200);
239
+ const notesBody = (await notesRes.json()) as { data: { notes: readonly string[] } };
240
+ expect(notesBody.data.notes).toContain("from-anon");
241
+ });
242
+
243
+ test("deps.write is access-checked as the anonymous session — a role-gated handler is denied, nothing written", async () => {
244
+ const before = anonymousWriteStore.get(TENANT_ID)?.length ?? 0;
245
+ const res = await stack.app.request("/api/anon-gated-write-probe", {
246
+ method: "POST",
247
+ headers: { "X-Tenant": TENANT_ID },
248
+ });
249
+ expect(res.status).toBe(200);
250
+ const body = (await res.json()) as { isSuccess: boolean; error?: { code: string } };
251
+ expect(body.isSuccess).toBe(false);
252
+ expect(body.error?.code).toBe("access_denied");
253
+ expect(anonymousWriteStore.get(TENANT_ID)?.length ?? 0).toBe(before);
254
+ });
255
+
256
+ test("deps.write parity: a Bearer token whose role clears the gate succeeds — same as calling /api/write directly (see the no-token case above, which 403s)", async () => {
257
+ const token = await stack.jwt.sign(TestUsers.admin);
258
+ const res = await stack.app.request("/api/anon-gated-write-probe", {
259
+ method: "POST",
260
+ headers: { Authorization: `Bearer ${token}` },
261
+ });
262
+ expect(res.status).toBe(200);
263
+ const body = (await res.json()) as { isSuccess: boolean };
264
+ expect(body.isSuccess).toBe(true);
265
+ });
266
+
267
+ test("deps.write lands in the request-resolved tenant, not a different one", async () => {
268
+ // The store is module-level; earlier tests already wrote into TENANT_ID.
269
+ anonymousWriteStore.clear();
270
+ const res = await stack.app.request("/api/anon-write-probe", {
271
+ method: "POST",
272
+ headers: { "X-Tenant": OTHER_TENANT_ID },
273
+ });
274
+ expect(res.status).toBe(200);
275
+ const body = (await res.json()) as { data: { tenantSeen: string } };
276
+ expect(body.data.tenantSeen).toBe(OTHER_TENANT_ID);
277
+ expect(anonymousWriteStore.get(OTHER_TENANT_ID)).toContain("from-anon");
278
+ expect(anonymousWriteStore.has(TENANT_ID)).toBe(false);
279
+ });
280
+
281
+ test("outside /api/, deps.write throws with a message naming the /api/ + anonymousAccess requirement, nothing written", async () => {
282
+ const before = anonymousWriteStore.get(TENANT_ID)?.length ?? 0;
283
+ const res = await stack.app.request("/public/anon-write-probe", { method: "POST" });
284
+ expect(res.status).toBe(500);
285
+ const body = (await res.json()) as { error: { message: string } };
286
+ expect(body.error.message).toMatch(/mounted\s+under\s+"\/api\/"\s+with\s+anonymousAccess/);
287
+ expect(anonymousWriteStore.get(TENANT_ID)?.length ?? 0).toBe(before);
288
+ });
289
+ });
290
+
291
+ describe("extraRoutes: entry:user", () => {
292
+ const selfWriteRoute: UserExtraRoute = {
293
+ method: "POST",
294
+ path: "/api/user-probe",
295
+ entry: "user",
296
+ handler: async (c, deps) => {
297
+ const result = await deps.write("extra-route-probe:write:self-write", { note: "hi" });
298
+ return c.json({ userId: deps.user.id, result });
299
+ },
300
+ };
301
+ const adminOnlyRoute: UserExtraRoute = {
302
+ method: "POST",
303
+ path: "/api/user-admin-probe",
304
+ entry: "user",
305
+ handler: async (c, deps) => {
306
+ const result = await deps.write("extra-route-probe:write:admin-only-write", {});
307
+ return c.json(result);
308
+ },
309
+ };
310
+
311
+ let stack: TestStack;
312
+
313
+ beforeAll(async () => {
314
+ stack = await setupTestStack({
315
+ features: [probeFeature],
316
+ extraRoutes: [selfWriteRoute, adminOnlyRoute],
317
+ });
318
+ });
319
+
320
+ afterAll(() => stack.cleanup());
321
+
322
+ test("without a session → 401 (jwtGuard rejects before the route ever runs)", async () => {
323
+ const res = await stack.app.request("/api/user-probe", { method: "POST" });
324
+ expect(res.status).toBe(401);
325
+ const body = (await res.json()) as { error: { code: string } };
326
+ expect(body.error.code).toBe("missing_token");
327
+ });
328
+
329
+ test("with a real session → 200, deps.user.id matches the JWT subject", async () => {
330
+ const token = await stack.jwt.sign(TestUsers.user);
331
+ const res = await stack.app.request("/api/user-probe", {
332
+ method: "POST",
333
+ headers: { Authorization: `Bearer ${token}` },
334
+ });
335
+ expect(res.status).toBe(200);
336
+ const body = (await res.json()) as { userId: string; result: { isSuccess: boolean } };
337
+ expect(body.userId).toBe(TestUsers.user.id);
338
+ expect(body.result.isSuccess).toBe(true);
339
+ });
340
+
341
+ test("deps.write runs access-checked as the calling user — 'User' role denied on an Admin-only handler", async () => {
342
+ const token = await stack.jwt.sign(TestUsers.user);
343
+ const res = await stack.app.request("/api/user-admin-probe", {
344
+ method: "POST",
345
+ headers: { Authorization: `Bearer ${token}` },
346
+ });
347
+ expect(res.status).toBe(200);
348
+ const body = (await res.json()) as { isSuccess: boolean; error?: { code: string } };
349
+ expect(body.isSuccess).toBe(false);
350
+ expect(body.error?.code).toBe("access_denied");
351
+ });
352
+
353
+ test("deps.write succeeds for a user whose role clears the handler's access check", async () => {
354
+ const token = await stack.jwt.sign(TestUsers.admin);
355
+ const res = await stack.app.request("/api/user-admin-probe", {
356
+ method: "POST",
357
+ headers: { Authorization: `Bearer ${token}` },
358
+ });
359
+ expect(res.status).toBe(200);
360
+ const body = (await res.json()) as { isSuccess: boolean };
361
+ expect(body.isSuccess).toBe(true);
362
+ });
363
+ });
364
+
365
+ describe("extraRoutes: entry:user under an anonymousAccess-wired server", () => {
366
+ const selfWriteRoute: UserExtraRoute = {
367
+ method: "POST",
368
+ path: "/api/user-probe",
369
+ entry: "user",
370
+ handler: async (c, deps) => c.json({ userId: deps.user.id }),
371
+ };
372
+
373
+ test("no JWT: jwtGuard synthesises an anonymous user, buildExtraRouteHonoHandler's own ANONYMOUS_ROLE check still 401s", async () => {
374
+ const stack = await setupTestStack({
375
+ features: [probeFeature],
376
+ extraRoutes: [selfWriteRoute],
377
+ anonymousAccess: { defaultTenantId: TENANT_ID },
378
+ });
379
+ try {
380
+ const res = await stack.app.request("/api/user-probe", { method: "POST" });
381
+ expect(res.status).toBe(401);
382
+ const body = (await res.json()) as { error: { code: string } };
383
+ expect(body.error.code).toBe("unauthenticated");
384
+ } finally {
385
+ await stack.cleanup();
386
+ }
387
+ });
388
+ });
389
+
390
+ test("extraRoutes: entry:user route mounted outside /api throws at boot", () => {
391
+ const registry = createRegistry([probeFeature]);
392
+ expect(() =>
393
+ buildServer({
394
+ registry,
395
+ context: {},
396
+ jwtSecret: JWT_SECRET,
397
+ extraRoutes: [
398
+ {
399
+ method: "GET",
400
+ path: "/user-outside-api",
401
+ entry: "user",
402
+ handler: async (c) => c.json({}),
403
+ },
404
+ ],
405
+ }),
406
+ ).toThrow(/must be\s+mounted under "\/api\/"/);
407
+ });
408
+
409
+ test("extraRoutes: entry:signature wildcard under /api throws at boot", () => {
410
+ const registry = createRegistry([probeFeature]);
411
+ expect(() =>
412
+ buildServer({
413
+ registry,
414
+ context: {},
415
+ jwtSecret: JWT_SECRET,
416
+ extraRoutes: [
417
+ signatureRoute({
418
+ method: "POST",
419
+ path: "/api/*",
420
+ entry: "signature",
421
+ verify: async () => true,
422
+ handler: async (c) => c.json({}),
423
+ }),
424
+ ],
425
+ }),
426
+ ).toThrow(/must not\s+use a wildcard under "\/api\/"/);
427
+ });
428
+
429
+ test("extraRoutes: an unknown entry value throws at boot", () => {
430
+ const registry = createRegistry([probeFeature]);
431
+ // A JS caller without the ExtraRouteDefinition type can construct this at
432
+ // runtime — buildServer must reject it at boot, not at first request.
433
+ const badRoute = {
434
+ method: "GET",
435
+ path: "/whatever",
436
+ entry: "admin",
437
+ handler: async () => new Response(),
438
+ } as unknown as ExtraRouteDefinition; // @cast-boundary simulates an untyped JS caller passing an unknown entry
439
+ expect(() =>
440
+ buildServer({
441
+ registry,
442
+ context: {},
443
+ jwtSecret: JWT_SECRET,
444
+ extraRoutes: [badRoute],
445
+ }),
446
+ ).toThrow(/unknown entry/);
447
+ });
448
+
449
+ describe("extraRoutes: entry:signature", () => {
450
+ const HMAC_KEY = "shared-hmac-key";
451
+
452
+ function signHmac(rawBody: string): string {
453
+ return createHmac("sha256", HMAC_KEY).update(rawBody).digest("hex");
454
+ }
455
+
456
+ const webhookRoute: SignatureExtraRoute<{ note: string }> = {
457
+ method: "POST",
458
+ path: "/webhooks/probe",
459
+ entry: "signature",
460
+ verify: async (req) => {
461
+ if (req.headers["x-force-404"] === "1") {
462
+ throw new ExtraRouteRejection(404, { error: "unknown-provider" });
463
+ }
464
+ if (req.headers["x-hmac"] !== signHmac(req.rawBody)) {
465
+ throw new Error("signature mismatch");
466
+ }
467
+ return { note: (JSON.parse(req.rawBody) as { note: string }).note };
468
+ },
469
+ handler: async (c, verified, deps) => {
470
+ const result = await deps.dispatchSystemWrite({
471
+ handlerQn: "extra-route-probe:write:hmac-write",
472
+ payload: { note: verified.note },
473
+ tenantId: TENANT_ID,
474
+ });
475
+ return c.json(result);
476
+ },
477
+ };
478
+
479
+ const webhookParamRoute: SignatureExtraRoute<{ provider: string }> = {
480
+ method: "POST",
481
+ path: "/api/webhooks/:provider",
482
+ entry: "signature",
483
+ verify: async (req) => {
484
+ if (req.headers["x-hmac"] !== signHmac(req.rawBody)) throw new Error("signature mismatch");
485
+ return { provider: req.params["provider"] ?? "" };
486
+ },
487
+ handler: async (c, verified) => c.json({ provider: verified.provider }),
488
+ };
489
+
490
+ let stack: TestStack;
491
+
492
+ beforeAll(async () => {
493
+ stack = await setupTestStack({
494
+ features: [probeFeature],
495
+ extraRoutes: [signatureRoute(webhookRoute), signatureRoute(webhookParamRoute)],
496
+ });
497
+ });
498
+
499
+ afterAll(() => stack.cleanup());
500
+
501
+ test("wrong signature → 401 extra_route_signature_invalid", async () => {
502
+ const res = await stack.app.request("/webhooks/probe", {
503
+ method: "POST",
504
+ headers: { "x-hmac": "wrong", "content-type": "application/json" },
505
+ body: JSON.stringify({ note: "x" }),
506
+ });
507
+ expect(res.status).toBe(401);
508
+ const body = (await res.json()) as { error: { code: string } };
509
+ expect(body.error.code).toBe("extra_route_signature_invalid");
510
+ });
511
+
512
+ test("right signature → 200, dispatchSystemWrite's SystemAdmin write is visible in the response", async () => {
513
+ const rawBody = JSON.stringify({ note: "from-webhook" });
514
+ const res = await stack.app.request("/webhooks/probe", {
515
+ method: "POST",
516
+ headers: { "x-hmac": signHmac(rawBody), "content-type": "application/json" },
517
+ body: rawBody,
518
+ });
519
+ expect(res.status).toBe(200);
520
+ const body = (await res.json()) as {
521
+ isSuccess: boolean;
522
+ data?: { tenantSeen: string; note: string };
523
+ };
524
+ expect(body.isSuccess).toBe(true);
525
+ expect(body.data?.tenantSeen).toBe(TENANT_ID);
526
+ expect(body.data?.note).toBe("from-webhook");
527
+ });
528
+
529
+ test("verify() throwing ExtraRouteRejection(404, body) surfaces exactly that body", async () => {
530
+ const res = await stack.app.request("/webhooks/probe", {
531
+ method: "POST",
532
+ headers: { "x-force-404": "1", "content-type": "application/json" },
533
+ body: JSON.stringify({ note: "x" }),
534
+ });
535
+ expect(res.status).toBe(404);
536
+ expect(await res.json()).toEqual({ error: "unknown-provider" });
537
+ });
538
+
539
+ test("mounted under /api/:provider — no session needed, honoPathToRegex matches the :param, rawBody arrives intact through /api/*", async () => {
540
+ const rawBody = JSON.stringify({ event: "payment.succeeded" });
541
+ const res = await stack.app.request("/api/webhooks/stripe", {
542
+ method: "POST",
543
+ headers: { "x-hmac": signHmac(rawBody), "content-type": "application/json" },
544
+ body: rawBody,
545
+ });
546
+ expect(res.status).toBe(200);
547
+ expect(await res.json()).toEqual({ provider: "stripe" });
548
+ });
549
+ });
550
+
551
+ // Type-level contract for ExtraRouteDefinition (kumiko-framework#3050): the
552
+ // bodies below are never invoked — tsc checks them, bun:test does not run
553
+ // them. Each `@ts-expect-error` turns into an "unused directive" compile
554
+ // error if the constraint ever stops firing.
555
+ function _requiresEntry(): ExtraRouteDefinition {
556
+ // @ts-expect-error — entry is required; a def without it must not satisfy ExtraRouteDefinition.
557
+ return { method: "GET", path: "/probe", handler: () => new Response() };
558
+ }
559
+
560
+ function _anonymousDepsHaveNoDispatchSystemWrite(): AnonymousExtraRoute {
561
+ return {
562
+ method: "GET",
563
+ path: "/probe",
564
+ entry: "anonymous",
565
+ handler: (c, deps) => {
566
+ // @ts-expect-error — anonymous deps expose systemQuery only, no dispatchSystemWrite.
567
+ void deps.dispatchSystemWrite;
568
+ return c.json({});
569
+ },
570
+ };
571
+ }
572
+
573
+ function _userDepsHaveNoDispatchSystemWrite(): UserExtraRoute {
574
+ return {
575
+ method: "GET",
576
+ path: "/api/probe",
577
+ entry: "user",
578
+ handler: (c, deps) => {
579
+ // @ts-expect-error — user deps expose query/write only, no dispatchSystemWrite.
580
+ void deps.dispatchSystemWrite;
581
+ return c.json({});
582
+ },
583
+ };
584
+ }