@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.
@@ -1 +1 @@
1
- sha384-6M+exMhOm+JESB8xqgBNPFnykP6bx043yY1RbfIP4UidAlareN3dFvCVZDSd1/aj
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.pendingMutationsUseKey(collectionId, keyHandle))) {
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.removePendingMutations(collectionId);
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 { type ResolvedConnectTimeouts } from "./request-budget.js";
8
- export interface ConnectionTransportInternals {
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
- if (body?.protocol_version !== OPERATION_TRANSPORT_PROTOCOL_VERSION
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.sendAuthoritySyncRequest(token, method, path, input, budget.signal);
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.sendAuthoritySyncRequest(token, method, path, input, budget.signal);
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 = { binding, requestId, operation, counter };
165
- const key = await deriveDirectionalKey(record.agreementPrivateKey, binding, "request");
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
- OPERATION_TRANSPORT_PROTOCOL_VERSION,
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 [context(metadata.binding), metadata.requestId, direction, metadata.operation, metadata.counter].join("|");
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: OPERATION_TRANSPORT_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
- if (await this.options.shouldAttemptDirect(token)) {
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
- throw apiError(body, "operation_failed", "Collection file request failed.", response.status);
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.55",
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.55"
36
+ "@mdbase-dev/connect-protocol": "0.1.0-beta.57"
37
37
  },
38
38
  "devDependencies": {
39
39
  "esbuild": "^0.28.1",