@cosmicdrift/kumiko-framework 0.334.0 → 0.336.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/dist/api/anonymous-live-entities.d.ts +2 -0
- package/dist/api/anonymous-live-entities.js +17 -0
- package/dist/api/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/index.d.ts +2 -1
- package/dist/api/index.js +1 -0
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +20 -10
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +5 -2
- package/dist/api/sse-route.js +71 -11
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +32 -11
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +45 -38
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +5 -2
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +53 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +125 -46
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +65 -20
- package/dist/engine/build-config-feature-schema.js +8 -1
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/create-app.js +10 -1
- package/dist/engine/extension-names.d.ts +4 -0
- package/dist/engine/extension-names.js +4 -0
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +39 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/feature-entity-handlers.d.ts +1 -0
- package/dist/engine/feature-entity-handlers.js +2 -0
- package/dist/engine/index.d.ts +4 -3
- package/dist/engine/index.js +3 -2
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +14 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +1 -0
- package/dist/engine/schema-builder.js +9 -2
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +34 -7
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/system-user.d.ts +1 -0
- package/dist/engine/system-user.js +3 -0
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/dry-run.js +10 -3
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +10 -2
- package/dist/event-store/provenance-append.js +4 -1
- package/dist/files/file-routes.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +23 -35
- package/dist/i18n/settings-hub-keys.js +3 -0
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +172 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +2 -1
- package/dist/observability/index.js +2 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +16 -0
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +22 -5
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher-delivery.js +5 -0
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +30 -12
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +19 -20
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-origin.js +13 -6
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-event-consumers.js +3 -0
- package/dist/stack/drain-jobs.d.ts +3 -1
- package/dist/stack/drain-jobs.js +55 -13
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +264 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +111 -44
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
|
@@ -249,11 +249,21 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
249
249
|
const catalogFields = eventCatalog.get(row.type);
|
|
250
250
|
if (catalogFields) {
|
|
251
251
|
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
252
|
+
let subject;
|
|
253
|
+
try {
|
|
254
|
+
subject = resolveEventSubject(field, spec, payload, {
|
|
255
|
+
tenantId: row.tenant_id, // @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
256
|
+
aggregateType: row.aggregate_type,
|
|
257
|
+
aggregateId: row.aggregate_id,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
// Re-thrown as a plain Error: the owner-resolution retry hint added for
|
|
262
|
+
// SubjectResolutionError only applies to entity events.
|
|
263
|
+
if (!(e instanceof SubjectResolutionError))
|
|
264
|
+
throw e;
|
|
265
|
+
throw new Error(`catalog event subject unresolvable: ${e.message} (fix aggregate_type/tenant_id or the piiFields declaration)`);
|
|
266
|
+
}
|
|
257
267
|
if (subject === null)
|
|
258
268
|
continue;
|
|
259
269
|
const outcome = await encryptField(payload, field, subject);
|
|
@@ -109,8 +109,8 @@ export async function assertLiveTableHasNoRowLevelSecurity(tx, tableName, projec
|
|
|
109
109
|
throw new Error(`${context}: live table "${tableName}" has row level security (enabled: ${row.rls_enabled}, ` +
|
|
110
110
|
`forced: ${row.rls_forced}, policies: ${row.policy_count}). The shadow swap rebuilds the table ` +
|
|
111
111
|
"from EntityTableMeta and would silently drop RLS and every policy. Kumiko does not support RLS " +
|
|
112
|
-
"on rebuildable tables
|
|
113
|
-
"table untouched.");
|
|
112
|
+
"on rebuildable tables: drop the RLS flags/policies on this table or exclude it from online " +
|
|
113
|
+
"rebuild. Rebuild aborted; live table untouched.");
|
|
114
114
|
}
|
|
115
115
|
// Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
|
|
116
116
|
// Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
|
|
@@ -227,8 +227,9 @@ export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName
|
|
|
227
227
|
throw new Error(`projection-rebuild "${projectionName}": "${tableName}" has ${count} row(s) with a populated ` +
|
|
228
228
|
`blind-index column, but KUMIKO_BLIND_INDEX_KEY is not configured in this process. The rebuild ` +
|
|
229
229
|
`would recompute those columns to NULL, and equality lookups on that field (login, password ` +
|
|
230
|
-
`reset) would stop matching afterward. Configure the
|
|
231
|
-
`
|
|
230
|
+
`reset) would stop matching afterward. Configure the complete KMS wiring (PLATFORM_KEK, ` +
|
|
231
|
+
`SUBJECT_KEYS_DATABASE_URL, KUMIKO_BLIND_INDEX_KEY, all-or-none) in the process running this ` +
|
|
232
|
+
`apply/rebuild. See fw#3091. Rebuild aborted; live table untouched.`);
|
|
232
233
|
}
|
|
233
234
|
// Columns ignored by countColumnDrift — the one PROVABLY legitimate class of
|
|
234
235
|
// live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
|
|
@@ -5,6 +5,7 @@ import type { EntityDefinition, EntityRelations, FieldDefinition, FieldsMap } fr
|
|
|
5
5
|
import type { Table } from "./event-store-executor-context.js";
|
|
6
6
|
export { toSnakeCase } from "../utils/case.js";
|
|
7
7
|
import { type ColumnBuilder, type ColumnHandle, type SchemaTable, type TableColumns } from "./dialect.js";
|
|
8
|
+
export declare const SOFT_DELETE_LIVE_ROW_PREDICATE = "\"is_deleted\" = false";
|
|
8
9
|
export declare function physicalColumnName(table: Table, field: string): string;
|
|
9
10
|
export type AnyPgColumn = ColumnHandle;
|
|
10
11
|
export declare const READ_MODEL_PREFIX = "read_";
|
package/dist/db/table-builder.js
CHANGED
|
@@ -2,7 +2,10 @@ import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types
|
|
|
2
2
|
import { toSnakeCase } from "../utils/case.js";
|
|
3
3
|
import { assertUnreachable } from "../utils/index.js";
|
|
4
4
|
export { toSnakeCase } from "../utils/case.js";
|
|
5
|
-
import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate,
|
|
5
|
+
import { bigint, boolean, decimalColumn, doublePrecision, index, instant, integer, jsonb, moneyAmount, table as pgTable, plainDate, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
|
|
6
|
+
// Partial-index predicate for soft-delete entities; table-builder and
|
|
7
|
+
// entity-table-meta must render identical index definitions.
|
|
8
|
+
export const SOFT_DELETE_LIVE_ROW_PREDICATE = `"is_deleted" = false`;
|
|
6
9
|
// Shared by the CRUD executor and the parent-ref read-gate — a field's
|
|
7
10
|
// declared column name if it has one, else its snake_case default.
|
|
8
11
|
export function physicalColumnName(table, field) {
|
|
@@ -382,13 +385,7 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
382
385
|
else if (def.unique === true && entity.softDelete === true) {
|
|
383
386
|
// Soft-deleted rows keep their value, so a full unique index would
|
|
384
387
|
// permanently block reuse; kept in lock-step with entity-table-meta.ts (framework#2593).
|
|
385
|
-
|
|
386
|
-
kind: "sql-expr",
|
|
387
|
-
text: `"is_deleted" = false`,
|
|
388
|
-
params: [],
|
|
389
|
-
[SQL_EXPR_BRAND]: true,
|
|
390
|
-
};
|
|
391
|
-
chain = chain.where(softDeleteWhere);
|
|
388
|
+
chain = chain.where(sql.raw(SOFT_DELETE_LIVE_ROW_PREDICATE));
|
|
392
389
|
}
|
|
393
390
|
indexes[indexName] = chain;
|
|
394
391
|
// Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
|
|
@@ -407,14 +404,9 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
407
404
|
// restored row can't reuse a value a soft-deleted sibling still
|
|
408
405
|
// holds (framework#2464).
|
|
409
406
|
const whereText = (entity.softDelete === true
|
|
410
|
-
? [...notNullParts,
|
|
407
|
+
? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
|
|
411
408
|
: notNullParts).join(" AND ");
|
|
412
|
-
const partialWhere =
|
|
413
|
-
kind: "sql-expr",
|
|
414
|
-
text: whereText,
|
|
415
|
-
params: [],
|
|
416
|
-
[SQL_EXPR_BRAND]: true,
|
|
417
|
-
};
|
|
409
|
+
const partialWhere = sql.raw(whereText);
|
|
418
410
|
indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
|
|
419
411
|
.on(...bidxCols)
|
|
420
412
|
.where(partialWhere);
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -3,8 +3,7 @@ import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-t
|
|
|
3
3
|
import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
|
|
4
4
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
5
5
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
6
|
-
import { fallbackEscapeHatchReporter } from "../observability/
|
|
7
|
-
import { emitDbQuery, registerStandardMetrics, } from "../observability/index.js";
|
|
6
|
+
import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
|
|
8
7
|
import { bindTenantDbRunner, tenantDbRunner } from "./tenant-db-runner.js";
|
|
9
8
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
10
9
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
@@ -361,6 +360,17 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
361
360
|
return undefined;
|
|
362
361
|
return new AccessDeniedError({ message });
|
|
363
362
|
}
|
|
363
|
+
// A tenant-mode write against a "global" table can never match (rows carry SYSTEM_TENANT_ID, writeWhere pins the caller's tenant), so reject loudly instead of silently affecting zero rows.
|
|
364
|
+
function tenantWriteOnGlobalTable(table, method) {
|
|
365
|
+
if (mode !== "tenant" || !hasTenantColumn(table))
|
|
366
|
+
return undefined;
|
|
367
|
+
if (asEntityTableMeta(table)?.tenancy !== "global")
|
|
368
|
+
return undefined;
|
|
369
|
+
return new AccessDeniedError({
|
|
370
|
+
message: `${method}(${tableNameOf(table)}): "global" table rows carry SYSTEM_TENANT_ID and never match the caller's tenant; ` +
|
|
371
|
+
"use db.global(table) with escapeHatch instead.",
|
|
372
|
+
});
|
|
373
|
+
}
|
|
364
374
|
function globalTable(table) {
|
|
365
375
|
const meta = asEntityTableMeta(table);
|
|
366
376
|
if (meta?.tenancy !== "global") {
|
|
@@ -466,6 +476,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
466
476
|
if (!where || Object.keys(where).length === 0) {
|
|
467
477
|
return Promise.reject(new Error("TenantDb.updateMany without where would mass-update all tenant rows. Pass at least one where condition."));
|
|
468
478
|
}
|
|
479
|
+
const globalDenied = tenantWriteOnGlobalTable(table, "updateMany");
|
|
480
|
+
if (globalDenied)
|
|
481
|
+
return Promise.reject(globalDenied);
|
|
469
482
|
const personalDenied = personalDataDenied(table, Object.keys(set));
|
|
470
483
|
if (personalDenied)
|
|
471
484
|
return Promise.reject(personalDenied);
|
|
@@ -476,6 +489,9 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
476
489
|
if (!where || Object.keys(where).length === 0) {
|
|
477
490
|
return Promise.reject(new Error("TenantDb.deleteMany without where would mass-delete all tenant rows. Pass at least one where condition."));
|
|
478
491
|
}
|
|
492
|
+
const globalDenied = tenantWriteOnGlobalTable(table, "deleteMany");
|
|
493
|
+
if (globalDenied)
|
|
494
|
+
return Promise.reject(globalDenied);
|
|
479
495
|
const filter = writeWhere(table, where);
|
|
480
496
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
|
|
481
497
|
},
|
|
@@ -34,7 +34,9 @@ export function variantSuffix(name, spec) {
|
|
|
34
34
|
// A full derivative-suffix segment is `<name>-<16 hex chars>` — mirrors
|
|
35
35
|
// VARIANT_NAME_PATTERN (name grammar) + specHash's fixed 16-char slice.
|
|
36
36
|
// Keep in sync with both if either changes.
|
|
37
|
-
|
|
37
|
+
// The hash part stays case-sensitive (specHash emits lowercase hex only), so
|
|
38
|
+
// the erasure sweep never matches a differently-cased sibling file.
|
|
39
|
+
const DERIVATIVE_SUFFIX_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}-[0-9a-f]{16}$/;
|
|
38
40
|
// Mirrors deriveKey's own split so callers get the exact same base/ext this
|
|
39
41
|
// key's derivatives were built from.
|
|
40
42
|
function splitKey(key) {
|
|
@@ -81,19 +81,28 @@ function validatePersonalDataValue(feature, handlerName, access) {
|
|
|
81
81
|
`access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`);
|
|
82
82
|
}
|
|
83
83
|
// skip: roles include "anonymous", which personalData: "public-intake" requires
|
|
84
|
-
if (
|
|
84
|
+
if (accessAllowsAnonymous(access))
|
|
85
85
|
return;
|
|
86
86
|
throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
|
|
87
87
|
'access.personalData: "public-intake" but its roles do not include ' +
|
|
88
88
|
`"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
|
|
89
89
|
"that allow anonymous callers.");
|
|
90
90
|
}
|
|
91
|
+
function handlerInputKeys(feature, handlerName, handler) {
|
|
92
|
+
try {
|
|
93
|
+
return collectZodObjectKeys(handler.schema);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
97
|
+
throw new Error(`[Feature ${feature.name}] write handler "${handlerName}": ${reason}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
91
100
|
function validateOpenToAllPersonalData(feature, handlerName, handler) {
|
|
92
101
|
const access = handler.access;
|
|
93
102
|
// skip: no openToAll declared, or the handler declares tenant members may write personal data
|
|
94
103
|
if (!hasOpenToAll(access) || declaresTenantMembersPersonalData(access))
|
|
95
104
|
return;
|
|
96
|
-
const inputKeys =
|
|
105
|
+
const inputKeys = handlerInputKeys(feature, handlerName, handler);
|
|
97
106
|
const personalNames = candidatePersonalFieldNames(feature, handlerName, handler);
|
|
98
107
|
const offending = [...inputKeys].filter((key) => personalNames.has(key));
|
|
99
108
|
// skip: no personal-data fields in the handler's input
|
|
@@ -114,7 +123,7 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
|
|
|
114
123
|
// skip: not reachable by an anonymous caller, or already declares public-intake
|
|
115
124
|
if (!accessAllowsAnonymous(access) || declaredPersonalData(access) === "public-intake")
|
|
116
125
|
return;
|
|
117
|
-
const inputKeys =
|
|
126
|
+
const inputKeys = handlerInputKeys(feature, handlerName, handler);
|
|
118
127
|
const personalNames = candidatePersonalFieldNames(feature, handlerName, handler, false);
|
|
119
128
|
const offending = [...inputKeys].filter((key) => personalNames.has(key));
|
|
120
129
|
// skip: no personal-data fields in the handler's input
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
|
|
3
|
+
import { access as accessPresets } from "../config-helpers.js";
|
|
3
4
|
import { parseRefTarget } from "../parse-ref-target.js";
|
|
4
5
|
export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
|
|
5
6
|
// Field-Namen die typischerweise PII enthalten. Ohne `pii: true` /
|
|
@@ -150,7 +151,7 @@ export function validateNoAllRoleInHandlerAccess(featureName, kind, handlerName,
|
|
|
150
151
|
if (!("roles" in access))
|
|
151
152
|
return;
|
|
152
153
|
// skip: no "all" in the roles list, nothing unreachable to report
|
|
153
|
-
if (!access.roles.includes(
|
|
154
|
+
if (!accessPresets.all.some((role) => access.roles.includes(role)))
|
|
154
155
|
return;
|
|
155
156
|
throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" declares access: { roles: ["all"] } — ` +
|
|
156
157
|
`no session ever carries the role "all", so this handler is unreachable by any caller. ` +
|
|
@@ -578,8 +579,19 @@ function validateEmbeddedListMetadata(fieldName, entityName, field, entity) {
|
|
|
578
579
|
// default — wenn gesetzt — ist eine Teilmenge der options. Beides würde
|
|
579
580
|
// auch im Zod-Schema bei runtime fehlschlagen, der Boot-Catch ist nur
|
|
580
581
|
// die früheste Stelle für klare Fehlermeldungen.
|
|
582
|
+
// Exhaustive over MultiSelectFieldDef["columns"] (the compiler flags a union
|
|
583
|
+
// change); the TS union is gone at runtime for JS/JSON-sourced definitions.
|
|
584
|
+
const CHECKBOX_COLUMN_COUNTS = {
|
|
585
|
+
1: true,
|
|
586
|
+
2: true,
|
|
587
|
+
3: true,
|
|
588
|
+
4: true,
|
|
589
|
+
};
|
|
581
590
|
function validateCheckboxDisplayOptions(field, fieldName, entityName) {
|
|
582
591
|
if (field.display === "checkboxes") {
|
|
592
|
+
if (field.columns !== undefined && !(field.columns in CHECKBOX_COLUMN_COUNTS)) {
|
|
593
|
+
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" has invalid columns "${field.columns}" — must be ${Object.keys(CHECKBOX_COLUMN_COUNTS).join(", ")}.`);
|
|
594
|
+
}
|
|
583
595
|
if (field.maxRows !== undefined && (!Number.isInteger(field.maxRows) || field.maxRows < 1)) {
|
|
584
596
|
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" has invalid maxRows "${field.maxRows}" — must be a positive integer.`);
|
|
585
597
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types/index.js";
|
|
2
2
|
/** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
|
|
3
3
|
export declare const SEARCHABLE_FALSE_WHITELIST: Set<string>;
|
|
4
|
+
export declare const LIST_ROW_META_COLUMN_NAMES: Set<string>;
|
|
4
5
|
export declare function validateEntityListScreens(features: readonly FeatureDefinition[]): void;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
|
|
2
2
|
import { normalizeListColumn } from "../screen-helpers.js";
|
|
3
3
|
/** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
|
|
4
|
-
export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
|
|
4
|
+
export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list", "cap-list"]);
|
|
5
5
|
// Same set computeListViewModel (headless) can actually render — NOT
|
|
6
6
|
// db/table-builder's rowMetaFieldNames(softDelete), which also carries
|
|
7
7
|
// isDeleted/deletedAt/deletedById. Accepting those here would let a
|
|
8
8
|
// softDelete column pass the boot gate and then throw at render-time
|
|
9
9
|
// because the renderer's LIST_ROW_META_COLUMNS doesn't know them.
|
|
10
|
-
const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
|
|
10
|
+
export const LIST_ROW_META_COLUMN_NAMES = new Set(Object.keys(LIST_ROW_META_COLUMNS));
|
|
11
11
|
function hasFilterableFields(feature, entityName) {
|
|
12
12
|
const entities = feature.entities;
|
|
13
13
|
if (!entities)
|
|
@@ -60,11 +60,11 @@ export function validateBoot(rawFeatures, options) {
|
|
|
60
60
|
// `…:config:branding-title`; ohne toKebab failt jeder configEdit-Screen mit
|
|
61
61
|
// multi-word Config-Key fälschlich. allConfigKeys oben nutzt das ältere
|
|
62
62
|
// `feature.short`-Format für validateConfigReads.
|
|
63
|
-
const
|
|
63
|
+
const configKeyDefsByQn = new Map();
|
|
64
64
|
for (const f of features) {
|
|
65
|
-
for (const key of Object.
|
|
65
|
+
for (const [key, def] of Object.entries(f.configKeys)) {
|
|
66
66
|
allConfigKeys.add(`${f.name}.${key}`);
|
|
67
|
-
|
|
67
|
+
configKeyDefsByQn.set(qualifyEntityName(f.name, QnTypes.config, key), def);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
// Collect all claim keys — the ownership-rule validator below resolves
|
|
@@ -153,7 +153,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
153
153
|
// Function-Check hier überhaupt liefe.
|
|
154
154
|
validateActionWiring(feature);
|
|
155
155
|
validateFieldWiring(feature);
|
|
156
|
-
validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns,
|
|
156
|
+
validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens);
|
|
157
157
|
validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
|
|
158
158
|
validateWorkspaces(feature, allNavQns);
|
|
159
159
|
}
|
|
@@ -161,7 +161,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
161
161
|
warnOnNavAccessInversion(allNavQns);
|
|
162
162
|
const effectiveNavAllowlist = resolveNavAllowlist(options?.navAllowlist, allNavQns, allWorkspaceQns);
|
|
163
163
|
if (effectiveNavAllowlist !== undefined) {
|
|
164
|
-
warnOnUnreachableNavScreens(allNavQns, effectiveNavAllowlist, options?.navAllowlistExempt);
|
|
164
|
+
warnOnUnreachableNavScreens(allNavQns, effectiveNavAllowlist, options?.navAllowlistExempt, options?.navAllowlist === undefined ? "workspaces" : "explicit");
|
|
165
165
|
}
|
|
166
166
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
167
167
|
validateI18nSurfaceKeys(features);
|
|
@@ -188,7 +188,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
188
188
|
// custom KMS provider) satisfies the requirement without env keys.
|
|
189
189
|
validateEntityFieldEncryptionAvailable(options?.env);
|
|
190
190
|
}
|
|
191
|
-
if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {
|
|
191
|
+
if (hasFileFields && !(options?.env ?? process.env)[FILE_STORAGE_PROVIDER_ENV]) {
|
|
192
192
|
throw new Error(`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`);
|
|
193
193
|
}
|
|
194
194
|
validateConfigReads(features, allConfigKeys);
|
|
@@ -14,7 +14,8 @@ export declare function validateNavCycles(allNavQns: ReadonlyMap<string, NavDefi
|
|
|
14
14
|
export declare function warnOnNavAccessInversion(allNavQns: ReadonlyMap<string, NavDefinition & {
|
|
15
15
|
readonly featureName: string;
|
|
16
16
|
}>): void;
|
|
17
|
+
export type NavAllowlistSource = "explicit" | "workspaces";
|
|
17
18
|
export declare function warnOnUnreachableNavScreens(allNavQns: ReadonlyMap<string, NavDefinition & {
|
|
18
19
|
readonly featureName: string;
|
|
19
|
-
}>, allowedNavQns: ReadonlySet<string>, navAllowlistExempt?: ReadonlySet<string
|
|
20
|
+
}>, allowedNavQns: ReadonlySet<string>, navAllowlistExempt?: ReadonlySet<string>, allowlistSource?: NavAllowlistSource): void;
|
|
20
21
|
export declare function collectKnownRoles(features: readonly FeatureDefinition[]): Set<string>;
|
|
@@ -125,6 +125,8 @@ export function warnOnNavAccessInversion(allNavQns) {
|
|
|
125
125
|
const leafRoles = navViewerRoles(navDef.access);
|
|
126
126
|
if (parentRoles === undefined || leafRoles === undefined)
|
|
127
127
|
continue;
|
|
128
|
+
if (leafRoles.length === 0)
|
|
129
|
+
continue; // leaf visible to nobody (empty roles or invalid openToAll) — not an inversion
|
|
128
130
|
if (parentRoles.length === 0)
|
|
129
131
|
continue; // parent already visible to nobody — a different problem
|
|
130
132
|
if (leafRoles.some((role) => parentRoles.includes(role)))
|
|
@@ -136,41 +138,29 @@ export function warnOnNavAccessInversion(allNavQns) {
|
|
|
136
138
|
`otherwise align the role sets.`);
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
|
-
|
|
140
|
-
// <id>", see packages/types/src/nav.ts) — this only guards a short id
|
|
141
|
-
// slipping through, since a wrong qualification here would make every nav
|
|
142
|
-
// look orphaned (28 false positives on the real offlot-app schema instead
|
|
143
|
-
// of the intended 8, see fw#3019 measurement).
|
|
144
|
-
function qualifyNavScreenQn(featureName, screen) {
|
|
145
|
-
return screen.includes(":screen:") ? screen : `${featureName}:screen:${screen}`;
|
|
146
|
-
}
|
|
147
|
-
// fw#3019: warn (never throw — a screen deliberately left out of the app's
|
|
148
|
-
// sidebar is legitimate) when a declared nav's screen isn't reachable from
|
|
149
|
-
// any allowlisted nav entry. Checking "nav QN not in allowlist" directly
|
|
150
|
-
// warned 28x on the real offlot-app schema (58 navs, 30 allowed) — most of
|
|
151
|
-
// them app-shell leaves that intentionally re-target a screen another,
|
|
152
|
-
// allowlisted nav already reaches. Reachability warns only for a screen no
|
|
153
|
-
// allowlisted nav points at, which is the actual "unreachable via sidebar"
|
|
154
|
-
// bug (solon#113, offlot's VIN screen in pilot).
|
|
155
|
-
export function warnOnUnreachableNavScreens(allNavQns, allowedNavQns, navAllowlistExempt = new Set()) {
|
|
141
|
+
export function warnOnUnreachableNavScreens(allNavQns, allowedNavQns, navAllowlistExempt = new Set(), allowlistSource = "explicit") {
|
|
156
142
|
const reachableScreenQns = new Set();
|
|
157
143
|
for (const [qn, navDef] of allNavQns) {
|
|
158
144
|
if (!allowedNavQns.has(qn) || navDef.screen === undefined)
|
|
159
145
|
continue;
|
|
160
|
-
reachableScreenQns.add(
|
|
146
|
+
reachableScreenQns.add(navDef.screen);
|
|
161
147
|
}
|
|
162
148
|
for (const [qn, navDef] of allNavQns) {
|
|
163
149
|
if (allowedNavQns.has(qn) || navDef.screen === undefined || navAllowlistExempt.has(qn)) {
|
|
164
150
|
continue;
|
|
165
151
|
}
|
|
166
|
-
|
|
152
|
+
// validateNavs already guarantees a registered, qualified screen QN
|
|
153
|
+
const screenQn = navDef.screen;
|
|
167
154
|
if (reachableScreenQns.has(screenQn))
|
|
168
155
|
continue;
|
|
156
|
+
const remedy = allowlistSource === "workspaces"
|
|
157
|
+
? "add it to a workspace's `nav` array or set `workspaces` on the nav"
|
|
158
|
+
: "add it to the allowlist";
|
|
169
159
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
170
160
|
console.warn(`[kumiko:boot] Nav entry "${qn}" declared by feature "${navDef.featureName}" points at ` +
|
|
171
161
|
`screen "${screenQn}", which no allowlisted nav entry reaches — it is unreachable via ` +
|
|
172
162
|
`the sidebar. If this is intentional (e.g. reachable through a generated hub), add ` +
|
|
173
|
-
`"${qn}" to navAllowlistExempt; otherwise
|
|
163
|
+
`"${qn}" to navAllowlistExempt; otherwise ${remedy}.`);
|
|
174
164
|
}
|
|
175
165
|
}
|
|
176
166
|
// Roles we recognise at boot time. The framework has no explicit
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type OwnershipMap, type WhereRule } from "../ownership.js";
|
|
2
2
|
import type { ClaimKeyDefinition, FeatureDefinition } from "../types/index.js";
|
|
3
3
|
export type WhereRuleProbe = {
|
|
4
4
|
readonly table: unknown;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildEntityTable } from "../../db/table-builder.js";
|
|
2
|
+
import { tableNameOf, } from "../ownership.js";
|
|
2
3
|
import { SYSTEM_USER_ID } from "../system-user.js";
|
|
3
4
|
import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
|
|
4
5
|
import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule-lint.js";
|
|
@@ -68,7 +69,7 @@ export function validateOwnershipRules(feature, allClaimKeys, knownRoles) {
|
|
|
68
69
|
const table = buildEntityTable(entityName, entity);
|
|
69
70
|
const columns = tableColumnSqlNames(table);
|
|
70
71
|
if (columns.size > 0) {
|
|
71
|
-
probe = { table, tableName:
|
|
72
|
+
probe = { table, tableName: tableNameOf(table), columns };
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
catch {
|
|
@@ -1,2 +1,4 @@
|
|
|
1
|
+
import type { EntityDefinition } from "../types/fields.js";
|
|
1
2
|
import type { FeatureDefinition } from "../types/index.js";
|
|
3
|
+
export declare function entityHasAnonymizableSubjectField(fields: EntityDefinition["fields"]): boolean;
|
|
2
4
|
export declare function validatePiiAndRetention(feature: FeatureDefinition): void;
|
|
@@ -17,6 +17,9 @@ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
|
17
17
|
function hasAnonymizableSubjectField(annot) {
|
|
18
18
|
return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
|
|
19
19
|
}
|
|
20
|
+
export function entityHasAnonymizableSubjectField(fields) {
|
|
21
|
+
return Object.values(fields).some((f) => hasAnonymizableSubjectField(f));
|
|
22
|
+
}
|
|
20
23
|
function ownerFieldNamesReferencedByPersonalOf(fieldsByName) {
|
|
21
24
|
const ownerFieldNames = new Set();
|
|
22
25
|
for (const field of Object.values(fieldsByName)) {
|
|
@@ -216,12 +219,12 @@ export function validatePiiAndRetention(feature) {
|
|
|
216
219
|
// blockDelete with no field that can carry `anonymize` is fine;
|
|
217
220
|
// subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
|
|
218
221
|
// instead (#1622, #2336).
|
|
219
|
-
const
|
|
222
|
+
const entityHasAnonymizableField = entityHasAnonymizableSubjectField(fieldsByName);
|
|
220
223
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
221
224
|
const a = f; // @cast-boundary schema-walk
|
|
222
225
|
return Boolean(a.anonymize);
|
|
223
226
|
});
|
|
224
|
-
if (
|
|
227
|
+
if (entityHasAnonymizableField && !hasAnonymize) {
|
|
225
228
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
226
229
|
console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
|
|
227
230
|
}
|
|
@@ -88,6 +88,9 @@ const RESERVED_LIST_PAYLOAD_KEYS = new Set([
|
|
|
88
88
|
"filters",
|
|
89
89
|
]);
|
|
90
90
|
function validateProjectionListDateRangeFacets(prefix, screen, schema) {
|
|
91
|
+
// Shared params overwrite each other in the payload, silently dropping the first pick.
|
|
92
|
+
// (Two facets on one field are already rejected by the generic duplicate-facet check.)
|
|
93
|
+
const usedParams = new Set();
|
|
91
94
|
for (const facet of screen.facets ?? []) {
|
|
92
95
|
if (facet.type !== "dateRange")
|
|
93
96
|
continue;
|
|
@@ -100,6 +103,11 @@ function validateProjectionListDateRangeFacets(prefix, screen, schema) {
|
|
|
100
103
|
throw new Error(`${prefix}: dateRange facet on "${facet.field}" names "${param}" as a bound, ` +
|
|
101
104
|
`which is a reserved list-payload key — pick the query's own time-bound param names.`);
|
|
102
105
|
}
|
|
106
|
+
if (usedParams.has(param)) {
|
|
107
|
+
throw new Error(`${prefix}: dateRange facet on "${facet.field}" names "${param}" which another dateRange ` +
|
|
108
|
+
`facet on this screen already uses — the facets would overwrite each other's bound.`);
|
|
109
|
+
}
|
|
110
|
+
usedParams.add(param);
|
|
103
111
|
if (schemaAccepts(schema, param))
|
|
104
112
|
continue;
|
|
105
113
|
throw new Error(`${prefix}: dateRange facet on "${facet.field}" sends "${param}" but query ` +
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPagedQueryHandler } from "../define-handler.js";
|
|
2
|
-
import { normalizeListColumn } from "../screen-helpers.js";
|
|
2
|
+
import { normalizeListColumn, sectionFieldSpecs } from "../screen-helpers.js";
|
|
3
3
|
import { metricField } from "../types/index.js";
|
|
4
4
|
import { buildQueryHandlerMap } from "./projection-list-screens.js";
|
|
5
5
|
import { validateActionFieldRefs } from "./screens.js";
|
|
@@ -37,8 +37,23 @@ function checkPagedHandlerOutputSchemaShape(queryHandlers) {
|
|
|
37
37
|
throw new Error(`Query handler "${qn}" is a paged handler (definePagedQueryHandler) but its outputSchema does not describe the paged envelope { rows: [...], nextCursor, total? } — it has no "rows" field. Did you pass the row schema instead of wrapping it in the envelope?`);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
// Usage-site twin of checkPagedHandlerOutputSchemaShape: a plain
|
|
41
|
+
// r.queryHandler/defineQueryHandler feeding a list surface has no paged
|
|
42
|
+
// brand, so declaring the row shape instead of the `{ rows, ... }` envelope
|
|
43
|
+
// would otherwise make every column check below a silent no-op.
|
|
44
|
+
function resolveListRowShape(queryHandlers, queryQn, usage) {
|
|
45
|
+
const outputSchema = queryHandlers.get(queryQn)?.outputSchema;
|
|
46
|
+
const rowShape = getZodRowShape(outputSchema);
|
|
47
|
+
if (rowShape !== undefined)
|
|
48
|
+
return rowShape;
|
|
49
|
+
const envelopeShape = getZodObjectShape(outputSchema);
|
|
50
|
+
// skip: no introspectable object schema (absent / union) — capability absent per module policy
|
|
51
|
+
if (envelopeShape === undefined || "rows" in envelopeShape)
|
|
52
|
+
return undefined;
|
|
53
|
+
throw new Error(`${usage} uses query "${queryQn}" as a list source, but its outputSchema does not describe the paged envelope { rows: [...], nextCursor, total? } — it has no "rows" field. Did you pass the row schema instead of wrapping it in the envelope?`);
|
|
54
|
+
}
|
|
40
55
|
function checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen) {
|
|
41
|
-
const rowShape =
|
|
56
|
+
const rowShape = resolveListRowShape(queryHandlers, screen.query, `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
|
|
42
57
|
for (const column of screen.columns) {
|
|
43
58
|
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (projectionList) column "${field}" is not present in query "${screen.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
|
|
44
59
|
}
|
|
@@ -47,7 +62,7 @@ function checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, scre
|
|
|
47
62
|
for (const section of layout.sections) {
|
|
48
63
|
if (section.kind !== "relatedList")
|
|
49
64
|
continue;
|
|
50
|
-
const rowShape =
|
|
65
|
+
const rowShape = resolveListRowShape(queryHandlers, section.query, `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`);
|
|
51
66
|
for (const column of section.columns) {
|
|
52
67
|
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}" column "${field}" is not present in query "${section.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
|
|
53
68
|
}
|
|
@@ -77,6 +92,9 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
|
|
|
77
92
|
}
|
|
78
93
|
}
|
|
79
94
|
}
|
|
95
|
+
if (screen.header.subtitleHref !== undefined) {
|
|
96
|
+
checkHeaderField("subtitleHref", screen.header.subtitleHref);
|
|
97
|
+
}
|
|
80
98
|
if (screen.header.status !== undefined)
|
|
81
99
|
checkHeaderField("status", screen.header.status);
|
|
82
100
|
}
|
|
@@ -88,8 +106,26 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
|
|
|
88
106
|
const field = metricField(metric);
|
|
89
107
|
checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
90
108
|
}
|
|
109
|
+
checkDetailSectionFields(recordShape, prefix, screen);
|
|
91
110
|
checkRelatedListToolbarActionFields(recordShape, featureName, screenId, screen);
|
|
92
111
|
}
|
|
112
|
+
// Field-grid sections read the enclosing record (the query result), so a typo
|
|
113
|
+
// is a silently-empty cell. writeForm sections are skipped on purpose: their
|
|
114
|
+
// fields are form inputs defined by `fieldDefs`, not necessarily record keys.
|
|
115
|
+
function checkDetailSectionFields(recordShape, prefix, screen) {
|
|
116
|
+
for (const section of screen.layout.sections) {
|
|
117
|
+
const countField = "countField" in section ? section.countField : undefined;
|
|
118
|
+
if (countField !== undefined) {
|
|
119
|
+
checkFieldExists(recordShape, countField, () => `${prefix} section "${section.title ?? "(untitled)"}" countField references field "${countField}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
120
|
+
}
|
|
121
|
+
if (section.kind !== undefined && section.kind !== "fields")
|
|
122
|
+
continue;
|
|
123
|
+
for (const entry of sectionFieldSpecs(section)) {
|
|
124
|
+
const field = typeof entry === "string" ? entry : entry.field;
|
|
125
|
+
checkFieldExists(recordShape, field, () => `${prefix} section "${section.title ?? section.id ?? "(untitled)"}" references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
93
129
|
// relatedList toolbarActions' visible/params evaluate against the ENCLOSING
|
|
94
130
|
// projectionDetail's record, not the relatedList's own row — reuses the
|
|
95
131
|
// same per-action field-ref check as rowActions/toolbarActions elsewhere
|
|
@@ -128,6 +164,15 @@ function checkDashboardStatPanelFields(queryHandlers, featureName, screenId, pan
|
|
|
128
164
|
checkPanelField("deltaDirectionField", panel.deltaDirectionField);
|
|
129
165
|
checkPanelField("deltaToneField", panel.deltaToneField);
|
|
130
166
|
}
|
|
167
|
+
// visibleWhen reads a flat record like stat panels; a typo'd field would hide
|
|
168
|
+
// the panel forever without any message.
|
|
169
|
+
function checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel) {
|
|
170
|
+
// skip: panel is always visible, no field to check
|
|
171
|
+
if (panel.visibleWhen === undefined)
|
|
172
|
+
return;
|
|
173
|
+
const { query, field } = panel.visibleWhen;
|
|
174
|
+
checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}" visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema.`);
|
|
175
|
+
}
|
|
131
176
|
function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen) {
|
|
132
177
|
for (const panel of screen.panels) {
|
|
133
178
|
if (panel.kind === "stat") {
|
|
@@ -138,8 +183,11 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
|
|
|
138
183
|
checkDashboardStatPanelFields(queryHandlers, featureName, screenId, stat);
|
|
139
184
|
}
|
|
140
185
|
}
|
|
186
|
+
else if (panel.kind === "screen") {
|
|
187
|
+
checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel);
|
|
188
|
+
}
|
|
141
189
|
else if (panel.kind === "list") {
|
|
142
|
-
const rowShape =
|
|
190
|
+
const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
|
|
143
191
|
for (const column of panel.columns) {
|
|
144
192
|
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" column "${field}" is not present in query "${panel.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
|
|
145
193
|
}
|
|
@@ -154,7 +202,7 @@ function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen)
|
|
|
154
202
|
}
|
|
155
203
|
else if (screen.type === "entityList") {
|
|
156
204
|
if (screen.expandableRow !== undefined) {
|
|
157
|
-
const rowShape =
|
|
205
|
+
const rowShape = resolveListRowShape(queryHandlers, screen.expandableRow.query, `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow.title}"`);
|
|
158
206
|
for (const column of screen.expandableRow.columns) {
|
|
159
207
|
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}" column "${field}" is not present in query "${screen.expandableRow?.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
|
|
160
208
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
2
|
-
import type { FeatureDefinition } from "../types/index.js";
|
|
2
|
+
import type { ConfigKeyDefinition, FeatureDefinition } from "../types/index.js";
|
|
3
3
|
import type { DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, ToolbarAction } from "../types/screen.js";
|
|
4
4
|
export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
|
|
5
5
|
export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
|
|
@@ -13,7 +13,7 @@ export declare function validateScreenShortIdCollisions(screensByShortId: Readon
|
|
|
13
13
|
readonly screen: ScreenDefinition;
|
|
14
14
|
}>>): void;
|
|
15
15
|
export declare function resolveScreenTargetQn(featureName: string, target: string): string;
|
|
16
|
-
export declare function validateScreens(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, allWriteHandlerQns: ReadonlySet<string>, allScreenQns: ReadonlySet<string>,
|
|
16
|
+
export declare function validateScreens(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, allWriteHandlerQns: ReadonlySet<string>, allScreenQns: ReadonlySet<string>, configKeyDefsByQn: ReadonlyMap<string, ConfigKeyDefinition>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
|
|
17
17
|
readonly featureName: string;
|
|
18
18
|
readonly screen: ScreenDefinition;
|
|
19
19
|
}>>, detailForScreens: ReadonlyMap<string, {
|