@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.
- package/LICENSE +21 -0
- package/README.md +405 -0
- package/dist/async.d.ts +1 -0
- package/dist/async.js +13 -0
- package/dist/authorization-types.d.ts +7 -0
- package/dist/authorization-types.js +1 -0
- package/dist/authorization-url.d.ts +3 -0
- package/dist/authorization-url.js +27 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +21 -0
- package/dist/browser/integrity.json +4 -0
- package/dist/browser/mdbase-connect.min.js +3 -0
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
- package/dist/collection-client.d.ts +57 -0
- package/dist/collection-client.js +214 -0
- package/dist/connect.d.ts +39 -0
- package/dist/connect.js +620 -0
- package/dist/connection-notifications.d.ts +25 -0
- package/dist/connection-notifications.js +179 -0
- package/dist/connection-transport.d.ts +80 -0
- package/dist/connection-transport.js +670 -0
- package/dist/connection-types.d.ts +29 -0
- package/dist/connection-types.js +1 -0
- package/dist/connection.d.ts +154 -0
- package/dist/connection.js +371 -0
- package/dist/crypto.d.ts +64 -0
- package/dist/crypto.js +391 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +83 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/internal-types.d.ts +70 -0
- package/dist/internal-types.js +1 -0
- package/dist/notifications.d.ts +47 -0
- package/dist/notifications.js +52 -0
- package/dist/operation-helpers.d.ts +24 -0
- package/dist/operation-helpers.js +129 -0
- package/dist/operation-types.d.ts +254 -0
- package/dist/operation-types.js +1 -0
- package/dist/runtime-utils.d.ts +33 -0
- package/dist/runtime-utils.js +224 -0
- package/dist/selection.d.ts +48 -0
- package/dist/selection.js +112 -0
- package/dist/session.d.ts +76 -0
- package/dist/session.js +271 -0
- 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
|
+
}
|