@cosmicdrift/kumiko-framework 0.319.0 → 0.321.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 (71) hide show
  1. package/package.json +8 -4
  2. package/src/__tests__/schema-cli.integration.test.ts +11 -0
  3. package/src/__tests__/upgrade-cli.test.ts +50 -3
  4. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  6. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  7. package/src/api/__tests__/client-ip.test.ts +164 -0
  8. package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
  9. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  10. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  11. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  12. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  13. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  14. package/src/api/api-constants.ts +2 -0
  15. package/src/api/auth-routes.ts +92 -95
  16. package/src/api/client-ip.ts +118 -0
  17. package/src/api/extra-route.ts +3 -0
  18. package/src/api/index.ts +17 -0
  19. package/src/api/pat-route-guard.ts +51 -0
  20. package/src/api/post-auth-landing.ts +105 -0
  21. package/src/api/request-context.ts +4 -2
  22. package/src/api/request-id-middleware.ts +44 -14
  23. package/src/api/server.ts +130 -40
  24. package/src/changes.json +121 -0
  25. package/src/db/queries/event-store-transfer.ts +73 -0
  26. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  27. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  28. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  29. package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
  30. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  31. package/src/engine/boot-validator/pii-retention.ts +18 -2
  32. package/src/engine/boot-validator/screens.ts +75 -18
  33. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
  35. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
  36. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
  37. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
  38. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
  39. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
  40. package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
  41. package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
  42. package/src/engine/feature-ast/extractors/index.ts +4 -0
  43. package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
  44. package/src/engine/feature-ast/extractors/shared.ts +26 -19
  45. package/src/engine/feature-ast/parse.ts +13 -20
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/project-app-schema-for-roles.ts +950 -0
  48. package/src/engine/steps/webhook-send.ts +4 -2
  49. package/src/engine/types/handlers.ts +1 -1
  50. package/src/engine/types/index.ts +5 -1
  51. package/src/entrypoint/index.ts +14 -1
  52. package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
  53. package/src/event-store/index.ts +6 -0
  54. package/src/event-store/transfer.ts +84 -0
  55. package/src/files/__tests__/files.integration.test.ts +105 -1
  56. package/src/files/file-routes.ts +54 -2
  57. package/src/files/index.ts +1 -1
  58. package/src/http/index.ts +10 -2
  59. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  60. package/src/jobs/job-runner.ts +6 -1
  61. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  62. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  63. package/src/rate-limit/middleware.ts +32 -8
  64. package/src/schema-cli.ts +5 -1
  65. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  66. package/src/stack/push-entity-projection-tables.ts +52 -7
  67. package/src/stack/table-helpers.ts +17 -9
  68. package/src/stack/test-stack.ts +28 -1
  69. package/src/testing/real-providers.ts +2 -0
  70. package/src/ui-types/index.ts +1 -1
  71. package/src/upgrade-cli.ts +58 -18
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.
@@ -588,6 +617,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
588
617
  userId: event.metadata.userId,
589
618
  ...(mspOwner && { callerFeature: mspOwner }),
590
619
  ...(mspFiles && { files: mspFiles }),
620
+ ...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
591
621
  ...(mspFiles && {
592
622
  derivatives: createDerivativesContext({
593
623
  files: mspFiles,
@@ -668,7 +698,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
668
698
  registerMetricsRoute(app, observability.meter, options.metrics);
669
699
  }
670
700
 
671
- app.use("/api/*", requestIdMiddleware());
701
+ app.use("/api/*", requestIdMiddleware({ resolver: clientIpResolver }));
672
702
 
673
703
  // Cap JSON bodies before rate-limit/auth/observability even run. Header-
674
704
  // check is O(1); oversized requests never allocate memory for a full body
@@ -691,12 +721,19 @@ export function buildServer(options: ServerOptions): KumikoServer {
691
721
  if (options.rateLimit?.global) {
692
722
  app.use(
693
723
  "/api/*",
694
- globalIpRateLimit({ ...options.rateLimit.global, resolver: rateLimitResolver }),
724
+ globalIpRateLimit({
725
+ ...options.rateLimit.global,
726
+ resolver: rateLimitResolver,
727
+ clientIpResolver,
728
+ }),
695
729
  );
696
730
  }
697
731
  if (options.rateLimit?.auth) {
698
732
  const { path: l2Path = "/api/auth/*", ...l2Opts } = options.rateLimit.auth;
699
- app.use(l2Path, authEndpointRateLimit({ ...l2Opts, resolver: rateLimitResolver }));
733
+ app.use(
734
+ l2Path,
735
+ authEndpointRateLimit({ ...l2Opts, resolver: rateLimitResolver, clientIpResolver }),
736
+ );
700
737
  }
701
738
  }
702
739
  // Observability span wraps everything that follows (auth, routes).
@@ -746,6 +783,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
746
783
  return jwtGuard(c, next);
747
784
  });
748
785
 
786
+ // Fail-closed PAT scoping: a PAT-authenticated caller (user.pat set by
787
+ // jwtGuard's tokenVerifier above) may only reach the dispatcher routes —
788
+ // everywhere else on /api/* is off-limits regardless of granted scopes.
789
+ const patRouteGuardMiddleware = patRouteGuard();
790
+ app.use("/api/*", async (c, next) => {
791
+ if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
792
+ return patRouteGuardMiddleware(c, next);
793
+ });
794
+
749
795
  // Without anonymousAccess a missing token 401s instead of falling through as anonymous.
750
796
  const sessionOnlyGuard = authMiddleware(jwt, {
751
797
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
@@ -799,9 +845,10 @@ export function buildServer(options: ServerOptions): KumikoServer {
799
845
  return csrfGuard(c, next);
800
846
  });
801
847
 
802
- // Same order as /api/* above: auth → PAT → origin → CSRF.
848
+ // Same order as /api/* above: auth → PAT route scope → PAT rate-limit → origin → CSRF.
803
849
  const sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[] = [
804
850
  sessionOnlyGuard,
851
+ patRouteGuardMiddleware,
805
852
  ...(patRateLimitGuard ? [patRateLimitGuard] : []),
806
853
  ...(originGuard ? [originGuard] : []),
807
854
  csrfGuard,
@@ -822,7 +869,10 @@ export function buildServer(options: ServerOptions): KumikoServer {
822
869
  "need it to reject blocked principals).",
823
870
  );
824
871
  }
825
- app.route("/api", createAuthRoutes(dispatcher, jwt, options.auth));
872
+ app.route(
873
+ "/api",
874
+ createAuthRoutes(dispatcher, jwt, { ...options.auth, resolver: clientIpResolver }),
875
+ );
826
876
  }
827
877
  app.route(
828
878
  "/api",
@@ -832,6 +882,47 @@ export function buildServer(options: ServerOptions): KumikoServer {
832
882
  );
833
883
  app.route("/api", createSseRoute(sseBroker));
834
884
 
885
+ // GET /api/schema — the only way a client obtains the AppSchema. Behind
886
+ // jwtGuard (NON_PUBLIC_API_PATHS), so anonymousAccess's synthesised user
887
+ // also gets rejected below — the schema never reaches an unauthenticated
888
+ // caller. The FULL schema is built lazily once — the registry is
889
+ // immutable for this server's lifetime, so it's the same on every
890
+ // request — then projected per caller-role-set (fw#3314: a role only
891
+ // sees the screens/navs/workspaces/content-collections it may reach,
892
+ // not the whole app's schema) and cached per role key. Tenant is
893
+ // deliberately NOT part of the key: the projection depends only on
894
+ // roles, and per-tenant caching would grow unboundedly across tenants
895
+ // that share the same role set.
896
+ let fullSchema: AppSchema | undefined;
897
+ const projectedSchemaCache = new Map<string, { readonly json: string; readonly etag: string }>();
898
+ app.get(`/api${Routes.schema}`, (c) => {
899
+ const user = getUser(c);
900
+ if (isMissingOrAnonymousUser(user)) {
901
+ return unauthenticatedResponse(c);
902
+ }
903
+ const roleKey = JSON.stringify([...new Set(user?.roles ?? [])].sort());
904
+ let cached = projectedSchemaCache.get(roleKey);
905
+ if (cached === undefined) {
906
+ if (fullSchema === undefined) {
907
+ fullSchema = buildAppSchema(options.registry, {
908
+ searchAdapterMissing: !options.context.searchAdapter,
909
+ });
910
+ }
911
+ const projected = projectAppSchemaForRoles(fullSchema, user?.roles ?? []);
912
+ const json = JSON.stringify(projected);
913
+ cached = { json, etag: computeStrongEtag(json) };
914
+ projectedSchemaCache.set(roleKey, cached);
915
+ }
916
+ const cacheHeaders = { "Cache-Control": "private, no-cache", ETag: cached.etag };
917
+ if (etagMatches(c.req.header("If-None-Match") ?? null, cached.etag)) {
918
+ return c.body(null, 304, cacheHeaders);
919
+ }
920
+ return c.body(cached.json, 200, {
921
+ "Content-Type": "application/json; charset=utf-8",
922
+ ...cacheHeaders,
923
+ });
924
+ });
925
+
835
926
  // Mount upload/download routes whenever a file provider is resolvable (a
836
927
  // file-provider plugin is mounted, or a resolver was injected). They resolve
837
928
  // the provider per-tenant through file-foundation — no `files` option; route
@@ -881,7 +972,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
881
972
  // would leak it into the response. The forced tenant already
882
973
  // comes from bypassing the HTTP layer entirely; no elevated
883
974
  // role is needed or wanted on top of that.
884
- systemQuery: makeSystemQuery(c, dispatcher),
975
+ systemQuery: makeSystemQuery(c, dispatcher, clientIpResolver),
976
+ clientIp: clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
885
977
  });
886
978
  mountHonoRoute(
887
979
  app,
@@ -941,6 +1033,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
941
1033
  secrets: contextWithObservability.secrets,
942
1034
  dispatchSystemWrite,
943
1035
  dispatchSystemQuery,
1036
+ clientIpResolver,
944
1037
  });
945
1038
  mountHonoRoute(app, route.method, route.path, honoHandler);
946
1039
  }
@@ -1057,9 +1150,10 @@ function makeSystemQuery(
1057
1150
  // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1058
1151
  c: import("hono").Context<any, any>,
1059
1152
  dispatcher: Dispatcher,
1153
+ clientIpResolver?: ClientIpResolver,
1060
1154
  ): (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown> {
1061
1155
  return (type, payload, tenantId) =>
1062
- requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
1156
+ requestContext.run(requestContext.get() ?? buildRequestContextData(c, clientIpResolver), () =>
1063
1157
  dispatcher.query(type, payload, createAnonymousUser(tenantId)),
1064
1158
  );
1065
1159
  }
@@ -1103,6 +1197,27 @@ function makeAnonymousWrite(
1103
1197
  };
1104
1198
  }
1105
1199
 
1200
+ // Shared by entry:"user" extra routes and GET /api/schema — both require a
1201
+ // real, signed-in principal and reject the synthesised anonymousAccess user
1202
+ // (SessionUser.roles includes ANONYMOUS_ROLE) the same way.
1203
+ function isMissingOrAnonymousUser(user: SessionUser | undefined): boolean {
1204
+ return !user || user.roles.includes(ANONYMOUS_ROLE);
1205
+ }
1206
+
1207
+ function unauthenticatedResponse(c: import("hono").Context): Response {
1208
+ return c.json(
1209
+ {
1210
+ error: {
1211
+ code: "unauthenticated",
1212
+ httpStatus: 401,
1213
+ message: "this route requires a signed-in user",
1214
+ i18nKey: "auth.errors.missingToken",
1215
+ },
1216
+ },
1217
+ 401,
1218
+ );
1219
+ }
1220
+
1106
1221
  function isKnownExtraRouteEntry(entry: unknown): entry is ExtraRouteEntry {
1107
1222
  return (
1108
1223
  entry === ExtraRouteEntries.anonymous ||
@@ -1146,6 +1261,7 @@ type ExtraRouteHonoHandlerDeps = {
1146
1261
  readonly secrets: import("../secrets").SecretsContext | undefined;
1147
1262
  readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
1148
1263
  readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
1264
+ readonly clientIpResolver: ClientIpResolver;
1149
1265
  };
1150
1266
 
1151
1267
  function buildExtraRouteHonoHandler(
@@ -1159,24 +1275,15 @@ function buildExtraRouteHonoHandler(
1159
1275
  route.handler(c, {
1160
1276
  app: shared.app,
1161
1277
  registry: shared.registry,
1162
- systemQuery: makeSystemQuery(c, shared.dispatcher),
1278
+ systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1163
1279
  write: makeAnonymousWrite(c, shared.dispatcher),
1280
+ clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
1164
1281
  });
1165
1282
  case ExtraRouteEntries.user:
1166
1283
  return async (c) => {
1167
1284
  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
- );
1285
+ if (isMissingOrAnonymousUser(user)) {
1286
+ return unauthenticatedResponse(c);
1180
1287
  }
1181
1288
  return route.handler(c, {
1182
1289
  app: shared.app,
@@ -1220,7 +1327,7 @@ function buildExtraRouteHonoHandler(
1220
1327
  app: shared.app,
1221
1328
  registry: shared.registry,
1222
1329
  secrets: shared.secrets,
1223
- systemQuery: makeSystemQuery(c, shared.dispatcher),
1330
+ systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1224
1331
  dispatchSystemWrite: shared.dispatchSystemWrite,
1225
1332
  dispatchSystemQuery: shared.dispatchSystemQuery,
1226
1333
  });
@@ -1282,20 +1389,3 @@ function entitiesWithSearchableScreen(registry: Registry): readonly string[] {
1282
1389
  }
1283
1390
  return [...entities];
1284
1391
  }
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
- }
package/src/changes.json CHANGED
@@ -1,4 +1,125 @@
1
1
  [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "improvement",
5
+ "title": "kumiko-schema gained --help/-h/help",
6
+ "detail": "Prints usage listing the available subcommands and exits 0.",
7
+ "migration": "No action needed."
8
+ },
9
+ {
10
+ "version": "0.321.0",
11
+ "type": "improvement",
12
+ "title": "parseTrustedProxyHopsEnv/TRUSTED_PROXY_HOPS_ENV factored out of runProdApp for runDevApp and consumer tooling to share",
13
+ "detail": "`packages/framework/src/api/client-ip.ts` gained\n`parseTrustedProxyHopsEnv(raw, context)` and the\n`TRUSTED_PROXY_HOPS_ENV = \"KUMIKO_TRUSTED_PROXY_HOPS\"` constant (both\nexported via the `api` barrel), moved out of `runProdApp`'s inline\nparsing. Same validation and error message as before.",
14
+ "migration": "No action needed; runProdApp's behavior and error message are unchanged."
15
+ },
16
+ {
17
+ "version": "0.321.0",
18
+ "type": "improvement",
19
+ "title": "AnonymousExtraRouteDeps gained clientIp, resolved by the server's trustedProxyHops-aware resolver",
20
+ "detail": "`AnonymousExtraRouteDeps.clientIp: string`, populated the same way as the\nhttpRoute path via `shared.clientIpResolver.resolve\n(clientIpSourceFromHonoContext(c))`. `ExtraRouteHonoHandlerDeps\n.clientIpResolver` is now required (its only caller always supplied it).",
21
+ "migration": "Anonymous extra routes that need the caller's IP should read\n`deps.clientIp` instead of reading `X-Forwarded-For` (or any other\nheader) themselves — the latter is attacker-controlled unless you also\nknow the exact trusted-hop count. Tests that build an\n`AnonymousExtraRouteDeps` literal by hand must add a `clientIp` string."
22
+ },
23
+ {
24
+ "version": "0.321.0",
25
+ "type": "improvement",
26
+ "title": "feature-ast resolves imported constants and registrar wrappers without building a TypeScript program",
27
+ "detail": "Imported `const` initializers (named imports, aliases, `export { X as Y }`,\nre-export chains, `export *`) and imported registrar-wrapper functions are\nresolved through `ts.resolveModuleName` plus a syntactic export walk.\nSame-file lookups walk enclosing block scopes instead of the file top level. Parse\noutput is unchanged; parsing a feature file drops from hundreds of\nmilliseconds to a few milliseconds."
28
+ },
29
+ {
30
+ "version": "0.321.0",
31
+ "type": "breaking",
32
+ "title": "webhook.send step auth secretRef renamed to secret; MultiStreamApplyContext gained an optional secrets field",
33
+ "detail": "`r.step.webhook.send`'s `auth` union renamed `secretRef` → `secret` (see\nthe step-dispatcher entry for the full rationale). `MultiStreamApplyContext`\n(and `createMultiStreamApplyContext`'s deps) gained an optional\n`secrets?: SecretsContext` field, mirroring `files`/`derivatives` — present\nwhen the app booted with the secrets feature, letting a saga/process-\nmanager MSP apply read a tenant secret with its own audit context. The\nserver's MSP consumer wiring now threads the boot-time `AppContext.secrets`\nthrough automatically; `rebuildMultiStreamProjection`'s rebuild context\ndeliberately does not carry `secrets` (rebuild must stay side-effect-free).",
34
+ "migration": "Rename `auth.secretRef` → `auth.secret` at every `r.step.webhook.send`\ncall site. No action needed for `MultiStreamApplyContext` consumers that\ndon't read `ctx.secrets` — the field is optional and additive."
35
+ },
36
+ {
37
+ "version": "0.321.0",
38
+ "type": "improvement",
39
+ "title": "New event-store primitive transferAggregateStreams for tenant-to-tenant aggregate moves",
40
+ "detail": "`transferAggregateStreams(db, { sourceTenantId, destinationTenantId,\naggregateType, aggregateIds, transferredBy })` (event-store/transfer.ts)\nmoves an aggregate's events, drops its snapshot, and carries its archive\nmarker from one tenant to another, then appends one\n`kumiko:system:aggregate.transferred` system event per moved aggregate\n(own fresh stream, destination tenant, payload carries aggregateType/\naggregateId/sourceTenantId/destinationTenantId). An id with no events\nunder the given tenant/aggregateType is left untouched and gets no event.\nBacked by db/queries/event-store-transfer.ts.",
41
+ "migration": "No action for existing consumers — this is a new, additive primitive."
42
+ },
43
+ {
44
+ "version": "0.321.0",
45
+ "type": "fix",
46
+ "title": "upgrade-cli now collects every package's changes.json, not just framework's",
47
+ "detail": "`findCoreChangelogFile(cwd): string | null` is replaced by\n`findPackageChangelogFiles(cwd): string[]`. In the framework repo itself\nit collects every `packages/<dir>/src/changes.json` except\n`packages/bundled-features` (bundled features are already collected per\nfeature dir). In a consumer repo it walks up to 10 levels looking for\n`node_modules/@cosmicdrift/*/src/changes.json`, skipping\n`kumiko-bundled-features`, deduping by package name (nearest\n`node_modules` wins) and by realpath (symlinked workspace copies).",
48
+ "migration": "No action needed — `kumiko upgrade` now surfaces more relevant breaking\nchanges than before, it never hides ones it previously showed."
49
+ },
50
+ {
51
+ "version": "0.320.0",
52
+ "type": "breaking",
53
+ "title": "Centralized client-IP resolver replaces the old first-XFF-entry heuristic",
54
+ "detail": "New `trustedProxyHops` option on `ServerOptions` (default 0 — trusts no\nproxy header, only the socket address counts). Precedence:\n`options.trustedProxyHops ?? options.auth?.trustedProxyHops ?? 0`.\n`requestContext.ip` is now always set for an HTTP request (falls back to\n\"unknown\" instead of being left undefined), so `rateLimit: { per: \"ip\" }`\nhandlers reached via `r.httpRoute`/`extraRoutes` systemQuery are never\nsilently unthrottled. `rate-limit/middleware.ts`'s `globalIpRateLimit`/\n`authEndpointRateLimit` and `auth-routes.ts`'s `createAuthRoutes` gained a\n`trustedProxyHops`/`clientIpResolver` option and now share the server's\none resolver instance instead of each building their own.",
55
+ "migration": "Apps deployed behind a reverse-proxy/ingress that appends to\nX-Forwarded-For must set `trustedProxyHops` (buildServer/runProdApp/\nrunDevApp top-level option) to the number of trusted hops — usually `1`\nfor a single ingress. Without it, every client collapses into one shared\nrate-limit bucket (safe default, but likely too strict for real traffic).\nApps not behind a proxy need no change; `trustedProxyHops` defaults to 0.\n\nIntegration tests that call an `ip`/`ip+handler`-rate-limited handler\nrepeatedly through `stack.http.raw`/`stack.app.request` (no real socket)\nnow share one \"unknown\" bucket instead of skipping the bucket entirely —\na previously-passing test suite can start seeing 429s. Fix by flushing\nthe rate-limit namespace between test cases (`stack.redis.flushNamespace()`\nin `beforeEach`), or by setting `setupTestStack({ trustedProxyHops: 1 })`\nand sending a distinct `X-Forwarded-For` per test case. Also check any\ntest that nulls `context.redis` via `extraContext` to exercise a\nhandler's own redis-down guard: `setupTestStack` now keeps the L3\nrate-limit resolver alive from its own always-real Redis in that case\n(an explicit `context.rateLimit` still wins), so a handler-declared\n`rateLimit` is enforced instead of throwing `InternalError` for a missing\nresolver."
56
+ },
57
+ {
58
+ "version": "0.320.0",
59
+ "type": "fix",
60
+ "title": "Upload requests are now size-checked before the body is buffered",
61
+ "detail": "`POST /api/files` previously ran `parseBody()` on the full multipart request before any size check, so the body was parsed in full before the configured `maxUploadSize` ever ruled it out. It now rejects requests over the configured limit (checked upfront via `Content-Length`, and while streaming for chunked bodies) with a 413 before parsing. `runProdApp` and the dev server now also derive `Bun.serve`'s request-body cap from that same `maxUploadSize`/field `maxSize` configuration instead of Bun's own 128 MiB default; `buildBunServeOptions` keeps a lower fixed fallback for direct callers, overridable via `runProdApp`'s new `maxRequestBodySize` option."
62
+ },
63
+ {
64
+ "version": "0.320.0",
65
+ "type": "fix",
66
+ "title": "Dev-boot backfills nullable columns a persistent dev DB is missing",
67
+ "detail": "`pushEntityProjectionTables` (packages/framework/src/stack) now diffs an\nexisting implicit-projection table's live columns against the entity's\ncurrent column set via `columnNamesOf`, backfilling anything nullable or\ndefaulted through the newly extracted `addMissingColumns` helper (shared\nwith `unsafePushTables`'s existing column-diff loop). A missing required\ncolumn with no default throws instead, naming the column and the\npersistent dev DB (`KUMIKO_DEV_DB_NAME`) to drop so the next boot\nrecreates it. Covers implicit `r.entity()`-derived projection tables,\nwhich is what the reporting app's read-model table used. `setupTestStack`'s\nown explicit `r.projection()`/`r.storeTable()` push loop (test-stack.ts)\nhas the same skip-existing behavior and is not yet patched."
68
+ },
69
+ {
70
+ "version": "0.320.0",
71
+ "type": "fix",
72
+ "title": "Boot validator checks a metric's cross-screen navigate tab",
73
+ "detail": "A projectionDetail metric whose `navigate` sets `screen` or `entity` plus\n`tab` now fails boot when the target is not a projectionDetail with\nlayout.mode \"tabs\" or has no section with that id. Previously only a\nsame-screen `tab` was checked."
74
+ },
75
+ {
76
+ "version": "0.320.0",
77
+ "type": "breaking",
78
+ "title": "Personal Access Tokens are now scoped to the dispatcher routes only",
79
+ "migration": "A caller using a PAT for SSE, file upload/download, /api/auth/*, a feature\nr.httpRoute, or an entry:\"user\" extraRoute now gets a 403 and needs a\nsession-cookie or JWT credential for those calls instead. The dispatcher\nroutes (/api/write, /api/batch, /api/query, /api/command, /api/stream) are\nunaffected."
80
+ },
81
+ {
82
+ "version": "0.320.0",
83
+ "type": "fix",
84
+ "title": "No user-reference-name warning for owner fields referenced via personal.of",
85
+ "detail": "The `PII_USER_REFERENCE_NAME_HINTS` boot warning checked each field in isolation, so an owner field such as `authorId` kept warning even when a content field on the same entity already declared `personal: { of: \"authorId\" }`, the exact silencing condition the warning itself names. The validator now collects the owner fields referenced via `personal.of` per entity first and skips the warning for them. Fields with a user-reference-typical name that no `personal.of` references still warn."
86
+ },
87
+ {
88
+ "version": "0.320.0",
89
+ "type": "improvement",
90
+ "title": "New GET /api/schema route",
91
+ "detail": "Behind the existing `/api/*` auth guard. A signed-in, non-anonymous user\ngets the built `AppSchema` as JSON with `Cache-Control: private, no-cache`\nand a strong `ETag`; a matching `If-None-Match` gets a bodyless 304 (still\nbehind the same auth check, so an anonymous request with a stolen/guessed\nETag still 401s instead of getting a 304). Anonymous or missing auth gets\nthe same 401 `unauthenticated` response shape as every other non-public\nroute."
92
+ },
93
+ {
94
+ "version": "0.320.0",
95
+ "type": "improvement",
96
+ "title": "GET /api/schema now returns a per-role projection instead of the full AppSchema to every authenticated user",
97
+ "detail": "`buildAppSchema`'s output previously shipped every screen, nav, workspace and content-collection to any signed-in caller, regardless of role — a screen's own `access` rule only ever hid it in the UI, never removed it from the payload a curious client could still read. `projectAppSchemaForRoles` now strips every screen/nav/workspace/content-collection reference the caller's roles can't see (screens, nav entries, row/toolbar/related-list actions, entityEdit redirects, dashboard panels and metric navigation targets, tree actions, workspace nav membership) before the route serializes a response, closing empty parent nav sections and workspaces left with no surviving members along the way. Entities and translations are still shipped in full — the projection is a UI-visibility concern, not an entity-authorization concern; the dispatcher's `hasAccess` default-deny check is unchanged.\nThe route now builds the full schema lazily once per process and caches the projected JSON/ETag per canonical (deduplicated, sorted) role set — tenant is deliberately not part of the cache key, since the projection only depends on roles.\n`isUiAccessGranted` is the new shared default-visible UI predicate in `@cosmicdrift/kumiko-types`, re-exported through `framework/ui-types`. The renderer's `screenAccessAllows` is now an alias of it, and headless nav resolution and renderer-web's workspace filter call it directly instead of carrying their own copies.\nA deep link to a screen the caller's roles no longer receive now shows the \"screen not found\" banner instead of the access-denied banner, because the screen is no longer part of that caller's schema."
98
+ },
99
+ {
100
+ "version": "0.320.0",
101
+ "type": "fix",
102
+ "title": "Sequential-job lock key scoped by queueNamePrefix; contract clarified as mutual exclusion, not FIFO",
103
+ "detail": "The per-name Redis SETNX lock behind `concurrency: \"sequential\"` used a fixed `kumiko:lock:seq:<lane>:` key, unlike the queues themselves, which are scoped by `queueNamePrefix`. Two runners sharing a Redis but isolated by distinct prefixes (e.g. per-test-run prefixes) could collide on the same lock key even though their queues never saw each other's jobs. The key is now `kumiko:lock:seq:<queueNamePrefix>:<lane>:`. Separately, `JobDefinition.concurrency`'s doc comment and the integration test now state the contract precisely: \"sequential\" guarantees same-name dispatches never run concurrently, but does not guarantee they run in dispatch order. A lock loser is re-enqueued to the back of its queue, so a later dispatch can still complete before an earlier one. See fw#3265 for the local repro evidence backing this."
104
+ },
105
+ {
106
+ "version": "0.320.0",
107
+ "type": "improvement",
108
+ "title": "Real-provider helpers under the import-free subpath testing/real-providers",
109
+ "detail": "`@cosmicdrift/kumiko-framework/testing/real-providers` exports `requireRealProviders`, `isRealProviderRun` and `REAL_PROVIDERS_ENV` without pulling in the `./testing` barrel and its Bun-only dependencies, so code that loads under Node can use them."
110
+ },
111
+ {
112
+ "version": "0.320.0",
113
+ "type": "improvement",
114
+ "title": "Boot validator checks the target tab of toolbar navigate actions",
115
+ "detail": "A ToolbarAction with kind \"navigate\" and `tab` fails boot when its target\nis not a projectionDetail with layout.mode \"tabs\" or has no section with\nthat id. The check covers entityList, projectionList and relatedList\nsection toolbars."
116
+ },
117
+ {
118
+ "version": "0.320.0",
119
+ "type": "fix",
120
+ "title": "HttpRouteHandlerDeps carries the resolved clientIp",
121
+ "detail": "`r.httpRoute` handlers get a new `clientIp: string` dep, resolved once per\nrequest via `buildServer`'s existing shared `clientIpResolver` (the same\ninstance `requestIdMiddleware` and the L1/L2 rate limits use), instead of\neach handler parsing `X-Forwarded-For`/`X-Real-IP` itself with no\nknowledge of the deployment's actual `trustedProxyHops`. `UNKNOWN_CLIENT_IP`\nis now exported from `@cosmicdrift/kumiko-framework/api` so callers can\ndetect the resolver's no-value sentinel without hardcoding the string."
122
+ },
2
123
  {
3
124
  "version": "0.318.0",
4
125
  "type": "improvement",
@@ -0,0 +1,73 @@
1
+ // Raw SQL backing event-store/transfer.ts — the framework's own tenant-transfer
2
+ // primitive for streams. Kept in db/queries alongside the rest of the
3
+ // event-store's raw statements (see event-store.ts, event-store-admin.ts,
4
+ // backfill-pii.ts): the one place in the repo allowed to write tenant_id
5
+ // directly onto kumiko_events / kumiko_snapshots / kumiko_archived_streams.
6
+
7
+ import type { AnyDb } from "../query";
8
+ import { asRawClient } from "../query";
9
+
10
+ export type TransferEventRowsParams = {
11
+ readonly sourceTenantId: string;
12
+ readonly destinationTenantId: string;
13
+ readonly aggregateType: string;
14
+ readonly aggregateIds: readonly string[];
15
+ };
16
+
17
+ // Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
18
+ // per-event-row hits in-process instead of relying on invalid SQL.
19
+ export async function transferEventRows(
20
+ db: AnyDb,
21
+ params: TransferEventRowsParams,
22
+ ): Promise<readonly string[]> {
23
+ const rows = (await asRawClient(db).unsafe(
24
+ `UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
25
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4) ` +
26
+ `RETURNING "aggregate_id" AS id`,
27
+ [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
28
+ )) as ReadonlyArray<{ id: string }>;
29
+ return [...new Set(rows.map((row) => row.id))];
30
+ }
31
+
32
+ export type DeleteSnapshotsForAggregatesParams = {
33
+ readonly tenantId: string;
34
+ readonly aggregateIds: readonly string[];
35
+ };
36
+
37
+ // Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
38
+ // dropped rather than rewritten, forcing a full replay from the just-moved
39
+ // events on next read instead of carrying the snapshot's own generation
40
+ // bookkeeping across the tenant boundary.
41
+ export async function deleteSnapshotsForAggregates(
42
+ db: AnyDb,
43
+ params: DeleteSnapshotsForAggregatesParams,
44
+ ): Promise<void> {
45
+ await asRawClient(db).unsafe(
46
+ `DELETE FROM "kumiko_snapshots" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2)`,
47
+ [params.tenantId, params.aggregateIds],
48
+ );
49
+ }
50
+
51
+ export type TransferArchivedStreamRowsParams = {
52
+ readonly sourceTenantId: string;
53
+ readonly destinationTenantId: string;
54
+ readonly aggregateType: string;
55
+ readonly aggregateIds: readonly string[];
56
+ };
57
+
58
+ // An archived stream's marker must move with it — leaving it behind would
59
+ // make the stream writable again the instant it lands in the destination
60
+ // tenant (archive.ts's isStreamArchived check is itself tenant-scoped).
61
+ // `aggregate_type` is part of the match, same as transferEventRows — an
62
+ // archived marker for a DIFFERENT aggregate type that happens to share the
63
+ // moved aggregate's id must stay in the source tenant.
64
+ export async function transferArchivedStreamRows(
65
+ db: AnyDb,
66
+ params: TransferArchivedStreamRowsParams,
67
+ ): Promise<void> {
68
+ await asRawClient(db).unsafe(
69
+ `UPDATE "kumiko_archived_streams" SET "tenant_id" = $1 ` +
70
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4)`,
71
+ [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
72
+ );
73
+ }
@@ -365,6 +365,53 @@ describe("validateBoot — PII annotations", () => {
365
365
  expect(matchingWarn).toBeUndefined();
366
366
  });
367
367
 
368
+ const userReferenceHintWarningFor = (fieldName: string) =>
369
+ warnSpy.mock.calls.find((args: unknown[]) => {
370
+ const message = String(args[0]);
371
+ return (
372
+ message.includes(`Field "${fieldName}"`) && message.includes("user-reference-typical name")
373
+ );
374
+ });
375
+
376
+ function defineCommentFeatureWithBodyOwnedBy(ownerFieldName: "authorId" | "ownerId") {
377
+ return defineFeature("test", (r) => {
378
+ r.entity(
379
+ "user",
380
+ createEntity({
381
+ fields: {
382
+ email: createTextField({ personal: "self", find: "none" }),
383
+ },
384
+ }),
385
+ );
386
+ stubListHandler(r, "user");
387
+ r.entity(
388
+ "comment",
389
+ createEntity({
390
+ fields: {
391
+ body: createLongTextField({ personal: { of: ownerFieldName }, find: "none" }),
392
+ authorId: { type: "reference", entity: "user" },
393
+ ownerId: { type: "reference", entity: "user" },
394
+ },
395
+ }),
396
+ );
397
+ });
398
+ }
399
+
400
+ test("owner field referenced by another field's personal.of does not trigger the user-reference-name warning", () => {
401
+ validateBoot([defineCommentFeatureWithBodyOwnedBy("authorId")]);
402
+ expect(userReferenceHintWarningFor("authorId")).toBeUndefined();
403
+ const anyAuthorIdWarning = warnSpy.mock.calls.find((args: unknown[]) =>
404
+ String(args[0]).includes('Field "authorId"'),
405
+ );
406
+ expect(anyAuthorIdWarning).toBeUndefined();
407
+ });
408
+
409
+ test("user-reference-named field not referenced by any personal.of still warns", () => {
410
+ validateBoot([defineCommentFeatureWithBodyOwnedBy("ownerId")]);
411
+ expect(userReferenceHintWarningFor("authorId")).toBeDefined();
412
+ expect(userReferenceHintWarningFor("ownerId")).toBeUndefined();
413
+ });
414
+
368
415
  test("allowPlaintext marker silences PII-name heuristic warning", () => {
369
416
  const feature = defineFeature("test", (r) => {
370
417
  r.entity(