@cosmicdrift/kumiko-framework 0.270.0 → 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 (104) 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 +91 -0
  13. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  14. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  15. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  16. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  17. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  18. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  19. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  20. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  21. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  22. package/src/db/apply-entity-event.ts +1 -1
  23. package/src/db/assert-exists-in.ts +1 -1
  24. package/src/db/event-store-executor-context.ts +5 -2
  25. package/src/db/event-store-executor-read.ts +6 -4
  26. package/src/db/event-store-executor-write.ts +24 -18
  27. package/src/db/queries/shadow-swap.ts +36 -0
  28. package/src/db/replay-migration-sql.ts +5 -5
  29. package/src/db/tenant-db-runner.ts +23 -0
  30. package/src/db/tenant-db.ts +97 -24
  31. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  32. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  33. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  34. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  35. package/src/engine/__tests__/boot-validator.test.ts +51 -0
  36. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  37. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  38. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  39. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  40. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  41. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  42. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  43. package/src/engine/__tests__/screen.test.ts +150 -0
  44. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  45. package/src/engine/__tests__/steps-read.test.ts +204 -8
  46. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  47. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  48. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  49. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  50. package/src/engine/boot-validator/entity-handler.ts +21 -0
  51. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  52. package/src/engine/boot-validator/parent-ref.ts +1 -1
  53. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  54. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  55. package/src/engine/boot-validator/screens.ts +154 -31
  56. package/src/engine/build-config-feature-schema.ts +7 -4
  57. package/src/engine/entity-handlers.ts +71 -19
  58. package/src/engine/feature-entity-handlers.ts +4 -4
  59. package/src/engine/index.ts +5 -0
  60. package/src/engine/registry-validate.ts +12 -4
  61. package/src/engine/schema-builder.ts +6 -0
  62. package/src/engine/screen-helpers.ts +52 -0
  63. package/src/engine/steps/_read-source.ts +12 -0
  64. package/src/engine/steps/read-find-many.ts +8 -2
  65. package/src/engine/steps/read-find-one.ts +11 -8
  66. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  67. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  68. package/src/engine/types/index.ts +8 -1
  69. package/src/errors/i18n/de.yaml +14 -0
  70. package/src/errors/i18n/en.yaml +13 -0
  71. package/src/errors/reasons.ts +4 -0
  72. package/src/i18n/required-surface-keys.ts +6 -2
  73. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  74. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  75. package/src/jobs/job-runner.ts +35 -5
  76. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  77. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  78. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  79. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  80. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  81. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  82. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  83. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  84. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  85. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  86. package/src/pipeline/dispatch-query.ts +27 -8
  87. package/src/pipeline/dispatch-shared.ts +141 -57
  88. package/src/pipeline/dispatch-stream.ts +1 -3
  89. package/src/pipeline/dispatch-write.ts +62 -14
  90. package/src/pipeline/dispatcher.ts +20 -0
  91. package/src/pipeline/escape-hatch-report.ts +127 -0
  92. package/src/pipeline/index.ts +8 -0
  93. package/src/pipeline/member-read-only-transaction.ts +75 -0
  94. package/src/pipeline/msp-rebuild.ts +2 -0
  95. package/src/pipeline/projection-rebuild.ts +2 -0
  96. package/src/pipeline/projections-runner.ts +15 -13
  97. package/src/pipeline/system-identity-switch.ts +121 -9
  98. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  99. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  100. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  101. package/src/ui-types/index.ts +3 -0
  102. package/src/utils/__tests__/ids.test.ts +29 -1
  103. package/src/utils/ids.ts +10 -1
  104. package/src/utils/index.ts +1 -1
@@ -30,6 +30,7 @@ import {
30
30
  tryMapUniqueViolation,
31
31
  } from "./event-store-executor-context";
32
32
  import { runInSavepointIfSupported } from "./query";
33
+ import { tenantDbRunner } from "./tenant-db-runner";
33
34
 
34
35
  // Art. 17 erasure runs as the framework operator, not as a row owner; a
35
36
  // per-role ownership map can never cover it, and a silent deny means the
@@ -117,6 +118,7 @@ export function createWriteVerbs(
117
118
 
118
119
  return {
119
120
  async create(payload, user, db, options) {
121
+ const runner = tenantDbRunner(db);
120
122
  if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
121
123
  throw new AccessDeniedError({
122
124
  message:
@@ -215,9 +217,9 @@ export function createWriteVerbs(
215
217
  // of the version_conflict this catch classifies. runInSavepointIfSupported
216
218
  // confines the failed INSERT to a nested scope that rolls back on
217
219
  // its own (same pattern as ctx.tryAppendEvent), and falls back to a
218
- // plain call when db.raw is a bare pool connection with no active
220
+ // plain call when the runner is a bare pool connection with no active
219
221
  // transaction to poison (seeds/tests calling the executor directly).
220
- event = await runInSavepointIfSupported(db.raw, async (sp) =>
222
+ event = await runInSavepointIfSupported(runner, async (sp) =>
221
223
  append(sp, {
222
224
  aggregateId,
223
225
  aggregateType: entityName,
@@ -232,7 +234,7 @@ export function createWriteVerbs(
232
234
  if (e instanceof EventStoreVersionConflict) {
233
235
  let currentVersion = -1;
234
236
  try {
235
- currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
237
+ currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user));
236
238
  } catch {
237
239
  // Lookup failure — keep the sentinel.
238
240
  }
@@ -268,7 +270,7 @@ export function createWriteVerbs(
268
270
  // failed INSERT back; the error still propagates and stays catchable.
269
271
  let result: Awaited<ReturnType<typeof applyEntityEvent>>;
270
272
  try {
271
- result = await runInSavepointIfSupported(db.raw, async (sp) =>
273
+ result = await runInSavepointIfSupported(runner, async (sp) =>
272
274
  applyEntityEvent(event, table, entity, sp),
273
275
  );
274
276
  } catch (e) {
@@ -308,6 +310,7 @@ export function createWriteVerbs(
308
310
  },
309
311
 
310
312
  async update(payload, user, db, updateOptions) {
313
+ const runner = tenantDbRunner(db);
311
314
  if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
312
315
  throw new AccessDeniedError({
313
316
  message:
@@ -397,7 +400,7 @@ export function createWriteVerbs(
397
400
  // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
398
401
  // with version_conflict.
399
402
  const currentVersion = await getStreamVersion(
400
- db.raw,
403
+ runner,
401
404
  String(payload.id),
402
405
  streamTenantFor(user),
403
406
  );
@@ -452,7 +455,7 @@ export function createWriteVerbs(
452
455
  // Savepoint-scoped — see the create() append() above for why:
453
456
  // confines a losing writer's unique-violation to a nested scope
454
457
  // instead of poisoning the whole outer transaction.
455
- const event = await runInSavepointIfSupported(db.raw, (sp) =>
458
+ const event = await runInSavepointIfSupported(runner, (sp) =>
456
459
  append(sp, {
457
460
  aggregateId: String(payload.id),
458
461
  aggregateType: entityName,
@@ -480,7 +483,7 @@ export function createWriteVerbs(
480
483
  // failed nested one throws 500 instead of failing cleanly.
481
484
  let result: Awaited<ReturnType<typeof applyEntityEvent>>;
482
485
  try {
483
- result = await runInSavepointIfSupported(db.raw, async (sp) =>
486
+ result = await runInSavepointIfSupported(runner, async (sp) =>
484
487
  applyEntityEvent(event, table, entity, sp),
485
488
  );
486
489
  } catch (e) {
@@ -539,6 +542,7 @@ export function createWriteVerbs(
539
542
  },
540
543
 
541
544
  async delete(payload, user, db) {
545
+ const runner = tenantDbRunner(db);
542
546
  const existing = await loadById(payload.id, db);
543
547
  if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
544
548
 
@@ -565,7 +569,7 @@ export function createWriteVerbs(
565
569
 
566
570
  // Stream-version authoritative (see update() for rationale).
567
571
  const currentVersion = await getStreamVersion(
568
- db.raw,
572
+ runner,
569
573
  String(payload.id),
570
574
  streamTenantFor(user),
571
575
  );
@@ -578,7 +582,7 @@ export function createWriteVerbs(
578
582
  // land in the immutable log.
579
583
  let event: Awaited<ReturnType<typeof append>>;
580
584
  try {
581
- event = await runInSavepointIfSupported(db.raw, async (sp) =>
585
+ event = await runInSavepointIfSupported(runner, async (sp) =>
582
586
  append(sp, {
583
587
  aggregateId: String(payload.id),
584
588
  aggregateType: entityName,
@@ -606,7 +610,7 @@ export function createWriteVerbs(
606
610
  }
607
611
 
608
612
  // Live==Rebuild via applyEntityEvent. Savepoint like create/update.
609
- const deleteResult = await runInSavepointIfSupported(db.raw, async (sp) =>
613
+ const deleteResult = await runInSavepointIfSupported(runner, async (sp) =>
610
614
  applyEntityEvent(event, table, entity, sp),
611
615
  );
612
616
  if (deleteResult.kind !== "applied") {
@@ -636,6 +640,7 @@ export function createWriteVerbs(
636
640
  // the erasure replays on rebuild (created → forgotten → row gone). Loads
637
641
  // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
638
642
  async forget(payload, user, db) {
643
+ const runner = tenantDbRunner(db);
639
644
  const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
640
645
  if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
641
646
  const existing = await decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
@@ -660,14 +665,14 @@ export function createWriteVerbs(
660
665
 
661
666
  await assertStreamWritable(db, payload.id, streamTenantFor(user));
662
667
  const currentVersion = await getStreamVersion(
663
- db.raw,
668
+ runner,
664
669
  String(payload.id),
665
670
  streamTenantFor(user),
666
671
  );
667
672
 
668
673
  let event: Awaited<ReturnType<typeof append>>;
669
674
  try {
670
- event = await runInSavepointIfSupported(db.raw, async (sp) =>
675
+ event = await runInSavepointIfSupported(runner, async (sp) =>
671
676
  append(sp, {
672
677
  aggregateId: String(payload.id),
673
678
  aggregateType: entityName,
@@ -696,7 +701,7 @@ export function createWriteVerbs(
696
701
  throw e;
697
702
  }
698
703
 
699
- const forgetResult = await runInSavepointIfSupported(db.raw, async (sp) =>
704
+ const forgetResult = await runInSavepointIfSupported(runner, async (sp) =>
700
705
  applyEntityEvent(event, table, entity, sp),
701
706
  );
702
707
  if (forgetResult.kind !== "applied") {
@@ -722,6 +727,7 @@ export function createWriteVerbs(
722
727
  },
723
728
 
724
729
  async restore(payload, user, db) {
730
+ const runner = tenantDbRunner(db);
725
731
  if (!softDelete) {
726
732
  return writeFailure(
727
733
  new UnprocessableError("soft_delete_not_enabled", {
@@ -731,8 +737,8 @@ export function createWriteVerbs(
731
737
  }
732
738
 
733
739
  // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
734
- // selectMany(db.raw, ...) did not — any caller could un-delete a foreign
735
- // tenant's row by id. "system"-mode dbs (r.systemScope() / crossTenant
740
+ // selectMany(runner, ...) did not — any caller could un-delete a foreign
741
+ // tenant's row by id. "system"-mode dbs (r.systemScope() / escapeHatch
736
742
  // handlers) still read unfiltered. No isDeleted filter here: restore
737
743
  // targets exactly the soft-deleted row.
738
744
  const row = await db.fetchOne(table, { id: payload.id });
@@ -766,7 +772,7 @@ export function createWriteVerbs(
766
772
 
767
773
  // Stream-version authoritative (see update() for rationale).
768
774
  const currentVersion = await getStreamVersion(
769
- db.raw,
775
+ runner,
770
776
  String(payload.id),
771
777
  streamTenantFor(user),
772
778
  );
@@ -777,7 +783,7 @@ export function createWriteVerbs(
777
783
  // already ciphertext, no re-encrypt needed.
778
784
  let event: Awaited<ReturnType<typeof append>>;
779
785
  try {
780
- event = await runInSavepointIfSupported(db.raw, (sp) =>
786
+ event = await runInSavepointIfSupported(runner, (sp) =>
781
787
  append(sp, {
782
788
  aggregateId: String(payload.id),
783
789
  aggregateType: entityName,
@@ -807,7 +813,7 @@ export function createWriteVerbs(
807
813
  // Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
808
814
  // plus the version bump, so there is no sensitive-field drift and no
809
815
  // payload override is needed.
810
- const restoreResult = await runInSavepointIfSupported(db.raw, async (sp) =>
816
+ const restoreResult = await runInSavepointIfSupported(runner, async (sp) =>
811
817
  applyEntityEvent(event, table, entity, sp),
812
818
  );
813
819
  if (restoreResult.kind !== "applied" || restoreResult.row === null) {
@@ -105,6 +105,40 @@ export async function assertLiveColumnsMatchMeta(
105
105
  );
106
106
  }
107
107
 
108
+ // EntityTableMeta carries no RLS/policies, so the swap would silently drop them (#2907).
109
+ export async function assertLiveTableHasNoRowLevelSecurity(
110
+ tx: AnyDb,
111
+ tableName: string,
112
+ projectionName?: string,
113
+ ): Promise<void> {
114
+ const rows = await asRawClient(tx).unsafe<{
115
+ rls_enabled: boolean;
116
+ rls_forced: boolean;
117
+ policy_count: number;
118
+ }>(
119
+ `SELECT c.relrowsecurity AS rls_enabled, c.relforcerowsecurity AS rls_forced,
120
+ (SELECT count(*)::int FROM pg_policy p WHERE p.polrelid = c.oid) AS policy_count
121
+ FROM pg_class c
122
+ JOIN pg_namespace n ON n.oid = c.relnamespace
123
+ WHERE n.nspname = 'public' AND c.relname = $1`,
124
+ [tableName],
125
+ );
126
+ const row = rows[0];
127
+ // skip: no live table yet — nothing to have RLS on
128
+ if (!row) return;
129
+ // skip: no RLS flags and no policies — nothing the swap could drop
130
+ if (!row.rls_enabled && !row.rls_forced && row.policy_count === 0) return;
131
+ const context =
132
+ projectionName !== undefined ? `projection-rebuild "${projectionName}"` : "shadow swap";
133
+ throw new Error(
134
+ `${context}: live table "${tableName}" has row level security (enabled: ${row.rls_enabled}, ` +
135
+ `forced: ${row.rls_forced}, policies: ${row.policy_count}). The shadow swap rebuilds the table ` +
136
+ "from EntityTableMeta and would silently drop RLS and every policy. Kumiko does not support RLS " +
137
+ "on rebuildable tables — see kumiko-platform docs/plans/rls-evaluation.md. Rebuild aborted; live " +
138
+ "table untouched.",
139
+ );
140
+ }
141
+
108
142
  // Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
109
143
  // Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
110
144
  // rollback), drops any leftover shadow from a crashed run, then builds the
@@ -300,6 +334,8 @@ export async function countColumnDrift(
300
334
  // object depends on the live table the swap fails loud and the whole rebuild
301
335
  // rolls back, leaving the old table untouched.
302
336
  export async function swapShadowIntoLive(tx: AnyDb, tableName: string): Promise<void> {
337
+ // Re-checked right before DROP: RLS could have been enabled after the early check.
338
+ await assertLiveTableHasNoRowLevelSecurity(tx, tableName);
303
339
  const raw = asRawClient(tx);
304
340
  const ident = quoteTableIdent(tableName);
305
341
  await raw.unsafe(`DROP TABLE public.${ident}`);
@@ -84,7 +84,7 @@ const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp(
84
84
  `ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
85
85
  `ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
86
86
  `(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
87
- `\\b(ENABLE|DISABLE)\\s+ROW LEVEL SECURITY\\b`,
87
+ `\\b(ENABLE|DISABLE|(NO\\s+)?FORCE)\\s+ROW LEVEL SECURITY\\b`,
88
88
  `^OWNER TO\\b`,
89
89
  ].join("|"),
90
90
  "gi",
@@ -149,9 +149,9 @@ function applyStatement(
149
149
  else table.columns.delete(name);
150
150
  }
151
151
  // Shape-neutral clauses (ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP
152
- // DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER
153
- // TO) — none add/remove/rename a column, so this replay (which only
154
- // tracks column presence) correctly has nothing to do for them.
152
+ // DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE/[NO] FORCE ROW LEVEL
153
+ // SECURITY, OWNER TO) — none add/remove/rename a column, so this replay
154
+ // (which only tracks column presence) correctly has nothing to do for them.
155
155
  if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE)) matchedAClause = true;
156
156
  // An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
157
157
  // but whose body contains no recognized clause (e.g. RENAME TO/RENAME
@@ -177,7 +177,7 @@ function applyStatement(
177
177
  `starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
178
178
  `recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
179
179
  `COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
180
- `ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER TO — ` +
180
+ `ADD/DROP CONSTRAINT, ENABLE/DISABLE/[NO] FORCE ROW LEVEL SECURITY, OWNER TO — ` +
181
181
  `optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
182
182
  `identity change, not trackable here) or genuinely unparsed hand-written ` +
183
183
  `DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`,
@@ -0,0 +1,23 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
+ import { InternalError } from "../errors";
3
+ import type { DbRunner } from "./connection";
4
+
5
+ const tenantDbRunners = new WeakMap<TenantDb, DbRunner>();
6
+
7
+ export function bindTenantDbRunner(tenantDb: TenantDb, runner: DbRunner): void {
8
+ tenantDbRunners.set(tenantDb, runner);
9
+ }
10
+
11
+ // WeakMap.get never reads a property off tenantDb — safe even when tenantDb is a
12
+ // throwing systemScope guard Proxy that rejects every property access.
13
+ export function tenantDbRunner(tenantDb: TenantDb): DbRunner {
14
+ const runner = tenantDbRunners.get(tenantDb);
15
+ if (!runner) {
16
+ throw new InternalError({
17
+ message:
18
+ "framework infrastructure received a TenantDb not built by createTenantDb (or a " +
19
+ "fail-closed systemScope guard) — no connection bound.",
20
+ });
21
+ }
22
+ return runner;
23
+ }
@@ -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
 
@@ -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);
@@ -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
+ });