@cosmicdrift/kumiko-framework 0.319.0 → 0.320.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 (46) hide show
  1. package/package.json +8 -4
  2. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  4. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  5. package/src/api/__tests__/client-ip.test.ts +139 -0
  6. package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
  7. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  8. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  9. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  10. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  11. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  12. package/src/api/api-constants.ts +2 -0
  13. package/src/api/auth-routes.ts +92 -95
  14. package/src/api/client-ip.ts +91 -0
  15. package/src/api/index.ts +15 -0
  16. package/src/api/pat-route-guard.ts +51 -0
  17. package/src/api/post-auth-landing.ts +105 -0
  18. package/src/api/request-context.ts +4 -2
  19. package/src/api/request-id-middleware.ts +44 -14
  20. package/src/api/server.ts +128 -40
  21. package/src/changes.json +73 -0
  22. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  23. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  24. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  25. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  26. package/src/engine/boot-validator/pii-retention.ts +18 -2
  27. package/src/engine/boot-validator/screens.ts +75 -18
  28. package/src/engine/index.ts +1 -0
  29. package/src/engine/project-app-schema-for-roles.ts +950 -0
  30. package/src/engine/types/handlers.ts +1 -1
  31. package/src/engine/types/index.ts +5 -1
  32. package/src/entrypoint/index.ts +14 -1
  33. package/src/files/__tests__/files.integration.test.ts +105 -1
  34. package/src/files/file-routes.ts +54 -2
  35. package/src/files/index.ts +1 -1
  36. package/src/http/index.ts +10 -2
  37. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  38. package/src/jobs/job-runner.ts +6 -1
  39. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  40. package/src/rate-limit/middleware.ts +32 -8
  41. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  42. package/src/stack/push-entity-projection-tables.ts +52 -7
  43. package/src/stack/table-helpers.ts +17 -9
  44. package/src/stack/test-stack.ts +28 -1
  45. package/src/testing/real-providers.ts +2 -0
  46. package/src/ui-types/index.ts +1 -1
@@ -0,0 +1,51 @@
1
+ import type { Context, MiddlewareHandler, Next } from "hono";
2
+ import { AccessDeniedError } from "../errors";
3
+ import { Routes } from "./api-constants";
4
+ import { getUser } from "./auth-middleware";
5
+
6
+ // Personal Access Tokens are minted for headless dispatcher access only.
7
+ // Every other /api/* surface (SSE, files, auth/*, feature httpRoutes,
8
+ // entry:"user" extraRoutes) must stay off-limits to a PAT regardless of its
9
+ // granted scopes — those routes never call assertPatAllowed, so without this
10
+ // guard a PAT with any scope (even an empty one) authenticates everywhere a
11
+ // cookie/JWT session would.
12
+ const PAT_ALLOWED_API_PATHS: ReadonlySet<string> = new Set([
13
+ `/api${Routes.write}`,
14
+ `/api${Routes.batch}`,
15
+ `/api${Routes.query}`,
16
+ `/api${Routes.command}`,
17
+ `/api${Routes.stream}`,
18
+ ]);
19
+
20
+ function isDispatcherPath(path: string): boolean {
21
+ return PAT_ALLOWED_API_PATHS.has(path);
22
+ }
23
+
24
+ // Must run after an auth guard has resolved getUser(c) — same wiring point
25
+ // as buildPatRateLimitGuard. Cookie/JWT users (user.pat undefined) pass
26
+ // through untouched.
27
+ export function patRouteGuard(): MiddlewareHandler {
28
+ return async (c: Context, next: Next) => {
29
+ const user = getUser(c);
30
+ if (user?.pat && !isDispatcherPath(c.req.path)) {
31
+ const denied = new AccessDeniedError({
32
+ message:
33
+ "personal access tokens are only valid for the dispatcher API (write, batch, query, command, stream)",
34
+ details: { reason: "pat_dispatcher_routes_only" },
35
+ });
36
+ return c.json(
37
+ {
38
+ error: {
39
+ code: denied.code,
40
+ httpStatus: denied.httpStatus,
41
+ message: denied.message,
42
+ i18nKey: denied.i18nKey,
43
+ details: denied.details,
44
+ },
45
+ },
46
+ 403,
47
+ );
48
+ }
49
+ return next();
50
+ };
51
+ }
@@ -0,0 +1,105 @@
1
+ // Server-computed post-auth redirect target. AuthRoutesConfig.postAuthLanding
2
+ // lets an app decide, in ONE place, where a user lands after login/signup/
3
+ // invite — instead of every frontend screen re-deriving it from roles/
4
+ // tenantId via loggedInHref. The result travels in the JSON response
5
+ // (`landingPath`) and is validated here before it ever reaches the wire: an
6
+ // app-supplied resolver is untrusted input from the framework's perspective,
7
+ // and a bad value would otherwise become a same-response open redirect.
8
+
9
+ import { createFallbackLogger } from "../logging";
10
+
11
+ export type PostAuthLandingFlow = "login" | "signup" | "invite";
12
+
13
+ export type PostAuthLandingArgs = {
14
+ readonly flow: PostAuthLandingFlow;
15
+ readonly roles: readonly string[];
16
+ readonly tenantId: string;
17
+ // signup only.
18
+ readonly tenantKey?: string;
19
+ // signup only, present when signup-confirm claimed a bound handover grant.
20
+ readonly handover?: { readonly entityType: string; readonly id: string };
21
+ };
22
+
23
+ export type PostAuthLandingResolver = (args: PostAuthLandingArgs) => string | undefined;
24
+
25
+ function hasControlOrDelCharacter(candidate: string): boolean {
26
+ for (let i = 0; i < candidate.length; i++) {
27
+ const code = candidate.charCodeAt(i);
28
+ if (code <= 0x1f || code === 0x7f) return true;
29
+ }
30
+ return false;
31
+ }
32
+
33
+ // Root-relative-only allowlist, stricter than the framework's existing
34
+ // billing isRootRelativePath: rejects protocol-relative ("//host") and
35
+ // backslash-based ("/\host") paths a browser would still resolve to a
36
+ // different origin, and control chars/whitespace a URL parser silently
37
+ // strips (so "/\t/evil.com" would otherwise normalize to "//evil.com" past
38
+ // the leading-slash check). The new URL(...) parse is a backstop for
39
+ // anything the explicit checks above missed.
40
+ export function isSafeLandingPath(candidate: unknown): candidate is string {
41
+ if (typeof candidate !== "string" || candidate.length === 0) return false;
42
+ if (!candidate.startsWith("/")) return false;
43
+ const second = candidate[1];
44
+ if (second === "/" || second === "\\") return false;
45
+ if (candidate.includes("\\")) return false;
46
+ if (hasControlOrDelCharacter(candidate)) return false;
47
+ if (/\s/.test(candidate)) return false;
48
+ try {
49
+ const parsed = new URL(candidate, "https://landing.invalid");
50
+ if (parsed.origin !== "https://landing.invalid") return false;
51
+ // "/.//evil.com" clears every check above (no leading "//", no
52
+ // backslash) but normalizes to pathname "//evil.com" — still same-
53
+ // origin on first navigation, but protocol-relative if a caller ever
54
+ // re-resolves the bare pathname as a fresh href.
55
+ return !parsed.pathname.startsWith("//");
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
62
+ return (
63
+ typeof value === "object" &&
64
+ value !== null &&
65
+ "then" in value &&
66
+ typeof value.then === "function"
67
+ );
68
+ }
69
+
70
+ const landingLog = createFallbackLogger("auth");
71
+
72
+ export function resolvePostAuthLandingPath(
73
+ resolver: PostAuthLandingResolver | undefined,
74
+ args: PostAuthLandingArgs,
75
+ ): string | undefined {
76
+ if (!resolver) return undefined;
77
+ let candidate: unknown;
78
+ try {
79
+ candidate = resolver(args);
80
+ } catch (error) {
81
+ // Consumer error messages can carry PII or secrets; log only the error class.
82
+ landingLog.warn("postAuthLanding threw — landingPath omitted", {
83
+ flow: args.flow,
84
+ errorName: error instanceof Error ? error.name : typeof error,
85
+ });
86
+ return undefined;
87
+ }
88
+ if (isThenable(candidate)) {
89
+ // A JS consumer (or a cast) can bypass the sync signature; an unobserved
90
+ // rejection would terminate the Bun process on every login.
91
+ Promise.resolve(candidate).catch(() => undefined);
92
+ landingLog.warn("postAuthLanding async resolver not supported — landingPath omitted", {
93
+ flow: args.flow,
94
+ });
95
+ return undefined;
96
+ }
97
+ if (candidate === undefined) return undefined;
98
+ if (!isSafeLandingPath(candidate)) {
99
+ landingLog.warn("postAuthLanding returned an unsafe path — landingPath omitted", {
100
+ flow: args.flow,
101
+ });
102
+ return undefined;
103
+ }
104
+ return candidate;
105
+ }
@@ -29,8 +29,10 @@ export type RequestContextData = {
29
29
  readonly causationId?: string;
30
30
  readonly signal?: AbortSignal;
31
31
  // Client IP for per-IP rate limiting (L1, L2, L3 with per: "ip*").
32
- // Populated by requestIdMiddleware from x-forwarded-for or the
33
- // socket address. Undefined for non-HTTP entry points (jobs, MSP).
32
+ // Populated by requestIdMiddleware via the shared trustedProxyHops-aware
33
+ // resolver (see api/client-ip.ts) — never skipped
34
+ // for an HTTP request, falls back to "unknown" rather than leaving this
35
+ // undefined. Undefined only for non-HTTP entry points (jobs, MSP).
34
36
  readonly ip?: string;
35
37
  // Raw User-Agent header — audit trails (download tokens, GDPR export
36
38
  // access) want it alongside `ip`. Undefined for non-HTTP entry points.
@@ -1,8 +1,16 @@
1
1
  import type { Context, Next } from "hono";
2
2
  import { resolveHeaderLocale } from "../i18n/request-locale";
3
3
  import { LOCALE_HEADER_NAME } from "./api-constants";
4
+ import { type ClientIpResolver, createClientIpResolver, extractSocketAddress } from "./client-ip";
4
5
  import { type RequestContextData, requestContext } from "./request-context";
5
6
 
7
+ // Fallback for the few call-sites that build a RequestContextData without a
8
+ // caller-configured resolver (e.g. an internal system-query mount reached
9
+ // before requestIdMiddleware ran). trustedProxyHops 0 matches the framework
10
+ // default; callers that care about the real hop count pass their own
11
+ // resolver instead — see requestIdMiddleware(options).
12
+ const FALLBACK_CLIENT_IP_RESOLVER = createClientIpResolver(0, "requestIdMiddleware");
13
+
6
14
  const REQUEST_ID_HEADER = "X-Request-ID";
7
15
  const CORRELATION_ID_HEADER = "X-Correlation-ID";
8
16
 
@@ -34,7 +42,10 @@ function header(req: Request, name: string): string | undefined {
34
42
  * Hono's router entirely — can still populate the same AsyncLocalStorage
35
43
  * record via `requestContext.run(...)`.
36
44
  */
37
- export function buildRequestContextDataFromRequest(req: Request): RequestContextData {
45
+ export function buildRequestContextDataFromRequest(
46
+ req: Request,
47
+ options?: { readonly resolver?: ClientIpResolver; readonly socketAddress?: string },
48
+ ): RequestContextData {
38
49
  const requestId = sanitizeClientId(header(req, REQUEST_ID_HEADER)) ?? requestContext.generateId();
39
50
  const correlationId = sanitizeClientId(header(req, CORRELATION_ID_HEADER)) ?? requestId;
40
51
 
@@ -42,14 +53,15 @@ export function buildRequestContextDataFromRequest(req: Request): RequestContext
42
53
  // back-press, tab close). We propagate it through requestContext so
43
54
  // framework internals can honour cancellation at long-running checkpoints.
44
55
  const signal = req.signal;
45
- // Client IP for per-IP rate limiting. Trust `x-forwarded-for` when
46
- // present (proxy/CDN) — first hop is the originating client. Adapter-
47
- // specific socket-address fallback (bun, node) is not standardized
48
- // in Hono; deployments behind a proxy should always set xff. Without
49
- // either we leave `ip` undefined and skip ip-bucketed checks rather
50
- // than fabricate one.
51
- const xff = header(req, "x-forwarded-for");
52
- const ip = xff?.split(",")[0]?.trim();
56
+ // Client IP for per-IP rate limiting — resolved via the shared
57
+ // trustedProxyHops-aware resolver. Always set for
58
+ // an HTTP request; falls back to "unknown" rather than skipping the
59
+ // ip-bucket (see createClientIpResolver).
60
+ const resolver = options?.resolver ?? FALLBACK_CLIENT_IP_RESOLVER;
61
+ const ip = resolver.resolve({
62
+ header: (name) => header(req, name),
63
+ socketAddress: options?.socketAddress,
64
+ });
53
65
  const userAgent = header(req, "user-agent");
54
66
  // Runs before auth-middleware, so this reaches public routes too (e.g.
55
67
  // signup-request) — that's the whole point: the active UI locale must
@@ -64,7 +76,7 @@ export function buildRequestContextDataFromRequest(req: Request): RequestContext
64
76
  correlationId,
65
77
  startedAt: performance.now(),
66
78
  ...(signal ? { signal } : {}),
67
- ...(ip && ip.length > 0 ? { ip } : {}),
79
+ ip,
68
80
  ...(userAgent !== undefined ? { userAgent } : {}),
69
81
  ...(locale !== undefined ? { locale } : {}),
70
82
  };
@@ -76,7 +88,10 @@ export function buildRequestContextDataFromRequest(req: Request): RequestContext
76
88
  * export because most call-sites (server.ts's httpRoute→systemQuery mount,
77
89
  * `requestIdMiddleware` below) already hold a Hono `Context`.
78
90
  */
79
- export function buildRequestContextData(c: Context): RequestContextData {
91
+ export function buildRequestContextData(
92
+ c: Context,
93
+ resolver?: ClientIpResolver,
94
+ ): RequestContextData {
80
95
  // Older Hono / adapter combos may leave c.req.raw unset even though it's
81
96
  // typed as Request — degrade to a bare id pair (no signal/ip/ua/locale)
82
97
  // instead of letting req.headers.get() throw on every request.
@@ -84,7 +99,10 @@ export function buildRequestContextData(c: Context): RequestContextData {
84
99
  const requestId = requestContext.generateId();
85
100
  return { requestId, correlationId: requestId, startedAt: performance.now() };
86
101
  }
87
- return buildRequestContextDataFromRequest(c.req.raw);
102
+ return buildRequestContextDataFromRequest(c.req.raw, {
103
+ resolver,
104
+ socketAddress: extractSocketAddress(c.env),
105
+ });
88
106
  }
89
107
 
90
108
  /**
@@ -96,9 +114,21 @@ export function buildRequestContextData(c: Context): RequestContextData {
96
114
  * `x-correlation-id` — clients that don't care about cross-service tracing
97
115
  * still get sensible single-request correlation for free.
98
116
  */
99
- export function requestIdMiddleware() {
117
+ export function requestIdMiddleware(options?: {
118
+ // Shared resolver instance — pass buildServer's single clientIpResolver
119
+ // here so the whole server shares one warn-once flag.
120
+ // Takes precedence over `trustedProxyHops`, which only exists for
121
+ // standalone callers (no buildServer).
122
+ readonly resolver?: ClientIpResolver;
123
+ readonly trustedProxyHops?: number;
124
+ }) {
125
+ // Created once per middleware mount (not per request) so the warn-once
126
+ // flag in the resolver actually fires only once per server boot.
127
+ const resolver =
128
+ options?.resolver ??
129
+ createClientIpResolver(options?.trustedProxyHops ?? 0, "requestIdMiddleware");
100
130
  return async (c: Context, next: Next) => {
101
- const data = buildRequestContextData(c);
131
+ const data = buildRequestContextData(c, resolver);
102
132
  c.header(REQUEST_ID_HEADER, data.requestId);
103
133
  c.header(CORRELATION_ID_HEADER, data.correlationId);
104
134
  c.set("requestId", data.requestId);
package/src/api/server.ts CHANGED
@@ -2,7 +2,9 @@ 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";
5
+ import { buildAppSchema } from "../engine/build-app-schema";
5
6
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names";
7
+ import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles";
6
8
  import { runsInLane } from "../engine/run-in";
7
9
  import { ANONYMOUS_ROLE, createAnonymousUser, createSystemUser } from "../engine/system-user";
8
10
  import {
@@ -11,12 +13,13 @@ import {
11
13
  isFileField,
12
14
  type Registry,
13
15
  type RunIn,
16
+ type SessionUser,
14
17
  type TenantId,
15
18
  type WriteResult,
16
19
  } from "../engine/types";
17
20
  import { createFileContext } from "../files/file-handle";
18
21
  import type { FileRoutesOptions } from "../files/file-routes";
19
- import { createFileRoutes } from "../files/file-routes";
22
+ import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes";
20
23
  import { makeFileProviderResolver } from "../files/provider-resolver";
21
24
  import type { Lifecycle } from "../lifecycle";
22
25
  import {
@@ -52,8 +55,9 @@ import {
52
55
  } from "../rate-limit";
53
56
  import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config";
54
57
  import type { SearchAdapter } from "../search/types";
58
+ import type { AppSchema } from "../ui-types/app-schema";
55
59
  import { assertUnreachable, generateId } from "../utils";
56
- import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
60
+ import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants";
57
61
  import {
58
62
  type AnonymousAccessResolved,
59
63
  authMiddleware,
@@ -61,6 +65,12 @@ import {
61
65
  type TenantLifecycleStatusResolver,
62
66
  } from "./auth-middleware";
63
67
  import { type AuthRoutesConfig, createAuthRoutes, type LoginRateLimiter } from "./auth-routes";
68
+ import {
69
+ assertValidTrustedProxyHops,
70
+ type ClientIpResolver,
71
+ clientIpSourceFromHonoContext,
72
+ createClientIpResolver,
73
+ } from "./client-ip";
64
74
  import { csrfMiddleware } from "./csrf-middleware";
65
75
  import {
66
76
  type ExtraRouteDefinition,
@@ -69,9 +79,11 @@ import {
69
79
  ExtraRouteRejection,
70
80
  type SystemDispatchArgs,
71
81
  } from "./extra-route";
82
+ import { computeStrongEtag, etagMatches } from "./http-cache";
72
83
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
73
84
  import { observabilityMiddleware } from "./observability-middleware";
74
85
  import { assertOriginGuardConfig, normalizeOrigin, originMiddleware } from "./origin-middleware";
86
+ import { patRouteGuard } from "./pat-route-guard";
75
87
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
76
88
  import { createDefaultSseBroker, type RedisSseBroker } from "./redis-sse-broker";
77
89
  import { requestContext } from "./request-context";
@@ -102,6 +114,12 @@ export type ServerOptions = {
102
114
  eventDedup?: EventDedup;
103
115
  sseBroker?: SseBroker;
104
116
  auth?: AuthRoutesConfig;
117
+ // Number of trusted reverse-proxy hops for client-IP resolution — feeds
118
+ // requestIdMiddleware's `ip`, the L1/L2
119
+ // rate-limit middleware below, and createAuthRoutes. Wins over the
120
+ // deprecated `auth.trustedProxyHops`. Default 0 = trust no proxy header,
121
+ // only the socket address (or "unknown") counts — see client-ip.ts.
122
+ trustedProxyHops?: number;
105
123
  // No `files` option: file-storage is wired by mounting `file-foundation` +
106
124
  // a `file-provider-*` feature. Upload routes, ctx.files and the GDPR jobs
107
125
  // resolve the provider per-tenant through that single source (issue #608).
@@ -294,6 +312,17 @@ export function withFileProviderResolver(registry: Registry, context: AppContext
294
312
  }
295
313
 
296
314
  export function buildServer(options: ServerOptions): KumikoServer {
315
+ // Single effective hop-count for every IP-derived rate-limit + requestMeta
316
+ // below — the top-level option wins over the deprecated
317
+ // `auth.trustedProxyHops` fallback.
318
+ const trustedProxyHops = options.trustedProxyHops ?? options.auth?.trustedProxyHops ?? 0;
319
+ assertValidTrustedProxyHops(trustedProxyHops, "buildServer");
320
+ // Exactly ONE resolver for the whole server — the warn-once flag must fire
321
+ // once per boot, not once per consumer (requestIdMiddleware, L1, L2,
322
+ // auth-routes each sharing this same instance rather than building a
323
+ // private one).
324
+ const clientIpResolver = createClientIpResolver(trustedProxyHops, "buildServer");
325
+
297
326
  // File-storage is resolved per-tenant through file-foundation: a mounted
298
327
  // `file-provider-*` plugin (inmemory/s3/s3-env) is the single source for
299
328
  // uploads, ctx.files and the GDPR jobs.
@@ -668,7 +697,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
668
697
  registerMetricsRoute(app, observability.meter, options.metrics);
669
698
  }
670
699
 
671
- app.use("/api/*", requestIdMiddleware());
700
+ app.use("/api/*", requestIdMiddleware({ resolver: clientIpResolver }));
672
701
 
673
702
  // Cap JSON bodies before rate-limit/auth/observability even run. Header-
674
703
  // check is O(1); oversized requests never allocate memory for a full body
@@ -691,12 +720,19 @@ export function buildServer(options: ServerOptions): KumikoServer {
691
720
  if (options.rateLimit?.global) {
692
721
  app.use(
693
722
  "/api/*",
694
- globalIpRateLimit({ ...options.rateLimit.global, resolver: rateLimitResolver }),
723
+ globalIpRateLimit({
724
+ ...options.rateLimit.global,
725
+ resolver: rateLimitResolver,
726
+ clientIpResolver,
727
+ }),
695
728
  );
696
729
  }
697
730
  if (options.rateLimit?.auth) {
698
731
  const { path: l2Path = "/api/auth/*", ...l2Opts } = options.rateLimit.auth;
699
- app.use(l2Path, authEndpointRateLimit({ ...l2Opts, resolver: rateLimitResolver }));
732
+ app.use(
733
+ l2Path,
734
+ authEndpointRateLimit({ ...l2Opts, resolver: rateLimitResolver, clientIpResolver }),
735
+ );
700
736
  }
701
737
  }
702
738
  // Observability span wraps everything that follows (auth, routes).
@@ -746,6 +782,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
746
782
  return jwtGuard(c, next);
747
783
  });
748
784
 
785
+ // Fail-closed PAT scoping: a PAT-authenticated caller (user.pat set by
786
+ // jwtGuard's tokenVerifier above) may only reach the dispatcher routes —
787
+ // everywhere else on /api/* is off-limits regardless of granted scopes.
788
+ const patRouteGuardMiddleware = patRouteGuard();
789
+ app.use("/api/*", async (c, next) => {
790
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
791
+ return patRouteGuardMiddleware(c, next);
792
+ });
793
+
749
794
  // Without anonymousAccess a missing token 401s instead of falling through as anonymous.
750
795
  const sessionOnlyGuard = authMiddleware(jwt, {
751
796
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
@@ -799,9 +844,10 @@ export function buildServer(options: ServerOptions): KumikoServer {
799
844
  return csrfGuard(c, next);
800
845
  });
801
846
 
802
- // Same order as /api/* above: auth → PAT → origin → CSRF.
847
+ // Same order as /api/* above: auth → PAT route scope → PAT rate-limit → origin → CSRF.
803
848
  const sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[] = [
804
849
  sessionOnlyGuard,
850
+ patRouteGuardMiddleware,
805
851
  ...(patRateLimitGuard ? [patRateLimitGuard] : []),
806
852
  ...(originGuard ? [originGuard] : []),
807
853
  csrfGuard,
@@ -822,7 +868,10 @@ export function buildServer(options: ServerOptions): KumikoServer {
822
868
  "need it to reject blocked principals).",
823
869
  );
824
870
  }
825
- app.route("/api", createAuthRoutes(dispatcher, jwt, options.auth));
871
+ app.route(
872
+ "/api",
873
+ createAuthRoutes(dispatcher, jwt, { ...options.auth, resolver: clientIpResolver }),
874
+ );
826
875
  }
827
876
  app.route(
828
877
  "/api",
@@ -832,6 +881,47 @@ export function buildServer(options: ServerOptions): KumikoServer {
832
881
  );
833
882
  app.route("/api", createSseRoute(sseBroker));
834
883
 
884
+ // GET /api/schema — the only way a client obtains the AppSchema. Behind
885
+ // jwtGuard (NON_PUBLIC_API_PATHS), so anonymousAccess's synthesised user
886
+ // also gets rejected below — the schema never reaches an unauthenticated
887
+ // caller. The FULL schema is built lazily once — the registry is
888
+ // immutable for this server's lifetime, so it's the same on every
889
+ // request — then projected per caller-role-set (fw#3314: a role only
890
+ // sees the screens/navs/workspaces/content-collections it may reach,
891
+ // not the whole app's schema) and cached per role key. Tenant is
892
+ // deliberately NOT part of the key: the projection depends only on
893
+ // roles, and per-tenant caching would grow unboundedly across tenants
894
+ // that share the same role set.
895
+ let fullSchema: AppSchema | undefined;
896
+ const projectedSchemaCache = new Map<string, { readonly json: string; readonly etag: string }>();
897
+ app.get(`/api${Routes.schema}`, (c) => {
898
+ const user = getUser(c);
899
+ if (isMissingOrAnonymousUser(user)) {
900
+ return unauthenticatedResponse(c);
901
+ }
902
+ const roleKey = JSON.stringify([...new Set(user?.roles ?? [])].sort());
903
+ let cached = projectedSchemaCache.get(roleKey);
904
+ if (cached === undefined) {
905
+ if (fullSchema === undefined) {
906
+ fullSchema = buildAppSchema(options.registry, {
907
+ searchAdapterMissing: !options.context.searchAdapter,
908
+ });
909
+ }
910
+ const projected = projectAppSchemaForRoles(fullSchema, user?.roles ?? []);
911
+ const json = JSON.stringify(projected);
912
+ cached = { json, etag: computeStrongEtag(json) };
913
+ projectedSchemaCache.set(roleKey, cached);
914
+ }
915
+ const cacheHeaders = { "Cache-Control": "private, no-cache", ETag: cached.etag };
916
+ if (etagMatches(c.req.header("If-None-Match") ?? null, cached.etag)) {
917
+ return c.body(null, 304, cacheHeaders);
918
+ }
919
+ return c.body(cached.json, 200, {
920
+ "Content-Type": "application/json; charset=utf-8",
921
+ ...cacheHeaders,
922
+ });
923
+ });
924
+
835
925
  // Mount upload/download routes whenever a file provider is resolvable (a
836
926
  // file-provider plugin is mounted, or a resolver was injected). They resolve
837
927
  // the provider per-tenant through file-foundation — no `files` option; route
@@ -881,7 +971,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
881
971
  // would leak it into the response. The forced tenant already
882
972
  // comes from bypassing the HTTP layer entirely; no elevated
883
973
  // role is needed or wanted on top of that.
884
- systemQuery: makeSystemQuery(c, dispatcher),
974
+ systemQuery: makeSystemQuery(c, dispatcher, clientIpResolver),
975
+ clientIp: clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
885
976
  });
886
977
  mountHonoRoute(
887
978
  app,
@@ -941,6 +1032,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
941
1032
  secrets: contextWithObservability.secrets,
942
1033
  dispatchSystemWrite,
943
1034
  dispatchSystemQuery,
1035
+ clientIpResolver,
944
1036
  });
945
1037
  mountHonoRoute(app, route.method, route.path, honoHandler);
946
1038
  }
@@ -1057,9 +1149,10 @@ function makeSystemQuery(
1057
1149
  // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1058
1150
  c: import("hono").Context<any, any>,
1059
1151
  dispatcher: Dispatcher,
1152
+ clientIpResolver?: ClientIpResolver,
1060
1153
  ): (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown> {
1061
1154
  return (type, payload, tenantId) =>
1062
- requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
1155
+ requestContext.run(requestContext.get() ?? buildRequestContextData(c, clientIpResolver), () =>
1063
1156
  dispatcher.query(type, payload, createAnonymousUser(tenantId)),
1064
1157
  );
1065
1158
  }
@@ -1103,6 +1196,27 @@ function makeAnonymousWrite(
1103
1196
  };
1104
1197
  }
1105
1198
 
1199
+ // Shared by entry:"user" extra routes and GET /api/schema — both require a
1200
+ // real, signed-in principal and reject the synthesised anonymousAccess user
1201
+ // (SessionUser.roles includes ANONYMOUS_ROLE) the same way.
1202
+ function isMissingOrAnonymousUser(user: SessionUser | undefined): boolean {
1203
+ return !user || user.roles.includes(ANONYMOUS_ROLE);
1204
+ }
1205
+
1206
+ function unauthenticatedResponse(c: import("hono").Context): Response {
1207
+ return c.json(
1208
+ {
1209
+ error: {
1210
+ code: "unauthenticated",
1211
+ httpStatus: 401,
1212
+ message: "this route requires a signed-in user",
1213
+ i18nKey: "auth.errors.missingToken",
1214
+ },
1215
+ },
1216
+ 401,
1217
+ );
1218
+ }
1219
+
1106
1220
  function isKnownExtraRouteEntry(entry: unknown): entry is ExtraRouteEntry {
1107
1221
  return (
1108
1222
  entry === ExtraRouteEntries.anonymous ||
@@ -1146,6 +1260,7 @@ type ExtraRouteHonoHandlerDeps = {
1146
1260
  readonly secrets: import("../secrets").SecretsContext | undefined;
1147
1261
  readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
1148
1262
  readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
1263
+ readonly clientIpResolver?: ClientIpResolver;
1149
1264
  };
1150
1265
 
1151
1266
  function buildExtraRouteHonoHandler(
@@ -1159,24 +1274,14 @@ function buildExtraRouteHonoHandler(
1159
1274
  route.handler(c, {
1160
1275
  app: shared.app,
1161
1276
  registry: shared.registry,
1162
- systemQuery: makeSystemQuery(c, shared.dispatcher),
1277
+ systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1163
1278
  write: makeAnonymousWrite(c, shared.dispatcher),
1164
1279
  });
1165
1280
  case ExtraRouteEntries.user:
1166
1281
  return async (c) => {
1167
1282
  const user = getUser(c);
1168
- if (!user || user.roles.includes(ANONYMOUS_ROLE)) {
1169
- return c.json(
1170
- {
1171
- error: {
1172
- code: "unauthenticated",
1173
- httpStatus: 401,
1174
- message: "this route requires a signed-in user",
1175
- i18nKey: "auth.errors.missingToken",
1176
- },
1177
- },
1178
- 401,
1179
- );
1283
+ if (isMissingOrAnonymousUser(user)) {
1284
+ return unauthenticatedResponse(c);
1180
1285
  }
1181
1286
  return route.handler(c, {
1182
1287
  app: shared.app,
@@ -1220,7 +1325,7 @@ function buildExtraRouteHonoHandler(
1220
1325
  app: shared.app,
1221
1326
  registry: shared.registry,
1222
1327
  secrets: shared.secrets,
1223
- systemQuery: makeSystemQuery(c, shared.dispatcher),
1328
+ systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1224
1329
  dispatchSystemWrite: shared.dispatchSystemWrite,
1225
1330
  dispatchSystemQuery: shared.dispatchSystemQuery,
1226
1331
  });
@@ -1282,20 +1387,3 @@ function entitiesWithSearchableScreen(registry: Registry): readonly string[] {
1282
1387
  }
1283
1388
  return [...entities];
1284
1389
  }
1285
-
1286
- // Upload-route policy carried by createFilesFeature(opts?) — read from the
1287
- // feature's exports so buildServer applies it without a parallel ServerOptions
1288
- // surface. Absent feature / opts → defaults in createFileRoutes.
1289
- function readFilesRouteOptions(
1290
- registry: Registry,
1291
- ): Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize"> {
1292
- const exp = registry.features.get("files")?.exports;
1293
- if (exp && typeof exp === "object" && "routeOptions" in exp) {
1294
- const ro = (exp as { routeOptions?: unknown }).routeOptions;
1295
- if (ro && typeof ro === "object") {
1296
- // @cast-boundary feature-exports: engine-payload (unknown) → known shape
1297
- return ro as Pick<FileRoutesOptions, "accessGuard" | "privilegedRoles" | "maxUploadSize">;
1298
- }
1299
- }
1300
- return {};
1301
- }