@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.
- package/package.json +8 -4
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +139 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +91 -0
- package/src/api/index.ts +15 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +128 -40
- package/src/changes.json +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- 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
|
|
33
|
-
//
|
|
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(
|
|
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
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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(
|
|
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(
|
|
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 (
|
|
1169
|
-
return c
|
|
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
|
-
}
|