@enbox/agent 0.6.8 → 0.7.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.
Files changed (52) hide show
  1. package/dist/browser.mjs +11 -11
  2. package/dist/browser.mjs.map +4 -4
  3. package/dist/esm/agent-session.js +16 -0
  4. package/dist/esm/agent-session.js.map +1 -0
  5. package/dist/esm/dwn-api.js +5 -4
  6. package/dist/esm/dwn-api.js.map +1 -1
  7. package/dist/esm/dwn-encryption.js +11 -4
  8. package/dist/esm/dwn-encryption.js.map +1 -1
  9. package/dist/esm/enbox-connect-protocol.js +455 -246
  10. package/dist/esm/enbox-connect-protocol.js.map +1 -1
  11. package/dist/esm/index.js +1 -0
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/local-key-manager.js.map +1 -1
  14. package/dist/esm/protocol-utils.js +19 -6
  15. package/dist/esm/protocol-utils.js.map +1 -1
  16. package/dist/esm/store-data.js.map +1 -1
  17. package/dist/esm/store-key.js +1 -1
  18. package/dist/esm/store-key.js.map +1 -1
  19. package/dist/esm/sync-engine-level.js +18 -5
  20. package/dist/esm/sync-engine-level.js.map +1 -1
  21. package/dist/esm/types/dwn.js.map +1 -1
  22. package/dist/esm/utils.js +0 -12
  23. package/dist/esm/utils.js.map +1 -1
  24. package/dist/types/agent-session.d.ts +59 -0
  25. package/dist/types/agent-session.d.ts.map +1 -0
  26. package/dist/types/dwn-api.d.ts.map +1 -1
  27. package/dist/types/dwn-encryption.d.ts.map +1 -1
  28. package/dist/types/enbox-connect-protocol.d.ts +34 -3
  29. package/dist/types/enbox-connect-protocol.d.ts.map +1 -1
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/index.d.ts.map +1 -1
  32. package/dist/types/local-key-manager.d.ts.map +1 -1
  33. package/dist/types/protocol-utils.d.ts.map +1 -1
  34. package/dist/types/sync-engine-level.d.ts +6 -1
  35. package/dist/types/sync-engine-level.d.ts.map +1 -1
  36. package/dist/types/types/dwn.d.ts +10 -1
  37. package/dist/types/types/dwn.d.ts.map +1 -1
  38. package/dist/types/utils.d.ts +0 -2
  39. package/dist/types/utils.d.ts.map +1 -1
  40. package/package.json +6 -6
  41. package/src/agent-session.ts +78 -0
  42. package/src/dwn-api.ts +5 -4
  43. package/src/dwn-encryption.ts +14 -6
  44. package/src/enbox-connect-protocol.ts +556 -298
  45. package/src/index.ts +1 -0
  46. package/src/local-key-manager.ts +7 -3
  47. package/src/protocol-utils.ts +19 -8
  48. package/src/store-data.ts +1 -1
  49. package/src/store-key.ts +1 -1
  50. package/src/sync-engine-level.ts +19 -6
  51. package/src/types/dwn.ts +21 -12
  52. package/src/utils.ts +3 -18
@@ -18,7 +18,7 @@ import type { EnboxPlatformAgent } from './types/agent.js';
18
18
  import type { PrivateKeyJwk } from '@enbox/crypto';
19
19
  import type { RequireOnly } from '@enbox/common';
20
20
  import type { DidDocument, PortableDid } from '@enbox/dids';
21
- import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js';
21
+ import type { DwnDataEncodedRecordsWriteMessage, DwnPermissionScope, DwnProtocolDefinition, DwnRecordsPermissionScope } from './types/dwn.js';
22
22
 
23
23
  /**
24
24
  * The protocols of permissions requested, along with the definition and permission scopes for each protocol.
@@ -103,7 +103,7 @@ import type {
103
103
  Jwk } from '@enbox/crypto';
104
104
 
105
105
  import { type BearerDid, DidJwk } from '@enbox/dids';
106
- import { Convert, logger } from '@enbox/common';
106
+ import { concatenateUrl, Convert, logger, nowMs, timed } from '@enbox/common';
107
107
  import {
108
108
  CryptoUtils,
109
109
  Ed25519,
@@ -120,7 +120,7 @@ import { getEncryptionKeyInfo } from './dwn-encryption.js';
120
120
  import { isMultiPartyContext } from './protocol-utils.js';
121
121
  import { isRecordPermissionScope } from './dwn-api.js';
122
122
  import { KeyDeliveryProtocolDefinition } from './store-data-protocols.js';
123
- import { concatenateUrl, mapConcurrent, mapConcurrentSettled } from './utils.js';
123
+ import { mapConcurrent, mapConcurrentSettled } from './utils.js';
124
124
 
125
125
  // ---------------------------------------------------------------------------
126
126
  // Tunables
@@ -136,6 +136,24 @@ import { concatenateUrl, mapConcurrent, mapConcurrentSettled } from './utils.js'
136
136
  */
137
137
  const CONNECT_FANOUT_CONCURRENCY = 8;
138
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;
153
+
154
+ /** Log namespace used for wallet-side connect critical-path timings. */
155
+ const CONNECT_PERF_LOG_PREFIX = '[connect.perf]';
156
+
139
157
  // ---------------------------------------------------------------------------
140
158
  // Types
141
159
  // ---------------------------------------------------------------------------
@@ -318,13 +336,20 @@ function buildConnectUrl({
318
336
  // JWT signing and verification
319
337
  // ---------------------------------------------------------------------------
320
338
 
321
- /** Signs an object as a JWT using an Ed25519 DID key. */
339
+ /**
340
+ * Signs an object as a JWT using an Ed25519 DID key.
341
+ *
342
+ * `data` is constrained to `object` so callers don't have to widen
343
+ * typed payload shapes (e.g. `EnboxConnectResponse`) to
344
+ * `Record<string, unknown>` at the call site. `Convert.object(data)`
345
+ * stringifies whatever JSON-serializable shape is passed.
346
+ */
322
347
  async function signJwt({
323
348
  did,
324
349
  data,
325
350
  }: {
326
351
  did: BearerDid;
327
- data: Record<string, unknown>;
352
+ data: object;
328
353
  }): Promise<string> {
329
354
  const header = Convert.object({
330
355
  alg : 'EdDSA',
@@ -343,7 +368,17 @@ async function signJwt({
343
368
  return `${header}.${payload}.${signatureBase64Url}`;
344
369
  }
345
370
 
346
- /** Verifies a JWT signature using the DID in the `kid` header. Returns the parsed payload. */
371
+ /**
372
+ * Verifies a JWT signature using the DID in the `kid` header. Returns the
373
+ * parsed payload as an untyped object.
374
+ *
375
+ * The return type is intentionally `Record<string, unknown>` rather than a
376
+ * caller-supplied generic — a JWT payload is bytes from a remote party, and
377
+ * we can't soundly assert its shape without runtime validation. Callers must
378
+ * apply one of the {@link assertConnectRequest} / {@link assertConnectResponse}
379
+ * assertion helpers (or their own type guard) to narrow the payload before
380
+ * accessing fields.
381
+ */
347
382
  async function verifyJwt({ jwt }: { jwt: string }): Promise<Record<string, unknown>> {
348
383
  const [headerB64U, payloadB64U, signatureB64U] = jwt.split('.');
349
384
 
@@ -379,7 +414,129 @@ async function verifyJwt({ jwt }: { jwt: string }): Promise<Record<string, unkno
379
414
  throw new Error('Connect: JWT verification failed — invalid signature.');
380
415
  }
381
416
 
382
- return Convert.base64Url(payloadB64U).toObject() as Record<string, unknown>;
417
+ const decoded: unknown = Convert.base64Url(payloadB64U).toObject();
418
+ if (typeof decoded !== 'object' || decoded === null || Array.isArray(decoded)) {
419
+ throw new Error('Connect: JWT verification failed — payload must be a JSON object.');
420
+ }
421
+ return decoded as Record<string, unknown>;
422
+ }
423
+
424
+ // ─── Field-level validation helpers ─────────────────────────────────────
425
+ //
426
+ // The connect-request / connect-response assertions below describe their
427
+ // expected shape declaratively in terms of these primitive checks. Each
428
+ // helper throws a consistent `Connect: <context> — \`<field>\` <reason>`
429
+ // message on mismatch, so per-field error formatting is centralized here
430
+ // rather than duplicated at every call site.
431
+
432
+ /**
433
+ * Throws a shape-validation error during connect JWT assertion.
434
+ *
435
+ * Deliberately throws plain `Error` rather than `TypeError` even though
436
+ * the failures are runtime type-shape mismatches. The reason is layered
437
+ * error handling: boundary-validation failures need to propagate through
438
+ * the same `try/catch` paths as every other connect-flow error — vault
439
+ * lock failure, JWT signature failure, DWN request failure, etc. —
440
+ * without `catch` blocks having to special-case a `TypeError` subclass.
441
+ *
442
+ * SonarCloud's `typescript:S7786` flags this as "too unspecific for a
443
+ * type check" and prefers `TypeError`. We suppress it at the file level
444
+ * (see `sonar-project.properties`) because every `require*` helper goes
445
+ * through this single throw site.
446
+ */
447
+ function fail(context: string, field: string, reason: string): never {
448
+ throw new Error(`Connect: ${context} — \`${field}\` ${reason}.`);
449
+ }
450
+
451
+ function requireString(payload: Record<string, unknown>, field: string, context: string): void {
452
+ if (typeof payload[field] !== 'string') { fail(context, field, 'must be a string'); }
453
+ }
454
+
455
+ function requireNumber(payload: Record<string, unknown>, field: string, context: string): void {
456
+ if (typeof payload[field] !== 'number') { fail(context, field, 'must be a number'); }
457
+ }
458
+
459
+ function requireArray(payload: Record<string, unknown>, field: string, context: string): void {
460
+ if (!Array.isArray(payload[field])) { fail(context, field, 'must be an array'); }
461
+ }
462
+
463
+ function requireObject(payload: Record<string, unknown>, field: string, context: string): void {
464
+ const value = payload[field];
465
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
466
+ fail(context, field, 'must be an object');
467
+ }
468
+ }
469
+
470
+ function requireLiteral<L extends string | number | boolean>(
471
+ payload: Record<string, unknown>, field: string, expected: L, context: string,
472
+ ): void {
473
+ if (payload[field] !== expected) { fail(context, field, `must be ${JSON.stringify(expected)}`); }
474
+ }
475
+
476
+ function requireStringArray(payload: Record<string, unknown>, field: string, context: string): void {
477
+ const value = payload[field];
478
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
479
+ fail(context, field, 'must be a string[]');
480
+ }
481
+ }
482
+
483
+ function requireOptionalString(payload: Record<string, unknown>, field: string, context: string): void {
484
+ if (payload[field] !== undefined && typeof payload[field] !== 'string') {
485
+ fail(context, field, 'must be a string when present');
486
+ }
487
+ }
488
+
489
+ function requireOptionalArray(payload: Record<string, unknown>, field: string, context: string): void {
490
+ if (payload[field] !== undefined && !Array.isArray(payload[field])) {
491
+ fail(context, field, 'must be an array when present');
492
+ }
493
+ }
494
+
495
+ // ─── Boundary assertions ────────────────────────────────────────────────
496
+ //
497
+ // Each `assertConnect*` describes the shape of its target type as a list
498
+ // of per-field requirements. The structural existence/primitive checks
499
+ // are the only validation done at the JWT-payload boundary; deeper
500
+ // validation of nested arrays/objects (permission scopes, grants,
501
+ // portable DID structure) happens downstream in DWN-aware validators
502
+ // where the richer logic already lives.
503
+
504
+ /**
505
+ * Runtime assertion that a verified JWT payload has the shape of an
506
+ * {@link EnboxConnectRequest}. Use immediately after `verifyJwt()` to narrow
507
+ * a `Record<string, unknown>` payload before accessing fields.
508
+ */
509
+ function assertConnectRequest(payload: Record<string, unknown>): asserts payload is EnboxConnectRequest {
510
+ const ctx = 'invalid connect request';
511
+ requireString(payload, 'clientDid', ctx);
512
+ requireString(payload, 'appName', ctx);
513
+ requireArray(payload, 'permissionRequests', ctx);
514
+ requireString(payload, 'nonce', ctx);
515
+ requireString(payload, 'state', ctx);
516
+ requireString(payload, 'callbackUrl', ctx);
517
+ requireLiteral(payload, 'responseMode', 'direct_post', ctx);
518
+ requireStringArray(payload, 'supportedDidMethods', ctx);
519
+ }
520
+
521
+ /**
522
+ * Runtime assertion that a verified JWT payload has the shape of an
523
+ * {@link EnboxConnectResponse}. Use immediately after `verifyJwt()` to narrow
524
+ * a `Record<string, unknown>` payload before accessing fields.
525
+ */
526
+ function assertConnectResponse(payload: Record<string, unknown>): asserts payload is EnboxConnectResponse {
527
+ const ctx = 'invalid connect response';
528
+ requireString(payload, 'providerDid', ctx);
529
+ requireString(payload, 'delegateDid', ctx);
530
+ requireString(payload, 'aud', ctx);
531
+ requireNumber(payload, 'iat', ctx);
532
+ requireNumber(payload, 'exp', ctx);
533
+ requireOptionalString(payload, 'nonce', ctx);
534
+ requireArray(payload, 'delegateGrants', ctx);
535
+ requireObject(payload, 'delegatePortableDid', ctx);
536
+ requireOptionalArray(payload, 'delegateDecryptionKeys', ctx);
537
+ requireOptionalArray(payload, 'delegateContextKeys', ctx);
538
+ requireOptionalArray(payload, 'delegateMultiPartyProtocols', ctx);
539
+ requireOptionalArray(payload, 'sessionRevocations', ctx);
383
540
  }
384
541
 
385
542
  // ---------------------------------------------------------------------------
@@ -622,7 +779,9 @@ async function getConnectRequest(requestUri: string, encryptionKey: string): Pro
622
779
  const response = await fetch(requestUri, { signal: AbortSignal.timeout(30_000) });
623
780
  const jwe = await response.text();
624
781
  const jwt = await decryptRequest({ jwe, encryptionKey });
625
- return (await verifyJwt({ jwt })) as unknown as EnboxConnectRequest;
782
+ const payload = await verifyJwt({ jwt });
783
+ assertConnectRequest(payload);
784
+ return payload;
626
785
  }
627
786
 
628
787
  // ---------------------------------------------------------------------------
@@ -722,6 +881,7 @@ async function createPermissionGrants(
722
881
  targetDid : selectedDid,
723
882
  message : rawMessage,
724
883
  data : new Blob([data as BlobPart]),
884
+ signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
725
885
  });
726
886
  return { grantIndex, dwnUrl, reply };
727
887
  },
@@ -760,24 +920,31 @@ async function createPermissionGrants(
760
920
  // ---------------------------------------------------------------------------
761
921
 
762
922
  /**
763
- * Installs a DWN protocol on the provider's DWN if it doesn't already exist.
764
- * Ensures the protocol is available on both the local and remote DWN.
923
+ * Ensures the protocol is installed on the provider's local DWN so that the
924
+ * agent can sign and (when applicable) encrypt grants for it during
925
+ * `submitConnectResponse`.
926
+ *
927
+ * Remote installation (push to every owner DWN endpoint) is the
928
+ * responsibility of the calling client (the wallet's own `prepareProtocol`
929
+ * runs *before* `submitConnectResponse` and fans out to every endpoint in
930
+ * parallel). When the protocol already exists locally — the common case —
931
+ * this function performs a single local `ProtocolsQuery` and returns: there
932
+ * is no remote send, so a slow/unhealthy DWN endpoint cannot block the
933
+ * "Authorizing…" hot path.
765
934
  *
766
- * When the protocol definition contains types with `encryptionRequired: true`,
767
- * the protocol is installed with `encryption: true` so that the agent injects
768
- * `$encryption` keys (derived from the owner's X25519 root key) into the
769
- * protocol definition. This ensures the protocol is immediately usable for
770
- * encrypted record operations by both the owner and any delegates.
935
+ * When the protocol is *not* installed locally a safety fallback for
936
+ * callers that did not pre-install the protocol is configured locally
937
+ * (with `encryption: true` when any type declares `encryptionRequired: true`,
938
+ * so the agent injects `$encryption` keys derived from the owner's X25519
939
+ * root key) and then fanned out to every owner DWN endpoint with bounded
940
+ * concurrency and a short per-request budget. Endpoint failures are
941
+ * non-fatal — sync delivers any missing copies eventually.
771
942
  */
772
943
  async function prepareProtocol(
773
944
  selectedDid: string,
774
945
  agent: EnboxPlatformAgent,
775
946
  protocolDefinition: DwnProtocolDefinition
776
947
  ): Promise<void> {
777
- // Detect whether any type in the protocol requires encryption.
778
- const needsEncryption = Object.values(protocolDefinition.types ?? {})
779
- .some((type: any) => type?.encryptionRequired === true);
780
-
781
948
  const queryMessage = await agent.processDwnRequest({
782
949
  author : selectedDid,
783
950
  messageType : DwnInterface.ProtocolsQuery,
@@ -787,42 +954,66 @@ async function prepareProtocol(
787
954
 
788
955
  if (queryMessage.reply.status.code !== 200) {
789
956
  throw new Error(`Could not fetch protocol: ${queryMessage.reply.status.detail}`);
790
- } else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) {
791
- logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`);
792
-
793
- const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({
794
- author : selectedDid,
795
- target : selectedDid,
796
- messageType : DwnInterface.ProtocolsConfigure,
797
- messageParams : { definition: protocolDefinition },
798
- encryption : needsEncryption || undefined,
799
- });
957
+ }
800
958
 
801
- if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
802
- throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
803
- }
959
+ const isInstalledLocally = queryMessage.reply.entries !== undefined
960
+ && queryMessage.reply.entries.length > 0;
961
+
962
+ if (isInstalledLocally) {
963
+ // Already installed locally. The wallet's pre-call `prepareProtocol`
964
+ // is responsible for fanning the protocol out to every owner DWN
965
+ // endpoint; sync delivers any missing copies eventually. Skipping the
966
+ // remote send here turns this hot path into a single local DB read
967
+ // (~10 ms) instead of a sequential per-endpoint network round-trip
968
+ // with retries — the latter could take minutes if any endpoint was
969
+ // slow or unreachable.
970
+ logger.log(`Protocol already installed locally: ${protocolDefinition.protocol}`);
971
+ return;
972
+ }
804
973
 
805
- await agent.processDwnRequest({
806
- author : selectedDid,
807
- target : selectedDid,
808
- messageType : DwnInterface.ProtocolsConfigure,
809
- rawMessage : configureMessage
810
- });
811
- } else {
812
- logger.log(`Protocol already exists: ${protocolDefinition.protocol}`);
813
-
814
- const configureMessage = queryMessage.reply.entries![0];
815
- const { reply: sendReply } = await agent.sendDwnRequest({
816
- author : selectedDid,
817
- target : selectedDid,
818
- messageType : DwnInterface.ProtocolsConfigure,
819
- rawMessage : configureMessage,
820
- });
974
+ // Safety fallback — protocol is missing locally, so the caller did not
975
+ // pre-install. Configure it locally (with encryption derivation if any
976
+ // type requires it) so the agent can sign/encrypt grants, then push to
977
+ // every owner DWN endpoint in parallel with a short per-request budget.
978
+ logger.log(`Protocol not installed, configuring locally: ${protocolDefinition.protocol}`);
979
+ const needsEncryption = Object.values(protocolDefinition.types ?? {})
980
+ .some((type: any) => type?.encryptionRequired === true);
821
981
 
822
- if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
823
- throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
824
- }
982
+ const { reply: configureReply, message: configureMessage } = await agent.processDwnRequest({
983
+ author : selectedDid,
984
+ target : selectedDid,
985
+ messageType : DwnInterface.ProtocolsConfigure,
986
+ messageParams : { definition: protocolDefinition },
987
+ encryption : needsEncryption || undefined,
988
+ });
989
+
990
+ if (configureReply.status.code !== 202 && configureReply.status.code !== 409) {
991
+ throw new Error(`Could not configure protocol locally: ${configureReply.status.detail}`);
825
992
  }
993
+
994
+ let dwnEndpointUrls: string[] = [];
995
+ try {
996
+ dwnEndpointUrls = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
997
+ } catch {
998
+ // Endpoint resolution failure — protocol stays local-only until sync.
999
+ }
1000
+
1001
+ if (dwnEndpointUrls.length === 0) {
1002
+ return;
1003
+ }
1004
+
1005
+ // Best-effort remote fan-out with bounded concurrency and a per-request
1006
+ // abort signal. Failures are tolerated (sync delivers eventually).
1007
+ await mapConcurrentSettled(
1008
+ dwnEndpointUrls,
1009
+ CONNECT_FANOUT_CONCURRENCY,
1010
+ (dwnUrl) => agent.rpc.sendDwnRequest({
1011
+ dwnUrl,
1012
+ targetDid : selectedDid,
1013
+ message : configureMessage!,
1014
+ signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
1015
+ }),
1016
+ );
826
1017
  }
827
1018
 
828
1019
  /**
@@ -856,10 +1047,12 @@ async function deriveScopedDecryptionKeys(
856
1047
  DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
857
1048
  ]);
858
1049
 
859
- // Collect read-like scopes only.
1050
+ // Collect read-like scopes only. `isRecordPermissionScope` narrows to
1051
+ // `DwnRecordsPermissionScope`, which declares `protocolPath?: string`
1052
+ // and `contextId?: string` — no `as any` needed for those reads below.
860
1053
  const readScopes = scopes.filter(
861
- (s): s is DwnPermissionScope & { method: string } =>
862
- isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1054
+ (s): s is DwnRecordsPermissionScope =>
1055
+ isRecordPermissionScope(s) && readMethods.has(s.method),
863
1056
  );
864
1057
 
865
1058
  if (readScopes.length === 0) {
@@ -868,7 +1061,7 @@ async function deriveScopedDecryptionKeys(
868
1061
 
869
1062
  // Fail closed: reject contextId-scoped encrypted reads.
870
1063
  for (const scope of readScopes) {
871
- if ('contextId' in scope && (scope as any).contextId) {
1064
+ if (scope.contextId) {
872
1065
  throw new Error(
873
1066
  `Encrypted delegate access scoped by contextId is not supported ` +
874
1067
  `yet; use protocol-wide permissions for protocol '${protocolUri}'.`,
@@ -889,9 +1082,7 @@ async function deriveScopedDecryptionKeys(
889
1082
  }
890
1083
 
891
1084
  // Check if any scope is protocol-wide (no protocolPath).
892
- const hasProtocolWideRead = readScopes.some(
893
- (s) => !('protocolPath' in s) || !(s as any).protocolPath,
894
- );
1085
+ const hasProtocolWideRead = readScopes.some((s) => !s.protocolPath);
895
1086
 
896
1087
  const { keyId, keyUri } = await getEncryptionKeyInfo(agent, ownerDid);
897
1088
 
@@ -920,8 +1111,7 @@ async function deriveScopedDecryptionKeys(
920
1111
  // Emit one exact-path key per unique protocolPath.
921
1112
  const uniquePaths = new Set<string>();
922
1113
  for (const scope of readScopes) {
923
- const pp = (scope as any).protocolPath as string | undefined;
924
- if (pp) { uniquePaths.add(pp); }
1114
+ if (scope.protocolPath) { uniquePaths.add(scope.protocolPath); }
925
1115
  }
926
1116
 
927
1117
  const keys: DelegateDecryptionKey[] = [];
@@ -1011,9 +1201,12 @@ async function deriveContextKeysForDelegate(
1011
1201
  DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
1012
1202
  ]);
1013
1203
 
1204
+ // `isRecordPermissionScope` narrows to `DwnRecordsPermissionScope`,
1205
+ // which declares `protocolPath?: string` and `contextId?: string` —
1206
+ // no `as any` needed for the field reads below.
1014
1207
  const readScopes = scopes.filter(
1015
- (s): s is DwnPermissionScope & { method: string } =>
1016
- isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1208
+ (s): s is DwnRecordsPermissionScope =>
1209
+ isRecordPermissionScope(s) && readMethods.has(s.method),
1017
1210
  );
1018
1211
 
1019
1212
  if (readScopes.length === 0) {
@@ -1022,7 +1215,7 @@ async function deriveContextKeysForDelegate(
1022
1215
 
1023
1216
  // Fail closed: reject contextId-scoped reads.
1024
1217
  for (const scope of readScopes) {
1025
- if ('contextId' in scope && (scope as any).contextId) {
1218
+ if (scope.contextId) {
1026
1219
  throw new Error(
1027
1220
  `Encrypted delegate access scoped by contextId is not supported ` +
1028
1221
  `yet; use protocol-wide permissions for protocol ` +
@@ -1033,7 +1226,7 @@ async function deriveContextKeysForDelegate(
1033
1226
 
1034
1227
  // Fail closed: reject protocolPath-scoped reads on multi-party protocols.
1035
1228
  for (const scope of readScopes) {
1036
- if ('protocolPath' in scope && (scope as any).protocolPath) {
1229
+ if (scope.protocolPath) {
1037
1230
  throw new Error(
1038
1231
  `Encrypted delegate access scoped by protocolPath on multi-party ` +
1039
1232
  `protocols is not supported yet; use protocol-wide permissions for ` +
@@ -1066,8 +1259,7 @@ async function deriveContextKeysForDelegate(
1066
1259
  });
1067
1260
 
1068
1261
  for (const entry of reply.entries ?? []) {
1069
- const rootContextId = (entry as any).contextId?.split('/')[0]
1070
- || (entry as any).recordId;
1262
+ const rootContextId = entry.contextId?.split('/')[0] ?? entry.recordId;
1071
1263
 
1072
1264
  if (!rootContextId || seenContextIds.has(rootContextId)) { continue; }
1073
1265
  seenContextIds.add(rootContextId);
@@ -1119,260 +1311,324 @@ async function submitConnectResponse(
1119
1311
  pin: string | undefined,
1120
1312
  agent: EnboxPlatformAgent
1121
1313
  ): Promise<void> {
1122
- const delegateBearerDid = await DidJwk.create();
1123
- const delegatePortableDid = await delegateBearerDid.export();
1124
-
1125
- // Add X25519 key derived from the delegate's Ed25519 key.
1126
- // did:jwk only supports one verification method, but DWN encryption
1127
- // requires X25519 for key agreement. Including the derived X25519
1128
- // private key in the PortableDid ensures the delegate agent's KMS
1129
- // has both keys after import. The Ed25519→X25519 conversion is a
1130
- // standard cryptographic operation (RFC 8032 / libsodium).
1131
- const delegateEdPrivateKey = delegatePortableDid.privateKeys![0];
1132
- const delegateX25519PrivateKey = await Ed25519.convertPrivateKeyToX25519({
1133
- privateKey: delegateEdPrivateKey,
1134
- });
1135
- delegatePortableDid.privateKeys!.push(delegateX25519PrivateKey);
1136
-
1137
- // Derive the delegate's key-delivery ProtocolPath leaf public key.
1138
- // This is the pre-derived key that the owner will use later when writing
1139
- // contextKey records addressed to this delegate. The owner cannot derive
1140
- // this from the delegate's root public key alone (HKDF needs the private
1141
- // key), so we compute it now while we have temporary access to the
1142
- // delegate's private key material.
1143
- const delegateX25519PrivateKeyBytes = await X25519.privateKeyToBytes({
1144
- privateKey: delegateX25519PrivateKey,
1145
- });
1146
- const keyDeliveryDerivationPath = [
1147
- KeyDerivationScheme.ProtocolPath,
1148
- KeyDeliveryProtocolDefinition.protocol,
1149
- 'contextKey',
1150
- ];
1151
- const delegateLeafPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(
1152
- delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath,
1314
+ const submitStart = nowMs();
1315
+ const numProtocols = connectRequest.permissionRequests.length;
1316
+ const numScopes = connectRequest.permissionRequests.reduce(
1317
+ (sum, req) => sum + req.permissionScopes.length,
1318
+ 0,
1319
+ );
1320
+ // Tracked across the try/finally so the aggregate `submitConnectResponse.total`
1321
+ // log emits on both success and failure paths operators bisecting wall-time
1322
+ // from wallet debug logs need the total even when a phase throws.
1323
+ let sessionGrantCount = 0;
1324
+ let outcome: 'ok' | 'fail' = 'ok';
1325
+ logger.log(
1326
+ `${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.start `
1327
+ + `(protocols=${numProtocols}, scopes=${numScopes})`,
1153
1328
  );
1154
- const delegateLeafPrivateKeyJwk = await X25519.bytesToPrivateKey({
1155
- privateKeyBytes: delegateLeafPrivateKeyBytes,
1156
- });
1157
- const delegateKeyDeliveryLeafPublicKey = await X25519.getPublicKey({
1158
- key: delegateLeafPrivateKeyJwk,
1159
- });
1160
1329
 
1161
- // The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
1162
- // For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
1163
- // this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
1164
- // `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
1165
- // the same id which they do because both derive from verificationMethod.id
1166
- // of the keyAgreement relationship.
1167
- const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod![0].id;
1168
- const delegateKeyDeliveryData = {
1169
- rootKeyId : delegateKeyAgreementVmId,
1170
- publicKeyJwk : delegateKeyDeliveryLeafPublicKey,
1171
- };
1330
+ try {
1331
+ const delegateBearerDid = await timed(`${CONNECT_PERF_LOG_PREFIX} delegateDid.create`, () => DidJwk.create());
1332
+ const delegatePortableDid = await delegateBearerDid.export();
1333
+
1334
+ // Add X25519 key derived from the delegate's Ed25519 key.
1335
+ // did:jwk only supports one verification method, but DWN encryption
1336
+ // requires X25519 for key agreement. Including the derived X25519
1337
+ // private key in the PortableDid ensures the delegate agent's KMS
1338
+ // has both keys after import. The Ed25519→X25519 conversion is a
1339
+ // standard cryptographic operation (RFC 8032 / libsodium).
1340
+ const delegateEdPrivateKey = delegatePortableDid.privateKeys![0];
1341
+ const delegateX25519PrivateKey = await Ed25519.convertPrivateKeyToX25519({
1342
+ privateKey: delegateEdPrivateKey,
1343
+ });
1344
+ delegatePortableDid.privateKeys!.push(delegateX25519PrivateKey);
1345
+
1346
+ // Derive the delegate's key-delivery ProtocolPath leaf public key.
1347
+ // This is the pre-derived key that the owner will use later when writing
1348
+ // contextKey records addressed to this delegate. The owner cannot derive
1349
+ // this from the delegate's root public key alone (HKDF needs the private
1350
+ // key), so we compute it now while we have temporary access to the
1351
+ // delegate's private key material.
1352
+ const delegateX25519PrivateKeyBytes = await X25519.privateKeyToBytes({
1353
+ privateKey: delegateX25519PrivateKey,
1354
+ });
1355
+ const keyDeliveryDerivationPath = [
1356
+ KeyDerivationScheme.ProtocolPath,
1357
+ KeyDeliveryProtocolDefinition.protocol,
1358
+ 'contextKey',
1359
+ ];
1360
+ const delegateLeafPrivateKeyBytes = await HdKey.derivePrivateKeyBytes(
1361
+ delegateX25519PrivateKeyBytes, keyDeliveryDerivationPath,
1362
+ );
1363
+ const delegateLeafPrivateKeyJwk = await X25519.bytesToPrivateKey({
1364
+ privateKeyBytes: delegateLeafPrivateKeyBytes,
1365
+ });
1366
+ const delegateKeyDeliveryLeafPublicKey = await X25519.getPublicKey({
1367
+ key: delegateLeafPrivateKeyJwk,
1368
+ });
1172
1369
 
1173
- // Derive scope-aware decryption keys for encrypted protocols.
1174
- // Single-party: ProtocolPath keys (protocol-wide or exact-path).
1175
- // Multi-party: ProtocolContext keys (per rootContextId).
1176
- // Write-only delegates receive no decryption capability.
1177
- const delegateDecryptionKeys: DelegateDecryptionKey[] = [];
1178
- const delegateContextKeys: DelegateContextKey[] = [];
1179
- const delegateMultiPartyProtocols: string[] = [];
1370
+ // The rootKeyId is the delegate's keyAgreement VM id (e.g. `did:jwk:...#0`).
1371
+ // For did:jwk this is the Ed25519 VM, but getEncryptionKeyInfo() also returns
1372
+ // this same id after Ed25519→X25519 conversion. The DWN SDK matches the JWE
1373
+ // `kid` header against the KeyDecrypter's `rootKeyId`, so both sides must use
1374
+ // the same id — which they do because both derive from verificationMethod.id
1375
+ // of the keyAgreement relationship.
1376
+ const delegateKeyAgreementVmId = delegateBearerDid.document.verificationMethod![0].id;
1377
+ const delegateKeyDeliveryData = {
1378
+ rootKeyId : delegateKeyAgreementVmId,
1379
+ publicKeyJwk : delegateKeyDeliveryLeafPublicKey,
1380
+ };
1381
+
1382
+ // Derive scope-aware decryption keys for encrypted protocols.
1383
+ // Single-party: ProtocolPath keys (protocol-wide or exact-path).
1384
+ // Multi-party: ProtocolContext keys (per rootContextId).
1385
+ // Write-only delegates receive no decryption capability.
1386
+ const delegateDecryptionKeys: DelegateDecryptionKey[] = [];
1387
+ const delegateContextKeys: DelegateContextKey[] = [];
1388
+ const delegateMultiPartyProtocols: string[] = [];
1389
+
1390
+ const delegateGrants = await timed(
1391
+ `${CONNECT_PERF_LOG_PREFIX} permissionGrants.fanout (protocols=${numProtocols})`,
1392
+ async () => {
1393
+ const delegateGrantPromises = connectRequest.permissionRequests.map(
1394
+ async (permissionRequest) => {
1395
+ const { protocolDefinition, permissionScopes } = permissionRequest;
1396
+
1397
+ const grantsMatchProtocolUri = permissionScopes.every(
1398
+ scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol
1399
+ );
1400
+ if (!grantsMatchProtocolUri) {
1401
+ throw new Error('All permission scopes must match the protocol URI they are provided with.');
1402
+ }
1403
+
1404
+ await prepareProtocol(selectedDid, agent, protocolDefinition);
1405
+
1406
+ const hasEncryptedTypes = Object.values(protocolDefinition.types ?? {})
1407
+ .some((type: any) => type?.encryptionRequired === true);
1408
+
1409
+ if (hasEncryptedTypes) {
1410
+ const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
1411
+
1412
+ if (multiParty.length > 0 && singleParty.length > 0) {
1413
+ // Mixed protocol: some roots are multi-party, others single-party.
1414
+ // We cannot safely model this with either key type alone.
1415
+ throw new Error(
1416
+ `Encrypted delegate access for protocols with mixed single-party ` +
1417
+ `and multi-party roots is not supported yet. ` +
1418
+ `Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
1419
+ `[${multiParty.join(', ')}] and single-party roots ` +
1420
+ `[${singleParty.join(', ')}].`,
1421
+ );
1422
+ }
1423
+
1424
+ if (multiParty.length > 0) {
1425
+ // Pure multi-party: derive per-context keys for existing contexts.
1426
+ // Unsupported scope shapes (protocolPath, contextId) throw.
1427
+ const ctxKeys = await deriveContextKeysForDelegate(
1428
+ agent, selectedDid, protocolDefinition, permissionScopes,
1429
+ );
1430
+ delegateContextKeys.push(...ctxKeys);
1431
+
1432
+ // Only register the protocol for post-connect delivery if the
1433
+ // delegate has at least one read-like scope. Write-only delegates
1434
+ // must NOT receive context keys — they have no decryption need.
1435
+ const readMethods = new Set([
1436
+ DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
1437
+ ]);
1438
+ const hasReadLikeScope = permissionScopes.some(
1439
+ (s): boolean => isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1440
+ );
1441
+ if (hasReadLikeScope) {
1442
+ delegateMultiPartyProtocols.push(protocolDefinition.protocol);
1443
+ }
1444
+ } else {
1445
+ // Pure single-party: derive ProtocolPath keys.
1446
+ // Unsupported scope shapes (contextId) throw.
1447
+ const keys = await deriveScopedDecryptionKeys(
1448
+ agent, selectedDid, protocolDefinition.protocol,
1449
+ permissionScopes, protocolDefinition,
1450
+ );
1451
+ delegateDecryptionKeys.push(...keys);
1452
+ }
1453
+ }
1454
+
1455
+ return EnboxConnectProtocol.createPermissionGrants(
1456
+ selectedDid,
1457
+ delegateBearerDid,
1458
+ agent,
1459
+ permissionScopes,
1460
+ delegateKeyDeliveryData,
1461
+ );
1462
+ }
1463
+ );
1180
1464
 
1181
- const delegateGrantPromises = connectRequest.permissionRequests.map(
1182
- async (permissionRequest) => {
1183
- const { protocolDefinition, permissionScopes } = permissionRequest;
1465
+ return (await Promise.all(delegateGrantPromises)).flat();
1466
+ },
1467
+ );
1184
1468
 
1185
- const grantsMatchProtocolUri = permissionScopes.every(
1186
- scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol
1187
- );
1188
- if (!grantsMatchProtocolUri) {
1189
- throw new Error('All permission scopes must match the protocol URI they are provided with.');
1190
- }
1469
+ // Create per-grant contextId-scoped revocation grants.
1470
+ // Each revocation grant authorizes the delegate to write a revocation
1471
+ // ONLY for the specific session grant it corresponds to.
1472
+ const permissionsApi = new AgentPermissionsApi({ agent });
1473
+ const sessionRevocations: { grantId: string; revocationGrantId: string }[] = [];
1474
+ let revGrantEndpoints: string[] = [];
1475
+ try {
1476
+ revGrantEndpoints = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
1477
+ } catch {
1478
+ // Endpoint resolution failure — revocation grants will be local-only until sync.
1479
+ }
1191
1480
 
1192
- await prepareProtocol(selectedDid, agent, protocolDefinition);
1481
+ // Snapshot the current length — revocation grants are appended to delegateGrants
1482
+ // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1483
+ sessionGrantCount = delegateGrants.length;
1484
+
1485
+ // Phase 1: create all revocation grants locally with bounded concurrency.
1486
+ // createGrant is local-only (storage + signing) so it's cheap, but we still
1487
+ // cap parallelism to avoid head-of-line blocking when sessionGrantCount is
1488
+ // large (e.g. dapp requesting many scopes at once).
1489
+ const revGrantResults = await timed(
1490
+ `${CONNECT_PERF_LOG_PREFIX} revocationGrants.create (n=${sessionGrantCount})`,
1491
+ () => mapConcurrent(
1492
+ delegateGrants.slice(0, sessionGrantCount),
1493
+ CONNECT_FANOUT_CONCURRENCY,
1494
+ (grantMessage) =>
1495
+ permissionsApi.createGrant({
1496
+ delegated : true,
1497
+ store : true,
1498
+ grantedTo : delegateBearerDid.uri,
1499
+ scope : {
1500
+ interface : DwnInterfaceName.Records,
1501
+ method : DwnMethodName.Write,
1502
+ protocol : PermissionsProtocol.uri,
1503
+ contextId : grantMessage.recordId,
1504
+ },
1505
+ dateExpires : '2040-06-25T16:09:16.693356Z',
1506
+ author : selectedDid,
1507
+ }).then((revGrant) => ({ grantMessage, revGrant })),
1508
+ ),
1509
+ );
1193
1510
 
1194
- const hasEncryptedTypes = Object.values(protocolDefinition.types ?? {})
1195
- .some((type: any) => type?.encryptionRequired === true);
1511
+ // Phase 2: fan out every revocation grant to every owner DWN endpoint with
1512
+ // a single global concurrency cap so that (grants × endpoints) cannot blow
1513
+ // up. This is best-effort (sync delivers eventually) so individual failures
1514
+ // are tolerated by `mapConcurrentSettled`.
1515
+ const revSendTasks = revGrantResults.flatMap(({ grantMessage, revGrant }) => {
1516
+ sessionRevocations.push({
1517
+ grantId : grantMessage.recordId,
1518
+ revocationGrantId : revGrant.message.recordId,
1519
+ });
1196
1520
 
1197
- if (hasEncryptedTypes) {
1198
- const { multiParty, singleParty } = classifyProtocolRoots(protocolDefinition);
1521
+ const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
1522
+ const revData = Uint8Array.from(Convert.base64Url(revEncoded).toUint8Array());
1199
1523
 
1200
- if (multiParty.length > 0 && singleParty.length > 0) {
1201
- // Mixed protocol: some roots are multi-party, others single-party.
1202
- // We cannot safely model this with either key type alone.
1203
- throw new Error(
1204
- `Encrypted delegate access for protocols with mixed single-party ` +
1205
- `and multi-party roots is not supported yet. ` +
1206
- `Protocol '${protocolDefinition.protocol}' has multi-party roots ` +
1207
- `[${multiParty.join(', ')}] and single-party roots ` +
1208
- `[${singleParty.join(', ')}].`,
1209
- );
1210
- }
1524
+ // Include the revocation grant in the delegate grants for distribution.
1525
+ delegateGrants.push(revGrant.message);
1211
1526
 
1212
- if (multiParty.length > 0) {
1213
- // Pure multi-party: derive per-context keys for existing contexts.
1214
- // Unsupported scope shapes (protocolPath, contextId) throw.
1215
- const ctxKeys = await deriveContextKeysForDelegate(
1216
- agent, selectedDid, protocolDefinition, permissionScopes,
1217
- );
1218
- delegateContextKeys.push(...ctxKeys);
1219
-
1220
- // Only register the protocol for post-connect delivery if the
1221
- // delegate has at least one read-like scope. Write-only delegates
1222
- // must NOT receive context keys — they have no decryption need.
1223
- const readMethods = new Set([
1224
- DwnMethodName.Read, DwnMethodName.Query, DwnMethodName.Subscribe,
1225
- ]);
1226
- const hasReadLikeScope = permissionScopes.some(
1227
- (s): boolean => isRecordPermissionScope(s) && readMethods.has(s.method as DwnMethodName),
1228
- );
1229
- if (hasReadLikeScope) {
1230
- delegateMultiPartyProtocols.push(protocolDefinition.protocol);
1231
- }
1232
- } else {
1233
- // Pure single-party: derive ProtocolPath keys.
1234
- // Unsupported scope shapes (contextId) throw.
1235
- const keys = await deriveScopedDecryptionKeys(
1236
- agent, selectedDid, protocolDefinition.protocol,
1237
- permissionScopes, protocolDefinition,
1238
- );
1239
- delegateDecryptionKeys.push(...keys);
1240
- }
1241
- }
1527
+ return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
1528
+ });
1242
1529
 
1243
- return EnboxConnectProtocol.createPermissionGrants(
1244
- selectedDid,
1245
- delegateBearerDid,
1246
- agent,
1247
- permissionScopes,
1248
- delegateKeyDeliveryData,
1530
+ if (revSendTasks.length > 0) {
1531
+ await timed(
1532
+ `${CONNECT_PERF_LOG_PREFIX} revocationGrants.fanout (sends=${revSendTasks.length}, endpoints=${revGrantEndpoints.length})`,
1533
+ () => mapConcurrentSettled(
1534
+ revSendTasks,
1535
+ CONNECT_FANOUT_CONCURRENCY,
1536
+ ({ revRawMessage, revData, dwnUrl }) =>
1537
+ agent.rpc.sendDwnRequest({
1538
+ dwnUrl,
1539
+ targetDid : selectedDid,
1540
+ message : revRawMessage,
1541
+ data : new Blob([revData]),
1542
+ signal : AbortSignal.timeout(CONNECT_REQUEST_TIMEOUT_MS),
1543
+ }),
1544
+ ),
1249
1545
  );
1250
1546
  }
1251
- );
1252
1547
 
1253
- const delegateGrants = (await Promise.all(delegateGrantPromises)).flat();
1254
-
1255
- // Create per-grant contextId-scoped revocation grants.
1256
- // Each revocation grant authorizes the delegate to write a revocation
1257
- // ONLY for the specific session grant it corresponds to.
1258
- const permissionsApi = new AgentPermissionsApi({ agent });
1259
- const sessionRevocations: { grantId: string; revocationGrantId: string }[] = [];
1260
- let revGrantEndpoints: string[] = [];
1261
- try {
1262
- revGrantEndpoints = await agent.dwn.getDwnEndpointUrlsForTarget(selectedDid);
1263
- } catch {
1264
- // Endpoint resolution failure revocation grants will be local-only until sync.
1265
- }
1266
-
1267
- // Snapshot the current length — revocation grants are appended to delegateGrants
1268
- // below, but we must NOT iterate over them (they are meta-grants, not session grants).
1269
- const sessionGrantCount = delegateGrants.length;
1548
+ const responseObject = await timed(
1549
+ `${CONNECT_PERF_LOG_PREFIX} response.build`,
1550
+ () => EnboxConnectProtocol.createConnectResponse({
1551
+ providerDid : selectedDid,
1552
+ delegateDid : delegateBearerDid.uri,
1553
+ aud : connectRequest.clientDid,
1554
+ nonce : connectRequest.nonce,
1555
+ delegateGrants,
1556
+ delegatePortableDid,
1557
+ delegateDecryptionKeys : delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
1558
+ delegateContextKeys : delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
1559
+ delegateMultiPartyProtocols : delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
1560
+ sessionRevocations : sessionRevocations.length > 0 ? sessionRevocations : undefined,
1561
+ }),
1562
+ );
1270
1563
 
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
- );
1564
+ const responseObjectJwt = await timed(
1565
+ `${CONNECT_PERF_LOG_PREFIX} response.sign`,
1566
+ () => EnboxConnectProtocol.signJwt({
1567
+ did : delegateBearerDid,
1568
+ data : responseObject,
1569
+ }),
1570
+ );
1293
1571
 
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 }) => {
1299
- sessionRevocations.push({
1300
- grantId : grantMessage.recordId,
1301
- revocationGrantId : revGrant.message.recordId,
1302
- });
1572
+ const clientDid = await timed(
1573
+ `${CONNECT_PERF_LOG_PREFIX} clientDid.resolve`,
1574
+ () => DidJwk.resolve(connectRequest.clientDid),
1575
+ );
1303
1576
 
1304
- const { encodedData: revEncoded, ...revRawMessage } = revGrant.message;
1305
- const revData = Convert.base64Url(revEncoded).toUint8Array();
1577
+ const sharedKey = await timed(
1578
+ `${CONNECT_PERF_LOG_PREFIX} response.deriveSharedKey`,
1579
+ () => EnboxConnectProtocol.deriveSharedKey(
1580
+ delegateBearerDid,
1581
+ clientDid?.didDocument!,
1582
+ ),
1583
+ );
1306
1584
 
1307
- // Include the revocation grant in the delegate grants for distribution.
1308
- delegateGrants.push(revGrant.message);
1585
+ const encryptedResponse = await timed(
1586
+ `${CONNECT_PERF_LOG_PREFIX} response.encrypt`,
1587
+ () => EnboxConnectProtocol.encryptResponse({
1588
+ jwt : responseObjectJwt,
1589
+ encryptionKey : sharedKey,
1590
+ delegatePublicKeyJwk : delegateBearerDid.document.verificationMethod![0].publicKeyJwk!,
1591
+ pin,
1592
+ }),
1593
+ );
1309
1594
 
1310
- return revGrantEndpoints.map((dwnUrl) => ({ revRawMessage, revData, dwnUrl }));
1311
- });
1595
+ const formEncodedRequest = new URLSearchParams({
1596
+ id_token : encryptedResponse,
1597
+ state : connectRequest.state,
1598
+ }).toString();
1599
+
1600
+ await timed(
1601
+ `${CONNECT_PERF_LOG_PREFIX} response.callbackPost`,
1602
+ async () => {
1603
+ const response = await fetch(connectRequest.callbackUrl, {
1604
+ body : formEncodedRequest,
1605
+ method : 'POST',
1606
+ headers : {
1607
+ 'Content-Type': 'application/x-www-form-urlencoded',
1608
+ },
1609
+ signal: AbortSignal.timeout(30_000),
1610
+ });
1611
+
1612
+ if (!response.ok) {
1613
+ // NOTE: delegate grants have already been written/fanned out by this
1614
+ // point, so callers may observe partial owner-side state when the
1615
+ // callback server rejects the final response delivery.
1616
+ throw new Error(`Connect: callback POST failed with HTTP ${response.status}.`);
1617
+ }
1312
1618
 
1313
- if (revSendTasks.length > 0) {
1314
- await mapConcurrentSettled(
1315
- revSendTasks,
1316
- CONNECT_FANOUT_CONCURRENCY,
1317
- ({ revRawMessage, revData, dwnUrl }) =>
1318
- agent.rpc.sendDwnRequest({
1319
- dwnUrl,
1320
- targetDid : selectedDid,
1321
- message : revRawMessage,
1322
- data : new Blob([revData as BlobPart]),
1323
- }),
1619
+ return response;
1620
+ },
1621
+ );
1622
+ } catch (err) {
1623
+ outcome = 'fail';
1624
+ throw err;
1625
+ } finally {
1626
+ const totalElapsed = nowMs() - submitStart;
1627
+ logger.log(
1628
+ `${CONNECT_PERF_LOG_PREFIX} submitConnectResponse.total ${outcome} in ${totalElapsed.toFixed(1)}ms `
1629
+ + `(protocols=${numProtocols}, scopes=${numScopes}, sessionGrants=${sessionGrantCount})`,
1324
1630
  );
1325
1631
  }
1326
-
1327
- logger.log('Building connect response...');
1328
- const responseObject = await EnboxConnectProtocol.createConnectResponse({
1329
- providerDid : selectedDid,
1330
- delegateDid : delegateBearerDid.uri,
1331
- aud : connectRequest.clientDid,
1332
- nonce : connectRequest.nonce,
1333
- delegateGrants,
1334
- delegatePortableDid,
1335
- delegateDecryptionKeys : delegateDecryptionKeys.length > 0 ? delegateDecryptionKeys : undefined,
1336
- delegateContextKeys : delegateContextKeys.length > 0 ? delegateContextKeys : undefined,
1337
- delegateMultiPartyProtocols : delegateMultiPartyProtocols.length > 0 ? delegateMultiPartyProtocols : undefined,
1338
- sessionRevocations : sessionRevocations.length > 0 ? sessionRevocations : undefined,
1339
- });
1340
-
1341
- logger.log('Signing connect response...');
1342
- const responseObjectJwt = await EnboxConnectProtocol.signJwt({
1343
- did : delegateBearerDid,
1344
- data : responseObject as unknown as Record<string, unknown>,
1345
- });
1346
-
1347
- const clientDid = await DidJwk.resolve(connectRequest.clientDid);
1348
-
1349
- const sharedKey = await EnboxConnectProtocol.deriveSharedKey(
1350
- delegateBearerDid,
1351
- clientDid?.didDocument!
1352
- );
1353
-
1354
- logger.log('Encrypting connect response...');
1355
- const encryptedResponse = await EnboxConnectProtocol.encryptResponse({
1356
- jwt : responseObjectJwt,
1357
- encryptionKey : sharedKey,
1358
- delegatePublicKeyJwk : delegateBearerDid.document.verificationMethod![0].publicKeyJwk!,
1359
- pin,
1360
- });
1361
-
1362
- const formEncodedRequest = new URLSearchParams({
1363
- id_token : encryptedResponse,
1364
- state : connectRequest.state,
1365
- }).toString();
1366
-
1367
- logger.log(`Sending connect response to: ${connectRequest.callbackUrl}`);
1368
- await fetch(connectRequest.callbackUrl, {
1369
- body : formEncodedRequest,
1370
- method : 'POST',
1371
- headers : {
1372
- 'Content-Type': 'application/x-www-form-urlencoded',
1373
- },
1374
- signal: AbortSignal.timeout(30_000),
1375
- });
1376
1632
  }
1377
1633
 
1378
1634
  // ---------------------------------------------------------------------------
@@ -1383,6 +1639,8 @@ export const EnboxConnectProtocol = {
1383
1639
  buildConnectUrl,
1384
1640
  signJwt,
1385
1641
  verifyJwt,
1642
+ assertConnectRequest,
1643
+ assertConnectResponse,
1386
1644
  encryptRequest,
1387
1645
  decryptRequest,
1388
1646
  encryptResponse,