@cosmicdrift/kumiko-framework 0.269.2 → 0.273.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 (176) 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-cross-tenant/manual-only.expected.ts +44 -0
  6. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  7. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  8. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  9. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
  10. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
  11. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
  12. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
  13. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
  14. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
  15. package/src/__tests__/full-stack.integration.test.ts +2 -2
  16. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  17. package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
  18. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  19. package/src/__tests__/transition-guard.integration.test.ts +7 -7
  20. package/src/api/__tests__/api.test.ts +2 -2
  21. package/src/api/__tests__/body-limit.test.ts +1 -1
  22. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  23. package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
  24. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  25. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  26. package/src/api/server.ts +7 -20
  27. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  28. package/src/bun-db/query.ts +24 -21
  29. package/src/changes.json +99 -0
  30. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  31. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  32. package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
  33. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  34. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  35. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  36. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  37. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  38. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  39. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  40. package/src/db/apply-entity-event.ts +1 -1
  41. package/src/db/assert-exists-in.ts +1 -1
  42. package/src/db/event-store-executor-context.ts +5 -2
  43. package/src/db/event-store-executor-read.ts +6 -4
  44. package/src/db/event-store-executor-write.ts +24 -18
  45. package/src/db/queries/shadow-swap.ts +36 -0
  46. package/src/db/replay-migration-sql.ts +5 -5
  47. package/src/db/tenant-db-runner.ts +23 -0
  48. package/src/db/tenant-db.ts +97 -24
  49. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
  50. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  51. package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
  52. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  53. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  54. package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
  55. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
  56. package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
  57. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  58. package/src/engine/__tests__/boot-validator.test.ts +69 -18
  59. package/src/engine/__tests__/build-app-schema.test.ts +10 -6
  60. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
  61. package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
  62. package/src/engine/__tests__/engine.test.ts +39 -25
  63. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  64. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  65. package/src/engine/__tests__/entity-handlers.test.ts +47 -8
  66. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
  67. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  68. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  69. package/src/engine/__tests__/extends-registrar.test.ts +3 -3
  70. package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
  71. package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
  72. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  73. package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
  74. package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
  75. package/src/engine/__tests__/post-query-hook.test.ts +6 -2
  76. package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
  77. package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
  78. package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
  79. package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
  80. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  81. package/src/engine/__tests__/registry.test.ts +19 -13
  82. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  83. package/src/engine/__tests__/screen.test.ts +176 -20
  84. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  85. package/src/engine/__tests__/steps-read.test.ts +204 -8
  86. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  87. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  88. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
  89. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  90. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
  91. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  92. package/src/engine/boot-validator/access-declarations.ts +3 -5
  93. package/src/engine/boot-validator/entity-handler.ts +21 -0
  94. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  95. package/src/engine/boot-validator/nav.ts +4 -3
  96. package/src/engine/boot-validator/parent-ref.ts +1 -1
  97. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  98. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  99. package/src/engine/boot-validator/screens.ts +154 -31
  100. package/src/engine/build-config-feature-schema.ts +12 -2
  101. package/src/engine/entity-handlers.ts +72 -20
  102. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
  103. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
  104. package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
  105. package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
  106. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
  107. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
  108. package/src/engine/feature-ast/extractors/handlers.ts +16 -2
  109. package/src/engine/feature-ast/extractors/hooks.ts +25 -4
  110. package/src/engine/feature-ast/patcher.ts +15 -3
  111. package/src/engine/feature-ast/patterns.ts +4 -0
  112. package/src/engine/feature-ast/render.ts +3 -0
  113. package/src/engine/feature-entity-handlers.ts +4 -4
  114. package/src/engine/index.ts +5 -0
  115. package/src/engine/pattern-library/shared-fields.ts +7 -3
  116. package/src/engine/registry-validate.ts +12 -4
  117. package/src/engine/schema-builder.ts +6 -0
  118. package/src/engine/screen-helpers.ts +52 -0
  119. package/src/engine/steps/_read-source.ts +12 -0
  120. package/src/engine/steps/read-find-many.ts +8 -2
  121. package/src/engine/steps/read-find-one.ts +11 -8
  122. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  123. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  124. package/src/engine/types/index.ts +8 -1
  125. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  126. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
  127. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  128. package/src/errors/i18n/de.yaml +14 -0
  129. package/src/errors/i18n/en.yaml +13 -0
  130. package/src/errors/reasons.ts +4 -0
  131. package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
  132. package/src/i18n/required-surface-keys.ts +6 -2
  133. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
  134. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
  135. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  136. package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
  137. package/src/jobs/job-runner.ts +35 -5
  138. package/src/observability/__tests__/observability.integration.test.ts +7 -7
  139. package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
  140. package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
  141. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  142. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  143. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
  144. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  145. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  146. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  147. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  148. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  149. package/src/pipeline/__tests__/query-projection.integration.test.ts +172 -17
  150. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  151. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  152. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  153. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  154. package/src/pipeline/dispatch-query.ts +27 -8
  155. package/src/pipeline/dispatch-shared.ts +141 -57
  156. package/src/pipeline/dispatch-stream.ts +1 -3
  157. package/src/pipeline/dispatch-write.ts +62 -14
  158. package/src/pipeline/dispatcher.ts +20 -0
  159. package/src/pipeline/escape-hatch-report.ts +127 -0
  160. package/src/pipeline/index.ts +8 -0
  161. package/src/pipeline/member-read-only-transaction.ts +75 -0
  162. package/src/pipeline/msp-rebuild.ts +2 -0
  163. package/src/pipeline/projection-rebuild.ts +2 -0
  164. package/src/pipeline/projections-runner.ts +15 -13
  165. package/src/pipeline/system-identity-switch.ts +121 -9
  166. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  167. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  168. package/src/scripts/codemod/migrate-open-to-all.ts +249 -0
  169. package/src/secrets/__tests__/contains-secret.test.ts +5 -5
  170. package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
  171. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  172. package/src/testing/__tests__/e2e-generator.test.ts +19 -5
  173. package/src/ui-types/index.ts +3 -0
  174. package/src/utils/__tests__/ids.test.ts +29 -1
  175. package/src/utils/ids.ts +10 -1
  176. package/src/utils/index.ts +1 -1
@@ -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
  }
@@ -23,7 +23,7 @@ import type {
23
23
  HandlerRef,
24
24
  NameOrRef,
25
25
  QueryHandlerFn,
26
- RateLimitOption,
26
+ RateLimitDeclaration,
27
27
  RelationDefinition,
28
28
  StreamHandlerFn,
29
29
  WriteHandlerFn,
@@ -124,7 +124,7 @@ export function buildEntityHandlerMethods<TName extends string>(
124
124
  handler?: WriteHandlerFn<z.infer<TSchema>>,
125
125
  options?: {
126
126
  access: AccessRule;
127
- rateLimit?: RateLimitOption;
127
+ rateLimit?: RateLimitDeclaration;
128
128
  description?: string;
129
129
  agent?: AgentHandlerHints;
130
130
  escapeHatch?: EscapeHatchDeclaration;
@@ -181,7 +181,7 @@ export function buildEntityHandlerMethods<TName extends string>(
181
181
  handler?: QueryHandlerFn<z.infer<TSchema>>,
182
182
  options?: {
183
183
  access: AccessRule;
184
- rateLimit?: RateLimitOption;
184
+ rateLimit?: RateLimitDeclaration;
185
185
  outputSchema?: ZodType;
186
186
  description?: string;
187
187
  agent?: AgentHandlerHints;
@@ -230,7 +230,7 @@ export function buildEntityHandlerMethods<TName extends string>(
230
230
  handler?: StreamHandlerFn<z.infer<TSchema>>,
231
231
  options?: {
232
232
  access: AccessRule;
233
- rateLimit?: RateLimitOption;
233
+ rateLimit?: RateLimitDeclaration;
234
234
  escapeHatch?: EscapeHatchDeclaration;
235
235
  },
236
236
  ): HandlerRef {
@@ -374,7 +374,12 @@ export type {
374
374
  EntityListScreenDefinition,
375
375
  EntityRef,
376
376
  EntityRelations,
377
+ EscapeHatchAuditSink,
377
378
  EscapeHatchDeclaration,
379
+ EscapeHatchKind,
380
+ EscapeHatchReporter,
381
+ EscapeHatchTarget,
382
+ EscapeHatchUseEvent,
378
383
  EventDef,
379
384
  FeatureDefinition,
380
385
  FeatureRegistrar,
@@ -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
  },
@@ -25,7 +25,7 @@ import type {
25
25
  PreSaveHookFn,
26
26
  ReferenceFieldDef,
27
27
  } from "./types";
28
- import { HookPhases } from "./types";
28
+ import { HookPhases, isRateLimitDisabled } from "./types";
29
29
 
30
30
  function allHandlerQns(state: RegistryState): ReadonlySet<string> {
31
31
  return new Set([...state.writeHandlerMap.keys(), ...state.queryHandlerMap.keys()]);
@@ -721,10 +721,18 @@ export function validateExtensionUsageTargets(state: RegistryState): void {
721
721
  export function computeHasRateLimitedHandler(state: RegistryState): void {
722
722
  // Pre-compute: any handler with a rateLimit option? Keeps the boot
723
723
  // path able to short-circuit the RateLimitResolver wiring (and its
724
- // Lua-script registration on Redis) when nobody opted in.
724
+ // Lua-script registration on Redis) when nobody opted in. Deliberately
725
+ // stays limited to explicit, non-disabled `rateLimit` declarations —
726
+ // `{ disabled: true }` must not auto-wire a resolver for an app that only
727
+ // opted OUT, and fw#2861's systemScope default only fires once a resolver
728
+ // already exists, so it must not force wantsL3 either.
725
729
  state.hasRateLimitedHandlerCached = (() => {
726
- for (const h of state.writeHandlerMap.values()) if (h.rateLimit !== undefined) return true;
727
- for (const h of state.queryHandlerMap.values()) if (h.rateLimit !== undefined) return true;
730
+ for (const h of state.writeHandlerMap.values()) {
731
+ if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
732
+ }
733
+ for (const h of state.queryHandlerMap.values()) {
734
+ if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
735
+ }
728
736
  return false;
729
737
  })();
730
738
  }
@@ -380,6 +380,12 @@ export function buildInsertSchema(
380
380
  shape[name] = isRequired || hasDefault ? zodField : zodField.optional();
381
381
  }
382
382
 
383
+ // Caller-chosen aggregate id for idempotent creates (deterministic-id
384
+ // patterns). Accepting it here only lets it survive parsing — the create
385
+ // handler still honors it only for a system-identity caller (jobs/hooks
386
+ // via createSystemUser), never for an ordinary HTTP-authenticated user.
387
+ shape["id"] = z.uuid().optional();
388
+
383
389
  return applyTotalsMatchRefinements(entity, z.object(shape));
384
390
  }
385
391
 
@@ -11,10 +11,62 @@ import type {
11
11
  FormatSpec,
12
12
  ListColumnSpec,
13
13
  ProjectionDetailScreenDefinition,
14
+ RowAction,
14
15
  ScreenDefinition,
15
16
  SecretMintScreenDefinition,
17
+ ToolbarAction,
16
18
  } from "./types/screen";
17
19
 
20
+ // Every screen type carrying `listScreenId` declares it identically; centralizing
21
+ // here keeps the switch the only place that grows for a new screen type.
22
+ export function explicitListScreenId(screen: ScreenDefinition): string | undefined {
23
+ switch (screen.type) {
24
+ case "custom":
25
+ case "projectionDetail":
26
+ case "entityEdit":
27
+ case "actionForm":
28
+ case "secretMint":
29
+ return screen.listScreenId;
30
+ default:
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ // Shared by the renderer breadcrumb and the boot-validator so both resolve
36
+ // parents the same way; `getId` lets each caller normalize `screen.id`.
37
+ export function resolveNavParentScreen(
38
+ screens: readonly ScreenDefinition[],
39
+ detail: ScreenDefinition,
40
+ getId: (screen: ScreenDefinition) => string,
41
+ ): ScreenDefinition | undefined {
42
+ const detailScreenId = getId(detail);
43
+
44
+ const listFromExplicit = ((): ScreenDefinition | undefined => {
45
+ const explicitId = explicitListScreenId(detail);
46
+ return explicitId !== undefined ? screens.find((s) => getId(s) === explicitId) : undefined;
47
+ })();
48
+
49
+ // rowActions, toolbarActions, and drawer-kind actions (which mount inline
50
+ // and have no page of their own) all count as "reached from" this list.
51
+ const navigatesToDetail = (
52
+ actions: readonly (RowAction | ToolbarAction)[] | undefined,
53
+ ): boolean =>
54
+ (actions ?? []).some(
55
+ (a) => (a.kind === "navigate" || a.kind === "drawer") && a.screen === detailScreenId,
56
+ );
57
+ const listFromRowAction = screens.find((s) => {
58
+ if (s.type !== "entityList" && s.type !== "projectionList") return false;
59
+ return navigatesToDetail(s.rowActions) || navigatesToDetail(s.toolbarActions);
60
+ });
61
+
62
+ const listFromEntity =
63
+ detail.type === "entityEdit"
64
+ ? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
65
+ : undefined;
66
+
67
+ return listFromExplicit ?? listFromRowAction ?? listFromEntity;
68
+ }
69
+
18
70
  export type EditLayoutScreen =
19
71
  | ProjectionDetailScreenDefinition
20
72
  | EntityEditScreenDefinition
@@ -0,0 +1,12 @@
1
+ import type { DbRunner } from "../../db/connection";
2
+ import { type TenantDb, unsafeRawForDeclaredStep } from "../../db/tenant-db";
3
+ import type { PipelineCtx } from "../types/step";
4
+
5
+ // unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
6
+ export function readSourceFor(
7
+ ctx: PipelineCtx,
8
+ unsafeAllTenants: { readonly reason: string } | undefined,
9
+ ): TenantDb | DbRunner {
10
+ if (!unsafeAllTenants) return ctx.db;
11
+ return unsafeRawForDeclaredStep(ctx.systemDb ?? ctx.db, unsafeAllTenants.reason);
12
+ }
@@ -1,6 +1,8 @@
1
1
  // r.step.read.findMany — load multiple rows from a projection table.
2
2
  //
3
- // Sibling to read.findOne — same tenant-filter caveat (caller-owned).
3
+ // Sibling to read.findOne — same tenant-filtering: own tenant +
4
+ // SYSTEM_TENANT_ID reference rows by default, `unsafeAllTenants: { reason }`
5
+ // plus `escapeHatch: { reason }` on the handler to read across tenants.
4
6
  // Resolves to a row-array (possibly empty), landed under steps.<name>.
5
7
  //
6
8
  // Optional `limit` — defaults to no-limit (caller-chosen, NOT a
@@ -11,6 +13,7 @@
11
13
  import { selectMany, type WhereObject } from "../../db/query";
12
14
  import { defineStep } from "../define-step";
13
15
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
16
+ import { readSourceFor } from "./_read-source";
14
17
  import { resolveOptional } from "./_resolver-utils";
15
18
 
16
19
  type ReadFindManyArgs = {
@@ -18,6 +21,7 @@ type ReadFindManyArgs = {
18
21
  readonly table: unknown;
19
22
  readonly where?: StepResolver<WhereObject | undefined>;
20
23
  readonly limit?: number;
24
+ readonly unsafeAllTenants?: { readonly reason: string };
21
25
  };
22
26
 
23
27
  defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
@@ -26,8 +30,9 @@ defineStep<ReadFindManyArgs, readonly Record<string, unknown>[]>({
26
30
  resultKey: (args) => args.name,
27
31
  run: async (args, ctx: PipelineCtx) => {
28
32
  const where = resolveOptional(args.where, ctx);
33
+ const source = readSourceFor(ctx, args.unsafeAllTenants);
29
34
  const rows = await selectMany(
30
- ctx.db.raw,
35
+ source,
31
36
  args.table,
32
37
  where,
33
38
  args.limit !== undefined ? { limit: args.limit } : undefined,
@@ -42,6 +47,7 @@ export function buildReadFindManyStep(
42
47
  readonly table: unknown;
43
48
  readonly where?: StepResolver<WhereObject | undefined>;
44
49
  readonly limit?: number;
50
+ readonly unsafeAllTenants?: { readonly reason: string };
45
51
  },
46
52
  ): StepInstance {
47
53
  return {
@@ -1,13 +1,12 @@
1
1
  // r.step.read.findOne — load a single row from a projection table.
2
2
  //
3
3
  // Thin wrapper on selectMany(db, table, where, { limit: 1 }) (bun-db).
4
- // Resolves to the first row or null. Tenant-isolation: the caller's
5
- // `where` clause is responsible for any tenantId filter — read.findOne
6
- // does NOT auto-inject one (different from ctx.queryProjection which
7
- // does). That's deliberate: most read-step uses are aggregate-lookups
8
- // where the where-clause already pins a uuid that's globally unique;
9
- // auto-tenant-filtering would be redundant and would surprise users
10
- // who pass an explicit tenantId.
4
+ // Resolves to the first row or null. Tenant-filtered like ctx.db
5
+ // method-form reads (own tenant + SYSTEM_TENANT_ID reference rows); a
6
+ // foreign `where.tenantId` is narrowed to the caller's own scope.
7
+ // Cross-tenant reads require `unsafeAllTenants: { reason }` here AND
8
+ // `escapeHatch: { reason }` on the handler — reported as an "unsafe-raw"
9
+ // escape-hatch audit event.
11
10
  //
12
11
  // Use when a subsequent step needs a row from the read-side. For
13
12
  // cross-feature reads, prefer `r.step.callFeature(...)` (M.2) so the
@@ -23,12 +22,14 @@
23
22
  import { selectMany, type WhereObject } from "../../db/query";
24
23
  import { defineStep } from "../define-step";
25
24
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
25
+ import { readSourceFor } from "./_read-source";
26
26
  import { resolveRequired } from "./_resolver-utils";
27
27
 
28
28
  type ReadFindOneArgs = {
29
29
  readonly name: string;
30
30
  readonly table: unknown;
31
31
  readonly where: StepResolver<WhereObject | undefined>;
32
+ readonly unsafeAllTenants?: { readonly reason: string };
32
33
  };
33
34
 
34
35
  defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
@@ -37,7 +38,8 @@ defineStep<ReadFindOneArgs, Record<string, unknown> | null>({
37
38
  resultKey: (args) => args.name,
38
39
  run: async (args, ctx: PipelineCtx) => {
39
40
  const where = resolveRequired(args.where, ctx);
40
- const rows = await selectMany(ctx.db.raw, args.table, where, { limit: 1 });
41
+ const source = readSourceFor(ctx, args.unsafeAllTenants);
42
+ const rows = await selectMany(source, args.table, where, { limit: 1 });
41
43
  return (rows[0] as Record<string, unknown> | undefined) ?? null;
42
44
  },
43
45
  });
@@ -47,6 +49,7 @@ export function buildReadFindOneStep(
47
49
  opts: {
48
50
  readonly table: unknown;
49
51
  readonly where: StepResolver<WhereObject | undefined>;
52
+ readonly unsafeAllTenants?: { readonly reason: string };
50
53
  },
51
54
  ): StepInstance {
52
55
  return {
@@ -18,6 +18,7 @@
18
18
 
19
19
  import type { EntityTableMeta } from "../../db/entity-table-meta";
20
20
  import { deleteMany, type WhereObject } from "../../db/query";
21
+ import { tenantDbRunner } from "../../db/tenant-db-runner";
21
22
  import { defineStep } from "../define-step";
22
23
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
23
24
  import { resolveRequired } from "./_resolver-utils";
@@ -40,7 +41,7 @@ defineStep<UnsafeProjectionDeleteArgs, void>({
40
41
  // The `unsafe*`-step IS the sanctioned direct projection write — it exists
41
42
  // to bypass the executor deliberately (boot-validated allowlist + reviewer
42
43
  // gate). The brand-strip cast makes that explicit at the one blessed seam.
43
- await deleteMany(ctx.db.raw, args.table as EntityTableMeta, where);
44
+ await deleteMany(tenantDbRunner(ctx.db), args.table as EntityTableMeta, where);
44
45
  },
45
46
  });
46
47
 
@@ -14,6 +14,7 @@
14
14
  import { KUMIKO_COLUMNS_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
15
15
  import { extractTableName } from "../../db";
16
16
  import { executeRawQuery } from "../../db/queries/raw-sql";
17
+ import { tenantDbRunner } from "../../db/tenant-db-runner";
17
18
  import { defineStep } from "../define-step";
18
19
  import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
19
20
  import { resolveRequired } from "./_resolver-utils";
@@ -78,7 +79,7 @@ defineStep<UnsafeProjectionUpsertArgs, void>({
78
79
  `INSERT INTO ${quoteIdent(tableName)} (${colNames.join(", ")}) VALUES (${placeholders.join(", ")}) ` +
79
80
  `ON CONFLICT (${conflictCols}) DO UPDATE SET ${setClauses.join(", ")}`;
80
81
 
81
- await executeRawQuery(ctx.db.raw, sqlText, params);
82
+ await executeRawQuery(tenantDbRunner(ctx.db), sqlText, params);
82
83
  },
83
84
  });
84
85
 
@@ -159,7 +159,12 @@ export type {
159
159
  DeclarativeEventMigration,
160
160
  DispatchWriteRef,
161
161
  EntityRef,
162
+ EscapeHatchAuditSink,
162
163
  EscapeHatchDeclaration,
164
+ EscapeHatchKind,
165
+ EscapeHatchReporter,
166
+ EscapeHatchTarget,
167
+ EscapeHatchUseEvent,
163
168
  EventDef,
164
169
  EventMigrationDef,
165
170
  EventPiiFields,
@@ -184,6 +189,8 @@ export type {
184
189
  QueryEvent,
185
190
  QueryHandlerDef,
186
191
  QueryHandlerFn,
192
+ RateLimitDeclaration,
193
+ RateLimitDisabled,
187
194
  RateLimitOption,
188
195
  RateLimitPer,
189
196
  SessionUser,
@@ -198,7 +205,7 @@ export type {
198
205
  WriteHandlerFn,
199
206
  WriteResult,
200
207
  } from "@cosmicdrift/kumiko-types/handlers";
201
- export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
208
+ export { isOpenToAllGranted, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
202
209
  export type {
203
210
  DeleteContext,
204
211
  EntityHookMap,
@@ -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",
@@ -113,6 +113,20 @@ identity_switch_denied:
113
113
  (oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
114
114
  oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
115
115
 
116
+ unsafe_all_tenants_denied:
117
+ endUser: |
118
+ Diese Aktion ist nicht erlaubt.
119
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
120
+ developer: |
121
+ `AccessDeniedError`: ein Handler oder Hook hat
122
+ `ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })`
123
+ aufgerufen, ohne `r.systemScope()`-Handler zu sein oder
124
+ `escapeHatch: { reason }` zu deklarieren.
125
+
126
+ Deklariere `escapeHatch: { reason: "..." }` am Write-, Query- oder
127
+ Stream-Handler (oder am `r.hook(...)`-Aufruf, wenn der Read in einem Hook
128
+ passiert), oder verlagere die Logik in ein `r.systemScope()`-Feature.
129
+
116
130
  member_resolution_denied:
117
131
  endUser: |
118
132
  Diese Aktion ist nicht erlaubt.
@@ -109,6 +109,19 @@ identity_switch_denied:
109
109
  (or on the `r.hook(...)` call, if the switch happens inside a hook), or
110
110
  move the logic into an `r.systemScope()` feature or a job.
111
111
 
112
+ unsafe_all_tenants_denied:
113
+ endUser: |
114
+ This action isn't allowed.
115
+ Please contact an administrator if you believe this is a mistake.
116
+ developer: |
117
+ `AccessDeniedError`: a handler or hook called
118
+ `ctx.queryProjection(qualifiedName, { unsafeAllTenants: true })` without
119
+ being an `r.systemScope()` handler or declaring `escapeHatch: { reason }`.
120
+
121
+ Declare `escapeHatch: { reason: "..." }` on the write, query or stream
122
+ handler (or on the `r.hook(...)` call, if the read happens inside a
123
+ hook), or move the logic into an `r.systemScope()` feature.
124
+
112
125
  member_resolution_denied:
113
126
  endUser: |
114
127
  This action isn't allowed.
@@ -48,6 +48,10 @@ export const FrameworkReasons = {
48
48
  // claims, origin or extra roles) without r.systemScope() or escapeHatch.
49
49
  identitySwitchDenied: "identity_switch_denied",
50
50
 
51
+ // AccessDeniedError: a handler/hook called ctx.queryProjection(..., { unsafeAllTenants: true })
52
+ // without an r.systemScope() feature or a declared escapeHatch.
53
+ unsafeAllTenantsDenied: "unsafe_all_tenants_denied",
54
+
51
55
  // AccessDeniedError: ctx.queryAsMember's userId is not an active member of
52
56
  // ctx's tenant. Deliberately generic — a caller can't probe which check failed.
53
57
  memberResolutionDenied: "member_resolution_denied",
@@ -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.
@@ -196,7 +196,9 @@ export function requiredKeysFromScreen(
196
196
  const form = screen as ActionFormScreenDefinition;
197
197
  pushKey(out, form.submitLabel);
198
198
  for (const fieldName of Object.keys(form.fields)) {
199
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
199
+ const override = form.fieldLabels?.[fieldName];
200
+ if (override !== undefined) pushKey(out, override);
201
+ else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
200
202
  }
201
203
  for (const section of form.layout.sections) {
202
204
  if (isExtensionEditSection(section)) {
@@ -207,7 +209,9 @@ export function requiredKeysFromScreen(
207
209
  pushKey(out, section.title);
208
210
  for (const f of section.fields) {
209
211
  const fieldName = editFieldName(f);
210
- out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
212
+ const override = form.fieldLabels?.[fieldName];
213
+ if (override !== undefined) pushKey(out, override);
214
+ else out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
211
215
  }
212
216
  }
213
217
  break;
@@ -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) => {