@cosmicdrift/kumiko-framework 0.270.0 → 0.274.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 (111) 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 +105 -0
  13. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  15. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  16. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  18. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  19. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  20. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  21. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  22. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  23. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  24. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  25. package/src/db/apply-entity-event.ts +1 -1
  26. package/src/db/assert-exists-in.ts +1 -1
  27. package/src/db/entity-field-encryption.ts +18 -0
  28. package/src/db/event-store-executor-context.ts +12 -5
  29. package/src/db/event-store-executor-read.ts +125 -37
  30. package/src/db/event-store-executor-write.ts +24 -18
  31. package/src/db/queries/shadow-swap.ts +36 -0
  32. package/src/db/replay-migration-sql.ts +5 -5
  33. package/src/db/tenant-db-runner.ts +23 -0
  34. package/src/db/tenant-db.ts +97 -24
  35. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  36. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  37. package/src/engine/__tests__/boot-validator-projection-list.test.ts +91 -0
  38. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +143 -0
  39. package/src/engine/__tests__/boot-validator.test.ts +71 -1
  40. package/src/engine/__tests__/engine.test.ts +86 -0
  41. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  42. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  43. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  44. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  45. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  46. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  47. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  48. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  49. package/src/engine/__tests__/screen.test.ts +150 -0
  50. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  51. package/src/engine/__tests__/steps-read.test.ts +204 -8
  52. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  53. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  54. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  55. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  56. package/src/engine/boot-validator/entity-handler.ts +21 -0
  57. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  58. package/src/engine/boot-validator/parent-ref.ts +1 -1
  59. package/src/engine/boot-validator/projection-list-screens.ts +68 -3
  60. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  61. package/src/engine/boot-validator/screens.ts +154 -31
  62. package/src/engine/build-config-feature-schema.ts +7 -4
  63. package/src/engine/entity-handlers.ts +74 -22
  64. package/src/engine/feature-entity-handlers.ts +4 -4
  65. package/src/engine/index.ts +5 -0
  66. package/src/engine/registry-validate.ts +36 -6
  67. package/src/engine/schema-builder.ts +6 -0
  68. package/src/engine/screen-filter-ops.ts +10 -2
  69. package/src/engine/screen-helpers.ts +52 -0
  70. package/src/engine/steps/_read-source.ts +12 -0
  71. package/src/engine/steps/read-find-many.ts +8 -2
  72. package/src/engine/steps/read-find-one.ts +11 -8
  73. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  74. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  75. package/src/engine/types/index.ts +8 -1
  76. package/src/errors/i18n/de.yaml +14 -0
  77. package/src/errors/i18n/en.yaml +13 -0
  78. package/src/errors/reasons.ts +4 -0
  79. package/src/i18n/required-surface-keys.ts +6 -2
  80. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  81. package/src/jobs/__tests__/jobs.integration.test.ts +41 -0
  82. package/src/jobs/job-runner.ts +35 -5
  83. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  84. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  85. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  86. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  87. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  88. package/src/pipeline/__tests__/query-projection.integration.test.ts +169 -14
  89. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  90. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  91. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  92. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  93. package/src/pipeline/dispatch-query.ts +27 -8
  94. package/src/pipeline/dispatch-shared.ts +141 -57
  95. package/src/pipeline/dispatch-stream.ts +1 -3
  96. package/src/pipeline/dispatch-write.ts +62 -14
  97. package/src/pipeline/dispatcher.ts +20 -0
  98. package/src/pipeline/escape-hatch-report.ts +127 -0
  99. package/src/pipeline/index.ts +8 -0
  100. package/src/pipeline/member-read-only-transaction.ts +75 -0
  101. package/src/pipeline/msp-rebuild.ts +2 -0
  102. package/src/pipeline/projection-rebuild.ts +2 -0
  103. package/src/pipeline/projections-runner.ts +15 -13
  104. package/src/pipeline/system-identity-switch.ts +121 -9
  105. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  106. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  107. package/src/scripts/codemod/migrate-open-to-all.ts +2 -1
  108. package/src/ui-types/index.ts +3 -0
  109. package/src/utils/__tests__/ids.test.ts +29 -1
  110. package/src/utils/ids.ts +10 -1
  111. package/src/utils/index.ts +1 -1
@@ -2306,7 +2306,7 @@ describe("boot-validator", () => {
2306
2306
  function makeFeature(
2307
2307
  filter: {
2308
2308
  readonly field: string;
2309
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
2309
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
2310
2310
  readonly value: unknown;
2311
2311
  },
2312
2312
  fields: Record<string, unknown> = {
@@ -2365,6 +2365,25 @@ describe("boot-validator", () => {
2365
2365
  ).not.toThrow();
2366
2366
  });
2367
2367
 
2368
+ test("filter.op=lte/gte auf number-Feld → kein Throw (vergleichbar)", () => {
2369
+ const fields = {
2370
+ name: { type: "text", filterable: true },
2371
+ rank: { type: "number", filterable: true },
2372
+ };
2373
+ expect(() =>
2374
+ validateBoot([makeFeature({ field: "rank", op: "lte", value: 5 }, fields)]),
2375
+ ).not.toThrow();
2376
+ expect(() =>
2377
+ validateBoot([makeFeature({ field: "rank", op: "gte", value: 5 }, fields)]),
2378
+ ).not.toThrow();
2379
+ });
2380
+
2381
+ test("filter.op=lte auf text-Feld → Throw (op-vs-Type-Compat)", () => {
2382
+ expect(() => validateBoot([makeFeature({ field: "status", op: "lte", value: "x" })])).toThrow(
2383
+ /filter\.op "lte" is not allowed on field "status" \(type "text"\)/,
2384
+ );
2385
+ });
2386
+
2368
2387
  test('filter.op="in" mit non-array value → Throw', () => {
2369
2388
  expect(() =>
2370
2389
  validateBoot([makeFeature({ field: "status", op: "in", value: "active" })]),
@@ -2403,6 +2422,10 @@ describe("boot-validator", () => {
2403
2422
  readonly sections?: ReadonlyArray<{
2404
2423
  readonly title: string;
2405
2424
  readonly fields: readonly string[];
2425
+ readonly groups?: ReadonlyArray<{
2426
+ readonly title: string;
2427
+ readonly fields: readonly string[];
2428
+ }>;
2406
2429
  }>;
2407
2430
  readonly redirect?: string | { readonly screen: string; readonly idFrom: string };
2408
2431
  readonly cancelTarget?: string | false;
@@ -2501,6 +2524,53 @@ describe("boot-validator", () => {
2501
2524
  ).toThrow(/layout references unknown field "ghost"/);
2502
2525
  });
2503
2526
 
2527
+ test("section mit fields UND groups → Throw", () => {
2528
+ expect(() =>
2529
+ validateBoot([
2530
+ makeFeature({
2531
+ sections: [
2532
+ {
2533
+ title: "Approval",
2534
+ fields: ["note"],
2535
+ groups: [{ title: "Priority", fields: ["priority"] }],
2536
+ },
2537
+ ],
2538
+ }),
2539
+ ]),
2540
+ ).toThrow(/declares both fields and groups/);
2541
+ });
2542
+
2543
+ test("group referenziert unknown field → Throw", () => {
2544
+ expect(() =>
2545
+ validateBoot([
2546
+ makeFeature({
2547
+ sections: [
2548
+ { title: "Approval", fields: [], groups: [{ title: "Priority", fields: ["ghost"] }] },
2549
+ ],
2550
+ }),
2551
+ ]),
2552
+ ).toThrow(/layout references unknown field "ghost"/);
2553
+ });
2554
+
2555
+ test("valide groups → kein Throw", () => {
2556
+ expect(() =>
2557
+ validateBoot([
2558
+ makeFeature({
2559
+ sections: [
2560
+ {
2561
+ title: "Approval",
2562
+ fields: [],
2563
+ groups: [
2564
+ { title: "Basics", fields: ["note"] },
2565
+ { title: "Priority", fields: ["priority"] },
2566
+ ],
2567
+ },
2568
+ ],
2569
+ }),
2570
+ ]),
2571
+ ).not.toThrow();
2572
+ });
2573
+
2504
2574
  test("redirect → existing screen-id im selben feature → kein Throw", () => {
2505
2575
  expect(() =>
2506
2576
  validateBoot([makeFeature({ redirect: "after-form", extraScreens: ["after-form"] })]),
@@ -754,6 +754,92 @@ describe("createRegistry", () => {
754
754
  expect(() => createRegistry([feature])).toThrow(/multiple/);
755
755
  });
756
756
 
757
+ test("throws at boot when a searchable reference targets an encrypted labelField", () => {
758
+ const feature = defineFeature("crm", (r) => {
759
+ r.entity(
760
+ "customer",
761
+ createEntity({
762
+ table: "Customers",
763
+ fields: {
764
+ name: createTextField({ personal: false, reason: "test_fixture", encrypted: true }),
765
+ },
766
+ }),
767
+ );
768
+ r.entity(
769
+ "order",
770
+ createEntity({
771
+ table: "Orders",
772
+ fields: {
773
+ customerId: {
774
+ type: "reference",
775
+ entity: "customer",
776
+ labelField: "name",
777
+ searchable: true,
778
+ },
779
+ },
780
+ }),
781
+ );
782
+ });
783
+
784
+ expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
785
+ });
786
+
787
+ test('throws at boot when a searchable reference targets a PII labelField without find: "fuzzy" (no plaintext index)', () => {
788
+ const feature = defineFeature("crm", (r) => {
789
+ r.entity(
790
+ "contact",
791
+ createEntity({
792
+ table: "Contacts",
793
+ fields: { displayName: createTextField({ personal: "self", find: "exact" }) },
794
+ }),
795
+ );
796
+ r.entity(
797
+ "order",
798
+ createEntity({
799
+ table: "Orders",
800
+ fields: {
801
+ contactId: {
802
+ type: "reference",
803
+ entity: "contact",
804
+ labelField: "displayName",
805
+ searchable: true,
806
+ },
807
+ },
808
+ }),
809
+ );
810
+ });
811
+
812
+ expect(() => createRegistry([feature])).toThrow(/encrypted\/PII/);
813
+ });
814
+
815
+ test('does not throw when a searchable reference targets a PII labelField with find: "fuzzy" (derived search index)', () => {
816
+ const feature = defineFeature("crm", (r) => {
817
+ r.entity(
818
+ "contact",
819
+ createEntity({
820
+ table: "Contacts",
821
+ fields: { displayName: createTextField({ personal: "self", find: "fuzzy" }) },
822
+ }),
823
+ );
824
+ r.entity(
825
+ "order",
826
+ createEntity({
827
+ table: "Orders",
828
+ fields: {
829
+ contactId: {
830
+ type: "reference",
831
+ entity: "contact",
832
+ labelField: "displayName",
833
+ searchable: true,
834
+ },
835
+ },
836
+ }),
837
+ );
838
+ });
839
+
840
+ expect(() => createRegistry([feature])).not.toThrow();
841
+ });
842
+
757
843
  test("returns sortable reference fields for entity, without leaking them into getSortableFields (fw#2741)", () => {
758
844
  const feature = defineFeature("crm", (r) => {
759
845
  r.entity(
@@ -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(
@@ -242,6 +248,28 @@ describe("defineProjectionQueryHandler", () => {
242
248
  defineProjectionQueryHandler("revenue:list", "showcase:projection:customer-revenue"),
243
249
  ).toThrow();
244
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
+ });
245
273
  });
246
274
 
247
275
  // Verb-spezifische Wrappers — sind dünne Convenience-Layers über
@@ -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
  });