@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.
- package/package.json +4 -4
- package/src/api/__tests__/api.test.ts +3 -2
- package/src/api/__tests__/body-limit.test.ts +4 -4
- package/src/api/__tests__/extra-routes.integration.test.ts +584 -0
- package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
- package/src/api/auth-routes.ts +53 -24
- package/src/api/extra-route.ts +146 -0
- package/src/api/index.ts +16 -1
- package/src/api/server.ts +376 -72
- package/src/changes.json +37 -0
- package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
- package/src/engine/__tests__/membership-roles.test.ts +13 -4
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
- package/src/engine/boot-validator/access-declarations.ts +59 -7
- package/src/engine/boot-validator/entity-handler.ts +20 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
- package/src/engine/feature-ast/extractors/hooks.ts +3 -1
- package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/patterns.ts +1 -1
- package/src/engine/feature-ast/render.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +6 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/membership-roles.ts +20 -4
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/pattern-library/mixed-schemas.ts +1 -0
- package/src/engine/system-user.ts +5 -5
- package/src/engine/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -0
- package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
- package/src/observability/index.ts +5 -0
- package/src/observability/metrics-wiring.ts +32 -0
- package/src/stack/test-stack.ts +8 -2
- package/src/testing/handler-context.ts +3 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
});
|