@enbox/agent 0.6.6 → 0.6.8
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/dist/browser.mjs +9 -9
- package/dist/browser.mjs.map +3 -3
- package/dist/esm/enbox-connect-protocol.js +82 -65
- package/dist/esm/enbox-connect-protocol.js.map +1 -1
- package/dist/esm/utils.js +61 -0
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
- package/dist/types/utils.d.ts +22 -0
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/enbox-connect-protocol.ts +99 -60
- package/src/utils.ts +69 -0
|
@@ -115,12 +115,26 @@ 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;
|
|
124
138
|
|
|
125
139
|
// ---------------------------------------------------------------------------
|
|
126
140
|
// Types
|
|
@@ -690,45 +704,55 @@ async function createPermissionGrants(
|
|
|
690
704
|
const dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
|
|
691
705
|
logger.log(`Sending ${permissionGrants.length} permission grants to ${dwnEndpointUrls.length} DWN endpoint(s)...`);
|
|
692
706
|
|
|
693
|
-
|
|
707
|
+
// Flatten (grant, endpoint) tuples into a single list of sends so that one
|
|
708
|
+
// global concurrency cap governs total in-flight requests during the
|
|
709
|
+
// connect flow — important when either dimension grows large.
|
|
710
|
+
const sendTasks = permissionGrants.flatMap((grant, grantIndex) => {
|
|
694
711
|
const { encodedData, ...rawMessage } = grant.message;
|
|
695
712
|
const data = Convert.base64Url(encodedData).toUint8Array();
|
|
713
|
+
return dwnEndpointUrls.map((dwnUrl) => ({ grantIndex, dwnUrl, rawMessage, data }));
|
|
714
|
+
});
|
|
696
715
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
716
|
+
const settled = await mapConcurrentSettled(
|
|
717
|
+
sendTasks,
|
|
718
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
719
|
+
async ({ grantIndex, dwnUrl, rawMessage, data }) => {
|
|
720
|
+
const reply = await agent.rpc.sendDwnRequest({
|
|
721
|
+
dwnUrl,
|
|
722
|
+
targetDid : selectedDid,
|
|
723
|
+
message : rawMessage,
|
|
724
|
+
data : new Blob([data as BlobPart]),
|
|
725
|
+
});
|
|
726
|
+
return { grantIndex, dwnUrl, reply };
|
|
727
|
+
},
|
|
728
|
+
);
|
|
708
729
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
730
|
+
// Aggregate results back per grant: each grant must have at least one
|
|
731
|
+
// endpoint accept it (status 202 or 409 — already-stored is acceptable).
|
|
732
|
+
const successPerGrant = new Array<boolean>(permissionGrants.length).fill(false);
|
|
733
|
+
for (let i = 0; i < settled.length; i++) {
|
|
734
|
+
const result = settled[i];
|
|
735
|
+
if (result.status === 'rejected') {
|
|
736
|
+
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
737
|
+
logger.error(`Grant send to ${sendTasks[i].dwnUrl} failed: ${reason}`);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
const { grantIndex, dwnUrl, reply } = result.value;
|
|
741
|
+
if (reply.status.code === 202 || reply.status.code === 409) {
|
|
742
|
+
successPerGrant[grantIndex] = true;
|
|
743
|
+
} else {
|
|
744
|
+
logger.error(`Grant send to ${dwnUrl} returned ${reply.status.code}: ${reply.status.detail}`);
|
|
717
745
|
}
|
|
746
|
+
}
|
|
718
747
|
|
|
719
|
-
|
|
748
|
+
for (let g = 0; g < permissionGrants.length; g++) {
|
|
749
|
+
if (!successPerGrant[g]) {
|
|
750
|
+
logger.error(`Error during batch-send of permission grants: grant ${g} reached no DWN endpoint.`);
|
|
720
751
|
throw new Error('Could not send permission grant to any DWN endpoint.');
|
|
721
752
|
}
|
|
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
753
|
}
|
|
754
|
+
|
|
755
|
+
return permissionGrants.map((g) => g.message);
|
|
732
756
|
}
|
|
733
757
|
|
|
734
758
|
// ---------------------------------------------------------------------------
|
|
@@ -1243,46 +1267,61 @@ async function submitConnectResponse(
|
|
|
1243
1267
|
// Snapshot the current length — revocation grants are appended to delegateGrants
|
|
1244
1268
|
// below, but we must NOT iterate over them (they are meta-grants, not session grants).
|
|
1245
1269
|
const sessionGrantCount = delegateGrants.length;
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1270
|
+
|
|
1271
|
+
// Phase 1: create all revocation grants locally with bounded concurrency.
|
|
1272
|
+
// createGrant is local-only (storage + signing) so it's cheap, but we still
|
|
1273
|
+
// cap parallelism to avoid head-of-line blocking when sessionGrantCount is
|
|
1274
|
+
// large (e.g. dapp requesting many scopes at once).
|
|
1275
|
+
const revGrantResults = await mapConcurrent(
|
|
1276
|
+
delegateGrants.slice(0, sessionGrantCount),
|
|
1277
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
1278
|
+
(grantMessage) =>
|
|
1279
|
+
permissionsApi.createGrant({
|
|
1280
|
+
delegated : true,
|
|
1281
|
+
store : true,
|
|
1282
|
+
grantedTo : delegateBearerDid.uri,
|
|
1283
|
+
scope : {
|
|
1284
|
+
interface : DwnInterfaceName.Records,
|
|
1285
|
+
method : DwnMethodName.Write,
|
|
1286
|
+
protocol : PermissionsProtocol.uri,
|
|
1287
|
+
contextId : grantMessage.recordId,
|
|
1288
|
+
},
|
|
1289
|
+
dateExpires : '2040-06-25T16:09:16.693356Z',
|
|
1290
|
+
author : selectedDid,
|
|
1291
|
+
}).then((revGrant) => ({ grantMessage, revGrant })),
|
|
1292
|
+
);
|
|
1293
|
+
|
|
1294
|
+
// Phase 2: fan out every revocation grant to every owner DWN endpoint with
|
|
1295
|
+
// a single global concurrency cap so that (grants × endpoints) cannot blow
|
|
1296
|
+
// up. This is best-effort (sync delivers eventually) so individual failures
|
|
1297
|
+
// are tolerated by `mapConcurrentSettled`.
|
|
1298
|
+
const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
|
|
1261
1299
|
sessionRevocations.push({
|
|
1262
1300
|
grantId : grantMessage.recordId,
|
|
1263
1301
|
revocationGrantId : revGrant.message.recordId,
|
|
1264
1302
|
});
|
|
1265
1303
|
|
|
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
1304
|
const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
|
|
1270
1305
|
const revData = Convert.base64Url(revEncoded).toUint8Array();
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1306
|
+
|
|
1307
|
+
// Include the revocation grant in the delegate grants for distribution.
|
|
1308
|
+
delegateGrants.push(revGrant.message);
|
|
1309
|
+
|
|
1310
|
+
return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
if (revSendTasks.length > 0) {
|
|
1314
|
+
await mapConcurrentSettled(
|
|
1315
|
+
revSendTasks,
|
|
1316
|
+
CONNECT_FANOUT_CONCURRENCY,
|
|
1317
|
+
({ revRawMessage, revData, dwnUrl }) =>
|
|
1318
|
+
agent.rpc.sendDwnRequest({
|
|
1274
1319
|
dwnUrl,
|
|
1275
1320
|
targetDid : selectedDid,
|
|
1276
1321
|
message : revRawMessage,
|
|
1277
1322
|
data : new Blob([revData as BlobPart]),
|
|
1278
|
-
})
|
|
1279
|
-
|
|
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);
|
|
1323
|
+
}),
|
|
1324
|
+
);
|
|
1286
1325
|
}
|
|
1287
1326
|
|
|
1288
1327
|
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
|
+
}
|