@enbox/api 0.6.11 → 0.6.13

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.
@@ -639,12 +639,25 @@ export class TypedEnbox<
639
639
  }
640
640
 
641
641
  // Not installed or definition has changed — configure the new version.
642
- // Auto-enable encryption if any type in the definition requires it.
643
- // Skip encryption key derivation when operating as a delegate the
644
- // wallet already configured the protocol with encryption keys.
645
- const needsEncryption = !this._dwn.isDelegate && Object.values(this._definition.types)
642
+ // In delegate mode, the wallet is responsible for installing encrypted
643
+ // protocols during the connect flow. The delegate cannot derive the
644
+ // owner's encryption keys, so configuring here would install the
645
+ // protocol WITHOUT $encryption keys — breaking all encrypted writes.
646
+ const hasEncryptedTypes = Object.values(this._definition.types)
646
647
  .some((type) => (type as ProtocolType)?.encryptionRequired === true);
647
648
 
649
+ if (this._dwn.isDelegate && hasEncryptedTypes) {
650
+ throw new Error(
651
+ `TypedEnbox: Protocol '${this._definition.protocol}' requires ` +
652
+ `encryption but is not installed or has changed. In delegate mode, ` +
653
+ `encrypted protocols must be installed by the wallet during the ` +
654
+ `connect flow. Ensure the sync engine has completed its initial ` +
655
+ `sync before performing record operations.`,
656
+ );
657
+ }
658
+
659
+ const needsEncryption = !this._dwn.isDelegate && hasEncryptedTypes;
660
+
648
661
  const result = await this._dwn.protocols.configure({
649
662
  definition : this._definition,
650
663
  encryption : options?.encryption ?? (needsEncryption || undefined),
@@ -765,12 +778,53 @@ export class TypedEnbox<
765
778
  return;
766
779
  }
767
780
 
768
- // Not installed — configure it now.
769
- // Auto-enable encryption if any type in the definition requires it.
770
- // Skip encryption key derivation when operating as a delegate — the
771
- // wallet already configured the protocol with encryption keys. The
772
- // delegate can't derive the owner's keys.
773
- const needsEncryption = !this._dwn.isDelegate && Object.values(this._definition.types)
781
+ // Not installed locally — configure it now.
782
+ //
783
+ // For delegates: the wallet already installed the protocol with derived
784
+ // `$encryption` keys on the owner's remote DWN. We fetch that remote
785
+ // definition and install it locally so the delegate can encrypt records
786
+ // using the public keys from `$encryption`. This avoids the delegate
787
+ // needing the owner's private X25519 key — only the already-public
788
+ // derived keys are used for ProtocolPath encryption.
789
+ //
790
+ // For owners: derive encryption keys locally via the KMS.
791
+ if (this._dwn.isDelegate) {
792
+ const installed = await this._autoConfigureDelegateProtocol();
793
+ if (installed) {
794
+ return;
795
+ }
796
+
797
+ // The remote definition could not be fetched. If this protocol has
798
+ // any types with `encryptionRequired: true`, we MUST fail — silently
799
+ // installing without `$encryption` keys would allow plaintext writes
800
+ // that the owner's remote DWN would later reject, or worse, persist
801
+ // unencrypted sensitive data.
802
+ const hasEncryptedTypes = Object.values(this._definition.types)
803
+ .some((type) => (type as ProtocolType)?.encryptionRequired === true);
804
+
805
+ if (hasEncryptedTypes) {
806
+ throw new Error(
807
+ `TypedEnbox: delegate cannot install protocol '${this._definition.protocol}' ` +
808
+ 'because it contains types with encryptionRequired but the owner\'s ' +
809
+ 'remote protocol definition (with $encryption keys) could not be fetched. ' +
810
+ 'Ensure the owner has installed the protocol on their DWN and that the ' +
811
+ 'delegate has network access to the owner\'s DWN endpoints.',
812
+ );
813
+ }
814
+
815
+ // No encrypted types — safe to install the app-provided definition
816
+ // as-is (no encryption keys needed).
817
+ const result = await this._dwn.protocols.configure({
818
+ definition: this._definition,
819
+ });
820
+ if (result.status.code === 202) {
821
+ this._configured = true;
822
+ }
823
+ return;
824
+ }
825
+
826
+ // Owner path: derive encryption keys locally when any type needs them.
827
+ const needsEncryption = Object.values(this._definition.types)
774
828
  .some((type) => (type as ProtocolType)?.encryptionRequired === true);
775
829
 
776
830
  const result = await this._dwn.protocols.configure({
@@ -783,6 +837,46 @@ export class TypedEnbox<
783
837
  }
784
838
  }
785
839
 
840
+ /**
841
+ * For delegates: fetch the owner's protocol definition (with `$encryption`
842
+ * keys) from the remote DWN and install it locally.
843
+ *
844
+ * Returns `true` if the remote definition was successfully installed.
845
+ */
846
+ private async _autoConfigureDelegateProtocol(): Promise<boolean> {
847
+ try {
848
+ const { protocols: remoteProtocols } = await this._dwn.protocols.query({
849
+ from : this._dwn.connectedDid,
850
+ filter : { protocol: this._definition.protocol },
851
+ });
852
+
853
+ if (remoteProtocols.length === 0) {
854
+ return false;
855
+ }
856
+
857
+ // The remote definition includes `$encryption` keys injected by the
858
+ // owner's agent during their `protocols.configure({ encryption: true })`
859
+ // call. Install it as-is on the delegate's local DWN so that:
860
+ // 1. `encryptionRequired` is enforced locally (matching the remote)
861
+ // 2. `$encryption` public keys are available for record encryption
862
+ const remoteDefinition = remoteProtocols[0].definition;
863
+
864
+ const result = await this._dwn.protocols.configure({
865
+ definition: remoteDefinition,
866
+ });
867
+
868
+ if (result.status.code === 202) {
869
+ this._configured = true;
870
+ return true;
871
+ }
872
+
873
+ return false;
874
+ } catch {
875
+ // Network error or grant issue — fall back to local-only install.
876
+ return false;
877
+ }
878
+ }
879
+
786
880
  /**
787
881
  * Protocol-scoped record operations.
788
882
  *
@@ -869,10 +963,11 @@ export class TypedEnbox<
869
963
  const typeName = lastSegment(normalizedPath);
870
964
  const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
871
965
 
872
- // Auto-enable encryption when the type requires it, but skip for
873
- // delegates they don't have the owner's keys to derive encryption
874
- // keys. The owner's DWN handles encryption on the remote side.
875
- const autoEncryption = !this._dwn.isDelegate && typeEntry?.encryptionRequired === true
966
+ // Auto-enable encryption when the type requires it.
967
+ // Delegates CAN encrypt because the $encryption public keys in the
968
+ // synced protocol definition are sufficient for ProtocolPath
969
+ // encryption no private key access is needed for writes.
970
+ const autoEncryption = typeEntry?.encryptionRequired === true
876
971
  ? true : undefined;
877
972
 
878
973
  const { status, record } = await this._dwn.records.write({
@@ -942,7 +1037,10 @@ export class TypedEnbox<
942
1037
  const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
943
1038
 
944
1039
  const queryFilter = mapParentContextId(request?.filter);
945
- const autoEncryption = !this._dwn.isDelegate && typeEntry?.encryptionRequired === true
1040
+ // Auto-enable decryption when the type requires it. Delegates use
1041
+ // delivered ProtocolPath keys from the connect flow; the agent layer
1042
+ // resolves the correct key decrypter automatically.
1043
+ const autoEncryption = typeEntry?.encryptionRequired === true
946
1044
  ? true : undefined;
947
1045
 
948
1046
  const { status, records, cursor } = await this._dwn.records.query({
@@ -998,7 +1096,9 @@ export class TypedEnbox<
998
1096
  const typeEntry = this._definition.types[typeName] as ProtocolType | undefined;
999
1097
 
1000
1098
  const readFilter = mapParentContextId(request.filter);
1001
- const autoEncryption = !this._dwn.isDelegate && typeEntry?.encryptionRequired === true
1099
+ // Auto-enable decryption when the type requires it. See query()
1100
+ // comment for delegate decryption details.
1101
+ const autoEncryption = typeEntry?.encryptionRequired === true
1002
1102
  ? true : undefined;
1003
1103
 
1004
1104
  const { status, record } = await this._dwn.records.read({
@@ -1145,14 +1245,43 @@ function mapParentContextId<T extends Record<string, unknown>>(
1145
1245
  }
1146
1246
 
1147
1247
  /**
1148
- * Compares two protocol definitions for deep equality using deterministic
1149
- * JSON serialization.
1248
+ * Compares two protocol definitions for **logical** equality using
1249
+ * deterministic JSON serialization with `$encryption` blocks stripped.
1250
+ *
1251
+ * When a protocol is installed with `encryption: true`, the agent injects
1252
+ * `$encryption` blocks into the `structure` containing derived public keys.
1253
+ * These blocks are operational metadata — not part of the developer-authored
1254
+ * definition — so they must be ignored during equality checks. Otherwise,
1255
+ * the installed definition (with `$encryption`) would always differ from
1256
+ * the source definition (without `$encryption`), causing false drift
1257
+ * detection and spurious reconfigure warnings.
1150
1258
  *
1151
1259
  * Keys are sorted recursively so that semantically identical definitions
1152
1260
  * with different key ordering are treated as equal.
1153
1261
  */
1154
- function definitionsEqual(a: unknown, b: unknown): boolean {
1155
- return stableStringify(a) === stableStringify(b);
1262
+ export function definitionsEqual(a: unknown, b: unknown): boolean {
1263
+ return stableStringify(stripEncryptionBlocks(a)) === stableStringify(stripEncryptionBlocks(b));
1264
+ }
1265
+
1266
+ /**
1267
+ * Recursively removes `$encryption` keys from an object tree.
1268
+ * Returns a new object — the original is not mutated.
1269
+ */
1270
+ function stripEncryptionBlocks(value: unknown): unknown {
1271
+ if (value === null || value === undefined || typeof value !== 'object') {
1272
+ return value;
1273
+ }
1274
+
1275
+ if (Array.isArray(value)) {
1276
+ return value.map((item) => stripEncryptionBlocks(item));
1277
+ }
1278
+
1279
+ const result: Record<string, unknown> = {};
1280
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
1281
+ if (key === '$encryption') { continue; }
1282
+ result[key] = stripEncryptionBlocks(val);
1283
+ }
1284
+ return result;
1156
1285
  }
1157
1286
 
1158
1287
  /**