@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
@@ -0,0 +1,87 @@
1
+ // The schema lets a caller id through, but only a system identity gets it honored; HTTP users
2
+ // can never present one (SYSTEM_ROLE is reserved), so both halves are pinned end to end here.
3
+
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
6
+ import {
7
+ createEntity,
8
+ createSystemUser,
9
+ createTextField,
10
+ defineEntityCreateHandler,
11
+ defineFeature,
12
+ } from "../index";
13
+
14
+ const thingEntity = createEntity({
15
+ table: "callerid_things",
16
+ fields: {
17
+ label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
18
+ },
19
+ access: { write: { Admin: "all" } },
20
+ });
21
+ const CREATE_QN = "callerid:write:thing:create";
22
+
23
+ const feature = defineFeature("callerid", (r) => {
24
+ r.entity("thing", thingEntity);
25
+ r.writeHandler(defineEntityCreateHandler("thing", thingEntity, { access: { roles: ["Admin"] } }));
26
+ });
27
+
28
+ let stack: TestStack;
29
+ const admin = TestUsers.admin;
30
+
31
+ beforeAll(async () => {
32
+ stack = await setupTestStack({ features: [feature] });
33
+ await unsafeCreateEntityTable(stack.db, thingEntity, "thing");
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await stack.cleanup();
38
+ });
39
+
40
+ describe("entity create — caller-chosen id", () => {
41
+ test("system-identity caller: the chosen id is honored", async () => {
42
+ const chosenId = "00000000-0000-4000-8000-0000000000c1";
43
+ const systemUser = createSystemUser(admin.tenantId, ["Admin"]);
44
+ const result = await stack.http.writeOk<{ id: string }>(
45
+ CREATE_QN,
46
+ { label: "from a job", id: chosenId },
47
+ systemUser,
48
+ );
49
+ expect(result.id).toBe(chosenId);
50
+ });
51
+
52
+ test("system-identity caller retrying the same id → version_conflict, no duplicate row created", async () => {
53
+ const chosenId = "00000000-0000-4000-8000-0000000000c2";
54
+ const systemUser = createSystemUser(admin.tenantId, ["Admin"]);
55
+ await stack.http.writeOk<{ id: string }>(
56
+ CREATE_QN,
57
+ { label: "first attempt", id: chosenId },
58
+ systemUser,
59
+ );
60
+ const retry = await stack.http.writeErr(
61
+ CREATE_QN,
62
+ { label: "redelivered event", id: chosenId },
63
+ systemUser,
64
+ );
65
+ expect(retry.code).toBe("version_conflict");
66
+ });
67
+
68
+ test("ordinary end user: the payload id is ignored, a fresh id is minted", async () => {
69
+ const chosenId = "00000000-0000-4000-8000-0000000000c3";
70
+ const result = await stack.http.writeOk<{ id: string }>(
71
+ CREATE_QN,
72
+ { label: "from a browser", id: chosenId },
73
+ admin,
74
+ );
75
+ expect(result.id).not.toBe(chosenId);
76
+ });
77
+
78
+ test("malformed id → validation error, regardless of caller identity", async () => {
79
+ const systemUser = createSystemUser(admin.tenantId, ["Admin"]);
80
+ const err = await stack.http.writeErr(
81
+ CREATE_QN,
82
+ { label: "bad id", id: "not-a-uuid" },
83
+ systemUser,
84
+ );
85
+ expect(err.code).toBe("validation_error");
86
+ });
87
+ });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ defineEntityListHandler,
4
+ defineEntityUpdateHandler,
5
+ isDeprecatedCrossTenantHandler,
6
+ } from "../entity-handlers";
7
+ import { createEntity, createTextField } from "../factories";
8
+
9
+ const thingEntity = createEntity({
10
+ table: "escape_hatch_things",
11
+ fields: {
12
+ label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
13
+ },
14
+ });
15
+
16
+ const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
17
+
18
+ describe("entity convention handlers: escapeHatch option", () => {
19
+ test("throws when both escapeHatch and the deprecated crossTenant are set", () => {
20
+ expect(() =>
21
+ defineEntityUpdateHandler("thing", thingEntity, {
22
+ ...sysadminAccess,
23
+ escapeHatch: { reason: "operator write" },
24
+ crossTenant: true,
25
+ }),
26
+ ).toThrow(/declare either escapeHatch or the deprecated crossTenant, not both/);
27
+ });
28
+
29
+ test("throws when escapeHatch.reason is empty", () => {
30
+ expect(() =>
31
+ defineEntityUpdateHandler("thing", thingEntity, {
32
+ ...sysadminAccess,
33
+ escapeHatch: { reason: "" },
34
+ }),
35
+ ).toThrow(/non-empty reason/);
36
+ });
37
+
38
+ test("throws when escapeHatch.reason is whitespace-only", () => {
39
+ expect(() =>
40
+ defineEntityListHandler("thing", thingEntity, {
41
+ ...sysadminAccess,
42
+ escapeHatch: { reason: " " },
43
+ }),
44
+ ).toThrow(/non-empty reason/);
45
+ });
46
+
47
+ test("WriteHandlerDef from an escapeHatch update handler carries no escapeHatch field", () => {
48
+ const def = defineEntityUpdateHandler("thing", thingEntity, {
49
+ ...sysadminAccess,
50
+ escapeHatch: { reason: "operator cross-tenant write" },
51
+ });
52
+ expect("escapeHatch" in def).toBe(false);
53
+ });
54
+
55
+ test("QueryHandlerDef from an escapeHatch list handler carries no escapeHatch field", () => {
56
+ const def = defineEntityListHandler("thing", thingEntity, {
57
+ ...sysadminAccess,
58
+ escapeHatch: { reason: "operator cross-tenant list" },
59
+ });
60
+ expect("escapeHatch" in def).toBe(false);
61
+ });
62
+
63
+ test("WriteHandlerDef from a legacy crossTenant handler also carries no escapeHatch field", () => {
64
+ const def = defineEntityUpdateHandler("thing", thingEntity, {
65
+ ...sysadminAccess,
66
+ crossTenant: true,
67
+ });
68
+ expect("escapeHatch" in def).toBe(false);
69
+ });
70
+
71
+ test("isDeprecatedCrossTenantHandler is true only for handlers built with crossTenant: true", () => {
72
+ const legacyWrite = defineEntityUpdateHandler("thing", thingEntity, {
73
+ ...sysadminAccess,
74
+ crossTenant: true,
75
+ });
76
+ const escapeHatchWrite = defineEntityUpdateHandler("thing", thingEntity, {
77
+ ...sysadminAccess,
78
+ escapeHatch: { reason: "operator cross-tenant write" },
79
+ });
80
+ const plainWrite = defineEntityUpdateHandler("thing", thingEntity, sysadminAccess);
81
+ const legacyList = defineEntityListHandler("thing", thingEntity, {
82
+ ...sysadminAccess,
83
+ crossTenant: true,
84
+ });
85
+
86
+ expect(isDeprecatedCrossTenantHandler(legacyWrite.handler)).toBe(true);
87
+ expect(isDeprecatedCrossTenantHandler(legacyList.handler)).toBe(true);
88
+ expect(isDeprecatedCrossTenantHandler(escapeHatchWrite.handler)).toBe(false);
89
+ expect(isDeprecatedCrossTenantHandler(plainWrite.handler)).toBe(false);
90
+ });
91
+ });
@@ -72,6 +72,12 @@ describe("defineEntityWriteHandler", () => {
72
72
  expect(def.schema.safeParse({}).success).toBe(false);
73
73
  });
74
74
 
75
+ test("create: schema accepts an optional caller-chosen id, well-formed UUID only", () => {
76
+ const def = defineEntityCreateHandler("note", noteEntity, adminAccess);
77
+ expect(def.schema.safeParse({ title: "x", id: VALID_UUID }).success).toBe(true);
78
+ expect(def.schema.safeParse({ title: "x", id: "not-a-uuid" }).success).toBe(false);
79
+ });
80
+
75
81
  test("update: schema requires id + version + changes", () => {
76
82
  const def = defineEntityUpdateHandler("note", noteEntity, adminAccess);
77
83
  expect(
@@ -149,9 +155,11 @@ describe("defineEntityQueryHandler", () => {
149
155
 
150
156
  test("access option is forwarded", () => {
151
157
  const def = defineEntityListHandler("note", noteEntity, {
152
- access: { openToAll: true },
158
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
159
+ });
160
+ expect(def.access).toEqual({
161
+ openToAll: { reason: "test handler callable by any signed-in test user" },
153
162
  });
154
- expect(def.access).toEqual({ openToAll: true });
155
163
  });
156
164
  });
157
165
 
@@ -180,10 +188,12 @@ describe("defineProjectionQueryHandler", () => {
180
188
  const def = defineProjectionQueryHandler(
181
189
  "revenue:list",
182
190
  "showcase:projection:customer-revenue",
183
- { access: { openToAll: true } },
191
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
184
192
  );
185
193
  expect(def.name).toBe("revenue:list");
186
- expect(def.access).toEqual({ openToAll: true });
194
+ expect(def.access).toEqual({
195
+ openToAll: { reason: "test handler callable by any signed-in test user" },
196
+ });
187
197
  // Empty-object schema — handler takes no payload fields.
188
198
  expect(def.schema.safeParse({}).success).toBe(true);
189
199
  });
@@ -192,7 +202,7 @@ describe("defineProjectionQueryHandler", () => {
192
202
  const def = defineProjectionQueryHandler(
193
203
  "revenue:list",
194
204
  "showcase:projection:customer-revenue",
195
- { access: { openToAll: true } },
205
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
196
206
  );
197
207
  const fakeRows = [{ customer: "a", totalCents: 100 }];
198
208
  const ctx = {
@@ -215,7 +225,10 @@ describe("defineProjectionQueryHandler", () => {
215
225
  const def = defineProjectionQueryHandler(
216
226
  "revenue:list",
217
227
  "showcase:projection:customer-revenue",
218
- { access: { openToAll: true }, unsafeAllTenants: true },
228
+ {
229
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
230
+ unsafeAllTenants: true,
231
+ },
219
232
  );
220
233
  const ctx = { queryProjection: mock().mockResolvedValue([]) };
221
234
  await def.handler(
@@ -235,6 +248,28 @@ describe("defineProjectionQueryHandler", () => {
235
248
  defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue"),
236
249
  ).toThrow();
237
250
  });
251
+
252
+ test("escapeHatch is forwarded to the returned def", () => {
253
+ const def = defineProjectionQueryHandler(
254
+ "revenue:list",
255
+ "showcase:projection:customer-revenue",
256
+ {
257
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
258
+ unsafeAllTenants: true,
259
+ escapeHatch: { reason: "fw#2913 test — cross-tenant revenue analytics" },
260
+ },
261
+ );
262
+ expect(def.escapeHatch).toEqual({ reason: "fw#2913 test — cross-tenant revenue analytics" });
263
+ });
264
+
265
+ test("escapeHatch is omitted from the def when not declared", () => {
266
+ const def = defineProjectionQueryHandler(
267
+ "revenue:list",
268
+ "showcase:projection:customer-revenue",
269
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
270
+ );
271
+ expect(def.escapeHatch).toBeUndefined();
272
+ });
238
273
  });
239
274
 
240
275
  // Verb-spezifische Wrappers — sind dünne Convenience-Layers über
@@ -328,7 +363,9 @@ describe("registerEntityCrud", () => {
328
363
  const { r, entities, writes, queries } = createCrudRegistrarMock();
329
364
  registerEntityCrud(r, "note", noteEntitySoftDelete, {
330
365
  write: { access: { roles: ["Admin"] } },
331
- read: { access: { openToAll: true } },
366
+ read: {
367
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
368
+ },
332
369
  });
333
370
  expect(entities["note"]).toBe(noteEntitySoftDelete);
334
371
  expect(writes.map((w) => w.name)).toEqual([
@@ -339,7 +376,9 @@ describe("registerEntityCrud", () => {
339
376
  ]);
340
377
  expect(queries.map((q) => q.name)).toEqual(["note:list", "note:detail"]);
341
378
  expect(writes[0]?.access).toEqual({ roles: ["Admin"] });
342
- expect(queries[0]?.access).toEqual({ openToAll: true });
379
+ expect(queries[0]?.access).toEqual({
380
+ openToAll: { reason: "test handler callable by any signed-in test user" },
381
+ });
343
382
  });
344
383
 
345
384
  test("verbs.delete: false skips delete handler", () => {
@@ -50,7 +50,7 @@ const throwOnPreSave: import("../types").PreSaveHookFn = async () => {
50
50
  const contactFeature = defineFeature("presave-wiring", (r) => {
51
51
  r.crud("contact", contactEntity, {
52
52
  write: { access: { roles: ["User"] } },
53
- read: { access: { openToAll: true } },
53
+ read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
54
54
  });
55
55
 
56
56
  // preSave has no entity-wide `{ allOf }` shorthand (unlike postSave/
@@ -70,7 +70,7 @@ const throwingEntity = createEntity({
70
70
  const throwingFeature = defineFeature("presave-wiring-throw", (r) => {
71
71
  r.crud("thing", throwingEntity, {
72
72
  write: { access: { roles: ["User"] } },
73
- read: { access: { openToAll: true } },
73
+ read: { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
74
74
  });
75
75
  r.hook("preSave", "thing:create", throwOnPreSave);
76
76
  });
@@ -3,11 +3,12 @@
3
3
  // a soft-deleted row belonging to another tenant and got the decrypted row back
4
4
  // in the response. delete() never had that hole (it goes through the
5
5
  // tenant-scoped loadById). These tests pin both halves: the tenant-scoped
6
- // handler must not reach across, and the crossTenant handler must still be able
7
- // to.
6
+ // handler must not reach across, and the crossTenant/escapeHatch handlers
7
+ // must still be able to.
8
8
  //
9
- // Two stacks on the SAME Postgres database: one registers the restore handler
10
- // without `crossTenant`, the other with it — a clean A/B on the one flag.
9
+ // Three stacks on the SAME Postgres database: one registers the restore
10
+ // handler without `crossTenant`/`escapeHatch`, one with the deprecated
11
+ // `crossTenant: true`, one with `escapeHatch: { reason }`.
11
12
 
12
13
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
14
  import { selectMany } from "../../db/query";
@@ -39,7 +40,9 @@ const thingEntity = createEntity({
39
40
  });
40
41
  const thingTable = buildEntityTable("thing", thingEntity);
41
42
 
42
- function buildFeature(crossTenant: boolean) {
43
+ const ESCAPE_HATCH_REASON = "fw#2915 integration test — operator restores a foreign tenant's row";
44
+
45
+ function buildFeature(crossTenant: boolean, escapeHatch?: boolean) {
43
46
  return defineFeature("ctrestore", (r) => {
44
47
  r.entity("thing", thingEntity);
45
48
  r.writeHandler(
@@ -56,6 +59,7 @@ function buildFeature(crossTenant: boolean) {
56
59
  defineEntityRestoreHandler("thing", thingEntity, {
57
60
  access: { roles: ["Admin", "SystemAdmin"] },
58
61
  ...(crossTenant && { crossTenant: true }),
62
+ ...(escapeHatch && { escapeHatch: { reason: ESCAPE_HATCH_REASON } }),
59
63
  }),
60
64
  );
61
65
  });
@@ -65,6 +69,7 @@ const dbName = `kumiko_test_ctrestore_${crypto.randomUUID().replace(/-/g, "").sl
65
69
 
66
70
  let tenantScopedStack: TestStack;
67
71
  let crossTenantStack: TestStack;
72
+ let escapeHatchStack: TestStack;
68
73
 
69
74
  beforeAll(async () => {
70
75
  tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
@@ -74,9 +79,15 @@ beforeAll(async () => {
74
79
  dbName,
75
80
  persistentDb: true,
76
81
  });
82
+ escapeHatchStack = await setupTestStack({
83
+ features: [buildFeature(false, true)],
84
+ dbName,
85
+ persistentDb: true,
86
+ });
77
87
  });
78
88
 
79
89
  afterAll(async () => {
90
+ await escapeHatchStack.cleanup();
80
91
  await crossTenantStack.cleanup();
81
92
  await tenantScopedStack.cleanup();
82
93
  });
@@ -140,4 +151,18 @@ describe("entity restore: tenant isolation", () => {
140
151
  expect(rows[0]?.["isDeleted"]).toBe(false);
141
152
  expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
142
153
  });
154
+
155
+ test("escapeHatch restore still reaches a foreign tenant's row", async () => {
156
+ const id = await createDeletedThing("escape-hatch-restore");
157
+
158
+ await escapeHatchStack.http.writeOk(
159
+ "ctrestore:write:thing:restore",
160
+ { id },
161
+ TestUsers.systemAdmin,
162
+ );
163
+
164
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
165
+ expect(rows[0]?.["isDeleted"]).toBe(false);
166
+ expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
167
+ });
143
168
  });