@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -1,11 +1,11 @@
1
1
  // Pure renderer: EntityTableMeta → SQL DDL statements.
2
- // Wird vom Migrate-Generator (Phase 2 — CLI-Tool `kumiko migrate generate`)
3
- // genutzt um initial-SQL-Files zu schreiben. Output ist Start-Form für
4
- // User-Review — App-Author darf das SQL danach hand-editieren (extra-Index,
5
- // partial-Index, BRIN, custom-clauses) bevor committed wird.
2
+ // The migrate generator (`kumiko-schema generate`) uses it to write the
3
+ // initial SQL files. The output is a starting point for review: the app
4
+ // author may hand-edit the SQL (extra index, partial index, BRIN, custom
5
+ // clauses) before committing it.
6
6
  //
7
- // NO-MAGIC-ON-DATA: dieser Renderer wird NIE zur App-Runtime aufgerufen.
8
- // Nur Build-Step. Runner liest checked-in SQL, nicht Renderer-Output.
7
+ // NO-MAGIC-ON-DATA: this renderer never runs at app runtime, only as a build
8
+ // step. The runner reads the checked-in SQL, not renderer output.
9
9
  import { pgTypeToSqlType } from "./dialect.js";
10
10
  function quoteIdent(name) {
11
11
  return `"${name.replace(/"/g, '""')}"`;
@@ -253,17 +253,17 @@ const ROW_META_WITH_SOFT_DELETE = new Set(Object.keys(buildBaseColumns(true)));
253
253
  export function rowMetaFieldNames(softDelete) {
254
254
  return softDelete ? ROW_META_WITH_SOFT_DELETE : ROW_META_BASE;
255
255
  }
256
- function stampGlobalTenancyMeta(table, entity) {
257
- // skip: non-global entities keep pgTable's default tenancy meta
258
- if (entity.tenancy !== "global")
259
- return;
256
+ // pgTable's own meta stamp sees neither entity.tenancy nor that this table is an executor-managed projection;
257
+ // patch both so db.global()'s runtime guards match the type brands.
258
+ function stampEntityTableMeta(table, entity) {
260
259
  const meta = table[KUMIKO_META_SYMBOL];
261
260
  // skip: no meta stamp present — nothing to patch
262
261
  if (!meta)
263
262
  return;
264
263
  table[KUMIKO_META_SYMBOL] = {
265
264
  ...meta,
266
- tenancy: "global",
265
+ source: "managed",
266
+ ...(entity.tenancy === "global" && { tenancy: "global" }),
267
267
  };
268
268
  }
269
269
  /**
@@ -416,7 +416,6 @@ export function buildEntityTable(entityName, entity, options) {
416
416
  }
417
417
  return indexes;
418
418
  });
419
- // pgTable's own meta stamp doesn't see entity.tenancy — patch it so db.global()'s runtime guard matches the type brand.
420
- stampGlobalTenancyMeta(built, entity);
419
+ stampEntityTableMeta(built, entity);
421
420
  return built;
422
421
  }
@@ -1,5 +1,5 @@
1
1
  import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
2
- import type { EscapeHatchDeclaration, EscapeHatchReporter } from "@cosmicdrift/kumiko-types/handlers";
2
+ import type { EscapeHatchDeclaration, EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
3
3
  import { type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
4
4
  import { type TenantDb, type TenantDbMode, type UncheckedSystemDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
5
5
  import { asRawClient } from "../db/query.js";
@@ -28,7 +28,10 @@ export type TenantDbGrants = {
28
28
  };
29
29
  export type PersonalDataGate = (tableName: string, keys: readonly string[], entity?: EntityDefinition) => void;
30
30
  export declare function withUnsafeRawGrant(tenantDb: TenantDb, grant: EscapeHatchDeclaration | undefined): TenantDb;
31
- export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string): TenantDb;
31
+ export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string, options?: {
32
+ readonly deferReport?: boolean;
33
+ }): TenantDb;
34
+ export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
32
35
  export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
33
36
  export declare function createTenantDb(db: DbRunner, tenantId: TenantId, mode?: TenantDbMode, tracer?: Tracer, meter?: Meter, signal?: AbortSignal, grants?: TenantDbGrants): TenantDb;
34
37
  export { asRawClient };
@@ -27,7 +27,17 @@ function boundRunner(runner, gate, projectionRegistry) {
27
27
  const value = Reflect.get(target, prop, target);
28
28
  if (typeof value !== "function")
29
29
  return value;
30
- if (prop === "begin" || prop === "savepoint") {
30
+ if (prop === "reserve") {
31
+ // Bun.SQL and postgres.js hand back a raw connection; re-wrap it so the gate survives.
32
+ return async (...args) => {
33
+ const reserved = await Reflect.apply(value, target, args);
34
+ return boundRunner(reserved, gate, projectionRegistry);
35
+ };
36
+ }
37
+ if (prop === "begin" ||
38
+ prop === "transaction" ||
39
+ prop === "beginDistributed" ||
40
+ prop === "savepoint") {
31
41
  // createTenantDb(tx, ...) inside the callback must inherit the gate too.
32
42
  return (...args) => {
33
43
  const callback = args[args.length - 1];
@@ -247,8 +257,11 @@ export function withUnsafeRawGrant(tenantDb, grant) {
247
257
  return rebind ? rebind(grant) : tenantDb;
248
258
  }
249
259
  const crossTenantRebinders = new WeakMap();
260
+ const crossTenantUseReporters = new WeakMap();
250
261
  // Framework-private (not re-exported from db/index.ts): lifting the tenant filter needs a registered declaration.
251
- export function acknowledgeConventionCrossTenant(tenantDb, reason) {
262
+ // `deferReport` leaves the audit entry to reportConventionCrossTenantUse, so a caller that learns
263
+ // which foreign row it touches can attach that row as the entry's target.
264
+ export function acknowledgeConventionCrossTenant(tenantDb, reason, options) {
252
265
  if (reason.trim().length === 0) {
253
266
  throw new Error("acknowledgeConventionCrossTenant requires a non-empty reason");
254
267
  }
@@ -259,7 +272,19 @@ export function acknowledgeConventionCrossTenant(tenantDb, reason) {
259
272
  "no cross-tenant rebinder bound.",
260
273
  });
261
274
  }
262
- return rebind(reason);
275
+ return rebind(reason, options?.deferReport === true);
276
+ }
277
+ // The report that acknowledgeConventionCrossTenant(..., { deferReport: true }) held back; the
278
+ // target keeps the 60s audit dedup from collapsing touches of different rows into one entry.
279
+ export function reportConventionCrossTenantUse(acknowledged, target) {
280
+ const reporter = crossTenantUseReporters.get(acknowledged);
281
+ if (!reporter) {
282
+ throw new InternalError({
283
+ message: "reportConventionCrossTenantUse received a TenantDb not returned by " +
284
+ "acknowledgeConventionCrossTenant.",
285
+ });
286
+ }
287
+ reporter(target);
263
288
  }
264
289
  const ownTransactionRebinders = new WeakMap();
265
290
  export async function runInOwnTransaction(tenantDb, fn) {
@@ -363,6 +388,14 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
363
388
  "writes through db.global().",
364
389
  });
365
390
  }
391
+ // The ExecutorOnly brand is type-only; a type-erased table must still not reach the read model without an event.
392
+ function executorManagedWriteDenied(table) {
393
+ if (asEntityTableMeta(table)?.source === "unmanaged")
394
+ return undefined;
395
+ return new AccessDeniedError({
396
+ message: `db.global(${tableNameOf(table)}): writes on executor-managed entity tables must go through the entity executor`,
397
+ });
398
+ }
366
399
  function globalWriteReason() {
367
400
  return grants?.globalWrites?.reason ?? "";
368
401
  }
@@ -411,6 +444,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
411
444
  },
412
445
  insertOne(values) {
413
446
  const denied = missingEscapeHatch(table) ??
447
+ executorManagedWriteDenied(table) ??
414
448
  foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
415
449
  personalDataDenied(table, Object.keys(values));
416
450
  if (denied)
@@ -420,6 +454,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
420
454
  },
421
455
  updateMany(set, where) {
422
456
  const denied = missingEscapeHatch(table) ??
457
+ executorManagedWriteDenied(table) ??
423
458
  foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
424
459
  personalDataDenied(table, Object.keys(set));
425
460
  if (denied)
@@ -431,7 +466,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
431
466
  return withDbSpan("update", table, async () => bunUpdateMany(db, table, set, where));
432
467
  },
433
468
  deleteMany(where) {
434
- const denied = missingEscapeHatch(table);
469
+ const denied = missingEscapeHatch(table) ?? executorManagedWriteDenied(table);
435
470
  if (denied)
436
471
  return Promise.reject(denied);
437
472
  if (!where || Object.keys(where).length === 0) {
@@ -522,10 +557,15 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
522
557
  unsafeRawRebinders.set(tenantDb, (grant) => createTenantDb(db, tenantId, mode, tracer, meter, signal, {
523
558
  ...rebindGrants,
524
559
  unsafeRaw: grant,
560
+ // db.global() writes are write-handler-only: a rebind (a hook's own grant) must not inherit them.
561
+ globalWrites: undefined,
525
562
  }));
526
- crossTenantRebinders.set(tenantDb, (reason) => {
527
- report("acknowledge-cross-tenant", reason);
528
- return createTenantDb(db, tenantId, "system", tracer, meter, signal, rebindGrants);
563
+ crossTenantRebinders.set(tenantDb, (reason, deferReport) => {
564
+ if (!deferReport)
565
+ report("acknowledge-cross-tenant", reason);
566
+ const acknowledged = createTenantDb(db, tenantId, "system", tracer, meter, signal, rebindGrants);
567
+ crossTenantUseReporters.set(acknowledged, (target) => report("acknowledge-cross-tenant", reason, target));
568
+ return acknowledged;
529
569
  });
530
570
  ownTransactionRebinders.set(tenantDb, async (fn) => {
531
571
  if (grants?.memberReadOnly) {
@@ -3,6 +3,7 @@ export declare function canonicalJson(value: unknown): string;
3
3
  export declare function specHash(spec: VariantSpec): string;
4
4
  export declare const VARIANT_NAME_PATTERN: RegExp;
5
5
  export declare function variantSuffix(name: string, spec: VariantSpec): string;
6
+ export declare function isDerivativeSuffix(suffix: string): boolean;
6
7
  export declare function derivativeListPrefix(originalKey: string): string;
7
8
  export declare function isDerivativeKeyOf(originalKey: string, candidateKey: string): boolean;
8
9
  export declare function parseDerivativeKey(candidateKey: string): {
@@ -37,6 +37,9 @@ export function variantSuffix(name, spec) {
37
37
  // The hash part stays case-sensitive (specHash emits lowercase hex only), so
38
38
  // the erasure sweep never matches a differently-cased sibling file.
39
39
  const DERIVATIVE_SUFFIX_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,31}-[0-9a-f]{16}$/;
40
+ export function isDerivativeSuffix(suffix) {
41
+ return DERIVATIVE_SUFFIX_PATTERN.test(suffix);
42
+ }
40
43
  // Mirrors deriveKey's own split so callers get the exact same base/ext this
41
44
  // key's derivatives were built from.
42
45
  function splitKey(key) {
@@ -1,4 +1,5 @@
1
- import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
1
+ import { escapeHatchGrantsProblem, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { codemodPlaceholderReasonProblem } from "../escape-hatch-reason.js";
2
3
  import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
3
4
  import { ANONYMOUS_ROLE } from "../system-user.js";
4
5
  import { collectZodObjectKeys } from "./zod-shape.js";
@@ -60,6 +61,10 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
60
61
  "explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
61
62
  "or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
62
63
  }
64
+ const placeholderProblem = codemodPlaceholderReasonProblem(escapeHatch.reason);
65
+ if (placeholderProblem !== undefined) {
66
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${placeholderProblem}`);
67
+ }
63
68
  }
64
69
  }
65
70
  function validatePersonalDataOnlyOnWrite(feature, kind, handlerName, access) {
@@ -143,6 +148,20 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
143
148
  'request shares one caller identity, so owner-binding via from("user:id", ...) does not ' +
144
149
  "vouch for it; protection comes from the handler's required rateLimit (per ip).");
145
150
  }
151
+ // The rateLimit is the only protection of a public-intake handler, so the
152
+ // generic anonymous opt-out (rateLimit: { disabled: true }) must not apply.
153
+ function validatePublicIntakeRateLimit(feature, handlerName, handler) {
154
+ // skip: not a public-intake handler, or its rateLimit is active
155
+ if (declaredPersonalData(handler.access) !== "public-intake")
156
+ return;
157
+ // skip: rateLimit is active
158
+ if (!isRateLimitDisabled(handler.rateLimit))
159
+ return;
160
+ throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
161
+ 'access.personalData: "public-intake" with rateLimit: { disabled: true } — the rateLimit ' +
162
+ 'is the only protection for anonymous personal-data writes. Declare rateLimit: { per: "ip" ' +
163
+ 'or "ip+handler", ... } instead.');
164
+ }
146
165
  export function validateAccessDeclarations(feature) {
147
166
  for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
148
167
  validateOpenToAllReason(feature, "write", handlerName, handler.access);
@@ -150,6 +169,7 @@ export function validateAccessDeclarations(feature) {
150
169
  validatePersonalDataValue(feature, handlerName, handler.access);
151
170
  validateOpenToAllPersonalData(feature, handlerName, handler);
152
171
  validateAnonymousPersonalData(feature, handlerName, handler);
172
+ validatePublicIntakeRateLimit(feature, handlerName, handler);
153
173
  }
154
174
  for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
155
175
  validateOpenToAllReason(feature, "query", handlerName, handler.access);
@@ -106,6 +106,9 @@ export function validateConfigKeyAllowPerRequest(feature) {
106
106
  export function validateConfigKeyOptionsQuery(feature) {
107
107
  for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
108
108
  const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
109
+ if ("conditionalOptions" in keyDef) {
110
+ throw new Error(`${prefix} declares conditionalOptions — conditionalOptions is only supported on entity select fields (enforced by the entity write path)`);
111
+ }
109
112
  if (keyDef.optionsQuery === undefined) {
110
113
  if (keyDef.optionsQueryPayload !== undefined) {
111
114
  throw new Error(`${prefix} has optionsQueryPayload without optionsQuery`);
@@ -20,6 +20,7 @@ export declare function validateReferenceFields(feature: FeatureDefinition, feat
20
20
  export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
21
21
  export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
22
22
  export declare function validateEntitySelectAvailabilityQuery(feature: FeatureDefinition, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
23
+ export declare function validateEntitySelectConditionalOptions(feature: FeatureDefinition): void;
23
24
  export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
24
25
  export declare function validateImageVariants(feature: FeatureDefinition): void;
25
26
  export declare function validateTransitions(feature: FeatureDefinition): void;
@@ -1,8 +1,10 @@
1
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
1
2
  import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
3
  import { ZodObject } from "zod";
3
4
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
4
5
  import { access as accessPresets } from "../config-helpers.js";
5
6
  import { parseRefTarget } from "../parse-ref-target.js";
7
+ import { isSelectOptionTone } from "./select-option-tone.js";
6
8
  export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
7
9
  // Field-Namen die typischerweise PII enthalten. Ohne `pii: true` /
8
10
  // `userOwned` / `tenantOwned` / `allowPlaintext`-Marker → Boot-Warning.
@@ -672,14 +674,25 @@ function validateCheckboxDisplayOptions(field, fieldName, entityName) {
672
674
  throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" sets maxRows, which is only valid with display: "checkboxes".`);
673
675
  }
674
676
  }
677
+ function assertSelectOptionTones(field, path, featureName) {
678
+ const tones = field.type === "select" ? Object.entries(field.optionTones ?? {}) : [];
679
+ for (const [optionValue, tone] of tones) {
680
+ if (!isSelectOptionTone(tone)) {
681
+ throw new Error(`[Feature ${featureName}] Entity select field "${path}" optionTones["${optionValue}"] is ` +
682
+ `"${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
683
+ }
684
+ }
685
+ }
675
686
  export function validateEntitySelectOptionsQuery(feature) {
676
687
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
677
688
  for (const [fieldName, field] of Object.entries(entity.fields)) {
678
689
  assertNoSelectOptionsQuery(field, `${entityName}.${fieldName}`, feature.name);
690
+ assertSelectOptionTones(field, `${entityName}.${fieldName}`, feature.name);
679
691
  if (field.type !== "embedded")
680
692
  continue;
681
693
  for (const [subName, subField] of Object.entries(field.schema ?? {})) {
682
694
  assertNoSelectOptionsQuery(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
695
+ assertSelectOptionTones(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
683
696
  }
684
697
  }
685
698
  }
@@ -709,6 +722,46 @@ export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
709
722
  }
710
723
  }
711
724
  }
725
+ export function validateEntitySelectConditionalOptions(feature) {
726
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
727
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
728
+ if (field.type !== "select" || field.conditionalOptions === undefined)
729
+ continue;
730
+ const where = `[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}"`;
731
+ if (field.optionsQuery !== undefined) {
732
+ throw new Error(`${where} combines conditionalOptions with optionsQuery — conditionalOptions filters the static options only.`);
733
+ }
734
+ const knownOptions = new Set(field.options);
735
+ const ruleOfOption = new Map();
736
+ field.conditionalOptions.forEach((rule, ruleIndex) => {
737
+ const ruleLabel = `conditionalOptions[${ruleIndex}]`;
738
+ if (rule.options.length === 0) {
739
+ throw new Error(`${where} has an empty ${ruleLabel}.options — drop the rule.`);
740
+ }
741
+ for (const option of rule.options) {
742
+ if (!knownOptions.has(option)) {
743
+ throw new Error(`${where} lists "${option}" in ${ruleLabel} which is not in options (${field.options.join(", ")}).`);
744
+ }
745
+ const otherRule = ruleOfOption.get(option);
746
+ if (otherRule !== undefined) {
747
+ throw new Error(`${where} lists "${option}" in conditionalOptions[${otherRule}] and ${ruleLabel} — an option may belong to one rule only.`);
748
+ }
749
+ ruleOfOption.set(option, ruleIndex);
750
+ if (option === field.default) {
751
+ throw new Error(`${where} has default "${option}" inside ${ruleLabel} — the default must be available unconditionally.`);
752
+ }
753
+ }
754
+ const conditionField = rule.when.field;
755
+ if (conditionField === fieldName) {
756
+ throw new Error(`${where} has ${ruleLabel}.when on its own field.`);
757
+ }
758
+ if (!(conditionField in entity.fields)) {
759
+ throw new Error(`${where} has ${ruleLabel}.when.field "${conditionField}" which is not a field of the entity.`);
760
+ }
761
+ });
762
+ }
763
+ }
764
+ }
712
765
  export function validateMultiSelectFields(feature) {
713
766
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
714
767
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -0,0 +1,2 @@
1
+ import type { FeatureDefinition } from "../types/index.js";
2
+ export declare function validateHandlerFieldReferences(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
@@ -0,0 +1,25 @@
1
+ import { handlerFieldReference } from "../handler-field-references.js";
2
+ import { parseRefTarget } from "../parse-ref-target.js";
3
+ import { getZodObjectShape } from "./zod-shape.js";
4
+ function validateHandlerGroup(feature, featureMap, handlers, handlerNoun) {
5
+ for (const [handlerName, def] of Object.entries(handlers)) {
6
+ const shape = getZodObjectShape(def.schema);
7
+ if (shape === undefined)
8
+ continue;
9
+ for (const fieldName of Object.keys(shape)) {
10
+ const reference = handlerFieldReference(def.schema, fieldName);
11
+ if (reference === undefined)
12
+ continue;
13
+ const target = parseRefTarget(reference, feature.name);
14
+ const targetEntities = featureMap.get(target.featureName)?.entities;
15
+ if (targetEntities?.[target.entityName] !== undefined)
16
+ continue;
17
+ throw new Error(`[Feature ${feature.name}] Input field "${fieldName}" of ${handlerNoun} "${handlerName}" has meta references: "${reference}", ` +
18
+ `which is not a registered entity. Use "<entity>" for this feature or "<feature>:<entity>" for another one.`);
19
+ }
20
+ }
21
+ }
22
+ export function validateHandlerFieldReferences(feature, featureMap) {
23
+ validateHandlerGroup(feature, featureMap, feature.writeHandlers ?? {}, "write handler");
24
+ validateHandlerGroup(feature, featureMap, feature.queryHandlers ?? {}, "query handler");
25
+ }
@@ -9,10 +9,11 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.
9
9
  import { validateFeatureBootChecks } from "./boot-check.js";
10
10
  import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
11
11
  import { collectDetailForScreens } from "./detail-screens.js";
12
- import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
12
+ import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectConditionalOptions, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
13
13
  import { validateEntityListScreens } from "./entity-list-screens.js";
14
14
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
15
15
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
16
+ import { validateHandlerFieldReferences } from "./handler-field-references.js";
16
17
  import { validateI18nSurfaceKeys } from "./i18n-keys.js";
17
18
  import { collectKnownRoles, collectNavQns, collectWriteHandlerQns, validateNavCycles, validateNavs, warnOnNavAccessInversion, warnOnUnreachableNavScreens, } from "./nav.js";
18
19
  import { collectClaimKeys, validateOwnershipRules } from "./ownership.js";
@@ -26,6 +27,7 @@ import { collectScreenQns, collectScreensByShortId, validateScreenShortIdCollisi
26
27
  import { warnOnMissingSecurityBaseline } from "./security-baseline.js";
27
28
  import { validateExtensionSelectorPanels } from "./selector-owner-panels.js";
28
29
  import { validateTransferGraph } from "./transfer-graph.js";
30
+ import { validateSubmitPrefilledHiddenFields } from "./url-prefill-fields.js";
29
31
  import { collectWorkspaceQns, resolveNavAllowlist, validateDefaultWorkspaceUniqueness, validateWorkspaces, } from "./workspaces.js";
30
32
  export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
31
33
  // Re-export: wird von run-dev-app.ts benötigt um Write-Handler-QNs
@@ -170,10 +172,12 @@ export function validateBoot(rawFeatures, options) {
170
172
  validateApiExposureMatching(feature, allExposedApis, featureMap);
171
173
  validateEntitySelectOptionsQuery(feature);
172
174
  validateEntitySelectAvailabilityQuery(feature, queryHandlerQns);
175
+ validateEntitySelectConditionalOptions(feature);
173
176
  validateEmbeddedFields(feature, featureMap, queryHandlerQns);
174
177
  validateMultiSelectFields(feature);
175
178
  validateImageVariants(feature);
176
179
  validateReferenceFields(feature, featureMap, queryHandlerQns);
180
+ validateHandlerFieldReferences(feature, featureMap);
177
181
  validateTransitions(feature);
178
182
  validateExtensionUsages(feature, extensionProviders);
179
183
  validateExtendSchemaCollisions(feature);
@@ -212,6 +216,7 @@ export function validateBoot(rawFeatures, options) {
212
216
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
213
217
  validateI18nSurfaceKeys(features);
214
218
  validateEntityListScreens(features);
219
+ validateSubmitPrefilledHiddenFields(features);
215
220
  // Must run before validateProjectionListScreens: an unresolvable query
216
221
  // there is silently treated as "capability absent" and surfaces as a
217
222
  // misleading "no search parameter in its Zod schema" error instead of
@@ -59,7 +59,9 @@ export function validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns)
59
59
  // `screen`/`target`. Without this check an action with neither is a
60
60
  // Hover-Icon that does nothing; one with both is ambiguous about which
61
61
  // click-mode wins.
62
- function validateTreeAction(featureName, navId, actionPath, action, allScreenQns) {
62
+ function validateTreeAction(featureName, navId, actionPath, action,
63
+ // Loose on purpose: this is the runtime guard for untyped input.
64
+ allScreenQns) {
63
65
  const { screen, target } = action;
64
66
  const hasScreen = screen !== undefined;
65
67
  const hasTarget = target !== undefined;
@@ -5,7 +5,7 @@ export type WhereRuleProbe = {
5
5
  readonly tableName: string;
6
6
  readonly columns: ReadonlySet<string>;
7
7
  };
8
- export type AccessPath = "read" | "write";
8
+ export type AccessPath = "read" | "fieldRead" | "write";
9
9
  export declare function collectClaimKeys(features: readonly FeatureDefinition[]): Map<string, ClaimKeyDefinition>;
10
10
  export declare function validateOwnershipBoot(features: readonly FeatureDefinition[]): void;
11
11
  export declare function validateOwnershipRules(feature: FeatureDefinition, allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined, knownRoles: ReadonlySet<string> | undefined): void;
@@ -32,5 +32,6 @@ export declare function checkOwnershipMap(args: {
32
32
  readonly path: AccessPath;
33
33
  }): void;
34
34
  export declare function probeWhereRule(rule: WhereRule, roleName: string, probe: WhereRuleProbe | undefined, scope: string, featureName: string): void;
35
+ export declare function buildWhereOnFieldReadMessage(roleName: string, scope: string, featureName: string): string;
35
36
  export declare function buildWhereOnWritePathMessage(roleName: string, scope: string, featureName: string): string;
36
37
  export declare function buildUnknownRoleMessage(roleName: string, knownRoles: ReadonlySet<string>, scope: string, featureName: string): string;
@@ -14,6 +14,9 @@ import { assertQualifiedWhereFragment, tableColumnSqlNames } from "../where-rule
14
14
  // lint (see ../where-rule-lint.ts) so a fail-open unqualified-column bug
15
15
  // fails the boot instead of waiting for a request from the right role.
16
16
  //
17
+ // Field-level `access.read` rejects where-rules too: filterFieldsForRead
18
+ // evaluates it in memory and userCanReadFieldRow fails closed on a where-rule.
19
+ //
17
20
  // fw#2626: `access.write` maps reject `{ kind: "where" }` outright. A
18
21
  // where-rule is raw SQL and the write path never reaches SQL — it evaluates
19
22
  // rules in memory against the concrete row (userCanCreateFieldRow /
@@ -114,8 +117,11 @@ export function validateOwnershipRules(feature, allClaimKeys, knownRoles) {
114
117
  scope: `${entityName}.${fieldName}.access.read`,
115
118
  featureName: feature.name,
116
119
  probe,
117
- path: "read",
120
+ path: "fieldRead",
118
121
  });
122
+ if (field.type === "embedded") {
123
+ rejectWhereInEmbeddedRead(field.schema, `${entityName}.${fieldName}`, feature.name);
124
+ }
119
125
  checkFieldAccess({
120
126
  access: field.access?.write,
121
127
  columnNames,
@@ -193,6 +199,9 @@ export function checkOwnershipMap(args) {
193
199
  if (args.path === "write") {
194
200
  throw new Error(buildWhereOnWritePathMessage(roleName, args.scope, args.featureName));
195
201
  }
202
+ if (args.path === "fieldRead") {
203
+ throw new Error(buildWhereOnFieldReadMessage(roleName, args.scope, args.featureName));
204
+ }
196
205
  probeWhereRule(rule, roleName, args.probe, args.scope, args.featureName);
197
206
  continue; // escape hatch — feature author owns the SQL
198
207
  }
@@ -249,6 +258,28 @@ export function probeWhereRule(rule, roleName, probe, scope, featureName) {
249
258
  return;
250
259
  assertQualifiedWhereFragment(fragment.sqlText, probe.columns, `${scope} (role "${roleName}", feature: "${featureName}")`);
251
260
  }
261
+ // Embedded sub-fields are filtered in memory too (filterFieldsForRead), so a
262
+ // where-rule there is just as unevaluable as on a top-level field.
263
+ function rejectWhereInEmbeddedRead(schema, parentScope, featureName) {
264
+ for (const [subName, sub] of Object.entries(schema)) {
265
+ const map = sub.access?.read;
266
+ if (!map || Array.isArray(map))
267
+ continue;
268
+ for (const [roleName, rule] of Object.entries(map)) {
269
+ if (rule !== "all" && rule.kind === "where") {
270
+ throw new Error(buildWhereOnFieldReadMessage(roleName, `${parentScope}.${subName}.access.read`, featureName));
271
+ }
272
+ }
273
+ }
274
+ }
275
+ export function buildWhereOnFieldReadMessage(roleName, scope, featureName) {
276
+ return (`[Kumiko Ownership] ${scope} uses a \`{ kind: "where" }\` rule for role ` +
277
+ `"${roleName}" (feature: "${featureName}"). Field-level read access is decided ` +
278
+ `in memory against the concrete row (userCanReadFieldRow), which cannot run ` +
279
+ `raw SQL — the rule could only ever hide the field from this role. Use a ` +
280
+ `from()-rule (e.g. from("user:id", "ownerId")) on the field, or move the ` +
281
+ `where-rule to entity access.read.`);
282
+ }
252
283
  export function buildWhereOnWritePathMessage(roleName, scope, featureName) {
253
284
  return (`[Kumiko Ownership] ${scope} uses a \`{ kind: "where" }\` rule for role ` +
254
285
  `"${roleName}" (feature: "${featureName}"). where-rules are raw SQL and only ` +
@@ -1,4 +1,6 @@
1
+ import { createFallbackLogger } from "../../logging/utils.js";
1
2
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
3
+ const log = createFallbackLogger("kumiko:boot");
2
4
  // Framework-managed Timestamp-Spalten — dürfen als retention.reference
3
5
  // genutzt werden auch wenn nicht in entity.fields deklariert.
4
6
  const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
@@ -238,6 +240,11 @@ export function validatePiiAndRetention(feature) {
238
240
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
239
241
  console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`);
240
242
  }
243
+ else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
244
+ // The factories refuse a missing stance, but a raw literal such as
245
+ // { type: "text" } still compiles and reaches the entity unchecked.
246
+ log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } via createTextField / createLongTextField; "false" additionally needs { reason: "..." } stating why the value is not personal data.`);
247
+ }
241
248
  }
242
249
  }
243
250
  // --- Entity-level retention ---
@@ -266,7 +273,7 @@ export function validatePiiAndRetention(feature) {
266
273
  });
267
274
  if (entityHasAnonymizableField && !hasAnonymize) {
268
275
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
269
- 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.`);
276
+ console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. Once keepFor expires, the data-retention cron anonymizes nothing and the row keeps its PII. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
270
277
  }
271
278
  }
272
279
  }
@@ -10,13 +10,13 @@ function checkFieldExists(shape, field, buildMessage) {
10
10
  return;
11
11
  throw new Error(buildMessage());
12
12
  }
13
- // Mirrors entity-list-screens.ts's "unlabeled unknown column" convention: a
14
- // column with its own `label` is a virtual/computed cell drawn by a
15
- // renderer, not a row field — exempt from the shape check.
13
+ // Unlike entity-list-screens.ts, `label` does not exempt a column here: query
14
+ // screens label nearly every column, so that exemption would leave the check
15
+ // validating nothing. Only an explicit `virtual: true` skips the shape check.
16
16
  function checkColumnField(rowShape, column, buildMessage) {
17
17
  const normalized = normalizeListColumn(column);
18
- // skip: labeled column is a virtual/computed cell drawn by a renderer, not a row field
19
- if (normalized.label !== undefined)
18
+ // skip: virtual column is a computed cell drawn by a renderer, not a row field
19
+ if (normalized.virtual === true)
20
20
  return;
21
21
  checkFieldExists(rowShape, normalized.field, () => buildMessage(normalized.field));
22
22
  }
@@ -55,7 +55,7 @@ function resolveListRowShape(queryHandlers, queryQn, usage) {
55
55
  function checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen) {
56
56
  const rowShape = resolveListRowShape(queryHandlers, screen.query, `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
57
57
  for (const column of screen.columns) {
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.`);
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 set virtual: true on it to mark it a virtual/computed column.`);
59
59
  }
60
60
  }
61
61
  function checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, screenType, layout) {
@@ -64,7 +64,7 @@ function checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, scre
64
64
  continue;
65
65
  const rowShape = resolveListRowShape(queryHandlers, section.query, `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`);
66
66
  for (const column of section.columns) {
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.`);
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 set virtual: true on it to mark it a virtual/computed column.`);
68
68
  }
69
69
  }
70
70
  }
@@ -189,7 +189,7 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
189
189
  else if (panel.kind === "list") {
190
190
  const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
191
191
  for (const column of panel.columns) {
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.`);
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 set virtual: true on it to mark it a virtual/computed column.`);
193
193
  }
194
194
  }
195
195
  // chart/feed/progress-list/custom panels have a fixed query-result
@@ -205,7 +205,7 @@ function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen)
205
205
  if (screen.expandableRow !== undefined) {
206
206
  const rowShape = resolveListRowShape(queryHandlers, screen.expandableRow.query, `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow.title}"`);
207
207
  for (const column of screen.expandableRow.columns) {
208
- 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.`);
208
+ 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 set virtual: true on it to mark it a virtual/computed column.`);
209
209
  }
210
210
  }
211
211
  }