@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 (111) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  3. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  4. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  5. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  6. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  7. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  8. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  9. package/src/api/server.ts +7 -20
  10. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  11. package/src/bun-db/query.ts +24 -21
  12. package/src/changes.json +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -8,7 +8,8 @@ import {
8
8
  } from "../db/eagerload";
9
9
  import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-store-executor";
10
10
  import { buildEntityTable, type EntityTable } from "../db/table-builder";
11
- import { createTenantDb, type TenantDb } from "../db/tenant-db";
11
+ import { acknowledgeConventionCrossTenant, type TenantDb } from "../db/tenant-db";
12
+ import { isSystemIdentity } from "../pipeline/system-identity-switch";
12
13
  import { assertUnreachable } from "../utils";
13
14
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
14
15
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
@@ -16,6 +17,7 @@ import type {
16
17
  AccessRule,
17
18
  DeriveContext,
18
19
  EntityDefinition,
20
+ EscapeHatchDeclaration,
19
21
  HandlerContext,
20
22
  QueryHandlerDef,
21
23
  SessionUser,
@@ -100,13 +102,40 @@ type ListPayload = {
100
102
  // den gleichen Query-Handler.
101
103
  filter?: {
102
104
  readonly field: string;
103
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
105
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
104
106
  readonly value: unknown;
105
107
  };
106
108
  };
107
109
 
108
110
  const idSchema = z.object({ id: z.uuid() });
109
111
 
112
+ // Keyed by handler function: the registrar rebuilds the def object but keeps the function reference.
113
+ const deprecatedCrossTenantHandlers = new WeakSet<object>();
114
+
115
+ export function isDeprecatedCrossTenantHandler(handler: object): boolean {
116
+ return deprecatedCrossTenantHandlers.has(handler);
117
+ }
118
+
119
+ export const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
120
+
121
+ function resolveCrossTenantReason(name: string, options: EntityHandlerOptions): string | undefined {
122
+ if (options.escapeHatch !== undefined && options.crossTenant === true) {
123
+ throw new Error(
124
+ `"${name}": declare either escapeHatch or the deprecated crossTenant, not both.`,
125
+ );
126
+ }
127
+ if (options.escapeHatch !== undefined) {
128
+ if (options.escapeHatch.reason.trim().length === 0) {
129
+ throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
130
+ }
131
+ return options.escapeHatch.reason;
132
+ }
133
+ if (options.crossTenant === true) {
134
+ return `deprecated crossTenant: true on entity convention handler ${name}`;
135
+ }
136
+ return undefined;
137
+ }
138
+
110
139
  // Upper bound on entity-list `limit`: unbounded/fractional values ran
111
140
  // straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
112
141
  // read.ts), letting a client demand a full-table materialisation or trip a
@@ -128,7 +157,7 @@ export const entityListSchema = z.object({
128
157
  filter: z
129
158
  .object({
130
159
  field: z.string(),
131
- op: z.enum(["eq", "ne", "lt", "gt", "in"]),
160
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
132
161
  // Value ist `unknown` zur Compile-Zeit; Server-Side prüft beim
133
162
  // Build der WHERE-Clause ob der Type zum Field passt. z.unknown()
134
163
  // lässt alles durch; Type-Check kommt im executor.list.
@@ -141,7 +170,7 @@ export const entityListSchema = z.object({
141
170
  .array(
142
171
  z.object({
143
172
  field: z.string(),
144
- op: z.enum(["eq", "ne", "lt", "gt", "in"]),
173
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
145
174
  value: z.unknown(),
146
175
  }),
147
176
  )
@@ -194,10 +223,10 @@ export function defineEntityWriteHandler(
194
223
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
195
224
  // TenantDb for these handlers before the ctx.db cutover.
196
225
  //
197
- // options.crossTenant mirrors the query-handler branch below: this ONE
198
- // write handler reads/writes across every tenant without making the
199
- // whole feature r.systemScope().
200
- const crossTenant = options?.crossTenant === true;
226
+ // crossTenantReason (escapeHatch or the deprecated crossTenant) mirrors the
227
+ // query-handler branch below: this ONE write handler reads/writes across
228
+ // every tenant without making the whole feature r.systemScope().
229
+ const crossTenantReason = resolveCrossTenantReason(name, options);
201
230
 
202
231
  const dbFor = (ctx: HandlerContext): TenantDb => {
203
232
  if (ctx.systemDb) {
@@ -205,7 +234,9 @@ export function defineEntityWriteHandler(
205
234
  `entity convention handler for r.systemScope() feature (${name})`,
206
235
  );
207
236
  }
208
- return crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
237
+ return crossTenantReason !== undefined
238
+ ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
239
+ : ctx.db;
209
240
  };
210
241
 
211
242
  // The event stream is keyed by the acting user's tenantId (streamTenantFor in
@@ -219,7 +250,7 @@ export function defineEntityWriteHandler(
219
250
  id: unknown,
220
251
  db: TenantDb,
221
252
  ): Promise<SessionUser> => {
222
- if (!crossTenant || entity.systemStream) return user;
253
+ if (crossTenantReason === undefined || entity.systemStream) return user;
223
254
  const row = await db.fetchOne<Record<string, unknown>>(table, { id });
224
255
  const rowTenantId = row?.["tenantId"];
225
256
  if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId) return user;
@@ -234,7 +265,14 @@ export function defineEntityWriteHandler(
234
265
  schema = buildInsertSchema(entity);
235
266
  handler = async (event, ctx) => {
236
267
  const { runPreSave } = ctx;
237
- return executor.create(event.payload as DbRow, event.user, dbFor(ctx), {
268
+ // A caller-chosen id (deterministic-id idempotent creates) is only
269
+ // honored for a system-identity caller (jobs/hooks via
270
+ // createSystemUser) — an HTTP-authenticated end user can never
271
+ // present that identity, so their payload.id is dropped exactly as
272
+ // before this field became part of the schema.
273
+ const { id: _callerId, ...payloadWithoutId } = event.payload as DbRow;
274
+ const payload = isSystemIdentity(event.user) ? (event.payload as DbRow) : payloadWithoutId;
275
+ return executor.create(payload, event.user, dbFor(ctx), {
238
276
  preSave:
239
277
  runPreSave &&
240
278
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
@@ -288,6 +326,9 @@ export function defineEntityWriteHandler(
288
326
  assertUnreachable(verb, "write verb");
289
327
  }
290
328
 
329
+ if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
330
+
331
+ // escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
291
332
  return {
292
333
  name,
293
334
  schema,
@@ -340,21 +381,22 @@ export function defineEntityQueryHandler(
340
381
  // useReferenceLookup stays as a fallback (for apps that write custom
341
382
  // handlers by hand without this wrapper).
342
383
  const hasRefFields = collectReferenceFields(entity).length > 0;
384
+ const crossTenantReason = resolveCrossTenantReason(name, options);
343
385
 
344
386
  // Preference order:
345
387
  // 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
346
388
  // cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
347
389
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
348
390
  // TenantDb for these handlers before that cutover.
349
- // 2. options.crossTenant — this ONE handler reads across every tenant
350
- // (e.g. a SystemAdmin-only operator inspector) without making the
351
- // whole feature r.systemScope() — that would drop tenant isolation
352
- // from every OTHER handler the feature registers too. The executor's
353
- // list()/detail() only add a tenant filter when db.mode === "tenant"
354
- // (see event-store-executor.ts), so handing them a "system"-mode
355
- // TenantDb built from the same raw connection is enough to skip it —
356
- // access-control (who may call this handler at all) is unaffected,
357
- // still gated by `options.access`.
391
+ // 2. crossTenantReason (escapeHatch or the deprecated crossTenant) — this
392
+ // ONE handler reads across every tenant (e.g. a SystemAdmin-only
393
+ // operator inspector) without making the whole feature r.systemScope()
394
+ // — that would drop tenant isolation from every OTHER handler the
395
+ // feature registers too. The executor's list()/detail() only add a
396
+ // tenant filter when db.mode === "tenant" (see event-store-executor.ts),
397
+ // so handing them a "system"-mode TenantDb built from the same raw
398
+ // connection is enough to skip it — access-control (who may call this
399
+ // handler at all) is unaffected, still gated by `options.access`.
358
400
  // 3. plain ctx.db — the common case, tenant-filtered.
359
401
  const dbFor = (ctx: HandlerContext): TenantDb => {
360
402
  if (ctx.systemDb) {
@@ -362,7 +404,9 @@ export function defineEntityQueryHandler(
362
404
  `entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
363
405
  );
364
406
  }
365
- return options?.crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
407
+ return crossTenantReason !== undefined
408
+ ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
409
+ : ctx.db;
366
410
  };
367
411
 
368
412
  switch (verb) {
@@ -418,6 +462,9 @@ export function defineEntityQueryHandler(
418
462
  assertUnreachable(verb, "query verb");
419
463
  }
420
464
 
465
+ if (options.crossTenant === true) deprecatedCrossTenantHandlers.add(handler);
466
+
467
+ // escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
421
468
  return {
422
469
  name,
423
470
  schema,
@@ -543,7 +590,11 @@ export function createEntityExecutor(
543
590
  export function defineProjectionQueryHandler(
544
591
  name: string,
545
592
  projectionQualifiedName: string,
546
- options: { access: AccessRule; unsafeAllTenants?: boolean },
593
+ options: {
594
+ access: AccessRule;
595
+ unsafeAllTenants?: boolean;
596
+ escapeHatch?: EscapeHatchDeclaration;
597
+ },
547
598
  ): QueryHandlerDef {
548
599
  return {
549
600
  name,
@@ -558,6 +609,7 @@ export function defineProjectionQueryHandler(
558
609
  options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
559
610
  ), // @wrapper-known semantic-alias
560
611
  access: options.access,
612
+ ...(options.escapeHatch && { escapeHatch: options.escapeHatch }),
561
613
  };
562
614
  }
563
615
 
@@ -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,
@@ -1,4 +1,5 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
+ import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption";
2
3
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
4
  import { resolveName } from "./handler-helpers";
4
5
  import type {
@@ -16,6 +17,7 @@ import {
16
17
  softDeleteGraceDaysConfig,
17
18
  } from "./soft-delete-cleanup";
18
19
  import type {
20
+ EntityDefinition,
19
21
  EventPiiFields,
20
22
  EventUpcastFn,
21
23
  FeatureDefinition,
@@ -25,7 +27,7 @@ import type {
25
27
  PreSaveHookFn,
26
28
  ReferenceFieldDef,
27
29
  } from "./types";
28
- import { HookPhases } from "./types";
30
+ import { HookPhases, isRateLimitDisabled } from "./types";
29
31
 
30
32
  function allHandlerQns(state: RegistryState): ReadonlySet<string> {
31
33
  return new Set([...state.writeHandlerMap.keys(), ...state.queryHandlerMap.keys()]);
@@ -206,6 +208,7 @@ function buildSearchableReferenceField(
206
208
  entityName: string,
207
209
  fieldName: string,
208
210
  field: ReferenceFieldDef,
211
+ entityMap: ReadonlyMap<string, EntityDefinition>,
209
212
  ): SearchableReferenceField {
210
213
  if (field.labelField === undefined || field.labelField === "id") {
211
214
  throw new Error(
@@ -220,9 +223,26 @@ function buildSearchableReferenceField(
220
223
  `(array) reference fields — remove "multiple" or "searchable".`,
221
224
  );
222
225
  }
226
+ const targetEntityName = parseReferenceTargetEntityName(field.entity);
227
+ const targetEntity = entityMap.get(targetEntityName);
228
+ if (targetEntity !== undefined) {
229
+ if (
230
+ isSensitiveLabelField(targetEntity, field.labelField) &&
231
+ !hasSearchablePlaintext(targetEntity, field.labelField)
232
+ ) {
233
+ throw new Error(
234
+ `[Entity ${entityName}] field "${fieldName}": searchable reference targets ` +
235
+ `"${targetEntityName}.${field.labelField}", which is encrypted/PII and not itself ` +
236
+ `searchable — there is no plaintext anywhere to match a search term against. Mark ` +
237
+ `"${targetEntityName}.${field.labelField}" searchable: true (add find: "fuzzy" if it ` +
238
+ `also carries a personal annotation, so it lands in the derived search index) or ` +
239
+ `remove searchable from "${fieldName}".`,
240
+ );
241
+ }
242
+ }
223
243
  return {
224
244
  fieldName,
225
- targetEntityName: parseReferenceTargetEntityName(field.entity),
245
+ targetEntityName,
226
246
  labelField: field.labelField,
227
247
  };
228
248
  }
@@ -273,7 +293,9 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
273
293
  }
274
294
  }
275
295
  if (field.type === "reference" && field.searchable === true) {
276
- searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
296
+ searchableReferences.push(
297
+ buildSearchableReferenceField(name, fieldName, field, state.entityMap),
298
+ );
277
299
  }
278
300
  if (field.type === "reference" && field.sortable === true) {
279
301
  sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
@@ -721,10 +743,18 @@ export function validateExtensionUsageTargets(state: RegistryState): void {
721
743
  export function computeHasRateLimitedHandler(state: RegistryState): void {
722
744
  // Pre-compute: any handler with a rateLimit option? Keeps the boot
723
745
  // path able to short-circuit the RateLimitResolver wiring (and its
724
- // Lua-script registration on Redis) when nobody opted in.
746
+ // Lua-script registration on Redis) when nobody opted in. Deliberately
747
+ // stays limited to explicit, non-disabled `rateLimit` declarations —
748
+ // `{ disabled: true }` must not auto-wire a resolver for an app that only
749
+ // opted OUT, and fw#2861's systemScope default only fires once a resolver
750
+ // already exists, so it must not force wantsL3 either.
725
751
  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;
752
+ for (const h of state.writeHandlerMap.values()) {
753
+ if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
754
+ }
755
+ for (const h of state.queryHandlerMap.values()) {
756
+ if (h.rateLimit !== undefined && !isRateLimitDisabled(h.rateLimit)) return true;
757
+ }
728
758
  return false;
729
759
  })();
730
760
  }
@@ -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
 
@@ -6,7 +6,7 @@
6
6
  // compare nutzt der Author selten bewusst); Author kann den
7
7
  // Filter dann einfach nicht setzen.
8
8
  // - boolean: eq, ne (in/lt/gt sinnlos für 2-Werte-Type).
9
- // - number/money/decimal/date/timestamp/locatedTimestamp: alle 5
9
+ // - number/money/decimal/date/timestamp/locatedTimestamp: alle 7
10
10
  // Ops — die Felder sind natürlich vergleichbar.
11
11
  //
12
12
  // Boot-Validator nutzt diese Map um Author-Fehler früh zu fangen
@@ -17,7 +17,15 @@
17
17
  import type { FieldDefinition, ScreenFilterOp } from "./types";
18
18
 
19
19
  const EQUALITY_ONLY = ["eq", "ne", "in"] as const satisfies readonly ScreenFilterOp[];
20
- const COMPARABLE = ["eq", "ne", "lt", "gt", "in"] as const satisfies readonly ScreenFilterOp[];
20
+ const COMPARABLE = [
21
+ "eq",
22
+ "ne",
23
+ "lt",
24
+ "gt",
25
+ "lte",
26
+ "gte",
27
+ "in",
28
+ ] as const satisfies readonly ScreenFilterOp[];
21
29
  const BOOL_OPS = ["eq", "ne"] as const satisfies readonly ScreenFilterOp[];
22
30
 
23
31
  export function getAllowedFilterOps(field: FieldDefinition): readonly ScreenFilterOp[] {
@@ -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,
@@ -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.