@mdbase-dev/connect 0.1.0-beta.84 → 0.1.0-beta.86

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 (40) hide show
  1. package/README.md +33 -6
  2. package/dist/advanced.d.ts +1 -1
  3. package/dist/application-session.d.ts +31 -10
  4. package/dist/application-session.js +278 -64
  5. package/dist/authorization-refresh.d.ts +14 -0
  6. package/dist/authorization-refresh.js +41 -0
  7. package/dist/authorization-url.d.ts +1 -0
  8. package/dist/authorization-url.js +9 -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/connect.d.ts +4 -1
  13. package/dist/connect.js +33 -26
  14. package/dist/connection-file-transport.d.ts +3 -0
  15. package/dist/connection-file-transport.js +59 -16
  16. package/dist/connection-transport-internals.d.ts +2 -0
  17. package/dist/connection-transport.d.ts +3 -1
  18. package/dist/connection-transport.js +83 -78
  19. package/dist/grant-key-leases.d.ts +24 -0
  20. package/dist/grant-key-leases.js +144 -0
  21. package/dist/hosted-file-request.d.ts +2 -2
  22. package/dist/hosted-file-request.js +4 -4
  23. package/dist/internal-types.d.ts +2 -0
  24. package/dist/local-file-transport.d.ts +8 -2
  25. package/dist/local-file-transport.js +71 -20
  26. package/dist/mdbase-connect.d.ts +1 -0
  27. package/dist/mdbase-connect.js +3 -0
  28. package/dist/outcomes.d.ts +12 -12
  29. package/dist/outcomes.js +6 -0
  30. package/dist/pending-recovery-transports.d.ts +1 -0
  31. package/dist/pending-recovery-transports.js +7 -3
  32. package/dist/retired-grant-keys.d.ts +9 -0
  33. package/dist/retired-grant-keys.js +56 -0
  34. package/dist/session.d.ts +36 -7
  35. package/dist/session.js +287 -34
  36. package/dist/shared-operation.d.ts +7 -0
  37. package/dist/shared-operation.js +41 -0
  38. package/dist/token-response.d.ts +2 -1
  39. package/dist/token-response.js +2 -1
  40. package/package.json +2 -2
@@ -1 +1 @@
1
- sha384-l1syvCOespqx2xGAwlKWRSmphqrCw5zoEJLrsvcdCqadK8gQTQcTZJuGTJ7NtIZm
1
+ sha384-xB4T3sGGjWDwJkOtsrNfSBOhYxSGOGpRstFke++cfSX3EivOO+d2+QWNBH5yh0qc
package/dist/connect.d.ts CHANGED
@@ -25,6 +25,7 @@ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
25
25
  private application;
26
26
  private manifestPromise;
27
27
  private readonly completionPromises;
28
+ private readonly grantKeyLeases;
28
29
  private readonly connectionCache;
29
30
  private readonly listeners;
30
31
  private readonly invalidatedConnections;
@@ -39,11 +40,11 @@ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
39
40
  private authorizeWeb;
40
41
  private authorizePortable;
41
42
  completeAuthorization(callbackUrl: string, options?: ConnectRequestOptions): Promise<MdbaseAuthorizationResult<Frontmatter>>;
42
- private completeAuthorizationWithinBudget;
43
43
  private performAuthorizationCompletion;
44
44
  connections(): MdbaseConnectionInfo[];
45
45
  connection(collectionId: string): MdbaseConnection<Frontmatter> | null;
46
46
  unavailableReason(collectionId: string): MdbaseUnavailableReason | null;
47
+ connectionApplicationId(collectionId: string): string | null;
47
48
  onConnectionsChange(listener: (connections: MdbaseConnectionInfo[]) => void): () => void;
48
49
  storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
49
50
  forgetConnectorIdentity(connectorId: string): void;
@@ -51,6 +52,8 @@ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
51
52
  private connectorPinKey;
52
53
  private loadApplicationIdentity;
53
54
  removeToken(collectionId: string, keyHandle?: string, reason?: MdbaseUnavailableReason, discardPending?: boolean): void;
55
+ acquireGrantKeyLease(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
56
+ deleteGrantKeyWhenUnused(collectionId: string, keyHandle: string): void;
54
57
  tokenKey(collectionId: string): string;
55
58
  pendingMutationKey(collectionId: string): string;
56
59
  notificationKey(collectionId: string, transport?: "web_push" | "fcm"): string;
package/dist/connect.js CHANGED
@@ -1,5 +1,6 @@
1
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
+ import { authorizationCallbackState } from "./authorization-url.js";
3
4
  import { randomBase64Url } from "./base64.js";
4
5
  import { ApplicationIdentityStoreError, IndexedDbApplicationIdentityStore, MemoryApplicationIdentityStore, applicationIdentity, applicationInstallationId, signApplicationAuthorization } from "./application-identity.js";
5
6
  import { MdbaseConnection } from "./connection.js";
@@ -10,10 +11,13 @@ import { connectError, serverConnectError } from "./errors.js";
10
11
  import { DEFAULT_OPERATIONS } from "./internal-types.js";
11
12
  import { prepareAuthorizationWindow, publishPopupAuthorizationCompletion, waitForPopupAuthorization } from "./popup-authorization.js";
12
13
  import { uniqueOperations } from "./operation-helpers.js";
13
- import { pendingMutationsUseKey, pendingRecoveryTransports, removePendingMutations } from "./pending-recovery-transports.js";
14
+ import { pendingRecoveryTransports, removePendingMutations } from "./pending-recovery-transports.js";
15
+ import { GrantKeyLeaseRegistry } from "./grant-key-leases.js";
14
16
  import { resolveConnectTimeouts, withRequestBudget } from "./request-budget.js";
15
17
  import { MemoryStorage, apiError, applicationStorageOrigin, canonicalLoopbackUrl, collectionIdFromTokenKey, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, decodeJsonResponse, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseStored, stripTrailingSlash, validAuthorityTokenResponse } from "./runtime-utils.js";
16
18
  import { storedTokenFromResponse } from "./token-response.js";
19
+ import { deleteUnusedGrantKey, planRetiredGrantKeys, storedTokenKeyHandles } from "./retired-grant-keys.js";
20
+ import { SharedOperationMap } from "./shared-operation.js";
17
21
  export class MdbaseConnectInternals {
18
22
  serverUrl;
19
23
  manifest;
@@ -30,7 +34,8 @@ export class MdbaseConnectInternals {
30
34
  credentialStorage;
31
35
  application = null;
32
36
  manifestPromise = null;
33
- completionPromises = new Map();
37
+ completionPromises = new SharedOperationMap();
38
+ grantKeyLeases;
34
39
  connectionCache = new Map();
35
40
  listeners = new Set();
36
41
  invalidatedConnections = new Map();
@@ -61,6 +66,7 @@ export class MdbaseConnectInternals {
61
66
  this.loopbackUrl = canonicalLoopbackUrl(String(options.loopbackUrl ?? `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}`));
62
67
  this.navigate = options.navigate;
63
68
  this.timeouts = resolveConnectTimeouts(options.timeouts);
69
+ this.grantKeyLeases = new GrantKeyLeaseRegistry(this.storagePrefix(), (collectionId, keyHandle) => this.deleteGrantKeyWhenUnused(collectionId, keyHandle));
64
70
  if (typeof window !== "undefined" && this.storage === window.localStorage) {
65
71
  window.addEventListener("storage", (event) => {
66
72
  if (event.storageArea !== this.storage || !event.key?.startsWith(this.storagePrefix()))
@@ -479,32 +485,20 @@ export class MdbaseConnectInternals {
479
485
  }
480
486
  }
481
487
  completeAuthorization(callbackUrl, options = {}) {
482
- return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.completeAuthorizationWithinBudget(callbackUrl, budget.signal));
483
- }
484
- completeAuthorizationWithinBudget(callbackUrl, signal) {
485
- const state = new URL(callbackUrl).searchParams.get("state");
488
+ const state = authorizationCallbackState(callbackUrl);
486
489
  if (!state) {
487
490
  return Promise.reject(connectError("invalid_callback", "Authorization callback is missing its state."));
488
491
  }
489
492
  const pending = parseStored(this.storage.getItem(this.pendingKey(state)));
490
493
  const popupAttempt = pending?.presentation === "popup";
491
- const existing = this.completionPromises.get(state);
492
- if (existing)
493
- return existing;
494
- const completion = publishPopupAuthorizationCompletion({
494
+ return this.completionPromises.run(state, options, this.timeouts.requestMs, (signal) => publishPopupAuthorizationCompletion({
495
495
  storage: this.storage,
496
496
  storagePrefix: this.storagePrefix(),
497
497
  state,
498
498
  pending,
499
499
  popupAttempt,
500
500
  completion: this.performAuthorizationCompletion(callbackUrl, state, signal)
501
- });
502
- const shared = completion.finally(() => {
503
- if (this.completionPromises.get(state) === shared)
504
- this.completionPromises.delete(state);
505
- });
506
- this.completionPromises.set(state, shared);
507
- return shared;
501
+ }));
508
502
  }
509
503
  async performAuthorizationCompletion(callbackUrl, state, signal) {
510
504
  const callback = new URL(callbackUrl);
@@ -594,6 +588,13 @@ export class MdbaseConnectInternals {
594
588
  unavailableReason(collectionId) {
595
589
  return this.invalidatedConnections.get(collectionId) ?? null;
596
590
  }
591
+ connectionApplicationId(collectionId) {
592
+ // Null deliberately means the application must reauthorize.
593
+ if (!this.connection(collectionId))
594
+ return null;
595
+ const token = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
596
+ return typeof token?.clientId === "string" && token.clientId.length > 0 ? token.clientId : null;
597
+ }
597
598
  onConnectionsChange(listener) {
598
599
  this.listeners.add(listener);
599
600
  listener(this.connections());
@@ -605,18 +606,19 @@ export class MdbaseConnectInternals {
605
606
  throw connectError("invalid_token_response", "Authorization returned no collection ID.");
606
607
  }
607
608
  const previous = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
609
+ const retirement = planRetiredGrantKeys(this.storage, this.pendingMutationKey(collectionId), previous, keyHandle, this.grantKeyLeases.handles(collectionId), this.grantKeyLeases.retainAutomaticallyRetiredKeys);
608
610
  const token = storedTokenFromResponse({
609
611
  body,
610
612
  clientId,
611
613
  keyHandle,
612
614
  previous,
615
+ retiredKeyHandles: retirement.retained,
613
616
  defaultApplicationOrigin: this.defaultApplicationOrigin(),
614
617
  pinConnectorIdentity: (connectorId, publicKey) => this.pinConnectorIdentity(connectorId, publicKey)
615
618
  });
616
- if (previous?.keyHandle && previous.keyHandle !== keyHandle) {
617
- void this.keyStore.delete(previous.keyHandle).catch(() => undefined);
618
- }
619
619
  this.storage.setItem(this.tokenKey(collectionId), JSON.stringify(token));
620
+ for (const handle of retirement.disposable)
621
+ this.deleteGrantKeyWhenUnused(collectionId, handle);
620
622
  addConnectionId(this.storage, this.storagePrefix(), collectionId);
621
623
  this.invalidatedConnections.delete(collectionId);
622
624
  this.connectionCache.delete(collectionId);
@@ -650,14 +652,13 @@ export class MdbaseConnectInternals {
650
652
  }
651
653
  removeToken(collectionId, keyHandle, reason = "authorization_lost", discardPending = false) {
652
654
  this.invalidatedConnections.set(collectionId, reason);
653
- if (keyHandle && (discardPending || !pendingMutationsUseKey(this.storage, this.pendingMutationKey(collectionId), keyHandle))) {
654
- void this.keyStore.delete(keyHandle).catch(() => undefined);
655
- }
655
+ const stored = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
656
+ const handles = storedTokenKeyHandles(stored, keyHandle);
656
657
  this.storage.removeItem(this.tokenKey(collectionId));
657
658
  if (discardPending)
658
- removePendingMutations(this.storage, this.pendingMutationKey(collectionId), (pendingKeyHandle) => {
659
- void this.keyStore.delete(pendingKeyHandle).catch(() => undefined);
660
- });
659
+ removePendingMutations(this.storage, this.pendingMutationKey(collectionId), (pendingKeyHandle) => handles.add(pendingKeyHandle));
660
+ for (const handle of handles)
661
+ this.deleteGrantKeyWhenUnused(collectionId, handle);
661
662
  for (const transport of ["web_push", "fcm"]) {
662
663
  this.storage.removeItem(this.notificationKey(collectionId, transport));
663
664
  }
@@ -665,6 +666,12 @@ export class MdbaseConnectInternals {
665
666
  this.connectionCache.delete(collectionId);
666
667
  this.emitConnections();
667
668
  }
669
+ acquireGrantKeyLease(collectionId, keyHandle, signal) {
670
+ return this.grantKeyLeases.acquire(collectionId, keyHandle, signal);
671
+ }
672
+ deleteGrantKeyWhenUnused(collectionId, keyHandle) {
673
+ this.grantKeyLeases.deleteExclusive(collectionId, keyHandle, () => deleteUnusedGrantKey(this.storage, this.keyStore, this.pendingMutationKey(collectionId), this.tokenKey(collectionId), keyHandle));
674
+ }
668
675
  tokenKey(collectionId) {
669
676
  return `${this.storagePrefix()}:token:${collectionId}`;
670
677
  }
@@ -6,6 +6,8 @@ export interface ConnectionFileTransportOptions {
6
6
  keyStore: GrantKeyStore;
7
7
  serverUrl: string;
8
8
  loopbackUrl: string;
9
+ collectionId: string;
10
+ currentToken(): StoredToken | null;
9
11
  authorizedToken(signal?: AbortSignal): Promise<StoredToken | null>;
10
12
  refreshAuthorization(signal?: AbortSignal): Promise<StoredToken>;
11
13
  shouldAttemptDirect(token: StoredToken): Promise<boolean>;
@@ -13,6 +15,7 @@ export interface ConnectionFileTransportOptions {
13
15
  onDirectUnavailable(): void;
14
16
  onRelayAvailable(): void;
15
17
  authorityProofHeaders: ProofHeaders;
18
+ acquireGrantKeyLease(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
16
19
  }
17
20
  /**
18
21
  * Owns every transport concern specific to files. ConnectionTransport only
@@ -1,5 +1,6 @@
1
1
  import { connectError } from "./errors.js";
2
2
  import { performHostedFilePartRequest, performHostedFileRequest } from "./hosted-file-request.js";
3
+ import { GrantKeyLeaseSet, retainCurrentGrantToken } from "./grant-key-leases.js";
3
4
  import { LocalFileTransport } from "./local-file-transport.js";
4
5
  /**
5
6
  * Owns every transport concern specific to files. ConnectionTransport only
@@ -13,30 +14,72 @@ export class ConnectionFileTransport {
13
14
  this.local = new LocalFileTransport(options);
14
15
  }
15
16
  async control(method, path = "", input, signal) {
16
- const token = await this.requireFileToken(signal);
17
- if (!token.authority) {
18
- return this.local.control(token, method, path, input, signal);
17
+ const leased = await this.requireFileToken(signal);
18
+ const token = leased.token;
19
+ try {
20
+ if (!token.authority) {
21
+ return await this.local.control(token, method, path, input, signal);
22
+ }
23
+ return await performHostedFileRequest(token, method, path, input, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders, leased.retain);
24
+ }
25
+ finally {
26
+ leased.release();
19
27
  }
20
- return performHostedFileRequest(token, method, path, input, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders);
21
28
  }
22
- uploadChunk(session, chunkIndex, bytes, signal) {
23
- return this.local.uploadChunk(session, chunkIndex, bytes, signal);
29
+ async uploadChunk(session, chunkIndex, bytes, signal) {
30
+ const leased = await this.requireFileToken(signal);
31
+ try {
32
+ return await this.local.uploadChunk(session, chunkIndex, bytes, signal, leased.token);
33
+ }
34
+ finally {
35
+ leased.release();
36
+ }
24
37
  }
25
- downloadChunk(session, chunkIndex, signal) {
26
- return this.local.downloadChunk(session, chunkIndex, signal);
38
+ async downloadChunk(session, chunkIndex, signal) {
39
+ const leased = await this.requireFileToken(signal);
40
+ try {
41
+ return await this.local.downloadChunk(session, chunkIndex, signal, leased.token);
42
+ }
43
+ finally {
44
+ leased.release();
45
+ }
27
46
  }
28
47
  async downloadHostedPart(session, partIndex, expectedLength, signal) {
29
- const token = await this.requireFileToken(signal);
30
- if (!token.authority) {
31
- throw connectError("not_remote_authority", "Hosted file delivery requires a remote authority endpoint.");
48
+ const leased = await this.requireFileToken(signal);
49
+ const token = leased.token;
50
+ try {
51
+ if (!token.authority) {
52
+ throw connectError("not_remote_authority", "Hosted file delivery requires a remote authority endpoint.");
53
+ }
54
+ return await performHostedFilePartRequest(token, `downloads/${encodeURIComponent(session.transfer_id)}/parts/${partIndex}`, expectedLength, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders, leased.retain);
55
+ }
56
+ finally {
57
+ leased.release();
32
58
  }
33
- return performHostedFilePartRequest(token, `downloads/${encodeURIComponent(session.transfer_id)}/parts/${partIndex}`, expectedLength, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders);
34
59
  }
35
60
  async requireFileToken(signal) {
36
- const token = await this.options.authorizedToken(signal);
37
- if (!token?.fileCapability) {
38
- throw connectError("not_authorized", "This connection has no file capability.");
61
+ const leases = new GrantKeyLeaseSet(this.options.collectionId, this.options.acquireGrantKeyLease);
62
+ try {
63
+ await retainCurrentGrantToken(this.options.currentToken, leases, signal);
64
+ await this.options.authorizedToken(signal);
65
+ const token = await retainCurrentGrantToken(this.options.currentToken, leases, signal);
66
+ if (!token?.fileCapability) {
67
+ throw connectError("not_authorized", "This connection has no file capability.");
68
+ }
69
+ return {
70
+ token,
71
+ retain: async (_refreshed) => {
72
+ const current = await retainCurrentGrantToken(this.options.currentToken, leases, signal);
73
+ if (current)
74
+ return current;
75
+ throw connectError("not_authorized", "Reconnect this application to continue.");
76
+ },
77
+ release: () => leases.release()
78
+ };
79
+ }
80
+ catch (error) {
81
+ leases.release();
82
+ throw error;
39
83
  }
40
- return token;
41
84
  }
42
85
  }
@@ -5,6 +5,8 @@ export interface ConnectionTransportInternals {
5
5
  readonly relayEncryption: "required" | "disabled";
6
6
  removeToken(collectionId: string, keyHandle?: string, reason?: "not_authorized" | "authorization_lost" | "invalid_stored_grant", discardPending?: boolean): void;
7
7
  storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
8
+ acquireGrantKeyLease(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
9
+ deleteGrantKeyWhenUnused(collectionId: string, keyHandle: string): void;
8
10
  tokenKey(collectionId: string): string;
9
11
  pendingMutationKey(collectionId: string): string;
10
12
  directPreferenceKey(): string;
@@ -17,7 +17,7 @@ export declare class ConnectionTransport {
17
17
  private readonly timeouts;
18
18
  private readonly pendingMutationStore;
19
19
  readonly files: ConnectionFileTransport;
20
- private refreshPromise;
20
+ private readonly refreshOperations;
21
21
  private directStatus;
22
22
  private currentRoute;
23
23
  private directFailures;
@@ -38,6 +38,7 @@ export declare class ConnectionTransport {
38
38
  recoverPendingMutation<Result>(requestId: string, options?: ConnectRequestOptions): Promise<Result>;
39
39
  performOperation<Result>(operation: CollectionOperation, input: unknown, options?: ConnectRequestOptions): Promise<Result>;
40
40
  private performOperationWithinBudget;
41
+ private performLeasedOperation;
41
42
  performAuthoritySyncRequest<Result>(collectionId: string, replicaId: string, method: "GET" | "POST", path: string, input?: unknown, options?: ConnectRequestOptions): Promise<Result>;
42
43
  private sendOperation;
43
44
  private directCapable;
@@ -59,4 +60,5 @@ export declare class ConnectionTransport {
59
60
  private storedPendingMutations;
60
61
  private storedPendingMutation;
61
62
  private clearPendingMutation;
63
+ private grantKeyLeases;
62
64
  }
@@ -3,13 +3,16 @@ import { decryptRelayResponse, encryptRelayRequest, RelayCryptoError } from "./c
3
3
  import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
4
4
  import { ConnectionFileTransport } from "./connection-file-transport.js";
5
5
  import { authorityProofHeaders } from "./authority-proof.js";
6
+ import { performAuthorizationRefresh } from "./authorization-refresh.js";
6
7
  import { PendingMutationStore } from "./pending-mutation-store.js";
7
8
  import { directFallbackStatus, encryptedOperationError, fetchOperationRequest, isMutation, localNetworkPermission, loopbackRequest, operationFingerprint, operationTransportError, sameAuthorization, throwIfCancelled, unknownMutationOutcome, withOperationDeadline } from "./operation-helpers.js";
8
- import { apiError, decodeJsonResponse, oauthErrorCode, parseStored, } from "./runtime-utils.js";
9
+ import { apiError, decodeJsonResponse, parseStored, } from "./runtime-utils.js";
9
10
  import { readStoredToken } from "./stored-token.js";
10
11
  import { requestOptionsWithinBudget, withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
11
12
  import { sendAuthoritySyncRequest } from "./authority-sync-request.js";
12
13
  import { freshEncryptedRequest } from "./fresh-encrypted-request.js";
14
+ import { GrantKeyLeaseSet, retainCurrentGrantToken } from "./grant-key-leases.js";
15
+ import { SharedOperationMap } from "./shared-operation.js";
13
16
  import { isExplicitConnectorBusyResponse, retryExplicitConnectorBusy } from "./transient-retry.js";
14
17
  import { probeLoopbackAccess, tokenSupportsDirectAccess } from "./direct-access.js";
15
18
  import { decodeHostedOperationResponse } from "./hosted-operation-response.js";
@@ -25,7 +28,7 @@ export class ConnectionTransport {
25
28
  timeouts;
26
29
  pendingMutationStore;
27
30
  files;
28
- refreshPromise = null;
31
+ refreshOperations = new SharedOperationMap();
29
32
  directStatus;
30
33
  currentRoute = "relay";
31
34
  directFailures = 0;
@@ -45,8 +48,10 @@ export class ConnectionTransport {
45
48
  keyStore: this.keyStore,
46
49
  serverUrl: this.serverUrl,
47
50
  loopbackUrl: this.loopbackUrl,
51
+ collectionId: this.collectionId,
52
+ currentToken: () => this.currentToken(),
48
53
  authorizedToken: (signal) => this.authorizedToken({ signal, timeoutMs: null }),
49
- refreshAuthorization: (signal) => this.refreshAuthorization(signal),
54
+ refreshAuthorization: (signal) => this.refreshAuthorization({ signal, timeoutMs: null }),
50
55
  shouldAttemptDirect: (token) => this.shouldAttemptDirect(token),
51
56
  onDirectAvailable: () => {
52
57
  this.markDirectAvailable();
@@ -54,6 +59,7 @@ export class ConnectionTransport {
54
59
  },
55
60
  onDirectUnavailable: () => this.markDirectUnavailable(),
56
61
  onRelayAvailable: () => this.setRoute("relay"),
62
+ acquireGrantKeyLease: (collectionId, keyHandle, signal) => this.internals.acquireGrantKeyLease(collectionId, keyHandle, signal),
57
63
  authorityProofHeaders: (token, method, url, body, credential) => authorityProofHeaders(this.keyStore, token, method, url, body, credential)
58
64
  });
59
65
  this.directStatus = this.directAccessMode === "disabled"
@@ -132,9 +138,19 @@ export class ConnectionTransport {
132
138
  }
133
139
  async performOperationWithinBudget(operation, input, options, storedPending, freshReadRetried = false, connectorBusyRetries = 0, knownRejectedMutationRetry = false) {
134
140
  throwIfCancelled(options.signal);
135
- let token = this.currentToken();
136
- if (!token)
137
- throw connectError("not_authorized", "Connect this application before accessing a collection.");
141
+ const leases = this.grantKeyLeases();
142
+ try {
143
+ const token = await retainCurrentGrantToken(() => this.currentToken(), leases, options.signal);
144
+ if (!token)
145
+ throw connectError("not_authorized", "Connect this application before accessing a collection.");
146
+ await leases.retain(storedPending?.keyHandle, options.signal);
147
+ return await this.performLeasedOperation(token, operation, input, options, leases, storedPending, freshReadRetried, connectorBusyRetries, knownRejectedMutationRetry);
148
+ }
149
+ finally {
150
+ leases.release();
151
+ }
152
+ }
153
+ async performLeasedOperation(token, operation, input, options, leases, storedPending, freshReadRetried = false, connectorBusyRetries = 0, knownRejectedMutationRetry = false) {
138
154
  if (!token.operations.includes(operation)) {
139
155
  throw connectError("insufficient_access", `This connection does not allow ${operation}.`, {
140
156
  details: {
@@ -146,15 +162,19 @@ export class ConnectionTransport {
146
162
  }
147
163
  let tryDirect = await this.shouldAttemptDirect(token);
148
164
  if (!tryDirect) {
149
- token = await this.authorizedToken({ signal: options.signal, timeoutMs: null });
150
- if (!token)
165
+ const authorized = await this.authorizedToken({ signal: options.signal, timeoutMs: null });
166
+ if (!authorized)
167
+ throw connectError("not_authorized", "Reconnect this application to continue.");
168
+ const current = await retainCurrentGrantToken(() => this.currentToken(), leases, options.signal);
169
+ if (!current)
151
170
  throw connectError("not_authorized", "Reconnect this application to continue.");
171
+ token = current;
152
172
  }
153
173
  const pendingRequestId = storedPending?.requestId
154
174
  ?? (isMutation(operation, input) ? crypto.randomUUID() : undefined);
155
175
  let attempt;
156
176
  try {
157
- attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId, knownRejectedMutationRetry);
177
+ attempt = await this.sendOperation(token, operation, input, tryDirect, leases, options, pendingRequestId, knownRejectedMutationRetry);
158
178
  }
159
179
  catch (error) {
160
180
  throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
@@ -172,10 +192,14 @@ export class ConnectionTransport {
172
192
  }
173
193
  if (attempt.pendingMutation)
174
194
  this.clearPendingMutation(attempt.requestId);
175
- token = await this.refreshAuthorization(options.signal);
195
+ token = await this.refreshAuthorization({ signal: options.signal, timeoutMs: null });
196
+ const current = await retainCurrentGrantToken(() => this.currentToken(), leases, options.signal);
197
+ if (!current)
198
+ throw connectError("not_authorized", "Reconnect this application to continue.");
199
+ token = current;
176
200
  tryDirect = await this.shouldAttemptDirect(token);
177
201
  try {
178
- attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId, knownRejectedMutationRetry);
202
+ attempt = await this.sendOperation(token, operation, input, tryDirect, leases, options, pendingRequestId, knownRejectedMutationRetry);
179
203
  }
180
204
  catch (error) {
181
205
  throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
@@ -307,21 +331,18 @@ export class ConnectionTransport {
307
331
  }
308
332
  async performAuthoritySyncRequest(collectionId, replicaId, method, path, input, options = {}) {
309
333
  return withCooperativeRequestBudget(options, this.timeouts.syncMs, async (budget) => {
310
- let token = await this.authorizedToken({ signal: budget.signal, timeoutMs: null });
311
- if (!token?.authority
312
- || token.collectionId !== collectionId
313
- || token.authority.replicaId !== replicaId) {
314
- throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
315
- }
334
+ const leases = this.grantKeyLeases();
316
335
  try {
336
+ let token = await retainCurrentGrantToken(() => this.currentToken(), leases, budget.signal);
337
+ if (token)
338
+ await this.authorizedToken({ signal: budget.signal, timeoutMs: null });
339
+ token = await retainCurrentGrantToken(() => this.currentToken(), leases, budget.signal);
340
+ requireSyncAuthorization(token, collectionId, replicaId);
317
341
  let response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
318
342
  if (response.status === 401 && token.refreshToken) {
319
- token = await this.refreshAuthorization(budget.signal);
320
- if (!token.authority
321
- || token.collectionId !== collectionId
322
- || token.authority.replicaId !== replicaId) {
323
- throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
324
- }
343
+ token = await this.refreshAuthorization({ signal: budget.signal, timeoutMs: null });
344
+ token = await retainCurrentGrantToken(() => this.currentToken(), leases, budget.signal);
345
+ requireSyncAuthorization(token, collectionId, replicaId);
325
346
  response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
326
347
  }
327
348
  const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid synchronization response.");
@@ -338,9 +359,12 @@ export class ConnectionTransport {
338
359
  : undefined;
339
360
  throw operationTransportError(error, budget.signal, mutationRequestId, "hosted_provider_unavailable");
340
361
  }
362
+ finally {
363
+ leases.release();
364
+ }
341
365
  });
342
366
  }
343
- async sendOperation(token, operation, input, tryDirect, options = {}, pendingRequestId, knownRejectedMutationRetry = false) {
367
+ async sendOperation(token, operation, input, tryDirect, leases, options = {}, pendingRequestId, knownRejectedMutationRetry = false) {
344
368
  let body = input ?? {};
345
369
  let encryptedRequest;
346
370
  let pendingMutation = false;
@@ -475,9 +499,13 @@ export class ConnectionTransport {
475
499
  }
476
500
  let relayToken;
477
501
  try {
478
- relayToken = token.expiresAt > Date.now() + 30_000
479
- ? token
480
- : await this.refreshAuthorization(options.signal);
502
+ if (token.expiresAt <= Date.now() + 30_000) {
503
+ await this.refreshAuthorization({ signal: options.signal, timeoutMs: null });
504
+ }
505
+ const current = await retainCurrentGrantToken(() => this.currentToken(), leases, options.signal);
506
+ if (!current)
507
+ throw connectError("not_authorized", "Reconnect this application to continue.");
508
+ relayToken = current;
481
509
  }
482
510
  catch (error) {
483
511
  if (pendingMutation && (directDeliveryUncertain || resumingMutation))
@@ -639,58 +667,26 @@ export class ConnectionTransport {
639
667
  this.internals.removeToken(this.collectionId, token.keyHandle);
640
668
  return null;
641
669
  }
642
- return this.refreshAuthorization(options.signal);
670
+ return this.refreshAuthorization(options);
643
671
  }
644
- refreshAuthorization(signal) {
645
- if (this.refreshPromise)
646
- return this.refreshPromise;
647
- this.refreshPromise = this.performRefresh(signal).finally(() => {
648
- this.refreshPromise = null;
649
- });
650
- return this.refreshPromise;
672
+ refreshAuthorization(options = {}) {
673
+ return this.refreshOperations.run("refresh", options, this.timeouts.requestMs, (signal) => this.performRefresh(signal));
651
674
  }
652
675
  async performRefresh(signal) {
653
- const current = this.currentToken();
654
- if (!current?.refreshToken) {
655
- throw connectError("not_authorized", "Reconnect this application to continue.");
656
- }
657
- if ((current.refreshExpiresAt ?? 0) <= Date.now()) {
658
- throw connectError("relay_authorization_expired", "Direct access is still available on this computer, but using the relay requires reconnecting this application.");
659
- }
660
- const attemptedRefreshToken = current.refreshToken;
661
- const refreshUrl = `${this.serverUrl}/oauth/token`;
662
- const refreshBody = new URLSearchParams({
663
- grant_type: "refresh_token",
664
- refresh_token: current.refreshToken,
665
- client_id: current.clientId
666
- }).toString();
667
- const proof = current.authority
668
- ? await authorityProofHeaders(this.keyStore, current, "POST", refreshUrl, refreshBody, current.refreshToken)
669
- : {};
670
- const response = await fetch(refreshUrl, {
671
- method: "POST",
672
- headers: {
673
- "content-type": "application/x-www-form-urlencoded",
674
- ...proof
675
- },
676
- body: refreshBody,
677
- signal
678
- });
679
- const body = await decodeJsonResponse(response, "invalid_token_response", "The authorization service returned an invalid token response.");
680
- if (!response.ok) {
681
- const latest = this.currentToken();
682
- if (latest?.refreshToken && latest.refreshToken !== attemptedRefreshToken) {
683
- return latest;
684
- }
685
- if (!this.directCapable(current)) {
686
- this.internals.removeToken(this.collectionId, current.keyHandle);
687
- }
688
- if ((oauthErrorCode(body) ?? body?.error?.code) === "invalid_grant") {
689
- throw connectError("authorization_expired", body?.error_description ?? body?.error?.message ?? "Reconnect this application to continue.", { status: response.status });
690
- }
691
- throw apiError(body, "authorization_expired", "Reconnect this application to continue.", response.status);
676
+ const leases = this.grantKeyLeases();
677
+ try {
678
+ const current = await retainCurrentGrantToken(() => this.currentToken(), leases, signal);
679
+ return await performAuthorizationRefresh({
680
+ current, serverUrl: this.serverUrl, keyStore: this.keyStore, signal,
681
+ currentToken: () => this.currentToken(),
682
+ directCapable: (token) => this.directCapable(token),
683
+ removeToken: (keyHandle) => this.internals.removeToken(this.collectionId, keyHandle),
684
+ storeTokenResponse: (body, clientId, keyHandle) => this.storeTokenResponse(body, clientId, keyHandle)
685
+ });
686
+ }
687
+ finally {
688
+ leases.release();
692
689
  }
693
- return this.storeTokenResponse(body, current.clientId, current.keyHandle);
694
690
  }
695
691
  storeTokenResponse(body, clientId, keyHandle) {
696
692
  if (body.collection_id !== this.collectionId) {
@@ -712,9 +708,18 @@ export class ConnectionTransport {
712
708
  }
713
709
  clearPendingMutation(requestId) {
714
710
  const pending = this.pendingMutationStore.take(requestId);
715
- const currentKeyHandle = this.currentToken()?.keyHandle;
716
- if (pending?.keyHandle && pending.keyHandle !== currentKeyHandle) {
717
- void this.keyStore.delete(pending.keyHandle).catch(() => undefined);
711
+ if (pending?.keyHandle) {
712
+ this.internals.deleteGrantKeyWhenUnused(this.collectionId, pending.keyHandle);
718
713
  }
719
714
  }
715
+ grantKeyLeases() {
716
+ return new GrantKeyLeaseSet(this.collectionId, (collectionId, keyHandle, signal) => this.internals.acquireGrantKeyLease(collectionId, keyHandle, signal));
717
+ }
718
+ }
719
+ function requireSyncAuthorization(token, collectionId, replicaId) {
720
+ if (token?.authority
721
+ && token.collectionId === collectionId
722
+ && token.authority.replicaId === replicaId)
723
+ return;
724
+ throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
720
725
  }
@@ -0,0 +1,24 @@
1
+ import type { StoredToken } from "./internal-types.js";
2
+ export declare class GrantKeyLeaseRegistry {
3
+ private readonly namespace;
4
+ private readonly onFinalRelease;
5
+ private readonly leases;
6
+ private readonly locks;
7
+ readonly retainAutomaticallyRetiredKeys: boolean;
8
+ constructor(namespace: string, onFinalRelease: (collectionId: string, keyHandle: string) => void);
9
+ acquire(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
10
+ deleteExclusive(collectionId: string, keyHandle: string, action: () => void | Promise<void>): void;
11
+ has(collectionId: string, keyHandle: string): boolean;
12
+ handles(collectionId: string): Iterable<string>;
13
+ private acquireLocal;
14
+ private lockName;
15
+ }
16
+ export declare class GrantKeyLeaseSet {
17
+ private readonly collectionId;
18
+ private readonly acquire;
19
+ private readonly releases;
20
+ constructor(collectionId: string, acquire: (collectionId: string, keyHandle: string, signal?: AbortSignal) => Promise<() => void>);
21
+ retain(tokenOrHandle: StoredToken | string | null | undefined, signal?: AbortSignal): Promise<void>;
22
+ release(): void;
23
+ }
24
+ export declare function retainCurrentGrantToken(readCurrent: () => StoredToken | null, leases: GrantKeyLeaseSet, signal?: AbortSignal): Promise<StoredToken | null>;