@mdbase-dev/connect 0.1.0-beta.55 → 0.1.0-beta.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- sha384-6M+exMhOm+JESB8xqgBNPFnykP6bx043yY1RbfIP4UidAlareN3dFvCVZDSd1/aj
1
+ sha384-fZeP3Z5L7qaOmLPO0ST72DyQgwJCq0XaamP4LFUHOTWxA2MTeF4eerxdIfULZkEq
@@ -0,0 +1,22 @@
1
+ import type { GrantKeyStore } from "./crypto.js";
2
+ import type { StoredToken } from "./internal-types.js";
3
+ import type { ResolvedConnectTimeouts } from "./request-budget.js";
4
+ export interface ConnectionTransportInternals {
5
+ readonly relayEncryption: "required" | "disabled";
6
+ removeToken(collectionId: string, keyHandle?: string, reason?: "not_authorized" | "authorization_lost" | "invalid_stored_grant", discardPending?: boolean): void;
7
+ storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
8
+ tokenKey(collectionId: string): string;
9
+ pendingMutationKey(collectionId: string): string;
10
+ directPreferenceKey(): string;
11
+ }
12
+ export interface ConnectionTransportOptions {
13
+ serverUrl: string;
14
+ storage: Storage;
15
+ keyStore: GrantKeyStore;
16
+ directAccessMode: "auto" | "disabled";
17
+ loopbackUrl: string;
18
+ collectionId: string;
19
+ internals: ConnectionTransportInternals;
20
+ onChange(): void;
21
+ timeouts: ResolvedConnectTimeouts;
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,29 +1,10 @@
1
1
  import type { CollectionOperation } from "@mdbase-dev/connect-protocol";
2
- import { type GrantKeyStore } from "./crypto.js";
3
2
  import type { StoredToken } from "./internal-types.js";
4
3
  import type { DirectAccessStatus, MdbaseConnectionRoute } from "./connection-types.js";
5
4
  import type { ConnectRequestOptions, PendingMutationSummary } from "./operation-types.js";
6
5
  import { ConnectionFileTransport } from "./connection-file-transport.js";
7
- import { type ResolvedConnectTimeouts } from "./request-budget.js";
8
- export interface ConnectionTransportInternals {
9
- readonly relayEncryption: "required" | "disabled";
10
- removeToken(collectionId: string, keyHandle?: string, reason?: "not_authorized" | "authorization_lost" | "invalid_stored_grant", discardPending?: boolean): void;
11
- storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
12
- tokenKey(collectionId: string): string;
13
- pendingMutationKey(collectionId: string): string;
14
- directPreferenceKey(): string;
15
- }
16
- export interface ConnectionTransportOptions {
17
- serverUrl: string;
18
- storage: Storage;
19
- keyStore: GrantKeyStore;
20
- directAccessMode: "auto" | "disabled";
21
- loopbackUrl: string;
22
- collectionId: string;
23
- internals: ConnectionTransportInternals;
24
- onChange(): void;
25
- timeouts: ResolvedConnectTimeouts;
26
- }
6
+ import type { ConnectionTransportOptions } from "./connection-transport-internals.js";
7
+ export type { ConnectionTransportInternals, ConnectionTransportOptions } from "./connection-transport-internals.js";
27
8
  export declare class ConnectionTransport {
28
9
  private readonly serverUrl;
29
10
  private readonly storage;
@@ -62,7 +43,6 @@ export declare class ConnectionTransport {
62
43
  performOperation<Result>(operation: CollectionOperation, input: unknown, options?: ConnectRequestOptions): Promise<Result>;
63
44
  private performOperationWithinBudget;
64
45
  performAuthoritySyncRequest<Result>(collectionId: string, replicaId: string, method: "GET" | "POST", path: string, input?: unknown, options?: ConnectRequestOptions): Promise<Result>;
65
- private sendAuthoritySyncRequest;
66
46
  private sendOperation;
67
47
  private directCapable;
68
48
  hasResumableMutationTransport(): boolean;
@@ -8,6 +8,8 @@ import { directFallbackStatus, isMutation, localNetworkPermission, loopbackReque
8
8
  import { apiError, decodeJsonResponse, oauthErrorCode, parseStored, } from "./runtime-utils.js";
9
9
  import { readStoredToken } from "./stored-token.js";
10
10
  import { withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
11
+ import { sendAuthoritySyncRequest } from "./authority-sync-request.js";
12
+ import { freshEncryptedRequest } from "./fresh-encrypted-request.js";
11
13
  export class ConnectionTransport {
12
14
  serverUrl;
13
15
  storage;
@@ -134,7 +136,7 @@ export class ConnectionTransport {
134
136
  signal: budget.signal
135
137
  }));
136
138
  }
137
- async performOperationWithinBudget(operation, input, options, storedPending) {
139
+ async performOperationWithinBudget(operation, input, options, storedPending, freshReadRetried = false) {
138
140
  throwIfCancelled(options.signal);
139
141
  let token = this.currentToken();
140
142
  if (!token)
@@ -199,6 +201,11 @@ export class ConnectionTransport {
199
201
  }
200
202
  if (!response.ok) {
201
203
  const error = apiError(body, "operation_failed", "Collection operation failed.", response.status);
204
+ if (error.code === "fresh_request_required"
205
+ && !attempt.pendingMutation
206
+ && !freshReadRetried) {
207
+ return this.performOperationWithinBudget(operation, input, options, undefined, true);
208
+ }
202
209
  if (attempt.pendingMutation
203
210
  && (attempt.directDeliveryUncertain
204
211
  || (attempt.encryptedRequest && attempt.resumingMutation))) {
@@ -230,6 +237,12 @@ export class ConnectionTransport {
230
237
  applicationId: responseApplicationId,
231
238
  encryption: responseEncryption
232
239
  }, attempt.encryptedRequest, encryptedResponse);
240
+ if (!decrypted.ok
241
+ && decrypted.problem.code === "fresh_request_required"
242
+ && !attempt.pendingMutation
243
+ && !freshReadRetried) {
244
+ return this.performOperationWithinBudget(operation, input, options, undefined, true);
245
+ }
233
246
  if (attempt.pendingMutation)
234
247
  this.clearPendingMutation(attempt.requestId);
235
248
  if (!decrypted.ok)
@@ -287,7 +300,7 @@ export class ConnectionTransport {
287
300
  throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
288
301
  }
289
302
  try {
290
- let response = await this.sendAuthoritySyncRequest(token, method, path, input, budget.signal);
303
+ let response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
291
304
  if (response.status === 401 && token.refreshToken) {
292
305
  token = await this.refreshAuthorization(budget.signal);
293
306
  if (!token.authority
@@ -295,7 +308,7 @@ export class ConnectionTransport {
295
308
  || token.authority.replicaId !== replicaId) {
296
309
  throw connectError("authority_authorization_changed", "Reconnect this collection authority before synchronizing.");
297
310
  }
298
- response = await this.sendAuthoritySyncRequest(token, method, path, input, budget.signal);
311
+ response = await sendAuthoritySyncRequest(this.keyStore, token, method, path, input, budget.signal);
299
312
  }
300
313
  const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid synchronization response.");
301
314
  if (!response.ok) {
@@ -313,24 +326,6 @@ export class ConnectionTransport {
313
326
  }
314
327
  });
315
328
  }
316
- async sendAuthoritySyncRequest(token, method, path, input, signal) {
317
- if (!token.authority) {
318
- throw connectError("not_remote_authority", "This authorization has no remote authority endpoint.");
319
- }
320
- const url = `${token.authority.syncUrl}/${path}`;
321
- const body = input === undefined ? undefined : JSON.stringify(input);
322
- const proof = await authorityProofHeaders(this.keyStore, token, method, url, body, token.authority.accessToken);
323
- return fetch(url, {
324
- method,
325
- headers: {
326
- authorization: `Bearer ${token.authority.accessToken}`,
327
- ...(input === undefined ? {} : { "content-type": "application/json" }),
328
- ...proof
329
- },
330
- ...(body === undefined ? {} : { body }),
331
- signal
332
- });
333
- }
334
329
  async sendOperation(token, operation, input, tryDirect, options = {}, pendingRequestId) {
335
330
  let body = input ?? {};
336
331
  let encryptedRequest;
@@ -400,6 +395,7 @@ export class ConnectionTransport {
400
395
  throw serverConnectError(error.code, error.message);
401
396
  throw error;
402
397
  }
398
+ requestId = encryptedRequest.request_id;
403
399
  body = encryptedRequest;
404
400
  }
405
401
  else {
@@ -469,6 +465,10 @@ export class ConnectionTransport {
469
465
  throw error;
470
466
  }
471
467
  let response;
468
+ if (!pendingMutation) {
469
+ encryptedRequest = await freshEncryptedRequest(this.keyStore, relayToken, operation, input);
470
+ requestId = encryptedRequest.request_id;
471
+ }
472
472
  try {
473
473
  response = await fetch(`${this.serverUrl}/v1/authorities/${encodeURIComponent(relayToken.collectionId)}/operations/${operation}`, {
474
474
  method: "POST",
@@ -0,0 +1,4 @@
1
+ import type { CollectionOperation, EncryptedRelayOperationRequest } from "@mdbase-dev/connect-protocol";
2
+ import { type GrantKeyStore } from "./crypto.js";
3
+ import type { StoredToken } from "./internal-types.js";
4
+ export declare function freshEncryptedRequest(keyStore: GrantKeyStore, token: StoredToken, operation: CollectionOperation, input: unknown): Promise<EncryptedRelayOperationRequest>;
@@ -0,0 +1,28 @@
1
+ import { encryptRelayRequest, RelayCryptoError } from "./crypto.js";
2
+ import { connectError, serverConnectError } from "./errors.js";
3
+ export async function freshEncryptedRequest(keyStore, token, operation, input) {
4
+ if (!token.encryption || !token.grantId || !token.keyHandle) {
5
+ throw connectError("authorization_binding_incompatible", "The refreshed authorization no longer supports encrypted connector operations.", {
6
+ details: {
7
+ contract: "grant_encryption",
8
+ required: [1],
9
+ supported: token.encryption ? [token.encryption.protocol_version] : [],
10
+ peer: "connector",
11
+ operation
12
+ }
13
+ });
14
+ }
15
+ try {
16
+ return await encryptRelayRequest(keyStore, token.keyHandle, {
17
+ grantId: token.grantId,
18
+ applicationId: token.clientId,
19
+ encryption: token.encryption
20
+ }, operation, input);
21
+ }
22
+ catch (error) {
23
+ if (error instanceof RelayCryptoError) {
24
+ throw serverConnectError(error.code, error.message);
25
+ }
26
+ throw error;
27
+ }
28
+ }
@@ -17,6 +17,7 @@ export declare class LocalFileTransport {
17
17
  private readonly options;
18
18
  constructor(options: LocalFileTransportOptions);
19
19
  control<Result>(token: StoredToken, method: "GET" | "POST" | "DELETE", path: string, input: unknown, signal?: AbortSignal): Promise<Result>;
20
+ private controlAttempt;
20
21
  private decryptControlResponse;
21
22
  uploadChunk(session: FileTransferSession, chunkIndex: number, bytes: Uint8Array, signal?: AbortSignal): Promise<void>;
22
23
  downloadChunk(session: FileTransferSession, chunkIndex: number, signal?: AbortSignal): Promise<Uint8Array>;
@@ -11,11 +11,15 @@ export class LocalFileTransport {
11
11
  this.options = options;
12
12
  }
13
13
  async control(token, method, path, input, signal) {
14
+ return this.controlAttempt(token, method, path, input, signal, false);
15
+ }
16
+ async controlAttempt(token, method, path, input, signal, freshReadRetried) {
14
17
  requireEncryption(token);
15
18
  const controlInput = localFileControlInput(method, path, input);
16
19
  let encryptedRequest = await this.encryptControl(token, controlInput);
17
20
  let response;
18
- if (await this.options.shouldAttemptDirect(token)) {
21
+ const triedDirect = await this.options.shouldAttemptDirect(token);
22
+ if (triedDirect) {
19
23
  try {
20
24
  response = await fetch(`${this.options.loopbackUrl}/v1/files/control`, loopbackRequest({
21
25
  method: "POST",
@@ -32,11 +36,18 @@ export class LocalFileTransport {
32
36
  if (response && !directFallbackStatus(response.status)) {
33
37
  if (response.ok)
34
38
  this.options.onDirectAvailable();
35
- return this.decryptControlResponse(token, encryptedRequest, response);
39
+ return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried);
36
40
  }
37
41
  if (response)
38
42
  this.options.onDirectUnavailable();
39
43
  }
44
+ if (triedDirect && method === "GET") {
45
+ token = token.expiresAt > Date.now() + 30_000
46
+ ? token
47
+ : await this.options.refreshAuthorization(signal);
48
+ requireLocalFileToken(token);
49
+ encryptedRequest = await this.encryptControl(token, controlInput);
50
+ }
40
51
  response = await this.sendRelayControl(token, encryptedRequest, signal);
41
52
  if (await refreshableBindingFailure(response, token)) {
42
53
  token = await this.options.refreshAuthorization(signal);
@@ -46,18 +57,30 @@ export class LocalFileTransport {
46
57
  }
47
58
  if (response.ok)
48
59
  this.options.onRelayAvailable();
49
- return this.decryptControlResponse(token, encryptedRequest, response);
60
+ return this.decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried);
50
61
  }
51
- async decryptControlResponse(token, encryptedRequest, response) {
62
+ async decryptControlResponse(token, method, path, input, signal, encryptedRequest, response, freshReadRetried) {
52
63
  const body = await decodeJsonResponse(response, "invalid_operation_response", "The collection authority returned an invalid file control response.");
53
64
  if (!response.ok) {
54
- throw apiError(body, "operation_failed", "Collection file request failed.", response.status);
65
+ const error = apiError(body, "operation_failed", "Collection file request failed.", response.status);
66
+ if (error.code === "fresh_request_required"
67
+ && method === "GET"
68
+ && !freshReadRetried) {
69
+ return this.controlAttempt(token, method, path, input, signal, true);
70
+ }
71
+ throw error;
55
72
  }
56
73
  const encryptedResponse = body?.envelope;
57
74
  if (!encryptedResponse) {
58
75
  throw connectError("invalid_encrypted_response", "The connector omitted its encrypted file response.");
59
76
  }
60
77
  const decrypted = await decryptRelayResponse(this.options.keyStore, token.keyHandle, relayBinding(token), encryptedRequest, encryptedResponse);
78
+ if (!decrypted.ok
79
+ && decrypted.problem.code === "fresh_request_required"
80
+ && method === "GET"
81
+ && !freshReadRetried) {
82
+ return this.controlAttempt(token, method, path, input, signal, true);
83
+ }
61
84
  if (!decrypted.ok)
62
85
  throwEncryptedProblem(decrypted.problem);
63
86
  return decrypted.result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect",
3
- "version": "0.1.0-beta.55",
3
+ "version": "0.1.0-beta.56",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
- "@mdbase-dev/connect-protocol": "0.1.0-beta.55"
36
+ "@mdbase-dev/connect-protocol": "0.1.0-beta.56"
37
37
  },
38
38
  "devDependencies": {
39
39
  "esbuild": "^0.28.1",