@mdbase-dev/connect 0.1.0-beta.30 → 0.1.0-beta.32

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 (64) hide show
  1. package/README.md +75 -19
  2. package/dist/advanced.d.ts +7 -0
  3. package/dist/advanced.js +4 -0
  4. package/dist/application-session.d.ts +10 -5
  5. package/dist/application-session.js +32 -16
  6. package/dist/async.js +11 -5
  7. package/dist/authority-proof.d.ts +3 -0
  8. package/dist/authority-proof.js +24 -0
  9. package/dist/browser/integrity.json +1 -1
  10. package/dist/browser/mdbase-connect.min.js +3 -3
  11. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  12. package/dist/browser.d.ts +3 -0
  13. package/dist/browser.js +5 -0
  14. package/dist/capabilities.js +2 -1
  15. package/dist/collection-client.d.ts +28 -28
  16. package/dist/collection-client.js +42 -42
  17. package/dist/connect-options.d.ts +12 -4
  18. package/dist/connect.d.ts +12 -4
  19. package/dist/connect.js +105 -104
  20. package/dist/connection-file-transport.d.ts +2 -2
  21. package/dist/connection-file-transport.js +4 -4
  22. package/dist/connection-notifications.d.ts +10 -4
  23. package/dist/connection-notifications.js +41 -23
  24. package/dist/connection-transport.d.ts +19 -15
  25. package/dist/connection-transport.js +202 -202
  26. package/dist/connection-types.d.ts +5 -4
  27. package/dist/connection.d.ts +43 -43
  28. package/dist/connection.js +221 -77
  29. package/dist/crypto-entry.d.ts +4 -0
  30. package/dist/crypto-entry.js +4 -0
  31. package/dist/crypto.js +4 -4
  32. package/dist/external-store.d.ts +10 -0
  33. package/dist/external-store.js +10 -0
  34. package/dist/file-transfer-internals.d.ts +22 -0
  35. package/dist/file-transfer-internals.js +186 -0
  36. package/dist/files.d.ts +16 -12
  37. package/dist/files.js +52 -206
  38. package/dist/hosted-file-request.js +3 -3
  39. package/dist/index.d.ts +2 -5
  40. package/dist/index.js +1 -5
  41. package/dist/internal-types.d.ts +8 -1
  42. package/dist/local-file-transport.d.ts +2 -2
  43. package/dist/local-file-transport.js +13 -13
  44. package/dist/mdbase-connect.d.ts +6 -4
  45. package/dist/mdbase-connect.js +9 -8
  46. package/dist/notifications.d.ts +3 -2
  47. package/dist/operation-helpers.d.ts +3 -3
  48. package/dist/operation-helpers.js +12 -29
  49. package/dist/operation-types.d.ts +33 -8
  50. package/dist/outcomes.d.ts +15 -15
  51. package/dist/outcomes.js +13 -1
  52. package/dist/pending-mutation-store.d.ts +13 -0
  53. package/dist/pending-mutation-store.js +64 -0
  54. package/dist/request-budget.d.ts +27 -0
  55. package/dist/request-budget.js +95 -0
  56. package/dist/runtime-utils.d.ts +2 -0
  57. package/dist/runtime-utils.js +19 -0
  58. package/dist/session.d.ts +4 -3
  59. package/dist/session.js +4 -4
  60. package/dist/stored-token.d.ts +10 -0
  61. package/dist/stored-token.js +60 -0
  62. package/dist/token-response.d.ts +11 -0
  63. package/dist/token-response.js +77 -0
  64. package/package.json +12 -2
package/dist/connect.js CHANGED
@@ -1,13 +1,15 @@
1
- import { APPLICATION_AUTHORIZATION_PROTOCOL_VERSION, DEFAULT_LOOPBACK_PORT, ENCRYPTED_RELAY_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
1
+ import { APPLICATION_AUTHORIZATION_PROTOCOL_VERSION, authorizationContractRequirements, DEFAULT_LOOPBACK_PORT, GRANT_ENCRYPTION_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
2
2
  import { abortableDelay } from "./async.js";
3
3
  import { randomBase64Url } from "./base64.js";
4
4
  import { ApplicationIdentityStoreError, IndexedDbApplicationIdentityStore, MemoryApplicationIdentityStore, applicationIdentity, applicationInstallationId, signApplicationAuthorization } from "./application-identity.js";
5
5
  import { MdbaseConnection } from "./connection.js";
6
- import { IndexedDbGrantKeyStore, MemoryGrantKeyStore, validateGrantEncryption } from "./crypto.js";
7
- import { connectError, serverConnectError } from "./errors.js";
6
+ import { IndexedDbGrantKeyStore, MemoryGrantKeyStore } from "./crypto.js";
7
+ import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
8
8
  import { DEFAULT_OPERATIONS } from "./internal-types.js";
9
9
  import { uniqueOperations } from "./operation-helpers.js";
10
- import { MemoryStorage, apiError, applicationStorageOrigin, canonicalLoopbackUrl, collectionIdFromTokenKey, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseGrantScope, parseStored, stripTrailingSlash, validFileCapability, validAuthorityTokenResponse } from "./runtime-utils.js";
10
+ import { resolveConnectTimeouts, withRequestBudget } from "./request-budget.js";
11
+ import { MemoryStorage, apiError, applicationStorageOrigin, canonicalLoopbackUrl, collectionIdFromTokenKey, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, decodeJsonResponse, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseStored, stripTrailingSlash, validAuthorityTokenResponse } from "./runtime-utils.js";
12
+ import { storedTokenFromResponse } from "./token-response.js";
11
13
  export class MdbaseConnectInternals {
12
14
  serverUrl;
13
15
  manifest;
@@ -20,6 +22,7 @@ export class MdbaseConnectInternals {
20
22
  directAccessMode;
21
23
  loopbackUrl;
22
24
  navigate;
25
+ timeouts;
23
26
  credentialStorage;
24
27
  application = null;
25
28
  manifestPromise = null;
@@ -28,17 +31,19 @@ export class MdbaseConnectInternals {
28
31
  listeners = new Set();
29
32
  invalidatedConnections = new Map();
30
33
  constructor(options) {
31
- this.serverUrl = stripTrailingSlash(options.serverUrl);
32
- this.manifest = options.manifest ?? defaultManifestSource();
34
+ this.serverUrl = stripTrailingSlash(String(options.serverUrl));
35
+ this.manifest = options.manifest instanceof URL
36
+ ? options.manifest.href
37
+ : options.manifest ?? defaultManifestSource();
33
38
  this.manifestSource = typeof this.manifest === "string"
34
39
  ? this.manifest
35
40
  : this.manifest.distribution === "portable"
36
41
  ? `bundle:${this.manifest.id}:${manifestStorageFingerprint(this.manifest)}`
37
42
  : `bundle:${this.manifest.id}`;
38
43
  const opaquePortable = isOpaquePortableManifest(this.manifest);
39
- this.redirectUri = options.redirectUri ?? (typeof this.manifest !== "string" && this.manifest.distribution === "portable"
44
+ this.redirectUri = options.redirectUri === undefined ? (typeof this.manifest !== "string" && this.manifest.distribution === "portable"
40
45
  ? ""
41
- : defaultRedirectUri());
46
+ : defaultRedirectUri()) : String(options.redirectUri);
42
47
  this.storage = options.storage ?? defaultStorage(opaquePortable);
43
48
  this.relayEncryption = options.relayEncryption ?? "required";
44
49
  this.keyStore = options.keyStore ?? (opaquePortable ? new MemoryGrantKeyStore() : new IndexedDbGrantKeyStore());
@@ -49,8 +54,9 @@ export class MdbaseConnectInternals {
49
54
  ? "custom"
50
55
  : this.storage instanceof MemoryStorage ? "memory" : "persistent";
51
56
  this.directAccessMode = options.directAccess ?? "auto";
52
- this.loopbackUrl = canonicalLoopbackUrl(options.loopbackUrl ?? `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}`);
57
+ this.loopbackUrl = canonicalLoopbackUrl(String(options.loopbackUrl ?? `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}`));
53
58
  this.navigate = options.navigate;
59
+ this.timeouts = resolveConnectTimeouts(options.timeouts);
54
60
  if (typeof window !== "undefined" && this.storage === window.localStorage) {
55
61
  window.addEventListener("storage", (event) => {
56
62
  if (event.storageArea !== this.storage || !event.key?.startsWith(this.storagePrefix()))
@@ -73,34 +79,45 @@ export class MdbaseConnectInternals {
73
79
  credentialStorage: this.credentialStorage
74
80
  };
75
81
  }
76
- async register() {
82
+ register(options = {}) {
83
+ return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.registerWithinBudget(budget.signal));
84
+ }
85
+ async registerWithinBudget(signal) {
77
86
  if (this.application)
78
87
  return this.application;
79
88
  const response = await connectFetch(`${this.serverUrl}/v1/apps/register`, {
80
89
  method: "POST",
81
90
  headers: { "content-type": "application/json" },
82
- body: JSON.stringify({ manifest: await this.manifestDeclaration() })
91
+ body: JSON.stringify({ manifest: await this.manifestDeclaration({ signal, timeoutMs: null }) }),
92
+ signal
83
93
  }, "temporarily_unavailable", "Application registration is temporarily unavailable.");
84
- const body = await response.json();
94
+ const body = await decodeJsonResponse(response, "invalid_operation_response", "Connect returned an invalid application registration response.");
85
95
  if (!response.ok)
86
96
  throw apiError(body, "discovery_failed", "Application discovery failed.", response.status);
87
97
  this.application = body.application;
88
98
  return this.application;
89
99
  }
90
- manifestDeclaration() {
100
+ manifestDeclaration(options = {}) {
91
101
  if (this.manifestPromise)
92
102
  return this.manifestPromise;
93
- this.manifestPromise = this.loadManifest();
103
+ const loading = withRequestBudget(options, this.timeouts.requestMs, (budget) => this.loadManifest(budget.signal));
104
+ const shared = loading.catch((error) => {
105
+ if (this.manifestPromise === shared)
106
+ this.manifestPromise = null;
107
+ throw error;
108
+ });
109
+ this.manifestPromise = shared;
94
110
  return this.manifestPromise;
95
111
  }
96
- async loadManifest() {
112
+ async loadManifest(signal) {
97
113
  if (typeof this.manifest !== "string")
98
114
  return this.manifest;
99
115
  const source = this.manifest;
100
116
  let response;
101
117
  try {
102
118
  response = await fetch(source, {
103
- headers: { accept: "application/json" }
119
+ headers: { accept: "application/json" },
120
+ signal
104
121
  });
105
122
  }
106
123
  catch (cause) {
@@ -110,7 +127,7 @@ export class MdbaseConnectInternals {
110
127
  throw connectError("manifest_load_failed", `The bundled application declaration returned HTTP ${response.status}.`, { status: response.status });
111
128
  }
112
129
  try {
113
- return await response.json();
130
+ return await decodeJsonResponse(response, "invalid_application_manifest", "The bundled application declaration is not valid JSON.");
114
131
  }
115
132
  catch (cause) {
116
133
  throw connectError("invalid_application_manifest", "The bundled application declaration is not valid JSON.", {
@@ -127,6 +144,9 @@ export class MdbaseConnectInternals {
127
144
  }
128
145
  }
129
146
  async authorize(options = {}) {
147
+ return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.authorizeWithinBudget({ ...options, signal: budget.signal, timeoutMs: null }));
148
+ }
149
+ async authorizeWithinBudget(options) {
130
150
  if (typeof location === "undefined" && !this.navigate && !options.openVerification) {
131
151
  throw connectError("browser_required", "Authorization navigation requires a browser environment.");
132
152
  }
@@ -140,7 +160,7 @@ export class MdbaseConnectInternals {
140
160
  : null;
141
161
  let application;
142
162
  try {
143
- application = await this.register();
163
+ application = await this.registerWithinBudget(options.signal);
144
164
  }
145
165
  catch (error) {
146
166
  popup?.close();
@@ -189,6 +209,7 @@ export class MdbaseConnectInternals {
189
209
  redirect_uri: this.redirectUri,
190
210
  state,
191
211
  code_challenge: challenge,
212
+ contracts: authorizationContractRequirements(operations, application.requirements?.files),
192
213
  requested_operations: operations,
193
214
  ...(application.requirements?.files
194
215
  ? { requested_files: application.requirements.files }
@@ -226,7 +247,7 @@ export class MdbaseConnectInternals {
226
247
  }),
227
248
  signal: options.signal
228
249
  });
229
- const body = await response.json();
250
+ const body = await decodeJsonResponse(response, "invalid_device_authorization_response", "The authorization service returned an invalid authorization response.");
230
251
  if (!response.ok) {
231
252
  throw apiError(body, "device_authorization_failed", "Application authorization could not be started.", response.status);
232
253
  }
@@ -249,7 +270,7 @@ export class MdbaseConnectInternals {
249
270
  this.storage.removeItem(this.pendingKey(state));
250
271
  await this.keyStore.delete(keyHandle);
251
272
  if (options.signal?.aborted) {
252
- throw connectError("authorization_cancelled", "Application authorization was cancelled.", { cause: error });
273
+ throw authorizationAbort(options.signal, "Application authorization was cancelled.", error);
253
274
  }
254
275
  throw error;
255
276
  }
@@ -288,6 +309,7 @@ export class MdbaseConnectInternals {
288
309
  issued_at: issuedAt.toISOString(),
289
310
  expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
290
311
  code_challenge: challenge,
312
+ contracts: authorizationContractRequirements(operations, application.requirements?.files),
291
313
  requested_operations: operations,
292
314
  ...(application.requirements?.files
293
315
  ? { requested_files: application.requirements.files }
@@ -311,9 +333,10 @@ export class MdbaseConnectInternals {
311
333
  code_challenge: challenge,
312
334
  code_challenge_method: "S256",
313
335
  application_authorization: JSON.stringify(proof)
314
- })
336
+ }),
337
+ signal: options.signal
315
338
  });
316
- body = await response.json();
339
+ body = await decodeJsonResponse(response, "invalid_device_authorization_response", "The authorization service returned an invalid device authorization response.");
317
340
  }
318
341
  catch (cause) {
319
342
  popup?.close();
@@ -333,7 +356,7 @@ export class MdbaseConnectInternals {
333
356
  popup?.close();
334
357
  await this.keyStore.delete(keyHandle);
335
358
  if (options.signal?.aborted) {
336
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
359
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", error);
337
360
  }
338
361
  throw error;
339
362
  }
@@ -366,7 +389,7 @@ export class MdbaseConnectInternals {
366
389
  while (Date.now() < authorization.expiresAt) {
367
390
  await abortableDelay(intervalSeconds * 1_000, options.signal);
368
391
  if (options.signal?.aborted) {
369
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.");
392
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.");
370
393
  }
371
394
  let tokenResponse;
372
395
  try {
@@ -384,11 +407,11 @@ export class MdbaseConnectInternals {
384
407
  }
385
408
  catch (cause) {
386
409
  if (options.signal?.aborted) {
387
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause });
410
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", cause);
388
411
  }
389
412
  continue;
390
413
  }
391
- const tokenBody = await tokenResponse.json();
414
+ const tokenBody = await decodeJsonResponse(tokenResponse, "invalid_token_response", "The authorization service returned an invalid token response.");
392
415
  if (!tokenResponse.ok) {
393
416
  const code = oauthErrorCode(tokenBody);
394
417
  if (code === "authorization_pending") {
@@ -402,7 +425,7 @@ export class MdbaseConnectInternals {
402
425
  }
403
426
  const authority = validAuthorityTokenResponse(tokenBody.authority, tokenBody.collection_id);
404
427
  const localEncryption = tokenBody.encryption
405
- && tokenBody.encryption.protocol_version === ENCRYPTED_RELAY_PROTOCOL_VERSION
428
+ && tokenBody.encryption.protocol_version === GRANT_ENCRYPTION_PROTOCOL_VERSION
406
429
  && tokenBody.encryption.suite === RELAY_ENCRYPTION_SUITE
407
430
  && tokenBody.encryption.application_agreement_public_key
408
431
  === grantKey.agreementPublicKey;
@@ -437,12 +460,15 @@ export class MdbaseConnectInternals {
437
460
  popup?.close();
438
461
  await this.keyStore.delete(keyHandle);
439
462
  if (options.signal?.aborted) {
440
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
463
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", error);
441
464
  }
442
465
  throw error;
443
466
  }
444
467
  }
445
- completeAuthorization(callbackUrl) {
468
+ completeAuthorization(callbackUrl, options = {}) {
469
+ return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.completeAuthorizationWithinBudget(callbackUrl, budget.signal));
470
+ }
471
+ completeAuthorizationWithinBudget(callbackUrl, signal) {
446
472
  const state = new URL(callbackUrl).searchParams.get("state");
447
473
  if (!state) {
448
474
  return Promise.reject(connectError("invalid_callback", "Authorization callback is missing its state."));
@@ -450,7 +476,7 @@ export class MdbaseConnectInternals {
450
476
  const existing = this.completionPromises.get(state);
451
477
  if (existing)
452
478
  return existing;
453
- const completion = this.performAuthorizationCompletion(callbackUrl, state);
479
+ const completion = this.performAuthorizationCompletion(callbackUrl, state, signal);
454
480
  const shared = completion.finally(() => {
455
481
  if (this.completionPromises.get(state) === shared)
456
482
  this.completionPromises.delete(state);
@@ -458,7 +484,7 @@ export class MdbaseConnectInternals {
458
484
  this.completionPromises.set(state, shared);
459
485
  return shared;
460
486
  }
461
- async performAuthorizationCompletion(callbackUrl, state) {
487
+ async performAuthorizationCompletion(callbackUrl, state, signal) {
462
488
  const callback = new URL(callbackUrl);
463
489
  const code = callback.searchParams.get("code");
464
490
  const pendingKey = this.pendingKey(state);
@@ -484,9 +510,10 @@ export class MdbaseConnectInternals {
484
510
  client_id: pending.clientId,
485
511
  redirect_uri: pending.redirectUri,
486
512
  code_verifier: pending.verifier
487
- })
513
+ }),
514
+ signal
488
515
  }, "temporarily_unavailable", "Authorization completion is temporarily unavailable.");
489
- const body = await response.json();
516
+ const body = await decodeJsonResponse(response, "invalid_token_response", "The authorization service returned an invalid token response.");
490
517
  if (!response.ok)
491
518
  throw apiError(body, "token_exchange_failed", "Authorization could not be completed.", response.status);
492
519
  if (pending.relayEncryption === "required" && !body.authority && (!body.encryption
@@ -555,78 +582,18 @@ export class MdbaseConnectInternals {
555
582
  if (typeof collectionId !== "string") {
556
583
  throw connectError("invalid_token_response", "Authorization returned no collection ID.");
557
584
  }
558
- const scope = parseGrantScope(body.scope);
559
- if (!scope) {
560
- throw connectError("invalid_token_response", "Authorization returned no valid collection scope.");
561
- }
562
- if (body.authority && !validAuthorityTokenResponse(body.authority, collectionId)) {
563
- throw connectError("invalid_token_response", "Authorization returned an invalid remote authority capability.");
564
- }
565
- if (body.authority && body.encryption) {
566
- throw connectError("invalid_token_response", "Authorization returned conflicting collection transports.");
567
- }
568
- if (body.file_capability !== null && body.file_capability !== undefined
569
- && !validFileCapability(body.file_capability)) {
570
- throw connectError("invalid_token_response", "Authorization returned an invalid file capability.");
571
- }
572
- if (body.encryption) {
573
- try {
574
- validateGrantEncryption(body.encryption);
575
- }
576
- catch {
577
- throw connectError("invalid_token_response", "Authorization returned an invalid encrypted relay binding.");
578
- }
579
- this.pinConnectorIdentity(body.encryption.connector_id, body.encryption.connector_agreement_public_key);
580
- if (body.encryption.collection_id !== collectionId
581
- || typeof body.grant_id !== "string"
582
- || body.grant_id.length === 0) {
583
- throw connectError("invalid_token_response", "Authorization returned an encrypted relay binding for another grant.");
584
- }
585
- }
586
585
  const previous = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
587
- if (previous?.encryption
588
- && body.encryption
589
- && previous.keyHandle
590
- && previous.keyHandle === keyHandle
591
- && (previous.grantId !== body.grant_id
592
- || previous.encryption.connector_id !== body.encryption.connector_id
593
- || previous.encryption.connector_agreement_public_key
594
- !== body.encryption.connector_agreement_public_key
595
- || previous.encryption.application_agreement_public_key
596
- !== body.encryption.application_agreement_public_key)) {
597
- throw connectError("connector_identity_changed", "The connector identity changed during authorization renewal. Reauthorize before sending collection data.");
598
- }
586
+ const token = storedTokenFromResponse({
587
+ body,
588
+ clientId,
589
+ keyHandle,
590
+ previous,
591
+ defaultApplicationOrigin: this.defaultApplicationOrigin(),
592
+ pinConnectorIdentity: (connectorId, publicKey) => this.pinConnectorIdentity(connectorId, publicKey)
593
+ });
599
594
  if (previous?.keyHandle && previous.keyHandle !== keyHandle) {
600
595
  void this.keyStore.delete(previous.keyHandle).catch(() => undefined);
601
596
  }
602
- const token = {
603
- version: 1,
604
- accessToken: body.access_token,
605
- refreshToken: body.refresh_token,
606
- clientId,
607
- collectionId,
608
- collectionName: body.collection_name ?? `Collection ${collectionId.slice(0, 8)}`,
609
- operations: body.operations,
610
- scope,
611
- expiresAt: Date.now() + body.expires_in * 1_000,
612
- refreshExpiresAt: body.refresh_expires_in
613
- ? Date.now() + body.refresh_expires_in * 1_000
614
- : undefined,
615
- grantId: body.grant_id,
616
- encryption: body.encryption ?? undefined,
617
- fileCapability: body.file_capability ?? undefined,
618
- applicationOrigin: body.application_origin ?? this.defaultApplicationOrigin(),
619
- keyHandle,
620
- savedAt: Date.now(),
621
- authority: body.authority ? {
622
- operationsUrl: body.authority.operations_url,
623
- syncUrl: body.authority.sync_url,
624
- filesUrl: body.authority.files_url,
625
- replicaId: body.authority.replica_id,
626
- accessToken: body.authority.access_token,
627
- proofPublicKey: body.authority.proof_public_key
628
- } : undefined
629
- };
630
597
  this.storage.setItem(this.tokenKey(collectionId), JSON.stringify(token));
631
598
  this.addConnectionId(collectionId);
632
599
  this.invalidatedConnections.delete(collectionId);
@@ -659,12 +626,14 @@ export class MdbaseConnectInternals {
659
626
  : "The application installation identity is unavailable.", { cause });
660
627
  }
661
628
  }
662
- removeToken(collectionId, keyHandle, reason = "authorization_lost") {
629
+ removeToken(collectionId, keyHandle, reason = "authorization_lost", discardPending = false) {
663
630
  this.invalidatedConnections.set(collectionId, reason);
664
- if (keyHandle)
631
+ if (keyHandle && (discardPending || !this.pendingMutationsUseKey(collectionId, keyHandle))) {
665
632
  void this.keyStore.delete(keyHandle).catch(() => undefined);
633
+ }
666
634
  this.storage.removeItem(this.tokenKey(collectionId));
667
- this.storage.removeItem(this.pendingMutationKey(collectionId));
635
+ if (discardPending)
636
+ this.removePendingMutations(collectionId);
668
637
  for (const transport of ["web_push", "fcm"]) {
669
638
  this.storage.removeItem(this.notificationKey(collectionId, transport));
670
639
  }
@@ -681,6 +650,33 @@ export class MdbaseConnectInternals {
681
650
  pendingMutationKey(collectionId) {
682
651
  return `${this.storagePrefix()}:pending-mutation:${collectionId}`;
683
652
  }
653
+ removePendingMutations(collectionId) {
654
+ const baseKey = this.pendingMutationKey(collectionId);
655
+ const keys = [baseKey];
656
+ for (let index = 0; index < this.storage.length; index += 1) {
657
+ const key = this.storage.key(index);
658
+ if (key?.startsWith(`${baseKey}:`))
659
+ keys.push(key);
660
+ }
661
+ for (const key of keys) {
662
+ const pending = parseStored(this.storage.getItem(key));
663
+ if (pending?.keyHandle)
664
+ void this.keyStore.delete(pending.keyHandle).catch(() => undefined);
665
+ this.storage.removeItem(key);
666
+ }
667
+ }
668
+ pendingMutationsUseKey(collectionId, keyHandle) {
669
+ const baseKey = this.pendingMutationKey(collectionId);
670
+ for (let index = 0; index < this.storage.length; index += 1) {
671
+ const key = this.storage.key(index);
672
+ if (key !== baseKey && !key?.startsWith(`${baseKey}:`))
673
+ continue;
674
+ const pending = parseStored(this.storage.getItem(key));
675
+ if (pending?.keyHandle === keyHandle)
676
+ return true;
677
+ }
678
+ return false;
679
+ }
684
680
  notificationKey(collectionId, transport = "web_push") {
685
681
  return `${this.storagePrefix()}:notifications:${collectionId}:${transport}`;
686
682
  }
@@ -718,3 +714,8 @@ export class MdbaseConnectInternals {
718
714
  listener(connections);
719
715
  }
720
716
  }
717
+ function authorizationAbort(signal, message, cause) {
718
+ if (signal.reason instanceof MdbaseConnectError)
719
+ return signal.reason;
720
+ return connectError("authorization_cancelled", message, { cause });
721
+ }
@@ -6,8 +6,8 @@ export interface ConnectionFileTransportOptions {
6
6
  keyStore: GrantKeyStore;
7
7
  serverUrl: string;
8
8
  loopbackUrl: string;
9
- authorizedToken(): Promise<StoredToken | null>;
10
- refreshAuthorization(): Promise<StoredToken>;
9
+ authorizedToken(signal?: AbortSignal): Promise<StoredToken | null>;
10
+ refreshAuthorization(signal?: AbortSignal): Promise<StoredToken>;
11
11
  shouldAttemptDirect(token: StoredToken): Promise<boolean>;
12
12
  onDirectAvailable(): void;
13
13
  onDirectUnavailable(): void;
@@ -13,7 +13,7 @@ export class ConnectionFileTransport {
13
13
  this.local = new LocalFileTransport(options);
14
14
  }
15
15
  async control(method, path = "", input, signal) {
16
- const token = await this.requireFileToken();
16
+ const token = await this.requireFileToken(signal);
17
17
  if (!token.authority) {
18
18
  return this.local.control(token, method, path, input, signal);
19
19
  }
@@ -26,14 +26,14 @@ export class ConnectionFileTransport {
26
26
  return this.local.downloadChunk(session, chunkIndex, signal);
27
27
  }
28
28
  async downloadHostedPart(session, partIndex, expectedLength, signal) {
29
- const token = await this.requireFileToken();
29
+ const token = await this.requireFileToken(signal);
30
30
  if (!token.authority) {
31
31
  throw connectError("not_remote_authority", "Hosted file delivery requires a remote authority endpoint.");
32
32
  }
33
33
  return performHostedFilePartRequest(token, `downloads/${encodeURIComponent(session.transfer_id)}/parts/${partIndex}`, expectedLength, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders);
34
34
  }
35
- async requireFileToken() {
36
- const token = await this.options.authorizedToken();
35
+ async requireFileToken(signal) {
36
+ const token = await this.options.authorizedToken(signal);
37
37
  if (!token?.fileCapability) {
38
38
  throw connectError("not_authorized", "This connection has no file capability.");
39
39
  }
@@ -1,17 +1,20 @@
1
1
  import type { Application } from "./internal-types.js";
2
2
  import type { StoredToken } from "./internal-types.js";
3
+ import type { ConnectRequestOptions } from "./operation-types.js";
3
4
  import type { MdbaseNativeNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNotificationRegistrationOptions } from "./notifications.js";
4
5
  export interface ConnectionNotificationContext {
5
6
  serverUrl: string;
6
7
  storage: Storage;
7
- authorizedToken(): Promise<StoredToken | null>;
8
- register(): Promise<Application>;
8
+ authorizedToken(signal?: AbortSignal): Promise<StoredToken | null>;
9
+ register(signal?: AbortSignal): Promise<Application>;
9
10
  notificationKey(transport?: "web_push" | "fcm"): string;
11
+ requestTimeoutMs: number | null;
10
12
  }
11
13
  export declare class ConnectionNotifications {
12
14
  private readonly context;
13
15
  constructor(context: ConnectionNotificationContext);
14
16
  registerNotifications(options: MdbaseNotificationRegistrationOptions): Promise<MdbaseNotificationRegistration>;
17
+ private registerNotificationsWithinBudget;
15
18
  /**
16
19
  * Register an iOS or Android installation for Connect-managed FCM.
17
20
  *
@@ -19,7 +22,10 @@ export declare class ConnectionNotifications {
19
22
  * same installation whenever Firebase refreshes its token.
20
23
  */
21
24
  registerNativeNotifications(options: MdbaseNativeNotificationRegistrationOptions): Promise<MdbaseNativeNotificationRegistration>;
22
- unregisterNativeNotifications(): Promise<void>;
23
- unregisterNotifications(serviceWorker?: ServiceWorkerRegistration): Promise<void>;
25
+ private registerNativeNotificationsWithinBudget;
26
+ unregisterNativeNotifications(options?: ConnectRequestOptions): Promise<void>;
27
+ private unregisterNativeNotificationsWithinBudget;
28
+ unregisterNotifications(serviceWorker?: ServiceWorkerRegistration, options?: ConnectRequestOptions): Promise<void>;
29
+ private unregisterNotificationsWithinBudget;
24
30
  private deleteNotificationChannel;
25
31
  }