@cosmicdrift/kumiko-framework 0.386.0 → 0.387.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/LICENSE +21 -57
- package/README.md +2 -2
- package/agents/widgets.md +39 -39
- package/dist/api/api-constants.js +2 -2
- package/dist/api/auth-middleware.js +7 -7
- package/dist/api/auth-rate-limiters.js +7 -3
- package/dist/api/auth-routes-mfa.js +1 -1
- package/dist/api/auth-routes-session.js +1 -1
- package/dist/api/auth-routes-shared.js +7 -3
- package/dist/api/auth-routes-signup.js +1 -1
- package/dist/api/client-ip.js +3 -1
- package/dist/api/extra-route.js +1 -1
- package/dist/api/pii-leak-guard.js +4 -2
- package/dist/api/readiness.js +5 -5
- package/dist/api/redis-sse-broker.js +8 -6
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +34 -33
- package/dist/api/sse-broker.js +1 -1
- package/dist/api/sse-route.d.ts +13 -13
- package/dist/api/sse-route.js +15 -15
- package/dist/auth/index.js +4 -4
- package/dist/auth/roles.d.ts +4 -4
- package/dist/auth/roles.js +24 -24
- package/dist/compliance/duration-spec.js +18 -20
- package/dist/compliance/index.js +2 -3
- package/dist/compliance/override-schema.d.ts +4 -4
- package/dist/compliance/override-schema.js +19 -22
- package/dist/compliance/profiles.d.ts +26 -29
- package/dist/compliance/profiles.js +62 -69
- package/dist/compliance/sub-processors.d.ts +28 -30
- package/dist/compliance/sub-processors.js +17 -21
- package/dist/consumer-cli.js +4 -4
- package/dist/crypto/blind-index.js +18 -17
- package/dist/crypto/ciphertext-pattern.js +1 -1
- package/dist/crypto/event-pii.js +3 -3
- package/dist/crypto/kek-source.d.ts +1 -1
- package/dist/crypto/kek-source.js +3 -2
- package/dist/crypto/kms-adapter.d.ts +1 -1
- package/dist/crypto/kms-adapter.js +4 -6
- package/dist/crypto/kms-wiring.d.ts +1 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/crypto/pg-kms-adapter.js +2 -6
- package/dist/crypto/pii-field-encryption.js +4 -4
- package/dist/crypto/subject-resolver.d.ts +1 -1
- package/dist/crypto/subject-resolver.js +2 -2
- package/dist/db/apply-entity-event.d.ts +6 -6
- package/dist/db/apply-entity-event.js +55 -55
- package/dist/db/blind-index-cleanup.js +7 -7
- package/dist/db/collect-table-metas.js +10 -11
- package/dist/db/compound-types.d.ts +1 -1
- package/dist/db/compound-types.js +12 -12
- package/dist/db/cursor.js +3 -4
- package/dist/db/dialect.d.ts +2 -2
- package/dist/db/dialect.js +2 -2
- package/dist/db/eagerload.d.ts +3 -3
- package/dist/db/eagerload.js +21 -12
- package/dist/db/entity-table-meta.d.ts +2 -2
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/event-store-executor-context.d.ts +1 -1
- package/dist/db/event-store-executor-context.js +13 -13
- package/dist/db/event-store-executor-read.js +27 -28
- package/dist/db/event-store-executor-write.js +65 -70
- package/dist/db/located-timestamp.d.ts +11 -11
- package/dist/db/located-timestamp.js +29 -30
- package/dist/db/migrate-generator.d.ts +1 -1
- package/dist/db/migrate-generator.js +26 -19
- package/dist/db/migrate-runner.js +25 -25
- package/dist/db/money.d.ts +6 -6
- package/dist/db/money.js +29 -27
- package/dist/db/queries/backfill-pii.js +5 -5
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/es-ops.d.ts +1 -1
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/event-store.js +4 -2
- package/dist/db/queries/projection-rebuild.js +3 -3
- package/dist/db/queries/raw-sql.d.ts +2 -2
- package/dist/db/queries/raw-sql.js +2 -2
- package/dist/db/queries/shadow-swap.js +19 -19
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +6 -6
- package/dist/db/rebuild-marker.js +20 -21
- package/dist/db/replay-migration-sql.js +2 -2
- package/dist/db/schema-inspection.js +4 -5
- package/dist/db/table-builder.d.ts +1 -2
- package/dist/db/table-builder.js +82 -82
- package/dist/db/tenant-db.d.ts +2 -2
- package/dist/db/tenant-db.js +9 -2
- package/dist/derivatives/delete-stored-file.js +5 -2
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-roles.js +4 -3
- package/dist/engine/boot-validator/api-ext.js +14 -14
- package/dist/engine/boot-validator/config-deps.js +7 -6
- package/dist/engine/boot-validator/detail-screens.js +3 -3
- package/dist/engine/boot-validator/entity-handler.js +60 -61
- package/dist/engine/boot-validator/gdpr-storage.js +4 -4
- package/dist/engine/boot-validator/i18n-keys.js +1 -1
- package/dist/engine/boot-validator/index.d.ts +2 -2
- package/dist/engine/boot-validator/index.js +20 -20
- package/dist/engine/boot-validator/nav.js +5 -5
- package/dist/engine/boot-validator/ownership.js +12 -9
- package/dist/engine/boot-validator/parent-ref.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +50 -57
- package/dist/engine/boot-validator/projection-list-screens.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +1 -1
- package/dist/engine/boot-validator/screens-actions.js +15 -16
- package/dist/engine/boot-validator/screens-forms.js +10 -11
- package/dist/engine/boot-validator/screens-lists.js +14 -15
- package/dist/engine/boot-validator/screens.js +34 -40
- package/dist/engine/boot-validator/security-baseline.js +3 -2
- package/dist/engine/boot-validator/transfer-graph.js +2 -2
- package/dist/engine/boot-validator/workspaces.js +3 -3
- package/dist/engine/bridge-stub.js +3 -4
- package/dist/engine/build-app-schema.js +75 -75
- package/dist/engine/build-config-feature-schema.js +50 -50
- package/dist/engine/build-target.js +20 -23
- package/dist/engine/codemod/pipeline-codemod.js +20 -13
- package/dist/engine/config-helpers.js +4 -4
- package/dist/engine/constants.js +2 -2
- package/dist/engine/create-app.js +2 -2
- package/dist/engine/define-feature.js +12 -11
- package/dist/engine/define-handler.js +12 -11
- package/dist/engine/define-step.js +1 -1
- package/dist/engine/define-workflow.d.ts +5 -5
- package/dist/engine/define-workflow.js +6 -6
- package/dist/engine/entity-handlers.js +13 -14
- package/dist/engine/event-def.js +2 -2
- package/dist/engine/extension-names.d.ts +16 -17
- package/dist/engine/extension-names.js +37 -38
- package/dist/engine/extensions/escape-hatch-usage.js +1 -1
- package/dist/engine/extensions/tenant-data.js +2 -2
- package/dist/engine/extensions/user-data.d.ts +23 -24
- package/dist/engine/factories.d.ts +14 -13
- package/dist/engine/factories.js +38 -37
- package/dist/engine/feature-ast/entity-field-types.js +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +1 -1
- package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
- package/dist/engine/feature-ast/extractors/shared.js +2 -3
- package/dist/engine/feature-ast/parse.js +13 -13
- package/dist/engine/feature-ast/patch.js +1 -1
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
- package/dist/engine/feature-ast/patterns.d.ts +2 -2
- package/dist/engine/feature-ast/render.js +2 -2
- package/dist/engine/feature-config-events-jobs.js +15 -12
- package/dist/engine/feature-entity-handlers.js +9 -5
- package/dist/engine/feature-manifest.d.ts +9 -9
- package/dist/engine/feature-manifest.js +6 -7
- package/dist/engine/feature-ui-extensions.d.ts +3 -3
- package/dist/engine/feature-ui-extensions.js +30 -22
- package/dist/engine/field-access.js +5 -6
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +4 -4
- package/dist/engine/object-form.d.ts +1 -0
- package/dist/engine/object-form.js +14 -4
- package/dist/engine/ownership.js +3 -3
- package/dist/engine/parse-ref-target.js +5 -7
- package/dist/engine/pattern-library/index.js +2 -2
- package/dist/engine/pattern-library/library.js +2 -2
- package/dist/engine/pattern-library/types.js +3 -3
- package/dist/engine/pipeline.js +2 -2
- package/dist/engine/project-app-schema-for-roles.js +1 -1
- package/dist/engine/projection-helpers.js +9 -9
- package/dist/engine/qualified-name.js +2 -2
- package/dist/engine/registry-facade.js +1 -1
- package/dist/engine/registry-ingest.js +12 -12
- package/dist/engine/registry-state.js +17 -19
- package/dist/engine/registry-validate.js +12 -12
- package/dist/engine/run-in.js +2 -2
- package/dist/engine/run-pipeline.js +7 -0
- package/dist/engine/schema-builder.js +40 -38
- package/dist/engine/screen-filter-ops.js +17 -20
- package/dist/engine/screen-helpers.js +4 -3
- package/dist/engine/state-machine.d.ts +12 -12
- package/dist/engine/state-machine.js +7 -7
- package/dist/engine/steps/_resolver-utils.js +4 -3
- package/dist/engine/steps/for-each.js +4 -4
- package/dist/engine/steps/unsafe-projection-delete.js +1 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
- package/dist/engine/steps/wait-for-event.js +4 -4
- package/dist/engine/steps/workflow-system-events.js +3 -3
- package/dist/engine/tier-resolver-extension.d.ts +29 -27
- package/dist/engine/tier-resolver-extension.js +26 -26
- package/dist/engine/transfer-adjacency.js +2 -2
- package/dist/engine/validate-projection-allowlist.js +7 -27
- package/dist/engine/walk-step-instances.d.ts +2 -0
- package/dist/engine/walk-step-instances.js +38 -0
- package/dist/engine/where-rule-lint.js +1 -1
- package/dist/engine/write-handler-qn-extract.js +1 -1
- package/dist/entrypoint/index.js +3 -3
- package/dist/env/index.d.ts +7 -6
- package/dist/env/index.js +10 -9
- package/dist/errors/transition-details.js +7 -7
- package/dist/errors/write-error-info.d.ts +6 -6
- package/dist/errors/write-error-info.js +6 -6
- package/dist/es-ops/context.d.ts +9 -9
- package/dist/es-ops/context.js +28 -28
- package/dist/es-ops/index.js +7 -9
- package/dist/es-ops/operations-schema.js +4 -10
- package/dist/es-ops/runner.d.ts +11 -11
- package/dist/es-ops/runner.js +49 -53
- package/dist/es-ops/types.d.ts +55 -55
- package/dist/es-ops/types.js +11 -11
- package/dist/event-store/admin-api.js +6 -7
- package/dist/event-store/errors.js +1 -1
- package/dist/event-store/event-store.js +2 -2
- package/dist/event-store/rebuild-dead-letter.js +1 -1
- package/dist/files/feature.js +9 -10
- package/dist/files/file-handle.js +5 -6
- package/dist/files/file-ref-entity.js +15 -15
- package/dist/files/file-ref-table.js +7 -8
- package/dist/files/file-routes.d.ts +7 -0
- package/dist/files/file-routes.js +11 -5
- package/dist/files/in-memory-provider.js +8 -9
- package/dist/files/local-provider.js +18 -25
- package/dist/files/provider-resolver.d.ts +7 -7
- package/dist/files/storage-tracking.js +2 -2
- package/dist/files/zip-stream.d.ts +12 -19
- package/dist/files/zip-stream.js +44 -63
- package/dist/i18n/required-surface-keys.d.ts +1 -1
- package/dist/i18n/required-surface-keys.js +8 -8
- package/dist/jobs/job-runner-helpers.js +8 -2
- package/dist/jobs/job-runner-schedulers.d.ts +1 -1
- package/dist/jobs/job-runner-schedulers.js +3 -4
- package/dist/jobs/job-runner.js +13 -15
- package/dist/lifecycle/lifecycle.js +3 -5
- package/dist/lifecycle/signal-handlers.js +5 -4
- package/dist/logging/utils.d.ts +2 -1
- package/dist/logging/utils.js +38 -10
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/kumiko-drift.js +14 -13
- package/dist/migrations/pending-rebuilds.d.ts +21 -20
- package/dist/migrations/pending-rebuilds.js +27 -27
- package/dist/migrations/projection-table-index.d.ts +3 -3
- package/dist/migrations/projection-table-index.js +8 -8
- package/dist/observability/metric-validator.js +1 -1
- package/dist/observability/metrics-handle.js +3 -3
- package/dist/pipeline/append-event-core.js +11 -2
- package/dist/pipeline/cascade-handler.d.ts +1 -1
- package/dist/pipeline/dispatch-batch.js +2 -2
- package/dist/pipeline/dispatch-query.js +10 -10
- package/dist/pipeline/dispatch-shared.d.ts +1 -0
- package/dist/pipeline/dispatch-shared.js +15 -17
- package/dist/pipeline/dispatch-stream.js +1 -1
- package/dist/pipeline/dispatch-write.js +3 -3
- package/dist/pipeline/dispatcher-utils.d.ts +1 -1
- package/dist/pipeline/dispatcher-utils.js +1 -1
- package/dist/pipeline/event-consumer-state.js +2 -2
- package/dist/pipeline/event-dispatcher-delivery.js +1 -1
- package/dist/pipeline/event-dispatcher.js +17 -28
- package/dist/pipeline/irreversible-operation-gate.js +1 -1
- package/dist/pipeline/lifecycle-pipeline.js +9 -9
- package/dist/pipeline/member-read-only-transaction.js +1 -3
- package/dist/pipeline/msp-rebuild.js +1 -1
- package/dist/pipeline/projection-rebuild.js +13 -12
- package/dist/pipeline/system-hooks.js +39 -41
- package/dist/pipeline/system-identity-switch.js +1 -3
- package/dist/pipeline/tenant-timezone-cache.js +1 -1
- package/dist/random/generate.d.ts +29 -29
- package/dist/random/generate.js +30 -30
- package/dist/random/words.js +20 -20
- package/dist/rate-limit/middleware.js +9 -5
- package/dist/redis/client.js +2 -2
- package/dist/redis/pubsub-signal.js +14 -17
- package/dist/schema-cli.js +116 -22
- package/dist/search/index.js +4 -4
- package/dist/search/purge-subject.js +4 -5
- package/dist/search/reindex-entity.js +6 -9
- package/dist/time/boot-tz-warning.d.ts +2 -2
- package/dist/time/boot-tz-warning.js +8 -8
- package/dist/time/iana.js +10 -10
- package/dist/time/index.js +9 -9
- package/dist/time/legacy-date.d.ts +2 -2
- package/dist/time/legacy-date.js +6 -8
- package/dist/time/polyfill.js +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +1 -1
- package/dist/ui-types/app-schema.js +7 -7
- package/dist/ui-types/list-row-meta.js +4 -4
- package/dist/upgrade-cli.js +10 -9
- package/dist/utils/compare.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +48 -0
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
- package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
- package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
- package/src/scripts/codemod/pii-personal-migration.ts +17 -8
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
|
@@ -3,23 +3,23 @@ import { tableNameOf, } from "../ownership.js";
|
|
|
3
3
|
import { SYSTEM_USER_ID } from "../system-user.js";
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
|
|
5
5
|
import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule-lint.js";
|
|
6
|
-
// --- Ownership rule validation
|
|
6
|
+
// --- Ownership rule validation ---
|
|
7
7
|
//
|
|
8
8
|
// Walks every entity.access and every field.access map, resolves each
|
|
9
9
|
// FromRule against the cross-feature claim registry, and confirms the
|
|
10
10
|
// referenced column exists on the entity. Catches typos, renames, and
|
|
11
11
|
// cross-feature-claim-removal scenarios at boot instead of at request time.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
13
|
+
// Also probes every `{ kind: "where" }` rule against the where-rule
|
|
14
14
|
// lint (see ../where-rule-lint.ts) so a fail-open unqualified-column bug
|
|
15
15
|
// fails the boot instead of waiting for a request from the right role.
|
|
16
16
|
//
|
|
17
17
|
// Field-level `access.read` rejects where-rules too: filterFieldsForRead
|
|
18
18
|
// evaluates it in memory and userCanReadFieldRow fails closed on a where-rule.
|
|
19
19
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// `access.write` maps reject `{ kind: "where" }` outright. A where-rule is
|
|
21
|
+
// raw SQL and the write path never reaches SQL — it evaluates rules in memory
|
|
22
|
+
// against the concrete row (userCanCreateFieldRow /
|
|
23
23
|
// userCanWriteFieldRow), and a create has no row to run a predicate against
|
|
24
24
|
// at all. Such a rule can therefore only ever deny, so the boot fails instead
|
|
25
25
|
// of shipping an access map that silently locks the role out.
|
|
@@ -49,9 +49,8 @@ export function collectClaimKeys(features) {
|
|
|
49
49
|
// corpora go in as undefined — a three-feature stack genuinely does not know
|
|
50
50
|
// which roles and claims the whole app declares, and answering that question
|
|
51
51
|
// from a slice produces false "unknown role"/"unknown claim" errors. The
|
|
52
|
-
// subset-invariant checks (
|
|
53
|
-
//
|
|
54
|
-
// mounted.
|
|
52
|
+
// subset-invariant checks (where-lint, where-on-write, unknown column) run in
|
|
53
|
+
// full: those hold or fail per feature, whatever else is mounted.
|
|
55
54
|
export function validateOwnershipBoot(features) {
|
|
56
55
|
for (const feature of features) {
|
|
57
56
|
validateOwnershipRules(feature, undefined, undefined);
|
|
@@ -258,12 +257,16 @@ export function probeWhereRule(rule, roleName, probe, scope, featureName) {
|
|
|
258
257
|
return;
|
|
259
258
|
assertQualifiedWhereFragment(fragment.sqlText, probe.columns, `${scope} (role "${roleName}", feature: "${featureName}")`);
|
|
260
259
|
}
|
|
260
|
+
// Array.isArray does not narrow a readonly array out of a union.
|
|
261
|
+
function isRoleList(access) {
|
|
262
|
+
return Array.isArray(access);
|
|
263
|
+
}
|
|
261
264
|
// Embedded sub-fields are filtered in memory too (filterFieldsForRead), so a
|
|
262
265
|
// where-rule there is just as unevaluable as on a top-level field.
|
|
263
266
|
function rejectWhereInEmbeddedRead(schema, parentScope, featureName) {
|
|
264
267
|
for (const [subName, sub] of Object.entries(schema)) {
|
|
265
268
|
const map = sub.access?.read;
|
|
266
|
-
if (!map ||
|
|
269
|
+
if (!map || isRoleList(map))
|
|
267
270
|
continue;
|
|
268
271
|
for (const [roleName, rule] of Object.entries(map)) {
|
|
269
272
|
if (rule !== "all" && rule.kind === "where") {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
-
// --- Parent-ref boot validation
|
|
2
|
+
// --- Parent-ref boot validation ---
|
|
3
3
|
//
|
|
4
4
|
// Join-row entities (EntityDefinition.parentRef) derive both the write-path
|
|
5
5
|
// deny and the read-path SQL gate from one declaration. This is the boot-time
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import { createFallbackLogger } from "../../logging/utils.js";
|
|
1
2
|
import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
|
|
2
|
-
|
|
3
|
-
//
|
|
3
|
+
const log = createFallbackLogger("kumiko:boot");
|
|
4
|
+
// Framework-managed timestamp fields may be used as retention.reference
|
|
5
|
+
// even when they are not declared in entity.fields.
|
|
4
6
|
const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
|
|
5
7
|
"createdAt",
|
|
6
8
|
"updatedAt",
|
|
7
9
|
"lastSeenAt",
|
|
8
10
|
"deletedAt",
|
|
9
11
|
]);
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// werden statt erst beim ersten Cleanup-Run.
|
|
12
|
+
// Allowed format for retention.keepFor: a number followed by a suffix (h/d/w/m/y).
|
|
13
|
+
// The cleanup job performs the actual parsing; the boot validator only checks
|
|
14
|
+
// the format so typos ("30days") become visible before the first cleanup run.
|
|
14
15
|
const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
15
16
|
const FILE_FIELD_TYPES = new Set([
|
|
16
17
|
"file",
|
|
@@ -19,7 +20,7 @@ const FILE_FIELD_TYPES = new Set([
|
|
|
19
20
|
"images",
|
|
20
21
|
]);
|
|
21
22
|
// Excludes subjectRef: its `personal: "ref"` union member structurally
|
|
22
|
-
// forbids `anonymize` (packages/types/src/fields.ts)
|
|
23
|
+
// forbids `anonymize` (packages/types/src/fields.ts).
|
|
23
24
|
function hasAnonymizableSubjectField(annot) {
|
|
24
25
|
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
|
|
25
26
|
}
|
|
@@ -100,28 +101,28 @@ function validatePersonalJsonbField(featureName, entityName, entity, fieldName,
|
|
|
100
101
|
}
|
|
101
102
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
102
103
|
//
|
|
103
|
-
//
|
|
104
|
+
// Four classes of checks:
|
|
104
105
|
//
|
|
105
|
-
// 1. Mutual exclusion:
|
|
106
|
-
// (pii / userOwned / tenantOwned).
|
|
107
|
-
//
|
|
106
|
+
// 1. Mutual exclusion: each field may have only ONE subject annotation
|
|
107
|
+
// (pii / userOwned / tenantOwned). Multiple annotations are semantically
|
|
108
|
+
// contradictory because each field belongs to exactly one subject key.
|
|
108
109
|
//
|
|
109
|
-
// 2. Reference
|
|
110
|
-
// reference
|
|
111
|
-
//
|
|
112
|
-
// Encrypt-Aufruf.
|
|
110
|
+
// 2. Reference integrity: userOwned.ownerField must refer to an existing
|
|
111
|
+
// reference field that should target the user entity. This catches typos
|
|
112
|
+
// and refactors that removed a field during boot instead of at encryption.
|
|
113
113
|
//
|
|
114
|
-
// 3.
|
|
115
|
-
// (email, name, phone, body, etc.)
|
|
116
|
-
//
|
|
114
|
+
// 3. Heuristic warnings: field names that typically contain PII
|
|
115
|
+
// (email, name, phone, body, etc.) without an annotation produce a boot
|
|
116
|
+
// warning. `{ personal: false, reason: "<reason>" }` suppresses it and
|
|
117
|
+
// records the reason in the audit.
|
|
117
118
|
//
|
|
118
|
-
// 4. Retention
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
119
|
+
// 4. Retention integrity: retention.reference, when set, must refer to an
|
|
120
|
+
// existing field or a framework timestamp. retention.strategy="blockDelete"
|
|
121
|
+
// without anonymizable fields is ineffective because user forget cannot
|
|
122
|
+
// remove anything, so it produces a warning.
|
|
122
123
|
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
124
|
+
// Encryption and decryption are handled elsewhere; this validation runs early
|
|
125
|
+
// so schema drift is detected during boot.
|
|
125
126
|
export function validatePiiAndRetention(feature) {
|
|
126
127
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
127
128
|
const fieldsByName = entity.fields;
|
|
@@ -150,9 +151,9 @@ export function validatePiiAndRetention(feature) {
|
|
|
150
151
|
!annot.subjectRef) {
|
|
151
152
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
|
|
152
153
|
}
|
|
153
|
-
// lookupable
|
|
154
|
-
//
|
|
155
|
-
//
|
|
154
|
+
// lookupable: only text fields with a subject annotation are supported. Without
|
|
155
|
+
// a subject, the value remains plaintext, so a blind index would be a
|
|
156
|
+
// second deterministic copy without any benefit.
|
|
156
157
|
if (annot.lookupable === true) {
|
|
157
158
|
if (field.type !== "text") {
|
|
158
159
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") but has type "${field.type}" — blind-index equality lookups only apply to text fields. Use a text field or set find: "none".`);
|
|
@@ -161,9 +162,9 @@ export function validatePiiAndRetention(feature) {
|
|
|
161
162
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares find: "exact" (or "fuzzy") without a subject annotation (personal: "self" / "tenant" / { of: "<field>" }). Plaintext fields don't need a blind index — add personal or set find: "none".`);
|
|
162
163
|
}
|
|
163
164
|
}
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
// immutable
|
|
165
|
+
// Sensitive fields are stored as table ciphertext in the event log; without
|
|
166
|
+
// ciphertext at rest, appending the event would write plaintext to the
|
|
167
|
+
// immutable history.
|
|
167
168
|
const sensitiveFlags = field; // @cast-boundary schema-walk
|
|
168
169
|
if (sensitiveFlags.sensitive === true &&
|
|
169
170
|
annotCount === 0 &&
|
|
@@ -172,11 +173,10 @@ export function validatePiiAndRetention(feature) {
|
|
|
172
173
|
}
|
|
173
174
|
validateWriteOnlyField(feature.name, entityName, fieldName, field);
|
|
174
175
|
// Sorting reads the projection column — that stays ciphertext, so
|
|
175
|
-
// sortable + subject annotation stays a boot-fail. searchable
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// (never searchable).
|
|
176
|
+
// sortable + subject annotation stays a boot-fail. searchable is allowed:
|
|
177
|
+
// the search consumer decrypts into the derived index and forget purges
|
|
178
|
+
// those docs (see createSearchEventConsumer). sensitive + searchable stays
|
|
179
|
+
// forbidden (never searchable).
|
|
180
180
|
{
|
|
181
181
|
const flags = field; // @cast-boundary schema-walk
|
|
182
182
|
if (annotCount > 0 && flags.sortable === true) {
|
|
@@ -214,42 +214,37 @@ export function validatePiiAndRetention(feature) {
|
|
|
214
214
|
if (ownerField.required !== true && whenAbsent === undefined) {
|
|
215
215
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" is personal: { of: "${ownerName}" }, but "${ownerName}" is not required — rows without an owner would fail to encrypt (SubjectResolutionError, HTTP 500) on update, delete and forget. Declare { personal: { of: "${ownerName}", whenAbsent: "tenant" } } to encrypt those rows under the tenant key, whenAbsent: "self" to encrypt them under the row's own record key (shreddable per record), or make "${ownerName}" required: true. Entity fields have no plaintext fallback (fw#3654).`);
|
|
216
216
|
}
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
217
|
+
// Warn when the reference target is not obviously a user. Custom subject
|
|
218
|
+
// entities (such as HR employees or patients) are allowed but should
|
|
219
|
+
// be an intentional choice.
|
|
220
220
|
if (ownerField.type === "reference") {
|
|
221
221
|
const refTarget = ownerField.entity;
|
|
222
222
|
const targetEntity = refTarget.includes(":") ? refTarget.split(":")[1] : refTarget;
|
|
223
223
|
if (targetEntity !== "user") {
|
|
224
|
-
|
|
225
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
|
|
224
|
+
log.warn(`[Feature ${feature.name}] personal.of "${ownerName}" on entity "${entityName}" targets reference "${refTarget}" — typically should be a user reference. If intentional (custom subject-entity like employee/patient), ignore.`);
|
|
226
225
|
}
|
|
227
226
|
}
|
|
228
227
|
}
|
|
229
|
-
// PII
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
228
|
+
// PII heuristic: only when no annotation or allowPlaintext marker is set.
|
|
229
|
+
// Business data with person-like names (for example, company.legalName)
|
|
230
|
+
// can produce false positives; authors can suppress them with
|
|
231
|
+
// { personal: false, reason: "is_business_data" }.
|
|
233
232
|
const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
|
|
234
233
|
if (noAnnotation) {
|
|
235
234
|
const lower = fieldName.toLowerCase();
|
|
236
235
|
if (FILE_FIELD_TYPES.has(field.type)) {
|
|
237
|
-
|
|
238
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] File field "${fieldName}" on entity "${entityName}" has no personal annotation. A forget with strategy "delete" then only severs the uploader link and keeps the binary. If the file holds personal data, mark it { personal: "self" } or { personal: { of: "<ownerField>" } }. If it is business data, set { personal: false, reason: "is_business_data" } to silence.`);
|
|
236
|
+
log.warn(`[Feature ${feature.name}] File field "${fieldName}" on entity "${entityName}" has no personal annotation. A forget with strategy "delete" then only severs the uploader link and keeps the binary. If the file holds personal data, mark it { personal: "self" } or { personal: { of: "<ownerField>" } }. If it is business data, set { personal: false, reason: "is_business_data" } to silence.`);
|
|
239
237
|
}
|
|
240
238
|
else if (PII_DIRECT_NAME_HINTS.has(lower)) {
|
|
241
|
-
|
|
242
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`);
|
|
239
|
+
log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`);
|
|
243
240
|
}
|
|
244
241
|
else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
|
|
245
|
-
|
|
246
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`);
|
|
242
|
+
log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`);
|
|
247
243
|
}
|
|
248
244
|
else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) &&
|
|
249
245
|
!annot.subjectRef &&
|
|
250
246
|
!personalOfOwnerFieldNames.has(fieldName)) {
|
|
251
|
-
|
|
252
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`);
|
|
247
|
+
log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`);
|
|
253
248
|
}
|
|
254
249
|
}
|
|
255
250
|
}
|
|
@@ -257,8 +252,7 @@ export function validatePiiAndRetention(feature) {
|
|
|
257
252
|
const retention = entity.retention;
|
|
258
253
|
if (retention) {
|
|
259
254
|
if (!KEEP_FOR_PATTERN.test(retention.keepFor)) {
|
|
260
|
-
|
|
261
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}" has an invalid format. Expected: <number><h|d|w|m|y> (e.g. "30d", "10y", "6m"). The cleanup job cannot parse anything else; fix keepFor.`);
|
|
255
|
+
log.warn(`[Feature ${feature.name}] Entity "${entityName}" retention.keepFor="${retention.keepFor}" has an invalid format. Expected: <number><h|d|w|m|y> (e.g. "30d", "10y", "6m"). The cleanup job cannot parse anything else; fix keepFor.`);
|
|
262
256
|
}
|
|
263
257
|
if (retention.reference !== undefined) {
|
|
264
258
|
const refName = retention.reference;
|
|
@@ -271,15 +265,14 @@ export function validatePiiAndRetention(feature) {
|
|
|
271
265
|
if (retention.strategy === "blockDelete") {
|
|
272
266
|
// blockDelete with no field that can carry `anonymize` is fine;
|
|
273
267
|
// subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
|
|
274
|
-
// instead
|
|
268
|
+
// instead.
|
|
275
269
|
const entityHasAnonymizableField = entityHasAnonymizableSubjectField(fieldsByName);
|
|
276
270
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
277
271
|
const a = f; // @cast-boundary schema-walk
|
|
278
272
|
return Boolean(a.anonymize);
|
|
279
273
|
});
|
|
280
274
|
if (entityHasAnonymizableField && !hasAnonymize) {
|
|
281
|
-
|
|
282
|
-
console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. Once keepFor expires, the data-retention cron anonymizes nothing and the row keeps its PII. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
|
|
275
|
+
log.warn(`[Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. Once keepFor expires, the data-retention cron anonymizes nothing and the row keeps its PII. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
|
|
283
276
|
}
|
|
284
277
|
}
|
|
285
278
|
}
|
|
@@ -10,7 +10,7 @@ import { getZodObjectShape } from "./zod-shape.js";
|
|
|
10
10
|
// reaches into feature.entities[screen.entity] — a projectionList has no
|
|
11
11
|
// entity, so sharing the function would mean threading a discriminated
|
|
12
12
|
// union through every entity-bound helper it calls.
|
|
13
|
-
// Exported for query-refs.ts
|
|
13
|
+
// Exported for query-refs.ts — one source for the QN-derivation
|
|
14
14
|
// logic instead of duplicating it in both boot-validators.
|
|
15
15
|
export function buildQueryHandlerMap(features) {
|
|
16
16
|
const out = new Map();
|
|
@@ -29,7 +29,7 @@ function schemaAccepts(schema, key) {
|
|
|
29
29
|
return shape !== undefined && key in shape;
|
|
30
30
|
}
|
|
31
31
|
// sortable/paginated are derived by buildAppSchema from the query's Zod
|
|
32
|
-
// schema
|
|
32
|
+
// schema — there is no separate wire type from the author-facing
|
|
33
33
|
// ProjectionListScreenDefinition, so hand-authoring them would otherwise be
|
|
34
34
|
// silently overwritten with no signal to the author. Reject outright.
|
|
35
35
|
function rejectHandAuthoredDerivedFlags(prefix, screen) {
|
|
@@ -67,7 +67,7 @@ function validateOneProjectionListScreen(feature, screen, queryHandlers, feature
|
|
|
67
67
|
validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
|
|
68
68
|
validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
|
|
69
69
|
}
|
|
70
|
-
// filter
|
|
70
|
+
// filter sends `payload.filter` — same 422 footgun as facets below
|
|
71
71
|
// if the handler's schema doesn't accept it.
|
|
72
72
|
function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
|
|
73
73
|
// skip: no filter declared, or the schema already accepts it — nothing to reject.
|
|
@@ -77,11 +77,11 @@ function validateProjectionListFilterSchemaAcceptance(prefix, screen, schema) {
|
|
|
77
77
|
`schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
|
|
78
78
|
`value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
|
|
79
79
|
}
|
|
80
|
-
// facets
|
|
81
|
-
//
|
|
80
|
+
// facets send `payload.filters` — same failure mode as search/sort:
|
|
81
|
+
// definePagedQueryHandler doesn't auto-merge params into the
|
|
82
82
|
// handler's own Zod schema, so a declared facet would 422 on every query
|
|
83
83
|
// unless the author added `filters` themselves.
|
|
84
|
-
// A dateRange facet
|
|
84
|
+
// A dateRange facet sends its two bounds as the top-level payload
|
|
85
85
|
// keys it names in `params`, not as a `filters` entry — so the query has to
|
|
86
86
|
// accept exactly those keys. Catches the facet pointed at a field the query
|
|
87
87
|
// can't narrow by, which would otherwise 422 on the first date the user picks.
|
|
@@ -203,7 +203,7 @@ function validateRelatedListGroupBy(prefix, section) {
|
|
|
203
203
|
}
|
|
204
204
|
// relatedList sections declare search/facets the same way a projectionList
|
|
205
205
|
// screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
|
|
206
|
-
// auto-merge params into the handler's own Zod schema
|
|
206
|
+
// auto-merge params into the handler's own Zod schema, so an
|
|
207
207
|
// undeclared param would fail every query instead of failing at boot.
|
|
208
208
|
// Reachable on projectionDetail only (screens.ts rejects relatedList
|
|
209
209
|
// elsewhere), but the layout walk stays uniform so lifting that restriction
|
|
@@ -25,7 +25,7 @@ function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix) {
|
|
|
25
25
|
"Make the query's input fields optional or use a query without required input.");
|
|
26
26
|
}
|
|
27
27
|
// relatedList is currently only reachable on projectionDetail — screens.ts
|
|
28
|
-
// rejects it outright on entityEdit/actionForm/configEdit
|
|
28
|
+
// rejects it outright on entityEdit/actionForm/configEdit. Walking
|
|
29
29
|
// the shared EditLayout uniformly here (instead of excluding those three
|
|
30
30
|
// types) keeps this collector correct if that restriction is ever lifted.
|
|
31
31
|
function checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screenType, layout) {
|
|
@@ -7,7 +7,7 @@ export function isRowClickAction(action) {
|
|
|
7
7
|
return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
|
|
8
8
|
}
|
|
9
9
|
// entityList and projectionList both allow a rowAction to double as the
|
|
10
|
-
// row-body click target (rowClick: true
|
|
10
|
+
// row-body click target (rowClick: true) — at most one per
|
|
11
11
|
// screen, or the renderer can't tell which one should fire.
|
|
12
12
|
export function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions) {
|
|
13
13
|
const rowClickActions = rowActions.filter(isRowClickAction);
|
|
@@ -30,18 +30,17 @@ export function readsNavigateParamsAsFormPrefill(target, explicitEntityId, sourc
|
|
|
30
30
|
return false;
|
|
31
31
|
return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
|
|
32
32
|
}
|
|
33
|
-
//
|
|
34
|
-
// entityList and projectionList
|
|
33
|
+
// Navigate rowAction → target-screen params validity. Shared by
|
|
34
|
+
// entityList and projectionList — projectionList has no
|
|
35
35
|
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
36
36
|
// entityEdit target without an explicit entityId reaches create there.
|
|
37
37
|
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// `
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// a silently-empty dashboard.
|
|
38
|
+
// Dashboard targets also read URL search params — but only for the single
|
|
39
|
+
// `filter.id` a dashboard declares (useFilterParams in dashboard-body.tsx
|
|
40
|
+
// seeds its value from `nav.searchParams[filter.id]`). A dashboard with no
|
|
41
|
+
// `filter` has nowhere for the value to land, and a params extractor whose
|
|
42
|
+
// keys don't include the filter's id would silently miss it — both are boot
|
|
43
|
+
// errors instead of a silently-empty dashboard.
|
|
45
44
|
export function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target, actionLabel = "rowAction") {
|
|
46
45
|
// skip: not a navigate-with-params action — nothing to validate here.
|
|
47
46
|
if (action.kind !== "navigate" || action.params === undefined)
|
|
@@ -122,7 +121,7 @@ export function validateActionHasIcon(featureName, screenId, screenType, context
|
|
|
122
121
|
`its kebab segments via the default icon map (resolveActionIcon).`);
|
|
123
122
|
}
|
|
124
123
|
}
|
|
125
|
-
//
|
|
124
|
+
// Pin an action's declarative field references against the entity
|
|
126
125
|
// fields — a typo in pick/map source fields or visible.field would otherwise
|
|
127
126
|
// silently yield `undefined` in the payload or permanently wrong visibility
|
|
128
127
|
// (same "typo only surfaces on click" class as navigate/handler). Exported for
|
|
@@ -206,9 +205,9 @@ export function validateScreenNavTarget(featureName, screenId, screenKind, field
|
|
|
206
205
|
// validateScreens below), the drawer mounts the target inline using this
|
|
207
206
|
// feature's schema, so a cross-feature reference could never actually
|
|
208
207
|
// render. Two distinct failure messages: dangling reference vs. wrong
|
|
209
|
-
// screen type. Shared by ToolbarAction
|
|
210
|
-
//
|
|
211
|
-
//
|
|
208
|
+
// screen type. Shared by ToolbarAction and RowAction drawer variants —
|
|
209
|
+
// same target-resolution rule, only the label in the error message
|
|
210
|
+
// ("toolbarAction" vs. "rowAction" vs. "action") differs.
|
|
212
211
|
export function validateDrawerTargetAction(featureName, screenId, screenKind, actionLabel, action, screens) {
|
|
213
212
|
const target = screens[action.screen];
|
|
214
213
|
if (target === undefined) {
|
|
@@ -337,8 +336,8 @@ export function resolveRowActionNavigateTarget(featureName, screenId, screenType
|
|
|
337
336
|
// redirect is either a short screen id (same-feature, e.g. "item-list") or
|
|
338
337
|
// a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
339
338
|
// renderer strips the latter to the short id (lastSegment) when
|
|
340
|
-
// navigating, which the nav-router resolves app-wide
|
|
341
|
-
// form
|
|
339
|
+
// navigating, which the nav-router resolves app-wide. The object
|
|
340
|
+
// form carries the same target under `screen` plus the payload
|
|
342
341
|
// field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
|
|
343
342
|
// and entityEdit — same rule, same error message, one place to keep them in sync.
|
|
344
343
|
export function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups,
|
|
@@ -4,7 +4,7 @@ import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, sec
|
|
|
4
4
|
import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
|
|
5
5
|
import { isSensitiveOrPasswordField, validateIdlessSuccessLink, validateRedirectTarget, validateScreenNavTarget, } from "./screens-actions.js";
|
|
6
6
|
// A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
|
|
7
|
-
// entityEdit path
|
|
7
|
+
// entityEdit path — a required field the user can never fill would
|
|
8
8
|
// block every save. Only the statically-resolvable case is caught here: a
|
|
9
9
|
// literal `required: true` (screen-spec override or entity-level default).
|
|
10
10
|
// A dynamic FieldCondition depends on runtime form values and can't be
|
|
@@ -95,7 +95,7 @@ export function validateNoWidgetRequiredField(featureName, screenId, entityDef,
|
|
|
95
95
|
if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.includes(fieldDef.type))
|
|
96
96
|
return;
|
|
97
97
|
// Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
|
|
98
|
-
// grid widget
|
|
98
|
+
// grid widget — only plain (non-list) embedded has no widget.
|
|
99
99
|
const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
|
|
100
100
|
// skip: list variant has a widget — not the no-widget case this guard targets.
|
|
101
101
|
if (isEmbeddedList)
|
|
@@ -115,8 +115,7 @@ export function validateNoWidgetRequiredField(featureName, screenId, entityDef,
|
|
|
115
115
|
}
|
|
116
116
|
// Shared by projectionDetail and entityEdit: a tab strip needs something to
|
|
117
117
|
// label each tab with and a stable id per tab — the id anchors the ?tab=
|
|
118
|
-
// param on projectionDetail and the jump-to-erroring-tab logic on entityEdit
|
|
119
|
-
// (fw#3134).
|
|
118
|
+
// param on projectionDetail and the jump-to-erroring-tab logic on entityEdit.
|
|
120
119
|
export function validateTabSections(featureName, screenId, screenType, sections) {
|
|
121
120
|
if (sections.length < 2) {
|
|
122
121
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but only ` +
|
|
@@ -149,7 +148,7 @@ export function validateTabSections(featureName, screenId, screenType, sections)
|
|
|
149
148
|
export function validateWizardLayout(featureName, screenId, screenType, layout, featureMap) {
|
|
150
149
|
// entityEdit renders tabs like wizard steps: every section stays mounted,
|
|
151
150
|
// submit validates across all of them, and a field error jumps to the tab
|
|
152
|
-
// holding it
|
|
151
|
+
// holding it. The other three have no such jump — actionForm and
|
|
153
152
|
// secretMint are one-shot forms, configEdit fires a write per field — so a
|
|
154
153
|
// required field on a hidden tab would still block their submit silently.
|
|
155
154
|
if (layout.mode === "tabs") {
|
|
@@ -201,7 +200,7 @@ export function validateWizardLayout(featureName, screenId, screenType, layout,
|
|
|
201
200
|
//
|
|
202
201
|
// Field-level renderer QN strings (cross-feature `component:` references)
|
|
203
202
|
// are NOT validated here — the r.uiComponent registry that would resolve
|
|
204
|
-
// them ships
|
|
203
|
+
// them ships later. Until then those are kept opaque on purpose.
|
|
205
204
|
// Shared by the mint step's own handler (screen.handler) and its optional
|
|
206
205
|
// confirm step (confirm.handler) — both are a bare write-handler QN with the
|
|
207
206
|
// same registration requirement.
|
|
@@ -226,7 +225,7 @@ function validateWriteHandlerRegistered(featureName, screenId, context, handler,
|
|
|
226
225
|
// secretMint's own `confirm` step — a second, independent inline form on the
|
|
227
226
|
// same screen — can reuse the same checks as its `fields`/`layout` pair.
|
|
228
227
|
// `allowEmpty` is only ever true for a secretMint's own mint step (an
|
|
229
|
-
// input-less mint
|
|
228
|
+
// input-less mint): the secret is server-generated and the mint
|
|
230
229
|
// step is just its submit button — actionForm and the confirm step always
|
|
231
230
|
// require at least one field.
|
|
232
231
|
function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpty) {
|
|
@@ -330,7 +329,7 @@ export function validateFormSelectOptions(featureName, screenId, context, fieldN
|
|
|
330
329
|
}
|
|
331
330
|
}
|
|
332
331
|
}
|
|
333
|
-
// Fail-closed currency-source gate
|
|
332
|
+
// Fail-closed currency-source gate, the runtime half of the
|
|
334
333
|
// narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
|
|
335
334
|
// stop it. An inline form screen has no entity, so a money field there can't
|
|
336
335
|
// borrow `entity.defaultCurrency`: without a declared source the renderer
|
|
@@ -454,7 +453,7 @@ function validateInlineFormNavTargets(feature, screenId, screen, kind, lookups)
|
|
|
454
453
|
export function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandlerQns, lookups, featureMap) {
|
|
455
454
|
validateWriteHandlerRegistered(feature.name, screenId, kind, screen.handler, allWriteHandlerQns);
|
|
456
455
|
// Only a secretMint's own mint step may skip fields/sections entirely
|
|
457
|
-
// (input-less mint
|
|
456
|
+
// (input-less mint) — actionForm always needs at least one field.
|
|
458
457
|
const allowEmptyMintForm = kind === "secretMint";
|
|
459
458
|
const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
|
|
460
459
|
validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
|
|
@@ -472,14 +471,14 @@ export function validateInlineFormScreen(feature, screenId, screen, kind, allWri
|
|
|
472
471
|
}
|
|
473
472
|
validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
|
|
474
473
|
}
|
|
475
|
-
// SecretMintConfirmStep
|
|
474
|
+
// SecretMintConfirmStep: an optional proof-of-receipt form rendered
|
|
476
475
|
// on the reveal card in place of the bare acknowledge button (TOTP enrollment:
|
|
477
476
|
// scan the code, then enter one). Unlike the mint step, confirm always needs
|
|
478
477
|
// at least one field — a step whose only job is proving receipt with no input
|
|
479
478
|
// would be meaningless — and its layout is a single plain form, never a
|
|
480
479
|
// wizard/tabs flow and never a persisted draft: a one-time reveal-confirm is
|
|
481
480
|
// not a multi-step flow, and persisting a draft of it would re-open the leak
|
|
482
|
-
//
|
|
481
|
+
// (a lingering copy of the just-revealed secret in storage).
|
|
483
482
|
export function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns) {
|
|
484
483
|
const confirm = screen.confirm;
|
|
485
484
|
// skip: the screen declares no confirm step — nothing to validate.
|
|
@@ -258,24 +258,23 @@ export function assertRefTargetRegistered(prefix, subject, refTarget, currentFea
|
|
|
258
258
|
.join(", ") || "(none)"}.`);
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
|
-
// Facets
|
|
261
|
+
// Facets — unlike filter, a field inventory IS available here: the
|
|
262
262
|
// declared columns. A facet on a field with no column is almost always a
|
|
263
263
|
// typo (the user never sees the field anywhere), so this is hard-checked
|
|
264
|
-
// rather than just documented. Shared by projectionList and relatedList
|
|
265
|
-
//
|
|
264
|
+
// rather than just documented. Shared by projectionList and relatedList —
|
|
265
|
+
// `prefix` carries the caller's own screen/section message lead-in.
|
|
266
266
|
//
|
|
267
|
-
// `type: "reference"` is exempt from the same-name-column rule
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// matching column is still almost always a typo.
|
|
267
|
+
// `type: "reference"` is exempt from the same-name-column rule: it filters
|
|
268
|
+
// by an id field (e.g. "propertyId") but displays a different,
|
|
269
|
+
// human-readable column (e.g. "propertyLabel") — a column named after the
|
|
270
|
+
// filter field would almost never exist, and requiring one anyway would
|
|
271
|
+
// force apps to add a column no design calls for just to satisfy this check.
|
|
272
|
+
// Its own `entity` is mandatory on the type (TS, not just this validator) and
|
|
273
|
+
// is resolved below — that explicit, checked declaration is this facet type's
|
|
274
|
+
// field inventory, filling the same "not just a typo" role the column-name
|
|
275
|
+
// check plays for select/boolean facets. select/boolean facets keep the
|
|
276
|
+
// column requirement unchanged: both filter and display the same field, so a
|
|
277
|
+
// facet with no matching column is still almost always a typo.
|
|
279
278
|
export function validateListFacets(prefix, facets, columns, currentFeatureName, featureMap) {
|
|
280
279
|
const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
|
|
281
280
|
const seenFacetFields = new Set();
|