@mdbase-dev/connect 0.1.0-beta.61 → 0.1.0-beta.63

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-8q/ad0vEZFISbJVmcgiwghE8Zi5/u4kBtZPskPy+cHhXMEwZoHGTiGdujLx57o8/
1
+ sha384-lle3e5SEN8S45RwEaaerEa1VKEBzMw7JZGQcuO1VI734WP7P95QHHR5DbXASXaaL
@@ -4,10 +4,10 @@ import { MdbaseConnectError, connectError, serverConnectError } from "./errors.j
4
4
  import { ConnectionFileTransport } from "./connection-file-transport.js";
5
5
  import { authorityProofHeaders } from "./authority-proof.js";
6
6
  import { PendingMutationStore } from "./pending-mutation-store.js";
7
- import { directFallbackStatus, isMutation, localNetworkPermission, loopbackRequest, operationFingerprint, operationTransportError, sameAuthorization, throwIfCancelled, unknownMutationOutcome } from "./operation-helpers.js";
7
+ import { directFallbackStatus, encryptedOperationError, fetchOperationRequest, isMutation, localNetworkPermission, loopbackRequest, operationFingerprint, operationTransportError, sameAuthorization, throwIfCancelled, unknownMutationOutcome, withOperationDeadline } from "./operation-helpers.js";
8
8
  import { apiError, decodeJsonResponse, oauthErrorCode, parseStored, } from "./runtime-utils.js";
9
9
  import { readStoredToken } from "./stored-token.js";
10
- import { withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
10
+ import { requestOptionsWithinBudget, withCooperativeRequestBudget, withRequestBudget } from "./request-budget.js";
11
11
  import { sendAuthoritySyncRequest } from "./authority-sync-request.js";
12
12
  import { freshEncryptedRequest } from "./fresh-encrypted-request.js";
13
13
  import { isExplicitConnectorBusyResponse, retryExplicitConnectorBusy } from "./transient-retry.js";
@@ -124,13 +124,10 @@ export class ConnectionTransport {
124
124
  if (!pending) {
125
125
  throw connectError("no_pending_mutation", "There is no interrupted mutation to resume.");
126
126
  }
127
- return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.performOperationWithinBudget(pending.operation, pending.request?.input ?? {}, { ...options, signal: budget.signal }, pending));
127
+ return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.performOperationWithinBudget(pending.operation, pending.request?.input ?? {}, requestOptionsWithinBudget(options ?? {}, budget), pending));
128
128
  }
129
129
  async performOperation(operation, input, options = {}) {
130
- return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.performOperationWithinBudget(operation, input, {
131
- ...options,
132
- signal: budget.signal
133
- }));
130
+ return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.performOperationWithinBudget(operation, input, requestOptionsWithinBudget(options, budget)));
134
131
  }
135
132
  async performOperationWithinBudget(operation, input, options, storedPending, freshReadRetried = false, connectorBusyRetries = 0, knownRejectedMutationRetry = false) {
136
133
  throwIfCancelled(options.signal);
@@ -214,6 +211,8 @@ export class ConnectionTransport {
214
211
  && !freshReadRetried) {
215
212
  return this.performOperationWithinBudget(operation, input, options, undefined, true);
216
213
  }
214
+ if (attempt.pendingMutation && error.outcomeUnknown)
215
+ throw error;
217
216
  if (attempt.pendingMutation
218
217
  && (attempt.directDeliveryUncertain
219
218
  || (attempt.encryptedRequest && attempt.resumingMutation))) {
@@ -251,16 +250,14 @@ export class ConnectionTransport {
251
250
  && !freshReadRetried) {
252
251
  return this.performOperationWithinBudget(operation, input, options, undefined, true);
253
252
  }
253
+ if (!decrypted.ok) {
254
+ const error = encryptedOperationError(decrypted.problem);
255
+ if (attempt.pendingMutation && !error.outcomeUnknown)
256
+ this.clearPendingMutation(attempt.requestId);
257
+ throw error;
258
+ }
254
259
  if (attempt.pendingMutation)
255
260
  this.clearPendingMutation(attempt.requestId);
256
- if (!decrypted.ok)
257
- throw serverConnectError(decrypted.problem.code === "unknown"
258
- ? decrypted.problem.server_code
259
- : decrypted.problem.code, decrypted.problem.message, {
260
- details: decrypted.problem.details,
261
- operationOutcome: decrypted.problem.operation_outcome ?? "rejected",
262
- traceId: decrypted.problem.trace_id
263
- });
264
261
  return decrypted.result;
265
262
  }
266
263
  catch (error) {
@@ -292,7 +289,7 @@ export class ConnectionTransport {
292
289
  const recovery = await retryBusy(error);
293
290
  if (recovery.retried)
294
291
  return recovery.result;
295
- if (attempt.pendingMutation)
292
+ if (attempt.pendingMutation && !error.outcomeUnknown)
296
293
  this.clearPendingMutation(attempt.requestId);
297
294
  throw error;
298
295
  }
@@ -380,10 +377,12 @@ export class ConnectionTransport {
380
377
  if (!pending.envelope) {
381
378
  throw connectError("pending_mutation_unresolved", "The pending write belongs to a different transport. Reconnect before retrying it.");
382
379
  }
383
- encryptedRequest = pending.envelope;
380
+ // Preserve the durable identity and ciphertext, but refresh the
381
+ // unauthenticated scheduling deadline for this recovery attempt.
382
+ encryptedRequest = withOperationDeadline(pending.envelope, options.deadlineUnixMs);
384
383
  }
385
384
  else {
386
- encryptedRequest = await encryptRelayRequest(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, operation, input, requestId);
385
+ encryptedRequest = await encryptRelayRequest(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, operation, input, requestId, options.deadlineUnixMs);
387
386
  this.pendingMutationStore.store({
388
387
  collectionId: token.collectionId,
389
388
  ...(token.grantId ? { grantId: token.grantId } : {}),
@@ -401,7 +400,7 @@ export class ConnectionTransport {
401
400
  }
402
401
  }
403
402
  else {
404
- encryptedRequest = await encryptRelayRequest(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, operation, input);
403
+ encryptedRequest = await encryptRelayRequest(this.keyStore, token.keyHandle, { grantId: token.grantId, applicationId: token.clientId, encryption: token.encryption }, operation, input, undefined, options.deadlineUnixMs);
405
404
  }
406
405
  }
407
406
  catch (error) {
@@ -460,8 +459,11 @@ export class ConnectionTransport {
460
459
  this.markDirectUnavailable();
461
460
  }
462
461
  catch (error) {
463
- if (options.signal?.aborted)
462
+ if (options.signal?.aborted) {
463
+ if (pendingMutation)
464
+ throw unknownMutationOutcome(requestId, error);
464
465
  throw error;
466
+ }
465
467
  directDeliveryUncertain = true;
466
468
  if ((await localNetworkPermission()) === "denied")
467
469
  this.setDirectStatus("denied");
@@ -481,7 +483,7 @@ export class ConnectionTransport {
481
483
  }
482
484
  let response;
483
485
  if (!pendingMutation) {
484
- encryptedRequest = await freshEncryptedRequest(this.keyStore, relayToken, operation, input);
486
+ encryptedRequest = await freshEncryptedRequest(this.keyStore, relayToken, operation, input, options.deadlineUnixMs);
485
487
  requestId = encryptedRequest.request_id;
486
488
  }
487
489
  try {
@@ -496,7 +498,7 @@ export class ConnectionTransport {
496
498
  });
497
499
  }
498
500
  catch (error) {
499
- if (pendingMutation && (directDeliveryUncertain || resumingMutation))
501
+ if (pendingMutation)
500
502
  throw unknownMutationOutcome(requestId, error);
501
503
  throw error;
502
504
  }
@@ -519,16 +521,15 @@ export class ConnectionTransport {
519
521
  const proof = token.authority
520
522
  ? await authorityProofHeaders(this.keyStore, token, "POST", operationUrl, operationBody, token.authority.accessToken)
521
523
  : {};
522
- const response = await fetch(operationUrl, {
523
- method: "POST",
524
- headers: {
525
- authorization: `Bearer ${token.authority?.accessToken ?? token.accessToken}`,
526
- "content-type": "application/json",
527
- ...proof
528
- },
529
- body: operationBody,
530
- signal: options.signal
531
- });
524
+ let response;
525
+ try {
526
+ response = await fetchOperationRequest(operationUrl, token.authority?.accessToken ?? token.accessToken, proof, operationBody, options.signal);
527
+ }
528
+ catch (error) {
529
+ if (pendingMutation)
530
+ throw unknownMutationOutcome(requestId, error);
531
+ throw error;
532
+ }
532
533
  if (response.ok)
533
534
  this.setRoute(token.authority ? "remote" : "relay");
534
535
  return {
package/dist/crypto.d.ts CHANGED
@@ -46,7 +46,7 @@ export interface AuthorityProofInput {
46
46
  /** Sign one remote-authority or token-refresh request with the approved grant key. */
47
47
  export declare function signAuthorityRequest(store: GrantKeyStore, handle: string, expectedPublicKey: string, input: AuthorityProofInput): Promise<Record<string, string>>;
48
48
  export declare function authorityProofMessage(input: Required<Pick<AuthorityProofInput, "method" | "target" | "credential" | "timestamp" | "nonce">> & Pick<AuthorityProofInput, "body">): Promise<string>;
49
- export declare function encryptRelayRequest(store: GrantKeyStore, handle: string, binding: RelayBinding, operation: EncryptedRelayOperation, input: unknown, requestId?: string): Promise<EncryptedRelayOperationRequest>;
49
+ export declare function encryptRelayRequest(store: GrantKeyStore, handle: string, binding: RelayBinding, operation: EncryptedRelayOperation, input: unknown, requestId?: string, deadlineUnixMs?: number): Promise<EncryptedRelayOperationRequest>;
50
50
  export declare function decryptRelayResponse<Result>(store: GrantKeyStore, handle: string, binding: RelayBinding, request: EncryptedRelayOperationRequest, response: EncryptedRelayOperationResponse): Promise<{
51
51
  ok: true;
52
52
  result: Result;
package/dist/crypto.js CHANGED
@@ -157,8 +157,12 @@ export async function authorityProofMessage(input) {
157
157
  input.nonce
158
158
  ].join("\n");
159
159
  }
160
- export async function encryptRelayRequest(store, handle, binding, operation, input, requestId = crypto.randomUUID()) {
160
+ export async function encryptRelayRequest(store, handle, binding, operation, input, requestId = crypto.randomUUID(), deadlineUnixMs) {
161
161
  validateGrantEncryption(binding.encryption);
162
+ if (deadlineUnixMs !== undefined
163
+ && (!Number.isSafeInteger(deadlineUnixMs) || deadlineUnixMs <= 0)) {
164
+ throw new RelayCryptoError("invalid_deadline", "The operation deadline is invalid.");
165
+ }
162
166
  const record = await requireAgreementKey(store, handle, binding.encryption.application_agreement_public_key);
163
167
  const counter = await store.nextCounter(handle);
164
168
  const metadata = {
@@ -179,6 +183,7 @@ export async function encryptRelayRequest(store, handle, binding, operation, inp
179
183
  return {
180
184
  type: "encrypted_operation_request",
181
185
  ...envelopeMetadata(metadata),
186
+ ...(deadlineUnixMs === undefined ? {} : { deadline_unix_ms: deadlineUnixMs }),
182
187
  ciphertext: bytesToBase64Url(new Uint8Array(ciphertext))
183
188
  };
184
189
  }
package/dist/files.js CHANGED
@@ -387,8 +387,14 @@ export class MdbaseFileClient {
387
387
  }
388
388
  }
389
389
  catch (error) {
390
+ const failure = options.signal?.aborted
391
+ ? connectError("operation_cancelled", "The file transfer was cancelled.", {
392
+ operationOutcome: "not_sent",
393
+ cause: options.signal.reason
394
+ })
395
+ : error;
390
396
  await finish();
391
- controller.error(normalizeFileError(error));
397
+ controller.error(normalizeFileError(failure));
392
398
  }
393
399
  },
394
400
  cancel: finish
@@ -1,4 +1,4 @@
1
1
  import type { CollectionOperation, EncryptedRelayOperationRequest } from "@mdbase-dev/connect-protocol";
2
2
  import { type GrantKeyStore } from "./crypto.js";
3
3
  import type { StoredToken } from "./internal-types.js";
4
- export declare function freshEncryptedRequest(keyStore: GrantKeyStore, token: StoredToken, operation: CollectionOperation, input: unknown): Promise<EncryptedRelayOperationRequest>;
4
+ export declare function freshEncryptedRequest(keyStore: GrantKeyStore, token: StoredToken, operation: CollectionOperation, input: unknown, deadlineUnixMs?: number): Promise<EncryptedRelayOperationRequest>;
@@ -1,6 +1,6 @@
1
1
  import { encryptRelayRequest, RelayCryptoError } from "./crypto.js";
2
2
  import { connectError, serverConnectError } from "./errors.js";
3
- export async function freshEncryptedRequest(keyStore, token, operation, input) {
3
+ export async function freshEncryptedRequest(keyStore, token, operation, input, deadlineUnixMs) {
4
4
  if (!token.encryption || !token.grantId || !token.keyHandle) {
5
5
  throw connectError("authorization_binding_incompatible", "The refreshed authorization no longer supports encrypted connector operations.", {
6
6
  details: {
@@ -17,7 +17,7 @@ export async function freshEncryptedRequest(keyStore, token, operation, input) {
17
17
  grantId: token.grantId,
18
18
  applicationId: token.clientId,
19
19
  encryption: token.encryption
20
- }, operation, input);
20
+ }, operation, input, undefined, deadlineUnixMs);
21
21
  }
22
22
  catch (error) {
23
23
  if (error instanceof RelayCryptoError) {
@@ -1,4 +1,4 @@
1
- import { type CollectionOperation } from "@mdbase-dev/connect-protocol";
1
+ import { type CollectionOperation, type ConnectProblem, type EncryptedRelayOperationRequest } from "@mdbase-dev/connect-protocol";
2
2
  import { MdbaseConnectError } from "./errors.js";
3
3
  import type { StoredToken } from "./internal-types.js";
4
4
  import type { DeleteInput, DeletePreflightResult, MutationEstimate, RenameInput, RenamePreflightResult } from "./operation-types.js";
@@ -9,10 +9,13 @@ export declare function loopbackRequest(init: RequestInit): LoopbackRequestInit;
9
9
  export declare function localNetworkPermission(): Promise<PermissionState | null>;
10
10
  export declare function directFallbackStatus(status: number): boolean;
11
11
  export declare function isMutation(operation: CollectionOperation, input?: unknown): boolean;
12
+ export declare function withOperationDeadline(request: EncryptedRelayOperationRequest, deadlineUnixMs?: number): EncryptedRelayOperationRequest;
12
13
  export declare function uniqueOperations(operations: CollectionOperation[]): CollectionOperation[];
13
14
  export declare function sameAuthorization(left: StoredToken, right: StoredToken): boolean;
14
15
  export declare function throwIfCancelled(signal?: AbortSignal): void;
15
16
  export declare function operationTransportError(error: unknown, signal: AbortSignal | undefined, pendingRequestId: string | undefined, unavailableCode: "hosted_provider_unavailable" | "relay_unavailable"): Error;
17
+ export declare function encryptedOperationError(problem: ConnectProblem): MdbaseConnectError;
18
+ export declare function fetchOperationRequest(url: string, accessToken: string, proof: Record<string, string>, body: string, signal?: AbortSignal): Promise<Response>;
16
19
  export declare function isCancellation(error: unknown, signal?: AbortSignal): boolean;
17
20
  export declare function assertRenamePreview(input: RenameInput, preview: RenamePreflightResult): void;
18
21
  export declare function assertDeletePreview(input: DeleteInput, preview: DeletePreflightResult): void;
@@ -1,5 +1,5 @@
1
1
  import { isMutatingOperation, mutationFingerprint } from "@mdbase-dev/connect-protocol";
2
- import { MdbaseConnectError, connectError } from "./errors.js";
2
+ import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
3
3
  export function loopbackRequest(init) {
4
4
  return { ...init, credentials: "omit", targetAddressSpace: "loopback" };
5
5
  }
@@ -22,6 +22,15 @@ export function directFallbackStatus(status) {
22
22
  export function isMutation(operation, input) {
23
23
  return isMutatingOperation(operation, input);
24
24
  }
25
+ export function withOperationDeadline(request, deadlineUnixMs) {
26
+ if (request.deadline_unix_ms === deadlineUnixMs)
27
+ return request;
28
+ const { deadline_unix_ms: _previousDeadline, ...identity } = request;
29
+ return {
30
+ ...identity,
31
+ ...(deadlineUnixMs === undefined ? {} : { deadline_unix_ms: deadlineUnixMs })
32
+ };
33
+ }
25
34
  export function uniqueOperations(operations) {
26
35
  return [...new Set(operations)];
27
36
  }
@@ -61,6 +70,25 @@ export function operationTransportError(error, signal, pendingRequestId, unavail
61
70
  }
62
71
  return error instanceof Error ? error : new Error(String(error));
63
72
  }
73
+ export function encryptedOperationError(problem) {
74
+ return serverConnectError(problem.code === "unknown" ? problem.server_code : problem.code, problem.message, {
75
+ details: problem.details,
76
+ operationOutcome: problem.operation_outcome ?? "rejected",
77
+ traceId: problem.trace_id
78
+ });
79
+ }
80
+ export function fetchOperationRequest(url, accessToken, proof, body, signal) {
81
+ return fetch(url, {
82
+ method: "POST",
83
+ headers: {
84
+ authorization: `Bearer ${accessToken}`,
85
+ "content-type": "application/json",
86
+ ...proof
87
+ },
88
+ body,
89
+ signal
90
+ });
91
+ }
64
92
  export function isCancellation(error, signal) {
65
93
  return signal?.aborted === true
66
94
  || (error instanceof MdbaseConnectError && error.code === "operation_cancelled");
@@ -19,6 +19,11 @@ export interface RequestBudget {
19
19
  remainingMs(): number | null;
20
20
  dispose(): void;
21
21
  }
22
+ export type OperationRequestOptions = ConnectRequestOptions & {
23
+ /** Absolute budget shared with the connector; never supplied directly by callers. */
24
+ deadlineUnixMs?: number;
25
+ };
26
+ export declare function requestOptionsWithinBudget(options: ConnectRequestOptions, budget: RequestBudget): OperationRequestOptions;
22
27
  export declare function createRequestBudget(options?: ConnectRequestOptions, defaultTimeoutMs?: number | null, now?: () => number): RequestBudget;
23
28
  export declare function withRequestBudget<Result>(options: ConnectRequestOptions | undefined, defaultTimeoutMs: number | null, operation: (budget: RequestBudget) => Promise<Result>): Promise<Result>;
24
29
  /**
@@ -13,6 +13,13 @@ export function resolveConnectTimeouts(timeouts = {}) {
13
13
  syncMs: configuredTimeout(timeouts.syncMs, DEFAULT_SYNC_TIMEOUT_MS, "syncMs")
14
14
  };
15
15
  }
16
+ export function requestOptionsWithinBudget(options, budget) {
17
+ return {
18
+ ...options,
19
+ signal: budget.signal,
20
+ ...(budget.deadline === null ? {} : { deadlineUnixMs: budget.deadline })
21
+ };
22
+ }
16
23
  export function createRequestBudget(options = {}, defaultTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, now = Date.now) {
17
24
  const configured = options.timeoutMs === undefined ? defaultTimeoutMs : options.timeoutMs;
18
25
  const timeoutMs = configured === null ? null : validTimeout(configured);
@@ -49,7 +49,12 @@ export async function createPkce() {
49
49
  return { verifier, challenge: bytesToBase64Url(new Uint8Array(digest)) };
50
50
  }
51
51
  export function apiError(body, fallbackCode, fallbackMessage, status) {
52
- return serverConnectError(oauthErrorCode(body) ?? body?.error?.code ?? fallbackCode, body?.error_description ?? body?.error?.message ?? fallbackMessage, { status, details: body?.error?.details });
52
+ return serverConnectError(oauthErrorCode(body) ?? body?.error?.code ?? fallbackCode, body?.error_description ?? body?.error?.message ?? fallbackMessage, {
53
+ status,
54
+ details: body?.error?.details,
55
+ operationOutcome: body?.error?.operation_outcome,
56
+ traceId: body?.error?.trace_id
57
+ });
53
58
  }
54
59
  export function oauthErrorCode(body) {
55
60
  return typeof body?.error === "string" ? body.error : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdbase-dev/connect",
3
- "version": "0.1.0-beta.61",
3
+ "version": "0.1.0-beta.63",
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.61"
36
+ "@mdbase-dev/connect-protocol": "0.1.0-beta.63"
37
37
  },
38
38
  "devDependencies": {
39
39
  "esbuild": "^0.28.1",