@cosmicdrift/kumiko-framework 0.269.2 → 0.273.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +5 -1
  2. package/package.json +3 -3
  3. package/src/__tests__/anonymous-access.integration.test.ts +1 -1
  4. package/src/__tests__/error-contract.integration.test.ts +1 -1
  5. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
  6. package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
  7. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
  8. package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
  9. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
  10. package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
  11. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
  12. package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
  13. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
  14. package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
  15. package/src/__tests__/full-stack.integration.test.ts +2 -2
  16. package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
  17. package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
  18. package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
  19. package/src/__tests__/transition-guard.integration.test.ts +7 -7
  20. package/src/api/__tests__/api.test.ts +2 -2
  21. package/src/api/__tests__/body-limit.test.ts +1 -1
  22. package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
  23. package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
  24. package/src/api/__tests__/request-locale.integration.test.ts +1 -1
  25. package/src/api/__tests__/server-error-logging.test.ts +1 -1
  26. package/src/api/server.ts +7 -20
  27. package/src/bun-db/__tests__/query-guards.test.ts +60 -3
  28. package/src/bun-db/query.ts +24 -21
  29. package/src/changes.json +99 -0
  30. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
  31. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
  32. package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
  33. package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
  34. package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
  35. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
  36. package/src/db/__tests__/tenant-db-global.test.ts +92 -0
  37. package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
  38. package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
  39. package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
  40. package/src/db/apply-entity-event.ts +1 -1
  41. package/src/db/assert-exists-in.ts +1 -1
  42. package/src/db/event-store-executor-context.ts +5 -2
  43. package/src/db/event-store-executor-read.ts +6 -4
  44. package/src/db/event-store-executor-write.ts +24 -18
  45. package/src/db/queries/shadow-swap.ts +36 -0
  46. package/src/db/replay-migration-sql.ts +5 -5
  47. package/src/db/tenant-db-runner.ts +23 -0
  48. package/src/db/tenant-db.ts +97 -24
  49. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
  50. package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
  51. package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
  52. package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
  53. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
  54. package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
  55. package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
  56. package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
  57. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  58. package/src/engine/__tests__/boot-validator.test.ts +69 -18
  59. package/src/engine/__tests__/build-app-schema.test.ts +10 -6
  60. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
  61. package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
  62. package/src/engine/__tests__/engine.test.ts +39 -25
  63. package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
  64. package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
  65. package/src/engine/__tests__/entity-handlers.test.ts +47 -8
  66. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
  67. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
  68. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
  69. package/src/engine/__tests__/extends-registrar.test.ts +3 -3
  70. package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
  71. package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
  72. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  73. package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
  74. package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
  75. package/src/engine/__tests__/post-query-hook.test.ts +6 -2
  76. package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
  77. package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
  78. package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
  79. package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
  80. package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
  81. package/src/engine/__tests__/registry.test.ts +19 -13
  82. package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
  83. package/src/engine/__tests__/screen.test.ts +176 -20
  84. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
  85. package/src/engine/__tests__/steps-read.test.ts +204 -8
  86. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
  87. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
  88. package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
  89. package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
  90. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
  91. package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
  92. package/src/engine/boot-validator/access-declarations.ts +3 -5
  93. package/src/engine/boot-validator/entity-handler.ts +21 -0
  94. package/src/engine/boot-validator/global-tenancy.ts +13 -1
  95. package/src/engine/boot-validator/nav.ts +4 -3
  96. package/src/engine/boot-validator/parent-ref.ts +1 -1
  97. package/src/engine/boot-validator/projection-list-screens.ts +67 -2
  98. package/src/engine/boot-validator/query-output-columns.ts +33 -0
  99. package/src/engine/boot-validator/screens.ts +154 -31
  100. package/src/engine/build-config-feature-schema.ts +12 -2
  101. package/src/engine/entity-handlers.ts +72 -20
  102. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
  103. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
  104. package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
  105. package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
  106. package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
  107. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
  108. package/src/engine/feature-ast/extractors/handlers.ts +16 -2
  109. package/src/engine/feature-ast/extractors/hooks.ts +25 -4
  110. package/src/engine/feature-ast/patcher.ts +15 -3
  111. package/src/engine/feature-ast/patterns.ts +4 -0
  112. package/src/engine/feature-ast/render.ts +3 -0
  113. package/src/engine/feature-entity-handlers.ts +4 -4
  114. package/src/engine/index.ts +5 -0
  115. package/src/engine/pattern-library/shared-fields.ts +7 -3
  116. package/src/engine/registry-validate.ts +12 -4
  117. package/src/engine/schema-builder.ts +6 -0
  118. package/src/engine/screen-helpers.ts +52 -0
  119. package/src/engine/steps/_read-source.ts +12 -0
  120. package/src/engine/steps/read-find-many.ts +8 -2
  121. package/src/engine/steps/read-find-one.ts +11 -8
  122. package/src/engine/steps/unsafe-projection-delete.ts +2 -1
  123. package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
  124. package/src/engine/types/index.ts +8 -1
  125. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
  126. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
  127. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
  128. package/src/errors/i18n/de.yaml +14 -0
  129. package/src/errors/i18n/en.yaml +13 -0
  130. package/src/errors/reasons.ts +4 -0
  131. package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
  132. package/src/i18n/required-surface-keys.ts +6 -2
  133. package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
  134. package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
  135. package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
  136. package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
  137. package/src/jobs/job-runner.ts +35 -5
  138. package/src/observability/__tests__/observability.integration.test.ts +7 -7
  139. package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
  140. package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
  141. package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
  142. package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
  143. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
  144. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
  145. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
  146. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  147. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
  148. package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
  149. package/src/pipeline/__tests__/query-projection.integration.test.ts +172 -17
  150. package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
  151. package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
  152. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
  153. package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
  154. package/src/pipeline/dispatch-query.ts +27 -8
  155. package/src/pipeline/dispatch-shared.ts +141 -57
  156. package/src/pipeline/dispatch-stream.ts +1 -3
  157. package/src/pipeline/dispatch-write.ts +62 -14
  158. package/src/pipeline/dispatcher.ts +20 -0
  159. package/src/pipeline/escape-hatch-report.ts +127 -0
  160. package/src/pipeline/index.ts +8 -0
  161. package/src/pipeline/member-read-only-transaction.ts +75 -0
  162. package/src/pipeline/msp-rebuild.ts +2 -0
  163. package/src/pipeline/projection-rebuild.ts +2 -0
  164. package/src/pipeline/projections-runner.ts +15 -13
  165. package/src/pipeline/system-identity-switch.ts +121 -9
  166. package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
  167. package/src/scripts/codemod/migrate-db-raw.ts +19 -6
  168. package/src/scripts/codemod/migrate-open-to-all.ts +249 -0
  169. package/src/secrets/__tests__/contains-secret.test.ts +5 -5
  170. package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
  171. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
  172. package/src/testing/__tests__/e2e-generator.test.ts +19 -5
  173. package/src/ui-types/index.ts +3 -0
  174. package/src/utils/__tests__/ids.test.ts +29 -1
  175. package/src/utils/ids.ts +10 -1
  176. package/src/utils/index.ts +1 -1
@@ -1,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,
@@ -55,7 +55,8 @@ describe("validateAccessDeclarations", () => {
55
55
  });
56
56
 
57
57
  // 1b. malformed openToAll (from untyped sources) counts as invalid, not a crash.
58
- test.each([{ openToAll: false }, { openToAll: {} }])(
58
+ // `{ openToAll: true }` (deprecated) must fail boot too — it never grants access per isOpenToAllGranted.
59
+ test.each([{ openToAll: false }, { openToAll: {} }, { openToAll: true }])(
59
60
  "malformed openToAll %p throws, naming the handler",
60
61
  (access) => {
61
62
  const feature = defineFeature("notes", (r) => {
@@ -259,9 +260,9 @@ describe("validateAccessDeclarations", () => {
259
260
  expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
260
261
  });
261
262
 
262
- test("the deprecated openToAll: true form cannot carry personalData", () => {
263
- // @ts-expect-error personalData lives only on the { reason } object form
264
- const access: AccessRule = { openToAll: true, personalData: "tenant-members" };
263
+ test("the deprecated openToAll: true form no longer type-checks against AccessRule", () => {
264
+ // @ts-expect-error `openToAll: true` was removed from AccessRule — see #2858
265
+ const access: AccessRule = { openToAll: true };
265
266
  expect(access).toBeDefined();
266
267
  });
267
268
 
@@ -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
 
@@ -52,8 +52,16 @@ describe("validateParentRefs", () => {
52
52
  const entity = joinEntity(validRef);
53
53
  const feature = defineFeature("joins", (r) => {
54
54
  r.entity("join", entity);
55
- r.queryHandler(defineEntityListHandler("join", entity, { access: { openToAll: true } }));
56
- r.queryHandler(defineEntityDetailHandler("join", entity, { access: { openToAll: true } }));
55
+ r.queryHandler(
56
+ defineEntityListHandler("join", entity, {
57
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
58
+ }),
59
+ );
60
+ r.queryHandler(
61
+ defineEntityDetailHandler("join", entity, {
62
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
63
+ }),
64
+ );
57
65
  });
58
66
  expect(() => validate(feature)).not.toThrow();
59
67
  });
@@ -65,7 +73,7 @@ describe("validateParentRefs", () => {
65
73
  name: "host:list",
66
74
  schema: z.object({}),
67
75
  handler: async () => ({ rows: [], nextCursor: null }),
68
- access: { openToAll: true },
76
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
69
77
  });
70
78
  });
71
79
  expect(() => validate(feature)).not.toThrow();
@@ -99,7 +107,11 @@ describe("validateParentRefs", () => {
99
107
  const entity = joinEntity({ ...validRef, allowedTypes: ["host"] });
100
108
  const feature = defineFeature("joins", (r) => {
101
109
  r.entity("join", entity);
102
- r.queryHandler(defineEntityListHandler("join", entity, { access: { openToAll: true } }));
110
+ r.queryHandler(
111
+ defineEntityListHandler("join", entity, {
112
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
113
+ }),
114
+ );
103
115
  });
104
116
  expect(() => validate(feature, [hosts])).not.toThrow();
105
117
  });
@@ -121,7 +133,7 @@ describe("validateParentRefs", () => {
121
133
  name: "join:list",
122
134
  schema: z.object({}),
123
135
  handler: async () => ({ rows: [], nextCursor: null }),
124
- access: { openToAll: true },
136
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
125
137
  });
126
138
  });
127
139
  expect(() => validate(feature)).toThrow(/join:list/);
@@ -134,7 +146,7 @@ describe("validateParentRefs", () => {
134
146
  name: "join:detail",
135
147
  schema: z.object({ id: z.uuid() }),
136
148
  handler: async () => null,
137
- access: { openToAll: true },
149
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
138
150
  });
139
151
  });
140
152
  expect(() => validate(feature)).toThrow(/join:detail/);
@@ -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
+ });
@@ -88,12 +88,10 @@ function candidatePersonalFieldNames(
88
88
  return names;
89
89
  }
90
90
 
91
- // A value that is neither `true` nor an object with a non-empty string `reason`
92
- // counts as "empty" — catches malformed openToAll from untyped sources too.
91
+ // Not an object with a non-empty string `reason` (incl. deprecated `openToAll: true`) counts as "empty".
93
92
  function openToAllReasonIsEmpty(access: AccessRule): boolean {
94
93
  if (!("openToAll" in access)) return false;
95
94
  const openToAll: unknown = access.openToAll;
96
- if (openToAll === true) return false;
97
95
  if (typeof openToAll !== "object" || openToAll === null) return true;
98
96
  if (!("reason" in openToAll) || typeof openToAll.reason !== "string") return true;
99
97
  return openToAll.reason.trim().length === 0;
@@ -125,8 +123,8 @@ function validateOpenToAllReason(
125
123
  if (!openToAllReasonIsEmpty(access)) return;
126
124
  throw new Error(
127
125
  `[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid ` +
128
- '{ openToAll: ... } — must be `true` or { reason: "<non-empty explanation>" } ' +
129
- "for why any authenticated user may call this handler.",
126
+ '{ openToAll: ... } — must be { reason: "<non-empty explanation>" } for why any ' +
127
+ "authenticated user may call this handler. `{ openToAll: true }` is no longer accepted.",
130
128
  );
131
129
  }
132
130
 
@@ -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
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { qualifyEntityName } from "../qualified-name";
6
6
  import type { AccessRule, FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
7
+ import { isOpenToAllGranted } from "../types";
7
8
 
8
9
  export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
9
10
  const set = new Set<string>();
@@ -135,10 +136,10 @@ export function validateNavCycles(
135
136
  }
136
137
  }
137
138
 
138
- // undefined access or `{ openToAll: true }` both mean "visible to everyone"
139
- // — neither has a role-set an inversion check could compare against.
139
+ // undefined or granted openToAll means visible to everyone (no role-set to compare); malformed/denied openToAll returns `[]`, same as an explicit `roles: []`.
140
140
  function navViewerRoles(access: AccessRule | undefined): readonly string[] | undefined {
141
- if (access === undefined || "openToAll" in access) return undefined;
141
+ if (access === undefined) return undefined;
142
+ if ("openToAll" in access) return isOpenToAllGranted(access) ? undefined : [];
142
143
  return access.roles;
143
144
  }
144
145
 
@@ -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 {