@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
@@ -0,0 +1,114 @@
1
+ // kumiko-framework#2885 step 3: `HttpRouteDefinition.anonymous` now controls
2
+ // the mount, not just docs/boot-validation. `anonymous: false` must sit
3
+ // behind the SAME session-auth chain /api/* uses (no anonymous fallthrough,
4
+ // PAT rate limit, origin + CSRF guards) — a request without a session gets
5
+ // 401, never a synthesized anonymous user. `anonymous: true` stays public.
6
+ //
7
+ // anonymousAccess is deliberately wired on the test stack: without it, a
8
+ // wrongly-mounted `anonymous: false` route (using jwtGuard instead of
9
+ // sessionOnlyGuard) would ALSO 401 on a missing token, masking the bug the
10
+ // 401 test exists to catch.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { defineFeature } from "../../engine";
14
+ import type { TenantId } from "../../engine/types/identifiers";
15
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
16
+ import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, getUser } from "../auth-middleware";
17
+
18
+ const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
19
+
20
+ const entryFeature = defineFeature("http-route-entry", (r) => {
21
+ r.httpRoute({
22
+ method: "GET",
23
+ path: "/entry-public",
24
+ anonymous: true,
25
+ handler: async (c) => c.json({ ok: true }),
26
+ });
27
+ r.httpRoute({
28
+ method: "GET",
29
+ path: "/entry-private",
30
+ anonymous: false,
31
+ handler: async (c) => c.json({ id: getUser(c).id }),
32
+ });
33
+ r.httpRoute({
34
+ method: "POST",
35
+ path: "/entry-private",
36
+ anonymous: false,
37
+ handler: async (c) => c.json({ id: getUser(c).id }),
38
+ });
39
+ });
40
+
41
+ let stack: TestStack;
42
+
43
+ beforeAll(async () => {
44
+ stack = await setupTestStack({
45
+ features: [entryFeature],
46
+ anonymousAccess: { defaultTenantId: TENANT_ID },
47
+ });
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await stack.cleanup();
52
+ });
53
+
54
+ describe("r.httpRoute anonymous:false — session auth chain", () => {
55
+ test("GET without a session → 401, no anonymous fallthrough", async () => {
56
+ const res = await stack.app.request("/entry-private");
57
+ expect(res.status).toBe(401);
58
+ const body = (await res.json()) as { error: { code: string } };
59
+ expect(body.error.code).toBe("missing_token");
60
+ });
61
+
62
+ // Hono answers HEAD through the GET route while c.req.method stays "HEAD";
63
+ // the guard chain must still run for it.
64
+ test("HEAD without a session → 401, not the GET handler unguarded", async () => {
65
+ const res = await stack.app.request("/entry-private", { method: "HEAD" });
66
+ expect(res.status).toBe(401);
67
+ });
68
+
69
+ test("GET with a real session → 200, handler sees the logged-in user, not anonymous", async () => {
70
+ const token = await stack.jwt.sign(TestUsers.user);
71
+ const res = await stack.app.request("/entry-private", {
72
+ headers: { Authorization: `Bearer ${token}` },
73
+ });
74
+ expect(res.status).toBe(200);
75
+ const body = (await res.json()) as { id: string };
76
+ expect(body.id).toBe(TestUsers.user.id);
77
+ expect(body.id).not.toBe("anonymous");
78
+ });
79
+
80
+ test("POST with cookie auth, no CSRF token → 403 csrf_token_mismatch", async () => {
81
+ const token = await stack.jwt.sign(TestUsers.user);
82
+ const res = await stack.app.request("/entry-private", {
83
+ method: "POST",
84
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
85
+ });
86
+ expect(res.status).toBe(403);
87
+ const body = (await res.json()) as { error: { code: string } };
88
+ expect(body.error.code).toBe("csrf_token_mismatch");
89
+ });
90
+
91
+ test("POST with cookie auth + matching CSRF token → 200", async () => {
92
+ const token = await stack.jwt.sign(TestUsers.user);
93
+ const csrf = "csrf-fixed-http-route-entry-token";
94
+ const res = await stack.app.request("/entry-private", {
95
+ method: "POST",
96
+ headers: {
97
+ Cookie: `${AUTH_COOKIE_NAME}=${token}; ${CSRF_COOKIE_NAME}=${csrf}`,
98
+ [CSRF_HEADER_NAME]: csrf,
99
+ },
100
+ });
101
+ expect(res.status).toBe(200);
102
+ const body = (await res.json()) as { id: string };
103
+ expect(body.id).toBe(TestUsers.user.id);
104
+ });
105
+ });
106
+
107
+ describe("r.httpRoute anonymous:true — stays public", () => {
108
+ test("GET without a session → 200", async () => {
109
+ const res = await stack.app.request("/entry-public");
110
+ expect(res.status).toBe(200);
111
+ const body = (await res.json()) as { ok: boolean };
112
+ expect(body.ok).toBe(true);
113
+ });
114
+ });
@@ -5,7 +5,7 @@ import { deleteCookie, setCookie } from "hono/cookie";
5
5
  import type Redis from "ioredis";
6
6
  import { z } from "zod";
7
7
  import { buildSessionRoles } from "../engine/membership-roles";
8
- import { createSystemUser } from "../engine/system-user";
8
+ import { createAnonymousUser, createSystemUser } from "../engine/system-user";
9
9
  import {
10
10
  type ActiveMembershipRejection,
11
11
  type ActiveMembershipResult,
@@ -197,15 +197,6 @@ type MembershipRow = {
197
197
  tenantKey?: string;
198
198
  };
199
199
 
200
- // Guest identity used for unauthenticated calls (e.g. login). The "all" role
201
- // lets framework access checks pass for handlers declared with roles: ["all"].
202
- // `id` is the zero-uuid so it flows through event-store columns cleanly.
203
- const GUEST_USER: SessionUser = {
204
- id: "00000000-0000-0000-0000-000000000000",
205
- tenantId: SYSTEM_TENANT_ID,
206
- roles: ["all"],
207
- };
208
-
209
200
  // Pluggable rate-limiter for POST /auth/login. Returning `false` blocks the
210
201
  // request with 429 before the login handler runs — use this to slow down
211
202
  // brute-force attempts. The framework ships a default in-memory impl; apps
@@ -780,7 +771,7 @@ export function createAuthRoutes(
780
771
  }
781
772
  }
782
773
 
783
- const result = await dispatcher.write(loginQn, body, GUEST_USER);
774
+ const result = await dispatcher.write(loginQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
784
775
 
785
776
  if (!result.isSuccess) {
786
777
  // Feature-specific auth reason codes arrive via UnprocessableError.details.reason
@@ -845,7 +836,7 @@ export function createAuthRoutes(
845
836
  }
846
837
 
847
838
  // POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
848
- // structurally (public, GUEST_USER dispatch, mintSessionAndRespond on
839
+ // structurally (public, anonymous identity dispatch, mintSessionAndRespond on
849
840
  // success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
850
841
  // never goes through a dispatcher write-handler's own rateLimit config,
851
842
  // so without this it would have NO rate limiting at all. Per-account
@@ -877,7 +868,11 @@ export function createAuthRoutes(
877
868
  }
878
869
  }
879
870
 
880
- const result = await dispatcher.write(mfaVerifyQn, body, GUEST_USER);
871
+ const result = await dispatcher.write(
872
+ mfaVerifyQn,
873
+ body,
874
+ createAnonymousUser(SYSTEM_TENANT_ID),
875
+ );
881
876
 
882
877
  if (!result.isSuccess) {
883
878
  // @cast-boundary error-details — KumikoError.details shape is per-error
@@ -954,7 +949,11 @@ export function createAuthRoutes(
954
949
  }
955
950
  }
956
951
 
957
- const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
952
+ const result = await dispatcher.write(
953
+ mfaPreauthEnableStartQn,
954
+ body,
955
+ createAnonymousUser(SYSTEM_TENANT_ID),
956
+ );
958
957
 
959
958
  if (!result.isSuccess) {
960
959
  // @cast-boundary error-details — KumikoError.details shape is per-error
@@ -985,7 +984,7 @@ export function createAuthRoutes(
985
984
 
986
985
  // POST /auth/mfa/preauth-confirm — completes both the enrollment started
987
986
  // by preauth-enable-start AND the login mfa-setup-required blocked.
988
- // Mirrors /auth/mfa/verify structurally (public, GUEST_USER dispatch,
987
+ // Mirrors /auth/mfa/verify structurally (public, anonymous identity dispatch,
989
988
  // mintSessionAndRespond on success) with its OWN rate limiter
990
989
  // (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
991
990
  // Per-account brute-force protection (capping wrong-code guesses against
@@ -1016,7 +1015,11 @@ export function createAuthRoutes(
1016
1015
  }
1017
1016
  }
1018
1017
 
1019
- const result = await dispatcher.write(mfaPreauthConfirmQn, body, GUEST_USER);
1018
+ const result = await dispatcher.write(
1019
+ mfaPreauthConfirmQn,
1020
+ body,
1021
+ createAnonymousUser(SYSTEM_TENANT_ID),
1022
+ );
1020
1023
 
1021
1024
  if (!result.isSuccess) {
1022
1025
  // @cast-boundary error-details — KumikoError.details shape is per-error
@@ -1128,7 +1131,11 @@ export function createAuthRoutes(
1128
1131
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1129
1132
  }
1130
1133
 
1131
- const result = await dispatcher.write(sg.confirmHandler, parsed.data, GUEST_USER);
1134
+ const result = await dispatcher.write(
1135
+ sg.confirmHandler,
1136
+ parsed.data,
1137
+ createAnonymousUser(SYSTEM_TENANT_ID),
1138
+ );
1132
1139
 
1133
1140
  if (!result.isSuccess) {
1134
1141
  // 422 für invalid_signup_token (handler-level UnprocessableError).
@@ -1210,7 +1217,11 @@ export function createAuthRoutes(
1210
1217
  if (!parsed.success) {
1211
1218
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1212
1219
  }
1213
- const result = await dispatcher.write(inv.acceptWithLoginHandler, parsed.data, GUEST_USER);
1220
+ const result = await dispatcher.write(
1221
+ inv.acceptWithLoginHandler,
1222
+ parsed.data,
1223
+ createAnonymousUser(SYSTEM_TENANT_ID),
1224
+ );
1214
1225
  if (!result.isSuccess) {
1215
1226
  const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
1216
1227
  return c.json({ isSuccess: false, error: result.error }, status);
@@ -1258,7 +1269,11 @@ export function createAuthRoutes(
1258
1269
  if (!parsed.success) {
1259
1270
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1260
1271
  }
1261
- const result = await dispatcher.write(inv.signupCompleteHandler, parsed.data, GUEST_USER);
1272
+ const result = await dispatcher.write(
1273
+ inv.signupCompleteHandler,
1274
+ parsed.data,
1275
+ createAnonymousUser(SYSTEM_TENANT_ID),
1276
+ );
1262
1277
  if (!result.isSuccess) {
1263
1278
  const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
1264
1279
  return c.json({ isSuccess: false, error: result.error }, status);
@@ -1457,10 +1472,20 @@ function registerTokenRequestRoute(opts: {
1457
1472
  if (!parsed.success) return c.json({ isSuccess: true });
1458
1473
 
1459
1474
  // The handler dispatches the magic-link mail via delivery before returning.
1460
- // Handler-level failures (only legitimate reason: misconfiguration) are
1461
- // silently swallowed — observability logs capture them for ops — so the
1462
- // response shape stays uniform for unknown vs. known emails.
1463
- await opts.dispatcher.write(opts.requestHandler, { email: parsed.data.email }, GUEST_USER);
1475
+ // Handler-level failures (misconfiguration, e.g. no RateLimitResolver) stay
1476
+ // out of the response so it is uniform for unknown vs. known emails, but
1477
+ // they are logged — otherwise the mail silently never goes out. No email in
1478
+ // the log line (PII).
1479
+ const result = await opts.dispatcher.write(
1480
+ opts.requestHandler,
1481
+ { email: parsed.data.email },
1482
+ createAnonymousUser(SYSTEM_TENANT_ID),
1483
+ );
1484
+ if (!result.isSuccess) {
1485
+ console.error(
1486
+ `[kumiko] token request handler "${opts.requestHandler}" failed: ${result.error.code}`,
1487
+ );
1488
+ }
1464
1489
 
1465
1490
  return c.json({ isSuccess: true });
1466
1491
  });
@@ -1480,7 +1505,11 @@ function registerTokenConfirmRoute(opts: {
1480
1505
  if (!parsed.success) {
1481
1506
  return c.json({ isSuccess: false, error: "invalid_body" }, 400);
1482
1507
  }
1483
- const result = await opts.dispatcher.write(opts.confirmHandler, parsed.data, GUEST_USER);
1508
+ const result = await opts.dispatcher.write(
1509
+ opts.confirmHandler,
1510
+ parsed.data,
1511
+ createAnonymousUser(SYSTEM_TENANT_ID),
1512
+ );
1484
1513
  if (!result.isSuccess) {
1485
1514
  const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
1486
1515
  return c.json({ isSuccess: false, error: result.error }, status);
@@ -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";