@cosmicdrift/kumiko-framework 0.296.0 → 0.299.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.296.0",
3
+ "version": "0.299.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.296.0",
202
- "@cosmicdrift/kumiko-types": "0.296.0",
201
+ "@cosmicdrift/kumiko-http": "0.299.0",
202
+ "@cosmicdrift/kumiko-types": "0.299.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.296.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.299.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -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
+ }
@@ -0,0 +1,146 @@
1
+ // ExtraRoute — declarative replacement for the old `extraRoutes: (app, deps)
2
+ // => void` closure (kumiko-framework#3050). Every entry declares its access
3
+ // tier (`entry`) up front; buildServer wires the matching guard + deps
4
+ // instead of handing every route a raw db/redis escape hatch.
5
+
6
+ import type { Context, Hono } from "hono";
7
+ import type {
8
+ HttpRouteMethod,
9
+ Registry,
10
+ SessionUser,
11
+ TenantId,
12
+ WriteResult,
13
+ } from "../engine/types";
14
+ import type { SecretsContext } from "../secrets";
15
+
16
+ export const ExtraRouteEntries = {
17
+ anonymous: "anonymous",
18
+ user: "user",
19
+ signature: "signature",
20
+ } as const;
21
+
22
+ export type ExtraRouteEntry = (typeof ExtraRouteEntries)[keyof typeof ExtraRouteEntries];
23
+
24
+ export type AnonymousExtraRouteDeps = {
25
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's generic-Param ist im Framework-Boundary unsichtbar
26
+ readonly app: Hono<any, any>;
27
+ readonly registry: Registry;
28
+ readonly systemQuery: (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown>;
29
+ /** Runs as the session the /api chain resolved for this request —
30
+ * anonymous when no token is sent, the authenticated user otherwise.
31
+ * Never more than that caller could already do via /api/write: same
32
+ * user, same request-resolved tenant (never overridable), only under
33
+ * "/api/" (the only path that populates a session user). */
34
+ readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
35
+ };
36
+
37
+ export type AnonymousExtraRoute = {
38
+ readonly method: HttpRouteMethod;
39
+ readonly path: string;
40
+ readonly entry: "anonymous";
41
+ readonly handler: (
42
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
43
+ c: Context<any, any>,
44
+ deps: AnonymousExtraRouteDeps,
45
+ ) => Response | Promise<Response>;
46
+ };
47
+
48
+ export type UserExtraRouteDeps = {
49
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's generic-Param ist im Framework-Boundary unsichtbar
50
+ readonly app: Hono<any, any>;
51
+ readonly registry: Registry;
52
+ readonly user: SessionUser;
53
+ readonly query: (type: string, payload: unknown) => Promise<unknown>;
54
+ readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
55
+ };
56
+
57
+ export type UserExtraRoute = {
58
+ readonly method: HttpRouteMethod;
59
+ readonly path: string;
60
+ readonly entry: "user";
61
+ readonly handler: (
62
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
63
+ c: Context<any, any>,
64
+ deps: UserExtraRouteDeps,
65
+ ) => Response | Promise<Response>;
66
+ };
67
+
68
+ export type SignatureExtraRouteVerifyRequest = {
69
+ readonly rawBody: string;
70
+ /** Lowercase header names — Hono/undici normalize incoming headers to
71
+ * lowercase, verify() must not have to re-normalize per provider. */
72
+ readonly headers: Readonly<Record<string, string>>;
73
+ readonly params: Readonly<Record<string, string>>;
74
+ readonly query: Readonly<Record<string, string>>;
75
+ };
76
+
77
+ export type SignatureExtraRouteVerifyDeps = {
78
+ readonly registry: Registry;
79
+ readonly secrets?: SecretsContext;
80
+ };
81
+
82
+ export type SystemDispatchArgs = {
83
+ readonly handlerQn: string;
84
+ readonly payload: unknown;
85
+ readonly tenantId: TenantId;
86
+ };
87
+
88
+ export type SignatureExtraRouteDeps = {
89
+ // biome-ignore lint/suspicious/noExplicitAny: Hono's generic-Param ist im Framework-Boundary unsichtbar
90
+ readonly app: Hono<any, any>;
91
+ readonly registry: Registry;
92
+ readonly secrets?: SecretsContext;
93
+ readonly systemQuery: (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown>;
94
+ /** Privilege scope: SystemAdmin of the target tenant, WITHOUT the route's
95
+ * access check — only reachable because verify() has already proven the
96
+ * caller's authenticity (signature, HMAC state, etc.). */
97
+ readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
98
+ readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
99
+ };
100
+
101
+ export type SignatureExtraRoute<TVerified> = {
102
+ readonly method: HttpRouteMethod;
103
+ readonly path: string;
104
+ readonly entry: "signature";
105
+ readonly verify: (
106
+ request: SignatureExtraRouteVerifyRequest,
107
+ deps: SignatureExtraRouteVerifyDeps,
108
+ ) => Promise<TVerified>;
109
+ readonly handler: (
110
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
111
+ c: Context<any, any>,
112
+ verified: TVerified,
113
+ deps: SignatureExtraRouteDeps,
114
+ ) => Response | Promise<Response>;
115
+ };
116
+
117
+ export type ExtraRouteDefinition =
118
+ | AnonymousExtraRoute
119
+ | UserExtraRoute
120
+ | SignatureExtraRoute<unknown>;
121
+
122
+ /** Narrows a `SignatureExtraRoute<T>` into the storable `ExtraRouteDefinition`
123
+ * union. The single point where the verify/handler generic `T` is erased —
124
+ * callers keep full type-safety between their own verify() and handler(). */
125
+ export function signatureRoute<T>(def: SignatureExtraRoute<T>): ExtraRouteDefinition {
126
+ // @cast-boundary generic erasure at the public ExtraRouteDefinition boundary;
127
+ // verify() and handler() above stay paired through T at the call site.
128
+ return def as unknown as SignatureExtraRoute<unknown>;
129
+ }
130
+
131
+ export type ExtraRouteRejectionStatus = 400 | 401 | 403 | 404 | 500;
132
+
133
+ /** Thrown by `verify()` to reject a signature route with a specific status +
134
+ * JSON body. Any other throw from `verify()` is mapped to 401
135
+ * `extra_route_signature_invalid` by the buildServer wrapper. */
136
+ export class ExtraRouteRejection extends Error {
137
+ readonly status: ExtraRouteRejectionStatus;
138
+ readonly body: unknown;
139
+
140
+ constructor(status: ExtraRouteRejectionStatus, body: unknown, message?: string) {
141
+ super(message ?? `extra route rejected with status ${status}`);
142
+ this.name = "ExtraRouteRejection";
143
+ this.status = status;
144
+ this.body = body;
145
+ }
146
+ }
package/src/api/index.ts CHANGED
@@ -28,6 +28,21 @@ export {
28
28
  createInMemoryLoginRateLimiter,
29
29
  createRedisLoginRateLimiter,
30
30
  } from "./auth-routes";
31
+ export type {
32
+ AnonymousExtraRoute,
33
+ AnonymousExtraRouteDeps,
34
+ ExtraRouteDefinition,
35
+ ExtraRouteEntry,
36
+ ExtraRouteRejectionStatus,
37
+ SignatureExtraRoute,
38
+ SignatureExtraRouteDeps,
39
+ SignatureExtraRouteVerifyDeps,
40
+ SignatureExtraRouteVerifyRequest,
41
+ SystemDispatchArgs,
42
+ UserExtraRoute,
43
+ UserExtraRouteDeps,
44
+ } from "./extra-route";
45
+ export { ExtraRouteEntries, ExtraRouteRejection, signatureRoute } from "./extra-route";
31
46
  export type { CachedResponseInit, CachePolicy } from "./http-cache";
32
47
  export {
33
48
  cacheControlHeader,
@@ -50,7 +65,7 @@ export {
50
65
  } from "./request-id-middleware";
51
66
  export { createApiRoutes } from "./routes";
52
67
  export type { KumikoServer, ServerOptions } from "./server";
53
- export { buildServer } from "./server";
68
+ export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server";
54
69
  export type { SseBroker, SseClient, SseEvent } from "./sse-broker";
55
70
  export { createSseBroker } from "./sse-broker";
56
71
  export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
package/src/api/server.ts CHANGED
@@ -1,10 +1,19 @@
1
1
  import { Hono } from "hono";
2
+ import { ROLES } from "../auth/roles";
2
3
  import type { DbConnection, PgClient } from "../db/connection";
3
4
  import { createDerivativesContext } from "../derivatives/derivatives-context";
4
5
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names";
5
6
  import { runsInLane } from "../engine/run-in";
6
- import { createAnonymousUser } from "../engine/system-user";
7
- import { type AppContext, isFileField, type Registry, type RunIn } from "../engine/types";
7
+ import { ANONYMOUS_ROLE, createAnonymousUser, createSystemUser } from "../engine/system-user";
8
+ import {
9
+ type AppContext,
10
+ type HttpRouteMethod,
11
+ isFileField,
12
+ type Registry,
13
+ type RunIn,
14
+ type TenantId,
15
+ type WriteResult,
16
+ } from "../engine/types";
8
17
  import { createFileContext } from "../files/file-handle";
9
18
  import type { FileRoutesOptions } from "../files/file-routes";
10
19
  import { createFileRoutes } from "../files/file-routes";
@@ -52,6 +61,13 @@ import {
52
61
  } from "./auth-middleware";
53
62
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
54
63
  import { csrfMiddleware } from "./csrf-middleware";
64
+ import {
65
+ type ExtraRouteDefinition,
66
+ ExtraRouteEntries,
67
+ type ExtraRouteEntry,
68
+ ExtraRouteRejection,
69
+ type SystemDispatchArgs,
70
+ } from "./extra-route";
55
71
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
56
72
  import { observabilityMiddleware } from "./observability-middleware";
57
73
  import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
@@ -219,6 +235,13 @@ export type ServerOptions = {
219
235
  // (defaultTenantId only); run{Prod,Dev}App merge auth-foundation tenant
220
236
  // providers into AnonymousAccessResolved before calling buildServer.
221
237
  anonymousAccess?: AnonymousAccessResolved;
238
+ // Declarative HTTP routes outside the /api/write|query|batch pipeline that
239
+ // still need the framework's dispatcher (webhooks, OAuth callbacks, admin
240
+ // escape-hatches). Each entry declares its access tier (`entry`) up
241
+ // front — buildServer wires the matching guard + deps, no handler gets a
242
+ // raw db/redis. Mounted right after the r.httpRoute loop, before
243
+ // registerVersionRoute (kumiko-framework#3050).
244
+ extraRoutes?: readonly ExtraRouteDefinition[];
222
245
  };
223
246
 
224
247
  export type KumikoServer = {
@@ -606,6 +629,13 @@ export function buildServer(options: ServerOptions): KumikoServer {
606
629
 
607
630
  const app = new Hono();
608
631
 
632
+ // Only entry:"signature" bypasses jwtGuard (verify() authenticates
633
+ // itself); entry:"anonymous" still needs the anonymousAccess fallthrough
634
+ // (tenant-by-host), entry:"user" needs c.get("user") populated.
635
+ const extraRoutePublicMatchers = compileExtraRoutePublicMatchers(options.extraRoutes);
636
+ const isExtraRoutePublicPath = (c: import("hono").Context): boolean =>
637
+ extraRoutePublicMatchers.some((m) => m.method === c.req.method && m.pattern.test(c.req.path));
638
+
609
639
  const sensitiveConfig = mergeSensitiveConfig(
610
640
  options.observabilityOptions?.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG,
611
641
  );
@@ -689,7 +719,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
689
719
  ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
690
720
  });
691
721
  app.use("/api/*", async (c, next) => {
692
- if (PUBLIC_API_PATHS.has(c.req.path)) return next();
722
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
693
723
  return jwtGuard(c, next);
694
724
  });
695
725
 
@@ -701,7 +731,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
701
731
  const patRateLimiter = options.auth?.patRateLimiter;
702
732
  if (patRateLimiter) {
703
733
  app.use("/api/*", async (c, next) => {
704
- if (PUBLIC_API_PATHS.has(c.req.path)) return next();
734
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
705
735
  const pat = getUser(c)?.pat;
706
736
  if (pat && !(await patRateLimiter.check(pat.tokenId))) {
707
737
  return c.json(
@@ -733,7 +763,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
733
763
  if (allowedOrigins && allowedOrigins.length > 0) {
734
764
  const originGuard = originMiddleware(allowedOrigins);
735
765
  app.use("/api/*", async (c, next) => {
736
- if (PUBLIC_API_PATHS.has(c.req.path)) return next();
766
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
737
767
  return originGuard(c, next);
738
768
  });
739
769
  }
@@ -747,7 +777,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
747
777
  // are covered uniformly.
748
778
  const csrfGuard = csrfMiddleware();
749
779
  app.use("/api/*", async (c, next) => {
750
- if (PUBLIC_API_PATHS.has(c.req.path)) return next();
780
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
751
781
  return csrfGuard(c, next);
752
782
  });
753
783
 
@@ -815,55 +845,74 @@ export function buildServer(options: ServerOptions): KumikoServer {
815
845
  const honoHandler = async (c: import("hono").Context): Promise<Response> =>
816
846
  route.handler(c, {
817
847
  app,
818
- systemQuery: (type, payload, tenantId) =>
819
- // createAnonymousUser, NOT createSystemUser: httpRoute handlers
820
- // using systemQuery are, by construction, `anonymous: true`
821
- // public routes the synthesized user must clear the SAME
822
- // access gate a real anonymous visitor would, no more. The
823
- // system role would ALSO satisfy that gate here, but it can
824
- // read fields gated to "system" that "anonymous" can't
825
- // (filterReadFields is a plain role-in-map check) a future
826
- // systemQuery caller reading a system-gated field would leak
827
- // it into a public response. The forced tenant already comes
828
- // from bypassing the HTTP layer entirely; no elevated role
829
- // is needed or wanted on top of that.
830
- //
831
- // httpRoute handlers run OUTSIDE /api/* — requestIdMiddleware
832
- // (which wraps requestContext.run with ip/requestId/
833
- // correlationId) never sees this request. Without this wrap,
834
- // `rateLimit: {per: "ip", ...}` on a handler invoked via
835
- // systemQuery is silent dead-code: enforceRateLimit reads
836
- // requestContext.get()?.ip, which is undefined here, so
837
- // buildBucketKey always returns {kind: "skip"}.
838
- requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
839
- dispatcher.query(type, payload, createAnonymousUser(tenantId)),
840
- ),
848
+ // createAnonymousUser, NOT createSystemUser: httpRoute handlers
849
+ // using systemQuery are, by construction, `anonymous: true`
850
+ // public routes the synthesized user must clear the SAME
851
+ // access gate a real anonymous visitor would, no more. The
852
+ // system role would ALSO satisfy that gate here, but it can
853
+ // read fields gated to "system" that "anonymous" can't
854
+ // (filterReadFields is a plain role-in-map check) a future
855
+ // systemQuery caller reading a system-gated field would leak
856
+ // it into a public response. The forced tenant already comes
857
+ // from bypassing the HTTP layer entirely; no elevated role
858
+ // is needed or wanted on top of that.
859
+ systemQuery: makeSystemQuery(c, dispatcher),
841
860
  });
842
- switch (route.method) {
843
- case "GET":
844
- app.get(route.path, honoHandler);
845
- break;
846
- case "POST":
847
- app.post(route.path, honoHandler);
848
- break;
849
- case "PUT":
850
- app.put(route.path, honoHandler);
851
- break;
852
- case "PATCH":
853
- app.patch(route.path, honoHandler);
854
- break;
855
- case "DELETE":
856
- app.delete(route.path, honoHandler);
857
- break;
858
- case "OPTIONS":
859
- case "HEAD":
860
- // Hono-on() für die Methoden ohne Convenience-Method.
861
- app.on(route.method, route.path, honoHandler);
862
- break;
863
- default:
864
- assertUnreachable(route.method, "http method");
861
+ mountHonoRoute(app, route.method, route.path, honoHandler);
862
+ }
863
+ }
864
+
865
+ // extraRoutes (kumiko-framework#3050) — declarative HTTP-routes with a
866
+ // Pflicht `entry` tier. Mounted after r.httpRoute for the same reason: an
867
+ // extraRoute dispatching through `dispatcher` builds Hono's matcher, so
868
+ // this must run before any seed that also dispatches (runProdApp/
869
+ // createKumikoServer call buildServer before seeding).
870
+ if (options.extraRoutes) {
871
+ // Boot-time validation for the whole list BEFORE mounting anything —
872
+ // an app with one bad route should fail loud at boot, not mount N-1
873
+ // routes and then throw on route N.
874
+ for (const route of options.extraRoutes) {
875
+ if (!isKnownExtraRouteEntry(route.entry)) {
876
+ throw new Error(
877
+ `[kumiko] extraRoutes: unknown entry "${String(route.entry)}" on ` +
878
+ `"${route.method} ${route.path}" — expected "anonymous" | "user" | "signature". ` +
879
+ "A JS caller without the ExtraRouteDefinition type can hit this at boot.",
880
+ );
881
+ }
882
+ if (route.entry === ExtraRouteEntries.user && !route.path.startsWith("/api/")) {
883
+ throw new Error(
884
+ `[kumiko] extraRoutes: entry:"user" route "${route.method} ${route.path}" must be ` +
885
+ "mounted under \"/api/\" — that's the only path prefix that rides the framework's " +
886
+ "jwtGuard chain, which is what populates deps.user.",
887
+ );
888
+ }
889
+ // A signature route under /api/ skips jwtGuard/origin/csrf for every
890
+ // path its pattern matches — a wildcard would switch off auth for
891
+ // unrelated /api/* handlers (e.g. "/api/*" swallows /api/write).
892
+ if (
893
+ route.entry === ExtraRouteEntries.signature &&
894
+ route.path.startsWith("/api/") &&
895
+ route.path.includes("*")
896
+ ) {
897
+ throw new Error(
898
+ `[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
899
+ 'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.',
900
+ );
865
901
  }
866
902
  }
903
+ const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
904
+ const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
905
+ for (const route of options.extraRoutes) {
906
+ const honoHandler = buildExtraRouteHonoHandler(route, {
907
+ app,
908
+ dispatcher,
909
+ registry: options.registry,
910
+ secrets: contextWithObservability.secrets,
911
+ dispatchSystemWrite,
912
+ dispatchSystemQuery,
913
+ });
914
+ mountHonoRoute(app, route.method, route.path, honoHandler);
915
+ }
867
916
  }
868
917
 
869
918
  // /version-Default registriert NACH feature-routes — Hono "first match
@@ -900,6 +949,222 @@ export function buildServer(options: ServerOptions): KumikoServer {
900
949
  };
901
950
  }
902
951
 
952
+ // Method-switch shared by r.httpRoute and extraRoutes — one place to keep
953
+ // the two mounting paths from drifting on which Hono methods get a
954
+ // convenience-call vs. app.on().
955
+ function mountHonoRoute(
956
+ app: Hono,
957
+ method: HttpRouteMethod,
958
+ path: string,
959
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
960
+ handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>,
961
+ ): void {
962
+ switch (method) {
963
+ case "GET":
964
+ app.get(path, handler);
965
+ break;
966
+ case "POST":
967
+ app.post(path, handler);
968
+ break;
969
+ case "PUT":
970
+ app.put(path, handler);
971
+ break;
972
+ case "PATCH":
973
+ app.patch(path, handler);
974
+ break;
975
+ case "DELETE":
976
+ app.delete(path, handler);
977
+ break;
978
+ case "OPTIONS":
979
+ case "HEAD":
980
+ // Hono's on() for the methods without a convenience method.
981
+ app.on(method, path, handler);
982
+ break;
983
+ default:
984
+ assertUnreachable(method, "http method");
985
+ }
986
+ }
987
+
988
+ // Shared systemQuery builder for r.httpRoute and extraRoutes (anonymous +
989
+ // signature entries). requestContext.run must wrap the dispatcher call —
990
+ // both route kinds run outside the requestIdMiddleware chain that normally
991
+ // populates it, and `rateLimit: {per: "ip", ...}` on a handler invoked
992
+ // through systemQuery is otherwise silent dead-code (enforceRateLimit reads
993
+ // requestContext.get()?.ip, undefined without this wrap).
994
+ function makeSystemQuery(
995
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
996
+ c: import("hono").Context<any, any>,
997
+ dispatcher: Dispatcher,
998
+ ): (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown> {
999
+ return (type, payload, tenantId) =>
1000
+ requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
1001
+ dispatcher.query(type, payload, createAnonymousUser(tenantId)),
1002
+ );
1003
+ }
1004
+
1005
+ // SystemAdmin write/query builders shared by buildServer's `extraRoutes`
1006
+ // mount and server-runtime's `wire` hook (runProdApp/createKumikoServer,
1007
+ // after buildServer). Privilege-scope: SystemAdmin is the highest
1008
+ // non-tenant-scoped role — reaches ANY SystemAdmin-gated handler on ANY
1009
+ // tenant. Only safe for callers that already proved their own authenticity
1010
+ // (signature verify(), HMAC state, ...), never exposed to a raw request.
1011
+ export function makeDispatchSystemWrite(
1012
+ dispatcher: Dispatcher,
1013
+ ): (args: SystemDispatchArgs) => Promise<WriteResult> {
1014
+ return ({ handlerQn, payload, tenantId }) =>
1015
+ dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
1016
+ }
1017
+
1018
+ export function makeDispatchSystemQuery(
1019
+ dispatcher: Dispatcher,
1020
+ ): (args: SystemDispatchArgs) => Promise<unknown> {
1021
+ return ({ handlerQn, payload, tenantId }) =>
1022
+ dispatcher.query(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
1023
+ }
1024
+
1025
+ // Same dispatcher.write(...) as entry:"user", but the user is getUser(c) —
1026
+ // see AnonymousExtraRouteDeps.write for the privilege/tenant contract.
1027
+ function makeAnonymousWrite(
1028
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1029
+ c: import("hono").Context<any, any>,
1030
+ dispatcher: Dispatcher,
1031
+ ): (type: string, payload: unknown) => Promise<WriteResult> {
1032
+ return (type, payload) => {
1033
+ const user = getUser(c);
1034
+ if (!user) {
1035
+ throw new Error(
1036
+ '[kumiko] extraRoutes: entry:"anonymous" deps.write requires this route to be mounted ' +
1037
+ 'under "/api/" with anonymousAccess wired — no request-resolved session user was found.',
1038
+ );
1039
+ }
1040
+ return dispatcher.write(type, payload, user);
1041
+ };
1042
+ }
1043
+
1044
+ function isKnownExtraRouteEntry(entry: unknown): entry is ExtraRouteEntry {
1045
+ return (
1046
+ entry === ExtraRouteEntries.anonymous ||
1047
+ entry === ExtraRouteEntries.user ||
1048
+ entry === ExtraRouteEntries.signature
1049
+ );
1050
+ }
1051
+
1052
+ // Hono path pattern ("/api/foo/:bar", "/api/foo/*") → RegExp. Only the
1053
+ // subset extraRoutes actually uses (static segments, `:param`, trailing
1054
+ // `*`) — no inline `:param{regex}` constraints, no optional `:param?`.
1055
+ function honoPathToRegex(path: string): RegExp {
1056
+ const segments = path
1057
+ .split("/")
1058
+ .map((segment) => {
1059
+ if (segment.startsWith(":")) return "[^/]+";
1060
+ if (segment === "*") return ".*";
1061
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1062
+ })
1063
+ .join("/");
1064
+ return new RegExp(`^${segments}$`);
1065
+ }
1066
+
1067
+ type ExtraRoutePublicMatcher = { readonly method: string; readonly pattern: RegExp };
1068
+
1069
+ // See the bypass comment at the jwtGuard mount above — only signature
1070
+ // routes are public here.
1071
+ function compileExtraRoutePublicMatchers(
1072
+ extraRoutes: readonly ExtraRouteDefinition[] | undefined,
1073
+ ): readonly ExtraRoutePublicMatcher[] {
1074
+ if (!extraRoutes) return [];
1075
+ return extraRoutes
1076
+ .filter((route) => route.entry === ExtraRouteEntries.signature)
1077
+ .map((route) => ({ method: route.method, pattern: honoPathToRegex(route.path) }));
1078
+ }
1079
+
1080
+ type ExtraRouteHonoHandlerDeps = {
1081
+ readonly app: Hono;
1082
+ readonly dispatcher: Dispatcher;
1083
+ readonly registry: Registry;
1084
+ readonly secrets: import("../secrets").SecretsContext | undefined;
1085
+ readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
1086
+ readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
1087
+ };
1088
+
1089
+ function buildExtraRouteHonoHandler(
1090
+ route: ExtraRouteDefinition,
1091
+ shared: ExtraRouteHonoHandlerDeps,
1092
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1093
+ ): (c: import("hono").Context<any, any>) => Promise<Response> {
1094
+ switch (route.entry) {
1095
+ case ExtraRouteEntries.anonymous:
1096
+ return async (c) =>
1097
+ route.handler(c, {
1098
+ app: shared.app,
1099
+ registry: shared.registry,
1100
+ systemQuery: makeSystemQuery(c, shared.dispatcher),
1101
+ write: makeAnonymousWrite(c, shared.dispatcher),
1102
+ });
1103
+ case ExtraRouteEntries.user:
1104
+ return async (c) => {
1105
+ const user = getUser(c);
1106
+ if (!user || user.roles.includes(ANONYMOUS_ROLE)) {
1107
+ return c.json(
1108
+ {
1109
+ error: {
1110
+ code: "unauthenticated",
1111
+ httpStatus: 401,
1112
+ message: "this route requires a signed-in user",
1113
+ i18nKey: "auth.errors.missingToken",
1114
+ },
1115
+ },
1116
+ 401,
1117
+ );
1118
+ }
1119
+ return route.handler(c, {
1120
+ app: shared.app,
1121
+ registry: shared.registry,
1122
+ user,
1123
+ query: (type, payload) => shared.dispatcher.query(type, payload, user),
1124
+ write: (type, payload) => shared.dispatcher.write(type, payload, user),
1125
+ });
1126
+ };
1127
+ case ExtraRouteEntries.signature:
1128
+ return async (c) => {
1129
+ const rawBody = await c.req.text();
1130
+ const headers: Record<string, string> = {};
1131
+ c.req.raw.headers.forEach((value, key) => {
1132
+ headers[key.toLowerCase()] = value;
1133
+ });
1134
+ let verified: unknown;
1135
+ try {
1136
+ verified = await route.verify(
1137
+ { rawBody, headers, params: c.req.param(), query: c.req.query() },
1138
+ { registry: shared.registry, secrets: shared.secrets },
1139
+ );
1140
+ } catch (e) {
1141
+ if (e instanceof ExtraRouteRejection) {
1142
+ return c.json(e.body, e.status);
1143
+ }
1144
+ return c.json(
1145
+ {
1146
+ error: {
1147
+ code: "extra_route_signature_invalid",
1148
+ message: e instanceof Error ? e.message : String(e),
1149
+ },
1150
+ },
1151
+ 401,
1152
+ );
1153
+ }
1154
+ return route.handler(c, verified, {
1155
+ app: shared.app,
1156
+ registry: shared.registry,
1157
+ secrets: shared.secrets,
1158
+ systemQuery: makeSystemQuery(c, shared.dispatcher),
1159
+ dispatchSystemWrite: shared.dispatchSystemWrite,
1160
+ dispatchSystemQuery: shared.dispatchSystemQuery,
1161
+ });
1162
+ };
1163
+ default:
1164
+ return assertUnreachable(route, "extra route entry");
1165
+ }
1166
+ }
1167
+
903
1168
  function deriveTenantLifecycleResolver(
904
1169
  registry: Registry,
905
1170
  db: DbConnection | undefined,
package/src/changes.json CHANGED
@@ -1,4 +1,15 @@
1
1
  [
2
+ {
3
+ "version": "0.298.0",
4
+ "type": "improvement",
5
+ "title": "Anonymous extraRoutes get `write`, running as the session the /api chain resolved for this request (never more than that caller could do via /api/write, request-resolved tenant, /api/ only)."
6
+ },
7
+ {
8
+ "version": "0.298.0",
9
+ "type": "breaking",
10
+ "title": "extraRoutes/hostDispatch move to structured route and wire definitions",
11
+ "migration": "extraRoutes on runProdApp/createKumikoServer/runDevApp/setupTestStack changes from (app, deps) => void to readonly ExtraRouteDefinition[]. Each entry is { method, path, entry: \"anonymous\" | \"user\" | \"signature\", handler }, built via the helpers in @cosmicdrift/kumiko-framework/api; a signature route also needs verify(request, deps) via signatureRoute<T>(). An anonymous GET route that used to call app.get(path, handler) on the raw app now receives { app, registry, systemQuery } - replace direct db/redis reads with systemQuery. A route reading user data via a raw db handle now declares entry: \"user\" (path must live under /api/, unauthenticated requests get 401 automatically) and receives { app, registry, user, query, write } instead of db/redis. A route verifying an external signature (webhooks) declares entry: \"signature\" and receives { app, registry, secrets?, systemQuery, dispatchSystemWrite, dispatchSystemQuery }; reject invalid signatures with ExtraRouteRejection(status, body) from verify. Non-route setup that used to run inside the old extraRoutes(app, deps) callback (late-binding, background seeds, starting a runner) moves to the new wire?: (deps: SystemWireDeps) => void | Promise<void> option on runProdApp/createKumikoServer, which gets { db, redis, registry, dispatchSystemWrite } but no app. hostDispatch (dev) and HostDispatchFn (runProdApp) gain a second argument { systemQuery }; an app.use middleware that read db directly for host dispatch now uses systemQuery instead."
12
+ },
2
13
  {
3
14
  "version": "0.296.0",
4
15
  "type": "breaking",
@@ -5,11 +5,11 @@ export { SYSTEM_USER_ID };
5
5
 
6
6
  export const SYSTEM_ROLE = "system" as const;
7
7
 
8
- // extraRoles: hasAccess kennt keinen System-BypassHandler gaten auf
9
- // explizite Rollen. Caller, die Handler mit z.B. SystemAdmin-Gate erreichen
10
- // müssen (extraRoutes.dispatchSystemWrite billing-foundation
11
- // process-event), geben die Rolle hier zusätzlich mit; createdBy bleibt
12
- // SYSTEM_USER_ID, der Audit-Trail zeigt weiterhin System.
8
+ // extraRoles: hasAccess has no system bypass handlers gate on
9
+ // explicit roles. Callers that must reach handlers gated e.g. on
10
+ // SystemAdmin (dispatchSystemWrite on entry:"signature" routes or the
11
+ // `wire` hook → billing-foundation process-event) pass the role here
12
+ // additionally; createdBy stays SYSTEM_USER_ID, the audit trail still shows System.
13
13
  export function createSystemUser(
14
14
  tenantId: TenantId,
15
15
  extraRoles: readonly string[] = [],
@@ -87,6 +87,7 @@ export type ApiEntrypointOptions = BaseEntrypointOptions & {
87
87
  readonly maxRequestBytes?: ServerOptions["maxRequestBytes"];
88
88
  readonly readiness?: ServerOptions["readiness"];
89
89
  readonly metrics?: ServerOptions["metrics"];
90
+ readonly extraRoutes?: ServerOptions["extraRoutes"];
90
91
  // Job-enqueue surface for the API process. Required whenever the registry
91
92
  // defines event-triggered jobs: command-dispatcher fires handleEvent as
92
93
  // an afterCommit-hook — without a jobRunner the enqueue silently drops.
@@ -236,6 +237,7 @@ function buildApiServer(
236
237
  maxRequestBytes: opts.maxRequestBytes,
237
238
  readiness: opts.readiness,
238
239
  metrics: opts.metrics,
240
+ extraRoutes: opts.extraRoutes,
239
241
  observability: opts.observability,
240
242
  observabilityOptions: opts.observabilityOptions,
241
243
  dispatcherOptions,
@@ -42,8 +42,9 @@ export type TestStack = {
42
42
  // to assert a consumer pushed an invalidation without opening a real SSE
43
43
  // connection.
44
44
  sseBroker: SseBroker;
45
- // Command-dispatcher behind the HTTP routes — for direct system-writes
46
- // in tests and dev-server extraRoutes (provider-webhook wiring).
45
+ // Command-dispatcher behind the HTTP routes — for direct system-writes in
46
+ // tests and behind entry:"signature" extraRoutes / the `wire` hook
47
+ // (provider-webhook wiring).
47
48
  dispatcher: Dispatcher;
48
49
  // The AppContext buildServer handed the request path, incl. the fields it
49
50
  // wires itself (_fileProviderResolver). A dev-server that starts its own
@@ -105,6 +106,10 @@ export type TestStackOptions = {
105
106
  * The resolver is auto-built from the test Redis. Mirrors
106
107
  * buildServer's `rateLimit` option 1:1 — see there for shape. */
107
108
  rateLimit?: import("../api/server").ServerOptions["rateLimit"];
109
+ /** Forwarded to buildServer — Integration-Tests that exercise `extraRoutes`
110
+ * MUST go through here (real HTTP via `stack.http`/`stack.app.fetch`),
111
+ * never `createTestDispatcher`. */
112
+ extraRoutes?: import("../api/server").ServerOptions["extraRoutes"];
108
113
  /** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
109
114
  * AppContext — set/delete/get + rotation job pick it up. Omit for
110
115
  * suites that don't touch secrets. */
@@ -404,6 +409,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
404
409
  },
405
410
  eventDedup,
406
411
  sseBroker,
412
+ ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
407
413
  // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
408
414
  // deterministic drains — no timer-induced flakiness. pollIntervalMs
409
415
  // stays short anyway in case a test opts into `.start()`. pgClient