@cosmicdrift/kumiko-framework 0.363.0 → 0.364.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/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +8 -3
- package/dist/bun-db/query.js +27 -7
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/eagerload.js +2 -2
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +24 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.js +9 -8
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +30 -4
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +1 -1
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +8 -4
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +99 -22
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +36 -9
- package/package.json +7 -5
- package/src/changes.json +207 -8
|
@@ -15,3 +15,20 @@ export function tenantDbRunner(tenantDb) {
|
|
|
15
15
|
}
|
|
16
16
|
return runner;
|
|
17
17
|
}
|
|
18
|
+
const writesDeniedReasons = new WeakMap();
|
|
19
|
+
export function denyTenantDbWrites(tenantDb, reason) {
|
|
20
|
+
writesDeniedReasons.set(tenantDb, reason);
|
|
21
|
+
}
|
|
22
|
+
export function tenantDbWritesDeniedError(operation, reason) {
|
|
23
|
+
return new InternalError({
|
|
24
|
+
message: `${operation}: writes are not available ${reason}; run the write through ctx.write so it gets its own transaction`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
// For framework code that writes through the raw runner (executor verbs, event appends, projection steps):
|
|
28
|
+
// the TenantDb's own write methods are denied via TenantDbGrants, this closes the runner-based door.
|
|
29
|
+
export function tenantDbWriteRunner(tenantDb, operation) {
|
|
30
|
+
const reason = writesDeniedReasons.get(tenantDb);
|
|
31
|
+
if (reason !== undefined)
|
|
32
|
+
throw tenantDbWritesDeniedError(operation, reason);
|
|
33
|
+
return tenantDbRunner(tenantDb);
|
|
34
|
+
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare function unsafeRawForDeclaredStep(holder: TenantDb | UncheckedSys
|
|
|
16
16
|
export declare function withSystemDbUnsafeRawGrant(systemDb: UncheckedSystemDb, grant: EscapeHatchDeclaration | undefined, deniedCallerLabel: string): UncheckedSystemDb;
|
|
17
17
|
export declare function createUncheckedSystemDb(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
|
|
18
18
|
export declare function createSystemDbView(db: TenantDb, dbOutsideTransaction?: TenantDb, report?: EscapeHatchReporter): UncheckedSystemDb;
|
|
19
|
+
export declare function scopeTenantDbToTenant(db: TenantDb, tenantId: TenantId): TenantDb;
|
|
19
20
|
export declare function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[];
|
|
20
21
|
export { tableNameOf };
|
|
21
22
|
export declare function hasTenantColumn(table: Table | EntityTableMeta): boolean;
|
|
@@ -26,6 +27,7 @@ export type TenantDbGrants = {
|
|
|
26
27
|
readonly memberReadOnly?: boolean;
|
|
27
28
|
readonly personalDataGate?: PersonalDataGate;
|
|
28
29
|
readonly projectionRegistry?: Registry;
|
|
30
|
+
readonly writesDeniedReason?: string;
|
|
29
31
|
};
|
|
30
32
|
export type PersonalDataGate = (tableName: string, keys: readonly string[], entity?: EntityDefinition) => void;
|
|
31
33
|
export declare function withUnsafeRawGrant(tenantDb: TenantDb, grant: EscapeHatchDeclaration | undefined): TenantDb;
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -5,7 +5,7 @@ import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } fro
|
|
|
5
5
|
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
6
6
|
import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
|
|
7
7
|
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
8
|
-
import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
|
|
8
|
+
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
|
|
9
9
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
10
10
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
11
11
|
// The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
|
|
@@ -220,6 +220,12 @@ export function createUncheckedSystemDb(db, dbOutsideTransaction, report = fallb
|
|
|
220
220
|
export function createSystemDbView(db, dbOutsideTransaction, report = fallbackEscapeHatchReporter(db.tenantId)) {
|
|
221
221
|
return buildUncheckedSystemDb(db, dbOutsideTransaction, report, { kind: "source-tenant-db" });
|
|
222
222
|
}
|
|
223
|
+
// Narrows a (possibly "system"-mode) TenantDb to a "tenant"-mode handle for one tenant on the
|
|
224
|
+
// same connection/transaction. For code that must run caller-supplied callbacks (e.g. config
|
|
225
|
+
// validators) without handing them a cross-tenant view.
|
|
226
|
+
export function scopeTenantDbToTenant(db, tenantId) {
|
|
227
|
+
return createTenantDb(tenantDbRunner(db), tenantId, "tenant");
|
|
228
|
+
}
|
|
223
229
|
// @cast-boundary tenant-db-row
|
|
224
230
|
export function castTenantRows(rows) {
|
|
225
231
|
return rows;
|
|
@@ -390,6 +396,10 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
390
396
|
message: `db.global(${tableNameOf(table)}): writes on executor-managed entity tables must go through the entity executor`,
|
|
391
397
|
});
|
|
392
398
|
}
|
|
399
|
+
function writesDenied(operation) {
|
|
400
|
+
const reason = grants?.writesDeniedReason;
|
|
401
|
+
return reason === undefined ? undefined : tenantDbWritesDeniedError(operation, reason);
|
|
402
|
+
}
|
|
393
403
|
function globalWriteReason() {
|
|
394
404
|
return grants?.globalWrites?.reason ?? "";
|
|
395
405
|
}
|
|
@@ -437,7 +447,8 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
437
447
|
return withDbSpan("select", table, async () => bunFetchOne(db, table, where));
|
|
438
448
|
},
|
|
439
449
|
insertOne(values) {
|
|
440
|
-
const denied =
|
|
450
|
+
const denied = writesDenied("ctx.db.global().insertOne") ??
|
|
451
|
+
missingEscapeHatch(table) ??
|
|
441
452
|
executorManagedWriteDenied(table) ??
|
|
442
453
|
foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
|
|
443
454
|
personalDataDenied(table, Object.keys(values));
|
|
@@ -447,7 +458,8 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
447
458
|
return withDbSpan("insert", table, async () => bunInsertOne(db, table, values));
|
|
448
459
|
},
|
|
449
460
|
updateMany(set, where) {
|
|
450
|
-
const denied =
|
|
461
|
+
const denied = writesDenied("ctx.db.global().updateMany") ??
|
|
462
|
+
missingEscapeHatch(table) ??
|
|
451
463
|
executorManagedWriteDenied(table) ??
|
|
452
464
|
foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
|
|
453
465
|
personalDataDenied(table, Object.keys(set));
|
|
@@ -460,7 +472,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
460
472
|
return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, where));
|
|
461
473
|
},
|
|
462
474
|
deleteMany(where) {
|
|
463
|
-
const denied =
|
|
475
|
+
const denied = writesDenied("ctx.db.global().deleteMany") ??
|
|
476
|
+
missingEscapeHatch(table) ??
|
|
477
|
+
executorManagedWriteDenied(table);
|
|
464
478
|
if (denied)
|
|
465
479
|
return Promise.reject(denied);
|
|
466
480
|
if (!where || Object.keys(where).length === 0) {
|
|
@@ -509,6 +523,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
509
523
|
return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
|
|
510
524
|
},
|
|
511
525
|
insertOne(table, values) {
|
|
526
|
+
const writeDenied = writesDenied("ctx.db.insertOne");
|
|
527
|
+
if (writeDenied)
|
|
528
|
+
return Promise.reject(writeDenied);
|
|
512
529
|
if (mode === "tenant" &&
|
|
513
530
|
hasTenantColumn(table) &&
|
|
514
531
|
asEntityTableMeta(table)?.tenancy === "global") {
|
|
@@ -524,6 +541,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
524
541
|
return withDbSpan("insert", table, async () => bunInsertOne(db, table, data));
|
|
525
542
|
},
|
|
526
543
|
updateMany(table, set, where) {
|
|
544
|
+
const writeDenied = writesDenied("ctx.db.updateMany");
|
|
545
|
+
if (writeDenied)
|
|
546
|
+
return Promise.reject(writeDenied);
|
|
527
547
|
if (!where || Object.keys(where).length === 0) {
|
|
528
548
|
return Promise.reject(new Error("TenantDb.updateMany without where would mass-update all tenant rows. Pass at least one where condition."));
|
|
529
549
|
}
|
|
@@ -537,6 +557,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
537
557
|
return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, filter));
|
|
538
558
|
},
|
|
539
559
|
deleteMany(table, where) {
|
|
560
|
+
const writeDenied = writesDenied("ctx.db.deleteMany");
|
|
561
|
+
if (writeDenied)
|
|
562
|
+
return Promise.reject(writeDenied);
|
|
540
563
|
if (!where || Object.keys(where).length === 0) {
|
|
541
564
|
return Promise.reject(new Error("TenantDb.deleteMany without where would mass-delete all tenant rows. Pass at least one where condition."));
|
|
542
565
|
}
|
|
@@ -573,6 +596,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
573
596
|
return runInNewTransaction(db, (tx) => fn(createTenantDb(tx, tenantId, mode, tracer, meter, signal, txGrants)));
|
|
574
597
|
});
|
|
575
598
|
bindTenantDbRunner(tenantDb, db);
|
|
599
|
+
if (grants?.writesDeniedReason !== undefined) {
|
|
600
|
+
denyTenantDbWrites(tenantDb, grants.writesDeniedReason);
|
|
601
|
+
}
|
|
576
602
|
if (personalDataGate)
|
|
577
603
|
personalDataGates.set(tenantDb, personalDataGate);
|
|
578
604
|
if (projectionRegistry)
|
|
@@ -181,7 +181,7 @@ export function createDerivativesContext(deps) {
|
|
|
181
181
|
// Resolves qr dataTokens to concrete values BEFORE the spec is hashed
|
|
182
182
|
// — see resolveOverlaySpec.
|
|
183
183
|
const resolvedSpec = await resolveOverlaySpec(spec, row, deps.registry, deps.tenantId);
|
|
184
|
-
const src = deps.files.
|
|
184
|
+
const src = deps.files.refFromFileRef(row);
|
|
185
185
|
// ponytail: derived key keeps the source extension regardless of the
|
|
186
186
|
// spec's format — widen deriveKey if a storage backend ever routes on
|
|
187
187
|
// extension.
|
|
@@ -83,7 +83,7 @@ function validatePersonalDataSupportedForKind(feature, kind, handlerName, access
|
|
|
83
83
|
? 'only access.personalData: "public-intake" applies to query handlers, which write ' +
|
|
84
84
|
"personal data through nested writes of an anonymous request."
|
|
85
85
|
: "it only applies to write handlers, whose input is checked for personal-data fields.";
|
|
86
|
-
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ${property} — ${onlyAllowed}
|
|
86
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ${property} — ${onlyAllowed} Remove the personalData declaration from this handler.`);
|
|
87
87
|
}
|
|
88
88
|
function validatePersonalDataValue(feature, kind, handlerName, access) {
|
|
89
89
|
const declared = declaredPersonalData(access);
|
|
@@ -107,7 +107,7 @@ function validatePersonalDataValue(feature, kind, handlerName, access) {
|
|
|
107
107
|
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
108
108
|
'access.personalData: "public-intake" but its roles do not include ' +
|
|
109
109
|
`"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
|
|
110
|
-
"that allow anonymous callers.");
|
|
110
|
+
"that allow anonymous callers. Add the anonymous role to access.roles or remove personalData.");
|
|
111
111
|
}
|
|
112
112
|
function handlerInputKeys(feature, handlerName, handler) {
|
|
113
113
|
try {
|
|
@@ -31,7 +31,7 @@ export function validateApiExposureMatching(feature, allExposedApis, featureMap)
|
|
|
31
31
|
// Should always be true if allExposedApis was built from `features`,
|
|
32
32
|
// aber defensiv für unklare Constructor-Pfade.
|
|
33
33
|
if (!featureMap.has(providerFeature)) {
|
|
34
|
-
throw new Error(`[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map
|
|
34
|
+
throw new Error(`[Feature ${feature.name}] internal: r.usesApi("${apiName}") points to provider "${providerFeature}" which is not in feature map. Register the provider feature in the app's feature list.`);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -40,7 +40,7 @@ export function validateExtensionUsages(feature, extensionProviders) {
|
|
|
40
40
|
for (const usage of feature.extensionUsages) {
|
|
41
41
|
const providerFeature = extensionProviders.get(usage.extensionName);
|
|
42
42
|
if (!providerFeature) {
|
|
43
|
-
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension
|
|
43
|
+
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" on entity "${usage.entityName}" but no feature defines that extension. Register the feature that defines it or fix the extension name.`);
|
|
44
44
|
}
|
|
45
45
|
// self-extension is legitimate: requires(self) would be circular.
|
|
46
46
|
if (providerFeature === feature.name) {
|
|
@@ -48,7 +48,7 @@ export function validateExtensionUsages(feature, extensionProviders) {
|
|
|
48
48
|
}
|
|
49
49
|
const allDeps = [...feature.requires, ...feature.optionalRequires];
|
|
50
50
|
if (!allDeps.includes(providerFeature)) {
|
|
51
|
-
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}")
|
|
51
|
+
throw new Error(`Feature "${feature.name}" uses extension "${usage.extensionName}" but missing requires("${providerFeature}"). Add r.requires("${providerFeature}").`);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -13,7 +13,7 @@ export function validateFeatureBootChecks(features) {
|
|
|
13
13
|
}
|
|
14
14
|
catch (err) {
|
|
15
15
|
const message = err instanceof Error ? err.message : String(err);
|
|
16
|
-
throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}
|
|
16
|
+
throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}. Fix the condition this check reports.`);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -38,15 +38,15 @@ export function validateConfigKeyBounds(feature) {
|
|
|
38
38
|
}
|
|
39
39
|
const { min, max } = bounds;
|
|
40
40
|
if (min !== undefined && max !== undefined && min > max) {
|
|
41
|
-
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has bounds.min (${min}) > bounds.max (${max})
|
|
41
|
+
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has bounds.min (${min}) > bounds.max (${max}). Set bounds.min to a value at or below bounds.max.`);
|
|
42
42
|
}
|
|
43
43
|
if (keyDef.default !== undefined) {
|
|
44
44
|
const defaultNum = keyDef.default; // @cast-boundary engine-payload
|
|
45
45
|
if (min !== undefined && defaultNum < min) {
|
|
46
|
-
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is below bounds.min (${min})
|
|
46
|
+
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is below bounds.min (${min}). Raise the default or lower bounds.min.`);
|
|
47
47
|
}
|
|
48
48
|
if (max !== undefined && defaultNum > max) {
|
|
49
|
-
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is above bounds.max (${max})
|
|
49
|
+
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" default (${defaultNum}) is above bounds.max (${max}). Lower the default or raise bounds.max.`);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -61,7 +61,7 @@ export function validateConfigKeyComputed(feature) {
|
|
|
61
61
|
// cascade doesn't know which one to prefer on write. Rejecting at boot
|
|
62
62
|
// is cheaper than surprising behaviour at runtime.
|
|
63
63
|
if (isEncryptedAtRest(keyDef)) {
|
|
64
|
-
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has both encrypted/backing="secrets" and a computed resolver — these are mutually exclusive paradigms
|
|
64
|
+
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has both encrypted/backing="secrets" and a computed resolver — these are mutually exclusive paradigms. Use either encrypted/backing="secrets" or a computed resolver on one key.`);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
}
|
|
@@ -92,7 +92,7 @@ export function validateConfigKeyAllowPerRequest(feature) {
|
|
|
92
92
|
// anyway, but declaring allowPerRequest on a text key is a
|
|
93
93
|
// misconfiguration that should fail loudly at boot.
|
|
94
94
|
if (keyDef.type === "text") {
|
|
95
|
-
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but type="text" — text keys are permanently ineligible for per-request overrides (XSS/injection risk)
|
|
95
|
+
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has allowPerRequest=true but type="text" — text keys are permanently ineligible for per-request overrides (XSS/injection risk). Remove allowPerRequest or change the key type.`);
|
|
96
96
|
}
|
|
97
97
|
// encrypted + per-request would expose a cipher-text interpretation
|
|
98
98
|
// to query-strings. The secret-value shouldn't be transported this
|
|
@@ -107,16 +107,16 @@ export function validateConfigKeyOptionsQuery(feature) {
|
|
|
107
107
|
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
108
108
|
const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
|
|
109
109
|
if ("conditionalOptions" in keyDef) {
|
|
110
|
-
throw new Error(`${prefix} declares conditionalOptions — conditionalOptions is only supported on entity select fields (enforced by the entity write path)
|
|
110
|
+
throw new Error(`${prefix} declares conditionalOptions — conditionalOptions is only supported on entity select fields (enforced by the entity write path). Remove conditionalOptions from the config key.`);
|
|
111
111
|
}
|
|
112
112
|
if (keyDef.optionsQuery === undefined) {
|
|
113
113
|
if (keyDef.optionsQueryPayload !== undefined) {
|
|
114
|
-
throw new Error(`${prefix} has optionsQueryPayload without optionsQuery
|
|
114
|
+
throw new Error(`${prefix} has optionsQueryPayload without optionsQuery. Set optionsQuery or remove optionsQueryPayload.`);
|
|
115
115
|
}
|
|
116
116
|
continue;
|
|
117
117
|
}
|
|
118
118
|
if (keyDef.optionsQuery.length === 0) {
|
|
119
|
-
throw new Error(`${prefix} has an empty optionsQuery
|
|
119
|
+
throw new Error(`${prefix} has an empty optionsQuery. Set it to a query name or remove it.`);
|
|
120
120
|
}
|
|
121
121
|
if (keyDef.type !== "select") {
|
|
122
122
|
throw new Error(`${prefix} has optionsQuery but type="${keyDef.type}" — optionsQuery is only valid on select keys`);
|
|
@@ -134,7 +134,7 @@ function validateOptionsQueryKeyRefs(feature, keyName, keyDef) {
|
|
|
134
134
|
const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
|
|
135
135
|
for (const ref of optionsQueryFieldRefs(keyDef.optionsQueryPayload)) {
|
|
136
136
|
if (ref === keyName) {
|
|
137
|
-
throw new Error(`${prefix} optionsQueryPayload references itself ({ field: "${ref}" })
|
|
137
|
+
throw new Error(`${prefix} optionsQueryPayload references itself ({ field: "${ref}" }). Reference a different config key.`);
|
|
138
138
|
}
|
|
139
139
|
if (feature.configKeys[ref] === undefined) {
|
|
140
140
|
throw new Error(`${prefix} optionsQueryPayload references unknown config key "${ref}" of feature "${feature.name}"`);
|
|
@@ -166,12 +166,12 @@ export function validateConfigKeyPiiEncrypted(feature) {
|
|
|
166
166
|
if (!keyDef.piiEncrypted)
|
|
167
167
|
continue;
|
|
168
168
|
if (keyDef.type !== "text") {
|
|
169
|
-
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has piiEncrypted=true but type="${keyDef.type}" — piiEncrypted only applies to text keys
|
|
169
|
+
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has piiEncrypted=true but type="${keyDef.type}" — piiEncrypted only applies to text keys. Use type="text" or remove piiEncrypted.`);
|
|
170
170
|
}
|
|
171
171
|
// system scope has no subject (no tenant, no user) for the subject-KMS
|
|
172
172
|
// to encrypt under.
|
|
173
173
|
if (keyDef.scope === "system") {
|
|
174
|
-
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has piiEncrypted=true but scope="system" — system-scope has no subject (tenant/user) for the subject-KMS to encrypt under
|
|
174
|
+
throw new Error(`[Feature ${feature.name}] Config key "${keyName}" has piiEncrypted=true but scope="system" — system-scope has no subject (tenant/user) for the subject-KMS to encrypt under. Use scope="tenant" or "user", or remove piiEncrypted.`);
|
|
175
175
|
}
|
|
176
176
|
// Mutually exclusive with the shared-cipher path: a config value is
|
|
177
177
|
// either subject-keyed (piiEncrypted) or master-key-keyed (encrypted/
|
|
@@ -190,7 +190,7 @@ export function validateConfigReads(features, allConfigKeys) {
|
|
|
190
190
|
for (const feature of features) {
|
|
191
191
|
for (const key of feature.configReads) {
|
|
192
192
|
if (!allConfigKeys.has(key)) {
|
|
193
|
-
throw new Error(`Feature "${feature.name}" reads config "${key}" but no feature defines that key
|
|
193
|
+
throw new Error(`Feature "${feature.name}" reads config "${key}" but no feature defines that key. Declare the key via r.config(...) in a feature, or fix the key name.`);
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
}
|
|
@@ -201,7 +201,7 @@ export function validateCircularDeps(featureName, featureMap) {
|
|
|
201
201
|
const stack = new Set();
|
|
202
202
|
function visit(name, path) {
|
|
203
203
|
if (stack.has(name)) {
|
|
204
|
-
throw new Error(`Circular dependency: ${[...path, name].join(" → ")}
|
|
204
|
+
throw new Error(`Circular dependency: ${[...path, name].join(" → ")}. Break the cycle by removing one requires() link.`);
|
|
205
205
|
}
|
|
206
206
|
// skip: node already visited in DFS traversal
|
|
207
207
|
if (visited.has(name))
|
|
@@ -22,7 +22,7 @@ export function collectDetailForScreens(features, featureMap) {
|
|
|
22
22
|
screenQnByEntity.set(detailFor, qualified);
|
|
23
23
|
if (findEntityFeature(detailFor, featureMap) === undefined) {
|
|
24
24
|
throw new Error(`[detailFor] Screen "${qualified}" declares detailFor: "${detailFor}", ` +
|
|
25
|
-
`but no feature registers an entity with that name.`);
|
|
25
|
+
`but no feature registers an entity with that name. Set detailFor to a registered entity name or remove it.`);
|
|
26
26
|
}
|
|
27
27
|
result.set(detailFor, { featureName: feature.name, screen });
|
|
28
28
|
}
|
|
@@ -7,6 +7,7 @@ export declare const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string>;
|
|
|
7
7
|
export declare function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void;
|
|
8
8
|
export declare function validateHandlerAccess(feature: FeatureDefinition): void;
|
|
9
9
|
export declare function validateAdditionalRateLimits(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
10
|
+
export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
10
11
|
export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
11
12
|
export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
12
13
|
export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
|
|
@@ -140,6 +140,7 @@ export function validateHandlerAccess(feature) {
|
|
|
140
140
|
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
141
141
|
validateTenantlessAnonymous(feature.name, name, handler);
|
|
142
142
|
validateAdditionalRateLimits(feature.name, name, handler);
|
|
143
|
+
validatePerCommandTransaction(feature.name, name, handler);
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
const IP_BUCKETED_RATE_LIMIT_PER = new Set(["ip", "ip+handler"]);
|
|
@@ -172,7 +173,7 @@ function validateAnonymousHasIpBucket(where, handler) {
|
|
|
172
173
|
IP_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per);
|
|
173
174
|
if (!hasIpBucket) {
|
|
174
175
|
throw new Error(`${where} and allows anonymous callers, but has no ip-keyed rateLimit ` +
|
|
175
|
-
`(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket.`);
|
|
176
|
+
`(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket. Set rateLimit: { per: "ip", ... } on the handler.`);
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
function validatePayloadBucket(where, shape, entry) {
|
|
@@ -188,6 +189,23 @@ function validatePayloadBucket(where, shape, entry) {
|
|
|
188
189
|
throw new Error(`${where} for "${field}" with windowSeconds ${entry.windowSeconds} — must be a positive integer.`);
|
|
189
190
|
}
|
|
190
191
|
}
|
|
192
|
+
function perCommandConflict(handler) {
|
|
193
|
+
if (handler.transaction !== "perCommand")
|
|
194
|
+
return undefined;
|
|
195
|
+
if (handler.beforeTransaction)
|
|
196
|
+
return "beforeTransaction";
|
|
197
|
+
if (handler.reserveBeforeTransaction)
|
|
198
|
+
return "reserveBeforeTransaction";
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
export function validatePerCommandTransaction(featureName, handlerName, handler) {
|
|
202
|
+
const conflicting = perCommandConflict(handler);
|
|
203
|
+
if (conflicting !== undefined) {
|
|
204
|
+
throw new Error(`write handler "${featureName}:write:${handlerName}" declares transaction: "perCommand" together with ${conflicting} — ` +
|
|
205
|
+
"a perCommand handler holds no transaction, so move the phase into the commands it issues via ctx.write.");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const IP_SCOPED_RATE_LIMIT_PER = ["ip", "ip+handler"];
|
|
191
209
|
export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
192
210
|
// skip: handler does not opt into tenantless anonymous calls
|
|
193
211
|
if (!handler.tenantlessAnonymous)
|
|
@@ -197,12 +215,16 @@ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
|
197
215
|
const isAnonymousOnly = "roles" in access && access.roles.length === 1 && access.roles[0] === "anonymous";
|
|
198
216
|
if (!isAnonymousOnly) {
|
|
199
217
|
throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
|
|
200
|
-
`carries no principal beyond the anonymous visitor.`);
|
|
218
|
+
`carries no principal beyond the anonymous visitor. Set access to { roles: ["anonymous"] } or remove tenantlessAnonymous.`);
|
|
201
219
|
}
|
|
202
220
|
if (!handler.rateLimit || isRateLimitDisabled(handler.rateLimit)) {
|
|
203
221
|
throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
|
|
204
222
|
`needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
|
|
205
223
|
}
|
|
224
|
+
if (!IP_SCOPED_RATE_LIMIT_PER.includes(handler.rateLimit.per)) {
|
|
225
|
+
throw new Error(`${where} with rateLimit.per "${handler.rateLimit.per}" — every anonymous visitor shares the same ` +
|
|
226
|
+
`tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((per) => `"${per}"`).join(" or ")}.`);
|
|
227
|
+
}
|
|
206
228
|
}
|
|
207
229
|
export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
|
|
208
230
|
// skip: rate limit isn't disabled, so no reason is required
|
|
@@ -393,17 +415,17 @@ export function validateEncryptedFields(feature) {
|
|
|
393
415
|
found = true;
|
|
394
416
|
if (field.type === "text") {
|
|
395
417
|
if (field.searchable) {
|
|
396
|
-
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and searchable
|
|
418
|
+
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and searchable. Drop searchable: true or remove the encryption.`);
|
|
397
419
|
}
|
|
398
420
|
if (field.sortable) {
|
|
399
|
-
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable
|
|
421
|
+
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and sortable. Drop sortable: true or remove the encryption.`);
|
|
400
422
|
}
|
|
401
423
|
// AES-GCM draws a fresh random IV per call, so the ciphertext is
|
|
402
424
|
// non-deterministic — an equality filter on it never matches, even
|
|
403
425
|
// against the identical plaintext.
|
|
404
426
|
if (field.filterable) {
|
|
405
427
|
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be both encrypted and filterable ` +
|
|
406
|
-
`— non-deterministic ciphertext never matches an equality filter.`);
|
|
428
|
+
`— non-deterministic ciphertext never matches an equality filter. Drop filterable: true or remove the encryption.`);
|
|
407
429
|
}
|
|
408
430
|
}
|
|
409
431
|
}
|
|
@@ -537,7 +559,7 @@ export function validateEmbeddedFields(feature, featureMap, queryHandlers) {
|
|
|
537
559
|
if (field.type !== "embedded")
|
|
538
560
|
continue;
|
|
539
561
|
if (!field.schema || Object.keys(field.schema).length === 0) {
|
|
540
|
-
throw new Error(`Embedded field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has an empty schema
|
|
562
|
+
throw new Error(`Embedded field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has an empty schema. Declare at least one sub-field in the embedded schema.`);
|
|
541
563
|
}
|
|
542
564
|
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
543
565
|
if (!VALID_EMBEDDED_SUB_TYPES.has(subField.type)) {
|
|
@@ -547,7 +569,7 @@ export function validateEmbeddedFields(feature, featureMap, queryHandlers) {
|
|
|
547
569
|
throw new Error(`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid scale ${subField.scale}. Must be an integer between 0 and 15.`);
|
|
548
570
|
}
|
|
549
571
|
if (subField.type === "select" && subField.options.length === 0) {
|
|
550
|
-
throw new Error(`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has empty options
|
|
572
|
+
throw new Error(`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has empty options. Add at least one option.`);
|
|
551
573
|
}
|
|
552
574
|
// Reference sub-fields get the same target/labelField/query-handler
|
|
553
575
|
// checks as a top-level reference field — same failure mode
|
|
@@ -571,7 +593,7 @@ function validateEmbeddedListBounds(fieldName, entityName, field) {
|
|
|
571
593
|
if (field.minItems !== undefined &&
|
|
572
594
|
field.maxItems !== undefined &&
|
|
573
595
|
field.minItems > field.maxItems) {
|
|
574
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}.`);
|
|
596
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}. Set minItems at or below maxItems.`);
|
|
575
597
|
}
|
|
576
598
|
// required:true means "at least one row" (schema-builder.ts falls back to
|
|
577
599
|
// minItems:1 for that); an explicit minItems:0 would silently win over
|
|
@@ -587,14 +609,14 @@ function validateEmbeddedDerivedCells(fieldName, entityName, field) {
|
|
|
587
609
|
return;
|
|
588
610
|
for (const [derivedName, derivedDef] of Object.entries(field.derived)) {
|
|
589
611
|
if (!(derivedName in field.schema)) {
|
|
590
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema.`);
|
|
612
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema. Add the sub-field to the schema or remove the derived cell.`);
|
|
591
613
|
}
|
|
592
614
|
for (const sourceName of derivedDef.from) {
|
|
593
615
|
if (!(sourceName in field.schema)) {
|
|
594
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`);
|
|
616
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}". Read only sub-fields that exist in the schema.`);
|
|
595
617
|
}
|
|
596
618
|
if (sourceName in field.derived) {
|
|
597
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}".`);
|
|
619
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}". Read only stored sub-fields, not other derived cells.`);
|
|
598
620
|
}
|
|
599
621
|
}
|
|
600
622
|
}
|
|
@@ -606,7 +628,7 @@ function validateEmbeddedTotalsColumns(fieldName, entityName, field) {
|
|
|
606
628
|
for (const totalName of field.totals) {
|
|
607
629
|
const totalSubField = field.schema[totalName];
|
|
608
630
|
if (!totalSubField || !NUMERIC_EMBEDDED_SUB_TYPES.has(totalSubField.type)) {
|
|
609
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field.`);
|
|
631
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field. List only number, money or decimal sub-fields in totals.`);
|
|
610
632
|
}
|
|
611
633
|
}
|
|
612
634
|
}
|
|
@@ -617,11 +639,11 @@ function validateEmbeddedTotalsMatch(fieldName, entityName, field, entity) {
|
|
|
617
639
|
for (const [subFieldName, siblingFieldName] of Object.entries(field.totalsMatch)) {
|
|
618
640
|
const subField = field.schema[subFieldName];
|
|
619
641
|
if (subField?.type !== "money") {
|
|
620
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry for "${subFieldName}", which is not a money sub-field in its schema.`);
|
|
642
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry for "${subFieldName}", which is not a money sub-field in its schema. Use a money sub-field as the totalsMatch key.`);
|
|
621
643
|
}
|
|
622
644
|
const siblingField = entity.fields[siblingFieldName];
|
|
623
645
|
if (siblingField?.type !== "money") {
|
|
624
|
-
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry mapping "${subFieldName}" to sibling field "${siblingFieldName}", which is not a money field on entity "${entityName}".`);
|
|
646
|
+
throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry mapping "${subFieldName}" to sibling field "${siblingFieldName}", which is not a money field on entity "${entityName}". Map it to a money field of the entity.`);
|
|
625
647
|
}
|
|
626
648
|
}
|
|
627
649
|
}
|
|
@@ -723,6 +745,15 @@ export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
|
|
|
723
745
|
}
|
|
724
746
|
}
|
|
725
747
|
}
|
|
748
|
+
function conditionComparisonValues(when) {
|
|
749
|
+
if ("eq" in when)
|
|
750
|
+
return [when.eq];
|
|
751
|
+
if ("ne" in when)
|
|
752
|
+
return [when.ne];
|
|
753
|
+
if ("in" in when)
|
|
754
|
+
return when.in;
|
|
755
|
+
return when.notIn;
|
|
756
|
+
}
|
|
726
757
|
export function validateEntitySelectConditionalOptions(feature) {
|
|
727
758
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
728
759
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -730,7 +761,7 @@ export function validateEntitySelectConditionalOptions(feature) {
|
|
|
730
761
|
continue;
|
|
731
762
|
const where = `[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}"`;
|
|
732
763
|
if (field.optionsQuery !== undefined) {
|
|
733
|
-
throw new Error(`${where} combines conditionalOptions with optionsQuery — conditionalOptions filters the static options only.`);
|
|
764
|
+
throw new Error(`${where} combines conditionalOptions with optionsQuery — conditionalOptions filters the static options only. Remove either conditionalOptions or optionsQuery.`);
|
|
734
765
|
}
|
|
735
766
|
const knownOptions = new Set(field.options);
|
|
736
767
|
const ruleOfOption = new Map();
|
|
@@ -745,7 +776,7 @@ export function validateEntitySelectConditionalOptions(feature) {
|
|
|
745
776
|
}
|
|
746
777
|
const otherRule = ruleOfOption.get(option);
|
|
747
778
|
if (otherRule !== undefined) {
|
|
748
|
-
throw new Error(`${where} lists "${option}" in conditionalOptions[${otherRule}] and ${ruleLabel} — an option may belong to one rule only.`);
|
|
779
|
+
throw new Error(`${where} lists "${option}" in conditionalOptions[${otherRule}] and ${ruleLabel} — an option may belong to one rule only. Remove the option from one of the rules.`);
|
|
749
780
|
}
|
|
750
781
|
ruleOfOption.set(option, ruleIndex);
|
|
751
782
|
if (option === field.default) {
|
|
@@ -754,10 +785,19 @@ export function validateEntitySelectConditionalOptions(feature) {
|
|
|
754
785
|
}
|
|
755
786
|
const conditionField = rule.when.field;
|
|
756
787
|
if (conditionField === fieldName) {
|
|
757
|
-
throw new Error(`${where} has ${ruleLabel}.when on its own field.`);
|
|
788
|
+
throw new Error(`${where} has ${ruleLabel}.when on its own field. Make the condition depend on a different field.`);
|
|
758
789
|
}
|
|
759
790
|
if (!(conditionField in entity.fields)) {
|
|
760
|
-
throw new Error(`${where} has ${ruleLabel}.when.field "${conditionField}" which is not a field of the entity.`);
|
|
791
|
+
throw new Error(`${where} has ${ruleLabel}.when.field "${conditionField}" which is not a field of the entity. Use an existing field of the entity in when.field.`);
|
|
792
|
+
}
|
|
793
|
+
const conditionFieldDef = entity.fields[conditionField];
|
|
794
|
+
if (conditionFieldDef?.type === "select" || conditionFieldDef?.type === "multiSelect") {
|
|
795
|
+
const conditionOptions = conditionFieldDef.options;
|
|
796
|
+
for (const value of conditionComparisonValues(rule.when)) {
|
|
797
|
+
if (typeof value !== "string" || !conditionOptions.includes(value)) {
|
|
798
|
+
throw new Error(`${where} has ${ruleLabel}.when value "${String(value)}" which is not an option of "${conditionField}" (${conditionOptions.join(", ")}).`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
761
801
|
}
|
|
762
802
|
});
|
|
763
803
|
}
|
|
@@ -769,7 +809,7 @@ export function validateMultiSelectFields(feature) {
|
|
|
769
809
|
if (field.type !== "multiSelect")
|
|
770
810
|
continue;
|
|
771
811
|
if (field.options.length === 0) {
|
|
772
|
-
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has empty options
|
|
812
|
+
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" in feature "${feature.name}" has empty options. Add at least one option.`);
|
|
773
813
|
}
|
|
774
814
|
if (field.default !== undefined) {
|
|
775
815
|
const validOptions = new Set(field.options);
|
|
@@ -933,7 +973,7 @@ export function validateExtendSchemaCollisions(feature) {
|
|
|
933
973
|
const extraFields = ext.extendSchema(entityName);
|
|
934
974
|
for (const fieldName of Object.keys(extraFields)) {
|
|
935
975
|
if (existingFields.has(fieldName)) {
|
|
936
|
-
throw new Error(`extendSchema column "${fieldName}" conflicts with existing field on entity "${entityName}"
|
|
976
|
+
throw new Error(`extendSchema column "${fieldName}" conflicts with existing field on entity "${entityName}". Rename the extension column or the entity field.`);
|
|
937
977
|
}
|
|
938
978
|
}
|
|
939
979
|
}
|
|
@@ -954,7 +994,7 @@ export function validateDerivedFieldCollisions(feature) {
|
|
|
954
994
|
for (const derivedName of Object.keys(entity.derivedFields)) {
|
|
955
995
|
if (fieldNames.has(derivedName)) {
|
|
956
996
|
throw new Error(`Entity "${entityName}": derivedFields key "${derivedName}" conflicts with a stored ` +
|
|
957
|
-
`field of the same name — the derived value would silently overwrite the real one on every read.`);
|
|
997
|
+
`field of the same name — the derived value would silently overwrite the real one on every read. Rename the derivedFields key or the stored field.`);
|
|
958
998
|
}
|
|
959
999
|
}
|
|
960
1000
|
}
|
|
@@ -964,24 +1004,33 @@ export function validateDerivedFieldCollisions(feature) {
|
|
|
964
1004
|
// searchable field and `filterReadFields` only strips the response afterwards.
|
|
965
1005
|
// A role that cannot read the field could still guess its value from match
|
|
966
1006
|
// counts, so the combination is rejected until the index can filter per role.
|
|
1007
|
+
function isUnrestrictedReadAccess(access) {
|
|
1008
|
+
const readAccess = access?.read;
|
|
1009
|
+
if (readAccess === undefined)
|
|
1010
|
+
return true;
|
|
1011
|
+
return Array.isArray(readAccess) ? readAccess.length === 0 : Object.keys(readAccess).length === 0;
|
|
1012
|
+
}
|
|
1013
|
+
function throwSearchableRestricted(subject, entityName) {
|
|
1014
|
+
throw new Error(`${subject} on entity "${entityName}" cannot be searchable with a restricted ` +
|
|
1015
|
+
"access.read — the search index is not filtered per role, so search matches would leak " +
|
|
1016
|
+
'the value to roles that cannot read it. Drop `searchable`/`find: "fuzzy"` or `access.read`.');
|
|
1017
|
+
}
|
|
967
1018
|
export function validateSearchableFieldReadAccess(feature) {
|
|
968
1019
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
969
1020
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
970
|
-
if (field.type
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const readAccess = field.access?.read;
|
|
975
|
-
if (readAccess === undefined)
|
|
976
|
-
continue;
|
|
977
|
-
const isUnrestricted = Array.isArray(readAccess)
|
|
978
|
-
? readAccess.length === 0
|
|
979
|
-
: Object.keys(readAccess).length === 0;
|
|
980
|
-
if (isUnrestricted)
|
|
1021
|
+
if (field.type === "text" && field.searchable && !isUnrestrictedReadAccess(field.access)) {
|
|
1022
|
+
throwSearchableRestricted(`Field "${fieldName}"`, entityName);
|
|
1023
|
+
}
|
|
1024
|
+
if (field.type !== "embedded")
|
|
981
1025
|
continue;
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1026
|
+
for (const [subName, subField] of Object.entries(field.schema)) {
|
|
1027
|
+
if (subField.searchable !== true)
|
|
1028
|
+
continue;
|
|
1029
|
+
if (isUnrestrictedReadAccess(field.access) && isUnrestrictedReadAccess(subField.access)) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
throwSearchableRestricted(`Field "${fieldName}.${subName}"`, entityName);
|
|
1033
|
+
}
|
|
985
1034
|
}
|
|
986
1035
|
}
|
|
987
1036
|
}
|
|
@@ -28,13 +28,13 @@ function validateOneEntityListScreen(feature, screen) {
|
|
|
28
28
|
const filtersActive = hasFilterableFields(feature, screen.entity);
|
|
29
29
|
const searchable = screen.searchable !== false;
|
|
30
30
|
if ((searchable || filtersActive) && screen.defaultSort === undefined) {
|
|
31
|
-
throw new Error(`${prefix}: defaultSort required when searchable or filterable fields are active
|
|
31
|
+
throw new Error(`${prefix}: defaultSort required when searchable or filterable fields are active. Set defaultSort: { field, direction } on the screen.`);
|
|
32
32
|
}
|
|
33
33
|
if (screen.defaultSort !== undefined) {
|
|
34
34
|
const sortField = screen.defaultSort.field;
|
|
35
35
|
const col = screen.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
36
36
|
if (col === undefined) {
|
|
37
|
-
throw new Error(`${prefix}: defaultSort.field "${sortField}" is not a listed column
|
|
37
|
+
throw new Error(`${prefix}: defaultSort.field "${sortField}" is not a listed column. Pick a field from columns or add it there.`);
|
|
38
38
|
}
|
|
39
39
|
const entityDef = feature.entities?.[screen.entity];
|
|
40
40
|
const fieldDef = entityDef?.fields[sortField];
|