@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
@@ -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
  });
@@ -0,0 +1,215 @@
1
+ // fw#2914 — r.step.read.findOne/findMany now apply the caller's tenant
2
+ // filter by default (own tenant + SYSTEM_TENANT_ID reference rows), like
3
+ // ctx.db method-form reads. Cross-tenant reads need `unsafeAllTenants:
4
+ // { reason }` on the step AND `escapeHatch: { reason }` on the handler,
5
+ // reported as an "unsafe-raw" escape-hatch audit event. Real HTTP calls +
6
+ // setupTestStack — never createTestDispatcher. Modelled on the M.1.5
7
+ // section of pipeline-handler.integration.test.ts and
8
+ // escape-hatch-audit.integration.test.ts.
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { z } from "zod";
12
+ import { table, text, uuid } from "../../db/dialect";
13
+ import { insertOne } from "../../db/query";
14
+ import { defineFeature, defineWriteHandler } from "../../engine";
15
+ import { setupTestStack, type TestStack, TestUsers, unsafePushTables } from "../../stack";
16
+ import { stepsPipeline } from "../pipeline";
17
+ import type { EscapeHatchUseEvent } from "../types";
18
+
19
+ const readFilterTable = table("fw2914_read_filter_items", {
20
+ id: uuid("id").primaryKey().defaultRandom(),
21
+ tenantId: uuid("tenant_id").notNull(),
22
+ label: text("label").notNull(),
23
+ });
24
+
25
+ const UNDECLARED_REASON =
26
+ "fw#2914 integration test — cross-tenant read step without a handler grant";
27
+ const ESCAPED_REASON = "fw#2914 integration test — cross-tenant widget count for read step";
28
+
29
+ const listSchema = z.object({});
30
+ const listHandler = defineWriteHandler({
31
+ name: "list",
32
+ schema: listSchema,
33
+ access: { roles: ["Admin"] },
34
+ perform: stepsPipeline<z.infer<typeof listSchema>, { count: number; ids: string[] }>(({ r }) => [
35
+ r.step.read.findMany("items", { table: readFilterTable }),
36
+ r.step.return(({ steps }) => {
37
+ const rows = steps["items"] as readonly { id: string }[];
38
+ return {
39
+ isSuccess: true as const,
40
+ data: { count: rows.length, ids: rows.map((row) => row.id) },
41
+ };
42
+ }),
43
+ ]),
44
+ });
45
+
46
+ const getSchema = z.object({ id: z.uuid() });
47
+ const getHandler = defineWriteHandler({
48
+ name: "get",
49
+ schema: getSchema,
50
+ access: { roles: ["Admin"] },
51
+ perform: stepsPipeline<z.infer<typeof getSchema>, { found: boolean; label: string | null }>(
52
+ ({ event, r }) => [
53
+ r.step.read.findOne("item", {
54
+ table: readFilterTable,
55
+ where: () => ({ id: event.payload.id }),
56
+ }),
57
+ r.step.return(({ steps }) => {
58
+ const row = steps["item"] as { label?: string } | null;
59
+ return {
60
+ isSuccess: true as const,
61
+ data: { found: row !== null, label: row?.label ?? null },
62
+ };
63
+ }),
64
+ ],
65
+ ),
66
+ });
67
+
68
+ const listUnsafeUndeclaredSchema = z.object({});
69
+ const listUnsafeUndeclaredHandler = defineWriteHandler({
70
+ name: "list-unsafe-undeclared",
71
+ schema: listUnsafeUndeclaredSchema,
72
+ access: { roles: ["Admin"] },
73
+ perform: stepsPipeline<z.infer<typeof listUnsafeUndeclaredSchema>, { count: number }>(({ r }) => [
74
+ r.step.read.findMany("items", {
75
+ table: readFilterTable,
76
+ unsafeAllTenants: { reason: UNDECLARED_REASON },
77
+ }),
78
+ r.step.return(({ steps }) => ({
79
+ isSuccess: true as const,
80
+ data: { count: (steps["items"] as readonly unknown[]).length },
81
+ })),
82
+ ]),
83
+ });
84
+
85
+ const listEscapedSchema = z.object({});
86
+ const listEscapedHandler = defineWriteHandler({
87
+ name: "list-escaped",
88
+ schema: listEscapedSchema,
89
+ access: { roles: ["Admin"] },
90
+ escapeHatch: { reason: ESCAPED_REASON },
91
+ perform: stepsPipeline<z.infer<typeof listEscapedSchema>, { count: number }>(({ r }) => [
92
+ r.step.read.findMany("items", {
93
+ table: readFilterTable,
94
+ unsafeAllTenants: { reason: ESCAPED_REASON },
95
+ }),
96
+ r.step.return(({ steps }) => ({
97
+ isSuccess: true as const,
98
+ data: { count: (steps["items"] as readonly unknown[]).length },
99
+ })),
100
+ ]),
101
+ });
102
+
103
+ const readStepsTenantFilterFeature = defineFeature("read-steps-tenant-filter", (r) => {
104
+ r.requires.projection("fw2914_read_filter_items");
105
+ r.writeHandler(listHandler);
106
+ r.writeHandler(getHandler);
107
+ r.writeHandler(listUnsafeUndeclaredHandler);
108
+ r.writeHandler(listEscapedHandler);
109
+ });
110
+
111
+ const admin = TestUsers.admin;
112
+ const otherTenant = TestUsers.otherTenant;
113
+
114
+ describe("r.step.read.findOne/findMany tenant-filtering (fw#2914)", () => {
115
+ let stack: TestStack;
116
+ const events: EscapeHatchUseEvent[] = [];
117
+ let adminRowId: string;
118
+ let otherTenantRowId: string;
119
+
120
+ beforeAll(async () => {
121
+ stack = await setupTestStack({
122
+ features: [readStepsTenantFilterFeature],
123
+ extraContext: {
124
+ _escapeHatchAuditSink: async (event: EscapeHatchUseEvent) => {
125
+ events.push(event);
126
+ },
127
+ },
128
+ });
129
+ await unsafePushTables(stack.db, { fw2914_read_filter_items: readFilterTable });
130
+
131
+ const adminRow = await insertOne<{ id: string }>(stack.db, readFilterTable, {
132
+ tenantId: admin.tenantId,
133
+ label: "admin-row",
134
+ });
135
+ const otherRow = await insertOne<{ id: string }>(stack.db, readFilterTable, {
136
+ tenantId: otherTenant.tenantId,
137
+ label: "other-tenant-row",
138
+ });
139
+ adminRowId = adminRow!.id;
140
+ otherTenantRowId = otherRow!.id;
141
+ });
142
+
143
+ afterAll(async () => {
144
+ await stack.cleanup();
145
+ });
146
+
147
+ test("read.findMany returns only the caller's own tenant rows", async () => {
148
+ const data = await stack.http.writeOk<{ count: number; ids: string[] }>(
149
+ "read-steps-tenant-filter:write:list",
150
+ {},
151
+ admin,
152
+ );
153
+
154
+ expect(data.count).toBe(1);
155
+ expect(data.ids).toEqual([adminRowId]);
156
+ expect(data.ids).not.toContain(otherTenantRowId);
157
+ });
158
+
159
+ test("read.findOne returns the row when it belongs to the caller's tenant", async () => {
160
+ const data = await stack.http.writeOk<{ found: boolean; label: string | null }>(
161
+ "read-steps-tenant-filter:write:get",
162
+ { id: adminRowId },
163
+ admin,
164
+ );
165
+
166
+ expect(data).toEqual({ found: true, label: "admin-row" });
167
+ });
168
+
169
+ test("read.findOne returns null for a row belonging to a different tenant", async () => {
170
+ const data = await stack.http.writeOk<{ found: boolean; label: string | null }>(
171
+ "read-steps-tenant-filter:write:get",
172
+ { id: otherTenantRowId },
173
+ admin,
174
+ );
175
+
176
+ expect(data).toEqual({ found: false, label: null });
177
+ });
178
+
179
+ test("unsafeAllTenants without escapeHatch on the handler is denied and reports nothing", async () => {
180
+ events.length = 0;
181
+
182
+ const err = await stack.http.writeErr(
183
+ "read-steps-tenant-filter:write:list-unsafe-undeclared",
184
+ {},
185
+ admin,
186
+ );
187
+
188
+ expect(err.httpStatus).toBe(403);
189
+ expect(err.code).toBe("access_denied");
190
+ expect(events.filter((e) => e.kind === "unsafe-raw")).toEqual([]);
191
+ });
192
+
193
+ test("unsafeAllTenants with escapeHatch on the handler sees rows across tenants and reports unsafe-raw", async () => {
194
+ events.length = 0;
195
+
196
+ const data = await stack.http.writeOk<{ count: number }>(
197
+ "read-steps-tenant-filter:write:list-escaped",
198
+ {},
199
+ admin,
200
+ );
201
+
202
+ expect(data.count).toBeGreaterThanOrEqual(2);
203
+
204
+ const matches = events.filter((e) => e.kind === "unsafe-raw");
205
+ expect(matches).toEqual([
206
+ {
207
+ handler: "read-steps-tenant-filter:write:list-escaped",
208
+ kind: "unsafe-raw",
209
+ reason: ESCAPED_REASON,
210
+ tenantId: admin.tenantId,
211
+ actor: admin.id,
212
+ },
213
+ ]);
214
+ });
215
+ });
@@ -44,6 +44,22 @@ describe("requiredKeysFromScreen", () => {
44
44
  expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "title"));
45
45
  });
46
46
 
47
+ test("actionForm honors a fieldLabels override, falls back to ACTION_FORM_ENTITY otherwise", () => {
48
+ const keys = requiredKeysFromScreen("publicstatus", {
49
+ id: "reschedule-form",
50
+ type: "actionForm",
51
+ handler: "publicstatus:write:incident:reschedule",
52
+ fields: { dueAt: { type: "date" }, note: { type: "text" } },
53
+ fieldLabels: { dueAt: "publicstatus:override.dueAt" },
54
+ layout: {
55
+ sections: [{ fields: ["dueAt", "note"] }],
56
+ },
57
+ });
58
+ expect(keys).toContain("publicstatus:override.dueAt");
59
+ expect(keys).not.toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "dueAt"));
60
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "note"));
61
+ });
62
+
47
63
  test("entityEdit emits submitLabel + section titles + field labels (override honored)", () => {
48
64
  const screen: EntityEditScreenDefinition = {
49
65
  id: "component-edit",