@mdbase-dev/connect 0.1.0-beta.55 → 0.1.0-beta.57
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 +1 -1
- package/dist/authority-sync-request.d.ts +3 -0
- package/dist/authority-sync-request.js +20 -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 +0 -2
- package/dist/connect.js +7 -31
- package/dist/connection-transport-internals.d.ts +22 -0
- package/dist/connection-transport-internals.js +1 -0
- package/dist/connection-transport.d.ts +2 -22
- package/dist/connection-transport.js +24 -22
- package/dist/crypto.js +22 -9
- package/dist/fresh-encrypted-request.d.ts +4 -0
- package/dist/fresh-encrypted-request.js +28 -0
- package/dist/local-file-transport.d.ts +1 -0
- package/dist/local-file-transport.js +28 -5
- package/dist/pending-recovery-transports.d.ts +4 -0
- package/dist/pending-recovery-transports.js +48 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
sha384-
|
|
1
|
+
sha384-4Lyda0FLrTi1k549p7spkiOUvz9jT9v7sm+BUgVC4uwPVgyZgY8o6aJlDd+H45xY
|
package/dist/connect.d.ts
CHANGED
|
@@ -53,8 +53,6 @@ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
|
|
|
53
53
|
removeToken(collectionId: string, keyHandle?: string, reason?: MdbaseUnavailableReason, discardPending?: boolean): void;
|
|
54
54
|
tokenKey(collectionId: string): string;
|
|
55
55
|
pendingMutationKey(collectionId: string): string;
|
|
56
|
-
private removePendingMutations;
|
|
57
|
-
private pendingMutationsUseKey;
|
|
58
56
|
notificationKey(collectionId: string, transport?: "web_push" | "fcm"): string;
|
|
59
57
|
directPreferenceKey(): string;
|
|
60
58
|
defaultApplicationOrigin(): string;
|
package/dist/connect.js
CHANGED
|
@@ -8,6 +8,7 @@ import { IndexedDbGrantKeyStore, MemoryGrantKeyStore } from "./crypto.js";
|
|
|
8
8
|
import { connectError, serverConnectError } from "./errors.js";
|
|
9
9
|
import { DEFAULT_OPERATIONS } from "./internal-types.js";
|
|
10
10
|
import { uniqueOperations } from "./operation-helpers.js";
|
|
11
|
+
import { pendingMutationsUseKey, pendingRecoveryTransports, removePendingMutations } from "./pending-recovery-transports.js";
|
|
11
12
|
import { resolveConnectTimeouts, withRequestBudget } from "./request-budget.js";
|
|
12
13
|
import { MemoryStorage, apiError, applicationStorageOrigin, canonicalLoopbackUrl, collectionIdFromTokenKey, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, decodeJsonResponse, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseStored, stripTrailingSlash, validAuthorityTokenResponse } from "./runtime-utils.js";
|
|
13
14
|
import { storedTokenFromResponse } from "./token-response.js";
|
|
@@ -211,7 +212,7 @@ export class MdbaseConnectInternals {
|
|
|
211
212
|
redirect_uri: this.redirectUri,
|
|
212
213
|
state,
|
|
213
214
|
code_challenge: challenge,
|
|
214
|
-
contracts: authorizationContractRequirements(operations, application.requirements?.files),
|
|
215
|
+
contracts: authorizationContractRequirements(operations, application.requirements?.files, pendingRecoveryTransports(this.storage, this.storagePrefix(), operations, targetCollectionId)),
|
|
215
216
|
requested_operations: operations,
|
|
216
217
|
...(application.requirements?.files
|
|
217
218
|
? { requested_files: application.requirements.files }
|
|
@@ -312,7 +313,7 @@ export class MdbaseConnectInternals {
|
|
|
312
313
|
issued_at: issuedAt.toISOString(),
|
|
313
314
|
expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
|
|
314
315
|
code_challenge: challenge,
|
|
315
|
-
contracts: authorizationContractRequirements(operations, application.requirements?.files),
|
|
316
|
+
contracts: authorizationContractRequirements(operations, application.requirements?.files, pendingRecoveryTransports(this.storage, this.storagePrefix(), operations, options.target?.kind === "collection" ? options.target.collectionId : undefined)),
|
|
316
317
|
requested_operations: operations,
|
|
317
318
|
...(application.requirements?.files
|
|
318
319
|
? { requested_files: application.requirements.files }
|
|
@@ -631,12 +632,14 @@ export class MdbaseConnectInternals {
|
|
|
631
632
|
}
|
|
632
633
|
removeToken(collectionId, keyHandle, reason = "authorization_lost", discardPending = false) {
|
|
633
634
|
this.invalidatedConnections.set(collectionId, reason);
|
|
634
|
-
if (keyHandle && (discardPending || !this.
|
|
635
|
+
if (keyHandle && (discardPending || !pendingMutationsUseKey(this.storage, this.pendingMutationKey(collectionId), keyHandle))) {
|
|
635
636
|
void this.keyStore.delete(keyHandle).catch(() => undefined);
|
|
636
637
|
}
|
|
637
638
|
this.storage.removeItem(this.tokenKey(collectionId));
|
|
638
639
|
if (discardPending)
|
|
639
|
-
this.
|
|
640
|
+
removePendingMutations(this.storage, this.pendingMutationKey(collectionId), (pendingKeyHandle) => {
|
|
641
|
+
void this.keyStore.delete(pendingKeyHandle).catch(() => undefined);
|
|
642
|
+
});
|
|
640
643
|
for (const transport of ["web_push", "fcm"]) {
|
|
641
644
|
this.storage.removeItem(this.notificationKey(collectionId, transport));
|
|
642
645
|
}
|
|
@@ -653,33 +656,6 @@ export class MdbaseConnectInternals {
|
|
|
653
656
|
pendingMutationKey(collectionId) {
|
|
654
657
|
return `${this.storagePrefix()}:pending-mutation:${collectionId}`;
|
|
655
658
|
}
|
|
656
|
-
removePendingMutations(collectionId) {
|
|
657
|
-
const baseKey = this.pendingMutationKey(collectionId);
|
|
658
|
-
const keys = [baseKey];
|
|
659
|
-
for (let index = 0; index < this.storage.length; index += 1) {
|
|
660
|
-
const key = this.storage.key(index);
|
|
661
|
-
if (key?.startsWith(`${baseKey}:`))
|
|
662
|
-
keys.push(key);
|
|
663
|
-
}
|
|
664
|
-
for (const key of keys) {
|
|
665
|
-
const pending = parseStored(this.storage.getItem(key));
|
|
666
|
-
if (pending?.keyHandle)
|
|
667
|
-
void this.keyStore.delete(pending.keyHandle).catch(() => undefined);
|
|
668
|
-
this.storage.removeItem(key);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
pendingMutationsUseKey(collectionId, keyHandle) {
|
|
672
|
-
const baseKey = this.pendingMutationKey(collectionId);
|
|
673
|
-
for (let index = 0; index < this.storage.length; index += 1) {
|
|
674
|
-
const key = this.storage.key(index);
|
|
675
|
-
if (key !== baseKey && !key?.startsWith(`${baseKey}:`))
|
|
676
|
-
continue;
|
|
677
|
-
const pending = parseStored(this.storage.getItem(key));
|
|
678
|
-
if (pending?.keyHandle === keyHandle)
|
|
679
|
-
return true;
|
|
680
|
-
}
|
|
681
|
-
return false;
|
|
682
|
-
}
|
|
683
659
|
notificationKey(collectionId, transport = "web_push") {
|
|
684
660
|
return `${this.storagePrefix()}:notifications:${collectionId}:${transport}`;
|
|
685
661
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { GrantKeyStore } from "./crypto.js";
|
|
2
|
+
import type { StoredToken } from "./internal-types.js";
|
|
3
|
+
import type { ResolvedConnectTimeouts } from "./request-budget.js";
|
|
4
|
+
export interface ConnectionTransportInternals {
|
|
5
|
+
readonly relayEncryption: "required" | "disabled";
|
|
6
|
+
removeToken(collectionId: string, keyHandle?: string, reason?: "not_authorized" | "authorization_lost" | "invalid_stored_grant", discardPending?: boolean): void;
|
|
7
|
+
storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
|
|
8
|
+
tokenKey(collectionId: string): string;
|
|
9
|
+
pendingMutationKey(collectionId: string): string;
|
|
10
|
+
directPreferenceKey(): string;
|
|
11
|
+
}
|
|
12
|
+
export interface ConnectionTransportOptions {
|
|
13
|
+
serverUrl: string;
|
|
14
|
+
storage: Storage;
|
|
15
|
+
keyStore: GrantKeyStore;
|
|
16
|
+
directAccessMode: "auto" | "disabled";
|
|
17
|
+
loopbackUrl: string;
|
|
18
|
+
collectionId: string;
|
|
19
|
+
internals: ConnectionTransportInternals;
|
|
20
|
+
onChange(): void;
|
|
21
|
+
timeouts: ResolvedConnectTimeouts;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,29 +1,10 @@
|
|
|
1
1
|
import type { CollectionOperation } from "@mdbase-dev/connect-protocol";
|
|
2
|
-
import { type GrantKeyStore } from "./crypto.js";
|
|
3
2
|
import type { StoredToken } from "./internal-types.js";
|
|
4
3
|
import type { DirectAccessStatus, MdbaseConnectionRoute } from "./connection-types.js";
|
|
5
4
|
import type { ConnectRequestOptions, PendingMutationSummary } from "./operation-types.js";
|
|
6
5
|
import { ConnectionFileTransport } from "./connection-file-transport.js";
|
|
7
|
-
import {
|
|
8
|
-
export
|
|
9
|
-
readonly relayEncryption: "required" | "disabled";
|
|
10
|
-
removeToken(collectionId: string, keyHandle?: string, reason?: "not_authorized" | "authorization_lost" | "invalid_stored_grant", discardPending?: boolean): void;
|
|
11
|
-
storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
|
|
12
|
-
tokenKey(collectionId: string): string;
|
|
13
|
-
pendingMutationKey(collectionId: string): string;
|
|
14
|
-
directPreferenceKey(): string;
|
|
15
|
-
}
|
|
16
|
-
export interface ConnectionTransportOptions {
|
|
17
|
-
serverUrl: string;
|
|
18
|
-
storage: Storage;
|
|
19
|
-
keyStore: GrantKeyStore;
|
|
20
|
-
directAccessMode: "auto" | "disabled";
|
|
21
|
-
loopbackUrl: string;
|
|
22
|
-
collectionId: string;
|
|
23
|
-
internals: ConnectionTransportInternals;
|
|
24
|
-
onChange(): void;
|
|
25
|
-
timeouts: ResolvedConnectTimeouts;
|
|
26
|
-
}
|
|
6
|
+
import type { ConnectionTransportOptions } from "./connection-transport-internals.js";
|
|
7
|
+
export type { ConnectionTransportInternals, ConnectionTransportOptions } from "./connection-transport-internals.js";
|
|
27
8
|
export declare class ConnectionTransport {
|
|
28
9
|
private readonly serverUrl;
|
|
29
10
|
private readonly storage;
|
|
@@ -62,7 +43,6 @@ export declare class ConnectionTransport {
|
|
|
62
43
|
performOperation<Result>(operation: CollectionOperation, input: unknown, options?: ConnectRequestOptions): Promise<Result>;
|
|
63
44
|
private performOperationWithinBudget;
|
|
64
45
|
performAuthoritySyncRequest<Result>(collectionId: string, replicaId: string, method: "GET" | "POST", path: string, input?: unknown, options?: ConnectRequestOptions): Promise<Result>;
|
|
65
|
-
private sendAuthoritySyncRequest;
|
|
66
46
|
private sendOperation;
|
|
67
47
|
private directCapable;
|
|
68
48
|
hasResumableMutationTransport(): boolean;
|
|
@@ -8,6 +8,8 @@ import { directFallbackStatus, isMutation, localNetworkPermission, loopbackReque
|
|
|
8
8
|
import { apiError, decodeJsonResponse, oauthErrorCode, parseStored, } from "./runtime-utils.js";
|
|
9
9
|
import { readStoredToken } from "./stored-token.js";
|
|
10
10
|
import { withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
|
|
11
|
+
import { sendAuthoritySyncRequest } from "./authority-sync-request.js";
|
|
12
|
+
import { freshEncryptedRequest } from "./fresh-encrypted-request.js";
|
|
11
13
|
export class ConnectionTransport {
|
|
12
14
|
serverUrl;
|
|
13
15
|
storage;
|
|
@@ -134,7 +136,7 @@ export class ConnectionTransport {
|
|
|
134
136
|
signal: budget.signal
|
|
135
137
|
}));
|
|
136
138
|
}
|
|
137
|
-
async performOperationWithinBudget(operation, input, options, storedPending) {
|
|
139
|
+
async performOperationWithinBudget(operation, input, options, storedPending, freshReadRetried = false) {
|
|
138
140
|
throwIfCancelled(options.signal);
|
|
139
141
|
let token = this.currentToken();
|
|
140
142
|
if (!token)
|
|
@@ -199,6 +201,11 @@ export class ConnectionTransport {
|
|
|
199
201
|
}
|
|
200
202
|
if (!response.ok) {
|
|
201
203
|
const error = apiError(body, "operation_failed", "Collection operation failed.", response.status);
|
|
204
|
+
if (error.code === "fresh_request_required"
|
|
205
|
+
&& !attempt.pendingMutation
|
|
206
|
+
&& !freshReadRetried) {
|
|
207
|
+
return this.performOperationWithinBudget(operation, input, options, undefined, true);
|
|
208
|
+
}
|
|
202
209
|
if (attempt.pendingMutation
|
|
203
210
|
&& (attempt.directDeliveryUncertain
|
|
204
211
|
|| (attempt.encryptedRequest && attempt.resumingMutation))) {
|
|
@@ -230,6 +237,12 @@ export class ConnectionTransport {
|
|
|
230
237
|
applicationId: responseApplicationId,
|
|
231
238
|
encryption: responseEncryption
|
|
232
239
|
}, attempt.encryptedRequest, encryptedResponse);
|
|
240
|
+
if (!decrypted.ok
|
|
241
|
+
&& decrypted.problem.code === "fresh_request_required"
|
|
242
|
+
&& !attempt.pendingMutation
|
|
243
|
+
&& !freshReadRetried) {
|
|
244
|
+
return this.performOperationWithinBudget(operation, input, options, undefined, true);
|
|
245
|
+
}
|
|
233
246
|
if (attempt.pendingMutation)
|
|
234
247
|
this.clearPendingMutation(attempt.requestId);
|
|
235
248
|
if (!decrypted.ok)
|
|
@@ -253,7 +266,9 @@ export class ConnectionTransport {
|
|
|
253
266
|
throw error;
|
|
254
267
|
}
|
|
255
268
|
}
|
|
256
|
-
|
|
269
|
+
const expectedProtocol = attempt.pendingMutationRecord?.request?.protocol_version
|
|
270
|
+
?? OPERATION_TRANSPORT_PROTOCOL_VERSION;
|
|
271
|
+
if (body?.protocol_version !== expectedProtocol
|
|
257
272
|
|| body?.request_id !== attempt.requestId) {
|
|
258
273
|
if (attempt.pendingMutation)
|
|
259
274
|
throw unknownMutationOutcome(attempt.requestId, new Error("Operation response protocol or request ID did not match."));
|
|
@@ -287,7 +302,7 @@ export class ConnectionTransport {
|
|
|
287
302
|
throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
|
|
288
303
|
}
|
|
289
304
|
try {
|
|
290
|
-
let response = await this.
|
|
305
|
+
let response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
|
|
291
306
|
if (response.status === 401 && token.refreshToken) {
|
|
292
307
|
token = await this.refreshAuthorization(budget.signal);
|
|
293
308
|
if (!token.authority
|
|
@@ -295,7 +310,7 @@ export class ConnectionTransport {
|
|
|
295
310
|
|| token.authority.replicaId !== replicaId) {
|
|
296
311
|
throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
|
|
297
312
|
}
|
|
298
|
-
response = await this.
|
|
313
|
+
response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
|
|
299
314
|
}
|
|
300
315
|
const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid synchronization response.");
|
|
301
316
|
if (!response.ok) {
|
|
@@ -313,24 +328,6 @@ export class ConnectionTransport {
|
|
|
313
328
|
}
|
|
314
329
|
});
|
|
315
330
|
}
|
|
316
|
-
async sendAuthoritySyncRequest(token, method, path, input, signal) {
|
|
317
|
-
if (!token.authority) {
|
|
318
|
-
throw connectError("not_remote_authority", "This authorization has no remote authority endpoint.");
|
|
319
|
-
}
|
|
320
|
-
const url = `${token.authority.syncUrl}/${path}`;
|
|
321
|
-
const body = input === undefined ? undefined : JSON.stringify(input);
|
|
322
|
-
const proof = await authorityProofHeaders(this.keyStore, token, method, url, body, token.authority.accessToken);
|
|
323
|
-
return fetch(url, {
|
|
324
|
-
method,
|
|
325
|
-
headers: {
|
|
326
|
-
authorization: `Bearer ${token.authority.accessToken}`,
|
|
327
|
-
...(input === undefined ? {} : { "content-type": "application/json" }),
|
|
328
|
-
...proof
|
|
329
|
-
},
|
|
330
|
-
...(body === undefined ? {} : { body }),
|
|
331
|
-
signal
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
331
|
async sendOperation(token, operation, input, tryDirect, options = {}, pendingRequestId) {
|
|
335
332
|
let body = input ?? {};
|
|
336
333
|
let encryptedRequest;
|
|
@@ -400,6 +397,7 @@ export class ConnectionTransport {
|
|
|
400
397
|
throw serverConnectError(error.code, error.message);
|
|
401
398
|
throw error;
|
|
402
399
|
}
|
|
400
|
+
requestId = encryptedRequest.request_id;
|
|
403
401
|
body = encryptedRequest;
|
|
404
402
|
}
|
|
405
403
|
else {
|
|
@@ -469,6 +467,10 @@ export class ConnectionTransport {
|
|
|
469
467
|
throw error;
|
|
470
468
|
}
|
|
471
469
|
let response;
|
|
470
|
+
if (!pendingMutation) {
|
|
471
|
+
encryptedRequest = await freshEncryptedRequest(this.keyStore, relayToken, operation, input);
|
|
472
|
+
requestId = encryptedRequest.request_id;
|
|
473
|
+
}
|
|
472
474
|
try {
|
|
473
475
|
response = await fetch(`${this.serverUrl}/v1/authorities/${encodeURIComponent(relayToken.collectionId)}/operations/${operation}`, {
|
|
474
476
|
method: "POST",
|
package/dist/crypto.js
CHANGED
|
@@ -161,8 +161,14 @@ export async function encryptRelayRequest(store, handle, binding, operation, inp
|
|
|
161
161
|
validateGrantEncryption(binding.encryption);
|
|
162
162
|
const record = await requireAgreementKey(store, handle, binding.encryption.application_agreement_public_key);
|
|
163
163
|
const counter = await store.nextCounter(handle);
|
|
164
|
-
const metadata = {
|
|
165
|
-
|
|
164
|
+
const metadata = {
|
|
165
|
+
binding,
|
|
166
|
+
protocolVersion: OPERATION_TRANSPORT_PROTOCOL_VERSION,
|
|
167
|
+
requestId,
|
|
168
|
+
operation,
|
|
169
|
+
counter
|
|
170
|
+
};
|
|
171
|
+
const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, metadata.protocolVersion, "request");
|
|
166
172
|
const plaintext = new TextEncoder().encode(JSON.stringify(input ?? {}));
|
|
167
173
|
const ciphertext = await crypto.subtle.encrypt({
|
|
168
174
|
name: "AES-GCM",
|
|
@@ -183,11 +189,12 @@ export async function decryptRelayResponse(store, handle, binding, request, resp
|
|
|
183
189
|
const record = await requireAgreementKey(store, handle, binding.encryption.application_agreement_public_key);
|
|
184
190
|
const metadata = {
|
|
185
191
|
binding,
|
|
192
|
+
protocolVersion: request.protocol_version,
|
|
186
193
|
requestId: request.request_id,
|
|
187
194
|
operation: request.operation,
|
|
188
195
|
counter: request.counter
|
|
189
196
|
};
|
|
190
|
-
const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, "response");
|
|
197
|
+
const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, request.protocol_version, "response");
|
|
191
198
|
try {
|
|
192
199
|
const plaintext = await crypto.subtle.decrypt({
|
|
193
200
|
name: "AES-GCM",
|
|
@@ -246,10 +253,10 @@ async function requireSigningKey(store, handle, expectedPublicKey) {
|
|
|
246
253
|
}
|
|
247
254
|
return record;
|
|
248
255
|
}
|
|
249
|
-
async function deriveDirectionalKey(privateKey, binding, direction) {
|
|
256
|
+
async function deriveDirectionalKey(privateKey, binding, protocolVersion, direction) {
|
|
250
257
|
const shared = await deriveP256SharedSecret(privateKey, binding.encryption.connector_agreement_public_key);
|
|
251
258
|
const material = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
|
|
252
|
-
const contextBytes = new TextEncoder().encode(context(binding));
|
|
259
|
+
const contextBytes = new TextEncoder().encode(context(binding, protocolVersion));
|
|
253
260
|
const salt = await crypto.subtle.digest("SHA-256", contextBytes);
|
|
254
261
|
return crypto.subtle.deriveKey({
|
|
255
262
|
name: "HKDF",
|
|
@@ -268,11 +275,11 @@ export async function deriveP256SharedSecret(privateKey, peerPublicKey) {
|
|
|
268
275
|
throw new RelayCryptoError("invalid_public_key", "The connector relay public key is invalid.");
|
|
269
276
|
}
|
|
270
277
|
}
|
|
271
|
-
function context(binding) {
|
|
278
|
+
function context(binding, protocolVersion) {
|
|
272
279
|
const value = binding.encryption;
|
|
273
280
|
return [
|
|
274
281
|
"mdbase-connect",
|
|
275
|
-
|
|
282
|
+
protocolVersion,
|
|
276
283
|
value.suite,
|
|
277
284
|
binding.grantId,
|
|
278
285
|
binding.applicationId,
|
|
@@ -283,12 +290,18 @@ function context(binding) {
|
|
|
283
290
|
].join("|");
|
|
284
291
|
}
|
|
285
292
|
function aad(metadata, direction) {
|
|
286
|
-
return [
|
|
293
|
+
return [
|
|
294
|
+
context(metadata.binding, metadata.protocolVersion),
|
|
295
|
+
metadata.requestId,
|
|
296
|
+
direction,
|
|
297
|
+
metadata.operation,
|
|
298
|
+
metadata.counter
|
|
299
|
+
].join("|");
|
|
287
300
|
}
|
|
288
301
|
function envelopeMetadata(metadata) {
|
|
289
302
|
const encryption = metadata.binding.encryption;
|
|
290
303
|
return {
|
|
291
|
-
protocol_version:
|
|
304
|
+
protocol_version: metadata.protocolVersion,
|
|
292
305
|
suite: RELAY_ENCRYPTION_SUITE,
|
|
293
306
|
request_id: metadata.requestId,
|
|
294
307
|
grant_id: metadata.binding.grantId,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CollectionOperation, EncryptedRelayOperationRequest } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { type GrantKeyStore } from "./crypto.js";
|
|
3
|
+
import type { StoredToken } from "./internal-types.js";
|
|
4
|
+
export declare function freshEncryptedRequest(keyStore: GrantKeyStore, token: StoredToken, operation: CollectionOperation, input: unknown): Promise<EncryptedRelayOperationRequest>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { encryptRelayRequest, RelayCryptoError } from "./crypto.js";
|
|
2
|
+
import { connectError, serverConnectError } from "./errors.js";
|
|
3
|
+
export async function freshEncryptedRequest(keyStore, token, operation, input) {
|
|
4
|
+
if (!token.encryption || !token.grantId || !token.keyHandle) {
|
|
5
|
+
throw connectError("authorization_binding_incompatible", "The refreshed authorization no longer supports encrypted connector operations.", {
|
|
6
|
+
details: {
|
|
7
|
+
contract: "grant_encryption",
|
|
8
|
+
required: [1],
|
|
9
|
+
supported: token.encryption ? [token.encryption.protocol_version] : [],
|
|
10
|
+
peer: "connector",
|
|
11
|
+
operation
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return await encryptRelayRequest(keyStore, token.keyHandle, {
|
|
17
|
+
grantId: token.grantId,
|
|
18
|
+
applicationId: token.clientId,
|
|
19
|
+
encryption: token.encryption
|
|
20
|
+
}, operation, input);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof RelayCryptoError) {
|
|
24
|
+
throw serverConnectError(error.code, error.message);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -17,6 +17,7 @@ export declare class LocalFileTransport {
|
|
|
17
17
|
private readonly options;
|
|
18
18
|
constructor(options: LocalFileTransportOptions);
|
|
19
19
|
control<Result>(token: StoredToken, method: "GET" | "POST" | "DELETE", path: string, input: unknown, signal?: AbortSignal): Promise<Result>;
|
|
20
|
+
private controlAttempt;
|
|
20
21
|
private decryptControlResponse;
|
|
21
22
|
uploadChunk(session: FileTransferSession, chunkIndex: number, bytes: Uint8Array, signal?: AbortSignal): Promise<void>;
|
|
22
23
|
downloadChunk(session: FileTransferSession, chunkIndex: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
@@ -11,11 +11,15 @@ export class LocalFileTransport {
|
|
|
11
11
|
this.options = options;
|
|
12
12
|
}
|
|
13
13
|
async control(token, method, path, input, signal) {
|
|
14
|
+
return this.controlAttempt(token, method, path, input, signal, false);
|
|
15
|
+
}
|
|
16
|
+
async controlAttempt(token, method, path, input, signal, freshReadRetried) {
|
|
14
17
|
requireEncryption(token);
|
|
15
18
|
const controlInput = localFileControlInput(method, path, input);
|
|
16
19
|
let encryptedRequest = await this.encryptControl(token, controlInput);
|
|
17
20
|
let response;
|
|
18
|
-
|
|
21
|
+
const triedDirect = await this.options.shouldAttemptDirect(token);
|
|
22
|
+
if (triedDirect) {
|
|
19
23
|
try {
|
|
20
24
|
response = await fetch(`${this.options.loopbackUrl}/v1/files/control`, loopbackRequest({
|
|
21
25
|
method: "POST",
|
|
@@ -32,11 +36,18 @@ export class LocalFileTransport {
|
|
|
32
36
|
if (response && !directFallbackStatus(response.status)) {
|
|
33
37
|
if (response.ok)
|
|
34
38
|
this.options.onDirectAvailable();
|
|
35
|
-
return this.decryptControlResponse(token, encryptedRequest, response);
|
|
39
|
+
return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried);
|
|
36
40
|
}
|
|
37
41
|
if (response)
|
|
38
42
|
this.options.onDirectUnavailable();
|
|
39
43
|
}
|
|
44
|
+
if (triedDirect && method === "GET") {
|
|
45
|
+
token = token.expiresAt > Date.now() + 30_000
|
|
46
|
+
? token
|
|
47
|
+
: await this.options.refreshAuthorization(signal);
|
|
48
|
+
requireLocalFileToken(token);
|
|
49
|
+
encryptedRequest = await this.encryptControl(token, controlInput);
|
|
50
|
+
}
|
|
40
51
|
response = await this.sendRelayControl(token, encryptedRequest, signal);
|
|
41
52
|
if (await refreshableBindingFailure(response, token)) {
|
|
42
53
|
token = await this.options.refreshAuthorization(signal);
|
|
@@ -46,18 +57,30 @@ export class LocalFileTransport {
|
|
|
46
57
|
}
|
|
47
58
|
if (response.ok)
|
|
48
59
|
this.options.onRelayAvailable();
|
|
49
|
-
return this.decryptControlResponse(token, encryptedRequest, response);
|
|
60
|
+
return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried);
|
|
50
61
|
}
|
|
51
|
-
async decryptControlResponse(token, encryptedRequest, response) {
|
|
62
|
+
async decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried) {
|
|
52
63
|
const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid file control response.");
|
|
53
64
|
if (!response.ok) {
|
|
54
|
-
|
|
65
|
+
const error = apiError(body, "operation_failed", "Collection file request failed.", response.status);
|
|
66
|
+
if (error.code === "fresh_request_required"
|
|
67
|
+
&& method === "GET"
|
|
68
|
+
&& !freshReadRetried) {
|
|
69
|
+
return this.controlAttempt(token, method, path, input, signal, true);
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
55
72
|
}
|
|
56
73
|
const encryptedResponse = body?.envelope;
|
|
57
74
|
if (!encryptedResponse) {
|
|
58
75
|
throw connectError("invalid_encrypted_response", "The connector omitted its encrypted file response.");
|
|
59
76
|
}
|
|
60
77
|
const decrypted = await decryptRelayResponse(this.options.keyStore, token.keyHandle, relayBinding(token), encryptedRequest, encryptedResponse);
|
|
78
|
+
if (!decrypted.ok
|
|
79
|
+
&& decrypted.problem.code === "fresh_request_required"
|
|
80
|
+
&& method === "GET"
|
|
81
|
+
&& !freshReadRetried) {
|
|
82
|
+
return this.controlAttempt(token, method, path, input, signal, true);
|
|
83
|
+
}
|
|
61
84
|
if (!decrypted.ok)
|
|
62
85
|
throwEncryptedProblem(decrypted.problem);
|
|
63
86
|
return decrypted.result;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CollectionOperation, OperationTransportProtocolVersion } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
export declare function pendingRecoveryTransports(storage: Storage, storagePrefix: string, operations: readonly CollectionOperation[], collectionId?: string): OperationTransportProtocolVersion[];
|
|
3
|
+
export declare function pendingMutationsUseKey(storage: Storage, baseKey: string, keyHandle: string): boolean;
|
|
4
|
+
export declare function removePendingMutations(storage: Storage, baseKey: string, removeKey: (keyHandle: string) => void): void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isSupportedOperationTransport, OPERATION_TRANSPORT_PROTOCOL_VERSION } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { parseStored } from "./runtime-utils.js";
|
|
3
|
+
export function pendingRecoveryTransports(storage, storagePrefix, operations, collectionId) {
|
|
4
|
+
const prefix = `${storagePrefix}:pending-mutation:`;
|
|
5
|
+
const versions = new Set();
|
|
6
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
7
|
+
const key = storage.key(index);
|
|
8
|
+
if (!key?.startsWith(prefix))
|
|
9
|
+
continue;
|
|
10
|
+
const pending = parseStored(storage.getItem(key));
|
|
11
|
+
if (!pending
|
|
12
|
+
|| !operations.includes(pending.operation)
|
|
13
|
+
|| (collectionId && pending.collectionId !== collectionId))
|
|
14
|
+
continue;
|
|
15
|
+
const version = pending.envelope?.protocol_version ?? pending.request?.protocol_version;
|
|
16
|
+
if (version !== undefined
|
|
17
|
+
&& version !== OPERATION_TRANSPORT_PROTOCOL_VERSION
|
|
18
|
+
&& isSupportedOperationTransport(version)) {
|
|
19
|
+
versions.add(version);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [...versions].sort((left, right) => right - left);
|
|
23
|
+
}
|
|
24
|
+
export function pendingMutationsUseKey(storage, baseKey, keyHandle) {
|
|
25
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
26
|
+
const key = storage.key(index);
|
|
27
|
+
if (key !== baseKey && !key?.startsWith(`${baseKey}:`))
|
|
28
|
+
continue;
|
|
29
|
+
const pending = parseStored(storage.getItem(key));
|
|
30
|
+
if (pending?.keyHandle === keyHandle)
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
export function removePendingMutations(storage, baseKey, removeKey) {
|
|
36
|
+
const keys = [baseKey];
|
|
37
|
+
for (let index = 0; index < storage.length; index += 1) {
|
|
38
|
+
const key = storage.key(index);
|
|
39
|
+
if (key?.startsWith(`${baseKey}:`))
|
|
40
|
+
keys.push(key);
|
|
41
|
+
}
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const pending = parseStored(storage.getItem(key));
|
|
44
|
+
if (pending?.keyHandle)
|
|
45
|
+
removeKey(pending.keyHandle);
|
|
46
|
+
storage.removeItem(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mdbase-dev/connect",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.57",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"README.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@mdbase-dev/connect-protocol": "0.1.0-beta.
|
|
36
|
+
"@mdbase-dev/connect-protocol": "0.1.0-beta.57"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"esbuild": "^0.28.1",
|