@forklaunch/core 2.1.1 → 3.0.1
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/bin/gateway-mock/routes/email.mjs +233 -0
- package/bin/gateway-mock/routes/models.mjs +192 -0
- package/bin/gateway-mock/routes/payments.mjs +541 -0
- package/bin/gateway-mock/routes/sms.mjs +209 -0
- package/bin/gateway-mock/routes/voice.mjs +237 -0
- package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
- package/bin/gateway-mock/server.mjs +321 -0
- package/bin/migrate-compliant-fields.mjs +312 -0
- package/bin/model-gateway-mock.mjs +13 -0
- package/lib/http/emailClient.d.ts +102 -0
- package/lib/http/emailClient.d.ts.map +1 -0
- package/lib/http/index.d.ts +7 -0
- package/lib/http/index.d.ts.map +1 -1
- package/lib/http/index.js +850 -4
- package/lib/http/index.js.map +1 -1
- package/lib/http/index.mjs +809 -1
- package/lib/http/index.mjs.map +1 -1
- package/lib/http/instanceGateway.d.ts +113 -0
- package/lib/http/instanceGateway.d.ts.map +1 -0
- package/lib/http/modelGatewayClient.d.ts +126 -0
- package/lib/http/modelGatewayClient.d.ts.map +1 -0
- package/lib/http/paymentsClient.d.ts +123 -0
- package/lib/http/paymentsClient.d.ts.map +1 -0
- package/lib/http/smsClient.d.ts +123 -0
- package/lib/http/smsClient.d.ts.map +1 -0
- package/lib/http/voiceClient.d.ts +105 -0
- package/lib/http/voiceClient.d.ts.map +1 -0
- package/lib/http/whatsappClient.d.ts +118 -0
- package/lib/http/whatsappClient.d.ts.map +1 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
- package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
- package/lib/persistence/complianceTypes.d.ts +10 -1
- package/lib/persistence/complianceTypes.d.ts.map +1 -1
- package/lib/persistence/compliantField.d.ts +203 -0
- package/lib/persistence/compliantField.d.ts.map +1 -0
- package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
- package/lib/persistence/encryptedType.d.ts +8 -1
- package/lib/persistence/encryptedType.d.ts.map +1 -1
- package/lib/persistence/fieldEncryptor.d.ts +47 -6
- package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
- package/lib/persistence/index.d.ts +3 -2
- package/lib/persistence/index.d.ts.map +1 -1
- package/lib/persistence/index.js +987 -259
- package/lib/persistence/index.js.map +1 -1
- package/lib/persistence/index.mjs +976 -257
- package/lib/persistence/index.mjs.map +1 -1
- package/lib/persistence/keyRotation.d.ts +5 -0
- package/lib/persistence/keyRotation.d.ts.map +1 -1
- package/lib/services/index.js.map +1 -1
- package/lib/services/index.mjs.map +1 -1
- package/package.json +27 -11
package/lib/persistence/index.js
CHANGED
|
@@ -30,7 +30,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/persistence/index.ts
|
|
31
31
|
var persistence_exports = {};
|
|
32
32
|
__export(persistence_exports, {
|
|
33
|
+
Anon: () => Anon,
|
|
34
|
+
BLIND_INDEX_PREFIX: () => BLIND_INDEX_PREFIX,
|
|
33
35
|
ComplianceLevel: () => ComplianceLevel,
|
|
36
|
+
CompliantField: () => CompliantField,
|
|
37
|
+
CompliantIndexType: () => CompliantIndexType,
|
|
38
|
+
CompliantQueryError: () => CompliantQueryError,
|
|
39
|
+
CompliantType: () => CompliantType,
|
|
34
40
|
DecryptionError: () => DecryptionError,
|
|
35
41
|
ENCRYPTED_PREFIXES: () => ENCRYPTED_PREFIXES2,
|
|
36
42
|
ENCRYPTION_FORMAT_ENV: () => ENCRYPTION_FORMAT_ENV,
|
|
@@ -41,6 +47,7 @@ __export(persistence_exports, {
|
|
|
41
47
|
FieldEncryptor: () => FieldEncryptor,
|
|
42
48
|
LEGACY_ENCRYPTION_KEYS_ENV: () => LEGACY_ENCRYPTION_KEYS_ENV,
|
|
43
49
|
LEGACY_ENCRYPTION_KEY_ENV: () => LEGACY_ENCRYPTION_KEY_ENV,
|
|
50
|
+
LegacyCiphertextError: () => LegacyCiphertextError,
|
|
44
51
|
MissingEncryptionKeyError: () => MissingEncryptionKeyError,
|
|
45
52
|
RetentionAction: () => RetentionAction,
|
|
46
53
|
RetentionDuration: () => RetentionDuration,
|
|
@@ -53,6 +60,7 @@ __export(persistence_exports, {
|
|
|
53
60
|
collectFallbackTenantIds: () => collectFallbackTenantIds,
|
|
54
61
|
countValuesByKeyId: () => countValuesByKeyId,
|
|
55
62
|
createTenantFilterDef: () => createTenantFilterDef,
|
|
63
|
+
deanon: () => deanon,
|
|
56
64
|
defaultTenantIdsFor: () => defaultTenantIdsFor,
|
|
57
65
|
defineComplianceEntity: () => defineComplianceEntity,
|
|
58
66
|
encryptionKeyId: () => encryptionKeyId,
|
|
@@ -68,13 +76,17 @@ __export(persistence_exports, {
|
|
|
68
76
|
getEntityRetention: () => getEntityRetention,
|
|
69
77
|
getEntityUserIdField: () => getEntityUserIdField,
|
|
70
78
|
getSuperAdminContext: () => getSuperAdminContext,
|
|
79
|
+
isAnon: () => isAnon,
|
|
80
|
+
isCompliantField: () => isCompliantField,
|
|
71
81
|
isEncryptedCiphertext: () => isEncryptedCiphertext,
|
|
72
82
|
isEncryptedValue: () => isEncryptedValue,
|
|
83
|
+
onComplianceAccess: () => onComplianceAccess,
|
|
73
84
|
parseDuration: () => parseDuration,
|
|
74
85
|
parseEncryptionKeyList: () => parseEncryptionKeyList,
|
|
75
86
|
reencryptEncryptedColumns: () => reencryptEncryptedColumns,
|
|
76
87
|
registerEncryptor: () => registerEncryptor,
|
|
77
88
|
rotationTotals: () => rotationTotals,
|
|
89
|
+
sealedAnon: () => sealedAnon,
|
|
78
90
|
setEncryptionTenantId: () => setEncryptionTenantId,
|
|
79
91
|
setupRls: () => setupRls,
|
|
80
92
|
setupTenantFilter: () => setupTenantFilter,
|
|
@@ -249,8 +261,8 @@ function asEncryptionSafe(em) {
|
|
|
249
261
|
return em;
|
|
250
262
|
}
|
|
251
263
|
|
|
252
|
-
// src/persistence/
|
|
253
|
-
var
|
|
264
|
+
// src/persistence/compliantField.ts
|
|
265
|
+
var import_crypto2 = __toESM(require("crypto"));
|
|
254
266
|
var import_core2 = require("@mikro-orm/core");
|
|
255
267
|
|
|
256
268
|
// src/persistence/encryptedType.ts
|
|
@@ -265,6 +277,9 @@ var _tenantContext = new import_node_async_hooks.AsyncLocalStorage();
|
|
|
265
277
|
function registerEncryptor(encryptor) {
|
|
266
278
|
_encryptor = encryptor;
|
|
267
279
|
}
|
|
280
|
+
function getRegisteredEncryptor() {
|
|
281
|
+
return _encryptor;
|
|
282
|
+
}
|
|
268
283
|
var EmptyTenantError = class extends Error {
|
|
269
284
|
constructor(api) {
|
|
270
285
|
super(
|
|
@@ -420,252 +435,55 @@ var EncryptedType = class extends import_core.Type {
|
|
|
420
435
|
// Serialization helpers
|
|
421
436
|
// ---------------------------------------------------------------------------
|
|
422
437
|
serialize(value) {
|
|
423
|
-
|
|
424
|
-
if (value instanceof Date) return value.toISOString();
|
|
425
|
-
if (typeof value === "bigint") return value.toString();
|
|
426
|
-
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
427
|
-
return Buffer.from(value).toString("base64");
|
|
428
|
-
return JSON.stringify(value);
|
|
438
|
+
return serializeForEncryption(value);
|
|
429
439
|
}
|
|
430
440
|
deserialize(value) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
(el) => hydrateValue(el, this._elementRuntimeType)
|
|
437
|
-
);
|
|
438
|
-
} catch {
|
|
439
|
-
return value;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
switch (this._elementRuntimeType) {
|
|
443
|
-
case "string":
|
|
444
|
-
return value;
|
|
445
|
-
case "any":
|
|
446
|
-
try {
|
|
447
|
-
return JSON.parse(value);
|
|
448
|
-
} catch {
|
|
449
|
-
return value;
|
|
450
|
-
}
|
|
451
|
-
// Types serialized via toString/toISOString/base64 — hydrate from string directly
|
|
452
|
-
case "bigint":
|
|
453
|
-
case "Date":
|
|
454
|
-
case "Buffer":
|
|
455
|
-
return hydrateValue(value, this._elementRuntimeType);
|
|
456
|
-
// Types where JSON.parse recovers the native JS value (number, boolean)
|
|
457
|
-
default:
|
|
458
|
-
return hydrateValue(this.tryJsonParse(value), this._elementRuntimeType);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
tryJsonParse(value) {
|
|
462
|
-
try {
|
|
463
|
-
return JSON.parse(value);
|
|
464
|
-
} catch {
|
|
465
|
-
return value;
|
|
466
|
-
}
|
|
441
|
+
return deserializeFromEncryption(
|
|
442
|
+
value,
|
|
443
|
+
this._elementRuntimeType,
|
|
444
|
+
this._isArray
|
|
445
|
+
);
|
|
467
446
|
}
|
|
468
447
|
};
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
return
|
|
476
|
-
get(target, prop) {
|
|
477
|
-
if (prop === "compliance") {
|
|
478
|
-
return (level) => wrapClassified(target, level);
|
|
479
|
-
}
|
|
480
|
-
if (prop === "~options") return Reflect.get(target, prop, target);
|
|
481
|
-
if (prop === COMPLIANCE_KEY) return void 0;
|
|
482
|
-
const value = Reflect.get(target, prop, target);
|
|
483
|
-
if (typeof value === "function") {
|
|
484
|
-
return (...args) => {
|
|
485
|
-
const result = value.apply(
|
|
486
|
-
target,
|
|
487
|
-
args
|
|
488
|
-
);
|
|
489
|
-
return isBuilder(result) ? wrapUnclassified(result) : result;
|
|
490
|
-
};
|
|
491
|
-
}
|
|
492
|
-
return value;
|
|
493
|
-
}
|
|
494
|
-
});
|
|
448
|
+
function serializeForEncryption(value) {
|
|
449
|
+
if (typeof value === "string") return value;
|
|
450
|
+
if (value instanceof Date) return value.toISOString();
|
|
451
|
+
if (typeof value === "bigint") return value.toString();
|
|
452
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
453
|
+
return Buffer.from(value).toString("base64");
|
|
454
|
+
return JSON.stringify(value);
|
|
495
455
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
function resolveEncryptedTypeArgs(options) {
|
|
502
|
-
const type = options.type;
|
|
503
|
-
const isArray = options.array === true;
|
|
504
|
-
const resolved = resolveTypeInstance(type);
|
|
505
|
-
if (resolved) {
|
|
506
|
-
const rt = resolved.runtimeType;
|
|
507
|
-
if (rt.endsWith("[]")) {
|
|
508
|
-
return { elementRuntimeType: rt.slice(0, -2) || "string", isArray: true };
|
|
509
|
-
}
|
|
510
|
-
return { elementRuntimeType: rt, isArray };
|
|
456
|
+
function tryJsonParse(value) {
|
|
457
|
+
try {
|
|
458
|
+
return JSON.parse(value);
|
|
459
|
+
} catch {
|
|
460
|
+
return value;
|
|
511
461
|
}
|
|
512
|
-
return { elementRuntimeType: "string", isArray };
|
|
513
462
|
}
|
|
514
|
-
function
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
enumValues = extractEnumValues(options.items);
|
|
522
|
-
delete options.items;
|
|
523
|
-
delete options.nativeEnumName;
|
|
524
|
-
delete options.enum;
|
|
525
|
-
}
|
|
526
|
-
options.type = new EncryptedType(elementRuntimeType, isArray, enumValues);
|
|
527
|
-
options.columnType = "text";
|
|
528
|
-
options.runtimeType = isArray ? "object" : elementRuntimeType;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
return new Proxy(builder, {
|
|
532
|
-
get(target, prop) {
|
|
533
|
-
if (prop === COMPLIANCE_KEY) return level;
|
|
534
|
-
if (prop === "~options") return Reflect.get(target, prop, target);
|
|
535
|
-
const value = Reflect.get(target, prop, target);
|
|
536
|
-
if (typeof value === "function") {
|
|
537
|
-
return (...args) => {
|
|
538
|
-
const result = value.apply(
|
|
539
|
-
target,
|
|
540
|
-
args
|
|
541
|
-
);
|
|
542
|
-
return isBuilder(result) ? wrapClassified(result, level) : result;
|
|
543
|
-
};
|
|
544
|
-
}
|
|
463
|
+
function deserializeFromEncryption(value, elementRuntimeType, isArray) {
|
|
464
|
+
if (isArray) {
|
|
465
|
+
try {
|
|
466
|
+
const arr = JSON.parse(value);
|
|
467
|
+
if (!Array.isArray(arr)) return arr;
|
|
468
|
+
return arr.map((el) => hydrateValue(el, elementRuntimeType));
|
|
469
|
+
} catch {
|
|
545
470
|
return value;
|
|
546
471
|
}
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
function wrapRelation(builder) {
|
|
550
|
-
return wrapClassified(builder, "none");
|
|
551
|
-
}
|
|
552
|
-
var RELATION_METHODS = /* @__PURE__ */ new Set([
|
|
553
|
-
"manyToOne",
|
|
554
|
-
"oneToMany",
|
|
555
|
-
"manyToMany",
|
|
556
|
-
"oneToOne",
|
|
557
|
-
"embedded"
|
|
558
|
-
]);
|
|
559
|
-
function isRelationMethod(prop) {
|
|
560
|
-
return typeof prop === "string" && RELATION_METHODS.has(prop);
|
|
561
|
-
}
|
|
562
|
-
function extractEnumValues(items) {
|
|
563
|
-
if (typeof items === "function") {
|
|
564
|
-
return extractEnumValues(items());
|
|
565
|
-
}
|
|
566
|
-
if (Array.isArray(items)) return items;
|
|
567
|
-
if (items != null && typeof items === "object") {
|
|
568
|
-
const obj = items;
|
|
569
|
-
const allValues = Object.values(obj);
|
|
570
|
-
return allValues.filter((v) => typeof v !== "string" || !(v in obj));
|
|
571
|
-
}
|
|
572
|
-
return [];
|
|
573
|
-
}
|
|
574
|
-
var EnumTextType = class extends import_core2.Type {
|
|
575
|
-
convertToDatabaseValue(value) {
|
|
576
|
-
return (0, import_common.safeStringify)(value);
|
|
577
|
-
}
|
|
578
|
-
convertToJSValue(value) {
|
|
579
|
-
return (0, import_common.safeParse)(value);
|
|
580
|
-
}
|
|
581
|
-
getColumnType() {
|
|
582
|
-
return "text";
|
|
583
|
-
}
|
|
584
|
-
get runtimeType() {
|
|
585
|
-
return "string";
|
|
586
|
-
}
|
|
587
|
-
};
|
|
588
|
-
function inferEnumType(items) {
|
|
589
|
-
const values = extractEnumValues(items);
|
|
590
|
-
if (values.length === 0) return "text";
|
|
591
|
-
const allStrings = values.every((v) => typeof v === "string");
|
|
592
|
-
if (allStrings) return "text";
|
|
593
|
-
const allNumbers = values.every((v) => typeof v === "number");
|
|
594
|
-
if (allNumbers) return "integer";
|
|
595
|
-
return new EnumTextType();
|
|
596
|
-
}
|
|
597
|
-
var fp = new Proxy(import_core2.p, {
|
|
598
|
-
get(target, prop) {
|
|
599
|
-
const value = Reflect.get(target, prop, target);
|
|
600
|
-
if (typeof value !== "function") return value;
|
|
601
|
-
if (isRelationMethod(prop)) {
|
|
602
|
-
return (...args) => {
|
|
603
|
-
const result = value.apply(
|
|
604
|
-
target,
|
|
605
|
-
args
|
|
606
|
-
);
|
|
607
|
-
return isBuilder(result) ? wrapRelation(result) : result;
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
return (...args) => {
|
|
611
|
-
const result = value.apply(
|
|
612
|
-
target,
|
|
613
|
-
args
|
|
614
|
-
);
|
|
615
|
-
if (isBuilder(result)) {
|
|
616
|
-
if (prop === "enum") {
|
|
617
|
-
const options = result["~options"];
|
|
618
|
-
if (options && !options.type) {
|
|
619
|
-
options.type = inferEnumType(options.items);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
return wrapUnclassified(result);
|
|
623
|
-
}
|
|
624
|
-
return result;
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
// src/persistence/defineComplianceEntity.ts
|
|
630
|
-
var import_core3 = require("@mikro-orm/core");
|
|
631
|
-
function readComplianceLevel(builder) {
|
|
632
|
-
if (builder == null || typeof builder !== "object") return void 0;
|
|
633
|
-
return builder[COMPLIANCE_KEY];
|
|
634
|
-
}
|
|
635
|
-
function defineComplianceEntity(meta) {
|
|
636
|
-
const entityName = "name" in meta ? meta.name : "Unknown";
|
|
637
|
-
const complianceFields = /* @__PURE__ */ new Map();
|
|
638
|
-
const rawProperties = meta.properties;
|
|
639
|
-
const resolvedProperties = typeof rawProperties === "function" ? rawProperties(import_core3.p) : rawProperties;
|
|
640
|
-
for (const [fieldName, rawProp] of Object.entries(resolvedProperties)) {
|
|
641
|
-
if (typeof rawProp === "function") {
|
|
642
|
-
complianceFields.set(fieldName, "none");
|
|
643
|
-
continue;
|
|
644
|
-
}
|
|
645
|
-
const level = readComplianceLevel(rawProp);
|
|
646
|
-
if (level == null) {
|
|
647
|
-
throw new Error(
|
|
648
|
-
`Field '${entityName}.${fieldName}' is missing compliance classification. Call .compliance('pii' | 'phi' | 'pci' | 'none') on this property, or use a relation method (fp.manyToOne, etc.) which is auto-classified.`
|
|
649
|
-
);
|
|
650
|
-
}
|
|
651
|
-
complianceFields.set(fieldName, level);
|
|
652
|
-
}
|
|
653
|
-
registerEntityCompliance(entityName, complianceFields);
|
|
654
|
-
if (meta.retention) {
|
|
655
|
-
parseDuration(meta.retention.duration);
|
|
656
|
-
if (!resolvedProperties["createdAt"]) {
|
|
657
|
-
throw new Error(
|
|
658
|
-
`Entity '${entityName}' has a retention policy but no 'createdAt' property. Retention requires createdAt to compute expiration.`
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
registerEntityRetention(entityName, meta.retention);
|
|
662
472
|
}
|
|
663
|
-
|
|
664
|
-
|
|
473
|
+
switch (elementRuntimeType) {
|
|
474
|
+
case "string":
|
|
475
|
+
return value;
|
|
476
|
+
case "any":
|
|
477
|
+
return tryJsonParse(value);
|
|
478
|
+
// Types serialized via toString/toISOString/base64 — hydrate from string directly
|
|
479
|
+
case "bigint":
|
|
480
|
+
case "Date":
|
|
481
|
+
case "Buffer":
|
|
482
|
+
return hydrateValue(value, elementRuntimeType);
|
|
483
|
+
// Types where JSON.parse recovers the native JS value (number, boolean)
|
|
484
|
+
default:
|
|
485
|
+
return hydrateValue(tryJsonParse(value), elementRuntimeType);
|
|
665
486
|
}
|
|
666
|
-
return (0, import_core3.defineEntity)(
|
|
667
|
-
meta
|
|
668
|
-
);
|
|
669
487
|
}
|
|
670
488
|
|
|
671
489
|
// src/persistence/fieldEncryptor.ts
|
|
@@ -693,9 +511,17 @@ var IV_BYTES = 12;
|
|
|
693
511
|
var KEY_BYTES = 32;
|
|
694
512
|
var HKDF_HASH = "sha256";
|
|
695
513
|
var HKDF_SALT = Buffer.alloc(0);
|
|
514
|
+
var BLIND_INDEX_INFO = "forklaunch:blind-index\0";
|
|
696
515
|
var KEY_ID_HEX_CHARS = 12;
|
|
697
516
|
var DERIVED_KEY_CACHE_LIMIT = 4096;
|
|
698
|
-
var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:"];
|
|
517
|
+
var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:", "v4:"];
|
|
518
|
+
var BLIND_INDEX_PREFIX = "bi1:";
|
|
519
|
+
function sealedAnon(ciphertext) {
|
|
520
|
+
if (!ciphertext.startsWith("v4:")) return null;
|
|
521
|
+
const parts = ciphertext.split(":");
|
|
522
|
+
if (parts.length !== 6) return null;
|
|
523
|
+
return Buffer.from(parts[2], "base64url").toString("utf8");
|
|
524
|
+
}
|
|
699
525
|
function isEncryptedCiphertext(value) {
|
|
700
526
|
return typeof value === "string" && ENCRYPTED_PREFIXES2.some((p3) => value.startsWith(p3));
|
|
701
527
|
}
|
|
@@ -717,9 +543,12 @@ function encryptionKeyId(masterKey) {
|
|
|
717
543
|
return import_crypto.default.createHash("sha256").update(masterKey).digest("hex").slice(0, KEY_ID_HEX_CHARS);
|
|
718
544
|
}
|
|
719
545
|
function stampedKeyId(ciphertext) {
|
|
720
|
-
if (!ciphertext.startsWith("v3:")) return null;
|
|
721
546
|
const parts = ciphertext.split(":");
|
|
722
|
-
return parts.length === 5 ? parts[1] : null;
|
|
547
|
+
if (ciphertext.startsWith("v3:")) return parts.length === 5 ? parts[1] : null;
|
|
548
|
+
if (ciphertext.startsWith("v4:")) return parts.length === 6 ? parts[1] : null;
|
|
549
|
+
if (ciphertext.startsWith(BLIND_INDEX_PREFIX))
|
|
550
|
+
return parts.length === 3 ? parts[1] : null;
|
|
551
|
+
return null;
|
|
723
552
|
}
|
|
724
553
|
function stampedPrefix(keyId) {
|
|
725
554
|
return `v3:${keyId}:`;
|
|
@@ -852,7 +681,8 @@ var FieldEncryptor = class _FieldEncryptor {
|
|
|
852
681
|
const version = parts[0];
|
|
853
682
|
let candidates;
|
|
854
683
|
let body;
|
|
855
|
-
|
|
684
|
+
let anon;
|
|
685
|
+
if (version === "v3" && parts.length === 5 || version === "v4" && parts.length === 6) {
|
|
856
686
|
const key = this.byId.get(parts[1]);
|
|
857
687
|
if (!key) {
|
|
858
688
|
throw new DecryptionError(
|
|
@@ -860,7 +690,12 @@ var FieldEncryptor = class _FieldEncryptor {
|
|
|
860
690
|
);
|
|
861
691
|
}
|
|
862
692
|
candidates = [key];
|
|
863
|
-
|
|
693
|
+
if (version === "v4") {
|
|
694
|
+
anon = Buffer.from(parts[2], "base64url").toString("utf8");
|
|
695
|
+
body = parts.slice(3);
|
|
696
|
+
} else {
|
|
697
|
+
body = parts.slice(2);
|
|
698
|
+
}
|
|
864
699
|
} else if ((version === "v1" || version === "v2") && parts.length === 4) {
|
|
865
700
|
candidates = this.ring;
|
|
866
701
|
body = parts.slice(1);
|
|
@@ -891,7 +726,8 @@ var FieldEncryptor = class _FieldEncryptor {
|
|
|
891
726
|
keyId: key.id,
|
|
892
727
|
current,
|
|
893
728
|
version,
|
|
894
|
-
|
|
729
|
+
anon,
|
|
730
|
+
stale: version === "v4" ? !current : !current || this.format === "v3" && version !== "v3"
|
|
895
731
|
};
|
|
896
732
|
} catch {
|
|
897
733
|
}
|
|
@@ -917,22 +753,776 @@ var FieldEncryptor = class _FieldEncryptor {
|
|
|
917
753
|
if (ciphertext === null || ciphertext === void 0) return null;
|
|
918
754
|
const opened = this.open(ciphertext, tenantId);
|
|
919
755
|
if (!opened.stale) return ciphertext;
|
|
756
|
+
if (opened.version === "v4") {
|
|
757
|
+
return this.seal(opened.plaintext, tenantId, opened.anon ?? "");
|
|
758
|
+
}
|
|
920
759
|
return this.encrypt(opened.plaintext, tenantId);
|
|
921
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* Encrypt for a compliant field: a fresh random IV every time (equal
|
|
763
|
+
* plaintexts produce unrelated ciphertexts), stamped with the current key,
|
|
764
|
+
* and carrying `anon` — the field's de-identified value — in clear so it
|
|
765
|
+
* can be read without decrypting.
|
|
766
|
+
*
|
|
767
|
+
* Equality lookups do not use the ciphertext; they use {@link blindIndex}.
|
|
768
|
+
*
|
|
769
|
+
* @returns `v4:{keyId}:{anon}:{iv}:{tag}:{data}`
|
|
770
|
+
*/
|
|
771
|
+
seal(plaintext, tenantId, anon) {
|
|
772
|
+
const key = this.deriveKey(tenantId);
|
|
773
|
+
const iv = import_crypto.default.randomBytes(IV_BYTES);
|
|
774
|
+
const cipher = import_crypto.default.createCipheriv(ALGORITHM, key, iv);
|
|
775
|
+
const encrypted = Buffer.concat([
|
|
776
|
+
cipher.update(plaintext, "utf8"),
|
|
777
|
+
cipher.final()
|
|
778
|
+
]);
|
|
779
|
+
return [
|
|
780
|
+
"v4",
|
|
781
|
+
this.ring[0].id,
|
|
782
|
+
Buffer.from(anon, "utf8").toString("base64url"),
|
|
783
|
+
iv.toString("base64"),
|
|
784
|
+
cipher.getAuthTag().toString("base64"),
|
|
785
|
+
encrypted.toString("base64")
|
|
786
|
+
].join(":");
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Keyed HMAC of a plaintext for equality lookups, under a key derived
|
|
790
|
+
* from the current master key and the tenant (separate from the
|
|
791
|
+
* encryption key). Deterministic per tenant and key, one-way, and never
|
|
792
|
+
* decryptable.
|
|
793
|
+
*
|
|
794
|
+
* After a master key rotation, rows still indexed under the previous key
|
|
795
|
+
* do not match until `reencryptEncryptedColumns` re-indexes them; see
|
|
796
|
+
* {@link blindIndexes} for a lookup that spans the ring.
|
|
797
|
+
*
|
|
798
|
+
* @returns `bi1:{keyId}:{hmac base64url}`
|
|
799
|
+
*/
|
|
800
|
+
blindIndex(plaintext, tenantId) {
|
|
801
|
+
return this.blindIndexFor(this.ring[0], plaintext, tenantId);
|
|
802
|
+
}
|
|
803
|
+
/** {@link blindIndex} under every key in the ring, current first. */
|
|
804
|
+
blindIndexes(plaintext, tenantId) {
|
|
805
|
+
return this.ring.map((k) => this.blindIndexFor(k, plaintext, tenantId));
|
|
806
|
+
}
|
|
807
|
+
/** True when a blind index was computed under a previous key. */
|
|
808
|
+
isStaleBlindIndex(index) {
|
|
809
|
+
return stampedKeyId(index) !== this.ring[0].id;
|
|
810
|
+
}
|
|
811
|
+
blindIndexFor(key, plaintext, tenantId) {
|
|
812
|
+
const indexKey = this.deriveKeyFor(key, BLIND_INDEX_INFO + tenantId);
|
|
813
|
+
const mac = import_crypto.default.createHmac("sha256", indexKey).update(plaintext, "utf8").digest("base64url");
|
|
814
|
+
return `${BLIND_INDEX_PREFIX}${key.id}:${mac}`;
|
|
815
|
+
}
|
|
922
816
|
};
|
|
923
817
|
|
|
924
|
-
// src/persistence/
|
|
925
|
-
var
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
818
|
+
// src/persistence/compliantField.ts
|
|
819
|
+
var LISTENERS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.listeners")] ??= /* @__PURE__ */ new Set();
|
|
820
|
+
function onComplianceAccess(listener) {
|
|
821
|
+
LISTENERS.add(listener);
|
|
822
|
+
return () => LISTENERS.delete(listener);
|
|
823
|
+
}
|
|
824
|
+
var LegacyCiphertextError = class extends Error {
|
|
825
|
+
name = "LegacyCiphertextError";
|
|
826
|
+
};
|
|
827
|
+
var CompliantQueryError = class extends Error {
|
|
828
|
+
name = "CompliantQueryError";
|
|
829
|
+
};
|
|
830
|
+
var MINT = /* @__PURE__ */ Symbol.for("forklaunch.compliance.mint");
|
|
831
|
+
var ANON_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.anon");
|
|
832
|
+
var Anon = class {
|
|
833
|
+
value;
|
|
834
|
+
/** @internal */
|
|
835
|
+
constructor(value, key) {
|
|
836
|
+
if (key !== MINT) {
|
|
837
|
+
throw new Error("Anon values can only be produced by a compliant field");
|
|
838
|
+
}
|
|
839
|
+
Object.defineProperty(this, ANON_BRAND, { value: true });
|
|
840
|
+
Object.defineProperty(this, "value", { value, enumerable: true });
|
|
841
|
+
Object.freeze(this);
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
function isAnon(value) {
|
|
845
|
+
return value != null && typeof value === "object" && value[ANON_BRAND] === true;
|
|
846
|
+
}
|
|
847
|
+
var RESTRICTED_ZIP3 = /* @__PURE__ */ new Set([
|
|
848
|
+
"036",
|
|
849
|
+
"059",
|
|
850
|
+
"063",
|
|
851
|
+
"102",
|
|
852
|
+
"203",
|
|
853
|
+
"556",
|
|
854
|
+
"692",
|
|
855
|
+
"790",
|
|
856
|
+
"821",
|
|
857
|
+
"823",
|
|
858
|
+
"830",
|
|
859
|
+
"831",
|
|
860
|
+
"878",
|
|
861
|
+
"879",
|
|
862
|
+
"884",
|
|
863
|
+
"890",
|
|
864
|
+
"893"
|
|
929
865
|
]);
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
"
|
|
934
|
-
|
|
935
|
-
|
|
866
|
+
var REDACTED = "[redacted]";
|
|
867
|
+
function computeAnon(strategy, value) {
|
|
868
|
+
switch (strategy) {
|
|
869
|
+
case "token":
|
|
870
|
+
return "tok_" + import_crypto2.default.randomBytes(9).toString("base64url");
|
|
871
|
+
case "year": {
|
|
872
|
+
if (value instanceof Date && !isNaN(value.getTime()))
|
|
873
|
+
return String(value.getUTCFullYear());
|
|
874
|
+
const match = /\d{4}/.exec(String(value));
|
|
875
|
+
return match ? match[0] : REDACTED;
|
|
876
|
+
}
|
|
877
|
+
case "zip3": {
|
|
878
|
+
const digits = String(value).replace(/\D/g, "").slice(0, 3);
|
|
879
|
+
if (digits.length < 3 || RESTRICTED_ZIP3.has(digits)) return "000";
|
|
880
|
+
return digits;
|
|
881
|
+
}
|
|
882
|
+
case "last4": {
|
|
883
|
+
const digits = String(value).replace(/\D/g, "");
|
|
884
|
+
return digits.length >= 4 ? `\u2022\u2022\u2022\u2022 ${digits.slice(-4)}` : REDACTED;
|
|
885
|
+
}
|
|
886
|
+
case "redact":
|
|
887
|
+
return REDACTED;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function defaultAnonStrategy(elementRuntimeType, isArray, hasEnum) {
|
|
891
|
+
if (isArray || hasEnum) return "redact";
|
|
892
|
+
if (elementRuntimeType === "string") return "token";
|
|
893
|
+
if (elementRuntimeType === "Date") return "year";
|
|
894
|
+
return "redact";
|
|
895
|
+
}
|
|
896
|
+
var STATE = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.state")] ??= /* @__PURE__ */ new WeakMap();
|
|
897
|
+
var FIELD_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.field");
|
|
898
|
+
function stateOf(field) {
|
|
899
|
+
const state = STATE.get(field);
|
|
900
|
+
if (!state) throw new Error("Not a compliant field");
|
|
901
|
+
return state;
|
|
902
|
+
}
|
|
903
|
+
var CompliantField = class _CompliantField {
|
|
904
|
+
constructor(state) {
|
|
905
|
+
Object.defineProperty(this, FIELD_BRAND, { value: true });
|
|
906
|
+
STATE.set(this, state);
|
|
907
|
+
Object.freeze(this);
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* @internal Wrap a plaintext value for `spec`. Applications assign plain
|
|
911
|
+
* values to the entity property instead.
|
|
912
|
+
*/
|
|
913
|
+
static fromPlain(value, spec) {
|
|
914
|
+
if (spec.enumValues) {
|
|
915
|
+
const values = spec.isArray && Array.isArray(value) ? value : [value];
|
|
916
|
+
for (const v of values) {
|
|
917
|
+
if (!spec.enumValues.includes(v)) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
`Invalid enum value${spec.path ? ` for ${spec.path}` : ""}: ${String(v)}. Allowed values: ${spec.enumValues.join(", ")}`
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
return new _CompliantField({
|
|
925
|
+
spec,
|
|
926
|
+
hasPlain: true,
|
|
927
|
+
plain: value,
|
|
928
|
+
anon: computeAnon(spec.anon, value)
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
/** @internal Wrap a stored value (any envelope, or legacy plaintext). */
|
|
932
|
+
static fromStored(stored, spec, index) {
|
|
933
|
+
if (!isEncryptedCiphertext(stored)) {
|
|
934
|
+
return new _CompliantField({
|
|
935
|
+
spec,
|
|
936
|
+
hasPlain: true,
|
|
937
|
+
plain: deserializeFromEncryption(
|
|
938
|
+
stored,
|
|
939
|
+
spec.elementRuntimeType,
|
|
940
|
+
spec.isArray
|
|
941
|
+
),
|
|
942
|
+
index
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return new _CompliantField({
|
|
946
|
+
spec,
|
|
947
|
+
hasPlain: false,
|
|
948
|
+
sealed: stored,
|
|
949
|
+
anon: sealedAnon(stored) ?? void 0,
|
|
950
|
+
index
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
/** @internal A queryable field whose ciphertext column was not selected. */
|
|
954
|
+
static fromIndexOnly(index, spec) {
|
|
955
|
+
return new _CompliantField({ spec, hasPlain: false, index });
|
|
956
|
+
}
|
|
957
|
+
/** The field's classification. */
|
|
958
|
+
get level() {
|
|
959
|
+
return stateOf(this).spec.level;
|
|
960
|
+
}
|
|
961
|
+
/** The de-identified value. Never decrypts for values in a `v4` envelope. */
|
|
962
|
+
get anon() {
|
|
963
|
+
const state = stateOf(this);
|
|
964
|
+
if (state.anon === void 0) {
|
|
965
|
+
if (state.spec.anon === "token") {
|
|
966
|
+
throw new LegacyCiphertextError(
|
|
967
|
+
`${state.spec.path} is stored in a pre-v4 envelope and has no token yet; run reencryptEncryptedColumns() once to upgrade it`
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
state.anon = computeAnon(state.spec.anon, readPlaintext(this, false));
|
|
971
|
+
}
|
|
972
|
+
return new Anon(state.anon, MINT);
|
|
973
|
+
}
|
|
974
|
+
/** The plaintext. Every read is reported to access listeners. */
|
|
975
|
+
get deanon() {
|
|
976
|
+
return readPlaintext(this, true);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
function readPlaintext(field, report) {
|
|
980
|
+
const state = stateOf(field);
|
|
981
|
+
let tenantId = "";
|
|
982
|
+
if (!state.hasPlain) {
|
|
983
|
+
tenantId = requireBoundTenantId("read");
|
|
984
|
+
if (state.sealed === void 0) {
|
|
985
|
+
throw new Error(
|
|
986
|
+
`${state.spec.path} was not loaded: its ciphertext column was left out of the selection`
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
const encryptor = getRegisteredEncryptor();
|
|
990
|
+
if (!encryptor) {
|
|
991
|
+
throw new EncryptionRequiredError(
|
|
992
|
+
`Cannot read ${state.spec.path}: no encryptor registered. Call registerEncryptor() at bootstrap.`
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
const opened = encryptor.open(state.sealed, tenantId);
|
|
996
|
+
state.plain = deserializeFromEncryption(
|
|
997
|
+
opened.plaintext,
|
|
998
|
+
state.spec.elementRuntimeType,
|
|
999
|
+
state.spec.isArray
|
|
1000
|
+
);
|
|
1001
|
+
state.hasPlain = true;
|
|
1002
|
+
}
|
|
1003
|
+
if (report) {
|
|
1004
|
+
tenantId ||= getBoundTenantId() ?? "";
|
|
1005
|
+
for (const listener of LISTENERS) {
|
|
1006
|
+
listener({ field: state.spec.path, level: state.spec.level, tenantId });
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return state.plain;
|
|
1010
|
+
}
|
|
1011
|
+
function isCompliantField(value) {
|
|
1012
|
+
return value != null && typeof value === "object" && value[FIELD_BRAND] === true;
|
|
1013
|
+
}
|
|
1014
|
+
function requireEncryptor(path) {
|
|
1015
|
+
const encryptor = getRegisteredEncryptor();
|
|
1016
|
+
if (!encryptor) {
|
|
1017
|
+
throw new EncryptionRequiredError(
|
|
1018
|
+
`Cannot write ${path}: no encryptor registered. Call registerEncryptor() at bootstrap; compliant fields are never stored in plaintext.`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
return encryptor;
|
|
1022
|
+
}
|
|
1023
|
+
function sealedValueOf(field) {
|
|
1024
|
+
const state = stateOf(field);
|
|
1025
|
+
if (state.sealed !== void 0 && state.sealedTenant === void 0) {
|
|
1026
|
+
return state.sealed;
|
|
1027
|
+
}
|
|
1028
|
+
if (!state.hasPlain) return state.sealed;
|
|
1029
|
+
const tenantId = requireBoundTenantId("write");
|
|
1030
|
+
if (state.sealed !== void 0 && state.sealedTenant === tenantId) {
|
|
1031
|
+
return state.sealed;
|
|
1032
|
+
}
|
|
1033
|
+
if (state.anon === void 0) {
|
|
1034
|
+
state.anon = computeAnon(state.spec.anon, state.plain);
|
|
1035
|
+
}
|
|
1036
|
+
state.sealed = requireEncryptor(state.spec.path).seal(
|
|
1037
|
+
serializeForEncryption(state.plain),
|
|
1038
|
+
tenantId,
|
|
1039
|
+
state.anon
|
|
1040
|
+
);
|
|
1041
|
+
state.sealedTenant = tenantId;
|
|
1042
|
+
return state.sealed;
|
|
1043
|
+
}
|
|
1044
|
+
function normalizeSerializedForIndex(serialized, spec) {
|
|
1045
|
+
return spec.normalize === "lowercase" ? serialized.trim().toLowerCase() : serialized;
|
|
1046
|
+
}
|
|
1047
|
+
function normalizedForIndex(value, spec) {
|
|
1048
|
+
return normalizeSerializedForIndex(serializeForEncryption(value), spec);
|
|
1049
|
+
}
|
|
1050
|
+
function blindIndexOfValue(value, spec) {
|
|
1051
|
+
return requireEncryptor(spec.path).blindIndex(
|
|
1052
|
+
normalizedForIndex(value, spec),
|
|
1053
|
+
requireBoundTenantId("query")
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
function blindIndexOf(field) {
|
|
1057
|
+
const state = stateOf(field);
|
|
1058
|
+
if (state.index) return state.index;
|
|
1059
|
+
if (!state.hasPlain && state.sealed === void 0) return state.index ?? null;
|
|
1060
|
+
try {
|
|
1061
|
+
const plain = state.hasPlain ? state.plain : readPlaintext(field, false);
|
|
1062
|
+
state.index = blindIndexOfValue(plain, state.spec);
|
|
1063
|
+
} catch {
|
|
1064
|
+
return state.index ?? null;
|
|
1065
|
+
}
|
|
1066
|
+
return state.index;
|
|
1067
|
+
}
|
|
1068
|
+
var PENDING = /* @__PURE__ */ Symbol.for("forklaunch.compliance.pending");
|
|
1069
|
+
function isPendingIndex(value) {
|
|
1070
|
+
return value != null && typeof value === "object" && PENDING in value;
|
|
1071
|
+
}
|
|
1072
|
+
function pendingIndexValue(value) {
|
|
1073
|
+
return value[PENDING];
|
|
1074
|
+
}
|
|
1075
|
+
var CompliantTypeBase = class extends import_core2.Type {
|
|
1076
|
+
constructor(spec) {
|
|
1077
|
+
super();
|
|
1078
|
+
this.spec = spec;
|
|
1079
|
+
}
|
|
1080
|
+
spec;
|
|
1081
|
+
getColumnType() {
|
|
1082
|
+
return "text";
|
|
1083
|
+
}
|
|
1084
|
+
get runtimeType() {
|
|
1085
|
+
return "string";
|
|
1086
|
+
}
|
|
1087
|
+
ensureComparable() {
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
compareAsType() {
|
|
1091
|
+
return "string";
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
var CompliantType = class extends CompliantTypeBase {
|
|
1095
|
+
convertToDatabaseValue(value, _platform, context) {
|
|
1096
|
+
if (context?.fromQuery) {
|
|
1097
|
+
throw new CompliantQueryError(
|
|
1098
|
+
`${this.spec.path} cannot be queried: declare it with .compliance('${this.spec.level}', { queryable: true }) to allow equality lookups`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (value === null || value === void 0) return null;
|
|
1102
|
+
if (isCompliantField(value)) return sealedValueOf(value) ?? null;
|
|
1103
|
+
if (isEncryptedCiphertext(value)) return value;
|
|
1104
|
+
return sealedValueOf(CompliantField.fromPlain(value, this.spec)) ?? null;
|
|
1105
|
+
}
|
|
1106
|
+
convertToJSValue(value) {
|
|
1107
|
+
if (value === null || value === void 0) return value;
|
|
1108
|
+
if (isCompliantField(value)) return value;
|
|
1109
|
+
return CompliantField.fromStored(String(value), this.spec);
|
|
1110
|
+
}
|
|
1111
|
+
};
|
|
1112
|
+
var EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$in", "$nin"]);
|
|
1113
|
+
var CompliantIndexType = class extends CompliantTypeBase {
|
|
1114
|
+
convertToDatabaseValue(value, _platform, context) {
|
|
1115
|
+
if (value === null || value === void 0) return null;
|
|
1116
|
+
if (context?.fromQuery) {
|
|
1117
|
+
const key = context.key;
|
|
1118
|
+
if (key !== void 0 && !EQUALITY_OPERATORS.has(String(key)) && !/^\d+$/.test(String(key))) {
|
|
1119
|
+
throw new CompliantQueryError(
|
|
1120
|
+
`${this.spec.path} supports equality lookups only ($eq, $ne, $in, $nin); ${String(key)} would need the plaintext`
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (isCompliantField(value)) return blindIndexOf(value);
|
|
1125
|
+
if (isPendingIndex(value)) return pendingIndexValue(value);
|
|
1126
|
+
if (typeof value === "string" && value.startsWith("bi1:")) return value;
|
|
1127
|
+
return blindIndexOfValue(value, this.spec);
|
|
1128
|
+
}
|
|
1129
|
+
convertToJSValue(value) {
|
|
1130
|
+
if (isCompliantField(value) || isPendingIndex(value)) return value;
|
|
1131
|
+
return { [PENDING]: value ?? null };
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
function isCompliantType(type) {
|
|
1135
|
+
return type instanceof CompliantType || type instanceof CompliantIndexType;
|
|
1136
|
+
}
|
|
1137
|
+
var SLOTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.slots")] ??= /* @__PURE__ */ new WeakMap();
|
|
1138
|
+
function slotsOf(entity) {
|
|
1139
|
+
let slots = SLOTS.get(entity);
|
|
1140
|
+
if (!slots) {
|
|
1141
|
+
slots = {};
|
|
1142
|
+
SLOTS.set(entity, slots);
|
|
1143
|
+
}
|
|
1144
|
+
return slots;
|
|
1145
|
+
}
|
|
1146
|
+
function readProperty(entity, binding) {
|
|
1147
|
+
const slots = slotsOf(entity);
|
|
1148
|
+
const value = slots[binding.property];
|
|
1149
|
+
if (binding.sibling) {
|
|
1150
|
+
const stored = slots[binding.sibling];
|
|
1151
|
+
if (isPendingIndex(value)) {
|
|
1152
|
+
const index = pendingIndexValue(value);
|
|
1153
|
+
const field = typeof stored === "string" ? CompliantField.fromStored(stored, binding.spec, index) : CompliantField.fromIndexOnly(index, binding.spec);
|
|
1154
|
+
slots[binding.property] = field;
|
|
1155
|
+
return field;
|
|
1156
|
+
}
|
|
1157
|
+
if (value == null && typeof stored === "string") {
|
|
1158
|
+
const field = CompliantField.fromStored(stored, binding.spec, null);
|
|
1159
|
+
slots[binding.property] = field;
|
|
1160
|
+
return field;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return value;
|
|
1164
|
+
}
|
|
1165
|
+
function sameRules(a, b) {
|
|
1166
|
+
return a === b || a.path === b.path && a.anon === b.anon && a.normalize === b.normalize && a.queryable === b.queryable;
|
|
1167
|
+
}
|
|
1168
|
+
function writeProperty(entity, binding, value) {
|
|
1169
|
+
const slots = slotsOf(entity);
|
|
1170
|
+
if (isPendingIndex(value)) {
|
|
1171
|
+
slots[binding.property] = value;
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (value === null || value === void 0) {
|
|
1175
|
+
slots[binding.property] = value;
|
|
1176
|
+
if (binding.sibling) slots[binding.sibling] = value;
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (isCompliantField(value)) {
|
|
1180
|
+
slots[binding.property] = sameRules(stateOf(value).spec, binding.spec) ? value : CompliantField.fromPlain(readPlaintext(value, false), binding.spec);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
slots[binding.property] = CompliantField.fromPlain(value, binding.spec);
|
|
1184
|
+
}
|
|
1185
|
+
function installCompliantAccessors(prototype, bindings) {
|
|
1186
|
+
installInspect(prototype, bindings);
|
|
1187
|
+
for (const binding of bindings) {
|
|
1188
|
+
Object.defineProperty(prototype, binding.property, {
|
|
1189
|
+
configurable: true,
|
|
1190
|
+
enumerable: true,
|
|
1191
|
+
get() {
|
|
1192
|
+
return readProperty(this, binding);
|
|
1193
|
+
},
|
|
1194
|
+
set(value) {
|
|
1195
|
+
writeProperty(this, binding, value);
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
if (binding.sibling) {
|
|
1199
|
+
const sibling = binding.sibling;
|
|
1200
|
+
Object.defineProperty(prototype, sibling, {
|
|
1201
|
+
configurable: true,
|
|
1202
|
+
enumerable: false,
|
|
1203
|
+
get() {
|
|
1204
|
+
const field = readProperty(this, binding);
|
|
1205
|
+
const stored = slotsOf(this)[sibling];
|
|
1206
|
+
if (field === null || field === void 0) return stored ?? field;
|
|
1207
|
+
return sealedValueOf(field) ?? stored;
|
|
1208
|
+
},
|
|
1209
|
+
set(value) {
|
|
1210
|
+
slotsOf(this)[sibling] = value;
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
|
|
1217
|
+
function installInspect(prototype, bindings) {
|
|
1218
|
+
const hidden = new Set(
|
|
1219
|
+
bindings.map((b) => b.sibling).filter((s) => !!s)
|
|
1220
|
+
);
|
|
1221
|
+
Object.defineProperty(prototype, INSPECT, {
|
|
1222
|
+
configurable: true,
|
|
1223
|
+
writable: true,
|
|
1224
|
+
value(depth, options, inspect) {
|
|
1225
|
+
const shown = {};
|
|
1226
|
+
for (const key of Object.keys(this)) {
|
|
1227
|
+
if (!hidden.has(key)) shown[key] = this[key];
|
|
1228
|
+
}
|
|
1229
|
+
for (const binding of bindings) {
|
|
1230
|
+
const value = this[binding.property];
|
|
1231
|
+
if (value !== void 0) shown[binding.property] = value;
|
|
1232
|
+
}
|
|
1233
|
+
const name = this.constructor?.name ?? "Entity";
|
|
1234
|
+
return `${name} ${inspect(shown, { ...options, depth })}`;
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
function deanon(entity) {
|
|
1239
|
+
const copy = {};
|
|
1240
|
+
for (const key in entity) {
|
|
1241
|
+
const value = entity[key];
|
|
1242
|
+
copy[key] = isCompliantField(value) ? value.deanon : value;
|
|
1243
|
+
}
|
|
1244
|
+
return copy;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/persistence/compliancePropertyBuilder.ts
|
|
1248
|
+
var import_common = require("@forklaunch/common");
|
|
1249
|
+
var import_core3 = require("@mikro-orm/core");
|
|
1250
|
+
function isBuilder(value) {
|
|
1251
|
+
return value != null && typeof value === "object" && "~options" in value;
|
|
1252
|
+
}
|
|
1253
|
+
function wrapUnclassified(builder) {
|
|
1254
|
+
return new Proxy(builder, {
|
|
1255
|
+
get(target, prop) {
|
|
1256
|
+
if (prop === "compliance") {
|
|
1257
|
+
return (level, options) => wrapClassified(target, level, options);
|
|
1258
|
+
}
|
|
1259
|
+
if (prop === "~options") return Reflect.get(target, prop, target);
|
|
1260
|
+
if (prop === COMPLIANCE_KEY) return void 0;
|
|
1261
|
+
const value = Reflect.get(target, prop, target);
|
|
1262
|
+
if (typeof value === "function") {
|
|
1263
|
+
return (...args) => {
|
|
1264
|
+
const result = value.apply(
|
|
1265
|
+
target,
|
|
1266
|
+
args
|
|
1267
|
+
);
|
|
1268
|
+
return isBuilder(result) ? wrapUnclassified(result) : result;
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
return value;
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
var ENCRYPTED_LEVELS2 = /* @__PURE__ */ new Set([
|
|
1276
|
+
"pii",
|
|
1277
|
+
"phi",
|
|
1278
|
+
"pci"
|
|
1279
|
+
]);
|
|
1280
|
+
function resolveEncryptedTypeArgs(options) {
|
|
1281
|
+
const type = options.type;
|
|
1282
|
+
const isArray = options.array === true;
|
|
1283
|
+
const resolved = resolveTypeInstance(type);
|
|
1284
|
+
if (resolved) {
|
|
1285
|
+
const rt = resolved.runtimeType;
|
|
1286
|
+
if (rt.endsWith("[]")) {
|
|
1287
|
+
return { elementRuntimeType: rt.slice(0, -2) || "string", isArray: true };
|
|
1288
|
+
}
|
|
1289
|
+
return { elementRuntimeType: rt, isArray };
|
|
1290
|
+
}
|
|
1291
|
+
return { elementRuntimeType: "string", isArray };
|
|
1292
|
+
}
|
|
1293
|
+
function wrapClassified(builder, level, complianceOptions) {
|
|
1294
|
+
if (ENCRYPTED_LEVELS2.has(level)) {
|
|
1295
|
+
const options = builder["~options"];
|
|
1296
|
+
if (options) {
|
|
1297
|
+
const { elementRuntimeType, isArray } = resolveEncryptedTypeArgs(options);
|
|
1298
|
+
let enumValues;
|
|
1299
|
+
if (options.items) {
|
|
1300
|
+
enumValues = extractEnumValues(options.items);
|
|
1301
|
+
delete options.items;
|
|
1302
|
+
delete options.nativeEnumName;
|
|
1303
|
+
delete options.enum;
|
|
1304
|
+
}
|
|
1305
|
+
const spec = {
|
|
1306
|
+
level,
|
|
1307
|
+
anon: complianceOptions?.anon ?? defaultAnonStrategy(elementRuntimeType, isArray, !!enumValues),
|
|
1308
|
+
queryable: complianceOptions?.queryable === true,
|
|
1309
|
+
normalize: complianceOptions?.normalize ?? "exact",
|
|
1310
|
+
elementRuntimeType,
|
|
1311
|
+
isArray,
|
|
1312
|
+
enumValues,
|
|
1313
|
+
path: ""
|
|
1314
|
+
// set by defineComplianceEntity once the property name is known
|
|
1315
|
+
};
|
|
1316
|
+
options.type = spec.queryable ? new CompliantIndexType(spec) : new CompliantType(spec);
|
|
1317
|
+
options.columnType = "text";
|
|
1318
|
+
options.runtimeType = "string";
|
|
1319
|
+
options.accessor = true;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return new Proxy(builder, {
|
|
1323
|
+
get(target, prop) {
|
|
1324
|
+
if (prop === COMPLIANCE_KEY) return level;
|
|
1325
|
+
if (prop === "~options") return Reflect.get(target, prop, target);
|
|
1326
|
+
const value = Reflect.get(target, prop, target);
|
|
1327
|
+
if (typeof value === "function") {
|
|
1328
|
+
return (...args) => {
|
|
1329
|
+
const result = value.apply(
|
|
1330
|
+
target,
|
|
1331
|
+
args
|
|
1332
|
+
);
|
|
1333
|
+
return isBuilder(result) ? wrapClassified(result, level) : result;
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
return value;
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
function wrapRelation(builder) {
|
|
1341
|
+
return wrapClassified(builder, "none");
|
|
1342
|
+
}
|
|
1343
|
+
var RELATION_METHODS = /* @__PURE__ */ new Set([
|
|
1344
|
+
"manyToOne",
|
|
1345
|
+
"oneToMany",
|
|
1346
|
+
"manyToMany",
|
|
1347
|
+
"oneToOne",
|
|
1348
|
+
"embedded"
|
|
1349
|
+
]);
|
|
1350
|
+
function isRelationMethod(prop) {
|
|
1351
|
+
return typeof prop === "string" && RELATION_METHODS.has(prop);
|
|
1352
|
+
}
|
|
1353
|
+
function extractEnumValues(items) {
|
|
1354
|
+
if (typeof items === "function") {
|
|
1355
|
+
return extractEnumValues(items());
|
|
1356
|
+
}
|
|
1357
|
+
if (Array.isArray(items)) return items;
|
|
1358
|
+
if (items != null && typeof items === "object") {
|
|
1359
|
+
const obj = items;
|
|
1360
|
+
const allValues = Object.values(obj);
|
|
1361
|
+
return allValues.filter((v) => typeof v !== "string" || !(v in obj));
|
|
1362
|
+
}
|
|
1363
|
+
return [];
|
|
1364
|
+
}
|
|
1365
|
+
var EnumTextType = class extends import_core3.Type {
|
|
1366
|
+
convertToDatabaseValue(value) {
|
|
1367
|
+
return (0, import_common.safeStringify)(value);
|
|
1368
|
+
}
|
|
1369
|
+
convertToJSValue(value) {
|
|
1370
|
+
return (0, import_common.safeParse)(value);
|
|
1371
|
+
}
|
|
1372
|
+
getColumnType() {
|
|
1373
|
+
return "text";
|
|
1374
|
+
}
|
|
1375
|
+
get runtimeType() {
|
|
1376
|
+
return "string";
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
function inferEnumType(items) {
|
|
1380
|
+
const values = extractEnumValues(items);
|
|
1381
|
+
if (values.length === 0) return "text";
|
|
1382
|
+
const allStrings = values.every((v) => typeof v === "string");
|
|
1383
|
+
if (allStrings) return "text";
|
|
1384
|
+
const allNumbers = values.every((v) => typeof v === "number");
|
|
1385
|
+
if (allNumbers) return "integer";
|
|
1386
|
+
return new EnumTextType();
|
|
1387
|
+
}
|
|
1388
|
+
var fp = new Proxy(import_core3.p, {
|
|
1389
|
+
get(target, prop) {
|
|
1390
|
+
const value = Reflect.get(target, prop, target);
|
|
1391
|
+
if (typeof value !== "function") return value;
|
|
1392
|
+
if (isRelationMethod(prop)) {
|
|
1393
|
+
return (...args) => {
|
|
1394
|
+
const result = value.apply(
|
|
1395
|
+
target,
|
|
1396
|
+
args
|
|
1397
|
+
);
|
|
1398
|
+
return isBuilder(result) ? wrapRelation(result) : result;
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
return (...args) => {
|
|
1402
|
+
const result = value.apply(
|
|
1403
|
+
target,
|
|
1404
|
+
args
|
|
1405
|
+
);
|
|
1406
|
+
if (isBuilder(result)) {
|
|
1407
|
+
if (prop === "enum") {
|
|
1408
|
+
const options = result["~options"];
|
|
1409
|
+
if (options && !options.type) {
|
|
1410
|
+
options.type = inferEnumType(options.items);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return wrapUnclassified(result);
|
|
1414
|
+
}
|
|
1415
|
+
return result;
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
// src/persistence/defineComplianceEntity.ts
|
|
1421
|
+
var import_core4 = require("@mikro-orm/core");
|
|
1422
|
+
var naming = new import_core4.UnderscoreNamingStrategy();
|
|
1423
|
+
function wireCompliantProperties(entityName, properties) {
|
|
1424
|
+
const wired = {};
|
|
1425
|
+
const bindings = [];
|
|
1426
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
1427
|
+
wired[name] = prop;
|
|
1428
|
+
if (typeof prop === "function" || prop == null) continue;
|
|
1429
|
+
const options = prop["~options"] ?? prop;
|
|
1430
|
+
const type = options.type;
|
|
1431
|
+
if (!isCompliantType(type)) continue;
|
|
1432
|
+
type.spec.path = `${entityName}.${name}`;
|
|
1433
|
+
if (!(type instanceof CompliantIndexType)) {
|
|
1434
|
+
bindings.push({ property: name, spec: type.spec });
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
const sibling = `${name}Sealed`;
|
|
1438
|
+
if (sibling in properties) {
|
|
1439
|
+
throw new Error(
|
|
1440
|
+
`Entity '${entityName}': '${sibling}' is reserved for the encrypted value of queryable field '${name}'`
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
const explicit = options.fieldName ?? options.fieldNames?.[0];
|
|
1444
|
+
const column = typeof explicit === "string" ? explicit : naming.propertyToColumnName(name);
|
|
1445
|
+
const required = options.nullable !== true;
|
|
1446
|
+
options.fieldName = `${column}_idx`;
|
|
1447
|
+
delete options.fieldNames;
|
|
1448
|
+
options.nullable = true;
|
|
1449
|
+
wired[sibling] = {
|
|
1450
|
+
type: "text",
|
|
1451
|
+
columnType: "text",
|
|
1452
|
+
fieldName: column,
|
|
1453
|
+
nullable: !required,
|
|
1454
|
+
hidden: true,
|
|
1455
|
+
accessor: true
|
|
1456
|
+
};
|
|
1457
|
+
bindings.push({ property: name, spec: type.spec, sibling });
|
|
1458
|
+
}
|
|
1459
|
+
return { properties: wired, bindings };
|
|
1460
|
+
}
|
|
1461
|
+
function readComplianceLevel(builder) {
|
|
1462
|
+
if (builder == null || typeof builder !== "object") return void 0;
|
|
1463
|
+
return builder[COMPLIANCE_KEY];
|
|
1464
|
+
}
|
|
1465
|
+
function defineComplianceEntity(meta) {
|
|
1466
|
+
const entityName = "name" in meta ? meta.name : "Unknown";
|
|
1467
|
+
const complianceFields = /* @__PURE__ */ new Map();
|
|
1468
|
+
const rawProperties = meta.properties;
|
|
1469
|
+
const resolvedProperties = typeof rawProperties === "function" ? rawProperties(import_core4.p) : rawProperties;
|
|
1470
|
+
for (const [fieldName, rawProp] of Object.entries(resolvedProperties)) {
|
|
1471
|
+
if (typeof rawProp === "function") {
|
|
1472
|
+
complianceFields.set(fieldName, "none");
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
const level = readComplianceLevel(rawProp);
|
|
1476
|
+
if (level == null) {
|
|
1477
|
+
throw new Error(
|
|
1478
|
+
`Field '${entityName}.${fieldName}' is missing compliance classification. Call .compliance('pii' | 'phi' | 'pci' | 'none') on this property, or use a relation method (fp.manyToOne, etc.) which is auto-classified.`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
complianceFields.set(fieldName, level);
|
|
1482
|
+
}
|
|
1483
|
+
registerEntityCompliance(entityName, complianceFields);
|
|
1484
|
+
if (meta.retention) {
|
|
1485
|
+
parseDuration(meta.retention.duration);
|
|
1486
|
+
if (!resolvedProperties["createdAt"]) {
|
|
1487
|
+
throw new Error(
|
|
1488
|
+
`Entity '${entityName}' has a retention policy but no 'createdAt' property. Retention requires createdAt to compute expiration.`
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
registerEntityRetention(entityName, meta.retention);
|
|
1492
|
+
}
|
|
1493
|
+
if (meta.userIdField) {
|
|
1494
|
+
registerEntityUserIdField(entityName, meta.userIdField);
|
|
1495
|
+
}
|
|
1496
|
+
const wired = wireCompliantProperties(entityName, resolvedProperties);
|
|
1497
|
+
const schema = (0, import_core4.defineEntity)({
|
|
1498
|
+
...meta,
|
|
1499
|
+
properties: wired.properties
|
|
1500
|
+
});
|
|
1501
|
+
if (wired.bindings.length > 0) {
|
|
1502
|
+
const entityClass = schema.meta.class;
|
|
1503
|
+
if (entityClass)
|
|
1504
|
+
installCompliantAccessors(entityClass.prototype, wired.bindings);
|
|
1505
|
+
const setClass = schema.setClass.bind(schema);
|
|
1506
|
+
schema.setClass = ((cls) => {
|
|
1507
|
+
installCompliantAccessors(cls.prototype, wired.bindings);
|
|
1508
|
+
return setClass(cls);
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
return schema;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// src/persistence/complianceEventSubscriber.ts
|
|
1515
|
+
var ENCRYPTED_LEVELS3 = /* @__PURE__ */ new Set([
|
|
1516
|
+
"pii",
|
|
1517
|
+
"phi",
|
|
1518
|
+
"pci"
|
|
1519
|
+
]);
|
|
1520
|
+
function wrapEmWithNativeQueryBlocking(em) {
|
|
1521
|
+
const BLOCKED_METHODS = [
|
|
1522
|
+
"nativeInsert",
|
|
1523
|
+
"nativeUpdate",
|
|
1524
|
+
"nativeDelete"
|
|
1525
|
+
];
|
|
936
1526
|
return new Proxy(em, {
|
|
937
1527
|
get(target, prop, receiver) {
|
|
938
1528
|
if (typeof prop === "string" && BLOCKED_METHODS.includes(prop)) {
|
|
@@ -1061,14 +1651,23 @@ async function reencryptEncryptedColumns(options) {
|
|
|
1061
1651
|
const fields = getEntityComplianceFields(meta.className);
|
|
1062
1652
|
if (!fields) continue;
|
|
1063
1653
|
const props = meta.properties;
|
|
1064
|
-
const
|
|
1654
|
+
const compliant = compliantColumnsOf(props);
|
|
1655
|
+
const compliantNames = new Set(compliant.map((c) => c.property));
|
|
1656
|
+
const encryptedProps = [...fields.entries()].filter(
|
|
1657
|
+
([prop, level]) => ENCRYPTED_LEVELS4.has(level) && !compliantNames.has(prop)
|
|
1658
|
+
).map(([prop]) => props[prop]).filter(
|
|
1065
1659
|
(p3) => !!p3 && Array.isArray(p3.fieldNames) && p3.fieldNames.length === 1
|
|
1066
1660
|
);
|
|
1067
|
-
if (encryptedProps.length === 0) continue;
|
|
1661
|
+
if (encryptedProps.length === 0 && compliant.length === 0) continue;
|
|
1068
1662
|
const pkProp = props[meta.primaryKeys[0]];
|
|
1069
1663
|
if (!pkProp) continue;
|
|
1070
1664
|
const pkColumn = pkProp.fieldNames[0];
|
|
1071
|
-
const columns =
|
|
1665
|
+
const columns = [
|
|
1666
|
+
...encryptedProps.map((p3) => p3.fieldNames[0]),
|
|
1667
|
+
...compliant.flatMap(
|
|
1668
|
+
(c) => c.indexColumn ? [c.envelopeColumn, c.indexColumn] : [c.envelopeColumn]
|
|
1669
|
+
)
|
|
1670
|
+
];
|
|
1072
1671
|
const report = {
|
|
1073
1672
|
table: meta.tableName,
|
|
1074
1673
|
columns,
|
|
@@ -1079,7 +1678,9 @@ async function reencryptEncryptedColumns(options) {
|
|
|
1079
1678
|
unreadable: 0,
|
|
1080
1679
|
unreadableIds: [],
|
|
1081
1680
|
byKeyId: {},
|
|
1082
|
-
missingKeyIds: {}
|
|
1681
|
+
missingKeyIds: {},
|
|
1682
|
+
sealed: 0,
|
|
1683
|
+
reindexed: 0
|
|
1083
1684
|
};
|
|
1084
1685
|
const exists = await execute(`select to_regclass(?) as t`, [
|
|
1085
1686
|
quote(meta.tableName)
|
|
@@ -1124,6 +1725,34 @@ async function reencryptEncryptedColumns(options) {
|
|
|
1124
1725
|
report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
|
|
1125
1726
|
}
|
|
1126
1727
|
}
|
|
1728
|
+
for (const column of compliant) {
|
|
1729
|
+
const outcome = planCompliantColumn(
|
|
1730
|
+
column,
|
|
1731
|
+
row,
|
|
1732
|
+
encryptor,
|
|
1733
|
+
tenantIds,
|
|
1734
|
+
fallbackTenantIds
|
|
1735
|
+
);
|
|
1736
|
+
if (outcome.kind === "unreadable") {
|
|
1737
|
+
report.unreadable += 1;
|
|
1738
|
+
rowUnreadable = true;
|
|
1739
|
+
const missing2 = stampedKeyId(String(row[column.envelopeColumn]));
|
|
1740
|
+
if (missing2)
|
|
1741
|
+
report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
if (outcome.kind === "empty") continue;
|
|
1745
|
+
if (outcome.sealed) {
|
|
1746
|
+
report.sealed = (report.sealed ?? 0) + 1;
|
|
1747
|
+
report.rewritten += 1;
|
|
1748
|
+
if (outcome.fromKeyId)
|
|
1749
|
+
report.byKeyId[outcome.fromKeyId] = (report.byKeyId[outcome.fromKeyId] ?? 0) + 1;
|
|
1750
|
+
} else {
|
|
1751
|
+
report.current += 1;
|
|
1752
|
+
}
|
|
1753
|
+
if (outcome.reindexed) report.reindexed = (report.reindexed ?? 0) + 1;
|
|
1754
|
+
updates.push(...outcome.updates.map((u) => ({ ...u, jsonb: false })));
|
|
1755
|
+
}
|
|
1127
1756
|
if (rowUnreadable) report.unreadableIds.push(String(row[pkColumn]));
|
|
1128
1757
|
if (updates.length && !dryRun) {
|
|
1129
1758
|
const sets = updates.map(
|
|
@@ -1138,7 +1767,7 @@ async function reencryptEncryptedColumns(options) {
|
|
|
1138
1767
|
const byKey = Object.entries(report.byKeyId).map(([k, n]) => `${k}: ${n}`).join(", ");
|
|
1139
1768
|
const missing = Object.entries(report.missingKeyIds).map(([k, n]) => `${k}: ${n}`).join(", ");
|
|
1140
1769
|
log(
|
|
1141
|
-
`[key-rotation] ${meta.tableName}: scanned ${report.scanned} rows, ${report.rewritten} values ${dryRun ? "would be " : ""}rewritten${byKey ? ` (from ${byKey})` : ""}, ${report.current} already current, ${report.plaintext} plaintext, ${report.unreadable} unreadable${missing ? ` (stamped with keys not in the ring: ${missing})` : ""}${report.unreadableIds.length ? ` (rows ${report.unreadableIds.slice(0, 10).join(", ")}${report.unreadableIds.length > 10 ? ", ..." : ""})` : ""}`
|
|
1770
|
+
`[key-rotation] ${meta.tableName}: scanned ${report.scanned} rows, ${report.rewritten} values ${dryRun ? "would be " : ""}rewritten${byKey ? ` (from ${byKey})` : ""}, ${report.current} already current, ${report.plaintext} plaintext, ${report.sealed ?? 0} compliant values sealed, ${report.reindexed ?? 0} blind indexes written, ${report.unreadable} unreadable${missing ? ` (stamped with keys not in the ring: ${missing})` : ""}${report.unreadableIds.length ? ` (rows ${report.unreadableIds.slice(0, 10).join(", ")}${report.unreadableIds.length > 10 ? ", ..." : ""})` : ""}`
|
|
1142
1771
|
);
|
|
1143
1772
|
reports.push(report);
|
|
1144
1773
|
}
|
|
@@ -1169,6 +1798,93 @@ function rotationTotals(reports) {
|
|
|
1169
1798
|
unreadable: sum((r) => r.unreadable)
|
|
1170
1799
|
};
|
|
1171
1800
|
}
|
|
1801
|
+
function compliantColumnsOf(props) {
|
|
1802
|
+
const columns = [];
|
|
1803
|
+
for (const [property, prop] of Object.entries(props)) {
|
|
1804
|
+
const type = prop?.customType;
|
|
1805
|
+
if (!prop || !isCompliantType(type)) continue;
|
|
1806
|
+
if (type instanceof CompliantIndexType) {
|
|
1807
|
+
const sibling = props[`${property}Sealed`];
|
|
1808
|
+
if (!sibling?.fieldNames?.[0]) continue;
|
|
1809
|
+
columns.push({
|
|
1810
|
+
property,
|
|
1811
|
+
spec: type.spec,
|
|
1812
|
+
envelopeColumn: sibling.fieldNames[0],
|
|
1813
|
+
indexColumn: prop.fieldNames[0]
|
|
1814
|
+
});
|
|
1815
|
+
} else {
|
|
1816
|
+
columns.push({
|
|
1817
|
+
property,
|
|
1818
|
+
spec: type.spec,
|
|
1819
|
+
envelopeColumn: prop.fieldNames[0]
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return columns;
|
|
1824
|
+
}
|
|
1825
|
+
function planCompliantColumn(column, row, encryptor, tenantIds, fallbackTenantIds) {
|
|
1826
|
+
const stored = row[column.envelopeColumn];
|
|
1827
|
+
if (stored === null || stored === void 0) {
|
|
1828
|
+
if (column.indexColumn && row[column.indexColumn] != null) {
|
|
1829
|
+
return {
|
|
1830
|
+
kind: "ok",
|
|
1831
|
+
sealed: false,
|
|
1832
|
+
reindexed: true,
|
|
1833
|
+
updates: [{ column: column.indexColumn, next: null }]
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
return { kind: "empty" };
|
|
1837
|
+
}
|
|
1838
|
+
const primary = tenantIds.length ? tenantIds : [""];
|
|
1839
|
+
const candidates = [
|
|
1840
|
+
...primary,
|
|
1841
|
+
...fallbackTenantIds.filter((t) => !primary.includes(t))
|
|
1842
|
+
];
|
|
1843
|
+
for (const tenantId of candidates) {
|
|
1844
|
+
let serialized;
|
|
1845
|
+
let anon;
|
|
1846
|
+
let current = false;
|
|
1847
|
+
let fromKeyId;
|
|
1848
|
+
if (!isEncryptedValue(stored)) {
|
|
1849
|
+
serialized = String(stored);
|
|
1850
|
+
} else {
|
|
1851
|
+
const opened = tryOpen(encryptor, String(stored), tenantId);
|
|
1852
|
+
if (!opened) continue;
|
|
1853
|
+
serialized = opened.plaintext;
|
|
1854
|
+
anon = opened.anon;
|
|
1855
|
+
current = opened.version === "v4" && !opened.stale;
|
|
1856
|
+
fromKeyId = opened.keyId;
|
|
1857
|
+
}
|
|
1858
|
+
const updates = [];
|
|
1859
|
+
if (!current) {
|
|
1860
|
+
anon ??= computeAnon(
|
|
1861
|
+
column.spec.anon,
|
|
1862
|
+
deserializeFromEncryption(
|
|
1863
|
+
serialized,
|
|
1864
|
+
column.spec.elementRuntimeType,
|
|
1865
|
+
column.spec.isArray
|
|
1866
|
+
)
|
|
1867
|
+
);
|
|
1868
|
+
updates.push({
|
|
1869
|
+
column: column.envelopeColumn,
|
|
1870
|
+
next: encryptor.seal(serialized, tenantId, anon)
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
let reindexed = false;
|
|
1874
|
+
if (column.indexColumn) {
|
|
1875
|
+
const index = encryptor.blindIndex(
|
|
1876
|
+
normalizeSerializedForIndex(serialized, column.spec),
|
|
1877
|
+
tenantId
|
|
1878
|
+
);
|
|
1879
|
+
if (row[column.indexColumn] !== index) {
|
|
1880
|
+
updates.push({ column: column.indexColumn, next: index });
|
|
1881
|
+
reindexed = true;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
return { kind: "ok", sealed: !current, reindexed, fromKeyId, updates };
|
|
1885
|
+
}
|
|
1886
|
+
return { kind: "unreadable" };
|
|
1887
|
+
}
|
|
1172
1888
|
|
|
1173
1889
|
// src/persistence/tenantFilter.ts
|
|
1174
1890
|
var TENANT_FILTER_NAME = "tenant";
|
|
@@ -1341,7 +2057,13 @@ function rebind(result, target, proxy) {
|
|
|
1341
2057
|
}
|
|
1342
2058
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1343
2059
|
0 && (module.exports = {
|
|
2060
|
+
Anon,
|
|
2061
|
+
BLIND_INDEX_PREFIX,
|
|
1344
2062
|
ComplianceLevel,
|
|
2063
|
+
CompliantField,
|
|
2064
|
+
CompliantIndexType,
|
|
2065
|
+
CompliantQueryError,
|
|
2066
|
+
CompliantType,
|
|
1345
2067
|
DecryptionError,
|
|
1346
2068
|
ENCRYPTED_PREFIXES,
|
|
1347
2069
|
ENCRYPTION_FORMAT_ENV,
|
|
@@ -1352,6 +2074,7 @@ function rebind(result, target, proxy) {
|
|
|
1352
2074
|
FieldEncryptor,
|
|
1353
2075
|
LEGACY_ENCRYPTION_KEYS_ENV,
|
|
1354
2076
|
LEGACY_ENCRYPTION_KEY_ENV,
|
|
2077
|
+
LegacyCiphertextError,
|
|
1355
2078
|
MissingEncryptionKeyError,
|
|
1356
2079
|
RetentionAction,
|
|
1357
2080
|
RetentionDuration,
|
|
@@ -1364,6 +2087,7 @@ function rebind(result, target, proxy) {
|
|
|
1364
2087
|
collectFallbackTenantIds,
|
|
1365
2088
|
countValuesByKeyId,
|
|
1366
2089
|
createTenantFilterDef,
|
|
2090
|
+
deanon,
|
|
1367
2091
|
defaultTenantIdsFor,
|
|
1368
2092
|
defineComplianceEntity,
|
|
1369
2093
|
encryptionKeyId,
|
|
@@ -1379,13 +2103,17 @@ function rebind(result, target, proxy) {
|
|
|
1379
2103
|
getEntityRetention,
|
|
1380
2104
|
getEntityUserIdField,
|
|
1381
2105
|
getSuperAdminContext,
|
|
2106
|
+
isAnon,
|
|
2107
|
+
isCompliantField,
|
|
1382
2108
|
isEncryptedCiphertext,
|
|
1383
2109
|
isEncryptedValue,
|
|
2110
|
+
onComplianceAccess,
|
|
1384
2111
|
parseDuration,
|
|
1385
2112
|
parseEncryptionKeyList,
|
|
1386
2113
|
reencryptEncryptedColumns,
|
|
1387
2114
|
registerEncryptor,
|
|
1388
2115
|
rotationTotals,
|
|
2116
|
+
sealedAnon,
|
|
1389
2117
|
setEncryptionTenantId,
|
|
1390
2118
|
setupRls,
|
|
1391
2119
|
setupTenantFilter,
|