@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.
- package/README.md +5 -1
- package/package.json +3 -3
- package/src/__tests__/anonymous-access.integration.test.ts +1 -1
- package/src/__tests__/error-contract.integration.test.ts +1 -1
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.expected.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/manual-only.input.ts +44 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.expected.ts +41 -0
- package/src/__tests__/fixtures/migrate-cross-tenant/rewrite.input.ts +41 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.expected.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/manual-only.input.ts +16 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.expected.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-nested-shorthand.input.ts +12 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.expected.ts +7 -0
- package/src/__tests__/fixtures/migrate-open-to-all/test-simple.input.ts +7 -0
- package/src/__tests__/full-stack.integration.test.ts +2 -2
- package/src/__tests__/migrate-cross-tenant-codemod.test.ts +106 -0
- package/src/__tests__/migrate-open-to-all-codemod.test.ts +84 -0
- package/src/__tests__/related-list-parent-filter.integration.test.ts +103 -0
- package/src/__tests__/transition-guard.integration.test.ts +7 -7
- package/src/api/__tests__/api.test.ts +2 -2
- package/src/api/__tests__/body-limit.test.ts +1 -1
- package/src/api/__tests__/nested-write-ownership.integration.test.ts +268 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -2
- package/src/api/__tests__/request-locale.integration.test.ts +1 -1
- package/src/api/__tests__/server-error-logging.test.ts +1 -1
- package/src/api/server.ts +7 -20
- package/src/bun-db/__tests__/query-guards.test.ts +60 -3
- package/src/bun-db/query.ts +24 -21
- package/src/changes.json +99 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +77 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +1 -1
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +1 -1
- package/src/db/__tests__/replay-migration-sql.test.ts +17 -0
- package/src/db/__tests__/tenant-db-convention-cross-tenant.test.ts +72 -0
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +104 -0
- package/src/db/__tests__/tenant-db-global.test.ts +92 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +75 -0
- package/src/db/__tests__/tenant-db.integration.test.ts +3 -1
- package/src/db/__tests__/unchecked-system-db.test.ts +76 -0
- package/src/db/apply-entity-event.ts +1 -1
- package/src/db/assert-exists-in.ts +1 -1
- package/src/db/event-store-executor-context.ts +5 -2
- package/src/db/event-store-executor-read.ts +6 -4
- package/src/db/event-store-executor-write.ts +24 -18
- package/src/db/queries/shadow-swap.ts +36 -0
- package/src/db/replay-migration-sql.ts +5 -5
- package/src/db/tenant-db-runner.ts +23 -0
- package/src/db/tenant-db.ts +97 -24
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +2 -2
- package/src/derivatives/__tests__/derivatives-context.test.ts +0 -1
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +5 -5
- package/src/engine/__tests__/boot-validator-nav-area.test.ts +116 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +108 -17
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +161 -18
- package/src/engine/__tests__/boot-validator-query-refs.test.ts +3 -3
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +69 -18
- package/src/engine/__tests__/build-app-schema.test.ts +10 -6
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +48 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +34 -5
- package/src/engine/__tests__/engine.test.ts +39 -25
- package/src/engine/__tests__/entity-create-caller-id.integration.test.ts +87 -0
- package/src/engine/__tests__/entity-handlers-escape-hatch.test.ts +91 -0
- package/src/engine/__tests__/entity-handlers.test.ts +47 -8
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +2 -2
- package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +30 -5
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +206 -47
- package/src/engine/__tests__/extends-registrar.test.ts +3 -3
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +9 -3
- package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +9 -3
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/post-query-hook.test.ts +6 -2
- package/src/engine/__tests__/projection-detail-actions.test.ts +2 -2
- package/src/engine/__tests__/projection-detail-singleton.test.ts +3 -3
- package/src/engine/__tests__/projection-detail-tabs.test.ts +3 -3
- package/src/engine/__tests__/projection-list-filter.integration.test.ts +1 -1
- package/src/engine/__tests__/read-steps-tenant-filter.integration.test.ts +215 -0
- package/src/engine/__tests__/registry.test.ts +19 -13
- package/src/engine/__tests__/required-surface-keys.test.ts +16 -0
- package/src/engine/__tests__/screen.test.ts +176 -20
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -1
- package/src/engine/__tests__/steps-read.test.ts +204 -8
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +7 -4
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +6 -3
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +5 -4
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +16 -1
- package/src/engine/boot-validator/__tests__/parent-ref.test.ts +18 -6
- package/src/engine/boot-validator/__tests__/rate-limit-disabled-reason.test.ts +45 -0
- package/src/engine/boot-validator/access-declarations.ts +3 -5
- package/src/engine/boot-validator/entity-handler.ts +21 -0
- package/src/engine/boot-validator/global-tenancy.ts +13 -1
- package/src/engine/boot-validator/nav.ts +4 -3
- package/src/engine/boot-validator/parent-ref.ts +1 -1
- package/src/engine/boot-validator/projection-list-screens.ts +67 -2
- package/src/engine/boot-validator/query-output-columns.ts +33 -0
- package/src/engine/boot-validator/screens.ts +154 -31
- package/src/engine/build-config-feature-schema.ts +12 -2
- package/src/engine/entity-handlers.ts +72 -20
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +3 -1
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +2 -2
- package/src/engine/feature-ast/__tests__/parse.test.ts +106 -4
- package/src/engine/feature-ast/__tests__/patcher.test.ts +3 -3
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +5 -1
- package/src/engine/feature-ast/extractors/handlers.ts +16 -2
- package/src/engine/feature-ast/extractors/hooks.ts +25 -4
- package/src/engine/feature-ast/patcher.ts +15 -3
- package/src/engine/feature-ast/patterns.ts +4 -0
- package/src/engine/feature-ast/render.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +4 -4
- package/src/engine/index.ts +5 -0
- package/src/engine/pattern-library/shared-fields.ts +7 -3
- package/src/engine/registry-validate.ts +12 -4
- package/src/engine/schema-builder.ts +6 -0
- package/src/engine/screen-helpers.ts +52 -0
- package/src/engine/steps/_read-source.ts +12 -0
- package/src/engine/steps/read-find-many.ts +8 -2
- package/src/engine/steps/read-find-one.ts +11 -8
- package/src/engine/steps/unsafe-projection-delete.ts +2 -1
- package/src/engine/steps/unsafe-projection-upsert.ts +2 -1
- package/src/engine/types/index.ts +8 -1
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -1
- package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +3 -3
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -1
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/es-ops/__tests__/runner.integration.test.ts +2 -2
- package/src/i18n/required-surface-keys.ts +6 -2
- package/src/jobs/__tests__/job-event-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-multi-trigger.integration.test.ts +2 -2
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +5 -4
- package/src/jobs/__tests__/jobs.integration.test.ts +42 -1
- package/src/jobs/job-runner.ts +35 -5
- package/src/observability/__tests__/observability.integration.test.ts +7 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +2 -2
- package/src/pipeline/__tests__/dispatcher.test.ts +15 -15
- package/src/pipeline/__tests__/escape-hatch-audit.integration.test.ts +254 -0
- package/src/pipeline/__tests__/escape-hatch-report.test.ts +149 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +10 -10
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -1
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +28 -0
- package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +94 -0
- package/src/pipeline/__tests__/projections-runner-systemscope.integration.test.ts +101 -0
- package/src/pipeline/__tests__/query-projection.integration.test.ts +172 -17
- package/src/pipeline/__tests__/rate-limit-default.integration.test.ts +118 -0
- package/src/pipeline/__tests__/rate-limit-default.test.ts +117 -0
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +5 -2
- package/src/pipeline/__tests__/system-identity-switch.test.ts +240 -1
- package/src/pipeline/dispatch-query.ts +27 -8
- package/src/pipeline/dispatch-shared.ts +141 -57
- package/src/pipeline/dispatch-stream.ts +1 -3
- package/src/pipeline/dispatch-write.ts +62 -14
- package/src/pipeline/dispatcher.ts +20 -0
- package/src/pipeline/escape-hatch-report.ts +127 -0
- package/src/pipeline/index.ts +8 -0
- package/src/pipeline/member-read-only-transaction.ts +75 -0
- package/src/pipeline/msp-rebuild.ts +2 -0
- package/src/pipeline/projection-rebuild.ts +2 -0
- package/src/pipeline/projections-runner.ts +15 -13
- package/src/pipeline/system-identity-switch.ts +121 -9
- package/src/scripts/codemod/migrate-cross-tenant.ts +335 -0
- package/src/scripts/codemod/migrate-db-raw.ts +19 -6
- package/src/scripts/codemod/migrate-open-to-all.ts +249 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -5
- package/src/stack/__tests__/request-helper.integration.test.ts +3 -3
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +19 -5
- package/src/ui-types/index.ts +3 -0
- package/src/utils/__tests__/ids.test.ts +29 -1
- package/src/utils/ids.ts +10 -1
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
21
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
22
22
|
);
|
|
23
23
|
});
|
|
24
24
|
|
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
|
-
//
|
|
496
|
-
//
|
|
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:
|
|
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:
|
|
552
|
+
db: baseDb,
|
|
566
553
|
tenantId: event.tenantId,
|
|
567
554
|
}),
|
|
568
555
|
}),
|
|
569
556
|
});
|
|
570
|
-
await applyFn(event,
|
|
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 {
|
|
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:
|
|
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
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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,
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function tenantDbDelegate(db: unknown): TenantDbDelegate | undefined {
|
|
222
|
+
return isTenantDbShape(db) ? (db as TenantDbDelegate) : undefined;
|
|
219
223
|
}
|
|
220
224
|
|
|
221
|
-
//
|
|
222
|
-
// passing
|
|
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
|
-
|
|
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
|
}
|