@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
@@ -1,5 +1,8 @@
1
1
  import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
- import type { EscapeHatchDeclaration } from "@cosmicdrift/kumiko-types/handlers";
2
+ import type {
3
+ EscapeHatchDeclaration,
4
+ EscapeHatchReporter,
5
+ } from "@cosmicdrift/kumiko-types/handlers";
3
6
  import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
4
7
  import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
5
8
  import {
@@ -23,7 +26,9 @@ import {
23
26
  import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
24
27
  import { AccessDeniedError, InternalError } from "../errors";
25
28
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
29
+ import { fallbackEscapeHatchReporter } from "../pipeline/escape-hatch-report";
26
30
  import type { DbRunner } from "./connection";
31
+ import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner";
27
32
 
28
33
  type Table = SchemaTable;
29
34
 
@@ -34,6 +39,30 @@ export {
34
39
  type UncheckedSystemDb,
35
40
  } from "@cosmicdrift/kumiko-types/tenant-db-types";
36
41
 
42
+ const declaredUnsafeRawRunners = new WeakMap<
43
+ TenantDb | UncheckedSystemDb,
44
+ (reason: string) => DbRunner
45
+ >();
46
+
47
+ // Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
48
+ export function unsafeRawForDeclaredStep(
49
+ holder: TenantDb | UncheckedSystemDb,
50
+ reason: string,
51
+ ): DbRunner {
52
+ if (reason.trim().length === 0) {
53
+ throw new Error("unsafeRawForDeclaredStep requires a non-empty reason");
54
+ }
55
+ const runner = declaredUnsafeRawRunners.get(holder);
56
+ if (!runner) {
57
+ throw new InternalError({
58
+ message:
59
+ "unsafeRawForDeclaredStep received a holder not built by createTenantDb or " +
60
+ "createUncheckedSystemDb — no declared unsafeRaw runner bound.",
61
+ });
62
+ }
63
+ return runner(reason);
64
+ }
65
+
37
66
  // buildHandlerContext (pipeline/dispatch-shared.ts) always builds "system"
38
67
  // mode from the caller's own tenantId, never a foreign one.
39
68
  //
@@ -45,6 +74,7 @@ export {
45
74
  export function createUncheckedSystemDb(
46
75
  db: TenantDb,
47
76
  dbOutsideTransaction?: TenantDb,
77
+ report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
48
78
  ): UncheckedSystemDb {
49
79
  const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
50
80
 
@@ -65,7 +95,15 @@ export function createUncheckedSystemDb(
65
95
  return dbOutsideTransaction;
66
96
  }
67
97
 
68
- return {
98
+ function grantedUnsafeRawRunner(reason: string): DbRunner {
99
+ if (reason.trim().length === 0) {
100
+ throw new Error("unsafeRaw requires a non-empty reason");
101
+ }
102
+ report("unsafe-raw", reason);
103
+ return tenantDbRunner(db);
104
+ }
105
+
106
+ const uncheckedSystemDb: UncheckedSystemDb = {
69
107
  [SYSTEM_SCOPE_CHECK_BRAND]: true,
70
108
 
71
109
  assertTenantMatch(tenantId) {
@@ -96,15 +134,11 @@ export function createUncheckedSystemDb(
96
134
  if (reason.trim().length === 0) {
97
135
  throw new Error("acknowledgeCrossTenant requires a non-empty reason");
98
136
  }
137
+ report("acknowledge-cross-tenant", reason);
99
138
  return db;
100
139
  },
101
140
 
102
- unsafeRaw(reason) {
103
- if (reason.trim().length === 0) {
104
- throw new Error("unsafeRaw requires a non-empty reason");
105
- }
106
- return db.raw;
107
- },
141
+ unsafeRaw: grantedUnsafeRawRunner,
108
142
 
109
143
  outsideTransaction: {
110
144
  assertTenantMatch(tenantId) {
@@ -120,10 +154,14 @@ export function createUncheckedSystemDb(
120
154
  if (reason.trim().length === 0) {
121
155
  throw new Error("acknowledgeCrossTenant requires a non-empty reason");
122
156
  }
123
- return requireOutsideTransactionDb();
157
+ const result = requireOutsideTransactionDb();
158
+ report("acknowledge-cross-tenant", reason);
159
+ return result;
124
160
  },
125
161
  },
126
162
  };
163
+ declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
164
+ return uncheckedSystemDb;
127
165
  }
128
166
 
129
167
  // @cast-boundary tenant-db-row
@@ -151,7 +189,7 @@ function isForeignTenantId(tenantIdValue: unknown): boolean {
151
189
  // `table.tenantId` property read — the latter only exists on branded EntityTables
152
190
  // and silently returned false (no tenant filter!) for plain EntityTableMeta
153
191
  // tables like unmanaged direct-write stores, e.g. userSessionTable.
154
- function hasTenantColumn(table: Table | EntityTableMeta): boolean {
192
+ export function hasTenantColumn(table: Table | EntityTableMeta): boolean {
155
193
  const meta = asEntityTableMeta(table);
156
194
  if (meta) return meta.columns.some((c) => c.name === "tenant_id");
157
195
  return (table as Record<string, unknown>)["tenantId"] !== undefined;
@@ -162,6 +200,7 @@ function hasTenantColumn(table: Table | EntityTableMeta): boolean {
162
200
  export type TenantDbGrants = {
163
201
  readonly globalWrites?: EscapeHatchDeclaration;
164
202
  readonly unsafeRaw?: EscapeHatchDeclaration;
203
+ readonly report?: EscapeHatchReporter;
165
204
  };
166
205
 
167
206
  const unsafeRawRebinders = new WeakMap<
@@ -179,6 +218,24 @@ export function withUnsafeRawGrant(
179
218
  return rebind ? rebind(grant) : tenantDb;
180
219
  }
181
220
 
221
+ const crossTenantRebinders = new WeakMap<TenantDb, (reason: string) => TenantDb>();
222
+
223
+ // Framework-private (not re-exported from db/index.ts): lifting the tenant filter needs a registered declaration.
224
+ export function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string): TenantDb {
225
+ if (reason.trim().length === 0) {
226
+ throw new Error("acknowledgeConventionCrossTenant requires a non-empty reason");
227
+ }
228
+ const rebind = crossTenantRebinders.get(tenantDb);
229
+ if (!rebind) {
230
+ throw new InternalError({
231
+ message:
232
+ "acknowledgeConventionCrossTenant received a TenantDb not built by createTenantDb — " +
233
+ "no cross-tenant rebinder bound.",
234
+ });
235
+ }
236
+ return rebind(reason);
237
+ }
238
+
182
239
  export function createTenantDb(
183
240
  db: DbRunner,
184
241
  tenantId: TenantId,
@@ -189,6 +246,7 @@ export function createTenantDb(
189
246
  grants?: TenantDbGrants,
190
247
  ): TenantDb {
191
248
  if (meter) registerStandardMetrics(meter);
249
+ const report = grants?.report ?? fallbackEscapeHatchReporter(tenantId);
192
250
 
193
251
  function withDbSpan<T>(
194
252
  operation: "select" | "insert" | "update" | "delete",
@@ -281,6 +339,10 @@ export function createTenantDb(
281
339
  });
282
340
  }
283
341
 
342
+ function globalWriteReason(): string {
343
+ return grants?.globalWrites?.reason ?? "";
344
+ }
345
+
284
346
  function foreignTenantOnGlobalWrite(
285
347
  table: Table | EntityTableMeta,
286
348
  tenantIdValue: unknown,
@@ -315,6 +377,7 @@ export function createTenantDb(
315
377
  const denied =
316
378
  missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
317
379
  if (denied) return Promise.reject(denied);
380
+ report("global-write", globalWriteReason());
318
381
  return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
319
382
  },
320
383
  updateMany<T = Record<string, unknown>>(
@@ -331,6 +394,7 @@ export function createTenantDb(
331
394
  ),
332
395
  );
333
396
  }
397
+ report("global-write", globalWriteReason());
334
398
  return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, where));
335
399
  },
336
400
  deleteMany(where: WhereObject): Promise<void> {
@@ -343,31 +407,34 @@ export function createTenantDb(
343
407
  ),
344
408
  );
345
409
  }
410
+ report("global-write", globalWriteReason());
346
411
  return withDbSpan("delete", table, async () => bunDeleteMany(db, table, where));
347
412
  },
348
413
  // @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
349
414
  } as GlobalTableDb<TTable>;
350
415
  }
351
416
 
417
+ function grantedUnsafeRawRunner(reason: string): DbRunner {
418
+ if (reason.trim().length === 0) {
419
+ throw new Error("unsafeRaw requires a non-empty reason");
420
+ }
421
+ if (!hasGrant(grants?.unsafeRaw)) {
422
+ throw new AccessDeniedError({
423
+ message:
424
+ 'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
425
+ "the handler or hook to allow unsafeRaw.",
426
+ });
427
+ }
428
+ report("unsafe-raw", reason);
429
+ return db;
430
+ }
431
+
352
432
  const tenantDb: TenantDb = {
353
433
  tenantId,
354
434
  mode,
355
- raw: db,
356
435
  global: globalTable,
357
436
 
358
- unsafeRaw(reason: string): DbRunner {
359
- if (reason.trim().length === 0) {
360
- throw new Error("unsafeRaw requires a non-empty reason");
361
- }
362
- if (!hasGrant(grants?.unsafeRaw)) {
363
- throw new AccessDeniedError({
364
- message:
365
- 'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
366
- "the handler or hook to allow unsafeRaw.",
367
- });
368
- }
369
- return db;
370
- },
437
+ unsafeRaw: grantedUnsafeRawRunner,
371
438
 
372
439
  selectMany<T = Record<string, unknown>>(
373
440
  table: Table | EntityTableMeta,
@@ -436,9 +503,15 @@ export function createTenantDb(
436
503
  },
437
504
  };
438
505
 
506
+ declaredUnsafeRawRunners.set(tenantDb, grantedUnsafeRawRunner);
439
507
  unsafeRawRebinders.set(tenantDb, (grant) =>
440
508
  createTenantDb(db, tenantId, mode, tracer, meter, signal, { ...grants, unsafeRaw: grant }),
441
509
  );
510
+ crossTenantRebinders.set(tenantDb, (reason) => {
511
+ report("acknowledge-cross-tenant", reason);
512
+ return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
513
+ });
514
+ bindTenantDbRunner(tenantDb, db);
442
515
  return tenantDb;
443
516
  }
444
517
 
@@ -38,7 +38,7 @@ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
38
38
  );
39
39
  return { isSuccess: true as const, data: result };
40
40
  },
41
- { access: { openToAll: true } },
41
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
42
42
  );
43
43
 
44
44
  // Catches the error INSIDE the handler (not via HTTP serialization) so the
@@ -69,7 +69,7 @@ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
69
69
  };
70
70
  }
71
71
  },
72
- { access: { openToAll: true } },
72
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
73
73
  );
74
74
 
75
75
  r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload, ctx) => {
@@ -47,7 +47,6 @@ function fakeDbWithFileRef(row: { storageKey: string; mimeType: string }): unkno
47
47
  isDeleted: false,
48
48
  };
49
49
  return {
50
- raw: { unsafe: () => {} },
51
50
  tenantId: TENANT_ID,
52
51
  selectMany: async (_table: unknown, where?: Record<string, unknown>) => {
53
52
  const matches = Object.entries(where ?? {}).every(([key, value]) => canned[key] === value);
@@ -19,10 +19,10 @@ function dashboardFeature(
19
19
  ) {
20
20
  return defineFeature("demo", (r) => {
21
21
  r.queryHandler("incident:open-count", z.object({}), async () => ({ count: 3 }), {
22
- access: { openToAll: true },
22
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
23
23
  });
24
24
  r.queryHandler("incident:latest", z.object({}), async () => ({ rows: [], nextCursor: null }), {
25
- access: { openToAll: true },
25
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
26
26
  });
27
27
  r.screen({
28
28
  id: "overview",
@@ -245,10 +245,10 @@ describe("validateBoot — dashboard screens", () => {
245
245
 
246
246
  const catalogFeature = defineFeature("catalog", (r) => {
247
247
  r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
248
- access: { openToAll: true },
248
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
249
249
  });
250
250
  r.queryHandler("items:status", z.object({}), async () => ({ enabled: true }), {
251
- access: { openToAll: true },
251
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
252
252
  });
253
253
  r.screen({
254
254
  id: "items",
@@ -289,7 +289,7 @@ describe("validateBoot — dashboard screen panels (fw#2841)", () => {
289
289
  test("accepts a same-feature short id", () => {
290
290
  const feature = defineFeature("demo", (r) => {
291
291
  r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
292
- access: { openToAll: true },
292
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
293
293
  });
294
294
  r.screen({
295
295
  id: "items",
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ describe("validateBoot — every screen must resolve its own nav area", () => {
13
+ test("a screen with no nav entry, no listScreenId and no resolvable parent → Throw naming listScreenId", () => {
14
+ const feature = defineFeature("app", (r) => {
15
+ r.nav({ id: "home", label: "app.nav.home" });
16
+ r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
17
+ });
18
+ expect(() => validateBoot([feature])).toThrow(/no nav entry and no resolvable list/);
19
+ expect(() => validateBoot([feature])).toThrow(/listScreenId/);
20
+ });
21
+
22
+ test("the same screen with listScreenId set → no throw", () => {
23
+ const feature = defineFeature("app", (r) => {
24
+ r.nav({ id: "home", label: "app.nav.home" });
25
+ r.screen({
26
+ id: "orphan-list",
27
+ type: "custom",
28
+ renderer: { react: "List" },
29
+ dormant: true,
30
+ });
31
+ r.screen({
32
+ id: "orphan",
33
+ type: "custom",
34
+ renderer: { react: "Orphan" },
35
+ listScreenId: "orphan-list",
36
+ });
37
+ });
38
+ expect(() => validateBoot([feature])).not.toThrow();
39
+ });
40
+
41
+ test("resolved via a list screen's rowAction navigate target → no throw", () => {
42
+ const feature = defineFeature("app", (r) => {
43
+ r.queryHandler("orphan:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
44
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
45
+ });
46
+ r.nav({ id: "home", label: "app.nav.home", screen: "app:screen:orphan-list" });
47
+ r.screen({
48
+ id: "orphan-list",
49
+ type: "projectionList",
50
+ query: "app:query:orphan:list",
51
+ columns: ["name"],
52
+ rowActions: [{ kind: "navigate", id: "open", label: "Open", screen: "orphan" }],
53
+ });
54
+ r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
55
+ });
56
+ expect(() => validateBoot([feature])).not.toThrow();
57
+ });
58
+
59
+ test("resolved via a list screen's toolbarAction navigate target → no throw", () => {
60
+ const feature = defineFeature("app", (r) => {
61
+ r.queryHandler("orphan:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
62
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
63
+ });
64
+ r.nav({ id: "home", label: "app.nav.home", screen: "app:screen:orphan-list" });
65
+ r.screen({
66
+ id: "orphan-list",
67
+ type: "projectionList",
68
+ query: "app:query:orphan:list",
69
+ columns: ["name"],
70
+ toolbarActions: [{ kind: "navigate", id: "create", label: "Create", screen: "orphan" }],
71
+ });
72
+ r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
73
+ });
74
+ expect(() => validateBoot([feature])).not.toThrow();
75
+ });
76
+
77
+ test("resolved via an entityList screen of the same entity → no throw", () => {
78
+ const feature = defineFeature("app", (r) => {
79
+ r.entity(
80
+ "widget",
81
+ createEntity({
82
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
83
+ }),
84
+ );
85
+ r.nav({ id: "home", label: "app.nav.home", screen: "app:screen:widget-list" });
86
+ r.screen({ id: "widget-list", type: "entityList", entity: "widget", columns: ["name"] });
87
+ r.screen({
88
+ id: "widget-edit",
89
+ type: "entityEdit",
90
+ entity: "widget",
91
+ layout: { sections: [{ fields: ["name"] }] },
92
+ });
93
+ });
94
+ expect(() => validateBoot([feature])).not.toThrow();
95
+ });
96
+
97
+ test("dormant: true → no throw even without any other resolution", () => {
98
+ const feature = defineFeature("app", (r) => {
99
+ r.nav({ id: "home", label: "app.nav.home" });
100
+ r.screen({
101
+ id: "orphan",
102
+ type: "custom",
103
+ renderer: { react: "Orphan" },
104
+ dormant: true,
105
+ });
106
+ });
107
+ expect(() => validateBoot([feature])).not.toThrow();
108
+ });
109
+
110
+ test("a feature set with no nav entries at all (sample/recipe) → the check does not run", () => {
111
+ const feature = defineFeature("recipe", (r) => {
112
+ r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
113
+ });
114
+ expect(() => validateBoot([feature])).not.toThrow();
115
+ });
116
+ });
@@ -50,7 +50,7 @@ function stubListHandler(r: any, entityName: string): void {
50
50
  name: `${entityName}:list`,
51
51
  schema: z.object({}),
52
52
  handler: async () => ({ rows: [], nextCursor: null }) as never,
53
- access: { openToAll: true },
53
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
54
54
  });
55
55
  }
56
56