@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,19 +1,23 @@
1
- // fw#2650 — crossTenant on write handlers. A SystemAdmin operator writes a
2
- // row that lives in a different tenant than their own session. That needs
3
- // two things: (1) an unfiltered db instead of the caller's tenant-scoped
4
- // one (so the row is even visible), and (2) the event-store stream AND the
5
- // entity's tenant-based write-ownership rule — both keyed off the ACTING
6
- // user, not the db — so the acting user's tenantId must be rewritten to the
7
- // target row's tenant before the write, not just handed an unfiltered db.
1
+ // fw#2650/fw#2915 — crossTenant / escapeHatch on entity convention handlers. A
2
+ // SystemAdmin operator reads/writes a row that lives in a different tenant
3
+ // than their own session. That needs two things: (1) an unfiltered db
4
+ // instead of the caller's tenant-scoped one (so the row is even visible),
5
+ // and (2) the event-store stream AND the entity's tenant-based
6
+ // write-ownership rule — both keyed off the ACTING user, not the db — so the
7
+ // acting user's tenantId must be rewritten to the target row's tenant before
8
+ // the write, not just handed an unfiltered db.
8
9
  //
9
- // Two stacks on the SAME Postgres database: one registers the update
10
- // handler without `crossTenant`, the other with it — a clean A/B on the one
11
- // flag, no handler-name collisions to work around.
10
+ // Three stacks on the SAME Postgres database: "none" (no cross-tenant
11
+ // option), "legacy" (`crossTenant: true`), "escapeHatch"
12
+ // (`escapeHatch: { reason }`) — a clean A/B/C on the one option, no
13
+ // handler-name collisions to work around.
12
14
 
13
15
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
+ import type { EscapeHatchUseEvent } from "@cosmicdrift/kumiko-types/handlers";
14
17
  import { selectMany } from "../../db/query";
15
18
  import { buildEntityTable } from "../../db/table-builder";
16
19
  import { eventsTable } from "../../event-store";
20
+ import type { Logger } from "../../logging/types";
17
21
  import {
18
22
  setupTestStack,
19
23
  type TestStack,
@@ -21,10 +25,12 @@ import {
21
25
  testTenantId,
22
26
  unsafeCreateEntityTable,
23
27
  } from "../../stack";
28
+ import { DEPRECATED_CROSS_TENANT_SIGNAL } from "../entity-handlers";
24
29
  import {
25
30
  createEntity,
26
31
  createTextField,
27
32
  defineEntityCreateHandler,
33
+ defineEntityListHandler,
28
34
  defineEntityUpdateHandler,
29
35
  defineFeature,
30
36
  from,
@@ -32,9 +38,9 @@ import {
32
38
 
33
39
  // "Admin" passes unconditionally (any tenant may create its own rows).
34
40
  // "SystemAdmin" is tenant-scoped — this is the entity-level rule the
35
- // crossTenant acting-user rewrite must satisfy for the operator write to
36
- // go through; without the rewrite the acting user's own tenant would never
37
- // match a foreign row's tenantId here.
41
+ // crossTenant/escapeHatch acting-user rewrite must satisfy for the operator
42
+ // write to go through; without the rewrite the acting user's own tenant
43
+ // would never match a foreign row's tenantId here.
38
44
  const thingEntity = createEntity({
39
45
  table: "ctwrite_things",
40
46
  fields: {
@@ -44,7 +50,22 @@ const thingEntity = createEntity({
44
50
  });
45
51
  const thingTable = buildEntityTable("thing", thingEntity);
46
52
 
47
- function buildFeature(crossTenant: boolean) {
53
+ const ESCAPE_HATCH_REASON = "fw#2915 integration test — operator cross-tenant scan";
54
+
55
+ type CrossTenantMode = "none" | "legacy" | "escapeHatch";
56
+
57
+ function crossTenantOptionsFor(mode: CrossTenantMode) {
58
+ switch (mode) {
59
+ case "legacy":
60
+ return { crossTenant: true as const };
61
+ case "escapeHatch":
62
+ return { escapeHatch: { reason: ESCAPE_HATCH_REASON } };
63
+ case "none":
64
+ return {};
65
+ }
66
+ }
67
+
68
+ function buildFeature(mode: CrossTenantMode) {
48
69
  return defineFeature("ctwrite", (r) => {
49
70
  r.entity("thing", thingEntity);
50
71
  r.writeHandler(
@@ -55,7 +76,13 @@ function buildFeature(crossTenant: boolean) {
55
76
  r.writeHandler(
56
77
  defineEntityUpdateHandler("thing", thingEntity, {
57
78
  access: { roles: ["SystemAdmin"] },
58
- ...(crossTenant && { crossTenant: true }),
79
+ ...crossTenantOptionsFor(mode),
80
+ }),
81
+ );
82
+ r.queryHandler(
83
+ defineEntityListHandler("thing", thingEntity, {
84
+ access: { roles: ["SystemAdmin"] },
85
+ ...crossTenantOptionsFor(mode),
59
86
  }),
60
87
  );
61
88
  });
@@ -63,48 +90,81 @@ function buildFeature(crossTenant: boolean) {
63
90
 
64
91
  const dbName = `kumiko_test_ctwrite_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
65
92
 
66
- let tenantScopedStack: TestStack;
67
- let crossTenantStack: TestStack;
93
+ function recordingSink(events: EscapeHatchUseEvent[]) {
94
+ return async (event: EscapeHatchUseEvent) => {
95
+ events.push(event);
96
+ };
97
+ }
98
+
99
+ function recordingLogger(sink: Array<{ msg: string; data?: Record<string, unknown> }>): Logger {
100
+ const log: Logger = {
101
+ info() {},
102
+ debug() {},
103
+ warn(msg, data) {
104
+ sink.push({ msg, data });
105
+ },
106
+ error() {},
107
+ child() {
108
+ return log;
109
+ },
110
+ };
111
+ return log;
112
+ }
113
+
114
+ let noneStack: TestStack;
115
+ let legacyStack: TestStack;
116
+ let escapeHatchStack: TestStack;
117
+
118
+ const legacyWarnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
119
+ const escapeHatchWarnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
120
+ const escapeHatchAuditEvents: EscapeHatchUseEvent[] = [];
68
121
 
69
122
  beforeAll(async () => {
70
- tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
71
- await unsafeCreateEntityTable(tenantScopedStack.db, thingEntity, "thing");
72
- // Second connection to the SAME database — persistentDb:true means its
73
- // cleanup() only closes the pool; tenantScopedStack (created without that
74
- // flag) owns the actual DROP DATABASE and must clean up last.
75
- crossTenantStack = await setupTestStack({
76
- features: [buildFeature(true)],
123
+ noneStack = await setupTestStack({ features: [buildFeature("none")], dbName });
124
+ await unsafeCreateEntityTable(noneStack.db, thingEntity, "thing");
125
+ // Second and third connections to the SAME database — persistentDb:true
126
+ // means their cleanup() only closes the pool; noneStack (created without
127
+ // that flag) owns the actual DROP DATABASE and must clean up last.
128
+ legacyStack = await setupTestStack({
129
+ features: [buildFeature("legacy")],
130
+ dbName,
131
+ persistentDb: true,
132
+ extraContext: { log: recordingLogger(legacyWarnCalls) },
133
+ });
134
+ escapeHatchStack = await setupTestStack({
135
+ features: [buildFeature("escapeHatch")],
77
136
  dbName,
78
137
  persistentDb: true,
138
+ extraContext: {
139
+ _escapeHatchAuditSink: recordingSink(escapeHatchAuditEvents),
140
+ log: recordingLogger(escapeHatchWarnCalls),
141
+ },
79
142
  });
80
143
  });
81
144
 
82
145
  afterAll(async () => {
83
- await crossTenantStack.cleanup();
84
- await tenantScopedStack.cleanup();
146
+ await escapeHatchStack.cleanup();
147
+ await legacyStack.cleanup();
148
+ await noneStack.cleanup();
85
149
  });
86
150
 
87
151
  async function updatedEventTenantId(aggregateId: string): Promise<string | undefined> {
88
- const rows = await selectMany<{ type: string; tenantId: string }>(
89
- tenantScopedStack.db,
90
- eventsTable,
91
- {
92
- aggregateId,
93
- },
94
- );
152
+ const rows = await selectMany<{ type: string; tenantId: string }>(noneStack.db, eventsTable, {
153
+ aggregateId,
154
+ });
95
155
  const updatedRow = rows.find((r) => r.type.includes("updated"));
96
156
  return updatedRow?.tenantId;
97
157
  }
98
158
 
99
- describe("entity write handlers: crossTenant option (fw#2650)", () => {
100
- test("crossTenant handler: SystemAdmin updates a row owned by another tenant", async () => {
101
- const created = await tenantScopedStack.http.writeOk<{ id: string }>(
159
+ describe("entity write/list handlers: crossTenant vs. escapeHatch (fw#2650/fw#2915)", () => {
160
+ test("legacy crossTenant handler: SystemAdmin updates a row owned by another tenant", async () => {
161
+ const created = await noneStack.http.writeOk<{ id: string }>(
102
162
  "ctwrite:write:thing:create",
103
163
  { label: "foreign" },
104
164
  TestUsers.otherTenant,
105
165
  );
106
166
 
107
- const updated = await crossTenantStack.http.writeOk<{ id: string; data: { label: string } }>(
167
+ const updated = await legacyStack.http.writeOk<{ id: string; data: { label: string } }>(
108
168
  "ctwrite:write:thing:update",
109
169
  { id: created.id, version: 1, changes: { label: "touched by operator" } },
110
170
  TestUsers.systemAdmin,
@@ -113,7 +173,7 @@ describe("entity write handlers: crossTenant option (fw#2650)", () => {
113
173
 
114
174
  // Row stays on its original tenant — crossTenant reaches across the
115
175
  // tenant boundary, it does not migrate the row.
116
- const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
176
+ const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
117
177
  expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
118
178
 
119
179
  // The append must land on the row's own tenant stream, not the acting
@@ -123,36 +183,135 @@ describe("entity write handlers: crossTenant option (fw#2650)", () => {
123
183
  expect(await updatedEventTenantId(created.id)).toBe(testTenantId(2));
124
184
  });
125
185
 
186
+ test("escapeHatch handler: SystemAdmin updates a row owned by another tenant, reported as acknowledge-cross-tenant", async () => {
187
+ escapeHatchAuditEvents.length = 0;
188
+ const created = await noneStack.http.writeOk<{ id: string }>(
189
+ "ctwrite:write:thing:create",
190
+ { label: "foreign-escape-hatch" },
191
+ TestUsers.otherTenant,
192
+ );
193
+
194
+ const updated = await escapeHatchStack.http.writeOk<{ id: string; data: { label: string } }>(
195
+ "ctwrite:write:thing:update",
196
+ { id: created.id, version: 1, changes: { label: "touched by operator via escapeHatch" } },
197
+ TestUsers.systemAdmin,
198
+ );
199
+ expect(updated.data.label).toBe("touched by operator via escapeHatch");
200
+
201
+ const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
202
+ expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
203
+ expect(await updatedEventTenantId(created.id)).toBe(testTenantId(2));
204
+
205
+ const matches = escapeHatchAuditEvents.filter((e) => e.kind === "acknowledge-cross-tenant");
206
+ expect(matches).toEqual([
207
+ {
208
+ handler: "ctwrite:write:thing:update",
209
+ kind: "acknowledge-cross-tenant",
210
+ reason: ESCAPE_HATCH_REASON,
211
+ tenantId: TestUsers.systemAdmin.tenantId,
212
+ actor: TestUsers.systemAdmin.id,
213
+ },
214
+ ]);
215
+ });
216
+
217
+ test("escapeHatch list: SystemAdmin sees rows from every tenant, reported as acknowledge-cross-tenant", async () => {
218
+ escapeHatchAuditEvents.length = 0;
219
+ await noneStack.http.writeOk(
220
+ "ctwrite:write:thing:create",
221
+ { label: "own-tenant-row" },
222
+ TestUsers.admin,
223
+ );
224
+ await noneStack.http.writeOk(
225
+ "ctwrite:write:thing:create",
226
+ { label: "other-tenant-row" },
227
+ TestUsers.otherTenant,
228
+ );
229
+
230
+ const list = await escapeHatchStack.http.queryOk<{
231
+ rows: ReadonlyArray<{ tenantId: string }>;
232
+ }>("ctwrite:query:thing:list", {}, TestUsers.systemAdmin);
233
+ const tenantIds = new Set(list.rows.map((r) => r.tenantId));
234
+ expect(tenantIds.has(testTenantId(1))).toBe(true);
235
+ expect(tenantIds.has(testTenantId(2))).toBe(true);
236
+
237
+ const matches = escapeHatchAuditEvents.filter((e) => e.kind === "acknowledge-cross-tenant");
238
+ expect(matches).toEqual([
239
+ {
240
+ handler: "ctwrite:query:thing:list",
241
+ kind: "acknowledge-cross-tenant",
242
+ reason: ESCAPE_HATCH_REASON,
243
+ tenantId: TestUsers.systemAdmin.tenantId,
244
+ actor: TestUsers.systemAdmin.id,
245
+ },
246
+ ]);
247
+ });
248
+
249
+ test("none: list returns only the caller's own tenant rows", async () => {
250
+ const created = await noneStack.http.writeOk<{ id: string }>(
251
+ "ctwrite:write:thing:create",
252
+ { label: "none-mode-own-tenant" },
253
+ TestUsers.admin,
254
+ );
255
+
256
+ const list = await noneStack.http.queryOk<{
257
+ rows: ReadonlyArray<{ id: string; tenantId: string }>;
258
+ }>("ctwrite:query:thing:list", {}, TestUsers.systemAdmin);
259
+ expect(list.rows.some((r) => r.id === created.id)).toBe(true);
260
+ expect(list.rows.every((r) => r.tenantId === testTenantId(1))).toBe(true);
261
+ });
262
+
126
263
  test("normal (non-crossTenant) handler cannot reach a row in another tenant", async () => {
127
- const created = await tenantScopedStack.http.writeOk<{ id: string }>(
264
+ const created = await noneStack.http.writeOk<{ id: string }>(
128
265
  "ctwrite:write:thing:create",
129
266
  { label: "foreign-untouched" },
130
267
  TestUsers.otherTenant,
131
268
  );
132
269
 
133
- const err = await tenantScopedStack.http.writeErr(
270
+ const err = await noneStack.http.writeErr(
134
271
  "ctwrite:write:thing:update",
135
272
  { id: created.id, version: 1, changes: { label: "nope" } },
136
273
  TestUsers.systemAdmin,
137
274
  );
138
275
  expect(err.code).toBe("not_found");
139
276
 
140
- const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
277
+ const rows = await selectMany(noneStack.db, thingTable, { id: created.id });
141
278
  expect(rows[0]?.["label"]).toBe("foreign-untouched");
142
279
  });
143
280
 
144
- test("crossTenant still gates on access — a role without SystemAdmin is denied", async () => {
145
- const created = await tenantScopedStack.http.writeOk<{ id: string }>(
281
+ test("crossTenant/escapeHatch still gate on access — a role without SystemAdmin is denied", async () => {
282
+ const createdLegacy = await noneStack.http.writeOk<{ id: string }>(
146
283
  "ctwrite:write:thing:create",
147
- { label: "foreign-denied" },
284
+ { label: "foreign-denied-legacy" },
148
285
  TestUsers.otherTenant,
149
286
  );
287
+ const errLegacy = await legacyStack.http.writeErr(
288
+ "ctwrite:write:thing:update",
289
+ { id: createdLegacy.id, version: 1, changes: { label: "nope" } },
290
+ TestUsers.admin,
291
+ );
292
+ expect(errLegacy.code).toBe("access_denied");
150
293
 
151
- const err = await crossTenantStack.http.writeErr(
294
+ const createdEscapeHatch = await noneStack.http.writeOk<{ id: string }>(
295
+ "ctwrite:write:thing:create",
296
+ { label: "foreign-denied-escape-hatch" },
297
+ TestUsers.otherTenant,
298
+ );
299
+ const errEscapeHatch = await escapeHatchStack.http.writeErr(
152
300
  "ctwrite:write:thing:update",
153
- { id: created.id, version: 1, changes: { label: "nope" } },
301
+ { id: createdEscapeHatch.id, version: 1, changes: { label: "nope" } },
154
302
  TestUsers.admin,
155
303
  );
156
- expect(err.code).toBe("access_denied");
304
+ expect(errEscapeHatch.code).toBe("access_denied");
305
+ });
306
+
307
+ test("legacy stack warns once per deprecated crossTenant handler at boot; the escapeHatch stack does not", () => {
308
+ const legacyMatches = legacyWarnCalls.filter((c) => c.msg === DEPRECATED_CROSS_TENANT_SIGNAL);
309
+ const legacyHandlers = legacyMatches.map((c) => c.data?.["handler"]).sort();
310
+ expect(legacyHandlers).toEqual(["ctwrite:query:thing:list", "ctwrite:write:thing:update"]);
311
+
312
+ const escapeHatchMatches = escapeHatchWarnCalls.filter(
313
+ (c) => c.msg === DEPRECATED_CROSS_TENANT_SIGNAL,
314
+ );
315
+ expect(escapeHatchMatches).toEqual([]);
157
316
  });
158
317
  });
@@ -142,7 +142,7 @@ describe("extendsRegistrar", () => {
142
142
  isSuccess: true as const,
143
143
  data: { id: "c1" },
144
144
  }),
145
- { access: { openToAll: true } },
145
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
146
146
  );
147
147
  r.useExtension("audited", "credit");
148
148
  });
@@ -181,7 +181,7 @@ describe("extendsRegistrar", () => {
181
181
  isSuccess: true as const,
182
182
  data: { id: "v1" },
183
183
  }),
184
- { access: { openToAll: true } },
184
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
185
185
  );
186
186
  r.writeHandler(
187
187
  "vehicle:update",
@@ -190,7 +190,7 @@ describe("extendsRegistrar", () => {
190
190
  isSuccess: true as const,
191
191
  data: { id: "v1" },
192
192
  }),
193
- { access: { openToAll: true } },
193
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
194
194
  );
195
195
  r.useExtension("audited", "vehicle");
196
196
  });
@@ -11,7 +11,9 @@ const taskEntity = createEntity({
11
11
  describe("r.crud", () => {
12
12
  test("registers the entity + full CRUD handler set, same as registerEntityCrud", () => {
13
13
  const write = { access: { roles: ["Admin"] } } as const;
14
- const read = { access: { openToAll: true } } as const;
14
+ const read = {
15
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
16
+ } as const;
15
17
 
16
18
  const feature = defineFeature("via-crud", (r) => {
17
19
  r.crud("task", taskEntity, { write, read });
@@ -28,7 +30,9 @@ describe("r.crud", () => {
28
30
 
29
31
  test("without verbAccess, write handlers keep write.access (never fall back to the broader read.access)", () => {
30
32
  const write = { access: { roles: ["Manager"] } } as const;
31
- const read = { access: { openToAll: true } } as const;
33
+ const read = {
34
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
35
+ } as const;
32
36
 
33
37
  const feature = defineFeature("via-crud-no-verb-access", (r) => {
34
38
  r.crud("task", taskEntity, { write, read });
@@ -44,7 +48,9 @@ describe("r.crud", () => {
44
48
 
45
49
  test("verbAccess overrides access per verb, other verbs keep write/read.access", () => {
46
50
  const write = { access: { roles: ["Manager"] } } as const;
47
- const read = { access: { openToAll: true } } as const;
51
+ const read = {
52
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
53
+ } as const;
48
54
  const deleteAccess = { roles: ["Operator"] } as const;
49
55
  const restoreAccess = { roles: ["Operator"] } as const;
50
56
  const listAccess = { roles: ["Auditor"] } as const;
@@ -13,7 +13,7 @@ const propertyEntity = createEntity({
13
13
  const propertyFeature = defineFeature("crud-verb-access", (r) => {
14
14
  r.crud("property", propertyEntity, {
15
15
  write: { access: { roles: ["User"] } },
16
- read: { access: { openToAll: true } },
16
+ read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
17
17
  verbAccess: {
18
18
  delete: { roles: ["Admin"] },
19
19
  restore: { roles: ["Admin"] },
@@ -27,7 +27,7 @@ describe("HookPhases defaults", () => {
27
27
  const feature = defineFeature("test", (r) => {
28
28
  r.entity("thing", createEntity({ table: "things", fields: {} }));
29
29
  r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
30
- access: { openToAll: true },
30
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
31
31
  });
32
32
  r.hook("postSave", "thing:create", noopSave);
33
33
  });
@@ -41,7 +41,7 @@ describe("HookPhases defaults", () => {
41
41
  const feature = defineFeature("test", (r) => {
42
42
  r.entity("thing", createEntity({ table: "things", fields: {} }));
43
43
  r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
44
- access: { openToAll: true },
44
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
45
45
  });
46
46
  r.hook("postSave", "thing:create", noopSave, { phase: HookPhases.inTransaction });
47
47
  });
@@ -60,7 +60,7 @@ describe("HookPhases defaults", () => {
60
60
  isSuccess: true,
61
61
  data: null,
62
62
  }),
63
- { access: { openToAll: true } },
63
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
64
64
  );
65
65
  r.hook("preDelete", "thing:delete", async () => undefined);
66
66
  });
@@ -95,7 +95,7 @@ describe("Registry phase filtering", () => {
95
95
  const feature = defineFeature("test", (r) => {
96
96
  r.entity("thing", createEntity({ table: "things", fields: {} }));
97
97
  r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
98
- access: { openToAll: true },
98
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
99
99
  });
100
100
  r.hook("postSave", "thing:create", inTxFn, { phase: HookPhases.inTransaction });
101
101
  r.hook("postSave", "thing:create", afterFn); // default afterCommit
@@ -127,7 +127,7 @@ describe("Registry phase filtering", () => {
127
127
  const feature = defineFeature("test", (r) => {
128
128
  r.entity("thing", createEntity({ table: "things", fields: {} }));
129
129
  r.writeHandler("thing:create", z.object({}), async () => ({ isSuccess: true, data: null }), {
130
- access: { openToAll: true },
130
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
131
131
  });
132
132
  });
133
133
 
@@ -69,7 +69,9 @@ describe("lifecycle hooks in registry", () => {
69
69
  test("merges preSave hooks within same feature", () => {
70
70
  const f1 = defineFeature("a", (r) => {
71
71
  r.entity("user", createEntity({ table: "Users", fields: {} }));
72
- r.writeHandler("user", z.object({}), stubHandler, { access: { openToAll: true } });
72
+ r.writeHandler("user", z.object({}), stubHandler, {
73
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
74
+ });
73
75
  r.hook("preSave", "user", async (changes) => changes);
74
76
  r.hook("preSave", "user", async (changes) => changes);
75
77
  });
@@ -81,12 +83,16 @@ describe("lifecycle hooks in registry", () => {
81
83
  test("cross-feature hooks use full prefixed name", () => {
82
84
  const f1 = defineFeature("a", (r) => {
83
85
  r.entity("user", createEntity({ table: "Users", fields: {} }));
84
- r.writeHandler("user", z.object({}), stubHandler, { access: { openToAll: true } });
86
+ r.writeHandler("user", z.object({}), stubHandler, {
87
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
88
+ });
85
89
  r.hook("postSave", "user", async () => {});
86
90
  });
87
91
  // Feature b hooks into a.user by using the full prefixed name
88
92
  const f2 = defineFeature("b", (r) => {
89
- r.writeHandler("a.user", z.object({}), stubHandler, { access: { openToAll: true } });
93
+ r.writeHandler("a.user", z.object({}), stubHandler, {
94
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
95
+ });
90
96
  r.hook("postSave", "a.user", async () => {});
91
97
  r.hook("postSave", "a.user", async () => {});
92
98
  });
@@ -28,7 +28,7 @@ const LIST_QN = "checklist:query:equipment:list";
28
28
  const checklistFeature = defineFeature("checklist", (r) => {
29
29
  r.crud("equipment", equipmentEntity, {
30
30
  write: { access: { roles: ["Admin"] } },
31
- read: { access: { openToAll: true } },
31
+ read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
32
32
  });
33
33
  });
34
34
 
@@ -24,7 +24,9 @@ describe("postQuery hook registration", () => {
24
24
  test("r.hook('postQuery', handlerQn, fn) lands in handler-keyed lifecycleHooks map", () => {
25
25
  const feature = defineFeature("test", (r) => {
26
26
  r.entity("thing", createEntity({ table: "things", fields: {} }));
27
- r.queryHandler("thing:list", z.object({}), async () => [], { access: { openToAll: true } });
27
+ r.queryHandler("thing:list", z.object({}), async () => [], {
28
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
29
+ });
28
30
  r.hook("postQuery", "thing:list", noop);
29
31
  });
30
32
 
@@ -70,7 +72,9 @@ describe("Registry getters", () => {
70
72
 
71
73
  const feature = defineFeature("test", (r) => {
72
74
  r.entity("thing", createEntity({ table: "things", fields: {} }));
73
- r.queryHandler("thing:list", z.object({}), async () => [], { access: { openToAll: true } });
75
+ r.queryHandler("thing:list", z.object({}), async () => [], {
76
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
77
+ });
74
78
  r.hook("postQuery", "thing:list", fn);
75
79
  });
76
80
 
@@ -42,13 +42,13 @@ describe("validateBoot — projectionDetail actions (fw#2166)", () => {
42
42
  test("valid navigate + writeHandler actions boot cleanly", () => {
43
43
  const feature = defineFeature("app", (r) => {
44
44
  r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
45
- access: { openToAll: true },
45
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
46
46
  });
47
47
  r.writeHandler(
48
48
  "archive",
49
49
  z.object({ id: z.string() }),
50
50
  async () => ({ isSuccess: true as const, data: {} }),
51
- { access: { openToAll: true } },
51
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
52
52
  );
53
53
  r.screen({
54
54
  id: "rent-detail",
@@ -21,7 +21,7 @@ describe("validateBoot — projectionDetail singleton (fw#2312)", () => {
21
21
  test("singleton + idParam throws", () => {
22
22
  const feature = defineFeature("app", (r) => {
23
23
  r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
24
- access: { openToAll: true },
24
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
25
25
  });
26
26
  r.screen({
27
27
  id: "profile",
@@ -46,7 +46,7 @@ describe("validateBoot — projectionDetail singleton (fw#2312)", () => {
46
46
  }),
47
47
  );
48
48
  r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
49
- access: { openToAll: true },
49
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
50
50
  });
51
51
  r.screen({
52
52
  id: "profile",
@@ -71,7 +71,7 @@ describe("validateBoot — projectionDetail singleton (fw#2312)", () => {
71
71
  test("singleton without idParam/detailFor boots cleanly", () => {
72
72
  const feature = defineFeature("app", (r) => {
73
73
  r.queryHandler("me", z.object({}), async () => ({ id: "u1" }), {
74
- access: { openToAll: true },
74
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
75
75
  });
76
76
  r.screen({
77
77
  id: "profile",
@@ -140,7 +140,7 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
140
140
  test("object-form metric with its own label needs no fieldLabels entry", () => {
141
141
  const feature = defineFeature("app", (r) => {
142
142
  r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
143
- access: { openToAll: true },
143
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
144
144
  });
145
145
  r.screen({
146
146
  id: "rent-detail",
@@ -192,7 +192,7 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
192
192
  test("metric navigate.tab with no screen/entity — a known section id boots cleanly", () => {
193
193
  const feature = defineFeature("app", (r) => {
194
194
  r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
195
- access: { openToAll: true },
195
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
196
196
  });
197
197
  r.screen({
198
198
  id: "rent-detail",
@@ -214,7 +214,7 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
214
214
  test("valid tabs + header + metrics declaration boots cleanly", () => {
215
215
  const feature = defineFeature("app", (r) => {
216
216
  r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
217
- access: { openToAll: true },
217
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
218
218
  });
219
219
  r.screen({
220
220
  id: "rent-detail",
@@ -37,7 +37,7 @@ const LIST_QN = "roster:query:member:list";
37
37
  const rosterFeature = defineFeature("roster", (r) => {
38
38
  r.crud("member", memberEntity, {
39
39
  write: { access: { roles: ["Admin"] } },
40
- read: { access: { openToAll: true } },
40
+ read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
41
41
  });
42
42
  r.screen({
43
43
  id: "member-list",