@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.
Files changed (38) hide show
  1. package/package.json +6 -2
  2. package/src/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
  3. package/src/api/pii-leak-guard.ts +45 -0
  4. package/src/api/server.ts +5 -0
  5. package/src/bun-db/query.ts +27 -2
  6. package/src/crypto/__tests__/blind-index.test.ts +130 -0
  7. package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
  8. package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
  9. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +117 -0
  10. package/src/crypto/__tests__/pii-field-encryption.test.ts +216 -0
  11. package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
  12. package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
  13. package/src/crypto/blind-index.ts +114 -0
  14. package/src/crypto/in-memory-kms-adapter.ts +50 -0
  15. package/src/crypto/index.ts +55 -0
  16. package/src/crypto/kms-adapter.ts +118 -0
  17. package/src/crypto/pg-kms-adapter.ts +173 -0
  18. package/src/crypto/pii-field-encryption.ts +181 -0
  19. package/src/crypto/request-kms-cache.ts +38 -0
  20. package/src/crypto/subject-resolver.ts +91 -0
  21. package/src/db/__tests__/blind-index.integration.test.ts +232 -0
  22. package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
  23. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
  24. package/src/db/apply-entity-event.ts +8 -0
  25. package/src/db/blind-index-cleanup.ts +53 -0
  26. package/src/db/entity-table-meta.ts +47 -2
  27. package/src/db/event-store-executor.ts +125 -30
  28. package/src/db/index.ts +1 -0
  29. package/src/db/table-builder.ts +98 -51
  30. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
  31. package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
  32. package/src/engine/boot-validator/pii-retention.ts +45 -9
  33. package/src/engine/define-feature.ts +3 -1
  34. package/src/engine/registry.ts +9 -0
  35. package/src/engine/types/feature.ts +10 -1
  36. package/src/engine/types/fields.ts +6 -0
  37. package/src/engine/types/handlers.ts +4 -0
  38. 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
- onlyKeys?: Iterable<string>,
350
+ user: SessionUser,
351
+ opts?: { onlyKeys?: Iterable<string>; subjectSource?: Record<string, unknown> },
322
352
  ): Promise<Record<string, unknown>> {
323
- if (!hasEncryptedFields) return row;
324
- return encryptEntityFieldValues(row, encryptedFields, fieldCipher(), {
325
- ...(onlyKeys !== undefined && { onlyKeys }),
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
- if (!hasEncryptedFields) return row;
331
- return decryptEntityFieldValues(row, encryptedFields, fieldCipher());
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
- const flatData = await encryptForStorage(flattenCompoundTypes(data, entity));
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
- const flatChanges = await encryptForStorage(
654
- flattenCompoundTypes(payload.changes, entity),
655
- Object.keys(payload.changes),
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: { kind: "delete", id: payload.id, data: existing, entityName, event },
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
- payload: { previous: stripSensitive(existing) },
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: { kind: "delete", id: payload.id, data: existing, entityName, event },
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: await decryptForRead(data),
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
- params.push(value);
1071
- whereSql.push(`${colSql(field)} = $${params.length}`);
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 rows = await Promise.all(
1092
- rawRows.map(async (r) =>
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
- rows.map((r) => ({ id: r["id"] as EntityId, data: r })), // @cast-boundary engine-payload
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";
@@ -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
- return { [name]: field.required ? withDefault.notNull() : withDefault };
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" | "select" | "tz";
281
+ type: "text";
282
+ lookupable: true;
276
283
  }
277
- ? F extends { required: true }
278
- ? { readonly [P in K]: Col<string> }
279
- : { readonly [P in K]: NullCol<string> }
280
- : F extends { type: "boolean" }
281
- ? // boolean default OR required → notNull (DB has DEFAULT, structurally never-null)
282
- F extends { default: boolean } | { required: true }
283
- ? { readonly [P in K]: Col<boolean> }
284
- : { readonly [P in K]: NullCol<boolean> }
285
- : F extends { type: "multiSelect" }
286
- ? // jsonb default `[]`, immer notNull
287
- { readonly [P in K]: Col<readonly string[]> }
288
- : F extends { type: "number" }
289
- ? F extends { required: true }
290
- ? { readonly [P in K]: Col<number> }
291
- : { readonly [P in K]: NullCol<number> }
292
- : F extends { type: "decimal" }
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: "money" }
307
+ : F extends { type: "decimal" }
297
308
  ? F extends { required: true }
298
- ? { readonly [P in K]: Col<number> } & {
299
- readonly [P in `${K}Currency`]: Col<string>;
300
- }
301
- : { readonly [P in K]: NullCol<number> } & {
302
- readonly [P in `${K}Currency`]: Col<string>;
303
- }
304
- : F extends { type: "reference"; multiple: true }
305
- ? { readonly [P in K]: Col<readonly string[]> }
306
- : F extends { type: "reference" }
307
- ? F extends { required: true }
308
- ? { readonly [P in K]: Col<string> }
309
- : { readonly [P in K]: NullCol<string> }
310
- : F extends { type: "embedded" }
311
- ? // jsonb default `{}`, immer notNull
312
- { readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
313
- : F extends { type: "date" | "timestamp" }
314
- ? F extends { required: true }
315
- ? { readonly [P in K]: Col<Temporal.Instant> }
316
- : { readonly [P in K]: NullCol<Temporal.Instant> }
317
- : F extends { type: "locatedTimestamp" }
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 `${K}Utc`]: Col<Temporal.Instant> } & {
320
- readonly [P in `${K}Tz`]: Col<string>;
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<string> }
328
- : { readonly [P in K]: NullCol<string> }
329
- : F extends { type: "files" | "images" }
330
- ? Record<never, never>
331
- : never;
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 pointing to non-reference field throws", () => {
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: "authorName" } }),
143
- authorName: createTextField(),
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(/must be a reference field, got type "text"/);
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 { defineUnmanagedTable, resolveTableName } from "../../db/entity-table-meta";
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
+ });