@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.
Files changed (178) hide show
  1. package/dist/api/anonymous-live-entities.d.ts +2 -0
  2. package/dist/api/anonymous-live-entities.js +17 -0
  3. package/dist/api/auth-routes.js +19 -4
  4. package/dist/api/client-ip.js +3 -6
  5. package/dist/api/extra-route.js +3 -2
  6. package/dist/api/index.d.ts +2 -1
  7. package/dist/api/index.js +1 -0
  8. package/dist/api/pii-leak-guard.js +7 -4
  9. package/dist/api/redis-sse-broker.d.ts +2 -0
  10. package/dist/api/redis-sse-broker.js +6 -7
  11. package/dist/api/routes.js +16 -17
  12. package/dist/api/server.js +20 -10
  13. package/dist/api/sse-broker.d.ts +3 -0
  14. package/dist/api/sse-broker.js +14 -0
  15. package/dist/api/sse-route.d.ts +5 -2
  16. package/dist/api/sse-route.js +71 -11
  17. package/dist/bun-db/connection.js +0 -2
  18. package/dist/bun-db/index.d.ts +1 -1
  19. package/dist/bun-db/index.js +1 -1
  20. package/dist/bun-db/query.d.ts +1 -0
  21. package/dist/bun-db/query.js +15 -4
  22. package/dist/crypto/kek-source.js +40 -36
  23. package/dist/crypto/kms-adapter.js +2 -2
  24. package/dist/db/apply-entity-event.js +8 -2
  25. package/dist/db/blind-index-cleanup.js +32 -11
  26. package/dist/db/entity-table-meta.js +5 -3
  27. package/dist/db/event-store-executor-context.js +13 -19
  28. package/dist/db/event-store-executor-read.js +45 -38
  29. package/dist/db/event-store-executor-write.js +15 -17
  30. package/dist/db/migrate-generator.js +27 -28
  31. package/dist/db/pg-error.js +9 -5
  32. package/dist/db/queries/backfill-pii.js +15 -5
  33. package/dist/db/queries/shadow-swap.js +5 -4
  34. package/dist/db/table-builder.d.ts +1 -0
  35. package/dist/db/table-builder.js +7 -15
  36. package/dist/db/tenant-db.js +18 -2
  37. package/dist/derivatives/variant-key.js +3 -1
  38. package/dist/engine/boot-validator/access-declarations.js +12 -3
  39. package/dist/engine/boot-validator/entity-handler.js +13 -1
  40. package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
  41. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  42. package/dist/engine/boot-validator/index.js +6 -6
  43. package/dist/engine/boot-validator/nav.d.ts +2 -1
  44. package/dist/engine/boot-validator/nav.js +10 -20
  45. package/dist/engine/boot-validator/ownership.d.ts +1 -1
  46. package/dist/engine/boot-validator/ownership.js +2 -1
  47. package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
  48. package/dist/engine/boot-validator/pii-retention.js +5 -2
  49. package/dist/engine/boot-validator/projection-list-screens.js +8 -0
  50. package/dist/engine/boot-validator/query-output-columns.js +53 -5
  51. package/dist/engine/boot-validator/screens.d.ts +2 -2
  52. package/dist/engine/boot-validator/screens.js +125 -46
  53. package/dist/engine/boot-validator/transfer-graph.js +9 -6
  54. package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
  55. package/dist/engine/boot-validator/zod-shape.js +17 -4
  56. package/dist/engine/build-app-schema.js +65 -20
  57. package/dist/engine/build-config-feature-schema.js +8 -1
  58. package/dist/engine/changeset-changes.js +24 -10
  59. package/dist/engine/create-app.js +10 -1
  60. package/dist/engine/extension-names.d.ts +4 -0
  61. package/dist/engine/extension-names.js +4 -0
  62. package/dist/engine/feature-ast/extractors/events.js +11 -8
  63. package/dist/engine/feature-ast/extractors/handlers.js +39 -16
  64. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
  65. package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
  66. package/dist/engine/feature-ast/parse.d.ts +1 -0
  67. package/dist/engine/feature-ast/parse.js +18 -3
  68. package/dist/engine/feature-ast/patch.js +80 -61
  69. package/dist/engine/feature-ast/patcher.d.ts +1 -0
  70. package/dist/engine/feature-ast/patcher.js +2 -1
  71. package/dist/engine/feature-ast/render.js +1 -1
  72. package/dist/engine/feature-entity-handlers.d.ts +1 -0
  73. package/dist/engine/feature-entity-handlers.js +2 -0
  74. package/dist/engine/index.d.ts +4 -3
  75. package/dist/engine/index.js +3 -2
  76. package/dist/engine/ownership.d.ts +1 -0
  77. package/dist/engine/ownership.js +15 -2
  78. package/dist/engine/pattern-library/mixed-schemas.js +15 -29
  79. package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
  80. package/dist/engine/pattern-library/shared-fields.js +17 -0
  81. package/dist/engine/registry-facade.js +3 -0
  82. package/dist/engine/registry-validate.d.ts +1 -0
  83. package/dist/engine/registry-validate.js +14 -0
  84. package/dist/engine/registry.js +2 -1
  85. package/dist/engine/role-assignment.d.ts +7 -1
  86. package/dist/engine/role-assignment.js +27 -7
  87. package/dist/engine/schema-builder.d.ts +1 -0
  88. package/dist/engine/schema-builder.js +9 -2
  89. package/dist/engine/screen-helpers.js +15 -4
  90. package/dist/engine/steps/_event-match.js +34 -7
  91. package/dist/engine/steps/_read-source.js +8 -0
  92. package/dist/engine/system-user.d.ts +1 -0
  93. package/dist/engine/system-user.js +3 -0
  94. package/dist/engine/where-rule-lint.js +22 -4
  95. package/dist/env/dry-run.js +10 -3
  96. package/dist/env/index.js +21 -1
  97. package/dist/errors/reasons.d.ts +0 -1
  98. package/dist/errors/reasons.js +2 -5
  99. package/dist/event-store/event-store.js +10 -2
  100. package/dist/event-store/provenance-append.js +4 -1
  101. package/dist/files/file-routes.d.ts +0 -1
  102. package/dist/files/file-routes.js +22 -7
  103. package/dist/files/local-provider.js +13 -10
  104. package/dist/files/provider-resolver.js +20 -1
  105. package/dist/files/types.js +16 -1
  106. package/dist/i18n/request-locale.d.ts +3 -2
  107. package/dist/i18n/request-locale.js +16 -6
  108. package/dist/i18n/required-surface-keys.js +23 -35
  109. package/dist/i18n/settings-hub-keys.js +3 -0
  110. package/dist/jobs/index.d.ts +2 -2
  111. package/dist/jobs/index.js +1 -1
  112. package/dist/jobs/job-runner.d.ts +2 -0
  113. package/dist/jobs/job-runner.js +172 -59
  114. package/dist/migrations/index.d.ts +1 -1
  115. package/dist/migrations/index.js +1 -1
  116. package/dist/migrations/pending-rebuilds.d.ts +4 -0
  117. package/dist/migrations/pending-rebuilds.js +23 -1
  118. package/dist/observability/escape-hatch-report.d.ts +1 -0
  119. package/dist/observability/escape-hatch-report.js +3 -0
  120. package/dist/observability/index.d.ts +2 -1
  121. package/dist/observability/index.js +2 -1
  122. package/dist/observability/standard-metrics.d.ts +4 -0
  123. package/dist/observability/standard-metrics.js +16 -0
  124. package/dist/pipeline/dispatch-query.d.ts +1 -1
  125. package/dist/pipeline/dispatch-query.js +2 -2
  126. package/dist/pipeline/dispatch-shared.d.ts +2 -3
  127. package/dist/pipeline/dispatch-shared.js +14 -9
  128. package/dist/pipeline/dispatch-stream.js +2 -5
  129. package/dist/pipeline/dispatch-write.js +22 -5
  130. package/dist/pipeline/dispatcher.js +1 -2
  131. package/dist/pipeline/event-dispatcher-admin.js +12 -2
  132. package/dist/pipeline/event-dispatcher-delivery.js +5 -0
  133. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  134. package/dist/pipeline/event-dispatcher.js +30 -12
  135. package/dist/pipeline/index.d.ts +1 -1
  136. package/dist/pipeline/index.js +1 -1
  137. package/dist/pipeline/member-read-only-transaction.js +1 -14
  138. package/dist/pipeline/msp-rebuild.d.ts +1 -0
  139. package/dist/pipeline/msp-rebuild.js +1 -0
  140. package/dist/pipeline/system-hooks.d.ts +1 -1
  141. package/dist/pipeline/system-hooks.js +19 -20
  142. package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
  143. package/dist/pipeline/tenant-timezone-cache.js +20 -6
  144. package/dist/pipeline/write-origin.js +13 -6
  145. package/dist/redis/pubsub-signal.d.ts +2 -0
  146. package/dist/redis/pubsub-signal.js +2 -2
  147. package/dist/schema-cli.js +4 -14
  148. package/dist/search/meilisearch-adapter.js +17 -6
  149. package/dist/search/purge-subject.js +8 -3
  150. package/dist/stack/drain-event-consumers.js +3 -0
  151. package/dist/stack/drain-jobs.d.ts +3 -1
  152. package/dist/stack/drain-jobs.js +55 -13
  153. package/dist/stack/table-helpers.js +10 -8
  154. package/dist/testing/closed-connection-error.js +2 -3
  155. package/dist/testing/wait-for.js +1 -0
  156. package/dist/ui-types/index.d.ts +1 -1
  157. package/dist/ui-types/index.js +1 -1
  158. package/dist/upgrade-cli.d.ts +1 -0
  159. package/dist/upgrade-cli.js +36 -15
  160. package/package.json +4 -4
  161. package/src/changes.json +264 -0
  162. package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
  163. package/src/scripts/codemod/migrate-db-raw.ts +16 -8
  164. package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
  165. package/src/scripts/codemod/pii-personal-migration.ts +111 -44
  166. package/src/scripts/codemod/unprocessable-error-details-reason.ts +136 -42
  167. package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
  168. package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
  169. package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
  170. package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
  171. package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
  172. package/dist/scripts/codemod/migrate-db-raw.js +0 -345
  173. package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
  174. package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
  175. package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
  176. package/dist/scripts/codemod/pii-personal-migration.js +0 -615
  177. package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
  178. 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
- const subject = resolveEventSubject(field, spec, payload, {
253
- tenantId: row.tenant_id, // @cast-boundary db-read — tenant_id column is the branded TenantId
254
- aggregateType: row.aggregate_type,
255
- aggregateId: row.aggregate_id,
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 — see kumiko-platform docs/plans/rls-evaluation.md. Rebuild aborted; live " +
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 blind-index key before this apply/rebuild ` +
231
- `runs. See fw#3091. Rebuild aborted; live table untouched.`);
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_";
@@ -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, SQL_EXPR_BRAND, serial, sql, text, uniqueIndex, uuid, } from "./dialect.js";
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
- const softDeleteWhere = {
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, `"is_deleted" = false`]
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);
@@ -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/escape-hatch-report.js";
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
- const DERIVATIVE_SUFFIX_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}-[0-9a-f]{16}$/i;
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 (Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE))
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 = collectZodObjectKeys(handler.schema);
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 = collectZodObjectKeys(handler.schema);
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("all"))
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 allConfigKeyQns = new Set();
63
+ const configKeyDefsByQn = new Map();
64
64
  for (const f of features) {
65
- for (const key of Object.keys(f.configKeys)) {
65
+ for (const [key, def] of Object.entries(f.configKeys)) {
66
66
  allConfigKeys.add(`${f.name}.${key}`);
67
- allConfigKeyQns.add(qualifyEntityName(f.name, QnTypes.config, key));
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, allConfigKeyQns, screensByShortId, detailForScreens);
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>): void;
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
- // `NavDefinition.screen` is authored already-qualified ("<feature>:screen:
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(qualifyNavScreenQn(navDef.featureName, navDef.screen));
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
- const screenQn = qualifyNavScreenQn(navDef.featureName, navDef.screen);
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 add it to the allowlist.`);
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 { OwnershipMap, WhereRule } from "../ownership.js";
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: entity.table ?? entityName, columns };
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 entityHasAnonymizableSubjectField = Object.values(fieldsByName).some((f) => hasAnonymizableSubjectField(f));
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 (entityHasAnonymizableSubjectField && !hasAnonymize) {
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 = getZodRowShape(queryHandlers.get(screen.query)?.outputSchema);
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 = getZodRowShape(queryHandlers.get(section.query)?.outputSchema);
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 = getZodRowShape(queryHandlers.get(panel.query)?.outputSchema);
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 = getZodRowShape(queryHandlers.get(screen.expandableRow.query)?.outputSchema);
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>, allConfigKeyQns: ReadonlySet<string>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
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, {