@cosmicdrift/kumiko-framework 0.297.0 → 0.304.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/api.test.ts +3 -2
  3. package/src/api/__tests__/body-limit.test.ts +4 -4
  4. package/src/api/__tests__/extra-routes.integration.test.ts +584 -0
  5. package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
  6. package/src/api/auth-routes.ts +53 -24
  7. package/src/api/extra-route.ts +146 -0
  8. package/src/api/index.ts +16 -1
  9. package/src/api/server.ts +376 -72
  10. package/src/changes.json +37 -0
  11. package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
  12. package/src/engine/__tests__/membership-roles.test.ts +13 -4
  13. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
  14. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
  15. package/src/engine/boot-validator/access-declarations.ts +59 -7
  16. package/src/engine/boot-validator/entity-handler.ts +20 -0
  17. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  18. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  19. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
  20. package/src/engine/feature-ast/extractors/hooks.ts +3 -1
  21. package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
  22. package/src/engine/feature-ast/patcher.ts +2 -2
  23. package/src/engine/feature-ast/patterns.ts +1 -1
  24. package/src/engine/feature-ast/render.ts +1 -1
  25. package/src/engine/feature-ui-extensions.ts +6 -0
  26. package/src/engine/index.ts +2 -0
  27. package/src/engine/membership-roles.ts +20 -4
  28. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  29. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  30. package/src/engine/system-user.ts +5 -5
  31. package/src/engine/types/index.ts +2 -0
  32. package/src/entrypoint/index.ts +2 -0
  33. package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
  34. package/src/observability/index.ts +5 -0
  35. package/src/observability/metrics-wiring.ts +32 -0
  36. package/src/stack/test-stack.ts +8 -2
  37. package/src/testing/handler-context.ts +3 -1
  38. package/src/ui-types/index.ts +2 -0
package/src/api/server.ts CHANGED
@@ -1,10 +1,19 @@
1
- import { Hono } from "hono";
1
+ import { Hono, type MiddlewareHandler } 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";
@@ -50,8 +59,15 @@ import {
50
59
  getUser,
51
60
  type TenantLifecycleStatusResolver,
52
61
  } from "./auth-middleware";
53
- import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
62
+ import { type AuthRoutesConfig, createAuthRoutes, type LoginRateLimiter } 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,34 +719,28 @@ 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
 
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
+
696
733
  // PAT rate limiting — runs AFTER the auth guard so the resolved principal is
697
734
  // available. Only PAT-authenticated requests are counted (keyed by token id);
698
735
  // cookie/JWT users pass through untouched. In-memory limiter is per-instance
699
736
  // (see run-prod-app) — a multi-node deployment wanting a shared counter swaps
700
737
  // in a Redis-backed LoginRateLimiter.
701
738
  const patRateLimiter = options.auth?.patRateLimiter;
702
- if (patRateLimiter) {
739
+ const patRateLimitGuard = patRateLimiter ? buildPatRateLimitGuard(patRateLimiter) : undefined;
740
+ if (patRateLimitGuard) {
703
741
  app.use("/api/*", async (c, next) => {
704
- if (PUBLIC_API_PATHS.has(c.req.path)) return next();
705
- const pat = getUser(c)?.pat;
706
- if (pat && !(await patRateLimiter.check(pat.tokenId))) {
707
- return c.json(
708
- {
709
- error: {
710
- code: "pat_rate_limited",
711
- httpStatus: 429,
712
- message: "personal access token rate limit exceeded",
713
- i18nKey: "auth.errors.patRateLimited",
714
- },
715
- },
716
- 429,
717
- );
718
- }
719
- return next();
742
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
743
+ return patRateLimitGuard(c, next);
720
744
  });
721
745
  }
722
746
 
@@ -730,10 +754,11 @@ export function buildServer(options: ServerOptions): KumikoServer {
730
754
  // unguarded-subdomain-XSS footgun, not a warn-and-continue case.
731
755
  assertOriginGuardConfig(options.auth);
732
756
  const allowedOrigins = options.auth?.allowedOrigins;
733
- if (allowedOrigins && allowedOrigins.length > 0) {
734
- const originGuard = originMiddleware(allowedOrigins);
757
+ const originGuard =
758
+ allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
759
+ if (originGuard) {
735
760
  app.use("/api/*", async (c, next) => {
736
- if (PUBLIC_API_PATHS.has(c.req.path)) return next();
761
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
737
762
  return originGuard(c, next);
738
763
  });
739
764
  }
@@ -747,10 +772,18 @@ export function buildServer(options: ServerOptions): KumikoServer {
747
772
  // are covered uniformly.
748
773
  const csrfGuard = csrfMiddleware();
749
774
  app.use("/api/*", async (c, next) => {
750
- if (PUBLIC_API_PATHS.has(c.req.path)) return next();
775
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
751
776
  return csrfGuard(c, next);
752
777
  });
753
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
+
754
787
  // Public auth routes (login) need to be registered BEFORE the generic
755
788
  // api routes so Hono matches them first.
756
789
  if (options.auth) {
@@ -815,55 +848,79 @@ export function buildServer(options: ServerOptions): KumikoServer {
815
848
  const honoHandler = async (c: import("hono").Context): Promise<Response> =>
816
849
  route.handler(c, {
817
850
  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
- ),
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.
861
+ systemQuery: makeSystemQuery(c, dispatcher),
841
862
  });
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");
863
+ mountHonoRoute(
864
+ app,
865
+ route.method,
866
+ route.path,
867
+ honoHandler,
868
+ route.anonymous ? [] : sessionOnlyHttpRouteGuards,
869
+ );
870
+ }
871
+ }
872
+
873
+ // extraRoutes (kumiko-framework#3050) — declarative HTTP-routes with a
874
+ // Pflicht `entry` tier. Mounted after r.httpRoute for the same reason: an
875
+ // extraRoute dispatching through `dispatcher` builds Hono's matcher, so
876
+ // this must run before any seed that also dispatches (runProdApp/
877
+ // createKumikoServer call buildServer before seeding).
878
+ if (options.extraRoutes) {
879
+ // Boot-time validation for the whole list BEFORE mounting anything —
880
+ // an app with one bad route should fail loud at boot, not mount N-1
881
+ // routes and then throw on route N.
882
+ for (const route of options.extraRoutes) {
883
+ if (!isKnownExtraRouteEntry(route.entry)) {
884
+ throw new Error(
885
+ `[kumiko] extraRoutes: unknown entry "${String(route.entry)}" on ` +
886
+ `"${route.method} ${route.path}" — expected "anonymous" | "user" | "signature". ` +
887
+ "A JS caller without the ExtraRouteDefinition type can hit this at boot.",
888
+ );
889
+ }
890
+ if (route.entry === ExtraRouteEntries.user && !route.path.startsWith("/api/")) {
891
+ throw new Error(
892
+ `[kumiko] extraRoutes: entry:"user" route "${route.method} ${route.path}" must be ` +
893
+ "mounted under \"/api/\" — that's the only path prefix that rides the framework's " +
894
+ "jwtGuard chain, which is what populates deps.user.",
895
+ );
896
+ }
897
+ // A signature route under /api/ skips jwtGuard/origin/csrf for every
898
+ // path its pattern matches — a wildcard would switch off auth for
899
+ // unrelated /api/* handlers (e.g. "/api/*" swallows /api/write).
900
+ if (
901
+ route.entry === ExtraRouteEntries.signature &&
902
+ route.path.startsWith("/api/") &&
903
+ route.path.includes("*")
904
+ ) {
905
+ throw new Error(
906
+ `[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
907
+ 'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.',
908
+ );
865
909
  }
866
910
  }
911
+ const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
912
+ const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
913
+ for (const route of options.extraRoutes) {
914
+ const honoHandler = buildExtraRouteHonoHandler(route, {
915
+ app,
916
+ dispatcher,
917
+ registry: options.registry,
918
+ secrets: contextWithObservability.secrets,
919
+ dispatchSystemWrite,
920
+ dispatchSystemQuery,
921
+ });
922
+ mountHonoRoute(app, route.method, route.path, honoHandler);
923
+ }
867
924
  }
868
925
 
869
926
  // /version-Default registriert NACH feature-routes — Hono "first match
@@ -900,6 +957,253 @@ export function buildServer(options: ServerOptions): KumikoServer {
900
957
  };
901
958
  }
902
959
 
960
+ // Method-switch shared by r.httpRoute and extraRoutes — one place to keep
961
+ // the two mounting paths from drifting on which Hono methods get a
962
+ // convenience-call vs. app.on().
963
+ function mountHonoRoute(
964
+ app: Hono,
965
+ method: HttpRouteMethod,
966
+ path: string,
967
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
968
+ handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>,
969
+ middlewares: readonly MiddlewareHandler[] = [],
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
+ }
980
+ switch (method) {
981
+ case "GET":
982
+ app.get(path, handler);
983
+ break;
984
+ case "POST":
985
+ app.post(path, handler);
986
+ break;
987
+ case "PUT":
988
+ app.put(path, handler);
989
+ break;
990
+ case "PATCH":
991
+ app.patch(path, handler);
992
+ break;
993
+ case "DELETE":
994
+ app.delete(path, handler);
995
+ break;
996
+ case "OPTIONS":
997
+ case "HEAD":
998
+ // Hono's on() for the methods without a convenience method.
999
+ app.on(method, path, handler);
1000
+ break;
1001
+ default:
1002
+ assertUnreachable(method, "http method");
1003
+ }
1004
+ }
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
+
1027
+ // Shared systemQuery builder for r.httpRoute and extraRoutes (anonymous +
1028
+ // signature entries). requestContext.run must wrap the dispatcher call —
1029
+ // both route kinds run outside the requestIdMiddleware chain that normally
1030
+ // populates it, and `rateLimit: {per: "ip", ...}` on a handler invoked
1031
+ // through systemQuery is otherwise silent dead-code (enforceRateLimit reads
1032
+ // requestContext.get()?.ip, undefined without this wrap).
1033
+ function makeSystemQuery(
1034
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1035
+ c: import("hono").Context<any, any>,
1036
+ dispatcher: Dispatcher,
1037
+ ): (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown> {
1038
+ return (type, payload, tenantId) =>
1039
+ requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
1040
+ dispatcher.query(type, payload, createAnonymousUser(tenantId)),
1041
+ );
1042
+ }
1043
+
1044
+ // SystemAdmin write/query builders shared by buildServer's `extraRoutes`
1045
+ // mount and server-runtime's `wire` hook (runProdApp/createKumikoServer,
1046
+ // after buildServer). Privilege-scope: SystemAdmin is the highest
1047
+ // non-tenant-scoped role — reaches ANY SystemAdmin-gated handler on ANY
1048
+ // tenant. Only safe for callers that already proved their own authenticity
1049
+ // (signature verify(), HMAC state, ...), never exposed to a raw request.
1050
+ export function makeDispatchSystemWrite(
1051
+ dispatcher: Dispatcher,
1052
+ ): (args: SystemDispatchArgs) => Promise<WriteResult> {
1053
+ return ({ handlerQn, payload, tenantId }) =>
1054
+ dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
1055
+ }
1056
+
1057
+ export function makeDispatchSystemQuery(
1058
+ dispatcher: Dispatcher,
1059
+ ): (args: SystemDispatchArgs) => Promise<unknown> {
1060
+ return ({ handlerQn, payload, tenantId }) =>
1061
+ dispatcher.query(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
1062
+ }
1063
+
1064
+ // Same dispatcher.write(...) as entry:"user", but the user is getUser(c) —
1065
+ // see AnonymousExtraRouteDeps.write for the privilege/tenant contract.
1066
+ function makeAnonymousWrite(
1067
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1068
+ c: import("hono").Context<any, any>,
1069
+ dispatcher: Dispatcher,
1070
+ ): (type: string, payload: unknown) => Promise<WriteResult> {
1071
+ return (type, payload) => {
1072
+ const user = getUser(c);
1073
+ if (!user) {
1074
+ throw new Error(
1075
+ '[kumiko] extraRoutes: entry:"anonymous" deps.write requires this route to be mounted ' +
1076
+ 'under "/api/" with anonymousAccess wired — no request-resolved session user was found.',
1077
+ );
1078
+ }
1079
+ return dispatcher.write(type, payload, user);
1080
+ };
1081
+ }
1082
+
1083
+ function isKnownExtraRouteEntry(entry: unknown): entry is ExtraRouteEntry {
1084
+ return (
1085
+ entry === ExtraRouteEntries.anonymous ||
1086
+ entry === ExtraRouteEntries.user ||
1087
+ entry === ExtraRouteEntries.signature
1088
+ );
1089
+ }
1090
+
1091
+ // Hono path pattern ("/api/foo/:bar", "/api/foo/*") → RegExp. Only the
1092
+ // subset extraRoutes actually uses (static segments, `:param`, trailing
1093
+ // `*`) — no inline `:param{regex}` constraints, no optional `:param?`.
1094
+ function honoPathToRegex(path: string): RegExp {
1095
+ const segments = path
1096
+ .split("/")
1097
+ .map((segment) => {
1098
+ if (segment.startsWith(":")) return "[^/]+";
1099
+ if (segment === "*") return ".*";
1100
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1101
+ })
1102
+ .join("/");
1103
+ return new RegExp(`^${segments}$`);
1104
+ }
1105
+
1106
+ type ExtraRoutePublicMatcher = { readonly method: string; readonly pattern: RegExp };
1107
+
1108
+ // See the bypass comment at the jwtGuard mount above — only signature
1109
+ // routes are public here.
1110
+ function compileExtraRoutePublicMatchers(
1111
+ extraRoutes: readonly ExtraRouteDefinition[] | undefined,
1112
+ ): readonly ExtraRoutePublicMatcher[] {
1113
+ if (!extraRoutes) return [];
1114
+ return extraRoutes
1115
+ .filter((route) => route.entry === ExtraRouteEntries.signature)
1116
+ .map((route) => ({ method: route.method, pattern: honoPathToRegex(route.path) }));
1117
+ }
1118
+
1119
+ type ExtraRouteHonoHandlerDeps = {
1120
+ readonly app: Hono;
1121
+ readonly dispatcher: Dispatcher;
1122
+ readonly registry: Registry;
1123
+ readonly secrets: import("../secrets").SecretsContext | undefined;
1124
+ readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
1125
+ readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
1126
+ };
1127
+
1128
+ function buildExtraRouteHonoHandler(
1129
+ route: ExtraRouteDefinition,
1130
+ shared: ExtraRouteHonoHandlerDeps,
1131
+ // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1132
+ ): (c: import("hono").Context<any, any>) => Promise<Response> {
1133
+ switch (route.entry) {
1134
+ case ExtraRouteEntries.anonymous:
1135
+ return async (c) =>
1136
+ route.handler(c, {
1137
+ app: shared.app,
1138
+ registry: shared.registry,
1139
+ systemQuery: makeSystemQuery(c, shared.dispatcher),
1140
+ write: makeAnonymousWrite(c, shared.dispatcher),
1141
+ });
1142
+ case ExtraRouteEntries.user:
1143
+ return async (c) => {
1144
+ const user = getUser(c);
1145
+ if (!user || user.roles.includes(ANONYMOUS_ROLE)) {
1146
+ return c.json(
1147
+ {
1148
+ error: {
1149
+ code: "unauthenticated",
1150
+ httpStatus: 401,
1151
+ message: "this route requires a signed-in user",
1152
+ i18nKey: "auth.errors.missingToken",
1153
+ },
1154
+ },
1155
+ 401,
1156
+ );
1157
+ }
1158
+ return route.handler(c, {
1159
+ app: shared.app,
1160
+ registry: shared.registry,
1161
+ user,
1162
+ query: (type, payload) => shared.dispatcher.query(type, payload, user),
1163
+ write: (type, payload) => shared.dispatcher.write(type, payload, user),
1164
+ });
1165
+ };
1166
+ case ExtraRouteEntries.signature:
1167
+ return async (c) => {
1168
+ const rawBody = await c.req.text();
1169
+ const headers: Record<string, string> = {};
1170
+ c.req.raw.headers.forEach((value, key) => {
1171
+ headers[key.toLowerCase()] = value;
1172
+ });
1173
+ let verified: unknown;
1174
+ try {
1175
+ verified = await route.verify(
1176
+ { rawBody, headers, params: c.req.param(), query: c.req.query() },
1177
+ { registry: shared.registry, secrets: shared.secrets },
1178
+ );
1179
+ } catch (e) {
1180
+ if (e instanceof ExtraRouteRejection) {
1181
+ return c.json(e.body, e.status);
1182
+ }
1183
+ return c.json(
1184
+ {
1185
+ error: {
1186
+ code: "extra_route_signature_invalid",
1187
+ message: e instanceof Error ? e.message : String(e),
1188
+ },
1189
+ },
1190
+ 401,
1191
+ );
1192
+ }
1193
+ return route.handler(c, verified, {
1194
+ app: shared.app,
1195
+ registry: shared.registry,
1196
+ secrets: shared.secrets,
1197
+ systemQuery: makeSystemQuery(c, shared.dispatcher),
1198
+ dispatchSystemWrite: shared.dispatchSystemWrite,
1199
+ dispatchSystemQuery: shared.dispatchSystemQuery,
1200
+ });
1201
+ };
1202
+ default:
1203
+ return assertUnreachable(route, "extra route entry");
1204
+ }
1205
+ }
1206
+
903
1207
  function deriveTenantLifecycleResolver(
904
1208
  registry: Registry,
905
1209
  db: DbConnection | undefined,
package/src/changes.json CHANGED
@@ -1,4 +1,41 @@
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
+ },
28
+ {
29
+ "version": "0.298.0",
30
+ "type": "improvement",
31
+ "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)."
32
+ },
33
+ {
34
+ "version": "0.298.0",
35
+ "type": "breaking",
36
+ "title": "extraRoutes/hostDispatch move to structured route and wire definitions",
37
+ "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."
38
+ },
2
39
  {
3
40
  "version": "0.296.0",
4
41
  "type": "breaking",
@@ -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
  });