@enbox/agent 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enbox/agent",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "main": "./dist/esm/index.js",
6
6
  "module": "./dist/esm/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@scure/bip39": "1.2.2",
75
- "@enbox/dwn-clients": "0.3.3",
75
+ "@enbox/dwn-clients": "0.4.0",
76
76
  "@enbox/dwn-sdk-js": "0.3.5",
77
77
  "@enbox/common": "0.1.0",
78
78
  "@enbox/crypto": "0.1.0",
@@ -115,12 +115,41 @@ import {
115
115
  import { DwnInterfaceName, DwnMethodName, HdKey, KeyDerivationScheme, PermissionsProtocol } from '@enbox/dwn-sdk-js';
116
116
 
117
117
  import { AgentPermissionsApi } from './permissions-api.js';
118
- import { concatenateUrl } from './utils.js';
119
118
  import { DwnInterface } from './types/dwn.js';
120
119
  import { getEncryptionKeyInfo } from './dwn-encryption.js';
121
120
  import { isMultiPartyContext } from './protocol-utils.js';
122
121
  import { isRecordPermissionScope } from './dwn-api.js';
123
122
  import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
123
+ import { concatenateUrl, mapConcurrent, mapConcurrentSettled } from './utils.js';
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Tunables
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Maximum number of in-flight DWN-endpoint sends issued by the connect flow
131
+ * (permission grants + revocation grants). Caps total concurrency across all
132
+ * `(grant, endpoint)` pairs so that a request with many permissions and/or a
133
+ * tenant with many DWN endpoints cannot stampede the network. Tuned to be
134
+ * generous enough to hide endpoint latency while staying well under typical
135
+ * per-host browser connection limits and server-side rate limits.
136
+ */
137
+ const CONNECT_FANOUT_CONCURRENCY = 8;
138
+
139
+ /**
140
+ * Per-request abort budget applied to every DWN-endpoint `sendDwnRequest`
141
+ * issued during the connect flow. The HttpDwnRpcClient's default per-attempt
142
+ * timeout is 30 s with 3 retries (~120 s worst-case per request) — that
143
+ * scales unacceptably when bounded fan-out has to wait for every settled
144
+ * task. With this budget, an unhealthy / cold endpoint short-circuits the
145
+ * retry loop within a few seconds (AbortError is non-retryable), keeping
146
+ * the user-visible "Authorizing…" wait bounded even when one of N DWN
147
+ * endpoints is misbehaving.
148
+ *
149
+ * Sync delivers any missed copies eventually, so aborting fast is safe:
150
+ * the connect-flow fan-outs are best-effort and tolerate per-task failure.
151
+ */
152
+ const CONNECT_REQUEST_TIMEOUT_MS = 10_000;
124
153
 
125
154
  // ---------------------------------------------------------------------------
126
155
  // Types
@@ -690,45 +719,56 @@ async function createPermissionGrants(
690
719
  const dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
691
720
  logger.log(`Sending ${permissionGrants.length} permission grants to ${dwnEndpointUrls.length} DWN endpoint(s)...`);
692
721
 
693
- const messagePromises = permissionGrants.map(async (grant) => {
722
+ // Flatten (grant, endpoint) tuples into a single list of sends so that one
723
+ // global concurrency cap governs total in-flight requests during the
724
+ // connect flow — important when either dimension grows large.
725
+ const sendTasks = permissionGrants.flatMap((grant, grantIndex) => {
694
726
  const { encodedData, ...rawMessage } = grant.message;
695
727
  const data = Convert.base64Url(encodedData).toUint8Array();
728
+ return dwnEndpointUrls.map((dwnUrl) => ({ grantIndex, dwnUrl, rawMessage, data }));
729
+ });
696
730
 
697
- // The rawMessage is already signed by createGrant(), so we send it
698
- // directly to each endpoint without re-constructing.
699
- let atLeastOneSuccess = false;
700
- for (const dwnUrl of dwnEndpointUrls) {
701
- try {
702
- const reply = await agent.rpc.sendDwnRequest({
703
- dwnUrl,
704
- targetDid : selectedDid,
705
- message : rawMessage,
706
- data : new Blob([data as BlobPart]),
707
- });
731
+ const settled = await mapConcurrentSettled(
732
+ sendTasks,
733
+ CONNECT_FANOUT_CONCURRENCY,
734
+ async ({ grantIndex, dwnUrl, rawMessage, data }) => {
735
+ const reply = await agent.rpc.sendDwnRequest({
736
+ dwnUrl,
737
+ targetDid : selectedDid,
738
+ message : rawMessage,
739
+ data : new Blob([data as BlobPart]),
740
+ signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
741
+ });
742
+ return { grantIndex, dwnUrl, reply };
743
+ },
744
+ );
708
745
 
709
- if (reply.status.code === 202 || reply.status.code === 409) {
710
- atLeastOneSuccess = true;
711
- } else {
712
- logger.error(`Grant send to ${dwnUrl} returned ${reply.status.code}: ${reply.status.detail}`);
713
- }
714
- } catch (error: any) {
715
- logger.error(`Grant send to ${dwnUrl} failed: ${error.message}`);
716
- }
746
+ // Aggregate results back per grant: each grant must have at least one
747
+ // endpoint accept it (status 202 or 409 — already-stored is acceptable).
748
+ const successPerGrant = new Array<boolean>(permissionGrants.length).fill(false);
749
+ for (let i = 0; i < settled.length; i++) {
750
+ const result = settled[i];
751
+ if (result.status === 'rejected') {
752
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
753
+ logger.error(`Grant send to ${sendTasks[i].dwnUrl} failed: ${reason}`);
754
+ continue;
755
+ }
756
+ const { grantIndex, dwnUrl, reply } = result.value;
757
+ if (reply.status.code === 202 || reply.status.code === 409) {
758
+ successPerGrant[grantIndex] = true;
759
+ } else {
760
+ logger.error(`Grant send to ${dwnUrl} returned ${reply.status.code}: ${reply.status.detail}`);
717
761
  }
762
+ }
718
763
 
719
- if (!atLeastOneSuccess) {
764
+ for (let g = 0; g < permissionGrants.length; g++) {
765
+ if (!successPerGrant[g]) {
766
+ logger.error(`Error during batch-send of permission grants: grant ${g} reached no DWN endpoint.`);
720
767
  throw new Error('Could not send permission grant to any DWN endpoint.');
721
768
  }
722
-
723
- return grant.message;
724
- });
725
-
726
- try {
727
- return await Promise.all(messagePromises);
728
- } catch (error) {
729
- logger.error(`Error during batch-send of permission grants: ${error}`);
730
- throw error;
731
769
  }
770
+
771
+ return permissionGrants.map((g) => g.message);
732
772
  }
733
773
 
734
774
  // ---------------------------------------------------------------------------
@@ -736,24 +776,31 @@ async function createPermissionGrants(
736
776
  // ---------------------------------------------------------------------------
737
777
 
738
778
  /**
739
- * Installs a DWN protocol on the provider's DWN if it doesn't already exist.
740
- * Ensures the protocol is available on both the local and remote DWN.
779
+ * Ensures the protocol is installed on the provider's local DWN so that the
780
+ * agent can sign and (when applicable) encrypt grants for it during
781
+ * `submitConnectResponse`.
741
782
  *
742
- * When the protocol definition contains types with `encryptionRequired: true`,
743
- * the protocol is installed with `encryption: true` so that the agent injects
744
- * `$encryption` keys (derived from the owner's X25519 root key) into the
745
- * protocol definition. This ensures the protocol is immediately usable for
746
- * encrypted record operations by both the owner and any delegates.
783
+ * Remote installation (push to every owner DWN endpoint) is the
784
+ * responsibility of the calling client (the wallet's own `prepareProtocol`
785
+ * runs *before* `submitConnectResponse` and fans out to every endpoint in
786
+ * parallel). When the protocol already exists locally — the common case —
787
+ * this function performs a single local `ProtocolsQuery` and returns: there
788
+ * is no remote send, so a slow/unhealthy DWN endpoint cannot block the
789
+ * "Authorizing…" hot path.
790
+ *
791
+ * When the protocol is *not* installed locally — a safety fallback for
792
+ * callers that did not pre-install — the protocol is configured locally
793
+ * (with `encryption: true` when any type declares `encryptionRequired: true`,
794
+ * so the agent injects `$encryption` keys derived from the owner's X25519
795
+ * root key) and then fanned out to every owner DWN endpoint with bounded
796
+ * concurrency and a short per-request budget. Endpoint failures are
797
+ * non-fatal — sync delivers any missing copies eventually.
747
798
  */
748
799
  async function prepareProtocol(
749
800
  selectedDid: string,
750
801
  agent: EnboxPlatformAgent,
751
802
  protocolDefinition: DwnProtocolDefinition
752
803
  ): Promise<void> {
753
- // Detect whether any type in the protocol requires encryption.
754
- const needsEncryption = Object.values(protocolDefinition.types ?? {})
755
- .some((type: any) => type?.encryptionRequired === true);
756
-
757
804
  const queryMessage = await agent.processDwnRequest({
758
805
  author : selectedDid,
759
806
  messageType : DwnInterface.ProtocolsQuery,
@@ -763,42 +810,66 @@ async function prepareProtocol(
763
810
 
764
811
  if (queryMessage.reply.status.code !== 200) {
765
812
  throw new Error(`Could not fetch protocol: ${queryMessage.reply.status.detail}`);
766
- } else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) {
767
- logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`);
768
-
769
- const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({
770
- author : selectedDid,
771
- target : selectedDid,
772
- messageType : DwnInterface.ProtocolsConfigure,
773
- messageParams : { definition: protocolDefinition },
774
- encryption : needsEncryption || undefined,
775
- });
813
+ }
776
814
 
777
- if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
778
- throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
779
- }
815
+ const isInstalledLocally = queryMessage.reply.entries !== undefined
816
+ && queryMessage.reply.entries.length > 0;
817
+
818
+ if (isInstalledLocally) {
819
+ // Already installed locally. The wallet's pre-call `prepareProtocol`
820
+ // is responsible for fanning the protocol out to every owner DWN
821
+ // endpoint; sync delivers any missing copies eventually. Skipping the
822
+ // remote send here turns this hot path into a single local DB read
823
+ // (~10 ms) instead of a sequential per-endpoint network round-trip
824
+ // with retries — the latter could take minutes if any endpoint was
825
+ // slow or unreachable.
826
+ logger.log(`Protocol already installed locally: ${protocolDefinition.protocol}`);
827
+ return;
828
+ }
780
829
 
781
- await agent.processDwnRequest({
782
- author : selectedDid,
783
- target : selectedDid,
784
- messageType : DwnInterface.ProtocolsConfigure,
785
- rawMessage : configureMessage
786
- });
787
- } else {
788
- logger.log(`Protocol already exists: ${protocolDefinition.protocol}`);
789
-
790
- const configureMessage = queryMessage.reply.entries![0];
791
- const { reply: sendReply } = await agent.sendDwnRequest({
792
- author : selectedDid,
793
- target : selectedDid,
794
- messageType : DwnInterface.ProtocolsConfigure,
795
- rawMessage : configureMessage,
796
- });
830
+ // Safety fallback — protocol is missing locally, so the caller did not
831
+ // pre-install. Configure it locally (with encryption derivation if any
832
+ // type requires it) so the agent can sign/encrypt grants, then push to
833
+ // every owner DWN endpoint in parallel with a short per-request budget.
834
+ logger.log(`Protocol not installed, configuring locally: ${protocolDefinition.protocol}`);
835
+ const needsEncryption = Object.values(protocolDefinition.types ?? {})
836
+ .some((type: any) => type?.encryptionRequired === true);
797
837
 
798
- if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
799
- throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
800
- }
838
+ const { reply: configureReply, message: configureMessage } = await agent.processDwnRequest({
839
+ author : selectedDid,
840
+ target : selectedDid,
841
+ messageType : DwnInterface.ProtocolsConfigure,
842
+ messageParams : { definition: protocolDefinition },
843
+ encryption : needsEncryption || undefined,
844
+ });
845
+
846
+ if (configureReply.status.code !== 202 && configureReply.status.code !== 409) {
847
+ throw new Error(`Could not configure protocol locally: ${configureReply.status.detail}`);
801
848
  }
849
+
850
+ let dwnEndpointUrls: string[] = [];
851
+ try {
852
+ dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
853
+ } catch {
854
+ // Endpoint resolution failure — protocol stays local-only until sync.
855
+ }
856
+
857
+ if (dwnEndpointUrls.length === 0) {
858
+ return;
859
+ }
860
+
861
+ // Best-effort remote fan-out with bounded concurrency and a per-request
862
+ // abort signal. Failures are tolerated (sync delivers eventually).
863
+ await mapConcurrentSettled(
864
+ dwnEndpointUrls,
865
+ CONNECT_FANOUT_CONCURRENCY,
866
+ (dwnUrl) => agent.rpc.sendDwnRequest({
867
+ dwnUrl,
868
+ targetDid : selectedDid,
869
+ message : configureMessage!,
870
+ signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
871
+ }),
872
+ );
802
873
  }
803
874
 
804
875
  /**
@@ -1243,46 +1314,62 @@ async function submitConnectResponse(
1243
1314
  // Snapshot the current length — revocation grants are appended to delegateGrants
1244
1315
  // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1245
1316
  const sessionGrantCount = delegateGrants.length;
1246
- for (let i = 0; i < sessionGrantCount; i++) {
1247
- const grantMessage = delegateGrants[i];
1248
- const revGrant = await permissionsApi.createGrant({
1249
- delegated : true,
1250
- store : true,
1251
- grantedTo : delegateBearerDid.uri,
1252
- scope : {
1253
- interface : DwnInterfaceName.Records,
1254
- method : DwnMethodName.Write,
1255
- protocol : PermissionsProtocol.uri,
1256
- contextId : grantMessage.recordId,
1257
- },
1258
- dateExpires : '2040-06-25T16:09:16.693356Z',
1259
- author : selectedDid,
1260
- });
1317
+
1318
+ // Phase 1: create all revocation grants locally with bounded concurrency.
1319
+ // createGrant is local-only (storage + signing) so it's cheap, but we still
1320
+ // cap parallelism to avoid head-of-line blocking when sessionGrantCount is
1321
+ // large (e.g. dapp requesting many scopes at once).
1322
+ const revGrantResults = await mapConcurrent(
1323
+ delegateGrants.slice(0, sessionGrantCount),
1324
+ CONNECT_FANOUT_CONCURRENCY,
1325
+ (grantMessage) =>
1326
+ permissionsApi.createGrant({
1327
+ delegated : true,
1328
+ store : true,
1329
+ grantedTo : delegateBearerDid.uri,
1330
+ scope : {
1331
+ interface : DwnInterfaceName.Records,
1332
+ method : DwnMethodName.Write,
1333
+ protocol : PermissionsProtocol.uri,
1334
+ contextId : grantMessage.recordId,
1335
+ },
1336
+ dateExpires : '2040-06-25T16:09:16.693356Z',
1337
+ author : selectedDid,
1338
+ }).then((revGrant) => ({ grantMessage, revGrant })),
1339
+ );
1340
+
1341
+ // Phase 2: fan out every revocation grant to every owner DWN endpoint with
1342
+ // a single global concurrency cap so that (grants × endpoints) cannot blow
1343
+ // up. This is best-effort (sync delivers eventually) so individual failures
1344
+ // are tolerated by `mapConcurrentSettled`.
1345
+ const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
1261
1346
  sessionRevocations.push({
1262
1347
  grantId : grantMessage.recordId,
1263
1348
  revocationGrantId : revGrant.message.recordId,
1264
1349
  });
1265
1350
 
1266
- // Fan out the revocation grant to all owner DWN endpoints (same
1267
- // as session grants) so that immediate disconnect can send a
1268
- // delegated revocation to a remote DWN that recognises the grant.
1269
1351
  const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
1270
1352
  const revData = Convert.base64Url(revEncoded).toUint8Array();
1271
- for (const dwnUrl of revGrantEndpoints) {
1272
- try {
1273
- await agent.rpc.sendDwnRequest({
1353
+
1354
+ // Include the revocation grant in the delegate grants for distribution.
1355
+ delegateGrants.push(revGrant.message);
1356
+
1357
+ return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
1358
+ });
1359
+
1360
+ if (revSendTasks.length > 0) {
1361
+ await mapConcurrentSettled(
1362
+ revSendTasks,
1363
+ CONNECT_FANOUT_CONCURRENCY,
1364
+ ({ revRawMessage, revData, dwnUrl }) =>
1365
+ agent.rpc.sendDwnRequest({
1274
1366
  dwnUrl,
1275
1367
  targetDid : selectedDid,
1276
1368
  message : revRawMessage,
1277
1369
  data : new Blob([revData as BlobPart]),
1278
- });
1279
- } catch {
1280
- // Best-effort — sync will deliver eventually.
1281
- }
1282
- }
1283
-
1284
- // Include the revocation grant in the delegate grants for distribution
1285
- delegateGrants.push(revGrant.message);
1370
+ signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
1371
+ }),
1372
+ );
1286
1373
  }
1287
1374
 
1288
1375
  logger.log('Building connect response...');
package/src/utils.ts CHANGED
@@ -169,3 +169,72 @@ export function concatenateUrl(baseUrl: string, path: string): string {
169
169
 
170
170
  return `${baseUrl}/${path}`;
171
171
  }
172
+
173
+ /**
174
+ * Map over an array with bounded concurrency, preserving input order in the
175
+ * output array. Uses a sliding-window pool of workers so the next item is
176
+ * picked up as soon as any in-flight task settles — strictly better than a
177
+ * fixed chunked-batch pattern (no stalling on the slowest item per batch).
178
+ *
179
+ * Semantics match `Promise.all`: the returned promise rejects on the first
180
+ * task rejection. Use {@link mapConcurrentSettled} when individual failures
181
+ * should not abort the whole batch (e.g. best-effort fan-out).
182
+ *
183
+ * @param items Input items to map over.
184
+ * @param concurrency Maximum number of in-flight tasks. Must be >= 1.
185
+ * @param fn Per-item async function. Receives the item and its index.
186
+ * @returns An array of results in the same order as `items`.
187
+ */
188
+ export async function mapConcurrent<T, R>(
189
+ items: readonly T[],
190
+ concurrency: number,
191
+ fn: (item: T, index: number) => Promise<R>,
192
+ ): Promise<R[]> {
193
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
194
+ throw new Error(`mapConcurrent: concurrency must be a positive integer, got ${concurrency}`);
195
+ }
196
+ if (items.length === 0) {
197
+ return [];
198
+ }
199
+
200
+ const results = new Array<R>(items.length);
201
+ let next = 0;
202
+
203
+ const worker = async (): Promise<void> => {
204
+ while (true) {
205
+ const i = next++;
206
+ if (i >= items.length) {
207
+ return;
208
+ }
209
+ results[i] = await fn(items[i], i);
210
+ }
211
+ };
212
+
213
+ const workerCount = Math.min(concurrency, items.length);
214
+ const workers: Promise<void>[] = [];
215
+ for (let w = 0; w < workerCount; w++) {
216
+ workers.push(worker());
217
+ }
218
+ await Promise.all(workers);
219
+ return results;
220
+ }
221
+
222
+ /**
223
+ * Settled variant of {@link mapConcurrent}: never rejects — every task's
224
+ * outcome is captured as a `PromiseSettledResult`. Use this for best-effort
225
+ * fan-outs where partial success is acceptable.
226
+ */
227
+ export async function mapConcurrentSettled<T, R>(
228
+ items: readonly T[],
229
+ concurrency: number,
230
+ fn: (item: T, index: number) => Promise<R>,
231
+ ): Promise<PromiseSettledResult<R>[]> {
232
+ return mapConcurrent(items, concurrency, async (item, index) => {
233
+ try {
234
+ const value = await fn(item, index);
235
+ return { status: 'fulfilled', value } as PromiseFulfilledResult<R>;
236
+ } catch (reason) {
237
+ return { status: 'rejected', reason } as PromiseRejectedResult;
238
+ }
239
+ });
240
+ }