@mdbase-dev/connect 0.1.0-beta.21

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +405 -0
  3. package/dist/async.d.ts +1 -0
  4. package/dist/async.js +13 -0
  5. package/dist/authorization-types.d.ts +7 -0
  6. package/dist/authorization-types.js +1 -0
  7. package/dist/authorization-url.d.ts +3 -0
  8. package/dist/authorization-url.js +27 -0
  9. package/dist/base64.d.ts +3 -0
  10. package/dist/base64.js +21 -0
  11. package/dist/browser/integrity.json +4 -0
  12. package/dist/browser/mdbase-connect.min.js +3 -0
  13. package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
  14. package/dist/collection-client.d.ts +57 -0
  15. package/dist/collection-client.js +214 -0
  16. package/dist/connect.d.ts +39 -0
  17. package/dist/connect.js +620 -0
  18. package/dist/connection-notifications.d.ts +25 -0
  19. package/dist/connection-notifications.js +179 -0
  20. package/dist/connection-transport.d.ts +80 -0
  21. package/dist/connection-transport.js +670 -0
  22. package/dist/connection-types.d.ts +29 -0
  23. package/dist/connection-types.js +1 -0
  24. package/dist/connection.d.ts +154 -0
  25. package/dist/connection.js +371 -0
  26. package/dist/crypto.d.ts +64 -0
  27. package/dist/crypto.js +391 -0
  28. package/dist/errors.d.ts +31 -0
  29. package/dist/errors.js +83 -0
  30. package/dist/index.d.ts +13 -0
  31. package/dist/index.js +12 -0
  32. package/dist/internal-types.d.ts +70 -0
  33. package/dist/internal-types.js +1 -0
  34. package/dist/notifications.d.ts +47 -0
  35. package/dist/notifications.js +52 -0
  36. package/dist/operation-helpers.d.ts +24 -0
  37. package/dist/operation-helpers.js +129 -0
  38. package/dist/operation-types.d.ts +254 -0
  39. package/dist/operation-types.js +1 -0
  40. package/dist/runtime-utils.d.ts +33 -0
  41. package/dist/runtime-utils.js +224 -0
  42. package/dist/selection.d.ts +48 -0
  43. package/dist/selection.js +112 -0
  44. package/dist/session.d.ts +76 -0
  45. package/dist/session.js +271 -0
  46. package/package.json +41 -0
@@ -0,0 +1,670 @@
1
+ import { decryptRelayResponse, encryptRelayRequest, RelayCryptoError, signAuthorityRequest } from "./crypto.js";
2
+ import { MdbaseConnectError } from "./errors.js";
3
+ import { directFallbackStatus, isMutation, localNetworkPermission, loopbackRequest, operationFingerprint, operationTransportError, sameAuthorization, throwIfCancelled, uncertainDirectMutation } from "./operation-helpers.js";
4
+ import { apiError, parseGrantScope, parseStored, validStoredAuthority, validStoredEncryption } from "./runtime-utils.js";
5
+ export class ConnectionTransport {
6
+ serverUrl;
7
+ storage;
8
+ keyStore;
9
+ directAccessMode;
10
+ loopbackUrl;
11
+ collectionId;
12
+ internals;
13
+ onChange;
14
+ refreshPromise = null;
15
+ directStatus;
16
+ currentRoute = "relay";
17
+ directFailures = 0;
18
+ directRetryAt = 0;
19
+ constructor(options) {
20
+ this.serverUrl = options.serverUrl;
21
+ this.storage = options.storage;
22
+ this.keyStore = options.keyStore;
23
+ this.directAccessMode = options.directAccessMode;
24
+ this.loopbackUrl = options.loopbackUrl;
25
+ this.collectionId = options.collectionId;
26
+ this.internals = options.internals;
27
+ this.onChange = options.onChange;
28
+ this.directStatus = this.directAccessMode === "disabled"
29
+ ? "disabled"
30
+ : "unavailable";
31
+ }
32
+ get directAccess() {
33
+ return this.currentToken()?.authority ? "disabled" : this.directStatus;
34
+ }
35
+ get route() {
36
+ return this.currentToken()?.authority ? "remote" : this.currentRoute;
37
+ }
38
+ notifyStorageChanged() {
39
+ this.emitConnection();
40
+ }
41
+ async checkDirectAccess() {
42
+ const token = this.currentToken();
43
+ if (!this.directEligible(token))
44
+ return this.setDirectStatus("disabled");
45
+ const permission = await localNetworkPermission();
46
+ if (permission === "denied")
47
+ return this.setDirectStatus("denied");
48
+ if ((permission === "prompt" || permission === null)
49
+ && this.storage.getItem(this.directPreferenceKey()) !== "enabled") {
50
+ return this.setDirectStatus("permission_required");
51
+ }
52
+ return this.probeDirectAccess();
53
+ }
54
+ /** Call from a user gesture to request browser permission for direct local access. */
55
+ async requestDirectAccess() {
56
+ const token = this.currentToken();
57
+ if (!this.directCapable(token))
58
+ return this.setDirectStatus("disabled");
59
+ this.storage.setItem(this.directPreferenceKey(), "enabled");
60
+ this.directRetryAt = 0;
61
+ return this.probeDirectAccess();
62
+ }
63
+ disableDirectAccess() {
64
+ this.storage.setItem(this.directPreferenceKey(), "disabled");
65
+ this.setDirectStatus("disabled");
66
+ this.setRoute("relay");
67
+ }
68
+ /**
69
+ * Return one offline-replication transport regardless of whether the
70
+ * authority is remote, directly connected, or relay connected.
71
+ * Authority credentials and routing stay private inside the SDK.
72
+ */
73
+ pendingMutation() {
74
+ const pending = parseStored(this.storage.getItem(this.pendingMutationKey()));
75
+ const token = this.currentToken();
76
+ if (!pending || !token
77
+ || pending.collectionId !== token.collectionId
78
+ || pending.grantId !== token.grantId
79
+ || pending.keyId !== token.encryption?.key_id)
80
+ return null;
81
+ return { operation: pending.operation, createdAt: pending.createdAt, resumable: true };
82
+ }
83
+ async resumePendingMutation(input, options) {
84
+ const pending = this.pendingMutation();
85
+ if (!pending) {
86
+ throw new MdbaseConnectError("no_pending_mutation", "There is no interrupted mutation to resume.", { recovery: "none" });
87
+ }
88
+ return this.performOperation(pending.operation, input, options);
89
+ }
90
+ async performOperation(operation, input, options = {}) {
91
+ throwIfCancelled(options.signal);
92
+ let token = this.currentToken();
93
+ if (!token)
94
+ throw new MdbaseConnectError("not_authorized", "Connect this application before accessing a collection.");
95
+ if (!token.operations.includes(operation)) {
96
+ throw new MdbaseConnectError("insufficient_access", `This connection does not allow ${operation}.`, {
97
+ requiresAuthorization: true,
98
+ recovery: "reauthorize",
99
+ details: {
100
+ requiredOperations: [operation],
101
+ grantedOperations: [...token.operations],
102
+ missingOperations: [operation]
103
+ }
104
+ });
105
+ }
106
+ let tryDirect = await this.shouldAttemptDirect(token);
107
+ if (!tryDirect) {
108
+ token = await this.authorizedToken();
109
+ if (!token)
110
+ throw new MdbaseConnectError("not_authorized", "Reconnect this application to continue.");
111
+ }
112
+ let attempt;
113
+ try {
114
+ attempt = await this.sendOperation(token, operation, input, tryDirect, options);
115
+ }
116
+ catch (error) {
117
+ throw operationTransportError(error, options.signal, isMutation(operation, input) && this.pendingMutation() !== null);
118
+ }
119
+ let response = attempt.response;
120
+ const staleBinding = response.status === 409
121
+ && (await response.clone().json().catch(() => null))?.error?.code === "encryption_binding_stale";
122
+ if ((response.status === 401 || staleBinding) && token.refreshToken) {
123
+ if (attempt.pendingMutation
124
+ && (attempt.directDeliveryUncertain
125
+ || (attempt.encryptedRequest && attempt.resumingMutation))) {
126
+ throw new MdbaseConnectError("direct_outcome_unknown", "The direct operation may have completed, but its encrypted grant changed before the response could be recovered. Refresh before making another change.");
127
+ }
128
+ if (attempt.pendingMutation)
129
+ this.clearPendingMutation();
130
+ token = await this.refreshAuthorization();
131
+ tryDirect = await this.shouldAttemptDirect(token);
132
+ try {
133
+ attempt = await this.sendOperation(token, operation, input, tryDirect, options);
134
+ }
135
+ catch (error) {
136
+ throw operationTransportError(error, options.signal, isMutation(operation, input) && this.pendingMutation() !== null);
137
+ }
138
+ response = attempt.response;
139
+ }
140
+ const body = await response.json();
141
+ if (!response.ok) {
142
+ const error = apiError(body, "operation_failed", "Collection operation failed.", response.status);
143
+ if (attempt.pendingMutation
144
+ && (attempt.directDeliveryUncertain
145
+ || (attempt.encryptedRequest && attempt.resumingMutation))) {
146
+ throw uncertainDirectMutation(error);
147
+ }
148
+ if (attempt.pendingMutation && !attempt.directDeliveryUncertain) {
149
+ this.clearPendingMutation();
150
+ }
151
+ if (error.code === "direct_operation_rejected" && error.status === 403) {
152
+ this.invalidateRejectedAuthorization(token);
153
+ }
154
+ throw error;
155
+ }
156
+ if (attempt.encryptedRequest) {
157
+ const encryptedResponse = body?.envelope;
158
+ if (!encryptedResponse || !token.encryption || !token.grantId || !token.keyHandle) {
159
+ throw new MdbaseConnectError("invalid_encrypted_response", "The relay did not return an encrypted connector response.");
160
+ }
161
+ try {
162
+ const decrypted = await decryptRelayResponse(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, attempt.encryptedRequest, encryptedResponse);
163
+ if (attempt.pendingMutation)
164
+ this.clearPendingMutation();
165
+ if (!decrypted.ok)
166
+ throw new MdbaseConnectError(decrypted.error.code, decrypted.error.message);
167
+ return decrypted.result;
168
+ }
169
+ catch (error) {
170
+ if (error instanceof MdbaseConnectError)
171
+ throw error;
172
+ if (error instanceof RelayCryptoError)
173
+ throw new MdbaseConnectError(error.code, error.message);
174
+ throw error;
175
+ }
176
+ }
177
+ if (body?.protocol_version !== 1 || body?.request_id !== attempt.requestId) {
178
+ throw new MdbaseConnectError("invalid_operation_response", "The collection authority returned a response for a different protocol request.");
179
+ }
180
+ if (attempt.pendingMutation)
181
+ this.clearPendingMutation();
182
+ return body.result;
183
+ }
184
+ async performAuthoritySyncRequest(collectionId, replicaId, method, path, input) {
185
+ let token = await this.authorizedToken();
186
+ if (!token?.authority
187
+ || token.collectionId !== collectionId
188
+ || token.authority.replicaId !== replicaId) {
189
+ throw new MdbaseConnectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
190
+ }
191
+ let response = await this.sendAuthoritySyncRequest(token, method, path, input);
192
+ if (response.status === 401 && token.refreshToken) {
193
+ token = await this.refreshAuthorization();
194
+ if (!token.authority
195
+ || token.collectionId !== collectionId
196
+ || token.authority.replicaId !== replicaId) {
197
+ throw new MdbaseConnectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
198
+ }
199
+ response = await this.sendAuthoritySyncRequest(token, method, path, input);
200
+ }
201
+ const body = await response.json();
202
+ if (!response.ok)
203
+ throw apiError(body, "sync_failed", "Collection synchronization failed.", response.status);
204
+ return body;
205
+ }
206
+ async sendAuthoritySyncRequest(token, method, path, input) {
207
+ if (!token.authority) {
208
+ throw new MdbaseConnectError("not_remote_authority", "This authorization has no remote authority endpoint.");
209
+ }
210
+ const url = `${token.authority.syncUrl}/${path}`;
211
+ const body = input === undefined ? undefined : JSON.stringify(input);
212
+ const proof = await this.authorityProofHeaders(token, method, url, body, token.authority.accessToken);
213
+ return fetch(url, {
214
+ method,
215
+ headers: {
216
+ authorization: `Bearer ${token.authority.accessToken}`,
217
+ ...(input === undefined ? {} : { "content-type": "application/json" }),
218
+ ...proof
219
+ },
220
+ ...(body === undefined ? {} : { body })
221
+ });
222
+ }
223
+ async sendOperation(token, operation, input, tryDirect, options = {}) {
224
+ let body = input ?? {};
225
+ let encryptedRequest;
226
+ let pendingMutation = false;
227
+ let resumingMutation = false;
228
+ let requestId = crypto.randomUUID();
229
+ let pending = null;
230
+ let inputFingerprint;
231
+ if (isMutation(operation, input)) {
232
+ inputFingerprint = await operationFingerprint(operation, input);
233
+ pending = parseStored(this.storage.getItem(this.pendingMutationKey()));
234
+ if (pending) {
235
+ if (pending.collectionId !== token.collectionId
236
+ || pending.grantId !== token.grantId
237
+ || pending.keyId !== token.encryption?.key_id
238
+ || pending.operation !== operation
239
+ || pending.inputFingerprint !== inputFingerprint) {
240
+ throw new MdbaseConnectError("pending_mutation_unresolved", "A previous write still has an unknown outcome. Retry that exact write before making another change.");
241
+ }
242
+ requestId = pending.requestId;
243
+ resumingMutation = true;
244
+ }
245
+ pendingMutation = true;
246
+ }
247
+ if (token.encryption && !token.authority) {
248
+ if (!token.grantId || !token.keyHandle) {
249
+ throw new MdbaseConnectError("missing_grant_key", "Reconnect this application to restore encrypted access.");
250
+ }
251
+ try {
252
+ if (pendingMutation) {
253
+ if (pending) {
254
+ if (!pending.envelope) {
255
+ throw new MdbaseConnectError("pending_mutation_unresolved", "The pending write belongs to a different transport. Reconnect before retrying it.");
256
+ }
257
+ encryptedRequest = pending.envelope;
258
+ }
259
+ else {
260
+ encryptedRequest = await encryptRelayRequest(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, operation, input, requestId);
261
+ this.storage.setItem(this.pendingMutationKey(), JSON.stringify({
262
+ collectionId: token.collectionId,
263
+ ...(token.grantId ? { grantId: token.grantId } : {}),
264
+ keyId: token.encryption.key_id,
265
+ operation,
266
+ inputFingerprint: inputFingerprint,
267
+ requestId,
268
+ envelope: encryptedRequest,
269
+ createdAt: Date.now()
270
+ }));
271
+ }
272
+ }
273
+ else {
274
+ encryptedRequest = await encryptRelayRequest(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, operation, input);
275
+ }
276
+ }
277
+ catch (error) {
278
+ if (error instanceof RelayCryptoError)
279
+ throw new MdbaseConnectError(error.code, error.message);
280
+ throw error;
281
+ }
282
+ body = encryptedRequest;
283
+ }
284
+ else {
285
+ const request = {
286
+ protocol_version: 1,
287
+ request_id: requestId,
288
+ input: body
289
+ };
290
+ body = request;
291
+ if (pendingMutation && !pending) {
292
+ this.storage.setItem(this.pendingMutationKey(), JSON.stringify({
293
+ collectionId: token.collectionId,
294
+ ...(token.grantId ? { grantId: token.grantId } : {}),
295
+ operation,
296
+ inputFingerprint: inputFingerprint,
297
+ requestId,
298
+ createdAt: Date.now()
299
+ }));
300
+ }
301
+ }
302
+ if (tryDirect && encryptedRequest && !token.authority) {
303
+ let directDeliveryUncertain = false;
304
+ try {
305
+ const response = await fetch(`${this.loopbackUrl}/v1/operations`, loopbackRequest({
306
+ method: "POST",
307
+ headers: { "content-type": "application/mdbase-connect+json" },
308
+ body: JSON.stringify(encryptedRequest),
309
+ signal: options.signal
310
+ }));
311
+ if (!directFallbackStatus(response.status)) {
312
+ if (response.ok) {
313
+ this.markDirectAvailable();
314
+ this.setRoute("direct");
315
+ }
316
+ return {
317
+ response,
318
+ requestId,
319
+ encryptedRequest,
320
+ pendingMutation,
321
+ resumingMutation
322
+ };
323
+ }
324
+ directDeliveryUncertain = response.status >= 500;
325
+ this.markDirectUnavailable();
326
+ }
327
+ catch (error) {
328
+ if (options.signal?.aborted)
329
+ throw error;
330
+ directDeliveryUncertain = true;
331
+ if ((await localNetworkPermission()) === "denied")
332
+ this.setDirectStatus("denied");
333
+ else
334
+ this.markDirectUnavailable();
335
+ }
336
+ let relayToken;
337
+ try {
338
+ relayToken = token.expiresAt > Date.now() + 30_000
339
+ ? token
340
+ : await this.refreshAuthorization();
341
+ }
342
+ catch (error) {
343
+ if (pendingMutation && (directDeliveryUncertain || resumingMutation))
344
+ throw uncertainDirectMutation(error);
345
+ throw error;
346
+ }
347
+ let response;
348
+ try {
349
+ response = await fetch(`${this.serverUrl}/v1/authorities/${encodeURIComponent(relayToken.collectionId)}/operations/${operation}`, {
350
+ method: "POST",
351
+ headers: {
352
+ authorization: `Bearer ${relayToken.accessToken}`,
353
+ "content-type": "application/json"
354
+ },
355
+ body: JSON.stringify(encryptedRequest),
356
+ signal: options.signal
357
+ });
358
+ }
359
+ catch (error) {
360
+ if (pendingMutation && (directDeliveryUncertain || resumingMutation))
361
+ throw uncertainDirectMutation(error);
362
+ throw error;
363
+ }
364
+ if (response.ok)
365
+ this.setRoute("relay");
366
+ return {
367
+ response,
368
+ requestId,
369
+ encryptedRequest,
370
+ directDeliveryUncertain,
371
+ pendingMutation,
372
+ resumingMutation
373
+ };
374
+ }
375
+ const operationUrl = token.authority
376
+ ? `${token.authority.operationsUrl}/${operation}`
377
+ : `${this.serverUrl}/v1/authorities/${encodeURIComponent(token.collectionId)}/operations/${operation}`;
378
+ const operationBody = JSON.stringify(body);
379
+ const proof = token.authority
380
+ ? await this.authorityProofHeaders(token, "POST", operationUrl, operationBody, token.authority.accessToken)
381
+ : {};
382
+ const response = await fetch(operationUrl, {
383
+ method: "POST",
384
+ headers: {
385
+ authorization: `Bearer ${token.authority?.accessToken ?? token.accessToken}`,
386
+ "content-type": "application/json",
387
+ ...proof
388
+ },
389
+ body: operationBody,
390
+ signal: options.signal
391
+ });
392
+ if (response.ok)
393
+ this.setRoute(token.authority ? "remote" : "relay");
394
+ return { response, requestId, encryptedRequest, pendingMutation, resumingMutation };
395
+ }
396
+ directCapable(token) {
397
+ if (!token || token.authority || !token.encryption || !token.grantId || !token.keyHandle)
398
+ return false;
399
+ if (this.directAccessMode === "disabled")
400
+ return false;
401
+ if (typeof location !== "undefined"
402
+ && token.applicationOrigin
403
+ && token.applicationOrigin !== location.origin)
404
+ return false;
405
+ return true;
406
+ }
407
+ hasResumableMutationTransport() {
408
+ const token = this.currentToken();
409
+ return Boolean(token);
410
+ }
411
+ directEligible(token) {
412
+ return token !== null
413
+ && this.directCapable(token)
414
+ && this.storage.getItem(this.directPreferenceKey()) !== "disabled";
415
+ }
416
+ async shouldAttemptDirect(token) {
417
+ if (!this.directEligible(token))
418
+ return false;
419
+ if (this.directStatus === "available")
420
+ return true;
421
+ if (this.directStatus === "unavailable" && Date.now() < this.directRetryAt)
422
+ return false;
423
+ const permission = await localNetworkPermission();
424
+ if (permission === "denied") {
425
+ this.setDirectStatus("denied");
426
+ return false;
427
+ }
428
+ if ((permission === "prompt" || permission === null)
429
+ && this.storage.getItem(this.directPreferenceKey()) !== "enabled") {
430
+ this.setDirectStatus("permission_required");
431
+ return false;
432
+ }
433
+ this.setDirectStatus("checking");
434
+ return true;
435
+ }
436
+ async probeDirectAccess() {
437
+ this.setDirectStatus("checking");
438
+ try {
439
+ const response = await fetch(`${this.loopbackUrl}/v1/ready`, loopbackRequest({
440
+ method: "GET",
441
+ cache: "no-store"
442
+ }));
443
+ const body = await response.json().catch(() => null);
444
+ if (response.ok
445
+ && body?.service === "mdbase-connect"
446
+ && body?.loopback_protocol_version === 1
447
+ && body?.encrypted_protocol_version === 1) {
448
+ this.markDirectAvailable();
449
+ return "available";
450
+ }
451
+ }
452
+ catch {
453
+ // Permission denial, unsupported mixed content, and an absent connector all reject fetch.
454
+ }
455
+ if ((await localNetworkPermission()) === "denied")
456
+ return this.setDirectStatus("denied");
457
+ this.markDirectUnavailable();
458
+ return "unavailable";
459
+ }
460
+ markDirectAvailable() {
461
+ this.directFailures = 0;
462
+ this.directRetryAt = 0;
463
+ this.setDirectStatus("available");
464
+ }
465
+ markDirectUnavailable() {
466
+ this.directFailures += 1;
467
+ this.directRetryAt = Date.now() + Math.min(60_000, 1_000 * 2 ** (this.directFailures - 1));
468
+ this.setDirectStatus("unavailable");
469
+ }
470
+ setDirectStatus(status) {
471
+ if (this.directStatus !== status) {
472
+ this.directStatus = status;
473
+ this.emitConnection();
474
+ }
475
+ return status;
476
+ }
477
+ setRoute(route) {
478
+ if (this.currentRoute !== route) {
479
+ this.currentRoute = route;
480
+ this.emitConnection();
481
+ }
482
+ }
483
+ emitConnection() {
484
+ this.onChange();
485
+ }
486
+ invalidateRejectedAuthorization(rejected) {
487
+ const current = parseStored(this.storage.getItem(this.tokenKey()));
488
+ if (!current || !sameAuthorization(current, rejected))
489
+ return;
490
+ this.internals.removeToken(this.collectionId, current.keyHandle);
491
+ this.currentRoute = "relay";
492
+ this.directStatus = this.directAccessMode === "disabled" ? "disabled" : "unavailable";
493
+ this.emitConnection();
494
+ }
495
+ currentToken() {
496
+ const stored = this.storage.getItem(this.tokenKey());
497
+ const token = parseStored(stored);
498
+ const invalidate = (keyHandle) => {
499
+ this.internals.removeToken(this.collectionId, typeof keyHandle === "string" ? keyHandle : undefined, "invalid_stored_grant");
500
+ return null;
501
+ };
502
+ if (!token) {
503
+ if (stored)
504
+ invalidate();
505
+ return null;
506
+ }
507
+ if (token.version !== 1
508
+ || typeof token.accessToken !== "string"
509
+ || token.accessToken.length === 0
510
+ || typeof token.clientId !== "string"
511
+ || token.clientId.length === 0
512
+ || token.collectionId !== this.collectionId
513
+ || typeof token.collectionName !== "string"
514
+ || token.collectionName.length === 0
515
+ || !Array.isArray(token.operations)
516
+ || token.operations.some((operation) => typeof operation !== "string")
517
+ || typeof token.expiresAt !== "number"
518
+ || !Number.isFinite(token.expiresAt)
519
+ || (token.refreshToken !== undefined
520
+ && (typeof token.refreshToken !== "string"
521
+ || token.refreshToken.length === 0))
522
+ || (token.refreshExpiresAt !== undefined
523
+ && (typeof token.refreshExpiresAt !== "number"
524
+ || !Number.isFinite(token.refreshExpiresAt))))
525
+ return invalidate(token.keyHandle);
526
+ if (!parseGrantScope(token.scope)) {
527
+ return invalidate(token.keyHandle);
528
+ }
529
+ if (token.authority
530
+ && !validStoredAuthority(token.authority, token.collectionId)) {
531
+ return invalidate(token.keyHandle);
532
+ }
533
+ if (this.internals.relayEncryption === "required") {
534
+ if (token.authority) {
535
+ if (!token.keyHandle || !token.authority.proofPublicKey) {
536
+ return invalidate(token.keyHandle);
537
+ }
538
+ }
539
+ else {
540
+ if (!token.grantId
541
+ || !token.keyHandle
542
+ || !validStoredEncryption(token.encryption, token.collectionId)) {
543
+ return invalidate(token.keyHandle);
544
+ }
545
+ }
546
+ }
547
+ if (token.expiresAt <= Date.now()
548
+ && (!token.refreshToken || (token.refreshExpiresAt ?? 0) <= Date.now())) {
549
+ // The cloud bearer and the local grant proof have separate lifetimes. Keep an
550
+ // encrypted local grant usable while the connector still recognizes it; relay
551
+ // use will require reauthorization, and revocation remains enforced locally.
552
+ if (this.directCapable(token))
553
+ return token;
554
+ this.internals.removeToken(this.collectionId, token.keyHandle);
555
+ return null;
556
+ }
557
+ return token;
558
+ }
559
+ async authorizedToken() {
560
+ const token = this.currentToken();
561
+ if (!token)
562
+ return null;
563
+ if (token.expiresAt > Date.now() + 30_000)
564
+ return token;
565
+ if (!token.refreshToken || (token.refreshExpiresAt ?? 0) <= Date.now()) {
566
+ if (this.directCapable(token)) {
567
+ throw new MdbaseConnectError("relay_authorization_expired", "Direct access is still available on this computer, but using the relay requires reconnecting this application.");
568
+ }
569
+ this.internals.removeToken(this.collectionId, token.keyHandle);
570
+ return null;
571
+ }
572
+ return this.refreshAuthorization();
573
+ }
574
+ refreshAuthorization() {
575
+ if (this.refreshPromise)
576
+ return this.refreshPromise;
577
+ this.refreshPromise = this.performRefresh().finally(() => {
578
+ this.refreshPromise = null;
579
+ });
580
+ return this.refreshPromise;
581
+ }
582
+ async performRefresh() {
583
+ const current = this.currentToken();
584
+ if (!current?.refreshToken) {
585
+ throw new MdbaseConnectError("not_authorized", "Reconnect this application to continue.");
586
+ }
587
+ if ((current.refreshExpiresAt ?? 0) <= Date.now()) {
588
+ throw new MdbaseConnectError("relay_authorization_expired", "Direct access is still available on this computer, but using the relay requires reconnecting this application.");
589
+ }
590
+ const attemptedRefreshToken = current.refreshToken;
591
+ const refreshUrl = `${this.serverUrl}/oauth/token`;
592
+ const refreshBody = new URLSearchParams({
593
+ grant_type: "refresh_token",
594
+ refresh_token: current.refreshToken,
595
+ client_id: current.clientId
596
+ }).toString();
597
+ const proof = current.authority
598
+ ? await this.authorityProofHeaders(current, "POST", refreshUrl, refreshBody, current.refreshToken)
599
+ : {};
600
+ const response = await fetch(refreshUrl, {
601
+ method: "POST",
602
+ headers: {
603
+ "content-type": "application/x-www-form-urlencoded",
604
+ ...proof
605
+ },
606
+ body: refreshBody
607
+ });
608
+ const body = await response.json();
609
+ if (!response.ok) {
610
+ const latest = this.currentToken();
611
+ if (latest?.refreshToken && latest.refreshToken !== attemptedRefreshToken) {
612
+ return latest;
613
+ }
614
+ if (!this.directCapable(current)) {
615
+ this.internals.removeToken(this.collectionId, current.keyHandle);
616
+ }
617
+ throw apiError(body, "authorization_expired", "Reconnect this application to continue.", response.status);
618
+ }
619
+ return this.storeTokenResponse(body, current.clientId, current.keyHandle);
620
+ }
621
+ async authorityProofHeaders(token, method, url, body, credential) {
622
+ if (!token.authority?.proofPublicKey)
623
+ return {};
624
+ if (!token.keyHandle) {
625
+ throw new MdbaseConnectError("missing_grant_key", "Reconnect this application to restore remote authority request signing.");
626
+ }
627
+ try {
628
+ const target = new URL(url);
629
+ return await signAuthorityRequest(this.keyStore, token.keyHandle, token.authority.proofPublicKey, {
630
+ method,
631
+ target: `${target.pathname}${target.search}`,
632
+ body,
633
+ credential
634
+ });
635
+ }
636
+ catch (error) {
637
+ if (error instanceof RelayCryptoError) {
638
+ throw new MdbaseConnectError(error.code, error.message);
639
+ }
640
+ throw error;
641
+ }
642
+ }
643
+ storeTokenResponse(body, clientId, keyHandle) {
644
+ if (body.collection_id !== this.collectionId) {
645
+ throw new MdbaseConnectError("collection_mismatch", "The refreshed authorization belongs to a different collection.");
646
+ }
647
+ const token = this.internals.storeTokenResponse(body, clientId, keyHandle);
648
+ this.currentRoute = token.authority ? "remote" : "relay";
649
+ this.directStatus = token.authority || this.directAccessMode === "disabled"
650
+ ? "disabled"
651
+ : "unavailable";
652
+ this.emitConnection();
653
+ return token;
654
+ }
655
+ tokenKey() {
656
+ return this.internals.tokenKey(this.collectionId);
657
+ }
658
+ notificationKey(transport = "web_push") {
659
+ return this.internals.notificationKey(this.collectionId, transport);
660
+ }
661
+ pendingMutationKey() {
662
+ return this.internals.pendingMutationKey(this.collectionId);
663
+ }
664
+ clearPendingMutation() {
665
+ this.storage.removeItem(this.pendingMutationKey());
666
+ }
667
+ directPreferenceKey() {
668
+ return this.internals.directPreferenceKey();
669
+ }
670
+ }