@cosmicdrift/kumiko-framework 0.269.2 → 0.270.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 (84) hide show
  1. package/README.md +5 -1
  2. package/package.json +3 -3
  3. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  4. package/src/__tests__/error-contract.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
  6. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
  7. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
  8. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
  9. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
  10. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
  11. package/src/__tests__/full-stack.integration.test.ts +2 -2
  12. package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
  13. package/src/__tests__/transition-guard.integration.test.ts +7 -7
  14. package/src/api/__tests__/api.test.ts +2 -2
  15. package/src/api/__tests__/body-limit.test.ts +1 -1
  16. package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
  17. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  18. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  19. package/src/changes.json +8 -0
  20. package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
  21. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
  22. package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
  23. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  24. package/src/engine/__tests__/boot-validator-projection-list.test.ts +17 -17
  25. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +18 -18
  26. package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
  27. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  28. package/src/engine/__tests__/boot-validator.test.ts +18 -18
  29. package/src/engine/__tests__/build-app-schema.test.ts +10 -6
  30. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
  31. package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
  32. package/src/engine/__tests__/engine.test.ts +39 -25
  33. package/src/engine/__tests__/entity-handlers.test.ts +19 -8
  34. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
  35. package/src/engine/__tests__/extends-registrar.test.ts +3 -3
  36. package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
  37. package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
  38. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  39. package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
  40. package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
  41. package/src/engine/__tests__/post-query-hook.test.ts +6 -2
  42. package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
  43. package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
  44. package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
  45. package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
  46. package/src/engine/__tests__/registry.test.ts +19 -13
  47. package/src/engine/__tests__/screen.test.ts +26 -20
  48. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
  49. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
  50. package/src/engine/boot-validator/access-declarations.ts +3 -5
  51. package/src/engine/boot-validator/nav.ts +4 -3
  52. package/src/engine/build-config-feature-schema.ts +9 -2
  53. package/src/engine/entity-handlers.ts +1 -1
  54. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
  55. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
  56. package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
  57. package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
  58. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
  59. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
  60. package/src/engine/feature-ast/extractors/handlers.ts +16 -2
  61. package/src/engine/feature-ast/extractors/hooks.ts +25 -4
  62. package/src/engine/feature-ast/patcher.ts +15 -3
  63. package/src/engine/feature-ast/patterns.ts +4 -0
  64. package/src/engine/feature-ast/render.ts +3 -0
  65. package/src/engine/pattern-library/shared-fields.ts +7 -3
  66. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  67. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
  68. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  69. package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
  70. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
  71. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
  72. package/src/jobs/__tests__/jobs.integration.test.ts +1 -1
  73. package/src/observability/__tests__/observability.integration.test.ts +7 -7
  74. package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
  75. package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
  76. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
  77. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  78. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  79. package/src/pipeline/__tests__/query-projection.integration.test.ts +3 -3
  80. package/src/scripts/codemod/migrate-open-to-all.ts +248 -0
  81. package/src/secrets/__tests__/contains-secret.test.ts +5 -5
  82. package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
  83. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  84. package/src/testing/__tests__/e2e-generator.test.ts +19 -5
@@ -131,14 +131,14 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
131
131
  name: "task:list",
132
132
  schemaSource: "z.object({})",
133
133
  handlerSource: "async (q, ctx) => []",
134
- access: { openToAll: true },
134
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
135
135
  });
136
136
  const result = parseSourceFile(sf);
137
137
  const pattern = result.patterns[0];
138
138
  expect(pattern).toMatchObject({
139
139
  kind: "queryHandler",
140
140
  handlerName: "task:list",
141
- access: { openToAll: true },
141
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
142
142
  });
143
143
  });
144
144
 
@@ -441,7 +441,7 @@ defineFeature("tasks", (r) => {
441
441
  name: "task:list",
442
442
  schemaSource: "z.object({})",
443
443
  handlerSource: "async (q, ctx) => []",
444
- access: { openToAll: true },
444
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
445
445
  });
446
446
  p.addStreamHandler({
447
447
  name: "task:stream",
@@ -20,9 +20,9 @@ describe("readOptionalAccessRule — openToAll.personalData", () => {
20
20
  ).toEqual({ openToAll: { reason: "signup" } });
21
21
  });
22
22
 
23
- test("the deprecated openToAll: true form carries no personalData", () => {
24
- expect(readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" })).toEqual({
25
- openToAll: true,
26
- });
23
+ test("the deprecated openToAll: true form is not extracted — fail-closed, consistent with isOpenToAllGranted", () => {
24
+ expect(
25
+ readOptionalAccessRule({ openToAll: true, personalData: "tenant-members" }),
26
+ ).toBeUndefined();
27
27
  });
28
28
  });
@@ -209,7 +209,11 @@ defineFeature("workflow", (r) => {
209
209
  "step:list",
210
210
  z.object({}),
211
211
  async (q, ctx) => [],
212
- { access: { openToAll: true }, description: "Lists steps.", agent: { risk: "low" } },
212
+ {
213
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
214
+ description: "Lists steps.",
215
+ agent: { risk: "low" },
216
+ },
213
217
  );
214
218
 
215
219
  r.streamHandler(
@@ -4,12 +4,13 @@ import type {
4
4
  AccessRule,
5
5
  AgentHandlerHints,
6
6
  AgentRisk,
7
+ EscapeHatchDeclaration,
7
8
  RateLimitOption,
8
9
  } from "../../types/handlers";
9
10
  import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
10
11
  import type { SourceLocation } from "../source-location";
11
12
  import { sourceLocationFromNode } from "../source-location";
12
- import { readOptionalAccessRule, readOptionalRateLimit } from "./hooks";
13
+ import { readOptionalAccessRule, readOptionalEscapeHatch, readOptionalRateLimit } from "./hooks";
13
14
  import {
14
15
  type ExtractOutput,
15
16
  fail,
@@ -32,6 +33,7 @@ export type ParsedHandlerCall = {
32
33
  readonly agent?: AgentHandlerHints;
33
34
  readonly rateLimit?: RateLimitOption;
34
35
  readonly unsafeSkipTransitionGuard?: boolean;
36
+ readonly escapeHatch?: EscapeHatchDeclaration;
35
37
  };
36
38
 
37
39
  const AGENT_RISK_VALUES: readonly AgentRisk[] = ["low", "mid", "high"];
@@ -85,18 +87,20 @@ function readDescriptionAndAgent(
85
87
  */
86
88
  function readOptionsFields(
87
89
  options: unknown,
88
- ): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent"> {
90
+ ): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent" | "escapeHatch"> {
89
91
  if (!isPlainObject(options)) return {};
90
92
  const access = readOptionalAccessRule(options["access"]);
91
93
  const rateLimit = readOptionalRateLimit(options["rateLimit"]);
92
94
  const description =
93
95
  typeof options["description"] === "string" ? options["description"] : undefined;
94
96
  const agent = readOptionalAgentHints(options["agent"]);
97
+ const escapeHatch = readOptionalEscapeHatch(options["escapeHatch"]);
95
98
  return {
96
99
  ...(access !== undefined && { access }),
97
100
  ...(description !== undefined && { description }),
98
101
  ...(agent !== undefined && { agent }),
99
102
  ...(rateLimit !== undefined && { rateLimit }),
103
+ ...(escapeHatch !== undefined && { escapeHatch }),
100
104
  };
101
105
  }
102
106
 
@@ -188,6 +192,13 @@ export function parseHandlerCall(
188
192
  : undefined;
189
193
  const { description, agent } = readDescriptionAndAgent(obj);
190
194
  const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
195
+ const escapeHatchInit = obj
196
+ .getProperty("escapeHatch")
197
+ ?.asKind(SyntaxKind.PropertyAssignment)
198
+ ?.getInitializer();
199
+ const escapeHatch = escapeHatchInit
200
+ ? readOptionalEscapeHatch(readDataLiteralNode(escapeHatchInit))
201
+ : undefined;
191
202
  return ok({
192
203
  source: sourceLocationFromNode(call, sourceFile),
193
204
  handlerName: nameLiteral.getLiteralValue(),
@@ -198,6 +209,7 @@ export function parseHandlerCall(
198
209
  ...(agent !== undefined && { agent }),
199
210
  ...(rateLimit !== undefined && { rateLimit }),
200
211
  ...(skip === true && { unsafeSkipTransitionGuard: true }),
212
+ ...(escapeHatch !== undefined && { escapeHatch }),
201
213
  });
202
214
  }
203
215
 
@@ -271,6 +283,7 @@ export function extractWriteHandler(
271
283
  ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
272
284
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
273
285
  ...(parsed.pattern.unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
286
+ ...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
274
287
  });
275
288
  }
276
289
 
@@ -296,6 +309,7 @@ export function extractQueryHandler(
296
309
  ...readHandlerFields(parsed),
297
310
  ...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
298
311
  ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
312
+ ...(parsed.pattern.escapeHatch !== undefined && { escapeHatch: parsed.pattern.escapeHatch }),
299
313
  });
300
314
  }
301
315
 
@@ -1,7 +1,7 @@
1
1
  import type { CallExpression, Node, SourceFile } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
3
  import type { LifecycleHookType } from "../../constants";
4
- import type { AccessRule, RateLimitOption } from "../../types/handlers";
4
+ import type { AccessRule, EscapeHatchDeclaration, RateLimitOption } from "../../types/handlers";
5
5
  import type { HookPhase } from "../../types/hooks";
6
6
  import type { AuthClaimsPattern, HookPattern } from "../patterns";
7
7
  import { sourceLocationFromNode } from "../source-location";
@@ -37,14 +37,12 @@ export function readOptionalPhase(node: Node | undefined): HookPhase | undefined
37
37
  return undefined;
38
38
  }
39
39
 
40
+ // `openToAll: true` (deprecated) is not recognised here — isOpenToAllGranted never grants it, so extracting it would round-trip a rule that looks configured but denies everyone.
40
41
  export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
41
42
  if (!isPlainObject(value)) return undefined;
42
43
  if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
43
44
  return { roles: value["roles"] as readonly string[] };
44
45
  }
45
- if (value["openToAll"] === true) {
46
- return { openToAll: true };
47
- }
48
46
  const openToAll = value["openToAll"];
49
47
  if (isPlainObject(openToAll) && typeof openToAll["reason"] === "string") {
50
48
  const personalData =
@@ -64,6 +62,25 @@ export function readOptionalRateLimit(value: unknown): RateLimitOption | undefin
64
62
  return value as unknown as RateLimitOption;
65
63
  }
66
64
 
65
+ export function readOptionalEscapeHatch(value: unknown): EscapeHatchDeclaration | undefined {
66
+ if (!isPlainObject(value)) return undefined;
67
+ if (typeof value["reason"] !== "string") return undefined;
68
+ return { reason: value["reason"] };
69
+ }
70
+
71
+ // Extracts the `escapeHatch` sub-property node first, not via readDataLiteralNode on the whole object — a sibling property like `handler` (a closure) isn't representable as plain data, which would make the whole-object read return undefined.
72
+ export function readOptionalHookEscapeHatch(
73
+ node: Node | undefined,
74
+ ): EscapeHatchDeclaration | undefined {
75
+ const obj = node?.asKind(SyntaxKind.ObjectLiteralExpression);
76
+ if (!obj) return undefined;
77
+ const init = obj
78
+ .getProperty("escapeHatch")
79
+ ?.asKind(SyntaxKind.PropertyAssignment)
80
+ ?.getInitializer();
81
+ return init ? readOptionalEscapeHatch(readDataLiteralNode(init)) : undefined;
82
+ }
83
+
67
84
  // r.hook's target: a NameOrRef, a list of them, or an entity-wide
68
85
  // `{ allOf: entityRef }` (replaces the old r.entityHook(type, entity, fn)).
69
86
  // Checked first so a malformed `{ allOf }` doesn't silently fall through
@@ -154,6 +171,7 @@ export function extractHook(
154
171
  );
155
172
  }
156
173
  const phase = readOptionalPhase(obj);
174
+ const escapeHatch = readOptionalHookEscapeHatch(obj);
157
175
  return ok({
158
176
  kind: "hook",
159
177
  source: sourceLocationFromNode(call, sourceFile),
@@ -161,6 +179,7 @@ export function extractHook(
161
179
  target,
162
180
  fnBody: sourceLocationFromNode(fn, sourceFile),
163
181
  ...(phase !== undefined && { phase }),
182
+ ...(escapeHatch !== undefined && { escapeHatch }),
164
183
  });
165
184
  }
166
185
 
@@ -212,6 +231,7 @@ export function extractHook(
212
231
  );
213
232
  }
214
233
  const phase = readOptionalPhase(args[3]);
234
+ const escapeHatch = readOptionalHookEscapeHatch(args[3]);
215
235
  return ok({
216
236
  kind: "hook",
217
237
  source: sourceLocationFromNode(call, sourceFile),
@@ -219,6 +239,7 @@ export function extractHook(
219
239
  target,
220
240
  fnBody: sourceLocationFromNode(fn, sourceFile),
221
241
  ...(phase !== undefined && { phase }),
242
+ ...(escapeHatch !== undefined && { escapeHatch }),
222
243
  });
223
244
  }
224
245
 
@@ -31,7 +31,12 @@ import type {
31
31
  } from "../types/config";
32
32
  import type { MetricOptions, SecretOptions } from "../types/feature";
33
33
  import type { EntityDefinition } from "../types/fields";
34
- import type { AccessRule, ClaimKeyType, RateLimitOption } from "../types/handlers";
34
+ import type {
35
+ AccessRule,
36
+ ClaimKeyType,
37
+ EscapeHatchDeclaration,
38
+ RateLimitOption,
39
+ } from "../types/handlers";
35
40
  import type { HookPhase } from "../types/hooks";
36
41
  import type { HttpRouteMethod } from "../types/http-route";
37
42
  import type { NavDefinition } from "../types/nav";
@@ -94,6 +99,7 @@ export type AddWriteHandlerArgs = {
94
99
  readonly access?: AccessRule;
95
100
  readonly rateLimit?: RateLimitOption;
96
101
  readonly unsafeSkipTransitionGuard?: boolean;
102
+ readonly escapeHatch?: EscapeHatchDeclaration;
97
103
  };
98
104
 
99
105
  export type AddQueryHandlerArgs = {
@@ -102,6 +108,7 @@ export type AddQueryHandlerArgs = {
102
108
  readonly handlerSource: string;
103
109
  readonly access?: AccessRule;
104
110
  readonly rateLimit?: RateLimitOption;
111
+ readonly escapeHatch?: EscapeHatchDeclaration;
105
112
  };
106
113
 
107
114
  export type AddStreamHandlerArgs = {
@@ -121,6 +128,7 @@ export type AddHookArgs = {
121
128
  /** Source text of the closure, e.g. `"async (event, ctx) => { ... }"`. */
122
129
  readonly handlerSource: string;
123
130
  readonly phase?: HookPhase;
131
+ readonly escapeHatch?: EscapeHatchDeclaration;
124
132
  };
125
133
 
126
134
  export type AddJobArgs = {
@@ -380,6 +388,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
380
388
  access,
381
389
  rateLimit,
382
390
  unsafeSkipTransitionGuard,
391
+ escapeHatch,
383
392
  }) {
384
393
  add({
385
394
  kind: "writeHandler",
@@ -390,10 +399,11 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
390
399
  ...(access !== undefined && { access }),
391
400
  ...(rateLimit !== undefined && { rateLimit }),
392
401
  ...(unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
402
+ ...(escapeHatch !== undefined && { escapeHatch }),
393
403
  });
394
404
  },
395
405
 
396
- addQueryHandler({ name, schemaSource, handlerSource, access, rateLimit }) {
406
+ addQueryHandler({ name, schemaSource, handlerSource, access, rateLimit, escapeHatch }) {
397
407
  add({
398
408
  kind: "queryHandler",
399
409
  source: SYNTHETIC_LOC,
@@ -402,6 +412,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
402
412
  handlerBody: rawLoc(handlerSource),
403
413
  ...(access !== undefined && { access }),
404
414
  ...(rateLimit !== undefined && { rateLimit }),
415
+ ...(escapeHatch !== undefined && { escapeHatch }),
405
416
  });
406
417
  },
407
418
 
@@ -417,7 +428,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
417
428
  });
418
429
  },
419
430
 
420
- addHook({ type, target, handlerSource, phase }) {
431
+ addHook({ type, target, handlerSource, phase, escapeHatch }) {
421
432
  add({
422
433
  kind: "hook",
423
434
  source: SYNTHETIC_LOC,
@@ -425,6 +436,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
425
436
  target,
426
437
  fnBody: rawLoc(handlerSource),
427
438
  ...(phase !== undefined && { phase }),
439
+ ...(escapeHatch !== undefined && { escapeHatch }),
428
440
  });
429
441
  },
430
442
 
@@ -70,6 +70,7 @@ import type {
70
70
  AccessRule,
71
71
  AgentHandlerHints,
72
72
  ClaimKeyType,
73
+ EscapeHatchDeclaration,
73
74
  RateLimitOption,
74
75
  } from "../types/handlers";
75
76
  import type { HookPhase } from "../types/hooks";
@@ -388,6 +389,7 @@ export type WriteHandlerPattern = {
388
389
  readonly agent?: AgentHandlerHints;
389
390
  readonly rateLimit?: RateLimitOption;
390
391
  readonly unsafeSkipTransitionGuard?: boolean;
392
+ readonly escapeHatch?: EscapeHatchDeclaration;
391
393
  };
392
394
 
393
395
  // `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
@@ -410,6 +412,7 @@ export type QueryHandlerPattern = {
410
412
  readonly description?: string;
411
413
  readonly agent?: AgentHandlerHints;
412
414
  readonly rateLimit?: RateLimitOption;
415
+ readonly escapeHatch?: EscapeHatchDeclaration;
413
416
  };
414
417
 
415
418
  // `r.streamHandler(...)` — registers a streaming read handler: name, Zod
@@ -444,6 +447,7 @@ export type HookPattern = {
444
447
  readonly target: string | readonly string[] | { readonly allOf: string };
445
448
  readonly fnBody: SourceLocation;
446
449
  readonly phase?: HookPhase;
450
+ readonly escapeHatch?: EscapeHatchDeclaration;
447
451
  };
448
452
 
449
453
  // `r.job(name, options, handler)` — registers a background job, qualified
@@ -408,6 +408,7 @@ function renderWriteHandler(p: WriteHandlerPattern): string {
408
408
  if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
409
409
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
410
410
  if (p.unsafeSkipTransitionGuard === true) lines.push(" unsafeSkipTransitionGuard: true,");
411
+ if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
411
412
  lines.push("});");
412
413
  return lines.join("\n");
413
414
  }
@@ -422,6 +423,7 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
422
423
  if (p.description !== undefined) lines.push(` description: ${JSON.stringify(p.description)},`);
423
424
  if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
424
425
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
426
+ if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
425
427
  lines.push("});");
426
428
  return lines.join("\n");
427
429
  }
@@ -450,6 +452,7 @@ function renderHook(p: HookPattern): string {
450
452
  lines.push(` target: ${renderHookTarget(p.target)},`);
451
453
  lines.push(` handler: ${reindentBody(p.fnBody.raw, PATTERN_INDENT)},`);
452
454
  if (p.phase !== undefined) lines.push(` phase: ${JSON.stringify(p.phase)},`);
455
+ if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
453
456
  lines.push("});");
454
457
  return lines.join("\n");
455
458
  }
@@ -61,9 +61,13 @@ export const accessRuleField: FormFieldSpec = {
61
61
  label: { en: "Open to all (auth still required)", de: "Für alle (Auth nötig)" },
62
62
  fields: [
63
63
  {
64
- path: "access.openToAll",
65
- label: { en: "Open to all", de: "Offen für alle" },
66
- input: "boolean",
64
+ path: "access.openToAll.reason",
65
+ label: { en: "Reason", de: "Begründung" },
66
+ hint: {
67
+ en: "Who may call this, and why that is safe without a role — required, `true` alone no longer grants access.",
68
+ },
69
+ input: "text",
70
+ required: true,
67
71
  },
68
72
  ],
69
73
  },
@@ -41,7 +41,7 @@ const writeProbeFeature = defineFeature("writeProbe", (r) => {
41
41
  });
42
42
  return { isSuccess: true as const, data: { note: event.payload.note } };
43
43
  },
44
- { access: { openToAll: true } },
44
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
45
45
  );
46
46
  r.job("write-via-job", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
47
47
  try {
@@ -33,7 +33,7 @@ const wiringFeature = defineFeature("wiring", (r) => {
33
33
  isSuccess: true as const,
34
34
  data: { id: 1, sku: event.payload.sku },
35
35
  }),
36
- { access: { openToAll: true } },
36
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
37
37
  );
38
38
  r.job(
39
39
  "record-order",
@@ -57,7 +57,7 @@ const mixedLaneFeature = defineFeature("mixed", (r) => {
57
57
  isSuccess: true as const,
58
58
  data: { id: 1, msg: event.payload.msg },
59
59
  }),
60
- { access: { openToAll: true } },
60
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
61
61
  );
62
62
  r.job(
63
63
  "handle-on-worker",
@@ -240,7 +240,7 @@ const featureGateJobFeature = defineFeature("featuregate", (r) => {
240
240
  isSuccess: true as const,
241
241
  data: { id: 1, msg: event.payload.msg },
242
242
  }),
243
- { access: { openToAll: true } },
243
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
244
244
  );
245
245
  r.job(
246
246
  "record-features",
@@ -57,7 +57,7 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
57
57
  });
58
58
  return { isSuccess: true as const, data: { note: event.payload.note } };
59
59
  },
60
- { access: { openToAll: true } },
60
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
61
61
  );
62
62
  r.multiStreamProjection({
63
63
  name: "consume-notes",
@@ -58,7 +58,7 @@ const seedTestFeature = defineFeature("esopstest", (r) => {
58
58
  "probe:create",
59
59
  z.object({ label: z.string().min(1) }),
60
60
  async (event, ctx) => probeExecutor.create(event.payload, event.user, ctx.db),
61
- { access: { openToAll: true } },
61
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
62
62
  );
63
63
  r.writeHandler(
64
64
  "probe:fail",
@@ -70,7 +70,7 @@ const seedTestFeature = defineFeature("esopstest", (r) => {
70
70
  currentVersion: 2,
71
71
  });
72
72
  },
73
- { access: { openToAll: true } },
73
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
74
74
  );
75
75
  // Role-gated handler (no openToAll): the bare system actor has no bypass
76
76
  // for explicit role gates — only systemWriteAs' extraRoles reaches it.
@@ -26,7 +26,7 @@ const ordersFeature = defineFeature("orders", (r) => {
26
26
  data: { id: 1, product: event.payload.product, amount: event.payload.amount },
27
27
  };
28
28
  },
29
- { access: { openToAll: true } },
29
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
30
30
  );
31
31
  });
32
32
 
@@ -51,7 +51,7 @@ const analyticsFeature = defineFeature("analytics", (r) => {
51
51
  isSuccess: true as const,
52
52
  data: null,
53
53
  }),
54
- { access: { openToAll: true } },
54
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
55
55
  );
56
56
 
57
57
  r.job("trackOrder", { trigger: { on: "orders:write:orders:create" } }, async (payload) => {
@@ -27,13 +27,13 @@ const orderFeature = defineFeature("multi", (r) => {
27
27
  "order:open",
28
28
  z.object({ id: z.string() }),
29
29
  async (event) => ({ isSuccess: true, data: { id: event.payload.id } }),
30
- { access: { openToAll: true } },
30
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
31
31
  );
32
32
  r.writeHandler(
33
33
  "order:cancel",
34
34
  z.object({ id: z.string() }),
35
35
  async (event) => ({ isSuccess: true, data: { id: event.payload.id } }),
36
- { access: { openToAll: true } },
36
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
37
37
  );
38
38
  r.job(
39
39
  "fanout",
@@ -119,7 +119,7 @@ const testFeature = defineFeature("test", (r) => {
119
119
  "capped-event",
120
120
  z.object({ n: z.number() }),
121
121
  async (event) => ({ isSuccess: true, data: { n: event.payload.n } }),
122
- { access: { openToAll: true } },
122
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
123
123
  );
124
124
  // Event-triggered twin — exercises handleEvent's maxPerTenant guard
125
125
  // (dispatch and handleEvent share isOverPerTenantLimit).
@@ -33,7 +33,7 @@ const productFeature = defineFeature("product", (r) => {
33
33
  ctx.metrics.inc("tracked_total", { kind: event.payload.kind });
34
34
  return { isSuccess: true, data: { ok: true } };
35
35
  },
36
- { access: { openToAll: true } },
36
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
37
37
  );
38
38
  });
39
39
 
@@ -44,7 +44,7 @@ const errorFeature = defineFeature("err", (r) => {
44
44
  async () => {
45
45
  throw new Error("boom from handler");
46
46
  },
47
- { access: { openToAll: true } },
47
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
48
48
  );
49
49
  });
50
50
 
@@ -90,7 +90,7 @@ const todoFeature = defineFeature("todo", (r) => {
90
90
  },
91
91
  };
92
92
  },
93
- { access: { openToAll: true } },
93
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
94
94
  );
95
95
 
96
96
  r.hook("postSave", "create", async (_save: SaveContext) => {
@@ -288,7 +288,7 @@ describe("Observability (integration) — Redis wrapper", () => {
288
288
  await ctx.redis.get("session:abc123:token");
289
289
  return { isSuccess: true, data: { ok: true } };
290
290
  },
291
- { access: { openToAll: true } },
291
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
292
292
  );
293
293
  });
294
294
 
@@ -352,7 +352,7 @@ describe("Observability (integration) — Pino trace bridge", () => {
352
352
  ctx.log?.info(event.payload.msg, { custom: "field" });
353
353
  return { isSuccess: true, data: { ok: true } };
354
354
  },
355
- { access: { openToAll: true } },
355
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
356
356
  );
357
357
  });
358
358
 
@@ -581,7 +581,7 @@ const consumerAFeature = defineFeature("consumer-a", (r) => {
581
581
  recordSharedCall(ctx);
582
582
  return { isSuccess: true, data: { ok: true } };
583
583
  },
584
- { access: { openToAll: true } },
584
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
585
585
  );
586
586
  });
587
587
 
@@ -594,7 +594,7 @@ const consumerBFeature = defineFeature("consumer-b", (r) => {
594
594
  ctx.metricsFor("unregistered-lib").inc("never_total");
595
595
  return { isSuccess: true, data: { ok: true } };
596
596
  },
597
- { access: { openToAll: true } },
597
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
598
598
  );
599
599
  });
600
600
 
@@ -109,14 +109,14 @@ const archFeature = defineFeature("archtest", (r) => {
109
109
  "item:events",
110
110
  z.object({ id: z.uuid() }),
111
111
  async (query, ctx) => ctx.loadAggregate(query.payload.id),
112
- { access: { openToAll: true } },
112
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
113
113
  );
114
114
 
115
115
  r.queryHandler(
116
116
  "item:is-archived",
117
117
  z.object({ id: z.uuid() }),
118
118
  async (query, ctx) => ctx.isStreamArchived(query.payload.id),
119
- { access: { openToAll: true } },
119
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
120
120
  );
121
121
  });
122
122