@cosmicdrift/kumiko-framework 0.299.0 → 0.305.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 (66) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/api.test.ts +3 -2
  3. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  4. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  5. package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
  6. package/src/api/__tests__/server-error-logging.test.ts +33 -0
  7. package/src/api/api-constants.ts +13 -0
  8. package/src/api/auth-routes.ts +53 -24
  9. package/src/api/extra-route.ts +33 -4
  10. package/src/api/index.ts +1 -0
  11. package/src/api/server.ts +79 -34
  12. package/src/changes.json +68 -0
  13. package/src/db/event-store-executor-write.ts +7 -0
  14. package/src/db/tenant-db.ts +50 -3
  15. package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
  16. package/src/engine/__tests__/membership-roles.test.ts +13 -4
  17. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
  18. package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
  19. package/src/engine/boot-validator/access-declarations.ts +58 -67
  20. package/src/engine/boot-validator/entity-handler.ts +20 -0
  21. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  22. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  23. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
  24. package/src/engine/feature-ast/extractors/hooks.ts +3 -1
  25. package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
  26. package/src/engine/feature-ast/patcher.ts +2 -2
  27. package/src/engine/feature-ast/patterns.ts +1 -1
  28. package/src/engine/feature-ast/render.ts +1 -1
  29. package/src/engine/feature-ui-extensions.ts +6 -0
  30. package/src/engine/index.ts +4 -0
  31. package/src/engine/membership-roles.ts +20 -4
  32. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  33. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  34. package/src/engine/personal-data-fields.ts +66 -0
  35. package/src/engine/registry-validate.ts +15 -0
  36. package/src/engine/registry.ts +2 -0
  37. package/src/engine/types/index.ts +4 -0
  38. package/src/env/__tests__/dry-run.test.ts +43 -3
  39. package/src/env/dry-run.ts +28 -15
  40. package/src/errors/__tests__/write-failures.test.ts +47 -4
  41. package/src/errors/i18n/de.yaml +12 -0
  42. package/src/errors/i18n/en.yaml +12 -0
  43. package/src/errors/reasons.ts +4 -0
  44. package/src/errors/write-error-info.ts +12 -3
  45. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  46. package/src/jobs/__tests__/jobs.integration.test.ts +35 -0
  47. package/src/jobs/job-runner.ts +19 -5
  48. package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
  49. package/src/observability/index.ts +5 -0
  50. package/src/observability/metrics-wiring.ts +32 -0
  51. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  52. package/src/pipeline/active-membership.ts +5 -1
  53. package/src/pipeline/dispatch-batch.ts +3 -0
  54. package/src/pipeline/dispatch-query.ts +16 -5
  55. package/src/pipeline/dispatch-shared.ts +12 -5
  56. package/src/pipeline/dispatch-stream.ts +7 -2
  57. package/src/pipeline/dispatch-write.ts +22 -5
  58. package/src/pipeline/dispatcher.ts +9 -2
  59. package/src/pipeline/member-reader.ts +3 -1
  60. package/src/pipeline/write-origin.ts +107 -0
  61. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  62. package/src/rate-limit/middleware.ts +3 -0
  63. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  64. package/src/stack/test-stack.ts +5 -0
  65. package/src/testing/handler-context.ts +3 -1
  66. package/src/ui-types/index.ts +2 -0
package/src/api/server.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Hono } from "hono";
1
+ 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";
@@ -59,7 +59,7 @@ import {
59
59
  getUser,
60
60
  type TenantLifecycleStatusResolver,
61
61
  } from "./auth-middleware";
62
- import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
62
+ import { type AuthRoutesConfig, createAuthRoutes, type LoginRateLimiter } from "./auth-routes";
63
63
  import { csrfMiddleware } from "./csrf-middleware";
64
64
  import {
65
65
  type ExtraRouteDefinition,
@@ -160,14 +160,17 @@ export type ServerOptions = {
160
160
  // of the app process.
161
161
  // - `auth`: gates a single path-pattern (default `/api/auth/*`)
162
162
  // with tighter limits. Typically `limit: 5, windowSeconds: 60`
163
- // to slow brute-force without breaking real users.
163
+ // to slow brute-force without breaking real users. GET /api/auth/tenants
164
+ // (session read, called on every page load) is always exempt — see
165
+ // AUTH_RATE_LIMIT_EXEMPT_ROUTES in api-constants.ts.
164
166
  // Both omitted → no L1/L2 wired and no resolver auto-built unless an
165
167
  // L3 handler declared `rateLimit:`. This keeps zero-cost when unused.
166
168
  rateLimit?: {
167
169
  readonly global?: Omit<GlobalIpRateLimitOptions, "resolver">;
168
170
  readonly auth?: Omit<AuthEndpointRateLimitOptions, "resolver"> & {
169
171
  // Path-pattern the L2 middleware applies to. Default `/api/auth/*`.
170
- // Override for apps with a different auth route layout.
172
+ // Override for apps with a different auth route layout. The
173
+ // GET /api/auth/tenants exemption above applies regardless of `path`.
171
174
  readonly path?: string;
172
175
  };
173
176
  };
@@ -723,30 +726,24 @@ export function buildServer(options: ServerOptions): KumikoServer {
723
726
  return jwtGuard(c, next);
724
727
  });
725
728
 
729
+ // Without anonymousAccess a missing token 401s instead of falling through as anonymous.
730
+ const sessionOnlyGuard = authMiddleware(jwt, {
731
+ ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
732
+ ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
733
+ ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
734
+ });
735
+
726
736
  // PAT rate limiting — runs AFTER the auth guard so the resolved principal is
727
737
  // available. Only PAT-authenticated requests are counted (keyed by token id);
728
738
  // cookie/JWT users pass through untouched. In-memory limiter is per-instance
729
739
  // (see run-prod-app) — a multi-node deployment wanting a shared counter swaps
730
740
  // in a Redis-backed LoginRateLimiter.
731
741
  const patRateLimiter = options.auth?.patRateLimiter;
732
- if (patRateLimiter) {
742
+ const patRateLimitGuard = patRateLimiter ? buildPatRateLimitGuard(patRateLimiter) : undefined;
743
+ if (patRateLimitGuard) {
733
744
  app.use("/api/*", async (c, next) => {
734
745
  if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
735
- const pat = getUser(c)?.pat;
736
- if (pat && !(await patRateLimiter.check(pat.tokenId))) {
737
- return c.json(
738
- {
739
- error: {
740
- code: "pat_rate_limited",
741
- httpStatus: 429,
742
- message: "personal access token rate limit exceeded",
743
- i18nKey: "auth.errors.patRateLimited",
744
- },
745
- },
746
- 429,
747
- );
748
- }
749
- return next();
746
+ return patRateLimitGuard(c, next);
750
747
  });
751
748
  }
752
749
 
@@ -760,8 +757,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
760
757
  // unguarded-subdomain-XSS footgun, not a warn-and-continue case.
761
758
  assertOriginGuardConfig(options.auth);
762
759
  const allowedOrigins = options.auth?.allowedOrigins;
763
- if (allowedOrigins && allowedOrigins.length > 0) {
764
- const originGuard = originMiddleware(allowedOrigins);
760
+ const originGuard =
761
+ allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
762
+ if (originGuard) {
765
763
  app.use("/api/*", async (c, next) => {
766
764
  if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
767
765
  return originGuard(c, next);
@@ -781,6 +779,14 @@ export function buildServer(options: ServerOptions): KumikoServer {
781
779
  return csrfGuard(c, next);
782
780
  });
783
781
 
782
+ // Same order as /api/* above: auth → PAT → origin → CSRF.
783
+ const sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[] = [
784
+ sessionOnlyGuard,
785
+ ...(patRateLimitGuard ? [patRateLimitGuard] : []),
786
+ ...(originGuard ? [originGuard] : []),
787
+ csrfGuard,
788
+ ];
789
+
784
790
  // Public auth routes (login) need to be registered BEFORE the generic
785
791
  // api routes so Hono matches them first.
786
792
  if (options.auth) {
@@ -845,20 +851,25 @@ export function buildServer(options: ServerOptions): KumikoServer {
845
851
  const honoHandler = async (c: import("hono").Context): Promise<Response> =>
846
852
  route.handler(c, {
847
853
  app,
848
- // createAnonymousUser, NOT createSystemUser: httpRoute handlers
849
- // using systemQuery are, by construction, `anonymous: true`
850
- // public routes — the synthesized user must clear the SAME
851
- // access gate a real anonymous visitor would, no more. The
852
- // system role would ALSO satisfy that gate here, but it can
853
- // read fields gated to "system" that "anonymous" can't
854
- // (filterReadFields is a plain role-in-map check) — a future
855
- // systemQuery caller reading a system-gated field would leak
856
- // it into a public response. The forced tenant already comes
857
- // from bypassing the HTTP layer entirely; no elevated role
858
- // is needed or wanted on top of that.
854
+ // createAnonymousUser, NOT createSystemUser: systemQuery's
855
+ // synthesized user must clear the SAME access gate a real
856
+ // anonymous visitor would, no more — regardless of the route's
857
+ // own `anonymous` mode. The system role would ALSO satisfy that
858
+ // gate here, but it can read fields gated to "system" that
859
+ // "anonymous" can't (filterReadFields is a plain role-in-map
860
+ // check) — a systemQuery caller reading a system-gated field
861
+ // would leak it into the response. The forced tenant already
862
+ // comes from bypassing the HTTP layer entirely; no elevated
863
+ // role is needed or wanted on top of that.
859
864
  systemQuery: makeSystemQuery(c, dispatcher),
860
865
  });
861
- mountHonoRoute(app, route.method, route.path, honoHandler);
866
+ mountHonoRoute(
867
+ app,
868
+ route.method,
869
+ route.path,
870
+ honoHandler,
871
+ route.anonymous ? [] : sessionOnlyHttpRouteGuards,
872
+ );
862
873
  }
863
874
  }
864
875
 
@@ -958,7 +969,17 @@ function mountHonoRoute(
958
969
  path: string,
959
970
  // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
960
971
  handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>,
972
+ middlewares: readonly MiddlewareHandler[] = [],
961
973
  ): void {
974
+ // Guards go into the route's own handler chain, never a method-gated
975
+ // app.use: Hono serves HEAD through the GET route with c.req.method still
976
+ // "HEAD", so a `method === c.req.method` gate would skip them for HEAD.
977
+ if (middlewares.length > 0) {
978
+ // [path]: only Hono's array-path overload accepts a variable-length handler spread.
979
+ app.on(method, [path], ...middlewares, handler);
980
+ // skip: guarded route already mounted with its guard chain
981
+ return;
982
+ }
962
983
  switch (method) {
963
984
  case "GET":
964
985
  app.get(path, handler);
@@ -985,6 +1006,27 @@ function mountHonoRoute(
985
1006
  }
986
1007
  }
987
1008
 
1009
+ // Must run after the auth guard so getUser(c) carries the PAT.
1010
+ function buildPatRateLimitGuard(patRateLimiter: LoginRateLimiter): MiddlewareHandler {
1011
+ return async (c, next) => {
1012
+ const pat = getUser(c)?.pat;
1013
+ if (pat && !(await patRateLimiter.check(pat.tokenId))) {
1014
+ return c.json(
1015
+ {
1016
+ error: {
1017
+ code: "pat_rate_limited",
1018
+ httpStatus: 429,
1019
+ message: "personal access token rate limit exceeded",
1020
+ i18nKey: "auth.errors.patRateLimited",
1021
+ },
1022
+ },
1023
+ 429,
1024
+ );
1025
+ }
1026
+ return next();
1027
+ };
1028
+ }
1029
+
988
1030
  // Shared systemQuery builder for r.httpRoute and extraRoutes (anonymous +
989
1031
  // signature entries). requestContext.run must wrap the dispatcher call —
990
1032
  // both route kinds run outside the requestIdMiddleware chain that normally
@@ -1139,6 +1181,9 @@ function buildExtraRouteHonoHandler(
1139
1181
  );
1140
1182
  } catch (e) {
1141
1183
  if (e instanceof ExtraRouteRejection) {
1184
+ if (e.retryAfterSeconds !== undefined) {
1185
+ return c.json(e.body, e.status, { "Retry-After": String(e.retryAfterSeconds) });
1186
+ }
1142
1187
  return c.json(e.body, e.status);
1143
1188
  }
1144
1189
  return c.json(
package/src/changes.json CHANGED
@@ -1,4 +1,72 @@
1
1
  [
2
+ {
3
+ "version": "0.305.0",
4
+ "type": "breaking",
5
+ "title": "rateLimit.auth (L2) no longer throttles GET /api/auth/tenants",
6
+ "detail": "The SPA's own session bootstrap called GET /api/auth/tenants on every\npage load, which counted against the L2 auth-endpoint bucket and threw\n429 on the 6th page load within a minute. This route is now exempted\nfrom rateLimit.auth by exact method+path match; all other auth routes\n(including POST on the same path, if ever added) are unaffected.",
7
+ "migration": "Apps using the default rateLimit.auth need no changes. Apps that want\nto keep throttling GET /api/auth/tenants should rely on rateLimit.global\n(L1, IP-based) instead. Credential-submitting POST routes are unaffected\neven when rateLimit.auth's `path` option is customized."
8
+ },
9
+ {
10
+ "version": "0.305.0",
11
+ "type": "fix",
12
+ "title": "KUMIKO_DRY_RUN_ENV=pulumi and =k8s now list optional env keys (e.g. PROMETHEUS_METRICS_TOKEN) as commented-out lines under an \"Optional\" header, with secret flag, generator and description; defaulted keys stay omitted",
13
+ "migration": "No action needed. Uncomment and set an optional line only when you want to enable that feature."
14
+ },
15
+ {
16
+ "version": "0.305.0",
17
+ "type": "improvement",
18
+ "title": "ExtraRouteRejection supports 503 + Retry-After for signature routes whose verify() is temporarily unable to run"
19
+ },
20
+ {
21
+ "version": "0.305.0",
22
+ "type": "fix",
23
+ "title": "Job backoff now waits between retries: backoff defaults to a 1000 ms base delay and accepts { type, delayMs } (fw#3167)",
24
+ "detail": "Previously, jobs with backoff set retried immediately: BullMQ received only { type } with no delay, and its fixed/exponential strategies compute NaN/undefined without one (falsy, so no wait). Now \"fixed\" waits a constant 1000 ms and \"exponential\" waits 1000/2000/4000 ms... between attempts by default. Jobs with a high retries count will therefore take noticeably longer to reach their final failure. A new object form, backoff: { type, delayMs }, lets a job configure its own base delay instead of the 1000 ms default."
25
+ },
26
+ {
27
+ "version": "0.305.0",
28
+ "type": "breaking",
29
+ "title": "Writes under an anonymous root need access.personalData: \"public-intake\" at runtime, across feature boundaries (fw#3165)",
30
+ "detail": "The boot check from fw#2885 only sees personal-data keys in an anonymous write handler's own input schema, for entities of its own feature. Every public dispatch (write, batch command, query, stream) now computes a WriteOrigin (root handler, anonymous root, public-intake declared) once. Every nested call inherits it: ctx.write, ctx.writeAs, ctx.query, ctx.queryAs, nested writes and afterCommit hooks. When the root is anonymous and does not declare public-intake, a write that touches a personal-data field (pii / userOwned / recordOwned) of any registered entity fails with AccessDeniedError, details.reason \"public_intake_required\". The error details name the root handler, the table and the fields, never the values. The check runs in TenantDb.insertOne/updateMany, in db.global().insertOne/updateMany and in the event-sourced create/update executor, after preSave and before the event append. It also covers rebound TenantDbs (acknowledgeCrossTenant, hook re-gating). Authenticated sessions are not affected. Not gated: ctx.db.unsafeRaw (covered by escapeHatch plus audit); ctx.appendEvent on a feature's own events (foreign events are already rejected); tables outside the registered entities; jobs and event subscribers queued from an anonymous root; and TenantDbs that handler code builds directly with createTenantDb. The last two are tracked in fw#3185, which also lists the bundled auth-email-password and user-data-rights flows that go through unsafeRaw or createTenantDb.",
31
+ "migration": "A write handler that anonymous callers can reach (roles include \"anonymous\") and that writes a personal-data field (pii / userOwned / recordOwned) of any entity must declare access: { roles: [..., \"anonymous\"], personalData: \"public-intake\" }. This applies whether the handler writes the field itself or through ctx.db, the CRUD executor, ctx.write, ctx.writeAs/queryAs or a postSave/afterCommit hook, and it applies across features. Without the declaration the write now fails with AccessDeniedError (details.reason \"public_intake_required\"). A failing afterCommit hook is only logged, and its write does not happen. Known consumer handlers, measured on 23.09.2026: offlot-app waitlist:submit, vehicle-enquiry:submit and try-first:set-contact; publicstatus email-subscriber:subscribe; show-pony rsvp:submit. Add the declaration if the anonymous intake is intended (the handler's rateLimit is then the only protection). Otherwise stop writing the field from the anonymous path."
32
+ },
33
+ {
34
+ "version": "0.305.0",
35
+ "type": "improvement",
36
+ "title": "setupTestStack accepts a metrics option, forwarded to buildServer like runProdApp (fw#3182)",
37
+ "detail": "TestStackOptions gains an optional metrics field forwarded to buildServer, same as runProdApp. Spread resolveObservabilityWiring(token) into setupTestStack/setupTestStackFromFeatures/setupAppTestStack to get /metrics mounted in integration tests with the same token-gated PrometheusMeter-backed behavior as prod."
38
+ },
39
+ {
40
+ "version": "0.305.0",
41
+ "type": "fix",
42
+ "title": "Write-path 5xx logs now include the original cause chain"
43
+ },
44
+ {
45
+ "version": "0.303.0",
46
+ "type": "breaking",
47
+ "title": "r.httpRoute's `anonymous` field is now required and controls the mount, not just docs (fw#2885)",
48
+ "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.",
49
+ "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."
50
+ },
51
+ {
52
+ "version": "0.302.0",
53
+ "type": "breaking",
54
+ "title": "Anonymous write handlers whose input accepts a personal-data field must declare access.personalData: \"public-intake\" (fw#2885)",
55
+ "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.",
56
+ "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."
57
+ },
58
+ {
59
+ "version": "0.301.0",
60
+ "type": "improvement",
61
+ "title": "Add shared /metrics wiring: prometheusMetricsEnvSchema and resolveObservabilityWiring under @cosmicdrift/kumiko-framework/observability",
62
+ "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)."
63
+ },
64
+ {
65
+ "version": "0.300.0",
66
+ "type": "breaking",
67
+ "title": "Remove the guest-identity all-role: unauthenticated handlers must declare roles: [\"anonymous\"] with a rateLimit",
68
+ "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."
69
+ },
2
70
  {
3
71
  "version": "0.298.0",
4
72
  "type": "improvement",
@@ -31,6 +31,7 @@ import {
31
31
  tryMapUniqueViolation,
32
32
  } from "./event-store-executor-context";
33
33
  import { runInSavepointIfSupported } from "./query";
34
+ import { assertPersonalDataWrite, tableNameOf } from "./tenant-db";
34
35
  import { tenantDbRunner } from "./tenant-db-runner";
35
36
 
36
37
  // Art. 17 erasure runs as the framework operator, not as a row owner; a
@@ -145,6 +146,9 @@ export function createWriteVerbs(
145
146
  if ("failure" in preSaveResult) return preSaveResult.failure;
146
147
  const data = preSaveResult.data;
147
148
 
149
+ // After preSave so derived fields count, before the event append so nothing persists.
150
+ assertPersonalDataWrite(db, tableNameOf(table), Object.keys(data), entity);
151
+
148
152
  // H.2 — entity-level write-ownership on create. No oldRow exists, so
149
153
  // only the new row is checked. No Straddle concern for creates.
150
154
  if (!userCanCreateFieldRow(user, entity.access?.write, data)) {
@@ -334,6 +338,9 @@ export function createWriteVerbs(
334
338
  if ("failure" in preSaveResult) return preSaveResult.failure;
335
339
  const changes = preSaveResult.data;
336
340
 
341
+ // After preSave so derived fields count, before the event append so nothing persists.
342
+ assertPersonalDataWrite(db, tableNameOf(table), Object.keys(changes), entity);
343
+
337
344
  // H.2 — entity-level write-ownership on update. Load old row (already
338
345
  // done above), build post-change row via shallow merge. Straddle-safe
339
346
  // multi-role check: at least one role must accept BOTH old and new —
@@ -24,6 +24,7 @@ import {
24
24
  type SelectOptions,
25
25
  type WhereObject,
26
26
  } from "../db/query";
27
+ import type { EntityDefinition } from "../engine/types/fields";
27
28
  import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
28
29
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
29
30
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
@@ -45,6 +46,21 @@ const declaredUnsafeRawRunners = new WeakMap<
45
46
  (reason: string) => DbRunner
46
47
  >();
47
48
 
49
+ // The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
50
+ // TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
51
+ // (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
52
+ const personalDataGates = new WeakMap<TenantDb, PersonalDataGate>();
53
+
54
+ // The executor passes its entity so the check does not depend on the table-name lookup.
55
+ export function assertPersonalDataWrite(
56
+ db: TenantDb,
57
+ tableName: string,
58
+ keys: readonly string[],
59
+ entity: EntityDefinition,
60
+ ): void {
61
+ personalDataGates.get(db)?.(tableName, keys, entity);
62
+ }
63
+
48
64
  // Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
49
65
  export function unsafeRawForDeclaredStep(
50
66
  holder: TenantDb | UncheckedSystemDb,
@@ -170,7 +186,7 @@ export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): rea
170
186
  return rows as unknown as readonly T[];
171
187
  }
172
188
 
173
- function tableNameOf(table: Table | EntityTableMeta): string {
189
+ export function tableNameOf(table: Table | EntityTableMeta): string {
174
190
  const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
175
191
  if (typeof sym === "string") return sym;
176
192
  return asEntityTableMeta(table)?.tableName ?? "<unknown>";
@@ -205,8 +221,16 @@ export type TenantDbGrants = {
205
221
  // Set for a resolved member principal (ctx.queryAsMember): no raw DbRunner leaves
206
222
  // this TenantDb, so no handler can COMMIT/RELEASE SAVEPOINT out of the READ ONLY scope.
207
223
  readonly memberReadOnly?: boolean;
224
+ // Set only for an anonymous root without personalData: "public-intake" (write-origin.ts).
225
+ readonly personalDataGate?: PersonalDataGate;
208
226
  };
209
227
 
228
+ export type PersonalDataGate = (
229
+ tableName: string,
230
+ keys: readonly string[],
231
+ entity?: EntityDefinition,
232
+ ) => void;
233
+
210
234
  const unsafeRawRebinders = new WeakMap<
211
235
  TenantDb,
212
236
  (grant: EscapeHatchDeclaration | undefined) => TenantDb
@@ -347,6 +371,20 @@ export function createTenantDb(
347
371
  return grants?.globalWrites?.reason ?? "";
348
372
  }
349
373
 
374
+ function personalDataDenied(
375
+ table: Table | EntityTableMeta,
376
+ keys: readonly string[],
377
+ ): AccessDeniedError | undefined {
378
+ if (!grants?.personalDataGate) return undefined;
379
+ try {
380
+ grants.personalDataGate(tableNameOf(table), keys);
381
+ return undefined;
382
+ } catch (e) {
383
+ if (e instanceof AccessDeniedError) return e;
384
+ throw e;
385
+ }
386
+ }
387
+
350
388
  function foreignTenantOnGlobalWrite(
351
389
  table: Table | EntityTableMeta,
352
390
  tenantIdValue: unknown,
@@ -379,7 +417,9 @@ export function createTenantDb(
379
417
  values: Record<string, unknown>,
380
418
  ): Promise<T | undefined> {
381
419
  const denied =
382
- missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
420
+ missingEscapeHatch(table) ??
421
+ foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
422
+ personalDataDenied(table, Object.keys(values));
383
423
  if (denied) return Promise.reject(denied);
384
424
  report("global-write", globalWriteReason());
385
425
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
@@ -389,7 +429,9 @@ export function createTenantDb(
389
429
  where: WhereObject,
390
430
  ): Promise<readonly T[]> {
391
431
  const denied =
392
- missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, set["tenantId"]);
432
+ missingEscapeHatch(table) ??
433
+ foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
434
+ personalDataDenied(table, Object.keys(set));
393
435
  if (denied) return Promise.reject(denied);
394
436
  if (!where || Object.keys(where).length === 0) {
395
437
  return Promise.reject(
@@ -483,6 +525,8 @@ export function createTenantDb(
483
525
  );
484
526
  if (denied) return Promise.reject(denied);
485
527
  }
528
+ const personalDenied = personalDataDenied(table, Object.keys(values));
529
+ if (personalDenied) return Promise.reject(personalDenied);
486
530
  const data = insertValues(table, values);
487
531
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
488
532
  },
@@ -499,6 +543,8 @@ export function createTenantDb(
499
543
  ),
500
544
  );
501
545
  }
546
+ const personalDenied = personalDataDenied(table, Object.keys(set));
547
+ if (personalDenied) return Promise.reject(personalDenied);
502
548
  const filter = writeWhere(table, where);
503
549
  return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, filter));
504
550
  },
@@ -525,6 +571,7 @@ export function createTenantDb(
525
571
  return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
526
572
  });
527
573
  bindTenantDbRunner(tenantDb, db);
574
+ if (grants?.personalDataGate) personalDataGates.set(tenantDb, grants.personalDataGate);
528
575
  return tenantDb;
529
576
  }
530
577
 
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "../define-feature";
3
+
4
+ describe("r.httpRoute — anonymous is required", () => {
5
+ test("missing anonymous → throws (JS caller without HttpRouteDefinition's type)", () => {
6
+ expect(() =>
7
+ defineFeature("feed", (r) => {
8
+ r.httpRoute({
9
+ method: "GET",
10
+ path: "/feed.xml",
11
+ handler: async () => new Response("ok"),
12
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
13
+ } as any);
14
+ }),
15
+ ).toThrow(/must declare anonymous: true \| false/);
16
+ });
17
+
18
+ test("anonymous: true → accepted", () => {
19
+ expect(() =>
20
+ defineFeature("feed", (r) => {
21
+ r.httpRoute({
22
+ method: "GET",
23
+ path: "/feed.xml",
24
+ anonymous: true,
25
+ handler: async () => new Response("ok"),
26
+ });
27
+ }),
28
+ ).not.toThrow();
29
+ });
30
+
31
+ test("anonymous: false → accepted", () => {
32
+ expect(() =>
33
+ defineFeature("feed", (r) => {
34
+ r.httpRoute({
35
+ method: "GET",
36
+ path: "/feed.xml",
37
+ anonymous: false,
38
+ handler: async () => new Response("ok"),
39
+ });
40
+ }),
41
+ ).not.toThrow();
42
+ });
43
+ });
@@ -38,10 +38,9 @@ describe("forbidden membership roles", () => {
38
38
  });
39
39
  });
40
40
 
41
- // The two cases that discriminate the fix at every JWT mint: the strip wraps
42
- // ONLY the membership portion, never the merged result — so a legitimate
43
- // SystemAdmin in globalRoles survives, a resurrected one in membership does not.
44
- describe("merge semantics (globalRoles never filtered)", () => {
41
+ // globalRoles keeps SystemAdmin/system but loses anonymous/all; membershipRoles
42
+ // strips all forbidden roles (SystemAdmin, system, anonymous, all).
43
+ describe("merge semantics (globalRoles: SystemAdmin/system kept, anonymous/all stripped)", () => {
45
44
  test("global SystemAdmin survives (no regression for real admins)", () => {
46
45
  expect(buildSessionRoles(["SystemAdmin"], [])).toContain("SystemAdmin");
47
46
  });
@@ -55,4 +54,14 @@ describe("merge semantics (globalRoles never filtered)", () => {
55
54
  ["Admin", "SystemAdmin"].sort(),
56
55
  );
57
56
  });
57
+
58
+ test("anonymous/all in globalRoles are stripped, SystemAdmin stays", () => {
59
+ expect([...buildSessionRoles(["anonymous", "all", "SystemAdmin"], [])].sort()).toEqual([
60
+ "SystemAdmin",
61
+ ]);
62
+ });
63
+
64
+ test("anonymous/all in membershipRoles are stripped too", () => {
65
+ expect(buildSessionRoles([], ["anonymous", "all", "Admin"])).toEqual(["Admin"]);
66
+ });
58
67
  });
@@ -578,3 +578,130 @@ describe("validateAccessDeclarations — self-bound personal-data fields", () =>
578
578
  expect(() => validateAccessDeclarations(feature)).toThrow(/"displayName"/);
579
579
  });
580
580
  });
581
+
582
+ describe("validateAccessDeclarations — anonymous (roles-form) personal-data intake", () => {
583
+ test("an anonymous write handler accepting a personal-data field without personalData throws", () => {
584
+ const feature = defineFeature("notes", (r) => {
585
+ r.entity("note", noteEntity);
586
+ r.writeHandler(
587
+ "note:signup",
588
+ z.object({ email: z.string() }),
589
+ async () => ({ isSuccess: true as const, data: {} }),
590
+ { access: { roles: ["anonymous"] } },
591
+ );
592
+ });
593
+ expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
594
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:signup"/);
595
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
596
+ expect(() => validateAccessDeclarations(feature)).toThrow(/personalData: "public-intake"/);
597
+ });
598
+
599
+ test('the same handler WITH personalData: "public-intake" boots fine', () => {
600
+ const feature = defineFeature("notes", (r) => {
601
+ r.entity("note", noteEntity);
602
+ r.writeHandler(
603
+ "note:signup",
604
+ z.object({ email: z.string() }),
605
+ async () => ({ isSuccess: true as const, data: {} }),
606
+ { access: { roles: ["anonymous"], personalData: "public-intake" } },
607
+ );
608
+ });
609
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
610
+ });
611
+
612
+ test('an owner-bound personal-data field is not exempted for an anonymous handler — anonymous callers share one user.id, so from("user:id", ...) binds no one', () => {
613
+ const ownedByCaller: OwnershipMap = { Member: from("user:id", "ownerUserId") };
614
+ const entity = createEntity({
615
+ table: "fw2885_guard_anon_owned",
616
+ fields: {
617
+ name: createTextField({ personal: { of: "ownerUserId" }, find: "none" }),
618
+ ownerUserId: createTextField({ required: false, personal: "ref" }),
619
+ },
620
+ access: { write: ownedByCaller },
621
+ });
622
+ const schema = z.object({ name: z.string() });
623
+ const anonymousFeature = defineFeature("notes", (r) => {
624
+ r.entity("note", entity);
625
+ r.writeHandler("note:signup", schema, async () => ({ isSuccess: true as const, data: {} }), {
626
+ access: { roles: ["anonymous"] },
627
+ });
628
+ });
629
+ expect(anonymousFeature.handlerEntityMappings["note:signup"]).toBe("note");
630
+ expect(() => validateAccessDeclarations(anonymousFeature)).toThrow(/"name"/);
631
+
632
+ // Control: the same owner-bound entity/schema via openToAll IS exempted, so the throw above is override-specific.
633
+ const openToAllFeature = defineFeature("notes", (r) => {
634
+ r.entity("note", entity);
635
+ r.writeHandler("note:signup", schema, async () => ({ isSuccess: true as const, data: {} }), {
636
+ access: { openToAll: { reason: "members share contacts" } },
637
+ });
638
+ });
639
+ expect(() => validateAccessDeclarations(openToAllFeature)).not.toThrow();
640
+ });
641
+
642
+ test('personalData: "public-intake" on roles without "anonymous" throws', () => {
643
+ const feature = defineFeature("notes", (r) => {
644
+ r.entity("note", noteEntity);
645
+ r.writeHandler(
646
+ "note:create",
647
+ z.object({ title: z.string() }),
648
+ async () => ({ isSuccess: true as const, data: {} }),
649
+ { access: { roles: ["Admin"], personalData: "public-intake" } },
650
+ );
651
+ });
652
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
653
+ expect(() => validateAccessDeclarations(feature)).toThrow(/anonymous/);
654
+ });
655
+
656
+ test('personalData: "tenant-members" on the roles form throws', () => {
657
+ const feature = defineFeature("notes", (r) => {
658
+ r.entity("note", noteEntity);
659
+ r.writeHandler(
660
+ "note:create",
661
+ z.object({ title: z.string() }),
662
+ async () => ({ isSuccess: true as const, data: {} }),
663
+ {
664
+ // @cast-boundary test — simulates JSON/Designer input that doesn't match the static union
665
+ access: { roles: ["anonymous"], personalData: "tenant-members" } as unknown as AccessRule,
666
+ },
667
+ );
668
+ });
669
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"tenant-members"/);
670
+ });
671
+
672
+ test("personalData on the roles form of a query handler throws", () => {
673
+ const feature = defineFeature("notes", (r) => {
674
+ r.entity("note", noteEntity);
675
+ r.queryHandler("note:list", z.object({}), async () => [], {
676
+ access: { roles: ["anonymous"], personalData: "public-intake" },
677
+ });
678
+ });
679
+ expect(() => validateAccessDeclarations(feature)).toThrow(/"note:list"/);
680
+ expect(() => validateAccessDeclarations(feature)).toThrow(/access\.personalData/);
681
+ });
682
+
683
+ test("a non-anonymous roles handler accepting a personal-data field does not throw", () => {
684
+ const feature = defineFeature("notes", (r) => {
685
+ r.entity("note", noteEntity);
686
+ r.writeHandler(
687
+ "note:create",
688
+ z.object({ email: z.string() }),
689
+ async () => ({ isSuccess: true as const, data: {} }),
690
+ { access: { roles: ["Admin"] } },
691
+ );
692
+ });
693
+ expect(() => validateAccessDeclarations(feature)).not.toThrow();
694
+ });
695
+
696
+ test('personalData: "tenant-members" no longer type-checks on the roles form', () => {
697
+ // @ts-expect-error "tenant-members" is only valid on openToAll, not the roles form
698
+ const access: AccessRule = { roles: ["anonymous"], personalData: "tenant-members" };
699
+ expect(access).toBeDefined();
700
+ });
701
+
702
+ test("an unrecognised personalData string does not type-check on the roles form", () => {
703
+ // @ts-expect-error only "public-intake" is a valid roles-form personalData value
704
+ const access: AccessRule = { roles: ["anonymous"], personalData: "whatever" };
705
+ expect(access).toBeDefined();
706
+ });
707
+ });