@mdbase-dev/connect 0.1.0-beta.31 → 0.1.0-beta.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +75 -19
  2. package/dist/advanced.d.ts +7 -0
  3. package/dist/advanced.js +4 -0
  4. package/dist/application-session.d.ts +10 -5
  5. package/dist/application-session.js +32 -16
  6. package/dist/async.js +11 -5
  7. package/dist/authority-proof.d.ts +3 -0
  8. package/dist/authority-proof.js +24 -0
  9. package/dist/browser/integrity.json +1 -1
  10. package/dist/browser/mdbase-connect.min.js +3 -3
  11. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  12. package/dist/browser.d.ts +3 -0
  13. package/dist/browser.js +5 -0
  14. package/dist/capabilities.js +2 -1
  15. package/dist/collection-client.d.ts +28 -28
  16. package/dist/collection-client.js +42 -42
  17. package/dist/connect-options.d.ts +12 -4
  18. package/dist/connect.d.ts +12 -4
  19. package/dist/connect.js +105 -104
  20. package/dist/connection-file-transport.d.ts +2 -2
  21. package/dist/connection-file-transport.js +4 -4
  22. package/dist/connection-notifications.d.ts +10 -4
  23. package/dist/connection-notifications.js +41 -23
  24. package/dist/connection-transport.d.ts +19 -15
  25. package/dist/connection-transport.js +202 -202
  26. package/dist/connection-types.d.ts +5 -4
  27. package/dist/connection.d.ts +43 -43
  28. package/dist/connection.js +221 -77
  29. package/dist/crypto-entry.d.ts +4 -0
  30. package/dist/crypto-entry.js +4 -0
  31. package/dist/crypto.js +4 -4
  32. package/dist/external-store.d.ts +10 -0
  33. package/dist/external-store.js +10 -0
  34. package/dist/file-transfer-internals.d.ts +22 -0
  35. package/dist/file-transfer-internals.js +186 -0
  36. package/dist/files.d.ts +16 -12
  37. package/dist/files.js +52 -206
  38. package/dist/hosted-file-request.js +3 -3
  39. package/dist/index.d.ts +2 -5
  40. package/dist/index.js +1 -5
  41. package/dist/internal-types.d.ts +8 -1
  42. package/dist/local-file-transport.d.ts +2 -2
  43. package/dist/local-file-transport.js +13 -13
  44. package/dist/mdbase-connect.d.ts +6 -4
  45. package/dist/mdbase-connect.js +9 -8
  46. package/dist/notifications.d.ts +3 -2
  47. package/dist/operation-helpers.d.ts +3 -3
  48. package/dist/operation-helpers.js +12 -29
  49. package/dist/operation-types.d.ts +33 -8
  50. package/dist/outcomes.d.ts +15 -15
  51. package/dist/outcomes.js +13 -1
  52. package/dist/pending-mutation-store.d.ts +13 -0
  53. package/dist/pending-mutation-store.js +64 -0
  54. package/dist/request-budget.d.ts +27 -0
  55. package/dist/request-budget.js +95 -0
  56. package/dist/runtime-utils.d.ts +2 -0
  57. package/dist/runtime-utils.js +19 -0
  58. package/dist/session.d.ts +4 -3
  59. package/dist/session.js +4 -4
  60. package/dist/stored-token.d.ts +10 -0
  61. package/dist/stored-token.js +60 -0
  62. package/dist/token-response.d.ts +11 -0
  63. package/dist/token-response.js +77 -0
  64. package/package.json +12 -2
@@ -1,9 +1,13 @@
1
- import { isConnectProblem } from "@mdbase-dev/connect-protocol";
2
- import { decryptRelayResponse, encryptRelayRequest, RelayCryptoError, signAuthorityRequest } from "./crypto.js";
1
+ import { OPERATION_TRANSPORT_PROTOCOL_VERSION, isConnectProblem, mutationOperationIdentifier } from "@mdbase-dev/connect-protocol";
2
+ import { decryptRelayResponse, encryptRelayRequest, RelayCryptoError } from "./crypto.js";
3
3
  import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
4
4
  import { ConnectionFileTransport } from "./connection-file-transport.js";
5
+ import { authorityProofHeaders } from "./authority-proof.js";
6
+ import { PendingMutationStore } from "./pending-mutation-store.js";
5
7
  import { directFallbackStatus, isMutation, localNetworkPermission, loopbackRequest, operationFingerprint, operationTransportError, sameAuthorization, throwIfCancelled, unknownMutationOutcome } from "./operation-helpers.js";
6
- import { apiError, oauthErrorCode, parseGrantScope, parseStored, validStoredAuthority, validStoredEncryption, validFileCapability } from "./runtime-utils.js";
8
+ import { apiError, decodeJsonResponse, oauthErrorCode, parseStored, } from "./runtime-utils.js";
9
+ import { readStoredToken } from "./stored-token.js";
10
+ import { withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
7
11
  export class ConnectionTransport {
8
12
  serverUrl;
9
13
  storage;
@@ -13,6 +17,8 @@ export class ConnectionTransport {
13
17
  collectionId;
14
18
  internals;
15
19
  onChange;
20
+ timeouts;
21
+ pendingMutationStore;
16
22
  files;
17
23
  refreshPromise = null;
18
24
  directStatus;
@@ -28,12 +34,14 @@ export class ConnectionTransport {
28
34
  this.collectionId = options.collectionId;
29
35
  this.internals = options.internals;
30
36
  this.onChange = options.onChange;
37
+ this.timeouts = options.timeouts;
38
+ this.pendingMutationStore = new PendingMutationStore(this.storage, this.internals.pendingMutationKey(this.collectionId));
31
39
  this.files = new ConnectionFileTransport({
32
40
  keyStore: this.keyStore,
33
41
  serverUrl: this.serverUrl,
34
42
  loopbackUrl: this.loopbackUrl,
35
- authorizedToken: () => this.authorizedToken(),
36
- refreshAuthorization: () => this.refreshAuthorization(),
43
+ authorizedToken: (signal) => this.authorizedToken({ signal, timeoutMs: null }),
44
+ refreshAuthorization: (signal) => this.refreshAuthorization(signal),
37
45
  shouldAttemptDirect: (token) => this.shouldAttemptDirect(token),
38
46
  onDirectAvailable: () => {
39
47
  this.markDirectAvailable();
@@ -41,7 +49,7 @@ export class ConnectionTransport {
41
49
  },
42
50
  onDirectUnavailable: () => this.markDirectUnavailable(),
43
51
  onRelayAvailable: () => this.setRoute("relay"),
44
- authorityProofHeaders: (token, method, url, body, credential) => this.authorityProofHeaders(token, method, url, body, credential)
52
+ authorityProofHeaders: (token, method, url, body, credential) => authorityProofHeaders(this.keyStore, token, method, url, body, credential)
45
53
  });
46
54
  this.directStatus = this.directAccessMode === "disabled"
47
55
  ? "disabled"
@@ -56,7 +64,10 @@ export class ConnectionTransport {
56
64
  notifyStorageChanged() {
57
65
  this.emitConnection();
58
66
  }
59
- async checkDirectAccess() {
67
+ async checkDirectAccess(options = {}) {
68
+ return withRequestBudget(options, this.timeouts.watchStartMs, (budget) => this.checkDirectAccessWithinBudget(budget.signal));
69
+ }
70
+ async checkDirectAccessWithinBudget(signal) {
60
71
  const token = this.currentToken();
61
72
  if (!this.directEligible(token))
62
73
  return this.setDirectStatus("disabled");
@@ -64,22 +75,25 @@ export class ConnectionTransport {
64
75
  if (permission === "denied")
65
76
  return this.setDirectStatus("denied");
66
77
  if ((permission === "prompt" || permission === null)
67
- && this.storage.getItem(this.directPreferenceKey()) !== "enabled") {
78
+ && this.storage.getItem(this.internals.directPreferenceKey()) !== "enabled") {
68
79
  return this.setDirectStatus("permission_required");
69
80
  }
70
- return this.probeDirectAccess();
81
+ return this.probeDirectAccess(signal);
71
82
  }
72
83
  /** Call from a user gesture to request browser permission for direct local access. */
73
- async requestDirectAccess() {
84
+ async requestDirectAccess(options = {}) {
85
+ return withRequestBudget(options, this.timeouts.watchStartMs, (budget) => this.requestDirectAccessWithinBudget(budget.signal));
86
+ }
87
+ async requestDirectAccessWithinBudget(signal) {
74
88
  const token = this.currentToken();
75
89
  if (!this.directCapable(token))
76
90
  return this.setDirectStatus("disabled");
77
- this.storage.setItem(this.directPreferenceKey(), "enabled");
91
+ this.storage.setItem(this.internals.directPreferenceKey(), "enabled");
78
92
  this.directRetryAt = 0;
79
- return this.probeDirectAccess();
93
+ return this.probeDirectAccess(signal);
80
94
  }
81
95
  disableDirectAccess() {
82
- this.storage.setItem(this.directPreferenceKey(), "disabled");
96
+ this.storage.setItem(this.internals.directPreferenceKey(), "disabled");
83
97
  this.setDirectStatus("disabled");
84
98
  this.setRoute("relay");
85
99
  }
@@ -88,24 +102,39 @@ export class ConnectionTransport {
88
102
  * authority is remote, directly connected, or relay connected.
89
103
  * Authority credentials and routing stay private inside the SDK.
90
104
  */
91
- pendingMutation() {
92
- const pending = parseStored(this.storage.getItem(this.pendingMutationKey()));
93
- const token = this.currentToken();
94
- if (!pending || !token
95
- || pending.collectionId !== token.collectionId
96
- || pending.grantId !== token.grantId
97
- || pending.keyId !== token.encryption?.key_id)
98
- return null;
99
- return { operation: pending.operation, createdAt: pending.createdAt, resumable: true };
100
- }
101
- async resumePendingMutation(input, options) {
102
- const pending = this.pendingMutation();
105
+ pendingMutations() {
106
+ return this.storedPendingMutations().map((pending) => ({
107
+ requestId: pending.requestId,
108
+ operation: this.pendingMutationStore.identifier(pending),
109
+ fingerprint: pending.inputFingerprint,
110
+ status: "outcome_unknown",
111
+ createdAt: new Date(pending.createdAt).toISOString()
112
+ }));
113
+ }
114
+ pendingMutation(requestId) {
115
+ const pending = this.storedPendingMutation(requestId);
116
+ return pending ? {
117
+ requestId: pending.requestId,
118
+ operation: this.pendingMutationStore.identifier(pending),
119
+ fingerprint: pending.inputFingerprint,
120
+ status: "outcome_unknown",
121
+ createdAt: new Date(pending.createdAt).toISOString()
122
+ } : null;
123
+ }
124
+ async recoverPendingMutation(requestId, options) {
125
+ const pending = this.storedPendingMutation(requestId);
103
126
  if (!pending) {
104
127
  throw connectError("no_pending_mutation", "There is no interrupted mutation to resume.");
105
128
  }
106
- return this.performOperation(pending.operation, input, options);
129
+ return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.performOperationWithinBudget(pending.operation, pending.request?.input ?? {}, { ...options, signal: budget.signal }, pending));
107
130
  }
108
131
  async performOperation(operation, input, options = {}) {
132
+ return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.performOperationWithinBudget(operation, input, {
133
+ ...options,
134
+ signal: budget.signal
135
+ }));
136
+ }
137
+ async performOperationWithinBudget(operation, input, options, storedPending) {
109
138
  throwIfCancelled(options.signal);
110
139
  let token = this.currentToken();
111
140
  if (!token)
@@ -121,45 +150,51 @@ export class ConnectionTransport {
121
150
  }
122
151
  let tryDirect = await this.shouldAttemptDirect(token);
123
152
  if (!tryDirect) {
124
- token = await this.authorizedToken();
153
+ token = await this.authorizedToken({ signal: options.signal, timeoutMs: null });
125
154
  if (!token)
126
155
  throw connectError("not_authorized", "Reconnect this application to continue.");
127
156
  }
157
+ const pendingRequestId = storedPending?.requestId
158
+ ?? (isMutation(operation, input) ? crypto.randomUUID() : undefined);
128
159
  let attempt;
129
160
  try {
130
- attempt = await this.sendOperation(token, operation, input, tryDirect, options);
161
+ attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId);
131
162
  }
132
163
  catch (error) {
133
- throw operationTransportError(error, options.signal, isMutation(operation, input) && this.pendingMutation() !== null, token.authority ? "hosted_provider_unavailable" : "relay_unavailable");
164
+ throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
165
+ ? pendingRequestId
166
+ : undefined, token.authority ? "hosted_provider_unavailable" : "relay_unavailable");
134
167
  }
135
168
  let response = attempt.response;
136
169
  const staleBinding = response.status === 409
137
- && (await response.clone().json().catch(() => null))?.error?.code === "encryption_binding_stale";
170
+ && (await decodeJsonResponse(response.clone(), "invalid_operation_response", "The collection authority returned an invalid operation response.").catch(() => null))?.error?.code === "encryption_binding_stale";
138
171
  if ((response.status === 401 || staleBinding) && token.refreshToken) {
139
172
  if (attempt.pendingMutation
140
173
  && (attempt.directDeliveryUncertain
141
174
  || (attempt.encryptedRequest && attempt.resumingMutation))) {
142
- throw connectError("operation_outcome_unknown", "The direct operation may have completed, but its encrypted grant changed before the response could be recovered. Refresh before making another change.", { operationOutcome: "unknown" });
175
+ throw connectError("operation_outcome_unknown", "The direct operation may have completed, but its encrypted grant changed before the response could be recovered. Refresh before making another change.", { operationOutcome: "unknown", details: { request_id: attempt.requestId } });
143
176
  }
144
177
  if (attempt.pendingMutation)
145
- this.clearPendingMutation();
146
- token = await this.refreshAuthorization();
178
+ this.clearPendingMutation(attempt.requestId);
179
+ token = await this.refreshAuthorization(options.signal);
147
180
  tryDirect = await this.shouldAttemptDirect(token);
148
181
  try {
149
- attempt = await this.sendOperation(token, operation, input, tryDirect, options);
182
+ attempt = await this.sendOperation(token, operation, input, tryDirect, options, pendingRequestId);
150
183
  }
151
184
  catch (error) {
152
- throw operationTransportError(error, options.signal, isMutation(operation, input) && this.pendingMutation() !== null, token.authority ? "hosted_provider_unavailable" : "relay_unavailable");
185
+ throw operationTransportError(error, options.signal, pendingRequestId !== undefined && this.storedPendingMutation(pendingRequestId) !== null
186
+ ? pendingRequestId
187
+ : undefined, token.authority ? "hosted_provider_unavailable" : "relay_unavailable");
153
188
  }
154
189
  response = attempt.response;
155
190
  }
156
191
  let body;
157
192
  try {
158
- body = await response.json();
193
+ body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned a response that is not valid JSON.");
159
194
  }
160
195
  catch (cause) {
161
196
  if (attempt.pendingMutation)
162
- throw unknownMutationOutcome(cause);
197
+ throw unknownMutationOutcome(attempt.requestId, cause);
163
198
  throw connectError("invalid_operation_response", "The collection authority returned a response that is not valid JSON.", { cause });
164
199
  }
165
200
  if (!response.ok) {
@@ -167,10 +202,10 @@ export class ConnectionTransport {
167
202
  if (attempt.pendingMutation
168
203
  && (attempt.directDeliveryUncertain
169
204
  || (attempt.encryptedRequest && attempt.resumingMutation))) {
170
- throw unknownMutationOutcome(error);
205
+ throw unknownMutationOutcome(attempt.requestId, error);
171
206
  }
172
207
  if (attempt.pendingMutation && !attempt.directDeliveryUncertain) {
173
- this.clearPendingMutation();
208
+ this.clearPendingMutation(attempt.requestId);
174
209
  }
175
210
  if (error.code === "direct_operation_rejected" && error.status === 403) {
176
211
  this.invalidateRejectedAuthorization(token);
@@ -179,15 +214,24 @@ export class ConnectionTransport {
179
214
  }
180
215
  if (attempt.encryptedRequest) {
181
216
  const encryptedResponse = body?.envelope;
182
- if (!encryptedResponse || !token.encryption || !token.grantId || !token.keyHandle) {
217
+ const pendingCrypto = attempt.pendingMutationRecord;
218
+ const responseEncryption = pendingCrypto?.encryption ?? token.encryption;
219
+ const responseGrantId = pendingCrypto?.grantId ?? token.grantId;
220
+ const responseApplicationId = pendingCrypto?.applicationId ?? token.clientId;
221
+ const responseKeyHandle = pendingCrypto?.keyHandle ?? token.keyHandle;
222
+ if (!encryptedResponse || !responseEncryption || !responseGrantId || !responseKeyHandle) {
183
223
  if (attempt.pendingMutation)
184
- throw unknownMutationOutcome(new Error("Encrypted operation response was missing its envelope."));
224
+ throw unknownMutationOutcome(attempt.requestId, new Error("Encrypted operation response was missing its envelope."));
185
225
  throw connectError("invalid_encrypted_response", "The relay did not return an encrypted connector response.");
186
226
  }
187
227
  try {
188
- const decrypted = await decryptRelayResponse(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, attempt.encryptedRequest, encryptedResponse);
228
+ const decrypted = await decryptRelayResponse(this.keyStore, responseKeyHandle, {
229
+ grantId: responseGrantId,
230
+ applicationId: responseApplicationId,
231
+ encryption: responseEncryption
232
+ }, attempt.encryptedRequest, encryptedResponse);
189
233
  if (attempt.pendingMutation)
190
- this.clearPendingMutation();
234
+ this.clearPendingMutation(attempt.requestId);
191
235
  if (!decrypted.ok)
192
236
  throw serverConnectError(decrypted.problem.code === "unknown"
193
237
  ? decrypted.problem.server_code
@@ -203,65 +247,79 @@ export class ConnectionTransport {
203
247
  throw error;
204
248
  if (error instanceof RelayCryptoError) {
205
249
  if (attempt.pendingMutation)
206
- throw unknownMutationOutcome(error);
250
+ throw unknownMutationOutcome(attempt.requestId, error);
207
251
  throw serverConnectError(error.code, error.message);
208
252
  }
209
253
  throw error;
210
254
  }
211
255
  }
212
- if (body?.protocol_version !== 1 || body?.request_id !== attempt.requestId) {
256
+ if (body?.protocol_version !== OPERATION_TRANSPORT_PROTOCOL_VERSION
257
+ || body?.request_id !== attempt.requestId) {
213
258
  if (attempt.pendingMutation)
214
- throw unknownMutationOutcome(new Error("Operation response protocol or request ID did not match."));
259
+ throw unknownMutationOutcome(attempt.requestId, new Error("Operation response protocol or request ID did not match."));
215
260
  throw connectError("invalid_operation_response", "The collection authority returned a response for a different protocol request.");
216
261
  }
217
262
  if (body.ok === false) {
218
263
  if (!isConnectProblem(body.problem)) {
219
264
  if (attempt.pendingMutation)
220
- throw unknownMutationOutcome(new Error("Operation rejection did not contain a canonical problem."));
265
+ throw unknownMutationOutcome(attempt.requestId, new Error("Operation rejection did not contain a canonical problem."));
221
266
  throw connectError("invalid_operation_response", "The collection authority returned an invalid operation problem.");
222
267
  }
223
268
  if (attempt.pendingMutation)
224
- this.clearPendingMutation();
269
+ this.clearPendingMutation(attempt.requestId);
225
270
  throw new MdbaseConnectError(body.problem);
226
271
  }
227
272
  if (body.ok !== true || !("result" in body)) {
228
273
  if (attempt.pendingMutation)
229
- throw unknownMutationOutcome(new Error("Successful operation response did not contain a result."));
274
+ throw unknownMutationOutcome(attempt.requestId, new Error("Successful operation response did not contain a result."));
230
275
  throw connectError("invalid_operation_response", "The collection authority returned an incomplete operation response.");
231
276
  }
232
277
  if (attempt.pendingMutation)
233
- this.clearPendingMutation();
278
+ this.clearPendingMutation(attempt.requestId);
234
279
  return body.result;
235
280
  }
236
- async performAuthoritySyncRequest(collectionId, replicaId, method, path, input) {
237
- let token = await this.authorizedToken();
238
- if (!token?.authority
239
- || token.collectionId !== collectionId
240
- || token.authority.replicaId !== replicaId) {
241
- throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
242
- }
243
- let response = await this.sendAuthoritySyncRequest(token, method, path, input);
244
- if (response.status === 401 && token.refreshToken) {
245
- token = await this.refreshAuthorization();
246
- if (!token.authority
281
+ async performAuthoritySyncRequest(collectionId, replicaId, method, path, input, options = {}) {
282
+ return withCooperativeRequestBudget(options, this.timeouts.syncMs, async (budget) => {
283
+ let token = await this.authorizedToken({ signal: budget.signal, timeoutMs: null });
284
+ if (!token?.authority
247
285
  || token.collectionId !== collectionId
248
286
  || token.authority.replicaId !== replicaId) {
249
287
  throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
250
288
  }
251
- response = await this.sendAuthoritySyncRequest(token, method, path, input);
252
- }
253
- const body = await response.json();
254
- if (!response.ok)
255
- throw apiError(body, "sync_failed", "Collection synchronization failed.", response.status);
256
- return body;
289
+ try {
290
+ let response = await this.sendAuthoritySyncRequest(token, method, path, input, budget.signal);
291
+ if (response.status === 401 && token.refreshToken) {
292
+ token = await this.refreshAuthorization(budget.signal);
293
+ if (!token.authority
294
+ || token.collectionId !== collectionId
295
+ || token.authority.replicaId !== replicaId) {
296
+ throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
297
+ }
298
+ response = await this.sendAuthoritySyncRequest(token, method, path, input, budget.signal);
299
+ }
300
+ const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid synchronization response.");
301
+ if (!response.ok) {
302
+ throw apiError(body, "sync_failed", "Collection synchronization failed.", response.status);
303
+ }
304
+ return body;
305
+ }
306
+ catch (error) {
307
+ const mutationRequestId = method === "POST" && path === "mutations"
308
+ && input && typeof input === "object"
309
+ && typeof input.mutation_id === "string"
310
+ ? input.mutation_id
311
+ : undefined;
312
+ throw operationTransportError(error, budget.signal, mutationRequestId, "hosted_provider_unavailable");
313
+ }
314
+ });
257
315
  }
258
- async sendAuthoritySyncRequest(token, method, path, input) {
316
+ async sendAuthoritySyncRequest(token, method, path, input, signal) {
259
317
  if (!token.authority) {
260
318
  throw connectError("not_remote_authority", "This authorization has no remote authority endpoint.");
261
319
  }
262
320
  const url = `${token.authority.syncUrl}/${path}`;
263
321
  const body = input === undefined ? undefined : JSON.stringify(input);
264
- const proof = await this.authorityProofHeaders(token, method, url, body, token.authority.accessToken);
322
+ const proof = await authorityProofHeaders(this.keyStore, token, method, url, body, token.authority.accessToken);
265
323
  return fetch(url, {
266
324
  method,
267
325
  headers: {
@@ -269,24 +327,24 @@ export class ConnectionTransport {
269
327
  ...(input === undefined ? {} : { "content-type": "application/json" }),
270
328
  ...proof
271
329
  },
272
- ...(body === undefined ? {} : { body })
330
+ ...(body === undefined ? {} : { body }),
331
+ signal
273
332
  });
274
333
  }
275
- async sendOperation(token, operation, input, tryDirect, options = {}) {
334
+ async sendOperation(token, operation, input, tryDirect, options = {}, pendingRequestId) {
276
335
  let body = input ?? {};
277
336
  let encryptedRequest;
278
337
  let pendingMutation = false;
279
338
  let resumingMutation = false;
280
- let requestId = crypto.randomUUID();
339
+ let requestId = pendingRequestId ?? crypto.randomUUID();
281
340
  let pending = null;
282
341
  let inputFingerprint;
283
- if (isMutation(operation, input)) {
284
- inputFingerprint = await operationFingerprint(operation, input);
285
- pending = parseStored(this.storage.getItem(this.pendingMutationKey()));
342
+ if (pendingRequestId !== undefined) {
343
+ pending = this.storedPendingMutation(pendingRequestId);
344
+ inputFingerprint = pending?.inputFingerprint
345
+ ?? await operationFingerprint(operation, input);
286
346
  if (pending) {
287
347
  if (pending.collectionId !== token.collectionId
288
- || pending.grantId !== token.grantId
289
- || pending.keyId !== token.encryption?.key_id
290
348
  || pending.operation !== operation
291
349
  || pending.inputFingerprint !== inputFingerprint) {
292
350
  throw connectError("pending_mutation_unresolved", "A previous write still has an unknown outcome. Retry that exact write before making another change.");
@@ -296,6 +354,13 @@ export class ConnectionTransport {
296
354
  }
297
355
  pendingMutation = true;
298
356
  }
357
+ const mutation = pending?.mutation ?? mutationOperationIdentifier(operation, input);
358
+ if (pending?.envelope && (token.authority || !token.encryption)) {
359
+ throw connectError("pending_mutation_unresolved", "The pending write requires its encrypted relay authority. Reconnect that authority before recovery.");
360
+ }
361
+ if (pending?.request && token.encryption && !token.authority) {
362
+ throw connectError("pending_mutation_unresolved", "The pending write belongs to a different authority transport. Reconnect that authority before recovery.");
363
+ }
299
364
  if (token.encryption && !token.authority) {
300
365
  if (!token.grantId || !token.keyHandle) {
301
366
  throw connectError("missing_grant_key", "Reconnect this application to restore encrypted access.");
@@ -310,16 +375,20 @@ export class ConnectionTransport {
310
375
  }
311
376
  else {
312
377
  encryptedRequest = await encryptRelayRequest(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, operation, input, requestId);
313
- this.storage.setItem(this.pendingMutationKey(), JSON.stringify({
378
+ this.pendingMutationStore.store({
314
379
  collectionId: token.collectionId,
315
380
  ...(token.grantId ? { grantId: token.grantId } : {}),
316
381
  keyId: token.encryption.key_id,
382
+ keyHandle: token.keyHandle,
383
+ applicationId: token.clientId,
384
+ encryption: token.encryption,
317
385
  operation,
386
+ mutation: mutation,
318
387
  inputFingerprint: inputFingerprint,
319
388
  requestId,
320
389
  envelope: encryptedRequest,
321
390
  createdAt: Date.now()
322
- }));
391
+ });
323
392
  }
324
393
  }
325
394
  else {
@@ -334,21 +403,23 @@ export class ConnectionTransport {
334
403
  body = encryptedRequest;
335
404
  }
336
405
  else {
337
- const request = {
338
- protocol_version: 1,
406
+ const request = pending?.request ?? {
407
+ protocol_version: OPERATION_TRANSPORT_PROTOCOL_VERSION,
339
408
  request_id: requestId,
340
409
  input: body
341
410
  };
342
411
  body = request;
343
412
  if (pendingMutation && !pending) {
344
- this.storage.setItem(this.pendingMutationKey(), JSON.stringify({
413
+ this.pendingMutationStore.store({
345
414
  collectionId: token.collectionId,
346
415
  ...(token.grantId ? { grantId: token.grantId } : {}),
347
416
  operation,
417
+ mutation: mutation,
348
418
  inputFingerprint: inputFingerprint,
349
419
  requestId,
420
+ request,
350
421
  createdAt: Date.now()
351
- }));
422
+ });
352
423
  }
353
424
  }
354
425
  if (tryDirect && encryptedRequest && !token.authority) {
@@ -370,6 +441,7 @@ export class ConnectionTransport {
370
441
  requestId,
371
442
  encryptedRequest,
372
443
  pendingMutation,
444
+ ...(pendingMutation ? { pendingMutationRecord: pending ?? this.storedPendingMutation(requestId) ?? undefined } : {}),
373
445
  resumingMutation
374
446
  };
375
447
  }
@@ -389,11 +461,11 @@ export class ConnectionTransport {
389
461
  try {
390
462
  relayToken = token.expiresAt > Date.now() + 30_000
391
463
  ? token
392
- : await this.refreshAuthorization();
464
+ : await this.refreshAuthorization(options.signal);
393
465
  }
394
466
  catch (error) {
395
467
  if (pendingMutation && (directDeliveryUncertain || resumingMutation))
396
- throw unknownMutationOutcome(error);
468
+ throw unknownMutationOutcome(requestId, error);
397
469
  throw error;
398
470
  }
399
471
  let response;
@@ -410,7 +482,7 @@ export class ConnectionTransport {
410
482
  }
411
483
  catch (error) {
412
484
  if (pendingMutation && (directDeliveryUncertain || resumingMutation))
413
- throw unknownMutationOutcome(error);
485
+ throw unknownMutationOutcome(requestId, error);
414
486
  throw error;
415
487
  }
416
488
  if (response.ok)
@@ -421,6 +493,7 @@ export class ConnectionTransport {
421
493
  encryptedRequest,
422
494
  directDeliveryUncertain,
423
495
  pendingMutation,
496
+ ...(pendingMutation ? { pendingMutationRecord: pending ?? this.storedPendingMutation(requestId) ?? undefined } : {}),
424
497
  resumingMutation
425
498
  };
426
499
  }
@@ -429,7 +502,7 @@ export class ConnectionTransport {
429
502
  : `${this.serverUrl}/v1/authorities/${encodeURIComponent(token.collectionId)}/operations/${operation}`;
430
503
  const operationBody = JSON.stringify(body);
431
504
  const proof = token.authority
432
- ? await this.authorityProofHeaders(token, "POST", operationUrl, operationBody, token.authority.accessToken)
505
+ ? await authorityProofHeaders(this.keyStore, token, "POST", operationUrl, operationBody, token.authority.accessToken)
433
506
  : {};
434
507
  const response = await fetch(operationUrl, {
435
508
  method: "POST",
@@ -443,7 +516,14 @@ export class ConnectionTransport {
443
516
  });
444
517
  if (response.ok)
445
518
  this.setRoute(token.authority ? "remote" : "relay");
446
- return { response, requestId, encryptedRequest, pendingMutation, resumingMutation };
519
+ return {
520
+ response,
521
+ requestId,
522
+ encryptedRequest,
523
+ pendingMutation,
524
+ ...(pendingMutation ? { pendingMutationRecord: pending ?? this.storedPendingMutation(requestId) ?? undefined } : {}),
525
+ resumingMutation
526
+ };
447
527
  }
448
528
  directCapable(token) {
449
529
  if (!token || token.authority || !token.encryption || !token.grantId || !token.keyHandle)
@@ -463,7 +543,7 @@ export class ConnectionTransport {
463
543
  directEligible(token) {
464
544
  return token !== null
465
545
  && this.directCapable(token)
466
- && this.storage.getItem(this.directPreferenceKey()) !== "disabled";
546
+ && this.storage.getItem(this.internals.directPreferenceKey()) !== "disabled";
467
547
  }
468
548
  async shouldAttemptDirect(token) {
469
549
  if (!this.directEligible(token))
@@ -478,25 +558,26 @@ export class ConnectionTransport {
478
558
  return false;
479
559
  }
480
560
  if ((permission === "prompt" || permission === null)
481
- && this.storage.getItem(this.directPreferenceKey()) !== "enabled") {
561
+ && this.storage.getItem(this.internals.directPreferenceKey()) !== "enabled") {
482
562
  this.setDirectStatus("permission_required");
483
563
  return false;
484
564
  }
485
565
  this.setDirectStatus("checking");
486
566
  return true;
487
567
  }
488
- async probeDirectAccess() {
568
+ async probeDirectAccess(signal) {
489
569
  this.setDirectStatus("checking");
490
570
  try {
491
571
  const response = await fetch(`${this.loopbackUrl}/v1/ready`, loopbackRequest({
492
572
  method: "GET",
493
- cache: "no-store"
573
+ cache: "no-store",
574
+ signal
494
575
  }));
495
- const body = await response.json().catch(() => null);
576
+ const body = await decodeJsonResponse(response, "invalid_operation_response", "The connector returned an invalid readiness response.").catch(() => null);
496
577
  if (response.ok
497
578
  && body?.service === "mdbase-connect"
498
579
  && body?.loopback_protocol_version === 1
499
- && body?.encrypted_protocol_version === 1) {
580
+ && body?.operation_transport_protocol_version === OPERATION_TRANSPORT_PROTOCOL_VERSION) {
500
581
  this.markDirectAvailable();
501
582
  return "available";
502
583
  }
@@ -536,7 +617,7 @@ export class ConnectionTransport {
536
617
  this.onChange();
537
618
  }
538
619
  invalidateRejectedAuthorization(rejected) {
539
- const current = parseStored(this.storage.getItem(this.tokenKey()));
620
+ const current = parseStored(this.storage.getItem(this.internals.tokenKey(this.collectionId)));
540
621
  if (!current || !sameAuthorization(current, rejected))
541
622
  return;
542
623
  this.internals.removeToken(this.collectionId, current.keyHandle);
@@ -545,73 +626,15 @@ export class ConnectionTransport {
545
626
  this.emitConnection();
546
627
  }
547
628
  currentToken() {
548
- const stored = this.storage.getItem(this.tokenKey());
549
- const token = parseStored(stored);
550
- const invalidate = (keyHandle) => {
551
- this.internals.removeToken(this.collectionId, typeof keyHandle === "string" ? keyHandle : undefined, "invalid_stored_grant");
552
- return null;
553
- };
554
- if (!token) {
555
- if (stored)
556
- invalidate();
557
- return null;
558
- }
559
- if (token.version !== 1
560
- || typeof token.accessToken !== "string"
561
- || token.accessToken.length === 0
562
- || typeof token.clientId !== "string"
563
- || token.clientId.length === 0
564
- || token.collectionId !== this.collectionId
565
- || typeof token.collectionName !== "string"
566
- || token.collectionName.length === 0
567
- || !Array.isArray(token.operations)
568
- || token.operations.some((operation) => typeof operation !== "string")
569
- || typeof token.expiresAt !== "number"
570
- || !Number.isFinite(token.expiresAt)
571
- || (token.refreshToken !== undefined
572
- && (typeof token.refreshToken !== "string"
573
- || token.refreshToken.length === 0))
574
- || (token.refreshExpiresAt !== undefined
575
- && (typeof token.refreshExpiresAt !== "number"
576
- || !Number.isFinite(token.refreshExpiresAt))))
577
- return invalidate(token.keyHandle);
578
- if (!parseGrantScope(token.scope)) {
579
- return invalidate(token.keyHandle);
580
- }
581
- if (token.fileCapability && !validFileCapability(token.fileCapability)) {
582
- return invalidate(token.keyHandle);
583
- }
584
- if (token.authority
585
- && !validStoredAuthority(token.authority, token.collectionId)) {
586
- return invalidate(token.keyHandle);
587
- }
588
- if (this.internals.relayEncryption === "required") {
589
- if (token.authority) {
590
- if (!token.keyHandle || !token.authority.proofPublicKey) {
591
- return invalidate(token.keyHandle);
592
- }
593
- }
594
- else {
595
- if (!token.grantId
596
- || !token.keyHandle
597
- || !validStoredEncryption(token.encryption, token.collectionId)) {
598
- return invalidate(token.keyHandle);
599
- }
600
- }
601
- }
602
- if (token.expiresAt <= Date.now()
603
- && (!token.refreshToken || (token.refreshExpiresAt ?? 0) <= Date.now())) {
604
- // The cloud bearer and the local grant proof have separate lifetimes. Keep an
605
- // encrypted local grant usable while the connector still recognizes it; relay
606
- // use will require reauthorization, and revocation remains enforced locally.
607
- if (this.directCapable(token))
608
- return token;
609
- this.internals.removeToken(this.collectionId, token.keyHandle);
610
- return null;
611
- }
612
- return token;
629
+ return readStoredToken({
630
+ stored: this.storage.getItem(this.internals.tokenKey(this.collectionId)),
631
+ collectionId: this.collectionId,
632
+ relayEncryption: this.internals.relayEncryption,
633
+ invalidate: (keyHandle) => this.internals.removeToken(this.collectionId, keyHandle, "invalid_stored_grant"),
634
+ directCapable: (token) => this.directCapable(token)
635
+ });
613
636
  }
614
- async authorizedToken() {
637
+ async authorizedToken(options = {}) {
615
638
  const token = this.currentToken();
616
639
  if (!token)
617
640
  return null;
@@ -624,17 +647,17 @@ export class ConnectionTransport {
624
647
  this.internals.removeToken(this.collectionId, token.keyHandle);
625
648
  return null;
626
649
  }
627
- return this.refreshAuthorization();
650
+ return this.refreshAuthorization(options.signal);
628
651
  }
629
- refreshAuthorization() {
652
+ refreshAuthorization(signal) {
630
653
  if (this.refreshPromise)
631
654
  return this.refreshPromise;
632
- this.refreshPromise = this.performRefresh().finally(() => {
655
+ this.refreshPromise = this.performRefresh(signal).finally(() => {
633
656
  this.refreshPromise = null;
634
657
  });
635
658
  return this.refreshPromise;
636
659
  }
637
- async performRefresh() {
660
+ async performRefresh(signal) {
638
661
  const current = this.currentToken();
639
662
  if (!current?.refreshToken) {
640
663
  throw connectError("not_authorized", "Reconnect this application to continue.");
@@ -650,7 +673,7 @@ export class ConnectionTransport {
650
673
  client_id: current.clientId
651
674
  }).toString();
652
675
  const proof = current.authority
653
- ? await this.authorityProofHeaders(current, "POST", refreshUrl, refreshBody, current.refreshToken)
676
+ ? await authorityProofHeaders(this.keyStore, current, "POST", refreshUrl, refreshBody, current.refreshToken)
654
677
  : {};
655
678
  const response = await fetch(refreshUrl, {
656
679
  method: "POST",
@@ -658,9 +681,10 @@ export class ConnectionTransport {
658
681
  "content-type": "application/x-www-form-urlencoded",
659
682
  ...proof
660
683
  },
661
- body: refreshBody
684
+ body: refreshBody,
685
+ signal
662
686
  });
663
- const body = await response.json();
687
+ const body = await decodeJsonResponse(response, "invalid_token_response", "The authorization service returned an invalid token response.");
664
688
  if (!response.ok) {
665
689
  const latest = this.currentToken();
666
690
  if (latest?.refreshToken && latest.refreshToken !== attemptedRefreshToken) {
@@ -676,28 +700,6 @@ export class ConnectionTransport {
676
700
  }
677
701
  return this.storeTokenResponse(body, current.clientId, current.keyHandle);
678
702
  }
679
- async authorityProofHeaders(token, method, url, body, credential) {
680
- if (!token.authority?.proofPublicKey)
681
- return {};
682
- if (!token.keyHandle) {
683
- throw connectError("missing_grant_key", "Reconnect this application to restore remote authority request signing.");
684
- }
685
- try {
686
- const target = new URL(url);
687
- return await signAuthorityRequest(this.keyStore, token.keyHandle, token.authority.proofPublicKey, {
688
- method,
689
- target: `${target.pathname}${target.search}`,
690
- body,
691
- credential
692
- });
693
- }
694
- catch (error) {
695
- if (error instanceof RelayCryptoError) {
696
- throw serverConnectError(error.code, error.message);
697
- }
698
- throw error;
699
- }
700
- }
701
703
  storeTokenResponse(body, clientId, keyHandle) {
702
704
  if (body.collection_id !== this.collectionId) {
703
705
  throw connectError("collection_mismatch", "The refreshed authorization belongs to a different collection.");
@@ -710,19 +712,17 @@ export class ConnectionTransport {
710
712
  this.emitConnection();
711
713
  return token;
712
714
  }
713
- tokenKey() {
714
- return this.internals.tokenKey(this.collectionId);
715
- }
716
- notificationKey(transport = "web_push") {
717
- return this.internals.notificationKey(this.collectionId, transport);
715
+ storedPendingMutations() {
716
+ return this.pendingMutationStore.list(this.currentToken()?.collectionId ?? null);
718
717
  }
719
- pendingMutationKey() {
720
- return this.internals.pendingMutationKey(this.collectionId);
718
+ storedPendingMutation(requestId) {
719
+ return this.pendingMutationStore.find(this.currentToken()?.collectionId ?? null, requestId);
721
720
  }
722
- clearPendingMutation() {
723
- this.storage.removeItem(this.pendingMutationKey());
724
- }
725
- directPreferenceKey() {
726
- return this.internals.directPreferenceKey();
721
+ clearPendingMutation(requestId) {
722
+ const pending = this.pendingMutationStore.take(requestId);
723
+ const currentKeyHandle = this.currentToken()?.keyHandle;
724
+ if (pending?.keyHandle && pending.keyHandle !== currentKeyHandle) {
725
+ void this.keyStore.delete(pending.keyHandle).catch(() => undefined);
726
+ }
727
727
  }
728
728
  }