@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.
- package/dist/api/server.js +14 -1
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.d.ts +2 -0
- package/dist/crypto/owner-transition-rekey.js +30 -0
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +5 -0
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +14 -1
- package/dist/db/apply-entity-event.js +0 -5
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-field-encryption.js +3 -2
- package/dist/db/entity-table-meta.js +3 -3
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +9 -2
- package/dist/db/event-store-executor-write.js +31 -5
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/event-store-executor.js +1 -0
- package/dist/db/index.d.ts +3 -3
- package/dist/db/index.js +2 -2
- package/dist/db/migrate-generator.d.ts +1 -0
- package/dist/db/migrate-generator.js +12 -0
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +120 -15
- package/dist/db/table-builder.d.ts +3 -1
- package/dist/db/table-builder.js +3 -6
- package/dist/engine/boot-validator/access-declarations.js +29 -18
- package/dist/engine/boot-validator/card-meta-overflow.js +5 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +38 -1
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +3 -0
- package/dist/engine/boot-validator/screens.d.ts +1 -1
- package/dist/engine/boot-validator/screens.js +89 -14
- package/dist/engine/build-app-schema.js +1 -0
- package/dist/engine/define-handler.js +1 -3
- package/dist/engine/factories.d.ts +2 -0
- package/dist/engine/feature-ast/extractors/round3.d.ts +2 -2
- package/dist/engine/feature-ast/extractors/round3.js +4 -4
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/schema-builder.js +2 -2
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +9 -0
- package/dist/engine/soft-delete-cleanup.js +12 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/errors/classes.d.ts +7 -0
- package/dist/errors/classes.js +12 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
- package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
- package/dist/files/bind-pending-file-refs.d.ts +1 -0
- package/dist/files/bind-pending-file-refs.js +13 -13
- package/dist/files/bind-referenced-files.write.d.ts +16 -0
- package/dist/files/bind-referenced-files.write.js +109 -0
- package/dist/files/dispose-removed-file-refs.d.ts +8 -0
- package/dist/files/dispose-removed-file-refs.js +51 -0
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -0
- package/dist/files/file-ref-entity.d.ts +1 -1
- package/dist/files/file-ref-entity.js +1 -1
- package/dist/files/file-routes.d.ts +4 -1
- package/dist/files/file-routes.js +37 -6
- package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
- package/dist/files/purge-expired-file-ref-bytes.js +34 -0
- package/dist/files/record-write-check.d.ts +22 -0
- package/dist/files/record-write-check.js +64 -0
- package/dist/files/types.d.ts +1 -0
- package/dist/files/types.js +3 -0
- package/dist/i18n/required-surface-keys.js +10 -0
- package/dist/migrations/pii-event-backfill.d.ts +1 -0
- package/dist/migrations/pii-event-backfill.js +16 -2
- package/dist/pipeline/cascade-handler.js +17 -2
- package/dist/pipeline/dispatch-write.js +6 -6
- package/dist/pipeline/write-origin.js +2 -2
- package/dist/schema-cli.js +12 -6
- package/dist/search/reindex-entity.js +3 -4
- package/dist/stack/test-stack.d.ts +3 -1
- package/dist/stack/test-stack.js +1 -0
- package/dist/ui-types/card-column-roles.d.ts +5 -0
- package/dist/ui-types/card-column-roles.js +22 -4
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/package.json +4 -4
- 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
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
} ?
|
|
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]>;
|
package/dist/db/table-builder.js
CHANGED
|
@@ -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)
|
|
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
|
-
//
|
|
177
|
-
|
|
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
|
|
71
|
-
// skip:
|
|
72
|
-
if (
|
|
70
|
+
function validatePersonalDataSupportedForKind(feature, kind, handlerName, access) {
|
|
71
|
+
// skip: nothing declared
|
|
72
|
+
if (declaredPersonalData(access) === undefined)
|
|
73
73
|
return;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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}]
|
|
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}]
|
|
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}]
|
|
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
|
|
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}]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
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;
|