@cosmicdrift/kumiko-framework 0.116.1 → 0.119.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/package.json +6 -2
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/pii-leak-guard.ts +45 -0
- package/src/api/server.ts +5 -0
- package/src/bun-db/query.ts +27 -2
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +216 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/blind-index.ts +114 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +55 -0
- package/src/crypto/kms-adapter.ts +118 -0
- package/src/crypto/pg-kms-adapter.ts +173 -0
- package/src/crypto/pii-field-encryption.ts +181 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/blind-index.integration.test.ts +232 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/apply-entity-event.ts +8 -0
- package/src/db/blind-index-cleanup.ts +53 -0
- package/src/db/entity-table-meta.ts +47 -2
- package/src/db/event-store-executor.ts +125 -30
- package/src/db/index.ts +1 -0
- package/src/db/table-builder.ts +98 -51
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
- package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
- package/src/engine/boot-validator/pii-retention.ts +45 -9
- package/src/engine/define-feature.ts +3 -1
- package/src/engine/registry.ts +9 -0
- package/src/engine/types/feature.ts +10 -1
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/handlers.ts +4 -0
- package/src/engine/types/index.ts +1 -0
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context";
|
|
2
|
+
import {
|
|
3
|
+
collectPiiSubjectFields,
|
|
4
|
+
computeBlindIndex,
|
|
5
|
+
configuredBlindIndexKey,
|
|
6
|
+
configuredPiiSubjectKms,
|
|
7
|
+
decryptPiiFieldValues,
|
|
8
|
+
encryptPiiFieldValues,
|
|
9
|
+
type KmsContext,
|
|
10
|
+
type LocalKeyKmsAdapter,
|
|
11
|
+
} from "../crypto";
|
|
2
12
|
import { executeRawQuery } from "../db/queries/raw-sql";
|
|
3
13
|
import type { WhereObject } from "../db/query";
|
|
4
14
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
@@ -121,6 +131,8 @@ export type EventStoreExecutorOptions = {
|
|
|
121
131
|
entityCache?: EntityCache;
|
|
122
132
|
/** Override the boot-injected cipher for fields marked `encrypted: true`. */
|
|
123
133
|
encryption?: EnvelopeCipher;
|
|
134
|
+
/** Override the boot-injected subject KMS for pii-annotated fields. */
|
|
135
|
+
kms?: LocalKeyKmsAdapter;
|
|
124
136
|
};
|
|
125
137
|
|
|
126
138
|
// F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
|
|
@@ -307,28 +319,64 @@ export function createEventStoreExecutor(
|
|
|
307
319
|
const encryptedFields = collectEncryptedFieldNames(entity);
|
|
308
320
|
const hasEncryptedFields = encryptedFields.size > 0;
|
|
309
321
|
|
|
322
|
+
const piiSubjectFields = collectPiiSubjectFields(entity);
|
|
323
|
+
const hasPiiFields = piiSubjectFields.length > 0;
|
|
324
|
+
|
|
310
325
|
function fieldCipher(): EnvelopeCipher {
|
|
311
326
|
if (options.encryption) return options.encryption;
|
|
312
327
|
return resolveEntityFieldEncryption();
|
|
313
328
|
}
|
|
314
329
|
|
|
330
|
+
// No adapter configured = crypto-shredding off; pii fields stay plaintext
|
|
331
|
+
// (pre-#724 behavior). The hard boot gate ships with the prod-grade
|
|
332
|
+
// PgKmsAdapter (phase E).
|
|
333
|
+
function piiKms(): LocalKeyKmsAdapter | undefined {
|
|
334
|
+
return options.kms ?? configuredPiiSubjectKms();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function kmsContextFor(user?: SessionUser): KmsContext {
|
|
338
|
+
return {
|
|
339
|
+
requestId: requestContext.get()?.requestId ?? "event-store-executor",
|
|
340
|
+
...(user && { tenantId: user.tenantId, userId: String(user.id) }),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
315
344
|
// Async on purpose: the envelope cipher wraps/unwraps DEKs via the
|
|
316
345
|
// MasterKeyProvider. Callers MUST await — a missed await here writes
|
|
317
346
|
// "[object Promise]" into the projection, which the Promise return
|
|
318
347
|
// types turn into a compile error at every call site.
|
|
319
348
|
async function encryptForStorage(
|
|
320
349
|
row: Record<string, unknown>,
|
|
321
|
-
|
|
350
|
+
user: SessionUser,
|
|
351
|
+
opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
|
|
322
352
|
): Promise<Record<string, unknown>> {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
353
|
+
let out = row;
|
|
354
|
+
if (hasEncryptedFields) {
|
|
355
|
+
out = await encryptEntityFieldValues(out, encryptedFields, fieldCipher(), {
|
|
356
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const kms = piiKms();
|
|
360
|
+
if (hasPiiFields && kms) {
|
|
361
|
+
out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
|
|
362
|
+
tenantId: user.tenantId,
|
|
363
|
+
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
364
|
+
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
327
368
|
}
|
|
328
369
|
|
|
329
370
|
async function decryptForRead(row: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
330
|
-
|
|
331
|
-
|
|
371
|
+
let out = row;
|
|
372
|
+
if (hasEncryptedFields) {
|
|
373
|
+
out = await decryptEntityFieldValues(out, encryptedFields, fieldCipher());
|
|
374
|
+
}
|
|
375
|
+
const kms = piiKms();
|
|
376
|
+
if (hasPiiFields && kms) {
|
|
377
|
+
out = await decryptPiiFieldValues(out, piiSubjectFields, kms, kmsContextFor());
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
332
380
|
}
|
|
333
381
|
|
|
334
382
|
function applyDefaults(payload: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -469,7 +517,12 @@ export function createEventStoreExecutor(
|
|
|
469
517
|
// Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
|
|
470
518
|
// dieselbe Pipeline. Caller schickt combined API-Form, Framework
|
|
471
519
|
// speichert flat DB-Form. Siehe db/compound-types.ts.
|
|
472
|
-
|
|
520
|
+
// subjectSource carries the freshly minted aggregateId: the create
|
|
521
|
+
// payload has no id column, but a pii:true self-subject resolves from it.
|
|
522
|
+
const flatCreateData = flattenCompoundTypes(data, entity);
|
|
523
|
+
const flatData = await encryptForStorage(flatCreateData, user, {
|
|
524
|
+
subjectSource: { ...flatCreateData, id: aggregateId },
|
|
525
|
+
});
|
|
473
526
|
|
|
474
527
|
// 1. Append event (same TX as the projection write — both must succeed
|
|
475
528
|
// or both roll back; the dispatcher wraps both in one transaction).
|
|
@@ -563,7 +616,9 @@ export function createEventStoreExecutor(
|
|
|
563
616
|
previous: {},
|
|
564
617
|
isNew: true,
|
|
565
618
|
entityName,
|
|
566
|
-
event
|
|
619
|
+
// Persisted event carries ciphertext by design — the caller-facing
|
|
620
|
+
// echo must be plaintext like every other response field (#820).
|
|
621
|
+
event: { ...event, payload: stripSensitive(flatCreateData) },
|
|
567
622
|
},
|
|
568
623
|
};
|
|
569
624
|
},
|
|
@@ -650,10 +705,13 @@ export function createEventStoreExecutor(
|
|
|
650
705
|
|
|
651
706
|
try {
|
|
652
707
|
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
708
|
+
// subjectSource: partial changes may carry a pii field without its
|
|
709
|
+
// ownerField — the merged row still names the subject.
|
|
710
|
+
const flatChangesPlain = flattenCompoundTypes(payload.changes, entity);
|
|
711
|
+
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
712
|
+
onlyKeys: Object.keys(payload.changes),
|
|
713
|
+
subjectSource: mergedNew,
|
|
714
|
+
});
|
|
657
715
|
|
|
658
716
|
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
659
717
|
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
@@ -673,7 +731,7 @@ export function createEventStoreExecutor(
|
|
|
673
731
|
type: entityEventName(entityName, "updated"),
|
|
674
732
|
payload: {
|
|
675
733
|
changes: stripSensitive(flatChanges),
|
|
676
|
-
previous: stripSensitive(await encryptForStorage(previous)),
|
|
734
|
+
previous: stripSensitive(await encryptForStorage(previous, user)),
|
|
677
735
|
},
|
|
678
736
|
metadata: buildEventMetadata(user),
|
|
679
737
|
});
|
|
@@ -717,7 +775,13 @@ export function createEventStoreExecutor(
|
|
|
717
775
|
previous,
|
|
718
776
|
isNew: false,
|
|
719
777
|
entityName,
|
|
720
|
-
event
|
|
778
|
+
event: {
|
|
779
|
+
...event,
|
|
780
|
+
payload: {
|
|
781
|
+
changes: stripSensitive(flatChangesPlain),
|
|
782
|
+
previous: stripSensitive(previous),
|
|
783
|
+
},
|
|
784
|
+
},
|
|
721
785
|
},
|
|
722
786
|
};
|
|
723
787
|
} catch (e) {
|
|
@@ -783,7 +847,7 @@ export function createEventStoreExecutor(
|
|
|
783
847
|
tenantId: streamTenantFor(user),
|
|
784
848
|
expectedVersion: currentVersion,
|
|
785
849
|
type: entityEventName(entityName, "deleted"),
|
|
786
|
-
payload: { previous: stripSensitive(await encryptForStorage(existing)) },
|
|
850
|
+
payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
|
|
787
851
|
metadata: buildEventMetadata(user),
|
|
788
852
|
});
|
|
789
853
|
|
|
@@ -805,7 +869,13 @@ export function createEventStoreExecutor(
|
|
|
805
869
|
|
|
806
870
|
return {
|
|
807
871
|
isSuccess: true,
|
|
808
|
-
data: {
|
|
872
|
+
data: {
|
|
873
|
+
kind: "delete",
|
|
874
|
+
id: payload.id,
|
|
875
|
+
data: existing,
|
|
876
|
+
entityName,
|
|
877
|
+
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
878
|
+
},
|
|
809
879
|
};
|
|
810
880
|
},
|
|
811
881
|
|
|
@@ -846,7 +916,9 @@ export function createEventStoreExecutor(
|
|
|
846
916
|
tenantId: streamTenantFor(user),
|
|
847
917
|
expectedVersion: currentVersion,
|
|
848
918
|
type: entityEventName(entityName, "forgotten"),
|
|
849
|
-
|
|
919
|
+
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
920
|
+
// plaintext must not land in the immutable log, least of all on forget.
|
|
921
|
+
payload: { previous: stripSensitive(await encryptForStorage(existing, user)) },
|
|
850
922
|
metadata: buildEventMetadata(user),
|
|
851
923
|
});
|
|
852
924
|
|
|
@@ -863,7 +935,13 @@ export function createEventStoreExecutor(
|
|
|
863
935
|
|
|
864
936
|
return {
|
|
865
937
|
isSuccess: true,
|
|
866
|
-
data: {
|
|
938
|
+
data: {
|
|
939
|
+
kind: "delete",
|
|
940
|
+
id: payload.id,
|
|
941
|
+
data: existing,
|
|
942
|
+
entityName,
|
|
943
|
+
event: { ...event, payload: { previous: stripSensitive(existing) } },
|
|
944
|
+
},
|
|
867
945
|
};
|
|
868
946
|
},
|
|
869
947
|
|
|
@@ -947,6 +1025,7 @@ export function createEventStoreExecutor(
|
|
|
947
1025
|
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
948
1026
|
);
|
|
949
1027
|
|
|
1028
|
+
const previousPlain = await decryptForRead(data);
|
|
950
1029
|
return {
|
|
951
1030
|
isSuccess: true,
|
|
952
1031
|
data: {
|
|
@@ -954,10 +1033,10 @@ export function createEventStoreExecutor(
|
|
|
954
1033
|
id: payload.id,
|
|
955
1034
|
data: restoredHydrated,
|
|
956
1035
|
changes: { isDeleted: false },
|
|
957
|
-
previous:
|
|
1036
|
+
previous: previousPlain,
|
|
958
1037
|
isNew: false,
|
|
959
1038
|
entityName,
|
|
960
|
-
event,
|
|
1039
|
+
event: { ...event, payload: { previous: stripSensitive(previousPlain) } },
|
|
961
1040
|
},
|
|
962
1041
|
};
|
|
963
1042
|
},
|
|
@@ -1067,8 +1146,19 @@ export function createEventStoreExecutor(
|
|
|
1067
1146
|
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
1068
1147
|
}
|
|
1069
1148
|
} else {
|
|
1070
|
-
|
|
1071
|
-
|
|
1149
|
+
// Blind-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
|
|
1150
|
+
// in bun-db/query.ts — Equality auf lookupable-Feldern matcht
|
|
1151
|
+
// Klartext-Arm ODER HMAC-Arm.
|
|
1152
|
+
const bidxKey = configuredBlindIndexKey();
|
|
1153
|
+
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
1154
|
+
params.push(value, computeBlindIndex(bidxKey, value));
|
|
1155
|
+
whereSql.push(
|
|
1156
|
+
`(${colSql(field)} = $${params.length - 1} OR ${colSql(`${field}Bidx`)} = $${params.length})`,
|
|
1157
|
+
);
|
|
1158
|
+
} else {
|
|
1159
|
+
params.push(value);
|
|
1160
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
1161
|
+
}
|
|
1072
1162
|
}
|
|
1073
1163
|
}
|
|
1074
1164
|
};
|
|
@@ -1086,21 +1176,26 @@ export function createEventStoreExecutor(
|
|
|
1086
1176
|
const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
|
|
1087
1177
|
|
|
1088
1178
|
const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
|
|
1089
|
-
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen
|
|
1179
|
+
// Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
|
|
1180
|
+
// Coerce BEFORE decrypt: the raw SELECT * rows carry snake_case column
|
|
1181
|
+
// names, while the encrypted/pii field lists are camelCase — decrypting
|
|
1182
|
+
// first silently skipped every multi-word field (ciphertext leaked to
|
|
1183
|
+
// the caller).
|
|
1090
1184
|
const tableInfo = extractTableInfo(table);
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
coerceRow(await decryptForRead(rehydrateCompoundTypes(r, entity)), tableInfo),
|
|
1094
|
-
),
|
|
1185
|
+
const encryptedRows = rawRows.map((r) =>
|
|
1186
|
+
coerceRow(rehydrateCompoundTypes(r, entity), tableInfo),
|
|
1095
1187
|
);
|
|
1188
|
+
const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
|
|
1096
1189
|
|
|
1097
1190
|
// list rows carry the READ-ROW version (display-only), never an optimistic-lock
|
|
1098
1191
|
// base — edit flows reload via detail(), which reconciles the stream version.
|
|
1192
|
+
// Cache the still-encrypted form: same at-rest guarantee as detail()'s
|
|
1193
|
+
// encryptForStorage round-trip, without paying a re-encrypt.
|
|
1099
1194
|
if (entityCache && entityName && rows.length > 0) {
|
|
1100
1195
|
await entityCache.mset(
|
|
1101
1196
|
user.tenantId,
|
|
1102
1197
|
entityName,
|
|
1103
|
-
|
|
1198
|
+
encryptedRows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
|
|
1104
1199
|
);
|
|
1105
1200
|
}
|
|
1106
1201
|
|
|
@@ -1180,7 +1275,7 @@ export function createEventStoreExecutor(
|
|
|
1180
1275
|
user.tenantId,
|
|
1181
1276
|
entityName,
|
|
1182
1277
|
payload.id,
|
|
1183
|
-
await encryptForStorage(coerced),
|
|
1278
|
+
await encryptForStorage(coerced, user),
|
|
1184
1279
|
);
|
|
1185
1280
|
}
|
|
1186
1281
|
|
package/src/db/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in";
|
|
2
|
+
export { nullBlindIndexesForSubject } from "./blind-index-cleanup";
|
|
2
3
|
export { collectTableMetas } from "./collect-table-metas";
|
|
3
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
4
5
|
export { seedConfigValues } from "./config-seed";
|
package/src/db/table-builder.ts
CHANGED
|
@@ -69,7 +69,13 @@ function fieldToColumns(
|
|
|
69
69
|
const base = text(snakeName);
|
|
70
70
|
const withDefault =
|
|
71
71
|
"default" in field && field.default !== undefined ? base.default(field.default) : base;
|
|
72
|
-
|
|
72
|
+
const mainCol = { [name]: field.required ? withDefault.notNull() : withDefault };
|
|
73
|
+
// lookupable → nullable HMAC-Blind-Index-Spalte (lock-step mit
|
|
74
|
+
// fieldToColumnMeta in entity-table-meta.ts).
|
|
75
|
+
if (field.type === "text" && field.lookupable === true) {
|
|
76
|
+
return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
|
|
77
|
+
}
|
|
78
|
+
return mainCol;
|
|
73
79
|
}
|
|
74
80
|
case "boolean":
|
|
75
81
|
return {
|
|
@@ -272,63 +278,72 @@ type NullCol<_T> = ColumnHandle & { readonly __notNull: false };
|
|
|
272
278
|
// columns (resolved via FileRef table). `notNull` propagiert von
|
|
273
279
|
// `field.required` (literal preserved by createXField generics).
|
|
274
280
|
type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
|
|
275
|
-
type: "text"
|
|
281
|
+
type: "text";
|
|
282
|
+
lookupable: true;
|
|
276
283
|
}
|
|
277
|
-
? F extends { required: true }
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
?
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
:
|
|
284
|
+
? (F extends { required: true }
|
|
285
|
+
? { readonly [P in K]: Col<string> }
|
|
286
|
+
: { readonly [P in K]: NullCol<string> }) & {
|
|
287
|
+
readonly [P in `${K}Bidx`]: NullCol<string>;
|
|
288
|
+
}
|
|
289
|
+
: F extends {
|
|
290
|
+
type: "text" | "select" | "tz";
|
|
291
|
+
}
|
|
292
|
+
? F extends { required: true }
|
|
293
|
+
? { readonly [P in K]: Col<string> }
|
|
294
|
+
: { readonly [P in K]: NullCol<string> }
|
|
295
|
+
: F extends { type: "boolean" }
|
|
296
|
+
? // boolean default OR required → notNull (DB has DEFAULT, structurally never-null)
|
|
297
|
+
F extends { default: boolean } | { required: true }
|
|
298
|
+
? { readonly [P in K]: Col<boolean> }
|
|
299
|
+
: { readonly [P in K]: NullCol<boolean> }
|
|
300
|
+
: F extends { type: "multiSelect" }
|
|
301
|
+
? // jsonb default `[]`, immer notNull
|
|
302
|
+
{ readonly [P in K]: Col<readonly string[]> }
|
|
303
|
+
: F extends { type: "number" }
|
|
293
304
|
? F extends { required: true }
|
|
294
305
|
? { readonly [P in K]: Col<number> }
|
|
295
306
|
: { readonly [P in K]: NullCol<number> }
|
|
296
|
-
: F extends { type: "
|
|
307
|
+
: F extends { type: "decimal" }
|
|
297
308
|
? F extends { required: true }
|
|
298
|
-
? { readonly [P in K]: Col<number> }
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
: F extends { type: "
|
|
311
|
-
?
|
|
312
|
-
{ readonly [P in K]: Col<
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
: F extends { type: "
|
|
309
|
+
? { readonly [P in K]: Col<number> }
|
|
310
|
+
: { readonly [P in K]: NullCol<number> }
|
|
311
|
+
: F extends { type: "money" }
|
|
312
|
+
? F extends { required: true }
|
|
313
|
+
? { readonly [P in K]: Col<number> } & {
|
|
314
|
+
readonly [P in `${K}Currency`]: Col<string>;
|
|
315
|
+
}
|
|
316
|
+
: { readonly [P in K]: NullCol<number> } & {
|
|
317
|
+
readonly [P in `${K}Currency`]: Col<string>;
|
|
318
|
+
}
|
|
319
|
+
: F extends { type: "reference"; multiple: true }
|
|
320
|
+
? { readonly [P in K]: Col<readonly string[]> }
|
|
321
|
+
: F extends { type: "reference" }
|
|
322
|
+
? F extends { required: true }
|
|
323
|
+
? { readonly [P in K]: Col<string> }
|
|
324
|
+
: { readonly [P in K]: NullCol<string> }
|
|
325
|
+
: F extends { type: "embedded" }
|
|
326
|
+
? // jsonb default `{}`, immer notNull
|
|
327
|
+
{ readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
|
|
328
|
+
: F extends { type: "date" | "timestamp" }
|
|
318
329
|
? F extends { required: true }
|
|
319
|
-
? { readonly [P in
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
: { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
|
|
323
|
-
readonly [P in `${K}Tz`]: NullCol<string>;
|
|
324
|
-
}
|
|
325
|
-
: F extends { type: "file" | "image" }
|
|
330
|
+
? { readonly [P in K]: Col<Temporal.Instant> }
|
|
331
|
+
: { readonly [P in K]: NullCol<Temporal.Instant> }
|
|
332
|
+
: F extends { type: "locatedTimestamp" }
|
|
326
333
|
? F extends { required: true }
|
|
327
|
-
? { readonly [P in K]: Col<
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
334
|
+
? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
|
|
335
|
+
readonly [P in `${K}Tz`]: Col<string>;
|
|
336
|
+
}
|
|
337
|
+
: { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
|
|
338
|
+
readonly [P in `${K}Tz`]: NullCol<string>;
|
|
339
|
+
}
|
|
340
|
+
: F extends { type: "file" | "image" }
|
|
341
|
+
? F extends { required: true }
|
|
342
|
+
? { readonly [P in K]: Col<string> }
|
|
343
|
+
: { readonly [P in K]: NullCol<string> }
|
|
344
|
+
: F extends { type: "files" | "images" }
|
|
345
|
+
? Record<never, never>
|
|
346
|
+
: never;
|
|
332
347
|
|
|
333
348
|
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
334
349
|
k: infer I,
|
|
@@ -526,6 +541,18 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
526
541
|
).on(column);
|
|
527
542
|
}
|
|
528
543
|
}
|
|
544
|
+
// lookupable-Felder: Index auf der bidx-Spalte (lock-step mit
|
|
545
|
+
// buildEntityTableMeta).
|
|
546
|
+
const bidxFieldByField = new Map<string, string>();
|
|
547
|
+
for (const [name, field] of Object.entries(entity.fields)) {
|
|
548
|
+
if (field.type !== "text" || field.lookupable !== true) continue;
|
|
549
|
+
bidxFieldByField.set(name, `${name}Bidx`);
|
|
550
|
+
const column = tHandle[`${name}Bidx`];
|
|
551
|
+
if (column) {
|
|
552
|
+
const idxName = `${tableName}_${toSnakeCase(name)}_bidx_idx`;
|
|
553
|
+
indexes[idxName] = index(idxName).on(column);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
529
556
|
// entity.indexes = composite/unique-Indices die der Author explizit
|
|
530
557
|
// deklariert hat. Spalten werden via field-name (camelCase) angesprochen,
|
|
531
558
|
// der Index-Name folgt der Convention <table>_<col1>_<col2>_<unique|idx>
|
|
@@ -546,6 +573,26 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
546
573
|
chain = chain.where(def.where as SqlExpression);
|
|
547
574
|
}
|
|
548
575
|
indexes[indexName] = chain;
|
|
576
|
+
// Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
|
|
577
|
+
// (lock-step mit buildEntityTableMeta).
|
|
578
|
+
if (def.unique === true && def.where === undefined) {
|
|
579
|
+
const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
|
|
580
|
+
if (bidxFieldNames.some((c, i) => c !== def.columns[i])) {
|
|
581
|
+
const bidxCols = bidxFieldNames
|
|
582
|
+
.map((c) => tHandle[c])
|
|
583
|
+
.filter((col): col is ColumnHandle => col !== undefined);
|
|
584
|
+
if (bidxCols.length === bidxFieldNames.length) {
|
|
585
|
+
const whereText = bidxFieldNames
|
|
586
|
+
.filter((c, i) => c !== def.columns[i])
|
|
587
|
+
.map((c) => `"${toSnakeCase(c)}" IS NOT NULL`)
|
|
588
|
+
.join(" AND ");
|
|
589
|
+
const partialWhere: SqlExpression = { kind: "sql-expr", text: whereText, params: [] };
|
|
590
|
+
indexes[`${indexName}_bidx`] = uniqueIndex(`${indexName}_bidx`)
|
|
591
|
+
.on(...bidxCols)
|
|
592
|
+
.where(partialWhere);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
549
596
|
}
|
|
550
597
|
return indexes;
|
|
551
598
|
},
|
|
@@ -133,19 +133,36 @@ describe("validateBoot — PII annotations", () => {
|
|
|
133
133
|
);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test("userOwned.ownerField
|
|
136
|
+
test("userOwned.ownerField on a text field passes (ES userId-by-convention)", () => {
|
|
137
137
|
const feature = defineFeature("test", (r) => {
|
|
138
138
|
r.entity(
|
|
139
139
|
"comment",
|
|
140
140
|
createEntity({
|
|
141
141
|
fields: {
|
|
142
|
-
body: createLongTextField({ userOwned: { ownerField: "
|
|
143
|
-
|
|
142
|
+
body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
|
|
143
|
+
authorId: createTextField(),
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("userOwned.ownerField pointing to a non-id-capable field throws", () => {
|
|
152
|
+
const feature = defineFeature("test", (r) => {
|
|
153
|
+
r.entity(
|
|
154
|
+
"comment",
|
|
155
|
+
createEntity({
|
|
156
|
+
fields: {
|
|
157
|
+
body: createLongTextField({ userOwned: { ownerField: "isPublic" } }),
|
|
158
|
+
isPublic: { type: "boolean" },
|
|
144
159
|
},
|
|
145
160
|
}),
|
|
146
161
|
);
|
|
147
162
|
});
|
|
148
|
-
expect(() => validateBoot([feature])).toThrow(
|
|
163
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
164
|
+
/must be a reference or text \(userId\) field, got type "boolean"/,
|
|
165
|
+
);
|
|
149
166
|
});
|
|
150
167
|
|
|
151
168
|
test("userOwned.ownerField referencing non-user entity warns", () => {
|
|
@@ -568,3 +585,77 @@ describe("validateBoot — retention", () => {
|
|
|
568
585
|
expect(matchingWarn).toBeUndefined();
|
|
569
586
|
});
|
|
570
587
|
});
|
|
588
|
+
|
|
589
|
+
describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
590
|
+
test("lookupable on subject-annotated text field passes", () => {
|
|
591
|
+
const feature = defineFeature("test", (r) => {
|
|
592
|
+
r.entity(
|
|
593
|
+
"user",
|
|
594
|
+
createEntity({
|
|
595
|
+
fields: {
|
|
596
|
+
email: createTextField({ pii: true, lookupable: true }),
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("lookupable without subject annotation throws", () => {
|
|
605
|
+
const feature = defineFeature("test", (r) => {
|
|
606
|
+
r.entity(
|
|
607
|
+
"doc",
|
|
608
|
+
createEntity({
|
|
609
|
+
fields: {
|
|
610
|
+
slug: createTextField({ lookupable: true }),
|
|
611
|
+
},
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
});
|
|
615
|
+
expect(() => validateBoot([feature])).toThrow(/lookupable.*without a subject annotation/);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("lookupable on non-text field throws", () => {
|
|
619
|
+
const feature = defineFeature("test", (r) => {
|
|
620
|
+
r.entity(
|
|
621
|
+
"person",
|
|
622
|
+
createEntity({
|
|
623
|
+
fields: {
|
|
624
|
+
// longText carries PiiAnnotations too — lookupable must still be rejected.
|
|
625
|
+
bio: createLongTextField({ userOwned: { ownerField: "ownerId" }, lookupable: true }),
|
|
626
|
+
ownerId: createTextField({ required: true }),
|
|
627
|
+
},
|
|
628
|
+
}),
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
expect(() => validateBoot([feature])).toThrow(/only apply to text fields/);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("searchable combined with a subject annotation throws", () => {
|
|
635
|
+
const feature = defineFeature("test", (r) => {
|
|
636
|
+
r.entity(
|
|
637
|
+
"user",
|
|
638
|
+
createEntity({
|
|
639
|
+
fields: {
|
|
640
|
+
displayName: createTextField({ pii: true, searchable: true }),
|
|
641
|
+
},
|
|
642
|
+
}),
|
|
643
|
+
);
|
|
644
|
+
});
|
|
645
|
+
expect(() => validateBoot([feature])).toThrow(/searchable.*cannot work/);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("sortable combined with a subject annotation throws", () => {
|
|
649
|
+
const feature = defineFeature("test", (r) => {
|
|
650
|
+
r.entity(
|
|
651
|
+
"user",
|
|
652
|
+
createEntity({
|
|
653
|
+
fields: {
|
|
654
|
+
email: createTextField({ pii: true, sortable: true }),
|
|
655
|
+
},
|
|
656
|
+
}),
|
|
657
|
+
);
|
|
658
|
+
});
|
|
659
|
+
expect(() => validateBoot([feature])).toThrow(/sortable.*cannot work/);
|
|
660
|
+
});
|
|
661
|
+
});
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
// drizzle migrate-runner). See define-feature.ts / DX-4.
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildEntityTableMeta,
|
|
8
|
+
defineUnmanagedTable,
|
|
9
|
+
resolveTableName,
|
|
10
|
+
} from "../../db/entity-table-meta";
|
|
7
11
|
import { defineFeature } from "../define-feature";
|
|
8
12
|
import { createEntity, createTextField } from "../index";
|
|
9
13
|
import { createRegistry } from "../registry";
|
|
@@ -125,3 +129,44 @@ describe("createRegistry — unmanagedTable aggregation", () => {
|
|
|
125
129
|
);
|
|
126
130
|
});
|
|
127
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("createRegistry — unmanaged tables with PII-annotated fields (#820)", () => {
|
|
134
|
+
const piiEntity = createEntity({
|
|
135
|
+
table: "ut_pii_probe",
|
|
136
|
+
fields: {
|
|
137
|
+
userId: createTextField({ required: true }),
|
|
138
|
+
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const piiMeta = buildEntityTableMeta("ut-pii-probe", piiEntity);
|
|
142
|
+
|
|
143
|
+
test("buildEntityTableMeta records the subject-annotated field names", () => {
|
|
144
|
+
expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("rejects a PII-carrying unmanaged table without piiEncryptedOnWrite", () => {
|
|
148
|
+
const feat = defineFeature("probe", (r) => {
|
|
149
|
+
r.unmanagedTable(piiMeta, { reason: "direct-write store" });
|
|
150
|
+
});
|
|
151
|
+
expect(() => createRegistry([feat])).toThrow(
|
|
152
|
+
/has PII-annotated fields \(ip\) but direct writes bypass the executor's PII encryption/,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("accepts it once the feature declares piiEncryptedOnWrite", () => {
|
|
157
|
+
const feat = defineFeature("probe", (r) => {
|
|
158
|
+
r.unmanagedTable(piiMeta, {
|
|
159
|
+
reason: "direct-write store",
|
|
160
|
+
piiEncryptedOnWrite: true,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
expect(() => createRegistry([feat])).not.toThrow();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a PII-free unmanaged table needs no declaration", () => {
|
|
167
|
+
const feat = defineFeature("probe", (r) => {
|
|
168
|
+
r.unmanagedTable(probeMeta, { reason: "plain store" });
|
|
169
|
+
});
|
|
170
|
+
expect(() => createRegistry([feat])).not.toThrow();
|
|
171
|
+
});
|
|
172
|
+
});
|