@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
@@ -86,7 +86,7 @@ const feature = defineFeature("txguard", (r) => {
86
86
  event.user,
87
87
  ctx.db,
88
88
  ),
89
- { access: { openToAll: true } },
89
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
90
90
  );
91
91
 
92
92
  r.writeHandler(
@@ -102,7 +102,7 @@ const feature = defineFeature("txguard", (r) => {
102
102
  event.user,
103
103
  ctx.db,
104
104
  ),
105
- { access: { openToAll: true } },
105
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
106
106
  );
107
107
 
108
108
  r.writeHandler(
@@ -114,7 +114,7 @@ const feature = defineFeature("txguard", (r) => {
114
114
  event.user,
115
115
  ctx.db,
116
116
  ),
117
- { access: { openToAll: true } },
117
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
118
118
  );
119
119
 
120
120
  r.writeHandler(
@@ -130,7 +130,7 @@ const feature = defineFeature("txguard", (r) => {
130
130
  event.user,
131
131
  ctx.db,
132
132
  ),
133
- { access: { openToAll: true } },
133
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
134
134
  );
135
135
 
136
136
  r.writeHandler(
@@ -142,7 +142,7 @@ const feature = defineFeature("txguard", (r) => {
142
142
  event.user,
143
143
  ctx.db,
144
144
  ),
145
- { access: { openToAll: true } },
145
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
146
146
  );
147
147
 
148
148
  r.writeHandler(
@@ -154,7 +154,7 @@ const feature = defineFeature("txguard", (r) => {
154
154
  event.user,
155
155
  ctx.db,
156
156
  ),
157
- { access: { openToAll: true } },
157
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
158
158
  );
159
159
 
160
160
  r.writeHandler(
@@ -170,7 +170,7 @@ const feature = defineFeature("txguard", (r) => {
170
170
  event.user,
171
171
  ctx.db,
172
172
  ),
173
- { access: { openToAll: true } },
173
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
174
174
  );
175
175
  });
176
176
 
@@ -37,7 +37,7 @@ const testFeature = defineFeature("test", (r) => {
37
37
  "item:list",
38
38
  z.object({ search: z.string().optional() }),
39
39
  async () => [{ id: 1, name: "Test" }],
40
- { access: { openToAll: true } },
40
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
41
41
  );
42
42
 
43
43
  r.streamHandler(
@@ -750,7 +750,7 @@ describe("feature-declared HTTP routes (r.httpRoute)", () => {
750
750
  }),
751
751
  );
752
752
  r.queryHandler("item:list", z.object({}), async () => [{ id: 7 }], {
753
- access: { openToAll: true },
753
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
754
754
  });
755
755
  r.httpRoute({
756
756
  method: "GET",
@@ -18,7 +18,7 @@ const testFeature = defineFeature("blob", (r) => {
18
18
  "note:create",
19
19
  z.object({ body: z.string() }),
20
20
  async (event) => ({ isSuccess: true, data: { body: event.payload.body } }),
21
- { access: { openToAll: true } },
21
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
22
22
  );
23
23
  });
24
24
 
@@ -0,0 +1,268 @@
1
+ // fw#2861 — nested-write parent-row ownership check. A custom (non-executor)
2
+ // `:create` handler can hand back an EXISTING row instead of inserting a
3
+ // fresh one (find-or-create, or a bug) — the framework must refuse to attach
4
+ // nested children to a parent row the caller does not own, both across
5
+ // tenants (role-only entity, no ownership field) and within a tenant (an
6
+ // ownership-bound field pointing at a different owner). Modelled on
7
+ // nested-write.integration.test.ts's project/task fixture. Real HTTP via
8
+ // setupTestStack + stack.http.write — never createTestDispatcher.
9
+
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { z } from "zod";
12
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
13
+ import { executeRawQuery } from "../../db/queries/raw-sql";
14
+ import { asRawClient, selectMany } from "../../db/query";
15
+ import { buildEntityTable } from "../../db/table-builder";
16
+ import { createEntity, createTextField, defineFeature, from } from "../../engine";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ testTenantId,
22
+ unsafeCreateEntityTable,
23
+ } from "../../stack";
24
+
25
+ // project2/task2: same-tenant ownership scenario (b/c). ownerId is an
26
+ // ownership-bound field — checkWriteFieldOwnership evaluates it.
27
+ const project2Entity = createEntity({
28
+ table: "nested_own_projects2",
29
+ fields: {
30
+ name: createTextField({ personal: false, reason: "test_fixture", required: true }),
31
+ ownerId: createTextField({
32
+ personal: false,
33
+ reason: "test_fixture",
34
+ required: true,
35
+ access: { write: { User: from("user:id", "ownerId") } },
36
+ }),
37
+ },
38
+ });
39
+ const project2Table = buildEntityTable("project2", project2Entity);
40
+
41
+ const task2Entity = createEntity({
42
+ table: "nested_own_tasks2",
43
+ fields: {
44
+ projectId: createTextField({ personal: false, reason: "test_fixture", required: true }),
45
+ title: createTextField({ personal: false, reason: "test_fixture", required: true }),
46
+ },
47
+ });
48
+ const task2Table = buildEntityTable("task2", task2Entity);
49
+
50
+ // project3/task3: cross-tenant scenario (a). Role-only entity, no ownership
51
+ // field — checkWriteFieldOwnership is a no-op here; only the tenant check
52
+ // can catch it.
53
+ const project3Entity = createEntity({
54
+ table: "nested_own_projects3",
55
+ fields: {
56
+ name: createTextField({ personal: false, reason: "test_fixture", required: true }),
57
+ },
58
+ });
59
+ const project3Table = buildEntityTable("project3", project3Entity);
60
+
61
+ const task3Entity = createEntity({
62
+ table: "nested_own_tasks3",
63
+ fields: {
64
+ projectId: createTextField({ personal: false, reason: "test_fixture", required: true }),
65
+ title: createTextField({ personal: false, reason: "test_fixture", required: true }),
66
+ },
67
+ });
68
+ const task3Table = buildEntityTable("task3", task3Entity);
69
+
70
+ const nestedOwnershipFeature = defineFeature("nested-own", (r) => {
71
+ r.entity("project2", project2Entity);
72
+ r.entity("task2", task2Entity);
73
+ r.relation("project2", "tasks", {
74
+ type: "hasMany",
75
+ target: "task2",
76
+ foreignKey: "projectId",
77
+ nestedWrite: true,
78
+ });
79
+
80
+ r.entity("project3", project3Entity);
81
+ r.entity("task3", task3Entity);
82
+ r.relation("project3", "tasks", {
83
+ type: "hasMany",
84
+ target: "task3",
85
+ foreignKey: "projectId",
86
+ nestedWrite: true,
87
+ });
88
+
89
+ // Custom (non-executor-entry) find-or-create: same-tenant only (tenant-
90
+ // scoped fetchOne), simulating a handler that doesn't strictly insert a
91
+ // fresh row every time — only the "found" branch skips the executor.
92
+ // No escapeHatch needed — the lookup stays within the caller's own tenant.
93
+ r.writeHandler(
94
+ "project2:create",
95
+ z.object({ name: z.string().min(1), ownerId: z.string().uuid().optional() }),
96
+ async (event, ctx) => {
97
+ const existing = await ctx.db.fetchOne(project2Table, { name: event.payload.name });
98
+ if (existing) return { isSuccess: true as const, data: existing };
99
+ const crud = createEventStoreExecutor(project2Table, project2Entity, {
100
+ entityName: "project2",
101
+ });
102
+ return crud.create(
103
+ { name: event.payload.name, ownerId: event.payload.ownerId ?? event.user.id },
104
+ event.user,
105
+ ctx.db,
106
+ );
107
+ },
108
+ { access: { roles: ["Admin", "User"] } },
109
+ );
110
+
111
+ r.writeHandler(
112
+ "task2:create",
113
+ z.object({ projectId: z.string().uuid(), title: z.string().min(1) }),
114
+ async (event, ctx) => {
115
+ const crud = createEventStoreExecutor(task2Table, task2Entity, { entityName: "task2" });
116
+ return crud.create(event.payload, event.user, ctx.db);
117
+ },
118
+ { access: { roles: ["Admin", "User"] } },
119
+ );
120
+
121
+ // Custom find-or-create for the cross-tenant scenario: an unfiltered raw
122
+ // lookup by name (declared escapeHatch) simulates a handler bug that finds
123
+ // a same-named row belonging to a DIFFERENT tenant and hands it back
124
+ // instead of inserting a fresh, tenant-scoped row.
125
+ r.writeHandler(
126
+ "project3:create",
127
+ z.object({ name: z.string().min(1) }),
128
+ async (event, ctx) => {
129
+ const runner = ctx.db.unsafeRaw(
130
+ "fw#2861 test fixture — simulate cross-tenant find-or-create bug",
131
+ );
132
+ const rows = await executeRawQuery<{
133
+ id: string;
134
+ name: string;
135
+ tenant_id: string;
136
+ }>(
137
+ runner,
138
+ `SELECT id, name, tenant_id FROM "${project3Table.tableName}" WHERE name = $1 LIMIT 1`,
139
+ [event.payload.name],
140
+ );
141
+ const existing = rows[0];
142
+ if (existing) {
143
+ return {
144
+ isSuccess: true as const,
145
+ data: { id: existing.id, name: existing.name, tenantId: existing.tenant_id },
146
+ };
147
+ }
148
+ const crud = createEventStoreExecutor(project3Table, project3Entity, {
149
+ entityName: "project3",
150
+ });
151
+ return crud.create({ name: event.payload.name }, event.user, ctx.db);
152
+ },
153
+ {
154
+ access: { roles: ["Admin", "User"] },
155
+ escapeHatch: { reason: "fw#2861 test fixture — simulate cross-tenant find-or-create bug" },
156
+ },
157
+ );
158
+
159
+ r.writeHandler(
160
+ "task3:create",
161
+ z.object({ projectId: z.string().uuid(), title: z.string().min(1) }),
162
+ async (event, ctx) => {
163
+ const crud = createEventStoreExecutor(task3Table, task3Entity, { entityName: "task3" });
164
+ return crud.create(event.payload, event.user, ctx.db);
165
+ },
166
+ { access: { roles: ["Admin", "User"] } },
167
+ );
168
+ });
169
+
170
+ let stack: TestStack;
171
+ const TENANT_A = testTenantId(9101);
172
+ const TENANT_B = testTenantId(9102);
173
+ const ownerUser = createTestUser({ id: 9111, tenantId: TENANT_A, roles: ["User"] });
174
+ const attackerUser = createTestUser({ id: 9112, tenantId: TENANT_A, roles: ["User"] });
175
+ const tenantAOtherUser = createTestUser({ id: 9114, tenantId: TENANT_A, roles: ["User"] });
176
+ const tenantBUser = createTestUser({ id: 9113, tenantId: TENANT_B, roles: ["User"] });
177
+
178
+ beforeAll(async () => {
179
+ stack = await setupTestStack({ features: [nestedOwnershipFeature] });
180
+ await unsafeCreateEntityTable(stack.db, project2Entity, "project2");
181
+ await unsafeCreateEntityTable(stack.db, task2Entity, "task2");
182
+ await unsafeCreateEntityTable(stack.db, project3Entity, "project3");
183
+ await unsafeCreateEntityTable(stack.db, task3Entity, "task3");
184
+ });
185
+
186
+ afterAll(async () => {
187
+ await stack.cleanup();
188
+ });
189
+
190
+ beforeEach(async () => {
191
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${task2Table.tableName}"`);
192
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${task3Table.tableName}"`);
193
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${project2Table.tableName}"`);
194
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${project3Table.tableName}"`);
195
+ });
196
+
197
+ describe("nested-write parent-row ownership check (fw#2861)", () => {
198
+ test("(a) cross-tenant: existing role-only parent row in tenant B, tenant-A caller -> access_denied, zero task rows", async () => {
199
+ // Seed the foreign row in tenant B.
200
+ const seedRes = await stack.http.write(
201
+ "nested-own:write:project3:create",
202
+ { name: "cross-tenant" },
203
+ tenantBUser,
204
+ );
205
+ expect(seedRes.status).toBe(200);
206
+
207
+ const res = await stack.http.write(
208
+ "nested-own:write:project3:create",
209
+ { name: "cross-tenant", tasks: [{ title: "t1" }] },
210
+ tenantAOtherUser,
211
+ );
212
+
213
+ expect(res.status).toBeGreaterThanOrEqual(400);
214
+ expect(res.status).toBeLessThan(500);
215
+ const body = await res.json();
216
+ expect(body.isSuccess).toBe(false);
217
+ expect(body.error.code).toBe("access_denied");
218
+
219
+ const dbTasks = await selectMany(stack.db, task3Table);
220
+ expect(dbTasks).toHaveLength(0);
221
+ });
222
+
223
+ test("(b) same tenant, ownership-bound field, row owned by a different user -> access_denied, zero task rows", async () => {
224
+ const seedRes = await stack.http.write(
225
+ "nested-own:write:project2:create",
226
+ { name: "owned-by-other" },
227
+ ownerUser,
228
+ );
229
+ expect(seedRes.status).toBe(200);
230
+
231
+ const res = await stack.http.write(
232
+ "nested-own:write:project2:create",
233
+ { name: "owned-by-other", tasks: [{ title: "t1" }] },
234
+ attackerUser,
235
+ );
236
+
237
+ expect(res.status).toBeGreaterThanOrEqual(400);
238
+ expect(res.status).toBeLessThan(500);
239
+ const body = await res.json();
240
+ expect(body.isSuccess).toBe(false);
241
+ expect(body.error.code).toBe("access_denied");
242
+
243
+ const dbTasks = await selectMany(stack.db, task2Table);
244
+ expect(dbTasks).toHaveLength(0);
245
+ });
246
+
247
+ test("(c) caller owns the existing same-tenant row -> children attach (regression)", async () => {
248
+ const seedRes = await stack.http.write(
249
+ "nested-own:write:project2:create",
250
+ { name: "owned-by-self" },
251
+ ownerUser,
252
+ );
253
+ expect(seedRes.status).toBe(200);
254
+
255
+ const res = await stack.http.write(
256
+ "nested-own:write:project2:create",
257
+ { name: "owned-by-self", tasks: [{ title: "t1" }] },
258
+ ownerUser,
259
+ );
260
+
261
+ expect(res.status).toBe(200);
262
+ const body = await res.json();
263
+ expect(body.isSuccess).toBe(true);
264
+
265
+ const dbTasks = await selectMany(stack.db, task2Table);
266
+ expect(dbTasks).toHaveLength(1);
267
+ });
268
+ });
@@ -18,7 +18,7 @@ const leakyFeature = defineFeature("leaky", (r) => {
18
18
  defineQueryHandler({
19
19
  name: "raw",
20
20
  schema: z.object({}),
21
- access: { openToAll: true },
21
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
22
22
  handler: async () => ({ email: CIPHERTEXT, note: "plain" }),
23
23
  }),
24
24
  );
@@ -26,7 +26,7 @@ const leakyFeature = defineFeature("leaky", (r) => {
26
26
  defineQueryHandler({
27
27
  name: "clean",
28
28
  schema: z.object({}),
29
- access: { openToAll: true },
29
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
30
30
  handler: async () => ({ email: "marc@example.com" }),
31
31
  }),
32
32
  );
@@ -18,7 +18,7 @@ const localeProbe = defineFeature("locale-probe", (r) => {
18
18
  "read-locale",
19
19
  z.object({}),
20
20
  async (_event, ctx) => ({ isSuccess: true, data: { locale: ctx.locale } }),
21
- { access: { openToAll: true } },
21
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
22
22
  );
23
23
  });
24
24
 
@@ -20,7 +20,7 @@ const boomFeature = defineFeature("boom", (r) => {
20
20
  async () => {
21
21
  throw new Error("disk on fire");
22
22
  },
23
- { access: { openToAll: true } },
23
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
24
24
  );
25
25
  });
26
26
 
package/src/api/server.ts CHANGED
@@ -1,17 +1,10 @@
1
1
  import { Hono } from "hono";
2
2
  import type { DbConnection, PgClient } from "../db/connection";
3
- import { createTenantDb } from "../db/tenant-db";
4
3
  import { createDerivativesContext } from "../derivatives/derivatives-context";
5
4
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names";
6
5
  import { runsInLane } from "../engine/run-in";
7
6
  import { createAnonymousUser } from "../engine/system-user";
8
- import {
9
- type AppContext,
10
- isFileField,
11
- type Registry,
12
- type RunIn,
13
- SYSTEM_TENANT_ID,
14
- } from "../engine/types";
7
+ import { type AppContext, isFileField, type Registry, type RunIn } from "../engine/types";
15
8
  import { createFileContext } from "../files/file-handle";
16
9
  import type { FileRoutesOptions } from "../files/file-routes";
17
10
  import { createFileRoutes } from "../files/file-routes";
@@ -491,9 +484,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
491
484
 
492
485
  // MultiStreamProjections: one EventConsumer per MSP. Handler routes by
493
486
  // event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
494
- // still within one tenant by default — the applier receives the
495
- // tenant-scoped DbRunner; SYSTEM_TENANT_ID events pass through the raw
496
- // baseDb so system-level sinks can read across tenants.
487
+ // still within one tenant by default — the applier receives the plain
488
+ // baseDb DbRunner; tenant consistency comes from the triggering event's
489
+ // own tenantId, not a per-event TenantDb scope.
497
490
  //
498
491
  // Lane-filter (Welle 2.6.b): MSPs declare `runIn` to pin them to a
499
492
  // deploy-lane. An MSP with `runIn: "api"` won't be wired into the
@@ -535,12 +528,6 @@ export function buildServer(options: ServerOptions): KumikoServer {
535
528
  // production. Defensive return for the type-narrowing path.
536
529
  return;
537
530
  }
538
- const scopedDb =
539
- event.tenantId === SYSTEM_TENANT_ID ? baseDb : createTenantDb(baseDb, event.tenantId);
540
- // Hand the raw DbRunner to apply(): MSPs write to their projection
541
- // table directly, they don't go through the TenantDb wrapper.
542
- const rawRunner =
543
- event.tenantId === SYSTEM_TENANT_ID ? baseDb : (scopedDb as { raw: typeof baseDb }).raw; // @cast-boundary engine-bridge
544
531
  // Saga/process-manager ctx: apply can call ctx.appendEvent to cascade
545
532
  // a follow-up event onto another aggregate. Uses the triggering event's
546
533
  // tenantId + userId so the causal chain stays tenant-consistent.
@@ -553,7 +540,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
553
540
  : undefined;
554
541
  const applyCtx = createMultiStreamApplyContext({
555
542
  registry: options.registry,
556
- db: rawRunner,
543
+ db: baseDb,
557
544
  tenantId: event.tenantId,
558
545
  userId: event.metadata.userId,
559
546
  ...(mspOwner && { callerFeature: mspOwner }),
@@ -562,12 +549,12 @@ export function buildServer(options: ServerOptions): KumikoServer {
562
549
  derivatives: createDerivativesContext({
563
550
  files: mspFiles,
564
551
  registry: options.registry,
565
- db: rawRunner,
552
+ db: baseDb,
566
553
  tenantId: event.tenantId,
567
554
  }),
568
555
  }),
569
556
  });
570
- await applyFn(event, rawRunner, applyCtx);
557
+ await applyFn(event, baseDb, applyCtx);
571
558
  // Keep ctx reachable to satisfy the EventConsumerHandler signature.
572
559
  void ctx;
573
560
  },
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { DbRunner } from "../../db/connection";
2
3
  import type { EntityTableMeta } from "../../db/entity-table-meta";
3
- import { incrementCounter, selectMany } from "../query";
4
+ import { createTenantDb } from "../../db/tenant-db";
5
+ import { testTenantId } from "../../stack";
6
+ import { asRawClient, countWhere, incrementCounter, selectMany, transaction } from "../query";
4
7
 
5
8
  const meta: EntityTableMeta = {
6
9
  source: "unmanaged",
@@ -35,9 +38,8 @@ describe("bun-db guards — limit injection", () => {
35
38
  });
36
39
 
37
40
  describe("bun-db guards — silent tenant-scope bypass", () => {
38
- // A TenantDb-shaped handle: has .raw.unsafe + the scoped methods + tenantId.
41
+ // A TenantDb-shaped handle: the scoped methods + tenantId, no `.unsafe` of its own.
39
42
  const tenantDb = {
40
- raw: { unsafe: async () => [] as unknown[] },
41
43
  tenantId: "00000000-0000-4000-8000-000000000001",
42
44
  selectMany: async () => [],
43
45
  fetchOne: async () => undefined,
@@ -52,3 +54,58 @@ describe("bun-db guards — silent tenant-scope bypass", () => {
52
54
  ).rejects.toThrow("does not apply the tenant filter");
53
55
  });
54
56
  });
57
+
58
+ describe("bun-db guards — a real TenantDb has no unfiltered escape via asRawClient", () => {
59
+ const tenantId = testTenantId(1);
60
+
61
+ // A runner whose unsafe()/begin() blow up — proves each guard throws
62
+ // BEFORE reaching the driver, same contract as `explodingDb` above.
63
+ function explodingRunner(): DbRunner {
64
+ return {
65
+ unsafe: async () => {
66
+ throw new Error("unsafe must not be reached — asRawClient should fail closed first");
67
+ },
68
+ begin: async () => {
69
+ throw new Error("begin must not be reached — asRawClient should fail closed first");
70
+ },
71
+ } as unknown as DbRunner;
72
+ }
73
+
74
+ test("asRawClient throws and points callers at ctx.db.unsafeRaw", () => {
75
+ const tdb = createTenantDb(explodingRunner(), tenantId);
76
+ expect(() => asRawClient(tdb)).toThrow("unsafeRaw");
77
+ });
78
+
79
+ test("countWhere throws before issuing any SQL", async () => {
80
+ const tdb = createTenantDb(explodingRunner(), tenantId);
81
+ await expect(countWhere(tdb, meta, {})).rejects.toThrow("unsafeRaw");
82
+ });
83
+
84
+ test("transaction throws before issuing any SQL", async () => {
85
+ const tdb = createTenantDb(explodingRunner(), tenantId);
86
+ await expect(transaction(tdb, async () => "unreached")).rejects.toThrow("unsafeRaw");
87
+ });
88
+ });
89
+
90
+ describe("bun-db guards — selectMany still delegates a real TenantDb, tenant-filtered", () => {
91
+ test("selectMany(tenantDb, ...) issues tenant-filtered SQL via the bound runner", async () => {
92
+ const tenantId = testTenantId(2);
93
+ const captured: { sql: string; values: readonly unknown[] }[] = [];
94
+ const recordingRunner: DbRunner = {
95
+ unsafe: async (sql: string, values: readonly unknown[]) => {
96
+ captured.push({ sql, values });
97
+ return [] as unknown[];
98
+ },
99
+ begin: async () => {
100
+ throw new Error("begin not used in this test");
101
+ },
102
+ } as unknown as DbRunner;
103
+ const tdb = createTenantDb(recordingRunner, tenantId);
104
+
105
+ await selectMany(tdb, meta, {});
106
+
107
+ expect(captured).toHaveLength(1);
108
+ expect(captured[0]?.sql).toMatch(/tenant_id/i);
109
+ expect(captured[0]?.values).toContain(tenantId);
110
+ });
111
+ });
@@ -100,11 +100,14 @@ export function asRawClient(db: unknown): RawClient {
100
100
  if (typeof dbAny["unsafe"] === "function") {
101
101
  return dbAny as unknown as RawClient;
102
102
  }
103
- // TenantDb-shape: framework wrapper exposing the underlying runner as `.raw`.
104
- // Callers that pass `ctx.db` instead of `ctx.db.raw` land here — unwrap once.
105
- const raw = dbAny["raw"];
106
- if (raw && typeof (raw as Record<string, unknown>)["unsafe"] === "function") {
107
- return raw as unknown as RawClient;
103
+ // TenantDb (ctx.db) has no unfiltered escape via asRawClient anymore — fail
104
+ // closed instead of silently unwrapping a tenant-scoped handle.
105
+ if (isTenantDbShape(db)) {
106
+ throw new Error(
107
+ "asRawClient: received a tenant-scoped TenantDb (ctx.db). Raw SQL helpers do not apply the " +
108
+ "tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw(reason) with escapeHatch: " +
109
+ "{ reason } on the handler/hook (r.systemScope() features: ctx.systemDb.unsafeRaw(reason)).",
110
+ );
108
111
  }
109
112
  // Drizzle DbConnection (legacy compat): $client = postgres-js Sql.
110
113
  const $client = dbAny["$client"];
@@ -118,7 +121,7 @@ export function asRawClient(db: unknown): RawClient {
118
121
  return sessionClient as unknown as RawClient;
119
122
  }
120
123
  throw new Error(
121
- "bun-db: db argument has no .unsafe() — pass Bun.SQL, postgres-js Sql, TenantDb, or a transaction handle.",
124
+ "bun-db: db argument has no .unsafe() — pass Bun.SQL, postgres-js Sql, or a transaction handle.",
122
125
  );
123
126
  }
124
127
 
@@ -199,35 +202,35 @@ type TenantDbDelegate = {
199
202
  deleteMany(table: TableLike, where: WhereObject): Promise<void>;
200
203
  };
201
204
 
202
- function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
203
- if (typeof db !== "object" || db === null) return undefined;
205
+ // Duck-types TenantDb: five scoped methods + tenantId, no `.unsafe` of its own.
206
+ // A throwing systemScope guard Proxy fails closed here too — intended, not a bug.
207
+ function isTenantDbShape(db: unknown): boolean {
208
+ if (typeof db !== "object" || db === null) return false;
204
209
  const d = db as Record<string, unknown>;
205
- const raw = d["raw"];
206
- if (
207
- raw &&
208
- typeof (raw as Record<string, unknown>)["unsafe"] === "function" &&
210
+ if (typeof d["unsafe"] === "function") return false;
211
+ return (
209
212
  typeof d["selectMany"] === "function" &&
210
213
  typeof d["fetchOne"] === "function" &&
211
214
  typeof d["insertOne"] === "function" &&
212
215
  typeof d["updateMany"] === "function" &&
213
216
  typeof d["deleteMany"] === "function" &&
214
217
  "tenantId" in d
215
- ) {
216
- return db as TenantDbDelegate;
217
- }
218
- return undefined;
218
+ );
219
+ }
220
+
221
+ function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
222
+ return isTenantDbShape(db) ? (db as TenantDbDelegate) : undefined;
219
223
  }
220
224
 
221
- // Guard for helpers that do NOT delegate to TenantDb (upsert/increment/insertMany):
222
- // passing a TenantDb here would silently run unscoped against `.raw`, bypassing
223
- // the tenant filter. Fail loudly so the caller picks `ctx.db.<method>` (scoped)
224
- // or `ctx.db.raw` (explicit cross-tenant) on purpose.
225
+ // Fails loudly for helpers that don't delegate to TenantDb (upsert/increment/insertMany) —
226
+ // passing one here would otherwise reach asRawClient and throw with a less specific message.
225
227
  // @wrapper-known semantic-alias
226
228
  function assertNotTenantScoped(db: unknown, fnName: string): void {
227
229
  if (tenantDbDelegate(db) !== undefined) {
228
230
  throw new Error(
229
231
  `${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
230
- `Pass ctx.db.raw for an explicit cross-tenant op, or use a scoped TenantDb method.`,
232
+ "Pass ctx.db.unsafeRaw(reason) (with escapeHatch: { reason } on the handler/hook) for an " +
233
+ "explicit cross-tenant op, or use a scoped TenantDb method.",
231
234
  );
232
235
  }
233
236
  }