@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
|
@@ -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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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("
|
|
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"
|
|
93
|
-
expect(params).toEqual([
|
|
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
|
|
14
|
-
// asRawClient(
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
263
|
-
// @ts-expect-error
|
|
264
|
-
const access: AccessRule = { openToAll: true
|
|
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
|
|
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(
|
|
56
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
129
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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 {
|