@mdbase-dev/connect 0.1.0-beta.83 → 0.1.0-beta.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -6
- package/dist/advanced.d.ts +1 -1
- package/dist/application-session.d.ts +31 -10
- package/dist/application-session.js +278 -64
- package/dist/authorization-refresh.d.ts +14 -0
- package/dist/authorization-refresh.js +41 -0
- package/dist/authorization-url.d.ts +1 -0
- package/dist/authorization-url.js +9 -0
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +3 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/connect.d.ts +4 -1
- package/dist/connect.js +33 -26
- package/dist/connection-file-transport.d.ts +3 -0
- package/dist/connection-file-transport.js +59 -16
- package/dist/connection-transport-internals.d.ts +2 -0
- package/dist/connection-transport.d.ts +3 -1
- package/dist/connection-transport.js +90 -82
- package/dist/grant-key-leases.d.ts +24 -0
- package/dist/grant-key-leases.js +144 -0
- package/dist/hosted-file-request.d.ts +2 -2
- package/dist/hosted-file-request.js +4 -4
- package/dist/hosted-operation-response.d.ts +5 -0
- package/dist/hosted-operation-response.js +26 -0
- package/dist/internal-types.d.ts +2 -0
- package/dist/local-file-transport.d.ts +8 -2
- package/dist/local-file-transport.js +71 -20
- package/dist/mdbase-connect.d.ts +1 -0
- package/dist/mdbase-connect.js +3 -0
- package/dist/outcomes.d.ts +12 -12
- package/dist/outcomes.js +6 -0
- package/dist/pending-recovery-transports.d.ts +1 -0
- package/dist/pending-recovery-transports.js +7 -3
- package/dist/retired-grant-keys.d.ts +9 -0
- package/dist/retired-grant-keys.js +56 -0
- package/dist/session.d.ts +36 -7
- package/dist/session.js +287 -34
- package/dist/shared-operation.d.ts +7 -0
- package/dist/shared-operation.js +41 -0
- package/dist/token-response.d.ts +2 -1
- package/dist/token-response.js +2 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
sha384-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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,15 +3,19 @@ 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,
|
|
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";
|
|
18
|
+
import { decodeHostedOperationResponse } from "./hosted-operation-response.js";
|
|
15
19
|
export class ConnectionTransport {
|
|
16
20
|
serverUrl;
|
|
17
21
|
storage;
|
|
@@ -24,7 +28,7 @@ export class ConnectionTransport {
|
|
|
24
28
|
timeouts;
|
|
25
29
|
pendingMutationStore;
|
|
26
30
|
files;
|
|
27
|
-
|
|
31
|
+
refreshOperations = new SharedOperationMap();
|
|
28
32
|
directStatus;
|
|
29
33
|
currentRoute = "relay";
|
|
30
34
|
directFailures = 0;
|
|
@@ -44,8 +48,10 @@ export class ConnectionTransport {
|
|
|
44
48
|
keyStore: this.keyStore,
|
|
45
49
|
serverUrl: this.serverUrl,
|
|
46
50
|
loopbackUrl: this.loopbackUrl,
|
|
51
|
+
collectionId: this.collectionId,
|
|
52
|
+
currentToken: () => this.currentToken(),
|
|
47
53
|
authorizedToken: (signal) => this.authorizedToken({ signal, timeoutMs: null }),
|
|
48
|
-
refreshAuthorization: (signal) => this.refreshAuthorization(signal),
|
|
54
|
+
refreshAuthorization: (signal) => this.refreshAuthorization({ signal, timeoutMs: null }),
|
|
49
55
|
shouldAttemptDirect: (token) => this.shouldAttemptDirect(token),
|
|
50
56
|
onDirectAvailable: () => {
|
|
51
57
|
this.markDirectAvailable();
|
|
@@ -53,6 +59,7 @@ export class ConnectionTransport {
|
|
|
53
59
|
},
|
|
54
60
|
onDirectUnavailable: () => this.markDirectUnavailable(),
|
|
55
61
|
onRelayAvailable: () => this.setRoute("relay"),
|
|
62
|
+
acquireGrantKeyLease: (collectionId, keyHandle, signal) => this.internals.acquireGrantKeyLease(collectionId, keyHandle, signal),
|
|
56
63
|
authorityProofHeaders: (token, method, url, body, credential) => authorityProofHeaders(this.keyStore, token, method, url, body, credential)
|
|
57
64
|
});
|
|
58
65
|
this.directStatus = this.directAccessMode === "disabled"
|
|
@@ -131,9 +138,19 @@ export class ConnectionTransport {
|
|
|
131
138
|
}
|
|
132
139
|
async performOperationWithinBudget(operation, input, options, storedPending, freshReadRetried = false, connectorBusyRetries = 0, knownRejectedMutationRetry = false) {
|
|
133
140
|
throwIfCancelled(options.signal);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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) {
|
|
137
154
|
if (!token.operations.includes(operation)) {
|
|
138
155
|
throw connectError("insufficient_access", `This connection does not allow ${operation}.`, {
|
|
139
156
|
details: {
|
|
@@ -145,15 +162,19 @@ export class ConnectionTransport {
|
|
|
145
162
|
}
|
|
146
163
|
let tryDirect = await this.shouldAttemptDirect(token);
|
|
147
164
|
if (!tryDirect) {
|
|
148
|
-
|
|
149
|
-
if (!
|
|
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)
|
|
150
170
|
throw connectError("not_authorized", "Reconnect this application to continue.");
|
|
171
|
+
token = current;
|
|
151
172
|
}
|
|
152
173
|
const pendingRequestId = storedPending?.requestId
|
|
153
174
|
?? (isMutation(operation, input) ? crypto.randomUUID() : undefined);
|
|
154
175
|
let attempt;
|
|
155
176
|
try {
|
|
156
|
-
attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId, knownRejectedMutationRetry);
|
|
177
|
+
attempt = await this.sendOperation(token, operation, input, tryDirect, leases, options, pendingRequestId, knownRejectedMutationRetry);
|
|
157
178
|
}
|
|
158
179
|
catch (error) {
|
|
159
180
|
throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
|
|
@@ -171,10 +192,14 @@ export class ConnectionTransport {
|
|
|
171
192
|
}
|
|
172
193
|
if (attempt.pendingMutation)
|
|
173
194
|
this.clearPendingMutation(attempt.requestId);
|
|
174
|
-
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;
|
|
175
200
|
tryDirect = await this.shouldAttemptDirect(token);
|
|
176
201
|
try {
|
|
177
|
-
attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId, knownRejectedMutationRetry);
|
|
202
|
+
attempt = await this.sendOperation(token, operation, input, tryDirect, leases, options, pendingRequestId, knownRejectedMutationRetry);
|
|
178
203
|
}
|
|
179
204
|
catch (error) {
|
|
180
205
|
throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
|
|
@@ -192,17 +217,19 @@ export class ConnectionTransport {
|
|
|
192
217
|
clearPending: (requestId) => this.clearPendingMutation(requestId),
|
|
193
218
|
retry: (pending, knownRejected) => this.performOperationWithinBudget(operation, input, options, pending, freshReadRetried, connectorBusyRetries + 1, knownRejected)
|
|
194
219
|
});
|
|
195
|
-
let
|
|
220
|
+
let decoded;
|
|
196
221
|
try {
|
|
197
|
-
|
|
222
|
+
decoded = await decodeHostedOperationResponse(response, token.authority !== undefined);
|
|
198
223
|
}
|
|
199
224
|
catch (cause) {
|
|
200
225
|
if (attempt.pendingMutation)
|
|
201
226
|
throw unknownMutationOutcome(attempt.requestId, cause);
|
|
202
|
-
throw
|
|
227
|
+
throw cause;
|
|
203
228
|
}
|
|
229
|
+
const body = decoded.body;
|
|
204
230
|
if (!response.ok) {
|
|
205
|
-
const error =
|
|
231
|
+
const error = decoded.httpError
|
|
232
|
+
?? apiError(body, "operation_failed", "Collection operation failed.", response.status);
|
|
206
233
|
const recovery = await retryBusy(error);
|
|
207
234
|
if (recovery.retried)
|
|
208
235
|
return recovery.result;
|
|
@@ -304,21 +331,18 @@ export class ConnectionTransport {
|
|
|
304
331
|
}
|
|
305
332
|
async performAuthoritySyncRequest(collectionId, replicaId, method, path, input, options = {}) {
|
|
306
333
|
return withCooperativeRequestBudget(options, this.timeouts.syncMs, async (budget) => {
|
|
307
|
-
|
|
308
|
-
if (!token?.authority
|
|
309
|
-
|| token.collectionId !== collectionId
|
|
310
|
-
|| token.authority.replicaId !== replicaId) {
|
|
311
|
-
throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
|
|
312
|
-
}
|
|
334
|
+
const leases = this.grantKeyLeases();
|
|
313
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);
|
|
314
341
|
let response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
|
|
315
342
|
if (response.status === 401 && token.refreshToken) {
|
|
316
|
-
token = await this.refreshAuthorization(budget.signal);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|| token.authority.replicaId !== replicaId) {
|
|
320
|
-
throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
|
|
321
|
-
}
|
|
343
|
+
token = await this.refreshAuthorization({ signal: budget.signal, timeoutMs: null });
|
|
344
|
+
token = await retainCurrentGrantToken(() => this.currentToken(), leases, budget.signal);
|
|
345
|
+
requireSyncAuthorization(token, collectionId, replicaId);
|
|
322
346
|
response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
|
|
323
347
|
}
|
|
324
348
|
const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid synchronization response.");
|
|
@@ -335,9 +359,12 @@ export class ConnectionTransport {
|
|
|
335
359
|
: undefined;
|
|
336
360
|
throw operationTransportError(error, budget.signal, mutationRequestId, "hosted_provider_unavailable");
|
|
337
361
|
}
|
|
362
|
+
finally {
|
|
363
|
+
leases.release();
|
|
364
|
+
}
|
|
338
365
|
});
|
|
339
366
|
}
|
|
340
|
-
async sendOperation(token, operation, input, tryDirect, options = {}, pendingRequestId, knownRejectedMutationRetry = false) {
|
|
367
|
+
async sendOperation(token, operation, input, tryDirect, leases, options = {}, pendingRequestId, knownRejectedMutationRetry = false) {
|
|
341
368
|
let body = input ?? {};
|
|
342
369
|
let encryptedRequest;
|
|
343
370
|
let pendingMutation = false;
|
|
@@ -472,9 +499,13 @@ export class ConnectionTransport {
|
|
|
472
499
|
}
|
|
473
500
|
let relayToken;
|
|
474
501
|
try {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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;
|
|
478
509
|
}
|
|
479
510
|
catch (error) {
|
|
480
511
|
if (pendingMutation && (directDeliveryUncertain || resumingMutation))
|
|
@@ -636,58 +667,26 @@ export class ConnectionTransport {
|
|
|
636
667
|
this.internals.removeToken(this.collectionId, token.keyHandle);
|
|
637
668
|
return null;
|
|
638
669
|
}
|
|
639
|
-
return this.refreshAuthorization(options
|
|
670
|
+
return this.refreshAuthorization(options);
|
|
640
671
|
}
|
|
641
|
-
refreshAuthorization(
|
|
642
|
-
|
|
643
|
-
return this.refreshPromise;
|
|
644
|
-
this.refreshPromise = this.performRefresh(signal).finally(() => {
|
|
645
|
-
this.refreshPromise = null;
|
|
646
|
-
});
|
|
647
|
-
return this.refreshPromise;
|
|
672
|
+
refreshAuthorization(options = {}) {
|
|
673
|
+
return this.refreshOperations.run("refresh", options, this.timeouts.requestMs, (signal) => this.performRefresh(signal));
|
|
648
674
|
}
|
|
649
675
|
async performRefresh(signal) {
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}).toString();
|
|
664
|
-
const proof = current.authority
|
|
665
|
-
? await authorityProofHeaders(this.keyStore, current, "POST", refreshUrl, refreshBody, current.refreshToken)
|
|
666
|
-
: {};
|
|
667
|
-
const response = await fetch(refreshUrl, {
|
|
668
|
-
method: "POST",
|
|
669
|
-
headers: {
|
|
670
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
671
|
-
...proof
|
|
672
|
-
},
|
|
673
|
-
body: refreshBody,
|
|
674
|
-
signal
|
|
675
|
-
});
|
|
676
|
-
const body = await decodeJsonResponse(response, "invalid_token_response", "The authorization service returned an invalid token response.");
|
|
677
|
-
if (!response.ok) {
|
|
678
|
-
const latest = this.currentToken();
|
|
679
|
-
if (latest?.refreshToken && latest.refreshToken !== attemptedRefreshToken) {
|
|
680
|
-
return latest;
|
|
681
|
-
}
|
|
682
|
-
if (!this.directCapable(current)) {
|
|
683
|
-
this.internals.removeToken(this.collectionId, current.keyHandle);
|
|
684
|
-
}
|
|
685
|
-
if ((oauthErrorCode(body) ?? body?.error?.code) === "invalid_grant") {
|
|
686
|
-
throw connectError("authorization_expired", body?.error_description ?? body?.error?.message ?? "Reconnect this application to continue.", { status: response.status });
|
|
687
|
-
}
|
|
688
|
-
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();
|
|
689
689
|
}
|
|
690
|
-
return this.storeTokenResponse(body, current.clientId, current.keyHandle);
|
|
691
690
|
}
|
|
692
691
|
storeTokenResponse(body, clientId, keyHandle) {
|
|
693
692
|
if (body.collection_id !== this.collectionId) {
|
|
@@ -709,9 +708,18 @@ export class ConnectionTransport {
|
|
|
709
708
|
}
|
|
710
709
|
clearPendingMutation(requestId) {
|
|
711
710
|
const pending = this.pendingMutationStore.take(requestId);
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
void this.keyStore.delete(pending.keyHandle).catch(() => undefined);
|
|
711
|
+
if (pending?.keyHandle) {
|
|
712
|
+
this.internals.deleteGrantKeyWhenUnused(this.collectionId, pending.keyHandle);
|
|
715
713
|
}
|
|
716
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.");
|
|
717
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>;
|