@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
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../define-feature";
4
+ import { validateHandlerAccess } from "../entity-handler";
5
+
6
+ describe('validateHandlerAccess — roles: ["all"] is rejected', () => {
7
+ test("a write handler with roles: ['all'] throws, naming the handler", () => {
8
+ const feature = defineFeature("no-all-write", (r) => {
9
+ r.writeHandler(
10
+ "note:create",
11
+ z.object({ title: z.string() }),
12
+ async () => ({
13
+ isSuccess: true as const,
14
+ data: {},
15
+ }),
16
+ {
17
+ access: { roles: ["all"] },
18
+ },
19
+ );
20
+ });
21
+
22
+ expect(() => validateHandlerAccess(feature)).toThrow(/no-all-write:write:note:create/);
23
+ expect(() => validateHandlerAccess(feature)).toThrow(/no session ever carries the role "all"/);
24
+ });
25
+
26
+ test("a query handler with roles: ['all'] throws, naming the handler", () => {
27
+ const feature = defineFeature("no-all-query", (r) => {
28
+ r.queryHandler(
29
+ "note:list",
30
+ z.object({}),
31
+ async () => ({ isSuccess: true as const, data: {} }),
32
+ {
33
+ access: { roles: ["all"] },
34
+ },
35
+ );
36
+ });
37
+
38
+ expect(() => validateHandlerAccess(feature)).toThrow(/no-all-query:query:note:list/);
39
+ });
40
+
41
+ test("openToAll access is unaffected", () => {
42
+ const feature = defineFeature("open-to-all-ok", (r) => {
43
+ r.writeHandler(
44
+ "note:create",
45
+ z.object({ title: z.string() }),
46
+ async () => ({
47
+ isSuccess: true as const,
48
+ data: {},
49
+ }),
50
+ {
51
+ access: { openToAll: { reason: "any signed-in user" } },
52
+ },
53
+ );
54
+ });
55
+
56
+ expect(() => validateHandlerAccess(feature)).not.toThrow();
57
+ });
58
+
59
+ test("roles: ['anonymous'] with a rateLimit boots fine", () => {
60
+ const feature = defineFeature("anon-ok", (r) => {
61
+ r.writeHandler(
62
+ "note:create",
63
+ z.object({ title: z.string() }),
64
+ async () => ({
65
+ isSuccess: true as const,
66
+ data: {},
67
+ }),
68
+ {
69
+ access: { roles: ["anonymous"] },
70
+ rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
71
+ },
72
+ );
73
+ });
74
+
75
+ expect(() => validateHandlerAccess(feature)).not.toThrow();
76
+ });
77
+ });
@@ -1,67 +1,20 @@
1
+ import {
2
+ accessAllowsAnonymous,
3
+ declaredPersonalData,
4
+ personalFieldNames,
5
+ } from "../personal-data-fields";
6
+ import { ANONYMOUS_ROLE } from "../system-user";
1
7
  import type {
2
8
  AccessRule,
3
9
  FeatureDefinition,
4
- OwnershipMap,
5
- OwnershipRule,
6
10
  QueryHandlerDef,
7
11
  StreamHandlerDef,
8
12
  WriteHandlerDef,
9
13
  } from "../types";
10
- import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
11
14
  import { collectZodObjectKeys } from "./zod-shape";
12
15
 
13
16
  type HandlerKind = "write" | "query" | "stream";
14
17
 
15
- // Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
16
- // minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
17
- // sense the openToAll personal-data check is guarding against.
18
- function isPersonalDataField(field: unknown): boolean {
19
- const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
20
- return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
21
- }
22
-
23
- function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
24
- if (rule === "all" || rule.kind !== "from") return false;
25
- return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
26
- }
27
-
28
- // The executor checks access.write against every created/updated row; one "all" role
29
- // or an empty map (= public) lets a caller write rows owned by someone else.
30
- function writeMapBindsRowsToCaller(
31
- writeMap: OwnershipMap | undefined,
32
- ownerColumn: string,
33
- ): boolean {
34
- const rules = Object.values(writeMap ?? {});
35
- return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
36
- }
37
-
38
- const ROW_ID_COLUMN = "id";
39
-
40
- // A self/record-owned field's subject is the row itself, so only from("user:id", "id")
41
- // makes that row the caller — on any other entity "self" names a third party.
42
- function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
43
- if (annot.userOwned) return annot.userOwned.ownerField;
44
- if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
45
- return undefined;
46
- }
47
-
48
- function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
49
- const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
50
- return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
51
- }
52
-
53
- function personalFieldNames(
54
- entity: EntityDefinition,
55
- honorOwnerBinding: boolean,
56
- ): ReadonlySet<string> {
57
- const names = new Set<string>();
58
- for (const [fieldName, field] of Object.entries(entity.fields)) {
59
- const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
60
- if (isPersonalDataField(field) && !exempt) names.add(fieldName);
61
- }
62
- return names;
63
- }
64
-
65
18
  // escapeHatch and r.systemScope() can write around the entity's write map
66
19
  // (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
67
20
  function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
@@ -73,12 +26,14 @@ function candidatePersonalFieldNames(
73
26
  feature: FeatureDefinition,
74
27
  handlerName: string,
75
28
  handler: WriteHandlerDef,
29
+ honorOwnerBindingOverride?: boolean,
76
30
  ): ReadonlySet<string> {
77
31
  const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
78
32
  const entities = feature.entities ?? {};
79
33
  if (mappedEntityName) {
80
34
  const entity = entities[mappedEntityName];
81
- const honorOwnerBinding = !canWriteAroundExecutor(feature, handler);
35
+ const honorOwnerBinding =
36
+ honorOwnerBindingOverride ?? !canWriteAroundExecutor(feature, handler);
82
37
  return entity ? personalFieldNames(entity, honorOwnerBinding) : new Set();
83
38
  }
84
39
  const names = new Set<string>();
@@ -101,14 +56,6 @@ function hasOpenToAll(access: AccessRule): boolean {
101
56
  return "openToAll" in access;
102
57
  }
103
58
 
104
- // Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
105
- function declaredPersonalData(access: AccessRule): unknown {
106
- if (!("openToAll" in access)) return undefined;
107
- const openToAll: unknown = access.openToAll;
108
- if (typeof openToAll !== "object" || openToAll === null) return undefined;
109
- return "personalData" in openToAll ? openToAll.personalData : undefined;
110
- }
111
-
112
59
  function declaresTenantMembersPersonalData(access: AccessRule): boolean {
113
60
  return declaredPersonalData(access) === "tenant-members";
114
61
  }
@@ -154,9 +101,10 @@ function validatePersonalDataOnlyOnWrite(
154
101
  ): void {
155
102
  // skip: write handlers may declare personalData, or it wasn't declared here
156
103
  if (kind === "write" || declaredPersonalData(access) === undefined) return;
104
+ const property = "openToAll" in access ? "openToAll.personalData" : "access.personalData";
157
105
  throw new Error(
158
106
  `[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
159
- "openToAll.personalData — it only applies to write handlers, whose input is " +
107
+ `${property} — it only applies to write handlers, whose input is ` +
160
108
  "checked for personal-data fields.",
161
109
  );
162
110
  }
@@ -167,11 +115,29 @@ function validatePersonalDataValue(
167
115
  access: AccessRule,
168
116
  ): void {
169
117
  const declared = declaredPersonalData(access);
170
- // skip: nothing declared, or the one supported value
171
- if (declared === undefined || declared === "tenant-members") return;
118
+ // skip: nothing declared
119
+ if (declared === undefined) return;
120
+ if ("openToAll" in access) {
121
+ // skip: the one supported value on openToAll
122
+ if (declared === "tenant-members") return;
123
+ throw new Error(
124
+ `[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
125
+ `openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
126
+ );
127
+ }
128
+ if (declared !== "public-intake") {
129
+ throw new Error(
130
+ `[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
131
+ `access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`,
132
+ );
133
+ }
134
+ // skip: roles include "anonymous", which personalData: "public-intake" requires
135
+ if (Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE)) return;
172
136
  throw new Error(
173
- `[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
174
- `openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`,
137
+ `[Feature ${feature.name}] write handler "${handlerName}" declares ` +
138
+ 'access.personalData: "public-intake" but its roles do not include ' +
139
+ `"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
140
+ "that allow anonymous callers.",
175
141
  );
176
142
  }
177
143
 
@@ -200,12 +166,37 @@ function validateOpenToAllPersonalData(
200
166
  );
201
167
  }
202
168
 
169
+ // Anonymous callers all share one user.id, so honorOwnerBindingOverride is always false here.
170
+ function validateAnonymousPersonalData(
171
+ feature: FeatureDefinition,
172
+ handlerName: string,
173
+ handler: WriteHandlerDef,
174
+ ): void {
175
+ const access = handler.access;
176
+ // skip: not reachable by an anonymous caller, or already declares public-intake
177
+ if (!accessAllowsAnonymous(access) || declaredPersonalData(access) === "public-intake") return;
178
+ const inputKeys = collectZodObjectKeys(handler.schema);
179
+ const personalNames = candidatePersonalFieldNames(feature, handlerName, handler, false);
180
+ const offending = [...inputKeys].filter((key) => personalNames.has(key));
181
+ // skip: no personal-data fields in the handler's input
182
+ if (offending.length === 0) return;
183
+ throw new Error(
184
+ `[Feature ${feature.name}] write handler "${handlerName}" allows anonymous callers ` +
185
+ `("${ANONYMOUS_ROLE}" in access.roles) and accepts personal-data field(s) ` +
186
+ `${offending.map((f) => `"${f}"`).join(", ")}. Declare ` +
187
+ 'access: { roles: [..., "anonymous"], personalData: "public-intake" } — every anonymous ' +
188
+ 'request shares one caller identity, so owner-binding via from("user:id", ...) does not ' +
189
+ "vouch for it; protection comes from the handler's required rateLimit (per ip).",
190
+ );
191
+ }
192
+
203
193
  export function validateAccessDeclarations(feature: FeatureDefinition): void {
204
194
  for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
205
195
  validateOpenToAllReason(feature, "write", handlerName, handler.access);
206
196
  validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
207
197
  validatePersonalDataValue(feature, handlerName, handler.access);
208
198
  validateOpenToAllPersonalData(feature, handlerName, handler);
199
+ validateAnonymousPersonalData(feature, handlerName, handler);
209
200
  }
210
201
  for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
211
202
  validateOpenToAllReason(feature, "query", handlerName, handler.access);
@@ -148,6 +148,7 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
148
148
  `Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`,
149
149
  );
150
150
  }
151
+ validateNoAllRoleInHandlerAccess(feature.name, kind, name, handler.access);
151
152
  validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
152
153
  validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
153
154
  }
@@ -171,6 +172,25 @@ export function validateRateLimitDisabledReason(
171
172
  }
172
173
  }
173
174
 
175
+ // No session ever carries the role "all" — `roles: ["all"]` is unreachable dead config, not a wildcard.
176
+ export function validateNoAllRoleInHandlerAccess(
177
+ featureName: string,
178
+ kind: "write" | "query" | "stream",
179
+ handlerName: string,
180
+ access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
181
+ ): void {
182
+ // skip: openToAll has no roles list to check
183
+ if (!("roles" in access)) return;
184
+ // skip: no "all" in the roles list, nothing unreachable to report
185
+ if (!access.roles.includes("all")) return;
186
+ throw new Error(
187
+ `${kind} handler "${featureName}:${kind}:${handlerName}" declares access: { roles: ["all"] } — ` +
188
+ `no session ever carries the role "all", so this handler is unreachable by any caller. ` +
189
+ `Use access: { roles: ["anonymous"] } plus rateLimit: { per: "ip", ... } (or "ip+handler") ` +
190
+ `for unauthenticated callers, or access: { openToAll: { reason: "..." } } for any signed-in user.`,
191
+ );
192
+ }
193
+
174
194
  export function validateAnonymousRateLimit(
175
195
  featureName: string,
176
196
  kind: "write" | "query" | "stream",
@@ -239,6 +239,7 @@ describe("patch coverage for the remaining pattern-kinds", () => {
239
239
  method: "GET",
240
240
  path: "/health",
241
241
  handlerSource: "async (c) => c.json({ ok: true })",
242
+ anonymous: true,
242
243
  });
243
244
  expect(parseSourceFile(sf).patterns).toHaveLength(3);
244
245
  removePattern(sf, { kind: "httpRoute", method: "GET", path: "/health" });
@@ -328,6 +328,7 @@ describe("FeaturePatcher — coverage for the remaining typed adds", () => {
328
328
  method: "GET",
329
329
  path: "/health",
330
330
  handlerSource: "async (c) => c.json({ ok: true })",
331
+ anonymous: true,
331
332
  });
332
333
  const result = parseSourceFile(sf);
333
334
  expect(result.errors).toEqual([]);
@@ -26,3 +26,17 @@ describe("readOptionalAccessRule — openToAll.personalData", () => {
26
26
  ).toBeUndefined();
27
27
  });
28
28
  });
29
+
30
+ describe("readOptionalAccessRule — roles.personalData", () => {
31
+ test("keeps personalData: public-intake on the roles form", () => {
32
+ expect(readOptionalAccessRule({ roles: ["anonymous"], personalData: "public-intake" })).toEqual(
33
+ { roles: ["anonymous"], personalData: "public-intake" },
34
+ );
35
+ });
36
+
37
+ test("drops an unknown personalData value on the roles form", () => {
38
+ expect(
39
+ readOptionalAccessRule({ roles: ["anonymous"], personalData: "tenant-members" }),
40
+ ).toEqual({ roles: ["anonymous"] });
41
+ });
42
+ });
@@ -41,7 +41,9 @@ export function readOptionalPhase(node: Node | undefined): HookPhase | undefined
41
41
  export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
42
42
  if (!isPlainObject(value)) return undefined;
43
43
  if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
44
- return { roles: value["roles"] as readonly string[] };
44
+ const personalData =
45
+ value["personalData"] === "public-intake" ? { personalData: "public-intake" as const } : {};
46
+ return { roles: value["roles"] as readonly string[], ...personalData };
45
47
  }
46
48
  const openToAll = value["openToAll"];
47
49
  if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
@@ -210,13 +210,16 @@ export function extractHttpRoute(
210
210
  "handler must be an inline arrow function or function expression",
211
211
  );
212
212
  }
213
- const anonymous = readBooleanProperty(arg, "anonymous");
213
+ // Missing/non-boolean `anonymous` reads as false (the safe side — mounted
214
+ // behind the session auth chain, not public) so a source file that predates
215
+ // the required field still parses instead of erroring here.
216
+ const anonymous = readBooleanProperty(arg, "anonymous") === true;
214
217
  return ok({
215
218
  kind: "httpRoute",
216
219
  source: sourceLocationFromNode(call, sourceFile),
217
220
  method: methodValue,
218
221
  path: pathLiteral.getLiteralValue(),
219
222
  handlerBody: sourceLocationFromNode(fn, sourceFile),
220
- ...(anonymous === true && { anonymous: true }),
223
+ anonymous,
221
224
  });
222
225
  }
@@ -149,7 +149,7 @@ export type AddHttpRouteArgs = {
149
149
  readonly method: HttpRouteMethod;
150
150
  readonly path: string;
151
151
  readonly handlerSource: string;
152
- readonly anonymous?: boolean;
152
+ readonly anonymous: boolean;
153
153
  };
154
154
 
155
155
  export type AddDefineEventArgs = {
@@ -480,7 +480,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
480
480
  method,
481
481
  path,
482
482
  handlerBody: rawLoc(handlerSource),
483
- ...(anonymous === true && { anonymous: true }),
483
+ anonymous,
484
484
  });
485
485
  },
486
486
 
@@ -508,7 +508,7 @@ export type HttpRoutePattern = {
508
508
  readonly source: SourceLocation;
509
509
  readonly method: HttpRouteMethod;
510
510
  readonly path: string;
511
- readonly anonymous?: boolean;
511
+ readonly anonymous: boolean;
512
512
  readonly handlerBody: SourceLocation;
513
513
  };
514
514
 
@@ -558,7 +558,7 @@ function renderHttpRoute(p: HttpRoutePattern): string {
558
558
  const lines: string[] = ["r.httpRoute({"];
559
559
  lines.push(` method: ${JSON.stringify(p.method)},`);
560
560
  lines.push(` path: ${JSON.stringify(p.path)},`);
561
- if (p.anonymous === true) lines.push(" anonymous: true,");
561
+ lines.push(` anonymous: ${p.anonymous},`);
562
562
  lines.push(` handler: ${p.handlerBody.raw},`);
563
563
  lines.push("});");
564
564
  return lines.join("\n");
@@ -498,6 +498,12 @@ export function buildUiExtensionsMethods<TName extends string>(
498
498
  `Pick a different path or use r.queryHandler / r.writeHandler.`,
499
499
  );
500
500
  }
501
+ if (typeof definition.anonymous !== "boolean") {
502
+ throw new Error(
503
+ `[Feature ${name}] httpRoute "${definition.method} ${definition.path}" must declare ` +
504
+ `anonymous: true | false — true mounts it public, false behind the session auth chain.`,
505
+ );
506
+ }
501
507
  const key = `${definition.method} ${definition.path}`;
502
508
  if (state.httpRoutes[key]) {
503
509
  throw new Error(
@@ -410,6 +410,8 @@ export type {
410
410
  HookMap,
411
411
  ImageFieldDef,
412
412
  ImagesFieldDef,
413
+ JobBackoff,
414
+ JobBackoffStrategy,
413
415
  JobContext,
414
416
  JobDefinition,
415
417
  JobHandlerFn,
@@ -476,6 +478,8 @@ export type {
476
478
  RelationDefinition,
477
479
  ResolvedPiiFlags,
478
480
  RetentionDef,
481
+ RoleAccessPersonalData,
482
+ RoleAccessRule,
479
483
  RowAction,
480
484
  RowActionNavigate,
481
485
  RowActionNavigateBase,
@@ -6,8 +6,9 @@
6
6
  // survive a projection rebuild: replaying a stored membership event goes
7
7
  // through the apply path, not the handler. stripForbiddenMembershipRoles is
8
8
  // the read-time backstop — applied at every JWT mint that derives roles from
9
- // membership, it neutralises a resurrected role without touching globalRoles
10
- // (where SystemAdmin legitimately lives).
9
+ // membership, it neutralises a resurrected role. buildSessionRoles applies a
10
+ // matching strip (anonymous/all only) to globalRoles, where SystemAdmin
11
+ // legitimately lives.
11
12
 
12
13
  import { access } from "./config-helpers";
13
14
 
@@ -26,11 +27,21 @@ export function findForbiddenMembershipRole(roles: readonly string[]): string |
26
27
  }
27
28
 
28
29
  // Filters reserved roles out of the membership portion only. Callers merge the
29
- // result with globalRoles, which is never filtered.
30
+ // result with globalRoles, which buildSessionRoles filters separately below.
30
31
  export function stripForbiddenMembershipRoles(roles: readonly string[]): readonly string[] {
31
32
  return roles.filter((role) => !isForbiddenMembershipRole(role));
32
33
  }
33
34
 
35
+ // "anonymous"/"all" are never legitimate global roles; system/SystemAdmin stay.
36
+ const NON_MINTABLE_GLOBAL_ROLES: ReadonlySet<string> = new Set<string>([
37
+ ...access.all,
38
+ ...access.anonymous,
39
+ ]);
40
+
41
+ function stripNonMintableGlobalRoles(roles: readonly string[]): readonly string[] {
42
+ return roles.filter((role) => !NON_MINTABLE_GLOBAL_ROLES.has(role));
43
+ }
44
+
34
45
  // Single mint path for session roles: merges globalRoles with the stripped
35
46
  // membership portion and dedupes. Every place that builds a session's roles
36
47
  // from a membership should go through this instead of repeating
@@ -41,5 +52,10 @@ export function buildSessionRoles(
41
52
  globalRoles: readonly string[],
42
53
  membershipRoles: readonly string[],
43
54
  ): readonly string[] {
44
- return Array.from(new Set([...globalRoles, ...stripForbiddenMembershipRoles(membershipRoles)]));
55
+ return Array.from(
56
+ new Set([
57
+ ...stripNonMintableGlobalRoles(globalRoles),
58
+ ...stripForbiddenMembershipRoles(membershipRoles),
59
+ ]),
60
+ );
45
61
  }
@@ -315,6 +315,7 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
315
315
  source: PLACEHOLDER_LOC,
316
316
  method: "GET",
317
317
  path: "/x",
318
+ anonymous: true,
318
319
  handlerBody: PLACEHOLDER_BODY_LOC,
319
320
  };
320
321
  case "projection":
@@ -453,6 +453,7 @@ export const httpRouteSchema: PatternFormSchema = {
453
453
  path: "anonymous",
454
454
  label: { en: "Anonymous (no auth)", de: "Anonym (keine Auth)" },
455
455
  input: "boolean",
456
+ required: true,
456
457
  },
457
458
  {
458
459
  path: "handlerBody",
@@ -0,0 +1,66 @@
1
+ import { ANONYMOUS_ROLE } from "./system-user";
2
+ import type { AccessRule, OwnershipMap, OwnershipRule } from "./types";
3
+ import type { EntityDefinition, ResolvedPiiFlags } from "./types/fields";
4
+
5
+ // Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
6
+ // minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
7
+ // sense the openToAll / public-intake personal-data checks are guarding against.
8
+ function isPersonalDataField(field: unknown): boolean {
9
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
10
+ return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
11
+ }
12
+
13
+ function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
14
+ if (rule === "all" || rule.kind !== "from") return false;
15
+ return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
16
+ }
17
+
18
+ // The executor checks access.write against every created/updated row; one "all" role
19
+ // or an empty map (= public) lets a caller write rows owned by someone else.
20
+ function writeMapBindsRowsToCaller(
21
+ writeMap: OwnershipMap | undefined,
22
+ ownerColumn: string,
23
+ ): boolean {
24
+ const rules = Object.values(writeMap ?? {});
25
+ return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
26
+ }
27
+
28
+ const ROW_ID_COLUMN = "id";
29
+
30
+ // A self/record-owned field's subject is the row itself, so only from("user:id", "id")
31
+ // makes that row the caller — on any other entity "self" names a third party.
32
+ function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
33
+ if (annot.userOwned) return annot.userOwned.ownerField;
34
+ if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
35
+ return undefined;
36
+ }
37
+
38
+ function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
39
+ const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
40
+ return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
41
+ }
42
+
43
+ export function personalFieldNames(
44
+ entity: EntityDefinition,
45
+ honorOwnerBinding: boolean,
46
+ ): ReadonlySet<string> {
47
+ const names = new Set<string>();
48
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
49
+ const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
50
+ if (isPersonalDataField(field) && !exempt) names.add(fieldName);
51
+ }
52
+ return names;
53
+ }
54
+
55
+ // Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
56
+ export function declaredPersonalData(access: AccessRule): unknown {
57
+ if (!("openToAll" in access)) return access.personalData;
58
+ const openToAll: unknown = access.openToAll;
59
+ if (typeof openToAll !== "object" || openToAll === null) return undefined;
60
+ return "personalData" in openToAll ? openToAll.personalData : undefined;
61
+ }
62
+
63
+ export function accessAllowsAnonymous(access: AccessRule): boolean {
64
+ if ("openToAll" in access) return false;
65
+ return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
66
+ }
@@ -729,6 +729,21 @@ export function validateBootGates(state: RegistryState): void {
729
729
  }
730
730
  }
731
731
 
732
+ export function validateJobBackoff(state: RegistryState): void {
733
+ // Object-form backoff carries an explicit delayMs base — catch a bad value
734
+ // at boot instead of letting BullMQ silently compute NaN/undefined delays.
735
+ for (const [jobName, jobDef] of state.jobMap) {
736
+ if (typeof jobDef.backoff !== "object") continue;
737
+ const { delayMs } = jobDef.backoff;
738
+ if (delayMs === undefined) continue;
739
+ if (!Number.isInteger(delayMs) || delayMs <= 0) {
740
+ throw new Error(
741
+ `Job "${jobName}" backoff.delayMs must be a positive integer (got ${delayMs})`,
742
+ );
743
+ }
744
+ }
745
+ }
746
+
732
747
  export function validateExtensionUsageTargets(state: RegistryState): void {
733
748
  // Validate: extension usages must reference existing extensions
734
749
  for (const usage of state.extensionUsages) {
@@ -34,6 +34,7 @@ import {
34
34
  validateExtensionSelectors,
35
35
  validateExtensionUsageTargets,
36
36
  validateFieldAccessHandlersAreEntityMapped,
37
+ validateJobBackoff,
37
38
  validateJobTriggers,
38
39
  validateLifecycleHookTargets,
39
40
  validateProjectionApplyKeys,
@@ -83,6 +84,7 @@ export function createRegistry(rawFeatures: readonly FeatureDefinition[]): Regis
83
84
  validateEntityHookTargets(state, features);
84
85
  validateJobTriggers(state);
85
86
  validateBootGates(state);
87
+ validateJobBackoff(state);
86
88
  validateExtensionUsageTargets(state);
87
89
  computeHasRateLimitedHandler(state);
88
90
  publishEventPiiCatalog(state);
@@ -32,6 +32,8 @@ export type {
32
32
  CreateTenantSeedOptions,
33
33
  CreateUserSeedOptions,
34
34
  ExtensionSelectorDef,
35
+ JobBackoff,
36
+ JobBackoffStrategy,
35
37
  JobDefinition,
36
38
  JobHandlerFn,
37
39
  JobRunIn,
@@ -194,6 +196,8 @@ export type {
194
196
  RateLimitDisabled,
195
197
  RateLimitOption,
196
198
  RateLimitPer,
199
+ RoleAccessPersonalData,
200
+ RoleAccessRule,
197
201
  SessionUser,
198
202
  SessionUserOrigin,
199
203
  StreamHandlerDef,