@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
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
EscapeHatchDeclaration,
|
|
4
|
+
EscapeHatchReporter,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/handlers";
|
|
3
6
|
import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
4
7
|
import type { TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
5
8
|
import {
|
|
@@ -23,7 +26,9 @@ import {
|
|
|
23
26
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
24
27
|
import { AccessDeniedError, InternalError } from "../errors";
|
|
25
28
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
29
|
+
import { fallbackEscapeHatchReporter } from "../pipeline/escape-hatch-report";
|
|
26
30
|
import type { DbRunner } from "./connection";
|
|
31
|
+
import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner";
|
|
27
32
|
|
|
28
33
|
type Table = SchemaTable;
|
|
29
34
|
|
|
@@ -34,6 +39,30 @@ export {
|
|
|
34
39
|
type UncheckedSystemDb,
|
|
35
40
|
} from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
36
41
|
|
|
42
|
+
const declaredUnsafeRawRunners = new WeakMap<
|
|
43
|
+
TenantDb | UncheckedSystemDb,
|
|
44
|
+
(reason: string) => DbRunner
|
|
45
|
+
>();
|
|
46
|
+
|
|
47
|
+
// Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
|
|
48
|
+
export function unsafeRawForDeclaredStep(
|
|
49
|
+
holder: TenantDb | UncheckedSystemDb,
|
|
50
|
+
reason: string,
|
|
51
|
+
): DbRunner {
|
|
52
|
+
if (reason.trim().length === 0) {
|
|
53
|
+
throw new Error("unsafeRawForDeclaredStep requires a non-empty reason");
|
|
54
|
+
}
|
|
55
|
+
const runner = declaredUnsafeRawRunners.get(holder);
|
|
56
|
+
if (!runner) {
|
|
57
|
+
throw new InternalError({
|
|
58
|
+
message:
|
|
59
|
+
"unsafeRawForDeclaredStep received a holder not built by createTenantDb or " +
|
|
60
|
+
"createUncheckedSystemDb — no declared unsafeRaw runner bound.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return runner(reason);
|
|
64
|
+
}
|
|
65
|
+
|
|
37
66
|
// buildHandlerContext (pipeline/dispatch-shared.ts) always builds "system"
|
|
38
67
|
// mode from the caller's own tenantId, never a foreign one.
|
|
39
68
|
//
|
|
@@ -45,6 +74,7 @@ export {
|
|
|
45
74
|
export function createUncheckedSystemDb(
|
|
46
75
|
db: TenantDb,
|
|
47
76
|
dbOutsideTransaction?: TenantDb,
|
|
77
|
+
report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
|
|
48
78
|
): UncheckedSystemDb {
|
|
49
79
|
const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
|
|
50
80
|
|
|
@@ -65,7 +95,15 @@ export function createUncheckedSystemDb(
|
|
|
65
95
|
return dbOutsideTransaction;
|
|
66
96
|
}
|
|
67
97
|
|
|
68
|
-
|
|
98
|
+
function grantedUnsafeRawRunner(reason: string): DbRunner {
|
|
99
|
+
if (reason.trim().length === 0) {
|
|
100
|
+
throw new Error("unsafeRaw requires a non-empty reason");
|
|
101
|
+
}
|
|
102
|
+
report("unsafe-raw", reason);
|
|
103
|
+
return tenantDbRunner(db);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const uncheckedSystemDb: UncheckedSystemDb = {
|
|
69
107
|
[SYSTEM_SCOPE_CHECK_BRAND]: true,
|
|
70
108
|
|
|
71
109
|
assertTenantMatch(tenantId) {
|
|
@@ -96,15 +134,11 @@ export function createUncheckedSystemDb(
|
|
|
96
134
|
if (reason.trim().length === 0) {
|
|
97
135
|
throw new Error("acknowledgeCrossTenant requires a non-empty reason");
|
|
98
136
|
}
|
|
137
|
+
report("acknowledge-cross-tenant", reason);
|
|
99
138
|
return db;
|
|
100
139
|
},
|
|
101
140
|
|
|
102
|
-
unsafeRaw
|
|
103
|
-
if (reason.trim().length === 0) {
|
|
104
|
-
throw new Error("unsafeRaw requires a non-empty reason");
|
|
105
|
-
}
|
|
106
|
-
return db.raw;
|
|
107
|
-
},
|
|
141
|
+
unsafeRaw: grantedUnsafeRawRunner,
|
|
108
142
|
|
|
109
143
|
outsideTransaction: {
|
|
110
144
|
assertTenantMatch(tenantId) {
|
|
@@ -120,10 +154,14 @@ export function createUncheckedSystemDb(
|
|
|
120
154
|
if (reason.trim().length === 0) {
|
|
121
155
|
throw new Error("acknowledgeCrossTenant requires a non-empty reason");
|
|
122
156
|
}
|
|
123
|
-
|
|
157
|
+
const result = requireOutsideTransactionDb();
|
|
158
|
+
report("acknowledge-cross-tenant", reason);
|
|
159
|
+
return result;
|
|
124
160
|
},
|
|
125
161
|
},
|
|
126
162
|
};
|
|
163
|
+
declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
|
|
164
|
+
return uncheckedSystemDb;
|
|
127
165
|
}
|
|
128
166
|
|
|
129
167
|
// @cast-boundary tenant-db-row
|
|
@@ -151,7 +189,7 @@ function isForeignTenantId(tenantIdValue: unknown): boolean {
|
|
|
151
189
|
// `table.tenantId` property read — the latter only exists on branded EntityTables
|
|
152
190
|
// and silently returned false (no tenant filter!) for plain EntityTableMeta
|
|
153
191
|
// tables like unmanaged direct-write stores, e.g. userSessionTable.
|
|
154
|
-
function hasTenantColumn(table: Table | EntityTableMeta): boolean {
|
|
192
|
+
export function hasTenantColumn(table: Table | EntityTableMeta): boolean {
|
|
155
193
|
const meta = asEntityTableMeta(table);
|
|
156
194
|
if (meta) return meta.columns.some((c) => c.name === "tenant_id");
|
|
157
195
|
return (table as Record<string, unknown>)["tenantId"] !== undefined;
|
|
@@ -162,6 +200,7 @@ function hasTenantColumn(table: Table | EntityTableMeta): boolean {
|
|
|
162
200
|
export type TenantDbGrants = {
|
|
163
201
|
readonly globalWrites?: EscapeHatchDeclaration;
|
|
164
202
|
readonly unsafeRaw?: EscapeHatchDeclaration;
|
|
203
|
+
readonly report?: EscapeHatchReporter;
|
|
165
204
|
};
|
|
166
205
|
|
|
167
206
|
const unsafeRawRebinders = new WeakMap<
|
|
@@ -179,6 +218,24 @@ export function withUnsafeRawGrant(
|
|
|
179
218
|
return rebind ? rebind(grant) : tenantDb;
|
|
180
219
|
}
|
|
181
220
|
|
|
221
|
+
const crossTenantRebinders = new WeakMap<TenantDb, (reason: string) => TenantDb>();
|
|
222
|
+
|
|
223
|
+
// Framework-private (not re-exported from db/index.ts): lifting the tenant filter needs a registered declaration.
|
|
224
|
+
export function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string): TenantDb {
|
|
225
|
+
if (reason.trim().length === 0) {
|
|
226
|
+
throw new Error("acknowledgeConventionCrossTenant requires a non-empty reason");
|
|
227
|
+
}
|
|
228
|
+
const rebind = crossTenantRebinders.get(tenantDb);
|
|
229
|
+
if (!rebind) {
|
|
230
|
+
throw new InternalError({
|
|
231
|
+
message:
|
|
232
|
+
"acknowledgeConventionCrossTenant received a TenantDb not built by createTenantDb — " +
|
|
233
|
+
"no cross-tenant rebinder bound.",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return rebind(reason);
|
|
237
|
+
}
|
|
238
|
+
|
|
182
239
|
export function createTenantDb(
|
|
183
240
|
db: DbRunner,
|
|
184
241
|
tenantId: TenantId,
|
|
@@ -189,6 +246,7 @@ export function createTenantDb(
|
|
|
189
246
|
grants?: TenantDbGrants,
|
|
190
247
|
): TenantDb {
|
|
191
248
|
if (meter) registerStandardMetrics(meter);
|
|
249
|
+
const report = grants?.report ?? fallbackEscapeHatchReporter(tenantId);
|
|
192
250
|
|
|
193
251
|
function withDbSpan<T>(
|
|
194
252
|
operation: "select" | "insert" | "update" | "delete",
|
|
@@ -281,6 +339,10 @@ export function createTenantDb(
|
|
|
281
339
|
});
|
|
282
340
|
}
|
|
283
341
|
|
|
342
|
+
function globalWriteReason(): string {
|
|
343
|
+
return grants?.globalWrites?.reason ?? "";
|
|
344
|
+
}
|
|
345
|
+
|
|
284
346
|
function foreignTenantOnGlobalWrite(
|
|
285
347
|
table: Table | EntityTableMeta,
|
|
286
348
|
tenantIdValue: unknown,
|
|
@@ -315,6 +377,7 @@ export function createTenantDb(
|
|
|
315
377
|
const denied =
|
|
316
378
|
missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
|
|
317
379
|
if (denied) return Promise.reject(denied);
|
|
380
|
+
report("global-write", globalWriteReason());
|
|
318
381
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
|
|
319
382
|
},
|
|
320
383
|
updateMany<T = Record<string, unknown>>(
|
|
@@ -331,6 +394,7 @@ export function createTenantDb(
|
|
|
331
394
|
),
|
|
332
395
|
);
|
|
333
396
|
}
|
|
397
|
+
report("global-write", globalWriteReason());
|
|
334
398
|
return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, where));
|
|
335
399
|
},
|
|
336
400
|
deleteMany(where: WhereObject): Promise<void> {
|
|
@@ -343,31 +407,34 @@ export function createTenantDb(
|
|
|
343
407
|
),
|
|
344
408
|
);
|
|
345
409
|
}
|
|
410
|
+
report("global-write", globalWriteReason());
|
|
346
411
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, where));
|
|
347
412
|
},
|
|
348
413
|
// @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
|
|
349
414
|
} as GlobalTableDb<TTable>;
|
|
350
415
|
}
|
|
351
416
|
|
|
417
|
+
function grantedUnsafeRawRunner(reason: string): DbRunner {
|
|
418
|
+
if (reason.trim().length === 0) {
|
|
419
|
+
throw new Error("unsafeRaw requires a non-empty reason");
|
|
420
|
+
}
|
|
421
|
+
if (!hasGrant(grants?.unsafeRaw)) {
|
|
422
|
+
throw new AccessDeniedError({
|
|
423
|
+
message:
|
|
424
|
+
'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
425
|
+
"the handler or hook to allow unsafeRaw.",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
report("unsafe-raw", reason);
|
|
429
|
+
return db;
|
|
430
|
+
}
|
|
431
|
+
|
|
352
432
|
const tenantDb: TenantDb = {
|
|
353
433
|
tenantId,
|
|
354
434
|
mode,
|
|
355
|
-
raw: db,
|
|
356
435
|
global: globalTable,
|
|
357
436
|
|
|
358
|
-
unsafeRaw
|
|
359
|
-
if (reason.trim().length === 0) {
|
|
360
|
-
throw new Error("unsafeRaw requires a non-empty reason");
|
|
361
|
-
}
|
|
362
|
-
if (!hasGrant(grants?.unsafeRaw)) {
|
|
363
|
-
throw new AccessDeniedError({
|
|
364
|
-
message:
|
|
365
|
-
'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
366
|
-
"the handler or hook to allow unsafeRaw.",
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
return db;
|
|
370
|
-
},
|
|
437
|
+
unsafeRaw: grantedUnsafeRawRunner,
|
|
371
438
|
|
|
372
439
|
selectMany<T = Record<string, unknown>>(
|
|
373
440
|
table: Table | EntityTableMeta,
|
|
@@ -436,9 +503,15 @@ export function createTenantDb(
|
|
|
436
503
|
},
|
|
437
504
|
};
|
|
438
505
|
|
|
506
|
+
declaredUnsafeRawRunners.set(tenantDb, grantedUnsafeRawRunner);
|
|
439
507
|
unsafeRawRebinders.set(tenantDb, (grant) =>
|
|
440
508
|
createTenantDb(db, tenantId, mode, tracer, meter, signal, { ...grants, unsafeRaw: grant }),
|
|
441
509
|
);
|
|
510
|
+
crossTenantRebinders.set(tenantDb, (reason) => {
|
|
511
|
+
report("acknowledge-cross-tenant", reason);
|
|
512
|
+
return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
|
|
513
|
+
});
|
|
514
|
+
bindTenantDbRunner(tenantDb, db);
|
|
442
515
|
return tenantDb;
|
|
443
516
|
}
|
|
444
517
|
|
|
@@ -38,7 +38,7 @@ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
|
|
|
38
38
|
);
|
|
39
39
|
return { isSuccess: true as const, data: result };
|
|
40
40
|
},
|
|
41
|
-
{ access: { openToAll:
|
|
41
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
42
42
|
);
|
|
43
43
|
|
|
44
44
|
// Catches the error INSIDE the handler (not via HTTP serialization) so the
|
|
@@ -69,7 +69,7 @@ const derivativesTestFeature = defineFeature("derivativestest", (r) => {
|
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
|
-
{ access: { openToAll:
|
|
72
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
73
73
|
);
|
|
74
74
|
|
|
75
75
|
r.job("record", { trigger: { manual: true }, runIn: "worker" }, async (payload, ctx) => {
|
|
@@ -47,7 +47,6 @@ function fakeDbWithFileRef(row: { storageKey: string; mimeType: string }): unkno
|
|
|
47
47
|
isDeleted: false,
|
|
48
48
|
};
|
|
49
49
|
return {
|
|
50
|
-
raw: { unsafe: () => {} },
|
|
51
50
|
tenantId: TENANT_ID,
|
|
52
51
|
selectMany: async (_table: unknown, where?: Record<string, unknown>) => {
|
|
53
52
|
const matches = Object.entries(where ?? {}).every(([key, value]) => canned[key] === value);
|
|
@@ -19,10 +19,10 @@ function dashboardFeature(
|
|
|
19
19
|
) {
|
|
20
20
|
return defineFeature("demo", (r) => {
|
|
21
21
|
r.queryHandler("incident:open-count", z.object({}), async () => ({ count: 3 }), {
|
|
22
|
-
access: { openToAll:
|
|
22
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
23
23
|
});
|
|
24
24
|
r.queryHandler("incident:latest", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
25
|
-
access: { openToAll:
|
|
25
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
26
26
|
});
|
|
27
27
|
r.screen({
|
|
28
28
|
id: "overview",
|
|
@@ -245,10 +245,10 @@ describe("validateBoot — dashboard screens", () => {
|
|
|
245
245
|
|
|
246
246
|
const catalogFeature = defineFeature("catalog", (r) => {
|
|
247
247
|
r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
248
|
-
access: { openToAll:
|
|
248
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
249
249
|
});
|
|
250
250
|
r.queryHandler("items:status", z.object({}), async () => ({ enabled: true }), {
|
|
251
|
-
access: { openToAll:
|
|
251
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
252
252
|
});
|
|
253
253
|
r.screen({
|
|
254
254
|
id: "items",
|
|
@@ -289,7 +289,7 @@ describe("validateBoot — dashboard screen panels (fw#2841)", () => {
|
|
|
289
289
|
test("accepts a same-feature short id", () => {
|
|
290
290
|
const feature = defineFeature("demo", (r) => {
|
|
291
291
|
r.queryHandler("items:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
292
|
-
access: { openToAll:
|
|
292
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
293
293
|
});
|
|
294
294
|
r.screen({
|
|
295
295
|
id: "items",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("validateBoot — every screen must resolve its own nav area", () => {
|
|
13
|
+
test("a screen with no nav entry, no listScreenId and no resolvable parent → Throw naming listScreenId", () => {
|
|
14
|
+
const feature = defineFeature("app", (r) => {
|
|
15
|
+
r.nav({ id: "home", label: "app.nav.home" });
|
|
16
|
+
r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
|
|
17
|
+
});
|
|
18
|
+
expect(() => validateBoot([feature])).toThrow(/no nav entry and no resolvable list/);
|
|
19
|
+
expect(() => validateBoot([feature])).toThrow(/listScreenId/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the same screen with listScreenId set → no throw", () => {
|
|
23
|
+
const feature = defineFeature("app", (r) => {
|
|
24
|
+
r.nav({ id: "home", label: "app.nav.home" });
|
|
25
|
+
r.screen({
|
|
26
|
+
id: "orphan-list",
|
|
27
|
+
type: "custom",
|
|
28
|
+
renderer: { react: "List" },
|
|
29
|
+
dormant: true,
|
|
30
|
+
});
|
|
31
|
+
r.screen({
|
|
32
|
+
id: "orphan",
|
|
33
|
+
type: "custom",
|
|
34
|
+
renderer: { react: "Orphan" },
|
|
35
|
+
listScreenId: "orphan-list",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("resolved via a list screen's rowAction navigate target → no throw", () => {
|
|
42
|
+
const feature = defineFeature("app", (r) => {
|
|
43
|
+
r.queryHandler("orphan:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
44
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
45
|
+
});
|
|
46
|
+
r.nav({ id: "home", label: "app.nav.home", screen: "app:screen:orphan-list" });
|
|
47
|
+
r.screen({
|
|
48
|
+
id: "orphan-list",
|
|
49
|
+
type: "projectionList",
|
|
50
|
+
query: "app:query:orphan:list",
|
|
51
|
+
columns: ["name"],
|
|
52
|
+
rowActions: [{ kind: "navigate", id: "open", label: "Open", screen: "orphan" }],
|
|
53
|
+
});
|
|
54
|
+
r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
|
|
55
|
+
});
|
|
56
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("resolved via a list screen's toolbarAction navigate target → no throw", () => {
|
|
60
|
+
const feature = defineFeature("app", (r) => {
|
|
61
|
+
r.queryHandler("orphan:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
62
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
63
|
+
});
|
|
64
|
+
r.nav({ id: "home", label: "app.nav.home", screen: "app:screen:orphan-list" });
|
|
65
|
+
r.screen({
|
|
66
|
+
id: "orphan-list",
|
|
67
|
+
type: "projectionList",
|
|
68
|
+
query: "app:query:orphan:list",
|
|
69
|
+
columns: ["name"],
|
|
70
|
+
toolbarActions: [{ kind: "navigate", id: "create", label: "Create", screen: "orphan" }],
|
|
71
|
+
});
|
|
72
|
+
r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
|
|
73
|
+
});
|
|
74
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("resolved via an entityList screen of the same entity → no throw", () => {
|
|
78
|
+
const feature = defineFeature("app", (r) => {
|
|
79
|
+
r.entity(
|
|
80
|
+
"widget",
|
|
81
|
+
createEntity({
|
|
82
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
r.nav({ id: "home", label: "app.nav.home", screen: "app:screen:widget-list" });
|
|
86
|
+
r.screen({ id: "widget-list", type: "entityList", entity: "widget", columns: ["name"] });
|
|
87
|
+
r.screen({
|
|
88
|
+
id: "widget-edit",
|
|
89
|
+
type: "entityEdit",
|
|
90
|
+
entity: "widget",
|
|
91
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("dormant: true → no throw even without any other resolution", () => {
|
|
98
|
+
const feature = defineFeature("app", (r) => {
|
|
99
|
+
r.nav({ id: "home", label: "app.nav.home" });
|
|
100
|
+
r.screen({
|
|
101
|
+
id: "orphan",
|
|
102
|
+
type: "custom",
|
|
103
|
+
renderer: { react: "Orphan" },
|
|
104
|
+
dormant: true,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a feature set with no nav entries at all (sample/recipe) → the check does not run", () => {
|
|
111
|
+
const feature = defineFeature("recipe", (r) => {
|
|
112
|
+
r.screen({ id: "orphan", type: "custom", renderer: { react: "Orphan" } });
|
|
113
|
+
});
|
|
114
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -50,7 +50,7 @@ function stubListHandler(r: any, entityName: string): void {
|
|
|
50
50
|
name: `${entityName}:list`,
|
|
51
51
|
schema: z.object({}),
|
|
52
52
|
handler: async () => ({ rows: [], nextCursor: null }) as never,
|
|
53
|
-
access: { openToAll:
|
|
53
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|