@cosmicdrift/kumiko-framework 0.299.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 (31) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/api.test.ts +3 -2
  3. package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
  4. package/src/api/auth-routes.ts +53 -24
  5. package/src/api/server.ts +71 -32
  6. package/src/changes.json +26 -0
  7. package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
  8. package/src/engine/__tests__/membership-roles.test.ts +13 -4
  9. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
  10. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
  11. package/src/engine/boot-validator/access-declarations.ts +59 -7
  12. package/src/engine/boot-validator/entity-handler.ts +20 -0
  13. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  14. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  15. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
  16. package/src/engine/feature-ast/extractors/hooks.ts +3 -1
  17. package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
  18. package/src/engine/feature-ast/patcher.ts +2 -2
  19. package/src/engine/feature-ast/patterns.ts +1 -1
  20. package/src/engine/feature-ast/render.ts +1 -1
  21. package/src/engine/feature-ui-extensions.ts +6 -0
  22. package/src/engine/index.ts +2 -0
  23. package/src/engine/membership-roles.ts +20 -4
  24. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  25. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  26. package/src/engine/types/index.ts +2 -0
  27. package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
  28. package/src/observability/index.ts +5 -0
  29. package/src/observability/metrics-wiring.ts +32 -0
  30. package/src/testing/handler-context.ts +3 -1
  31. 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.299.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.299.0",
202
- "@cosmicdrift/kumiko-types": "0.299.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.299.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
  });
@@ -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);
package/src/api/server.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Hono } from "hono";
1
+ import { Hono, type MiddlewareHandler } from "hono";
2
2
  import { ROLES } from "../auth/roles";
3
3
  import type { DbConnection, PgClient } from "../db/connection";
4
4
  import { createDerivativesContext } from "../derivatives/derivatives-context";
@@ -59,7 +59,7 @@ import {
59
59
  getUser,
60
60
  type TenantLifecycleStatusResolver,
61
61
  } from "./auth-middleware";
62
- import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
62
+ import { type AuthRoutesConfig, createAuthRoutes, type LoginRateLimiter } from "./auth-routes";
63
63
  import { csrfMiddleware } from "./csrf-middleware";
64
64
  import {
65
65
  type ExtraRouteDefinition,
@@ -723,30 +723,24 @@ export function buildServer(options: ServerOptions): KumikoServer {
723
723
  return jwtGuard(c, next);
724
724
  });
725
725
 
726
+ // Without anonymousAccess a missing token 401s instead of falling through as anonymous.
727
+ const sessionOnlyGuard = authMiddleware(jwt, {
728
+ ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
729
+ ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
730
+ ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
731
+ });
732
+
726
733
  // PAT rate limiting — runs AFTER the auth guard so the resolved principal is
727
734
  // available. Only PAT-authenticated requests are counted (keyed by token id);
728
735
  // cookie/JWT users pass through untouched. In-memory limiter is per-instance
729
736
  // (see run-prod-app) — a multi-node deployment wanting a shared counter swaps
730
737
  // in a Redis-backed LoginRateLimiter.
731
738
  const patRateLimiter = options.auth?.patRateLimiter;
732
- if (patRateLimiter) {
739
+ const patRateLimitGuard = patRateLimiter ? buildPatRateLimitGuard(patRateLimiter) : undefined;
740
+ if (patRateLimitGuard) {
733
741
  app.use("/api/*", async (c, next) => {
734
742
  if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
735
- const pat = getUser(c)?.pat;
736
- if (pat && !(await patRateLimiter.check(pat.tokenId))) {
737
- return c.json(
738
- {
739
- error: {
740
- code: "pat_rate_limited",
741
- httpStatus: 429,
742
- message: "personal access token rate limit exceeded",
743
- i18nKey: "auth.errors.patRateLimited",
744
- },
745
- },
746
- 429,
747
- );
748
- }
749
- return next();
743
+ return patRateLimitGuard(c, next);
750
744
  });
751
745
  }
752
746
 
@@ -760,8 +754,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
760
754
  // unguarded-subdomain-XSS footgun, not a warn-and-continue case.
761
755
  assertOriginGuardConfig(options.auth);
762
756
  const allowedOrigins = options.auth?.allowedOrigins;
763
- if (allowedOrigins && allowedOrigins.length > 0) {
764
- const originGuard = originMiddleware(allowedOrigins);
757
+ const originGuard =
758
+ allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
759
+ if (originGuard) {
765
760
  app.use("/api/*", async (c, next) => {
766
761
  if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
767
762
  return originGuard(c, next);
@@ -781,6 +776,14 @@ export function buildServer(options: ServerOptions): KumikoServer {
781
776
  return csrfGuard(c, next);
782
777
  });
783
778
 
779
+ // Same order as /api/* above: auth → PAT → origin → CSRF.
780
+ const sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[] = [
781
+ sessionOnlyGuard,
782
+ ...(patRateLimitGuard ? [patRateLimitGuard] : []),
783
+ ...(originGuard ? [originGuard] : []),
784
+ csrfGuard,
785
+ ];
786
+
784
787
  // Public auth routes (login) need to be registered BEFORE the generic
785
788
  // api routes so Hono matches them first.
786
789
  if (options.auth) {
@@ -845,20 +848,25 @@ export function buildServer(options: ServerOptions): KumikoServer {
845
848
  const honoHandler = async (c: import("hono").Context): Promise<Response> =>
846
849
  route.handler(c, {
847
850
  app,
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.
851
+ // createAnonymousUser, NOT createSystemUser: systemQuery's
852
+ // synthesized user must clear the SAME access gate a real
853
+ // anonymous visitor would, no more regardless of the route's
854
+ // own `anonymous` mode. The system role would ALSO satisfy that
855
+ // gate here, but it can read fields gated to "system" that
856
+ // "anonymous" can't (filterReadFields is a plain role-in-map
857
+ // check) a systemQuery caller reading a system-gated field
858
+ // would leak it into the response. The forced tenant already
859
+ // comes from bypassing the HTTP layer entirely; no elevated
860
+ // role is needed or wanted on top of that.
859
861
  systemQuery: makeSystemQuery(c, dispatcher),
860
862
  });
861
- mountHonoRoute(app, route.method, route.path, honoHandler);
863
+ mountHonoRoute(
864
+ app,
865
+ route.method,
866
+ route.path,
867
+ honoHandler,
868
+ route.anonymous ? [] : sessionOnlyHttpRouteGuards,
869
+ );
862
870
  }
863
871
  }
864
872
 
@@ -958,7 +966,17 @@ function mountHonoRoute(
958
966
  path: string,
959
967
  // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
960
968
  handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>,
969
+ middlewares: readonly MiddlewareHandler[] = [],
961
970
  ): void {
971
+ // Guards go into the route's own handler chain, never a method-gated
972
+ // app.use: Hono serves HEAD through the GET route with c.req.method still
973
+ // "HEAD", so a `method === c.req.method` gate would skip them for HEAD.
974
+ if (middlewares.length > 0) {
975
+ // [path]: only Hono's array-path overload accepts a variable-length handler spread.
976
+ app.on(method, [path], ...middlewares, handler);
977
+ // skip: guarded route already mounted with its guard chain
978
+ return;
979
+ }
962
980
  switch (method) {
963
981
  case "GET":
964
982
  app.get(path, handler);
@@ -985,6 +1003,27 @@ function mountHonoRoute(
985
1003
  }
986
1004
  }
987
1005
 
1006
+ // Must run after the auth guard so getUser(c) carries the PAT.
1007
+ function buildPatRateLimitGuard(patRateLimiter: LoginRateLimiter): MiddlewareHandler {
1008
+ return async (c, next) => {
1009
+ const pat = getUser(c)?.pat;
1010
+ if (pat && !(await patRateLimiter.check(pat.tokenId))) {
1011
+ return c.json(
1012
+ {
1013
+ error: {
1014
+ code: "pat_rate_limited",
1015
+ httpStatus: 429,
1016
+ message: "personal access token rate limit exceeded",
1017
+ i18nKey: "auth.errors.patRateLimited",
1018
+ },
1019
+ },
1020
+ 429,
1021
+ );
1022
+ }
1023
+ return next();
1024
+ };
1025
+ }
1026
+
988
1027
  // Shared systemQuery builder for r.httpRoute and extraRoutes (anonymous +
989
1028
  // signature entries). requestContext.run must wrap the dispatcher call —
990
1029
  // both route kinds run outside the requestIdMiddleware chain that normally
package/src/changes.json CHANGED
@@ -1,4 +1,30 @@
1
1
  [
2
+ {
3
+ "version": "0.303.0",
4
+ "type": "breaking",
5
+ "title": "r.httpRoute's `anonymous` field is now required and controls the mount, not just docs (fw#2885)",
6
+ "detail": "HttpRouteDefinition.anonymous changes from an optional, purely-documentary boolean to a required one that drives buildServer's mount. `anonymous: true` stays public, unchanged. `anonymous: false` now mounts the route behind the same session-auth chain /api/* uses: no anonymous fallthrough (a request without a session gets 401, never a synthesized anonymous user), the same PAT rate-limit guard, origin-allowlist guard and double-submit CSRF guard as /api/*, in the same order (auth → PAT → origin → CSRF). The handler reads the caller via getUser(c). feature-ui-extensions' httpRoute() now throws at feature-setup time when `anonymous` is missing or not a boolean (catches JS callers without the TypeScript type). The feature-AST (patterns/render/patcher/extractor/pattern-library) mirrors the field as required; a source file parsed before this change (missing `anonymous`) reads as `anonymous: false` — the safe side — and round-trip rendering always emits the field explicitly.",
7
+ "migration": "Every r.httpRoute({...}) call site must declare anonymous: true | false. Routes that were implicitly public (no field, or anonymous: true) keep working unchanged once anonymous: true is added explicitly — feature-ui-extensions now throws at boot for a route missing the field. Routes meant to require a session must set anonymous: false; a request without a session then gets 401 instead of running (there is no anonymous fallthrough on those routes any more), and a cookie-authenticated state-changing request needs the same X-CSRF-Token as /api/* or gets 403. Feature-AST round-trips: a saved feature file that predates this change parses `anonymous` as false (not true) — audit any httpRoute that was implicitly public and add `anonymous: true` before re-saving through the Designer/AI editor, or the next render will lock it behind the session-auth chain."
8
+ },
9
+ {
10
+ "version": "0.302.0",
11
+ "type": "breaking",
12
+ "title": "Anonymous write handlers whose input accepts a personal-data field must declare access.personalData: \"public-intake\" (fw#2885)",
13
+ "detail": "RoleAccessRule ({ roles, personalData? }) gains an optional personalData?: RoleAccessPersonalData (currently only \"public-intake\"), exported from @cosmicdrift/kumiko-framework/engine and /ui-types alongside OpenToAllPersonalData. validateAccessDeclarations now requires personalData: \"public-intake\" on a write handler whose access.roles includes \"anonymous\" and whose input schema accepts a personal-data field (pii / userOwned / recordOwned) of an entity in the same feature; declaring personalData on the roles form of a query or stream handler is rejected, \"public-intake\" on roles without \"anonymous\" is rejected, and \"tenant-members\" on the roles form is rejected (openToAll keeps accepting only \"tenant-members\"). Unlike the existing openToAll owner-binding exemption, an anonymous handler is never exempted by an owner-bound access.write map: every anonymous caller shares one user.id (\"anonymous\"), so from(\"user:id\", ...) binds no one specific. The feature-AST extractor (readOptionalAccessRule) reads roles.personalData the same way it already reads openToAll.personalData.",
14
+ "migration": "A write handler with \"anonymous\" in access.roles whose input schema accepts a personal-data field of an entity in the same feature now fails boot until it declares access: { roles: [..., \"anonymous\"], personalData: \"public-intake\" }. Owner-binding via from(\"user:id\", \"<column>\") on the entity access.write does not exempt an anonymous handler (it does exempt an openToAll handler) — anonymous requests share a single caller identity, so rely on the handler's required rateLimit (per ip) instead. personalData: \"public-intake\" is only valid on the roles form and only with \"anonymous\" in roles; openToAll keeps accepting only personalData: \"tenant-members\". The check only sees entities of the handler's own feature; anonymous intake into another feature's entity is covered by the follow-up runtime gate (kumiko-framework#3165). No known bundled-feature handler is affected."
15
+ },
16
+ {
17
+ "version": "0.301.0",
18
+ "type": "improvement",
19
+ "title": "Add shared /metrics wiring: prometheusMetricsEnvSchema and resolveObservabilityWiring under @cosmicdrift/kumiko-framework/observability",
20
+ "detail": "Apps that expose a Prometheus /metrics endpoint no longer need to hand-roll the fail-closed wiring. Compose prometheusMetricsEnvSchema.shape into your app's env extend block (extend: appSchema.extend(prometheusMetricsEnvSchema.shape)) and spread resolveObservabilityWiring(env.PROMETHEUS_METRICS_TOKEN) into runProdApp. Without a token the endpoint stays off; publicstatus can now drop its local copy of this wiring (publicstatus#479)."
21
+ },
22
+ {
23
+ "version": "0.300.0",
24
+ "type": "breaking",
25
+ "title": "Remove the guest-identity all-role: unauthenticated handlers must declare roles: [\"anonymous\"] with a rateLimit",
26
+ "migration": "Handlers declared with access: { roles: [\"all\"] } now fail boot — no session ever carries the role \"all\", so this is unreachable dead config, not a wildcard. Switch to access: { roles: [\"anonymous\"] } plus rateLimit: { per: \"ip\" | \"ip+handler\", limit: N, windowSeconds: N } for unauthenticated callers, or access: { openToAll: { reason: \"...\" } } for any signed-in user. Test fixtures that hand-roll a SessionUser with roles: [\"all\"] (bridgeStub, hand-rolled guest literals) must switch to createAnonymousUser(tenantId) or roles: [\"anonymous\"]. buildSessionRoles now also strips \"anonymous\" and \"all\" out of globalRoles at every JWT mint (membership roles were already stripped). auth-routes.ts now dispatches every public /auth/* write with createAnonymousUser(SYSTEM_TENANT_ID) instead of the removed GUEST_USER constant."
27
+ },
2
28
  {
3
29
  "version": "0.298.0",
4
30
  "type": "improvement",
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "../define-feature";
3
+
4
+ describe("r.httpRoute — anonymous is required", () => {
5
+ test("missing anonymous → throws (JS caller without HttpRouteDefinition's type)", () => {
6
+ expect(() =>
7
+ defineFeature("feed", (r) => {
8
+ r.httpRoute({
9
+ method: "GET",
10
+ path: "/feed.xml",
11
+ handler: async () => new Response("ok"),
12
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
13
+ } as any);
14
+ }),
15
+ ).toThrow(/must declare anonymous: true \| false/);
16
+ });
17
+
18
+ test("anonymous: true → accepted", () => {
19
+ expect(() =>
20
+ defineFeature("feed", (r) => {
21
+ r.httpRoute({
22
+ method: "GET",
23
+ path: "/feed.xml",
24
+ anonymous: true,
25
+ handler: async () => new Response("ok"),
26
+ });
27
+ }),
28
+ ).not.toThrow();
29
+ });
30
+
31
+ test("anonymous: false → accepted", () => {
32
+ expect(() =>
33
+ defineFeature("feed", (r) => {
34
+ r.httpRoute({
35
+ method: "GET",
36
+ path: "/feed.xml",
37
+ anonymous: false,
38
+ handler: async () => new Response("ok"),
39
+ });
40
+ }),
41
+ ).not.toThrow();
42
+ });
43
+ });
@@ -38,10 +38,9 @@ describe("forbidden membership roles", () => {
38
38
  });
39
39
  });
40
40
 
41
- // The two cases that discriminate the fix at every JWT mint: the strip wraps
42
- // ONLY the membership portion, never the merged result — so a legitimate
43
- // SystemAdmin in globalRoles survives, a resurrected one in membership does not.
44
- describe("merge semantics (globalRoles never filtered)", () => {
41
+ // globalRoles keeps SystemAdmin/system but loses anonymous/all; membershipRoles
42
+ // strips all forbidden roles (SystemAdmin, system, anonymous, all).
43
+ describe("merge semantics (globalRoles: SystemAdmin/system kept, anonymous/all stripped)", () => {
45
44
  test("global SystemAdmin survives (no regression for real admins)", () => {
46
45
  expect(buildSessionRoles(["SystemAdmin"], [])).toContain("SystemAdmin");
47
46
  });
@@ -55,4 +54,14 @@ describe("merge semantics (globalRoles never filtered)", () => {
55
54
  ["Admin", "SystemAdmin"].sort(),
56
55
  );
57
56
  });
57
+
58
+ test("anonymous/all in globalRoles are stripped, SystemAdmin stays", () => {
59
+ expect([...buildSessionRoles(["anonymous", "all", "SystemAdmin"], [])].sort()).toEqual([
60
+ "SystemAdmin",
61
+ ]);
62
+ });
63
+
64
+ test("anonymous/all in membershipRoles are stripped too", () => {
65
+ expect(buildSessionRoles([], ["anonymous", "all", "Admin"])).toEqual(["Admin"]);
66
+ });
58
67
  });