@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.
- 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 +83 -78
- 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/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,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,
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
150
|
-
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)
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
670
|
+
return this.refreshAuthorization(options);
|
|
643
671
|
}
|
|
644
|
-
refreshAuthorization(
|
|
645
|
-
|
|
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
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
716
|
-
|
|
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>;
|