@cosmicdrift/kumiko-framework 0.360.0 → 0.362.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 (85) hide show
  1. package/dist/api/server.js +14 -1
  2. package/dist/crypto/index.d.ts +1 -1
  3. package/dist/crypto/index.js +1 -1
  4. package/dist/crypto/owner-transition-rekey.d.ts +2 -0
  5. package/dist/crypto/owner-transition-rekey.js +30 -0
  6. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  7. package/dist/crypto/pii-field-encryption.js +5 -0
  8. package/dist/crypto/subject-resolver.d.ts +2 -0
  9. package/dist/crypto/subject-resolver.js +14 -1
  10. package/dist/db/apply-entity-event.js +0 -5
  11. package/dist/db/eagerload.js +2 -2
  12. package/dist/db/entity-field-encryption.js +3 -2
  13. package/dist/db/entity-table-meta.js +3 -3
  14. package/dist/db/event-store-executor-context.d.ts +1 -0
  15. package/dist/db/event-store-executor-context.js +9 -2
  16. package/dist/db/event-store-executor-write.js +31 -5
  17. package/dist/db/event-store-executor.d.ts +2 -1
  18. package/dist/db/event-store-executor.js +1 -0
  19. package/dist/db/index.d.ts +3 -3
  20. package/dist/db/index.js +2 -2
  21. package/dist/db/migrate-generator.d.ts +1 -0
  22. package/dist/db/migrate-generator.js +12 -0
  23. package/dist/db/queries/backfill-pii.d.ts +2 -0
  24. package/dist/db/queries/backfill-pii.js +120 -15
  25. package/dist/db/table-builder.d.ts +3 -1
  26. package/dist/db/table-builder.js +3 -6
  27. package/dist/engine/boot-validator/access-declarations.js +29 -18
  28. package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
  29. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  30. package/dist/engine/boot-validator/entity-handler.js +38 -1
  31. package/dist/engine/boot-validator/index.js +2 -1
  32. package/dist/engine/boot-validator/query-output-columns.js +3 -0
  33. package/dist/engine/boot-validator/screens.d.ts +1 -1
  34. package/dist/engine/boot-validator/screens.js +89 -14
  35. package/dist/engine/build-app-schema.js +1 -0
  36. package/dist/engine/define-handler.js +1 -3
  37. package/dist/engine/factories.d.ts +2 -0
  38. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
  39. package/dist/engine/feature-ast/extractors/round3.js +4 -4
  40. package/dist/engine/index.d.ts +1 -1
  41. package/dist/engine/schema-builder.js +2 -2
  42. package/dist/engine/screen-helpers.d.ts +1 -0
  43. package/dist/engine/screen-helpers.js +9 -0
  44. package/dist/engine/soft-delete-cleanup.js +12 -1
  45. package/dist/engine/types/index.d.ts +1 -1
  46. package/dist/errors/classes.d.ts +7 -0
  47. package/dist/errors/classes.js +12 -0
  48. package/dist/errors/index.d.ts +1 -1
  49. package/dist/errors/index.js +1 -1
  50. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
  51. package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
  52. package/dist/files/bind-pending-file-refs.d.ts +1 -0
  53. package/dist/files/bind-pending-file-refs.js +13 -13
  54. package/dist/files/bind-referenced-files.write.d.ts +16 -0
  55. package/dist/files/bind-referenced-files.write.js +109 -0
  56. package/dist/files/dispose-removed-file-refs.d.ts +8 -0
  57. package/dist/files/dispose-removed-file-refs.js +51 -0
  58. package/dist/files/feature.d.ts +1 -0
  59. package/dist/files/feature.js +2 -0
  60. package/dist/files/file-ref-entity.d.ts +1 -1
  61. package/dist/files/file-ref-entity.js +1 -1
  62. package/dist/files/file-routes.d.ts +4 -1
  63. package/dist/files/file-routes.js +37 -6
  64. package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
  65. package/dist/files/purge-expired-file-ref-bytes.js +34 -0
  66. package/dist/files/record-write-check.d.ts +22 -0
  67. package/dist/files/record-write-check.js +64 -0
  68. package/dist/files/types.d.ts +1 -0
  69. package/dist/files/types.js +3 -0
  70. package/dist/i18n/required-surface-keys.js +10 -0
  71. package/dist/migrations/pii-event-backfill.d.ts +1 -0
  72. package/dist/migrations/pii-event-backfill.js +16 -2
  73. package/dist/pipeline/cascade-handler.js +17 -2
  74. package/dist/pipeline/dispatch-write.js +6 -6
  75. package/dist/pipeline/write-origin.js +2 -2
  76. package/dist/schema-cli.js +12 -6
  77. package/dist/search/reindex-entity.js +3 -4
  78. package/dist/stack/test-stack.d.ts +3 -1
  79. package/dist/stack/test-stack.js +1 -0
  80. package/dist/ui-types/card-column-roles.d.ts +5 -0
  81. package/dist/ui-types/card-column-roles.js +22 -4
  82. package/dist/ui-types/index.d.ts +3 -3
  83. package/dist/ui-types/index.js +2 -2
  84. package/package.json +4 -4
  85. package/src/changes.json +134 -0
@@ -8,7 +8,7 @@
8
8
  // - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
9
9
  // restored) for every entity with PII subject annotations — generic over
10
10
  // subject kind (user/tenant/record, fw#2790), since it goes through the
11
- // same collectPiiSubjectFields/resolveSubjectForField the live write
11
+ // same collectPiiEncryptedFields/resolveSubjectForField the live write
12
12
  // path uses
13
13
  // - custom events from the event-PII catalog (r.defineEvent piiFields) —
14
14
  // user/tenant/self subjects, via the same resolveEventSubject the live
@@ -41,6 +41,9 @@
41
41
  // unresolvable (hard-deleted aggregate, no projection) → PII_ERASED_SENTINEL.
42
42
  // Stages 2 and 3 are opt-in and off by default; without them an
43
43
  // unresolvable subject still fails loud into `failures`, as before.
44
+ // 4. owner re-key ({@link PiiBackfillOptions.rekeyTenantKeyedOwnerFields}) —
45
+ // tenant-keyed ciphertext of a userOwned field moves under the user the
46
+ // strict chain (stages 1+2, never whenAbsent) names.
44
47
  //
45
48
  // Snapshots of touched aggregates are dropped per batch (they may cache
46
49
  // plaintext); the next snapshotting load recreates them. AFTER a run, rebuild the
@@ -50,8 +53,8 @@ import { asRawClient } from "../../bun-db/index.js";
50
53
  import { quoteIdent } from "../../crypto/ciphertext-pattern.js";
51
54
  import { configuredEventPiiCatalog } from "../../crypto/event-pii.js";
52
55
  import { isLocalKeyKmsAdapter, KeyErasedError, KeyNotFoundError, } from "../../crypto/kms-adapter.js";
53
- import { configuredPiiSubjectKms, encryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../../crypto/pii-field-encryption.js";
54
- import { collectPiiSubjectFields, resolveEventSubject, resolveSubjectForField, SubjectResolutionError, } from "../../crypto/subject-resolver.js";
56
+ import { configuredPiiSubjectKms, decryptPiiValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_ERASED_SENTINEL, piiCiphertextSubject, } from "../../crypto/pii-field-encryption.js";
57
+ import { collectPiiEncryptedFields, resolveEventSubject, resolveSubjectForField, SubjectResolutionError, } from "../../crypto/subject-resolver.js";
55
58
  import { resolveTableName } from "../entity-table-meta.js";
56
59
  import { tableExists } from "../schema-inspection.js";
57
60
  import { toSnakeCase } from "../table-builder.js";
@@ -59,7 +62,7 @@ const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten
59
62
  function collectPiiTargets(registry) {
60
63
  const entityTargets = new Map();
61
64
  for (const [name, entity] of registry.getAllEntities()) {
62
- const piiFields = collectPiiSubjectFields(entity);
65
+ const piiFields = collectPiiEncryptedFields(entity);
63
66
  if (piiFields.length > 0)
64
67
  entityTargets.set(name, { entity, piiFields });
65
68
  }
@@ -83,6 +86,7 @@ export async function backfillEventPiiEncryption(db, registry, options = {}) {
83
86
  updatedEvents: 0,
84
87
  encryptedFields: 0,
85
88
  erasedFields: 0,
89
+ rekeyedFields: 0,
86
90
  ownerFromProjection: 0,
87
91
  erasedUnresolvable: 0,
88
92
  deletedSnapshots: 0,
@@ -100,6 +104,7 @@ export async function backfillEventPiiEncryption(db, registry, options = {}) {
100
104
  total.updatedEvents += batch.updatedEvents;
101
105
  total.encryptedFields += batch.encryptedFields;
102
106
  total.erasedFields += batch.erasedFields;
107
+ total.rekeyedFields += batch.rekeyedFields;
103
108
  total.ownerFromProjection += batch.ownerFromProjection;
104
109
  total.erasedUnresolvable += batch.erasedUnresolvable;
105
110
  total.deletedSnapshots += batch.deletedSnapshots;
@@ -127,6 +132,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
127
132
  updatedEvents: 0,
128
133
  encryptedFields: 0,
129
134
  erasedFields: 0,
135
+ rekeyedFields: 0,
130
136
  ownerFromProjection: 0,
131
137
  erasedUnresolvable: 0,
132
138
  deletedSnapshots: 0,
@@ -168,6 +174,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
168
174
  continue;
169
175
  result.encryptedFields += outcome.encrypted;
170
176
  result.erasedFields += outcome.erased;
177
+ result.rekeyedFields += outcome.rekeyed;
171
178
  result.ownerFromProjection += outcome.ownerFromProjection;
172
179
  result.erasedUnresolvable += outcome.erasedUnresolvable;
173
180
  if (!options.dryRun) {
@@ -245,7 +252,11 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
245
252
  for (const projectionRow of projectionRows) {
246
253
  const id = projectionRow["id"];
247
254
  if (typeof id === "string") {
248
- ownersById.set(id, projectionRowToCamel(target.entity, projectionRow));
255
+ const rowTenantId = projectionRow["tenant_id"];
256
+ ownersById.set(id, {
257
+ tenantId: typeof rowTenantId === "string" ? rowTenantId : undefined,
258
+ fields: projectionRowToCamel(target.entity, projectionRow),
259
+ });
249
260
  }
250
261
  }
251
262
  }
@@ -254,15 +265,22 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
254
265
  return byType;
255
266
  }
256
267
  async function transformEvent(row, projectionOwners) {
257
- const counters = { encrypted: 0, erased: 0, ownerFromProjection: 0, erasedUnresolvable: 0 };
268
+ const counters = {
269
+ encrypted: 0,
270
+ erased: 0,
271
+ rekeyed: 0,
272
+ ownerFromProjection: 0,
273
+ erasedUnresolvable: 0,
274
+ };
258
275
  const payload = structuredClone(row.payload);
276
+ const tenantId = row.tenant_id; // @cast-boundary db-read — tenant_id column is the branded TenantId
259
277
  const catalogFields = eventCatalog.get(row.type);
260
278
  if (catalogFields) {
261
279
  for (const [field, spec] of Object.entries(catalogFields)) {
262
280
  let subject;
263
281
  try {
264
282
  subject = resolveEventSubject(field, spec, payload, {
265
- tenantId: row.tenant_id, // @cast-boundary db-read — tenant_id column is the branded TenantId
283
+ tenantId,
266
284
  aggregateType: row.aggregate_type,
267
285
  aggregateId: row.aggregate_id,
268
286
  });
@@ -284,9 +302,10 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
284
302
  const target = entityTargets.get(row.aggregate_type);
285
303
  if (!target || !isLifecycleEventOf(row.type, row.aggregate_type))
286
304
  return null;
287
- await applyEntityLifecycleFields(target, projectionOwners?.get(row.aggregate_id));
305
+ const ownerRow = projectionOwnerRowOfTenant(projectionOwners?.get(row.aggregate_id), row.tenant_id);
306
+ await applyEntityLifecycleFields(target, ownerRow);
288
307
  }
289
- if (counters.encrypted === 0 && counters.erased === 0)
308
+ if (counters.encrypted === 0 && counters.erased === 0 && counters.rekeyed === 0)
290
309
  return null;
291
310
  return { payload, ...counters };
292
311
  function bump(outcome) {
@@ -309,14 +328,18 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
309
328
  continue;
310
329
  if (typeof value !== "string")
311
330
  continue;
312
- // Already-ciphertext/sentinel fields must never enter owner
313
- // resolution — an unresolvable owner on an already-handled field
331
+ // Already-ciphertext/sentinel fields must never enter the encrypt/erase
332
+ // owner resolution — an unresolvable owner on an already-handled field
314
333
  // must stay untouched, not get erased by stage 3.
315
- if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL)
334
+ if (isPiiCiphertext(value)) {
335
+ if (options.rekeyTenantKeyedOwnerFields) {
336
+ await rekeyField(target.entity, section, field, value, subjectSource, ownerFallback, projectionOwnerRow);
337
+ }
338
+ continue;
339
+ }
340
+ if (value === PII_ERASED_SENTINEL)
316
341
  continue;
317
- const resolution = resolveOwnerSubject(target.entity, field, subjectSource, ownerFallback, projectionOwnerRow,
318
- // @cast-boundary db-read — tenant_id column is the branded TenantId
319
- row.tenant_id, row.aggregate_type);
342
+ const resolution = resolveOwnerSubject(target.entity, field, subjectSource, ownerFallback, projectionOwnerRow, tenantId, row.aggregate_type);
320
343
  if (resolution.kind === "unannotated")
321
344
  continue;
322
345
  if (resolution.kind === "unresolved") {
@@ -335,6 +358,32 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
335
358
  }
336
359
  }
337
360
  }
361
+ async function rekeyField(entity, section, field, ciphertext, subjectSource, ownerFallback, projectionOwnerRow) {
362
+ const rekey = await rekeyTenantKeyedValue({
363
+ subjectKms,
364
+ kmsCtx,
365
+ dryRun: options.dryRun === true,
366
+ isForgottenSubject: (subject) => isForgottenSubject(subject, row.aggregate_id),
367
+ predictOutcome: predictEncryptOutcome,
368
+ }, {
369
+ entity,
370
+ field,
371
+ ciphertext,
372
+ tenantId,
373
+ aggregateType: row.aggregate_type,
374
+ subjectSource,
375
+ ownerFallback,
376
+ projectionOwnerRow,
377
+ });
378
+ if (rekey.kind === "rekeyed") {
379
+ section[field] = rekey.value;
380
+ counters.rekeyed++;
381
+ }
382
+ else if (rekey.kind === "erased") {
383
+ section[field] = PII_ERASED_SENTINEL;
384
+ counters.erased++;
385
+ }
386
+ }
338
387
  async function encryptField(section, field, subject) {
339
388
  const value = section[field];
340
389
  if (value === null || value === undefined)
@@ -399,6 +448,62 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
399
448
  return scanCache.destroyedTenants;
400
449
  }
401
450
  }
451
+ // aggregate_id is only unique per tenant: another tenant's projection row must not name the owner.
452
+ function projectionOwnerRowOfTenant(owner, tenantId) {
453
+ if (owner === undefined)
454
+ return undefined;
455
+ return owner.tenantId === undefined || owner.tenantId === tenantId ? owner.fields : undefined;
456
+ }
457
+ function isPlainUserOwnedField(entity, field) {
458
+ const fieldDef = entity.fields[field];
459
+ if (fieldDef === undefined || !("userOwned" in fieldDef) || fieldDef.userOwned === undefined) {
460
+ return false;
461
+ }
462
+ return !("recordOwned" in fieldDef && fieldDef.recordOwned === true);
463
+ }
464
+ // Stage 4. In dryRun the "rekeyed" value is the untouched ciphertext (nothing is written).
465
+ async function rekeyTenantKeyedValue(deps, target) {
466
+ const { entity, field, ciphertext } = target;
467
+ if (!isPlainUserOwnedField(entity, field) || !isTenantKeyedUnder(ciphertext, target.tenantId)) {
468
+ return { kind: "unchanged" };
469
+ }
470
+ const resolution = resolveOwnerSubject(entity, field, target.subjectSource, target.ownerFallback, target.projectionOwnerRow, target.tenantId, target.aggregateType);
471
+ if (resolution.kind !== "resolved" || resolution.subject.kind !== "user") {
472
+ return { kind: "unchanged" };
473
+ }
474
+ const userSubject = resolution.subject;
475
+ if (await deps.isForgottenSubject(userSubject))
476
+ return { kind: "erased" };
477
+ if (deps.dryRun) {
478
+ return (await deps.predictOutcome(userSubject)) === "erased"
479
+ ? { kind: "erased" }
480
+ : { kind: "rekeyed", value: ciphertext };
481
+ }
482
+ const plaintext = await decryptPiiValueForSubject(deps.subjectKms, ciphertext, deps.kmsCtx, field);
483
+ if (plaintext === PII_ERASED_SENTINEL)
484
+ return { kind: "erased" };
485
+ try {
486
+ const value = await encryptPiiValueForSubject(deps.subjectKms, userSubject, plaintext, deps.kmsCtx, field);
487
+ return { kind: "rekeyed", value };
488
+ }
489
+ catch (e) {
490
+ if (e instanceof KeyErasedError)
491
+ return { kind: "erased" };
492
+ throw e;
493
+ }
494
+ }
495
+ // Only the row's own tenant key counts: another tenant's or a user's key is
496
+ // never decrypted here.
497
+ function isTenantKeyedUnder(ciphertext, tenantId) {
498
+ try {
499
+ const subject = piiCiphertextSubject(ciphertext);
500
+ return subject.kind === "tenant" && subject.tenantId === tenantId;
501
+ }
502
+ catch {
503
+ // skip: a malformed ciphertext prefix is not ours to re-key
504
+ return false;
505
+ }
506
+ }
402
507
  function attemptResolve(entity, field, source, opts) {
403
508
  try {
404
509
  return { ok: true, subject: resolveSubjectForField(entity, field, source, opts) };
@@ -137,7 +137,9 @@ type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
137
137
  readonly [P in K]: NullCol<string>;
138
138
  } : F extends {
139
139
  type: "files" | "images";
140
- } ? Record<never, never> : never;
140
+ } ? {
141
+ readonly [P in K]: Col<readonly string[]>;
142
+ } : never;
141
143
  type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
142
144
  type ColumnsForEntity<F extends FieldsMap> = UnionToIntersection<{
143
145
  [K in keyof F & string]: ColumnsForField<K, F[K]>;
@@ -12,7 +12,7 @@ export function physicalColumnName(table, field) {
12
12
  return table[field]?.name ?? toSnakeCase(field);
13
13
  }
14
14
  // Returns column(s) for a field. Most fields return a single entry,
15
- // money returns two (amount + currency), files/images return none.
15
+ // money returns two (amount + currency).
16
16
  //
17
17
  // `required: true` auf einem FieldDefinition mappt **immer** auf .notNull()
18
18
  // in der DB-Spalte. Die alte Implementation hat das nur für reference-
@@ -173,11 +173,8 @@ function fieldToColumns(name, field, entity) {
173
173
  }
174
174
  case "files":
175
175
  case "images":
176
- // Multi file: no column in entity table, resolved via FileRef table
177
- // over (entityType, entityId, fieldName). A bridge-table with
178
- // CASCADE + sort-order is a later improvement; today plural files
179
- // live entirely in fileRefsTable.
180
- return {};
176
+ // Ordered fileRef UUID array, same shape as multiSelect: never null.
177
+ return { [name]: jsonb(snakeName).default([]).notNull() };
181
178
  default:
182
179
  assertUnreachable(field, "field type");
183
180
  }
@@ -67,16 +67,25 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
67
67
  }
68
68
  }
69
69
  }
70
- function validatePersonalDataOnlyOnWrite(feature, kind, handlerName, access) {
71
- // skip: write handlers may declare personalData, or it wasn't declared here
72
- if (kind === "write" || declaredPersonalData(access) === undefined)
70
+ function validatePersonalDataSupportedForKind(feature, kind, handlerName, access) {
71
+ // skip: nothing declared
72
+ if (declaredPersonalData(access) === undefined)
73
73
  return;
74
- const property = "openToAll" in access ? "openToAll.personalData" : "access.personalData";
75
- throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
76
- `${property} — it only applies to write handlers, whose input is ` +
77
- "checked for personal-data fields.");
74
+ // skip: write handlers accept every personalData declaration
75
+ if (kind === "write")
76
+ return;
77
+ const isOpenToAll = "openToAll" in access;
78
+ // skip: query handlers may declare access.personalData, its value is validated separately
79
+ if (kind === "query" && !isOpenToAll)
80
+ return;
81
+ const property = isOpenToAll ? "openToAll.personalData" : "access.personalData";
82
+ const onlyAllowed = kind === "query"
83
+ ? 'only access.personalData: "public-intake" applies to query handlers, which write ' +
84
+ "personal data through nested writes of an anonymous request."
85
+ : "it only applies to write handlers, whose input is checked for personal-data fields.";
86
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ${property} — ${onlyAllowed}`);
78
87
  }
79
- function validatePersonalDataValue(feature, handlerName, access) {
88
+ function validatePersonalDataValue(feature, kind, handlerName, access) {
80
89
  const declared = declaredPersonalData(access);
81
90
  // skip: nothing declared
82
91
  if (declared === undefined)
@@ -85,17 +94,17 @@ function validatePersonalDataValue(feature, handlerName, access) {
85
94
  // skip: the one supported value on openToAll
86
95
  if (declared === "tenant-members")
87
96
  return;
88
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
97
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an unknown ` +
89
98
  `openToAll.personalData ${JSON.stringify(declared)} — the only supported value is "tenant-members".`);
90
99
  }
91
100
  if (declared !== "public-intake") {
92
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares an unknown ` +
101
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an unknown ` +
93
102
  `access.personalData ${JSON.stringify(declared)} — the only supported value is "public-intake".`);
94
103
  }
95
104
  // skip: roles include "anonymous", which personalData: "public-intake" requires
96
105
  if (accessAllowsAnonymous(access))
97
106
  return;
98
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
107
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
99
108
  'access.personalData: "public-intake" but its roles do not include ' +
100
109
  `"${ANONYMOUS_ROLE}" — personalData: "public-intake" only applies to handlers ` +
101
110
  "that allow anonymous callers.");
@@ -150,14 +159,14 @@ function validateAnonymousPersonalData(feature, handlerName, handler) {
150
159
  }
151
160
  // The rateLimit is the only protection of a public-intake handler, so the
152
161
  // 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
162
+ function validatePublicIntakeRateLimit(feature, kind, handlerName, handler) {
163
+ // skip: not a public-intake handler
155
164
  if (declaredPersonalData(handler.access) !== "public-intake")
156
165
  return;
157
166
  // skip: rateLimit is active
158
167
  if (!isRateLimitDisabled(handler.rateLimit))
159
168
  return;
160
- throw new Error(`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
169
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
161
170
  'access.personalData: "public-intake" with rateLimit: { disabled: true } — the rateLimit ' +
162
171
  'is the only protection for anonymous personal-data writes. Declare rateLimit: { per: "ip" ' +
163
172
  'or "ip+handler", ... } instead.');
@@ -166,19 +175,21 @@ export function validateAccessDeclarations(feature) {
166
175
  for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
167
176
  validateOpenToAllReason(feature, "write", handlerName, handler.access);
168
177
  validateEscapeHatchReason(feature, "write", handlerName, handler.escapeHatch);
169
- validatePersonalDataValue(feature, handlerName, handler.access);
178
+ validatePersonalDataValue(feature, "write", handlerName, handler.access);
170
179
  validateOpenToAllPersonalData(feature, handlerName, handler);
171
180
  validateAnonymousPersonalData(feature, handlerName, handler);
172
- validatePublicIntakeRateLimit(feature, handlerName, handler);
181
+ validatePublicIntakeRateLimit(feature, "write", handlerName, handler);
173
182
  }
174
183
  for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
175
184
  validateOpenToAllReason(feature, "query", handlerName, handler.access);
176
185
  validateEscapeHatchReason(feature, "query", handlerName, handler.escapeHatch);
177
- validatePersonalDataOnlyOnWrite(feature, "query", handlerName, handler.access);
186
+ validatePersonalDataSupportedForKind(feature, "query", handlerName, handler.access);
187
+ validatePersonalDataValue(feature, "query", handlerName, handler.access);
188
+ validatePublicIntakeRateLimit(feature, "query", handlerName, handler);
178
189
  }
179
190
  for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
180
191
  validateOpenToAllReason(feature, "stream", handlerName, handler.access);
181
192
  validateEscapeHatchReason(feature, "stream", handlerName, handler.escapeHatch);
182
- validatePersonalDataOnlyOnWrite(feature, "stream", handlerName, handler.access);
193
+ validatePersonalDataSupportedForKind(feature, "stream", handlerName, handler.access);
183
194
  }
184
195
  }
@@ -1,4 +1,4 @@
1
- import { CARD_META_MAX, cardColumnRoles } from "../../ui-types/card-column-roles.js";
1
+ import { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind, } from "../../ui-types/card-column-roles.js";
2
2
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta.js";
3
3
  import { parseRefTarget } from "../parse-ref-target.js";
4
4
  import { normalizeListColumn } from "../screen-helpers.js";
@@ -19,11 +19,14 @@ function resolveColumnType(spec, storedType, entity) {
19
19
  function countOverflow(feature, screen, location, columns, storedTypeOf, entity) {
20
20
  const cardColumns = columns.map((spec) => {
21
21
  const col = normalizeListColumn(spec);
22
+ const type = resolveColumnType(spec, storedTypeOf(col.field), entity);
23
+ const fileKind = cardFileDisplayKind(type, col.renderer);
22
24
  return {
23
25
  field: col.field,
24
- type: resolveColumnType(spec, storedTypeOf(col.field), entity),
26
+ type,
25
27
  renderer: col.renderer,
26
28
  hideOnNarrow: col.hideOnNarrow,
29
+ ...(fileKind !== undefined && { fileDisplay: { kind: fileKind } }),
27
30
  };
28
31
  });
29
32
  const { meta } = cardColumnRoles(cardColumns);
@@ -23,6 +23,7 @@ export declare function validateEntitySelectAvailabilityQuery(feature: FeatureDe
23
23
  export declare function validateEntitySelectConditionalOptions(feature: FeatureDefinition): void;
24
24
  export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
25
25
  export declare function validateImageVariants(feature: FeatureDefinition): void;
26
+ export declare function validateLockedWhen(feature: FeatureDefinition): void;
26
27
  export declare function validateTransitions(feature: FeatureDefinition): void;
27
28
  export declare function validateExtendSchemaCollisions(feature: FeatureDefinition): void;
28
29
  export declare function validateDerivedFieldCollisions(feature: FeatureDefinition): void;
@@ -341,7 +341,7 @@ export function validateEntityIndexes(feature) {
341
341
  field.type === "images" ||
342
342
  (field.type === "reference" && field.multiple === true))) {
343
343
  throw new Error(`${where}: column "${col}" is a multi-value field (${field.type}) — ` +
344
- `these have no DB column to index on. Use a single-value field or remove from the index.`);
344
+ `a jsonb array cannot be indexed as a column. Use a single-value field or remove from the index.`);
345
345
  }
346
346
  if (field && field.type === "longText") {
347
347
  // longText ist semantisch "potentially-megabytes content" — ein
@@ -857,6 +857,43 @@ export function validateImageVariants(feature) {
857
857
  }
858
858
  }
859
859
  }
860
+ // --- lockedWhen validation ---
861
+ function isCiphertextAtRest(field) {
862
+ return (("pii" in field && field.pii === true) ||
863
+ ("userOwned" in field && field.userOwned !== undefined) ||
864
+ ("tenantOwned" in field && field.tenantOwned === true) ||
865
+ ("recordOwned" in field && field.recordOwned === true) ||
866
+ ("encrypted" in field && field.encrypted === true));
867
+ }
868
+ // Types whose stored value is a plain scalar that the condition's `===` can match.
869
+ // Compound, temporal, array and bigint columns come back in another shape than the
870
+ // literal an author writes, so such a lock would silently never hold.
871
+ const LOCKABLE_FIELD_TYPES = new Set([
872
+ "text",
873
+ "longText",
874
+ "boolean",
875
+ "select",
876
+ "number",
877
+ "reference",
878
+ ]);
879
+ export function validateLockedWhen(feature) {
880
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
881
+ const { lockedWhen } = entity;
882
+ if (typeof lockedWhen !== "object")
883
+ continue;
884
+ const where = `Entity "${entityName}" in feature "${feature.name}": lockedWhen field "${lockedWhen.field}"`;
885
+ const field = entity.fields[lockedWhen.field];
886
+ if (!field) {
887
+ throw new Error(`${where} is not a field of the entity (known: ${Object.keys(entity.fields).sort().join(", ")}).`);
888
+ }
889
+ if (isCiphertextAtRest(field)) {
890
+ throw new Error(`${where} is personal or encrypted — the lock condition is evaluated on the stored value, which is ciphertext. Use a plaintext field.`);
891
+ }
892
+ if (!LOCKABLE_FIELD_TYPES.has(field.type)) {
893
+ throw new Error(`${where} has type "${field.type}", which does not store a plain scalar the condition can compare. Use a text, select, boolean, number or reference field.`);
894
+ }
895
+ }
896
+ }
860
897
  // --- Transition validation ---
861
898
  export function validateTransitions(feature) {
862
899
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
@@ -10,7 +10,7 @@ import { validateFeatureBootChecks } from "./boot-check.js";
10
10
  import { validateCardMetaOverflow } from "./card-meta-overflow.js";
11
11
  import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
12
12
  import { collectDetailForScreens } from "./detail-screens.js";
13
- import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectConditionalOptions, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
13
+ import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectConditionalOptions, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateLockedWhen, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
14
14
  import { validateEntityListScreens } from "./entity-list-screens.js";
15
15
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
16
16
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
@@ -181,6 +181,7 @@ export function validateBoot(rawFeatures, options) {
181
181
  validateReferenceFields(feature, featureMap, queryHandlerQns);
182
182
  validateHandlerFieldReferences(feature, featureMap);
183
183
  validateTransitions(feature);
184
+ validateLockedWhen(feature);
184
185
  validateExtensionUsages(feature, extensionProviders);
185
186
  validateExtendSchemaCollisions(feature);
186
187
  validateDerivedFieldCollisions(feature);
@@ -97,6 +97,9 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
97
97
  }
98
98
  if (screen.header.status !== undefined)
99
99
  checkHeaderField("status", screen.header.status);
100
+ if (screen.header.statusToneField !== undefined) {
101
+ checkHeaderField("statusToneField", screen.header.statusToneField);
102
+ }
100
103
  }
101
104
  if (screen.recordTitleField !== undefined) {
102
105
  const field = screen.recordTitleField;
@@ -3,7 +3,7 @@ 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;
6
- export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit", context: string, action: {
6
+ export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList", context: string, action: {
7
7
  readonly id: string;
8
8
  readonly icon?: IconKey;
9
9
  }): void;