@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
@@ -4,6 +4,7 @@ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
4
  import { validateBoot as validateBootRaw } from "../boot-validator";
5
5
  import { createTenantConfig } from "../config-helpers";
6
6
  import { defineFeature } from "../define-feature";
7
+ import { defineEntityListHandler } from "../entity-handlers";
7
8
  import { createDerivedField, createEntity, createTextField } from "../factories";
8
9
  import { createRegistry } from "../registry";
9
10
  import type { ScreenDefinition } from "../types/screen";
@@ -555,6 +556,155 @@ describe("r.screen() — registration", () => {
555
556
  });
556
557
  });
557
558
 
559
+ describe("relatedList parentFilter", () => {
560
+ test("rejects parentFilter combined with parentParam on the same section", () => {
561
+ const features = [
562
+ defineFeature("app", (r) => {
563
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
564
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
565
+ });
566
+ r.queryHandler(
567
+ "foo:list",
568
+ z.object({ filter: z.unknown().optional() }),
569
+ async () => ({ rows: [], nextCursor: null }),
570
+ {
571
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
572
+ },
573
+ );
574
+ r.screen({
575
+ id: "x",
576
+ type: "projectionDetail",
577
+ query: "app:query:foo:detail",
578
+ layout: {
579
+ sections: [
580
+ {
581
+ kind: "relatedList",
582
+ title: "s",
583
+ query: "app:query:foo:list",
584
+ columns: ["name"],
585
+ parentParam: "orderId",
586
+ parentFilter: { field: "orderId" },
587
+ },
588
+ ],
589
+ },
590
+ });
591
+ }),
592
+ ];
593
+ expect(() => validateBoot(features)).toThrow(/declares both parentFilter and parentParam/);
594
+ });
595
+
596
+ test("rejects parentFilter when the bound query's schema has no filter parameter", () => {
597
+ const features = [
598
+ defineFeature("app", (r) => {
599
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
600
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
601
+ });
602
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
603
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
604
+ });
605
+ r.screen({
606
+ id: "x",
607
+ type: "projectionDetail",
608
+ query: "app:query:foo:detail",
609
+ layout: {
610
+ sections: [
611
+ {
612
+ kind: "relatedList",
613
+ title: "s",
614
+ query: "app:query:foo:list",
615
+ columns: ["name"],
616
+ parentFilter: { field: "orderId" },
617
+ },
618
+ ],
619
+ },
620
+ });
621
+ }),
622
+ ];
623
+ expect(() => validateBoot(features)).toThrow(
624
+ /declares parentFilter but query.*no "filter" parameter/,
625
+ );
626
+ });
627
+
628
+ test("rejects parentFilter.field that is not a declared field on the entity behind the query", () => {
629
+ const orderEntity = createEntity({
630
+ table: "orders",
631
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
632
+ });
633
+ const features = [
634
+ defineFeature("app", (r) => {
635
+ r.entity("order", orderEntity);
636
+ r.queryHandler(
637
+ defineEntityListHandler("order", orderEntity, {
638
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
639
+ }),
640
+ );
641
+ r.screen({
642
+ id: "x",
643
+ type: "projectionDetail",
644
+ query: "app:query:foo:detail",
645
+ layout: {
646
+ sections: [
647
+ {
648
+ kind: "relatedList",
649
+ title: "s",
650
+ query: "app:query:order:list",
651
+ columns: ["name"],
652
+ parentFilter: { field: "unknownField" },
653
+ },
654
+ ],
655
+ },
656
+ });
657
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
658
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
659
+ });
660
+ }),
661
+ ];
662
+ expect(() => validateBoot(features)).toThrow(
663
+ /parentFilter\.field "unknownField" is not a declared field/,
664
+ );
665
+ });
666
+
667
+ test("accepts a valid parentFilter whose field is a real entity field and the query accepts filter", () => {
668
+ const orderEntity = createEntity({
669
+ table: "orders",
670
+ fields: {
671
+ name: createTextField({ personal: false, reason: "test_fixture" }),
672
+ orderId: createTextField({ personal: false, reason: "test_fixture" }),
673
+ },
674
+ });
675
+ const features = [
676
+ defineFeature("app", (r) => {
677
+ r.entity("order", orderEntity);
678
+ r.queryHandler(
679
+ defineEntityListHandler("order", orderEntity, {
680
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
681
+ }),
682
+ );
683
+ r.screen({
684
+ id: "x",
685
+ type: "projectionDetail",
686
+ query: "app:query:foo:detail",
687
+ layout: {
688
+ sections: [
689
+ {
690
+ kind: "relatedList",
691
+ title: "s",
692
+ query: "app:query:order:list",
693
+ columns: ["name"],
694
+ parentFilter: { field: "orderId" },
695
+ },
696
+ ],
697
+ },
698
+ });
699
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
700
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
701
+ });
702
+ }),
703
+ ];
704
+ expect(() => validateBoot(features)).not.toThrow();
705
+ });
706
+ });
707
+
558
708
  test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
559
709
  const features = [
560
710
  defineFeature("shop", (r) => {
@@ -58,7 +58,6 @@ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): JobContext
58
58
  // to this recorder instead of trying to extract real table metadata.
59
59
  const fakeDb = {
60
60
  tenantId: "t1",
61
- raw: { unsafe: async () => [] },
62
61
  selectMany: async () => [],
63
62
  fetchOne: async () => undefined,
64
63
  insertOne: async () => undefined,
@@ -1,8 +1,14 @@
1
1
  import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import type { EscapeHatchReporter } from "@cosmicdrift/kumiko-types/handlers";
3
+ import type { DbRunner } from "../../db/connection";
2
4
  import { table, text, uuid } from "../../db/dialect";
5
+ import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../../db/tenant-db";
6
+ import { AccessDeniedError } from "../../errors";
7
+ import { testTenantId } from "../../stack";
3
8
  import { getStep } from "../define-step";
4
9
  import { buildReadFindManyStep } from "../steps/read-find-many";
5
10
  import { buildReadFindOneStep } from "../steps/read-find-one";
11
+ import { SYSTEM_TENANT_ID } from "../types/identifiers";
6
12
  import type { PipelineCtx } from "../types/step";
7
13
 
8
14
  const testTable = table("test_read", {
@@ -11,14 +17,16 @@ const testTable = table("test_read", {
11
17
  label: text("label"),
12
18
  });
13
19
 
14
- // bun-db path: read-find-many/one call selectMany(ctx.db.raw, table, where, opts)
15
- // which goes through asRawClient(ctx.db.raw).unsafe(sqlText, params).
16
- // Mock the .unsafe() return value to feed back rows.
20
+ // fw#2914: read-find-many/one now call selectMany(ctx.db, table, where, opts) —
21
+ // ctx.db is a TenantDb, so selectMany's tenantDbDelegate branch runs
22
+ // TenantDb.selectMany (applies the tenant filter) instead of raw SQL. Mock
23
+ // .unsafe() to feed back rows once the (tenant-filtered) query reaches it.
17
24
  const unsafeMock = mock(
18
25
  async (_sqlText: string, _params: unknown[]): Promise<Record<string, unknown>[]> => [],
19
26
  );
20
- const rawDb = { unsafe: unsafeMock, begin: mock() };
21
- const ctxDb = { raw: rawDb };
27
+ const rawDb = { unsafe: unsafeMock, begin: mock() } as DbRunner;
28
+ const ownTenantId = testTenantId(1);
29
+ const ctxDb = createTenantDb(rawDb, ownTenantId);
22
30
 
23
31
  const mockCtx = {
24
32
  db: ctxDb,
@@ -27,6 +35,21 @@ const mockCtx = {
27
35
  scope: {},
28
36
  } as unknown as PipelineCtx;
29
37
 
38
+ const reportCalls: Array<{ kind: string; reason: string }> = [];
39
+ const recordingReport: EscapeHatchReporter = (kind, reason) => {
40
+ reportCalls.push({ kind, reason });
41
+ };
42
+ const grantedDb = createTenantDb(rawDb, ownTenantId, "tenant", undefined, undefined, undefined, {
43
+ unsafeRaw: { reason: "fw#2914 unit test — declared cross-tenant read" },
44
+ report: recordingReport,
45
+ });
46
+ const grantedCtx = {
47
+ db: grantedDb,
48
+ event: { type: "test", payload: {} },
49
+ steps: {},
50
+ scope: {},
51
+ } as unknown as PipelineCtx;
52
+
30
53
  describe("buildReadFindOneStep", () => {
31
54
  it("returns a StepInstance with kind read.findOne", () => {
32
55
  const step = buildReadFindOneStep("myLookup", {
@@ -51,6 +74,7 @@ describe("read.findOne run", () => {
51
74
  beforeEach(() => {
52
75
  mock.clearAllMocks();
53
76
  unsafeMock.mockResolvedValue([]);
77
+ reportCalls.length = 0;
54
78
  });
55
79
 
56
80
  it("returns null when no row is found", async () => {
@@ -78,7 +102,19 @@ describe("read.findOne run", () => {
78
102
  expect(result).toEqual(row);
79
103
  });
80
104
 
81
- it("resolves a function where-clause before querying", async () => {
105
+ it("filters by the caller's own tenant + SYSTEM_TENANT_ID by default", async () => {
106
+ const stepDef = getStep("read.findOne");
107
+ unsafeMock.mockResolvedValueOnce([]);
108
+
109
+ await stepDef!.run({ name: "lookup", table: testTable, where: { id: "x" } }, mockCtx);
110
+
111
+ expect(unsafeMock).toHaveBeenCalledTimes(1);
112
+ const [sqlText, params] = unsafeMock.mock.calls[0]!;
113
+ expect(sqlText).toMatch(/"tenant_id" IN \(\$\d, \$\d\)/);
114
+ expect(params).toEqual(expect.arrayContaining([ownTenantId, SYSTEM_TENANT_ID]));
115
+ });
116
+
117
+ it("narrows a foreign where.tenantId to the caller's own scope", async () => {
82
118
  const stepDef = getStep("read.findOne");
83
119
  const whereFn = mock(() => ({ tenantId: "dyn-tenant" }));
84
120
  unsafeMock.mockResolvedValueOnce([]);
@@ -89,8 +125,42 @@ describe("read.findOne run", () => {
89
125
  expect(unsafeMock).toHaveBeenCalledTimes(1);
90
126
  const [sqlText, params] = unsafeMock.mock.calls[0]!;
91
127
  expect(sqlText).toMatch(/SELECT \* FROM "test_read"/);
92
- expect(sqlText).toMatch(/"tenant_id" = \$1/);
93
- expect(params).toEqual(["dyn-tenant"]);
128
+ expect(sqlText).toMatch(/"tenant_id" IN \(\$1, \$2\)/);
129
+ expect(params).toEqual([ownTenantId, SYSTEM_TENANT_ID]);
130
+ });
131
+
132
+ it("rejects unsafeAllTenants without a grant, without ever calling unsafe()", async () => {
133
+ const stepDef = getStep("read.findOne");
134
+
135
+ await expect(
136
+ stepDef!.run(
137
+ {
138
+ name: "lookup",
139
+ table: testTable,
140
+ where: { id: "x" },
141
+ unsafeAllTenants: { reason: "fw#2914 unit test — no grant" },
142
+ },
143
+ mockCtx,
144
+ ),
145
+ ).rejects.toThrow(AccessDeniedError);
146
+
147
+ expect(unsafeMock).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it("unsafeAllTenants with a grant skips the tenant filter and reports unsafe-raw", async () => {
151
+ const stepDef = getStep("read.findOne");
152
+ unsafeMock.mockResolvedValueOnce([]);
153
+ const reason = "fw#2914 unit test — declared cross-tenant read";
154
+
155
+ await stepDef!.run(
156
+ { name: "lookup", table: testTable, where: { id: "x" }, unsafeAllTenants: { reason } },
157
+ grantedCtx,
158
+ );
159
+
160
+ expect(unsafeMock).toHaveBeenCalledTimes(1);
161
+ const [sqlText] = unsafeMock.mock.calls[0]!;
162
+ expect(sqlText).not.toMatch(/tenant_id/);
163
+ expect(reportCalls).toEqual([{ kind: "unsafe-raw", reason }]);
94
164
  });
95
165
  });
96
166
 
@@ -105,12 +175,23 @@ describe("buildReadFindManyStep", () => {
105
175
  const step = buildReadFindManyStep("myList", { table: testTable, limit: 10 });
106
176
  expect((step.args as { limit: number }).limit).toBe(10);
107
177
  });
178
+
179
+ it("stores unsafeAllTenants on step.args and rejects a boolean at the type level", () => {
180
+ const reason = "fw#2914 unit test — unsafeAllTenants stored on step.args";
181
+ const step = buildReadFindManyStep("x", { table: testTable, unsafeAllTenants: { reason } });
182
+ expect(step.args).toMatchObject({ unsafeAllTenants: { reason } });
183
+
184
+ // @ts-expect-error unsafeAllTenants requires { reason: string }, not a boolean
185
+ const rejected = buildReadFindManyStep("x", { table: testTable, unsafeAllTenants: true });
186
+ expect(rejected.args).toMatchObject({ unsafeAllTenants: true });
187
+ });
108
188
  });
109
189
 
110
190
  describe("read.findMany run", () => {
111
191
  beforeEach(() => {
112
192
  mock.clearAllMocks();
113
193
  unsafeMock.mockResolvedValue([]);
194
+ reportCalls.length = 0;
114
195
  });
115
196
 
116
197
  it("returns an empty array when no rows exist", async () => {
@@ -133,4 +214,119 @@ describe("read.findMany run", () => {
133
214
  const [sqlText] = unsafeMock.mock.calls[0]!;
134
215
  expect(sqlText).toMatch(/LIMIT 2/);
135
216
  });
217
+
218
+ it("filters by the caller's own tenant + SYSTEM_TENANT_ID by default", async () => {
219
+ const stepDef = getStep("read.findMany");
220
+ unsafeMock.mockResolvedValueOnce([]);
221
+
222
+ await stepDef!.run({ name: "list", table: testTable }, mockCtx);
223
+
224
+ expect(unsafeMock).toHaveBeenCalledTimes(1);
225
+ const [sqlText, params] = unsafeMock.mock.calls[0]!;
226
+ expect(sqlText).toMatch(/"tenant_id" IN \(\$1, \$2\)/);
227
+ expect(params).toEqual([ownTenantId, SYSTEM_TENANT_ID]);
228
+ });
229
+
230
+ it("rejects unsafeAllTenants without a grant, without ever calling unsafe()", async () => {
231
+ const stepDef = getStep("read.findMany");
232
+
233
+ await expect(
234
+ stepDef!.run(
235
+ {
236
+ name: "list",
237
+ table: testTable,
238
+ unsafeAllTenants: { reason: "fw#2914 unit test — no grant" },
239
+ },
240
+ mockCtx,
241
+ ),
242
+ ).rejects.toThrow(AccessDeniedError);
243
+
244
+ expect(unsafeMock).not.toHaveBeenCalled();
245
+ });
246
+
247
+ it("unsafeAllTenants with a grant skips the tenant filter and reports unsafe-raw", async () => {
248
+ const stepDef = getStep("read.findMany");
249
+ unsafeMock.mockResolvedValueOnce([]);
250
+ const reason = "fw#2914 unit test — declared cross-tenant read";
251
+
252
+ await stepDef!.run(
253
+ { name: "list", table: testTable, unsafeAllTenants: { reason } },
254
+ grantedCtx,
255
+ );
256
+
257
+ expect(unsafeMock).toHaveBeenCalledTimes(1);
258
+ const [sqlText] = unsafeMock.mock.calls[0]!;
259
+ expect(sqlText).not.toMatch(/tenant_id/);
260
+ expect(reportCalls).toEqual([{ kind: "unsafe-raw", reason }]);
261
+ });
262
+ });
263
+
264
+ describe("read steps in a systemScope handler", () => {
265
+ const systemScopeReportCalls: Array<{ kind: string; reason: string }> = [];
266
+ const systemScopeReporter: EscapeHatchReporter = (kind, reason) => {
267
+ systemScopeReportCalls.push({ kind, reason });
268
+ };
269
+ // Mirrors createSystemScopedDbGuard (pipeline/dispatch-shared.ts) — a
270
+ // systemScope() handler's ctx.db is a Proxy that throws on first touch.
271
+ const throwingDb = new Proxy({} as TenantDb, {
272
+ get(_target, prop) {
273
+ throw new Error(`ctx.db is unavailable in a systemScope() handler (read "${String(prop)}")`);
274
+ },
275
+ });
276
+ const systemScopeCtx = {
277
+ db: throwingDb,
278
+ systemDb: createUncheckedSystemDb(
279
+ createTenantDb(rawDb, testTenantId(1), "system"),
280
+ undefined,
281
+ systemScopeReporter,
282
+ ),
283
+ event: { type: "test", payload: {} },
284
+ steps: {},
285
+ scope: {},
286
+ } as unknown as PipelineCtx;
287
+
288
+ beforeEach(() => {
289
+ mock.clearAllMocks();
290
+ unsafeMock.mockResolvedValue([]);
291
+ systemScopeReportCalls.length = 0;
292
+ });
293
+
294
+ it("findMany with unsafeAllTenants reads through ctx.systemDb, skips the tenant filter, and reports unsafe-raw", async () => {
295
+ const stepDef = getStep("read.findMany");
296
+ unsafeMock.mockResolvedValueOnce([]);
297
+ const reason = "fw#2914 unit test — system-wide read step";
298
+
299
+ await stepDef!.run(
300
+ { name: "list", table: testTable, unsafeAllTenants: { reason } },
301
+ systemScopeCtx,
302
+ );
303
+
304
+ expect(unsafeMock).toHaveBeenCalledTimes(1);
305
+ const [sqlText] = unsafeMock.mock.calls[0]!;
306
+ expect(sqlText).not.toMatch(/tenant_id/);
307
+ expect(systemScopeReportCalls).toEqual([{ kind: "unsafe-raw", reason }]);
308
+ });
309
+
310
+ it("findMany without unsafeAllTenants falls through to the throwing ctx.db proxy", async () => {
311
+ const stepDef = getStep("read.findMany");
312
+
313
+ await expect(
314
+ stepDef!.run({ name: "list", table: testTable }, systemScopeCtx),
315
+ ).rejects.toThrow();
316
+
317
+ expect(unsafeMock).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("findMany with a whitespace-only reason is rejected by systemDb.unsafeRaw before any query runs", async () => {
321
+ const stepDef = getStep("read.findMany");
322
+
323
+ await expect(
324
+ stepDef!.run(
325
+ { name: "list", table: testTable, unsafeAllTenants: { reason: " " } },
326
+ systemScopeCtx,
327
+ ),
328
+ ).rejects.toThrow("non-empty reason");
329
+
330
+ expect(unsafeMock).not.toHaveBeenCalled();
331
+ });
136
332
  });
@@ -1,5 +1,8 @@
1
1
  import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import type { DbRunner } from "../../db/connection";
2
3
  import { table, text, uuid } from "../../db/dialect";
4
+ import { createTenantDb } from "../../db/tenant-db";
5
+ import { testTenantId } from "../../stack";
3
6
  import { getStep } from "../define-step";
4
7
  import { buildUnsafeProjectionDeleteStep } from "../steps/unsafe-projection-delete";
5
8
  import type { PipelineCtx } from "../types/step";
@@ -10,11 +13,11 @@ const testTable = table("test_projection", {
10
13
  label: text("label"),
11
14
  });
12
15
 
13
- // bun-db path: step calls deleteMany(ctx.db.raw, table, where) which lands on
14
- // asRawClient(ctx.db.raw).unsafe(sqlText, params).
16
+ // bun-db path: step calls deleteMany(tenantDbRunner(ctx.db), table, where) which
17
+ // lands on asRawClient(runner).unsafe(sqlText, params).
15
18
  const unsafeMock = mock(async (_sqlText: string, _params: unknown[]): Promise<unknown[]> => []);
16
- const rawDb = { unsafe: unsafeMock, begin: mock() };
17
- const ctxDb = { raw: rawDb };
19
+ const rawDb = { unsafe: unsafeMock, begin: mock() } as DbRunner;
20
+ const ctxDb = createTenantDb(rawDb, testTenantId(1));
18
21
 
19
22
  const mockCtx = {
20
23
  db: ctxDb,
@@ -1,5 +1,8 @@
1
1
  import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import type { DbRunner } from "../../db/connection";
2
3
  import { table, text, uuid } from "../../db/dialect";
4
+ import { createTenantDb } from "../../db/tenant-db";
5
+ import { testTenantId } from "../../stack";
3
6
  import { getStep } from "../define-step";
4
7
  import { buildUnsafeProjectionUpsertStep } from "../steps/unsafe-projection-upsert";
5
8
  import type { PipelineCtx } from "../types/step";
@@ -11,13 +14,13 @@ const testTable = table("test_projection", {
11
14
  label: text("label"),
12
15
  });
13
16
 
14
- // New bun-db path: step uses asRawClient(ctx.db.raw).unsafe(sqlText, params).
17
+ // New bun-db path: step uses asRawClient(tenantDbRunner(ctx.db)).unsafe(sqlText, params).
15
18
  // Capture the raw SQL string + params per call instead of the old
16
19
  // insert/values/onConflictDoUpdate chain.
17
20
  const unsafeMock = mock(async (_sqlText: string, _params: unknown[]) => []);
18
21
  const beginMock = mock(async (fn: (tx: unknown) => Promise<unknown>) => fn({}));
19
- const rawDb = { unsafe: unsafeMock, begin: beginMock };
20
- const ctxDb = { raw: rawDb };
22
+ const rawDb = { unsafe: unsafeMock, begin: beginMock } as DbRunner;
23
+ const ctxDb = createTenantDb(rawDb, testTenantId(1));
21
24
 
22
25
  const mockCtx = {
23
26
  db: ctxDb,
@@ -30,7 +30,21 @@ describe("validateGlobalTenancyEntities", () => {
30
30
  expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemStream/);
31
31
  });
32
32
 
33
- test("a tenancy: 'global' entity WITH systemStream: true passes", () => {
33
+ test("a tenancy: 'global' entity WITH systemStream: true but WITHOUT r.systemScope() throws", () => {
34
+ const entity = createEntity({
35
+ table: "read_fw2860_global_no_scope",
36
+ tenancy: "global",
37
+ systemStream: true,
38
+ fields: { name: textField() },
39
+ });
40
+ const feature = defineFeature("fw2860-global-no-scope", (r) => {
41
+ r.entity("fw2860GlobalNoScope", entity);
42
+ });
43
+
44
+ expect(() => validateGlobalTenancyEntities([feature])).toThrow(/systemScope/);
45
+ });
46
+
47
+ test("a tenancy: 'global' entity WITH systemStream: true AND r.systemScope() passes", () => {
34
48
  const entity = createEntity({
35
49
  table: "read_fw2858_global_with_stream",
36
50
  tenancy: "global",
@@ -38,6 +52,7 @@ describe("validateGlobalTenancyEntities", () => {
38
52
  fields: { name: textField() },
39
53
  });
40
54
  const feature = defineFeature("fw2858-global-with-stream", (r) => {
55
+ r.systemScope();
41
56
  r.entity("fw2858GlobalWithStream", entity);
42
57
  });
43
58
 
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../define-feature";
4
+ import { validateHandlerAccess } from "../entity-handler";
5
+
6
+ describe("validateHandlerAccess — rateLimit: { disabled: true } reason", () => {
7
+ test("an empty reason throws, naming the handler", () => {
8
+ const feature = defineFeature("rl-guard", (r) => {
9
+ r.writeHandler(
10
+ "note:create",
11
+ z.object({ title: z.string() }),
12
+ async () => ({
13
+ isSuccess: true as const,
14
+ data: {},
15
+ }),
16
+ {
17
+ access: { roles: ["Admin"] },
18
+ rateLimit: { disabled: true, reason: "" },
19
+ },
20
+ );
21
+ });
22
+
23
+ expect(() => validateHandlerAccess(feature)).toThrow(/rl-guard:write:note:create/);
24
+ expect(() => validateHandlerAccess(feature)).toThrow(/rateLimit: \{ disabled: true \}/);
25
+ });
26
+
27
+ test("a non-empty reason boots fine", () => {
28
+ const feature = defineFeature("rl-guard", (r) => {
29
+ r.writeHandler(
30
+ "note:create",
31
+ z.object({ title: z.string() }),
32
+ async () => ({
33
+ isSuccess: true as const,
34
+ data: {},
35
+ }),
36
+ {
37
+ access: { roles: ["Admin"] },
38
+ rateLimit: { disabled: true, reason: "ops replay path" },
39
+ },
40
+ );
41
+ });
42
+
43
+ expect(() => validateHandlerAccess(feature)).not.toThrow();
44
+ });
45
+ });
@@ -1,4 +1,5 @@
1
1
  import type { BlurRegion, VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
2
+ import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
3
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
3
4
  import { parseRefTarget } from "../parse-ref-target";
4
5
  import type {
@@ -147,10 +148,28 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
147
148
  );
148
149
  }
149
150
  validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
151
+ validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
150
152
  }
151
153
  }
152
154
  }
153
155
 
156
+ export function validateRateLimitDisabledReason(
157
+ featureName: string,
158
+ kind: "write" | "query" | "stream",
159
+ handlerName: string,
160
+ rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
161
+ ): void {
162
+ // skip: rate limit isn't disabled, so no reason is required
163
+ if (!isRateLimitDisabled(rateLimit)) return;
164
+ if (rateLimit.reason.trim().length === 0) {
165
+ throw new Error(
166
+ `${kind} handler "${featureName}:${kind}:${handlerName}" declares rateLimit: { disabled: true } ` +
167
+ `without a reason. Provide { disabled: true, reason: "..." } explaining why this handler must not ` +
168
+ `be rate-limited.`,
169
+ );
170
+ }
171
+ }
172
+
154
173
  export function validateAnonymousRateLimit(
155
174
  featureName: string,
156
175
  kind: "write" | "query" | "stream",
@@ -160,6 +179,8 @@ export function validateAnonymousRateLimit(
160
179
  ): void {
161
180
  // skip: handler doesn't opt into rate-limit, no user-bucket risk
162
181
  if (!rateLimit) return;
182
+ // skip: disabled declarations carry no `.per` to bucket on
183
+ if (isRateLimitDisabled(rateLimit)) return;
163
184
  // skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
164
185
  // the user-bucket footgun doesn't apply
165
186
  if (!("roles" in access)) return;
@@ -2,16 +2,28 @@ import type { FeatureDefinition } from "../types";
2
2
 
3
3
  // Boot-time twin of event-store-executor-context.ts's buildExecutorContext check —
4
4
  // surfaces the systemStream invariant once across all features before any dispatch.
5
+ //
6
+ // A "global" entity also requires r.systemScope() — otherwise convention writes
7
+ // dispatch in tenant-mode and a global row could carry the creating tenant's id.
5
8
  export function validateGlobalTenancyEntities(features: readonly FeatureDefinition[]): void {
6
9
  for (const feature of features) {
7
10
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
8
- if (entity.tenancy === "global" && entity.systemStream !== true) {
11
+ if (entity.tenancy !== "global") continue;
12
+ if (entity.systemStream !== true) {
9
13
  throw new Error(
10
14
  `Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities must ` +
11
15
  "also declare systemStream: true — a global aggregate's event stream must live on " +
12
16
  "the system tenant, not the creating tenant's.",
13
17
  );
14
18
  }
19
+ if (!feature.systemScope) {
20
+ throw new Error(
21
+ `Feature "${feature.name}" entity "${entityName}": tenancy: "global" entities require ` +
22
+ "their owning feature to declare r.systemScope() — without it, convention writes run " +
23
+ "in tenant-mode and a global row could end up carrying the creating tenant's id " +
24
+ "instead of the system tenant's.",
25
+ );
26
+ }
15
27
  }
16
28
  }
17
29
  }
@@ -11,7 +11,7 @@ import type { EntityDefinition, FeatureDefinition, QueryHandlerDef } from "../ty
11
11
  // framework-internal detail() call that doesn't wire it, so the enforcement
12
12
  // has to happen loudly here instead, once, at boot.
13
13
 
14
- function findEntity(
14
+ export function findEntity(
15
15
  featureMap: ReadonlyMap<string, FeatureDefinition>,
16
16
  entityName: string,
17
17
  ): EntityDefinition | undefined {