@mdbase-dev/connect 0.1.0-beta.21 → 0.1.0-beta.22

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 (49) hide show
  1. package/README.md +108 -38
  2. package/dist/authorization-url.d.ts +2 -1
  3. package/dist/authorization-url.js +5 -7
  4. package/dist/browser/integrity.json +1 -1
  5. package/dist/browser/mdbase-connect.min.js +3 -3
  6. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  7. package/dist/collection-client.d.ts +34 -31
  8. package/dist/collection-client.js +107 -61
  9. package/dist/connect.d.ts +4 -3
  10. package/dist/connect.js +53 -38
  11. package/dist/connection-file-transport.d.ts +31 -0
  12. package/dist/connection-file-transport.js +42 -0
  13. package/dist/connection-notifications.js +21 -21
  14. package/dist/connection-transport.d.ts +2 -0
  15. package/dist/connection-transport.js +95 -37
  16. package/dist/connection-types.d.ts +2 -1
  17. package/dist/connection.d.ts +49 -45
  18. package/dist/connection.js +73 -33
  19. package/dist/crypto.d.ts +5 -6
  20. package/dist/crypto.js +13 -13
  21. package/dist/errors.d.ts +38 -24
  22. package/dist/errors.js +130 -71
  23. package/dist/file-crypto.d.ts +25 -0
  24. package/dist/file-crypto.js +132 -0
  25. package/dist/file-sha256.d.ts +12 -0
  26. package/dist/file-sha256.js +143 -0
  27. package/dist/file-stream-source.d.ts +17 -0
  28. package/dist/file-stream-source.js +157 -0
  29. package/dist/files.d.ts +91 -0
  30. package/dist/files.js +644 -0
  31. package/dist/hosted-file-request.d.ts +6 -0
  32. package/dist/hosted-file-request.js +63 -0
  33. package/dist/index.d.ts +4 -1
  34. package/dist/index.js +3 -0
  35. package/dist/internal-types.d.ts +3 -1
  36. package/dist/local-file-transport.d.ts +30 -0
  37. package/dist/local-file-transport.js +367 -0
  38. package/dist/notifications.js +5 -5
  39. package/dist/operation-helpers.d.ts +2 -2
  40. package/dist/operation-helpers.js +22 -18
  41. package/dist/operation-types.d.ts +3 -4
  42. package/dist/outcomes.d.ts +51 -0
  43. package/dist/outcomes.js +168 -0
  44. package/dist/runtime-utils.d.ts +6 -1
  45. package/dist/runtime-utils.js +90 -11
  46. package/dist/selection.js +3 -3
  47. package/dist/session.d.ts +9 -8
  48. package/dist/session.js +31 -20
  49. package/package.json +2 -2
@@ -0,0 +1,63 @@
1
+ import { connectError } from "./errors.js";
2
+ import { apiError } from "./runtime-utils.js";
3
+ export async function sendHostedFileRequest(token, method, path, input, signal, proofHeaders) {
4
+ if (!token.authority) {
5
+ throw connectError("not_remote_authority", "This authorization has no remote authority endpoint.");
6
+ }
7
+ const suffix = path === "" || path.startsWith("?") ? path : `/${path}`;
8
+ const url = `${token.authority.filesUrl}${suffix}`;
9
+ const body = input === undefined ? undefined : JSON.stringify(input);
10
+ const proof = await proofHeaders(token, method, url, body, token.authority.accessToken);
11
+ return fetch(url, {
12
+ method,
13
+ redirect: "error",
14
+ headers: {
15
+ authorization: `Bearer ${token.authority.accessToken}`,
16
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
17
+ ...proof
18
+ },
19
+ ...(body === undefined ? {} : { body }),
20
+ ...(signal ? { signal } : {})
21
+ });
22
+ }
23
+ export async function performHostedFileRequest(token, method, path, input, signal, refresh, proofHeaders) {
24
+ let active = token;
25
+ let response = await sendHostedFileRequest(active, method, path, input, signal, proofHeaders);
26
+ if (response.status === 401 && active.refreshToken) {
27
+ active = await refresh();
28
+ if (!active.authority || !active.fileCapability) {
29
+ throw connectError("authority_authorization_changed", "Reconnect this collection before accessing its files.");
30
+ }
31
+ response = await sendHostedFileRequest(active, method, path, input, signal, proofHeaders);
32
+ }
33
+ const body = await response.json().catch(() => ({}));
34
+ if (!response.ok) {
35
+ throw apiError(body, "operation_failed", "Collection file request failed.", response.status);
36
+ }
37
+ return body;
38
+ }
39
+ export async function performHostedFilePartRequest(token, path, expectedLength, signal, refresh, proofHeaders) {
40
+ let active = token;
41
+ let response = await sendHostedFileRequest(active, "GET", path, undefined, signal, proofHeaders);
42
+ if (response.status === 401 && active.refreshToken) {
43
+ active = await refresh();
44
+ if (!active.authority || !active.fileCapability) {
45
+ throw connectError("authority_authorization_changed", "Reconnect this collection before accessing its files.");
46
+ }
47
+ response = await sendHostedFileRequest(active, "GET", path, undefined, signal, proofHeaders);
48
+ }
49
+ if (!response.ok) {
50
+ const body = await response.json().catch(() => ({}));
51
+ throw apiError(body, "operation_failed", "Collection file range request failed.", response.status);
52
+ }
53
+ const contentLength = response.headers.get("content-length");
54
+ if (contentLength === null || !/^(0|[1-9][0-9]*)$/u.test(contentLength)
55
+ || Number(contentLength) !== expectedLength) {
56
+ await response.body?.cancel().catch(() => undefined);
57
+ throw connectError("invalid_operation_response", "The hosted file response declared an unexpected byte length.");
58
+ }
59
+ if (!response.body) {
60
+ throw connectError("invalid_operation_response", "The hosted file response did not contain a byte stream.");
61
+ }
62
+ return response.body;
63
+ }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  export { decryptRelayResponse, encryptRelayRequest, IndexedDbGrantKeyStore, MemoryGrantKeyStore, RelayCryptoError, type RelayBinding, type GrantKeyRecord, type GrantKeyStore } from "./crypto.js";
2
+ export { FileTransferCryptoError, GrantFileTransferCipher, type FileTransferBinding } from "./file-crypto.js";
2
3
  export * from "./authorization-types.js";
3
4
  export * from "./collection-client.js";
4
5
  export * from "./connect.js";
5
6
  export * from "./connection.js";
6
7
  export * from "./connection-types.js";
7
8
  export * from "./errors.js";
9
+ export * from "./files.js";
8
10
  export * from "./notifications.js";
11
+ export * from "./outcomes.js";
9
12
  export * from "./operation-types.js";
10
13
  export * from "./selection.js";
11
14
  export * from "./session.js";
12
15
  export { createPkce } from "./runtime-utils.js";
13
- export type { ApplicationProvisions, ApplicationRequirements, ApplicationNotifications, CollectionChange, CollectionChangesPage, CollectionContractDescriptor, CollectionDescription, CollectionFileMetadata, CollectionOperation as MdbaseOperation, CollectionTypeDescriptor, CollectionTypeDocument, ContractRequirement, DataContractViewIdentity, GrantScope, JsonObject, MdbaseAppManifest, NotificationCriterion, MdbaseDiagnostic, MdbaseOperationEnvelope, RecordDocument, QueryRecord, SavedNamedView, SavedViewDocument, SavedViewExecution, SavedViewList, SavedViewPresentation, SavedViewProperty, SavedViewSource, SavedViewSourceDocument, ReadViewSourceInput, CreateViewSourceInput, UpdateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, TypePackManifest, TypePackManifestResource, TypePackInstallResult, TypePackProvision, TypePackResourceDiff, TypePackSourceResource } from "@mdbase-dev/connect-protocol";
16
+ export type { ApplicationProvisions, ApplicationRequirements, ApplicationNotifications, CollectionChange, CollectionChangesPage, CollectionContractDescriptor, CollectionDescription, CollectionFileMetadata, CollectionOperation as MdbaseOperation, CollectionTypeDescriptor, CollectionTypeDocument, ConnectOperationOutcome, ConnectProblem, ConnectProblemCategory, ConnectProblemCode, ConnectProblemDetailsByCode, ConnectRecoveryAction, ContractRequirement, DataContractViewIdentity, GrantScope, JsonObject, MdbaseAppManifest, NotificationCriterion, MdbaseDiagnostic, MdbaseOperationEnvelope, KnownConnectProblem, RecordDocument, QueryRecord, SavedNamedView, SavedViewDocument, SavedViewExecution, SavedViewList, SavedViewPresentation, SavedViewProperty, SavedViewSource, SavedViewSourceDocument, ReadViewSourceInput, CreateViewSourceInput, UpdateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, TypePackManifest, TypePackManifestResource, TypePackInstallResult, TypePackProvision, TypePackResourceDiff, TypePackSourceResource, UnknownConnectProblem } from "@mdbase-dev/connect-protocol";
package/dist/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  export { decryptRelayResponse, encryptRelayRequest, IndexedDbGrantKeyStore, MemoryGrantKeyStore, RelayCryptoError } from "./crypto.js";
2
+ export { FileTransferCryptoError, GrantFileTransferCipher } from "./file-crypto.js";
2
3
  export * from "./authorization-types.js";
3
4
  export * from "./collection-client.js";
4
5
  export * from "./connect.js";
5
6
  export * from "./connection.js";
6
7
  export * from "./connection-types.js";
7
8
  export * from "./errors.js";
9
+ export * from "./files.js";
8
10
  export * from "./notifications.js";
11
+ export * from "./outcomes.js";
9
12
  export * from "./operation-types.js";
10
13
  export * from "./selection.js";
11
14
  export * from "./session.js";
@@ -1,4 +1,4 @@
1
- import type { ApplicationNotifications, CollectionOperation, EncryptedRelayOperationRequest, GrantEncryption, GrantScope } from "@mdbase-dev/connect-protocol";
1
+ import type { ApplicationNotifications, CollectionOperation, EncryptedRelayOperationRequest, GrantEncryption, FileCapability, GrantScope } from "@mdbase-dev/connect-protocol";
2
2
  import { encryptRelayRequest } from "./crypto.js";
3
3
  export interface Application {
4
4
  id: string;
@@ -34,12 +34,14 @@ export interface StoredToken {
34
34
  refreshExpiresAt?: number;
35
35
  grantId?: string;
36
36
  encryption?: GrantEncryption;
37
+ fileCapability?: FileCapability;
37
38
  applicationOrigin?: string;
38
39
  keyHandle?: string;
39
40
  savedAt: number;
40
41
  authority?: {
41
42
  operationsUrl: string;
42
43
  syncUrl: string;
44
+ filesUrl: string;
43
45
  replicaId: string;
44
46
  accessToken: string;
45
47
  proofPublicKey?: string;
@@ -0,0 +1,30 @@
1
+ import type { FileTransferSession } from "@mdbase-dev/connect-protocol";
2
+ import { type GrantKeyStore } from "./crypto.js";
3
+ import type { StoredToken } from "./internal-types.js";
4
+ interface LocalFileTransportOptions {
5
+ keyStore: GrantKeyStore;
6
+ serverUrl: string;
7
+ loopbackUrl: string;
8
+ authorizedToken(): Promise<StoredToken | null>;
9
+ refreshAuthorization(): Promise<StoredToken>;
10
+ shouldAttemptDirect(token: StoredToken): Promise<boolean>;
11
+ onDirectAvailable(): void;
12
+ onDirectUnavailable(): void;
13
+ onRelayAvailable(): void;
14
+ }
15
+ /** Keeps grant-bound file crypto and loopback framing out of the operation transport. */
16
+ export declare class LocalFileTransport {
17
+ private readonly options;
18
+ constructor(options: LocalFileTransportOptions);
19
+ control<Result>(token: StoredToken, method: "GET" | "POST" | "DELETE", path: string, input: unknown, signal?: AbortSignal): Promise<Result>;
20
+ private decryptControlResponse;
21
+ uploadChunk(session: FileTransferSession, chunkIndex: number, bytes: Uint8Array, signal?: AbortSignal): Promise<void>;
22
+ downloadChunk(session: FileTransferSession, chunkIndex: number, signal?: AbortSignal): Promise<Uint8Array>;
23
+ private decryptDownloadResponse;
24
+ private encryptControl;
25
+ private sendRelayControl;
26
+ private sendRelayUpload;
27
+ private sendRelayDownload;
28
+ private localFileToken;
29
+ }
30
+ export {};
@@ -0,0 +1,367 @@
1
+ import { decodeFileFrame, FILE_PROTOCOL_VERSION, FILE_TRANSFER_PROTOCOL_VERSION } from "@mdbase-dev/connect-protocol";
2
+ import { decryptRelayResponse, encryptRelayRequest } from "./crypto.js";
3
+ import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
4
+ import { GrantFileTransferCipher } from "./file-crypto.js";
5
+ import { directFallbackStatus, loopbackRequest } from "./operation-helpers.js";
6
+ import { apiError } from "./runtime-utils.js";
7
+ /** Keeps grant-bound file crypto and loopback framing out of the operation transport. */
8
+ export class LocalFileTransport {
9
+ options;
10
+ constructor(options) {
11
+ this.options = options;
12
+ }
13
+ async control(token, method, path, input, signal) {
14
+ requireEncryption(token);
15
+ const controlInput = localFileControlInput(method, path, input);
16
+ let encryptedRequest = await this.encryptControl(token, controlInput);
17
+ let response;
18
+ if (await this.options.shouldAttemptDirect(token)) {
19
+ try {
20
+ response = await fetch(`${this.options.loopbackUrl}/v1/files/control`, loopbackRequest({
21
+ method: "POST",
22
+ headers: { "content-type": "application/mdbase-connect+json" },
23
+ body: JSON.stringify(encryptedRequest),
24
+ signal
25
+ }));
26
+ }
27
+ catch (error) {
28
+ if (signal?.aborted)
29
+ throw error;
30
+ this.options.onDirectUnavailable();
31
+ }
32
+ if (response && !directFallbackStatus(response.status)) {
33
+ if (response.ok)
34
+ this.options.onDirectAvailable();
35
+ return this.decryptControlResponse(token, encryptedRequest, response);
36
+ }
37
+ if (response)
38
+ this.options.onDirectUnavailable();
39
+ }
40
+ response = await this.sendRelayControl(token, encryptedRequest, signal);
41
+ if (await refreshableBindingFailure(response, token)) {
42
+ token = await this.options.refreshAuthorization();
43
+ requireLocalFileToken(token);
44
+ encryptedRequest = await this.encryptControl(token, controlInput);
45
+ response = await this.sendRelayControl(token, encryptedRequest, signal);
46
+ }
47
+ if (response.ok)
48
+ this.options.onRelayAvailable();
49
+ return this.decryptControlResponse(token, encryptedRequest, response);
50
+ }
51
+ async decryptControlResponse(token, encryptedRequest, response) {
52
+ const body = await response.json().catch(() => ({}));
53
+ if (!response.ok) {
54
+ throw apiError(body, "operation_failed", "Collection file request failed.", response.status);
55
+ }
56
+ const encryptedResponse = body?.envelope;
57
+ if (!encryptedResponse) {
58
+ throw connectError("invalid_encrypted_response", "The connector omitted its encrypted file response.");
59
+ }
60
+ const decrypted = await decryptRelayResponse(this.options.keyStore, token.keyHandle, relayBinding(token), encryptedRequest, encryptedResponse);
61
+ if (!decrypted.ok)
62
+ throwEncryptedProblem(decrypted.problem);
63
+ return decrypted.result;
64
+ }
65
+ async uploadChunk(session, chunkIndex, bytes, signal) {
66
+ const token = await this.localFileToken(session, "upload");
67
+ let deliveryToken = token;
68
+ let encoded = await encryptedUploadChunk(this.options.keyStore, deliveryToken, session, chunkIndex, bytes);
69
+ let response;
70
+ if (await this.options.shouldAttemptDirect(deliveryToken)) {
71
+ try {
72
+ response = await fetch(`${this.options.loopbackUrl}/v1/files/upload`, loopbackRequest(fileUploadRequest(encoded, signal)));
73
+ }
74
+ catch (error) {
75
+ if (signal?.aborted)
76
+ throw error;
77
+ this.options.onDirectUnavailable();
78
+ }
79
+ if (response && !directFallbackStatus(response.status)) {
80
+ if (response.ok)
81
+ this.options.onDirectAvailable();
82
+ return requireSuccessfulChunkResponse(response, "Encrypted file chunk upload failed.");
83
+ }
84
+ if (response)
85
+ this.options.onDirectUnavailable();
86
+ }
87
+ response = await this.sendRelayUpload(deliveryToken, encoded, signal);
88
+ if (response.status === 401 && deliveryToken.refreshToken) {
89
+ deliveryToken = await this.options.refreshAuthorization();
90
+ requireLocalFileToken(deliveryToken);
91
+ encoded = await encryptedUploadChunk(this.options.keyStore, deliveryToken, session, chunkIndex, bytes);
92
+ response = await this.sendRelayUpload(deliveryToken, encoded, signal);
93
+ }
94
+ if (!response.ok) {
95
+ const body = await response.json().catch(() => ({}));
96
+ throw apiError(body, "operation_failed", "Encrypted file chunk upload failed.", response.status);
97
+ }
98
+ this.options.onRelayAvailable();
99
+ }
100
+ async downloadChunk(session, chunkIndex, signal) {
101
+ let token = await this.localFileToken(session, "download");
102
+ let response;
103
+ if (await this.options.shouldAttemptDirect(token)) {
104
+ try {
105
+ response = await fetch(`${this.options.loopbackUrl}/v1/files/download/${encodeURIComponent(token.grantId)}/${encodeURIComponent(session.transfer_id)}/${chunkIndex}`, loopbackRequest({ method: "GET", signal }));
106
+ }
107
+ catch (error) {
108
+ if (signal?.aborted)
109
+ throw error;
110
+ this.options.onDirectUnavailable();
111
+ }
112
+ if (response && !directFallbackStatus(response.status)) {
113
+ if (response.ok)
114
+ this.options.onDirectAvailable();
115
+ return this.decryptDownloadResponse(token, session, chunkIndex, response);
116
+ }
117
+ if (response)
118
+ this.options.onDirectUnavailable();
119
+ }
120
+ response = await this.sendRelayDownload(token, session, chunkIndex, signal);
121
+ if (response.status === 401 && token.refreshToken) {
122
+ token = await this.options.refreshAuthorization();
123
+ requireLocalFileToken(token);
124
+ response = await this.sendRelayDownload(token, session, chunkIndex, signal);
125
+ }
126
+ if (response.ok)
127
+ this.options.onRelayAvailable();
128
+ return this.decryptDownloadResponse(token, session, chunkIndex, response);
129
+ }
130
+ async decryptDownloadResponse(token, session, chunkIndex, response) {
131
+ if (!response.ok) {
132
+ const body = await response.json().catch(() => ({}));
133
+ throw apiError(body, "operation_failed", "Encrypted file chunk download failed.", response.status);
134
+ }
135
+ if (response.headers.get("content-type")?.split(";", 1)[0]
136
+ !== "application/mdbase-connect-file") {
137
+ throw connectError("invalid_operation_response", "The connector returned an invalid file response media type.");
138
+ }
139
+ const encoded = new Uint8Array(await response.arrayBuffer());
140
+ let plaintext;
141
+ try {
142
+ validateDownloadFrame(session, chunkIndex, encoded);
143
+ plaintext = await (await fileCipher(this.options.keyStore, token, session))
144
+ .decryptChunk(encoded);
145
+ }
146
+ catch (cause) {
147
+ if (cause instanceof MdbaseConnectError)
148
+ throw cause;
149
+ throw connectError("invalid_operation_response", "The connector returned an invalid encrypted file frame.", { cause });
150
+ }
151
+ if (plaintext.byteLength !== expectedFramedChunkLength(session, chunkIndex)) {
152
+ throw connectError("invalid_operation_response", "The connector returned a truncated file chunk.");
153
+ }
154
+ return plaintext;
155
+ }
156
+ encryptControl(token, input) {
157
+ requireEncryption(token);
158
+ return encryptRelayRequest(this.options.keyStore, token.keyHandle, relayBinding(token), "file_control", input);
159
+ }
160
+ sendRelayControl(token, encryptedRequest, signal) {
161
+ return fetch(`${this.options.serverUrl}/v1/authorities/${encodeURIComponent(token.collectionId)}/files/control`, {
162
+ method: "POST",
163
+ headers: {
164
+ authorization: `Bearer ${token.accessToken}`,
165
+ "content-type": "application/json"
166
+ },
167
+ body: JSON.stringify(encryptedRequest),
168
+ signal
169
+ });
170
+ }
171
+ sendRelayUpload(token, encoded, signal) {
172
+ return fetch(`${this.options.serverUrl}/v1/authorities/${encodeURIComponent(token.collectionId)}/files/upload`, {
173
+ ...fileUploadRequest(encoded, signal),
174
+ headers: {
175
+ authorization: `Bearer ${token.accessToken}`,
176
+ "content-type": "application/mdbase-connect-file"
177
+ }
178
+ });
179
+ }
180
+ sendRelayDownload(token, session, chunkIndex, signal) {
181
+ return fetch(`${this.options.serverUrl}/v1/authorities/${encodeURIComponent(token.collectionId)}/files/download/${encodeURIComponent(session.transfer_id)}/${chunkIndex}`, {
182
+ method: "GET",
183
+ headers: { authorization: `Bearer ${token.accessToken}` },
184
+ signal
185
+ });
186
+ }
187
+ async localFileToken(session, direction) {
188
+ const token = await this.options.authorizedToken();
189
+ if (!token || token.authority || !token.fileCapability || !token.encryption
190
+ || !token.grantId || !token.keyHandle
191
+ || session.direction !== direction
192
+ || session.protection !== "grant_aead_v1") {
193
+ throw connectError("not_authorized", "This connection cannot use encrypted local file delivery.");
194
+ }
195
+ framedChunkSize(session);
196
+ return token;
197
+ }
198
+ }
199
+ function requireLocalFileToken(token) {
200
+ if (token.authority || !token.fileCapability) {
201
+ throw connectError("not_authorized", "This connection cannot use encrypted local file delivery.");
202
+ }
203
+ requireEncryption(token);
204
+ }
205
+ async function encryptedUploadChunk(keyStore, token, session, chunkIndex, bytes) {
206
+ const header = fileFrameHeader(token, session, chunkIndex, bytes.byteLength);
207
+ return (await fileCipher(keyStore, token, session))
208
+ .encryptChunk("upload_chunk", header, bytes);
209
+ }
210
+ function fileUploadRequest(encoded, signal) {
211
+ return {
212
+ method: "POST",
213
+ headers: { "content-type": "application/mdbase-connect-file" },
214
+ body: Uint8Array.from(encoded).buffer,
215
+ signal
216
+ };
217
+ }
218
+ async function requireSuccessfulChunkResponse(response, message) {
219
+ if (response.ok)
220
+ return;
221
+ const body = await response.json().catch(() => ({}));
222
+ throw apiError(body, "operation_failed", message, response.status);
223
+ }
224
+ async function refreshableBindingFailure(response, token) {
225
+ if (!token.refreshToken)
226
+ return false;
227
+ if (response.status === 401)
228
+ return true;
229
+ if (response.status !== 409)
230
+ return false;
231
+ const body = await response.clone().json().catch(() => null);
232
+ return body?.error?.code === "encryption_binding_stale";
233
+ }
234
+ function requireEncryption(token) {
235
+ if (!token.encryption || !token.grantId || !token.keyHandle) {
236
+ throw connectError("missing_grant_key", "Reconnect this application to restore encrypted file access.");
237
+ }
238
+ }
239
+ function relayBinding(token) {
240
+ requireEncryption(token);
241
+ return {
242
+ grantId: token.grantId,
243
+ applicationId: token.clientId,
244
+ encryption: token.encryption
245
+ };
246
+ }
247
+ function fileCipher(keyStore, token, session) {
248
+ requireEncryption(token);
249
+ return GrantFileTransferCipher.open(keyStore, token.keyHandle, {
250
+ ...relayBinding(token),
251
+ authorityId: token.encryption.connector_id,
252
+ transferId: session.transfer_id,
253
+ direction: session.direction
254
+ });
255
+ }
256
+ function fileFrameHeader(token, session, chunkIndex, plaintextLength) {
257
+ requireEncryption(token);
258
+ const chunkSize = framedChunkSize(session);
259
+ const expectedLength = expectedFramedChunkLength(session, chunkIndex);
260
+ if (plaintextLength !== expectedLength) {
261
+ throw connectError("invalid_request", "The file chunk has the wrong byte length.");
262
+ }
263
+ return {
264
+ protocol_version: FILE_TRANSFER_PROTOCOL_VERSION,
265
+ protection: "grant_aead_v1",
266
+ grant_id: token.grantId,
267
+ authority_id: token.encryption.connector_id,
268
+ collection_id: token.collectionId,
269
+ transfer_id: session.transfer_id,
270
+ direction: session.direction,
271
+ chunk_size: chunkSize,
272
+ chunk_index: chunkIndex,
273
+ offset: chunkIndex * chunkSize,
274
+ plaintext_length: plaintextLength,
275
+ total_size: session.total_size,
276
+ scope_epoch: token.encryption.scope_epoch,
277
+ key_id: token.encryption.key_id
278
+ };
279
+ }
280
+ function validateDownloadFrame(session, chunkIndex, encoded) {
281
+ const frame = decodeFileFrame(encoded);
282
+ const expectedLength = expectedFramedChunkLength(session, chunkIndex);
283
+ const chunkSize = framedChunkSize(session);
284
+ if (frame.kind !== "download_chunk"
285
+ || frame.header.chunk_index !== chunkIndex
286
+ || frame.header.chunk_size !== chunkSize
287
+ || frame.header.offset !== chunkIndex * chunkSize
288
+ || frame.header.plaintext_length !== expectedLength
289
+ || frame.header.total_size !== session.total_size) {
290
+ throw connectError("invalid_operation_response", "The connector returned a file frame for a different chunk.");
291
+ }
292
+ }
293
+ function throwEncryptedProblem(problem) {
294
+ throw serverConnectError(problem.code === "unknown" ? problem.server_code : problem.code, problem.message, {
295
+ details: problem.details,
296
+ operationOutcome: problem.operation_outcome ?? "rejected",
297
+ traceId: problem.trace_id
298
+ });
299
+ }
300
+ function localFileControlInput(method, path, input) {
301
+ if (method === "GET" && (path === "" || path.startsWith("?"))) {
302
+ const query = new URLSearchParams(path.startsWith("?") ? path.slice(1) : path);
303
+ const limit = query.get("limit");
304
+ return {
305
+ protocol_version: FILE_PROTOCOL_VERSION,
306
+ type: "list_files",
307
+ ...(query.get("folder") ? { folder: query.get("folder") } : {}),
308
+ ...(query.get("after") ? { after: query.get("after") } : {}),
309
+ ...(limit ? { limit: Number(limit) } : {})
310
+ };
311
+ }
312
+ const segments = path.split("/");
313
+ if (method === "GET" && segments.length === 2 && segments[0] === "transfers") {
314
+ return transferControl("get_file_transfer_status", segments[1]);
315
+ }
316
+ if (method === "DELETE" && segments.length === 2 && segments[0] === "transfers") {
317
+ return transferControl("abort_file_transfer", segments[1]);
318
+ }
319
+ if (method === "POST" && isRecord(input)) {
320
+ const expectedType = path === "uploads"
321
+ ? "open_file_upload"
322
+ : path === "downloads"
323
+ ? "open_file_download"
324
+ : segments.length === 3 && segments[0] === "uploads" && segments[2] === "commit"
325
+ ? "commit_file_upload"
326
+ : segments.length === 2 && segments[1] === "move"
327
+ && input.file_id === decodeURIComponent(segments[0])
328
+ ? "move_file"
329
+ : segments.length === 2 && segments[1] === "delete"
330
+ && input.file_id === decodeURIComponent(segments[0])
331
+ ? "delete_file"
332
+ : null;
333
+ if (expectedType && input.type === expectedType)
334
+ return { ...input };
335
+ }
336
+ throw connectError("invalid_request", "The file control request path is invalid.");
337
+ }
338
+ function transferControl(type, encodedId) {
339
+ return {
340
+ protocol_version: FILE_PROTOCOL_VERSION,
341
+ type,
342
+ transfer_id: decodeURIComponent(encodedId)
343
+ };
344
+ }
345
+ function framedChunkSize(session) {
346
+ if (session.strategy.kind !== "framed_chunks"
347
+ || !Number.isSafeInteger(session.strategy.chunk_size)
348
+ || session.strategy.chunk_size <= 0) {
349
+ throw connectError("invalid_operation_response", "The connector returned an invalid framed transfer strategy.");
350
+ }
351
+ return session.strategy.chunk_size;
352
+ }
353
+ function expectedFramedChunkLength(session, chunkIndex) {
354
+ const chunkSize = framedChunkSize(session);
355
+ if (!Number.isSafeInteger(chunkIndex) || chunkIndex < 0
356
+ || !Number.isSafeInteger(session.total_size) || session.total_size < 0) {
357
+ throw connectError("invalid_request", "The file chunk index is invalid.");
358
+ }
359
+ const offset = chunkIndex * chunkSize;
360
+ if (!Number.isSafeInteger(offset) || offset >= session.total_size) {
361
+ throw connectError("invalid_request", "The file chunk index is outside the transfer.");
362
+ }
363
+ return Math.min(chunkSize, session.total_size - offset);
364
+ }
365
+ function isRecord(value) {
366
+ return value !== null && typeof value === "object" && !Array.isArray(value);
367
+ }
@@ -1,7 +1,7 @@
1
- import { MdbaseConnectError } from "./errors.js";
1
+ import { connectError } from "./errors.js";
2
2
  export function parseMdbasePushPayload(value) {
3
3
  if (!value || typeof value !== "object") {
4
- throw new MdbaseConnectError("invalid_push_payload", "The push payload is not an object.");
4
+ throw connectError("invalid_push_payload", "The push payload is not an object.");
5
5
  }
6
6
  const payload = value;
7
7
  if (payload.type !== "mdbase.notification"
@@ -11,14 +11,14 @@ export function parseMdbasePushPayload(value) {
11
11
  || typeof payload.cursor !== "string"
12
12
  || !payload.presentation
13
13
  || typeof payload.presentation.title !== "string") {
14
- throw new MdbaseConnectError("invalid_push_payload", "The push payload is not an mdbase notification.");
14
+ throw connectError("invalid_push_payload", "The push payload is not an mdbase notification.");
15
15
  }
16
16
  return payload;
17
17
  }
18
18
  /** Parse the string-valued data attached to an APNs/FCM notification. */
19
19
  export function parseMdbaseNativeNotificationData(value) {
20
20
  if (!value || typeof value !== "object") {
21
- throw new MdbaseConnectError("invalid_push_payload", "The native notification data is not an object.");
21
+ throw connectError("invalid_push_payload", "The native notification data is not an object.");
22
22
  }
23
23
  const data = value;
24
24
  if (data.type !== "mdbase.notification"
@@ -26,7 +26,7 @@ export function parseMdbaseNativeNotificationData(value) {
26
26
  || typeof data.signal_id !== "string"
27
27
  || typeof data.criterion_id !== "string"
28
28
  || typeof data.cursor !== "string") {
29
- throw new MdbaseConnectError("invalid_push_payload", "The native notification data is not an mdbase notification.");
29
+ throw connectError("invalid_push_payload", "The native notification data is not an mdbase notification.");
30
30
  }
31
31
  return {
32
32
  type: "mdbase.notification",
@@ -12,7 +12,7 @@ export declare function isMutation(operation: CollectionOperation, input?: unkno
12
12
  export declare function uniqueOperations(operations: CollectionOperation[]): CollectionOperation[];
13
13
  export declare function sameAuthorization(left: StoredToken, right: StoredToken): boolean;
14
14
  export declare function throwIfCancelled(signal?: AbortSignal): void;
15
- export declare function operationTransportError(error: unknown, signal: AbortSignal | undefined, outcomeUnknown: boolean): Error;
15
+ export declare function operationTransportError(error: unknown, signal: AbortSignal | undefined, outcomeUnknown: boolean, unavailableCode: "hosted_provider_unavailable" | "relay_unavailable"): Error;
16
16
  export declare function isCancellation(error: unknown, signal?: AbortSignal): boolean;
17
17
  export declare function assertRenamePreview(input: RenameInput, preview: RenamePreflightResult): void;
18
18
  export declare function assertDeletePreview(input: DeleteInput, preview: DeletePreflightResult): void;
@@ -21,4 +21,4 @@ export declare function deleteEstimate(preview: DeletePreflightResult): Mutation
21
21
  export declare function operationFingerprint(operation: CollectionOperation, input: unknown): Promise<string>;
22
22
  export declare function canonicalJson(value: unknown): string;
23
23
  export declare function sortJson(value: unknown): unknown;
24
- export declare function uncertainDirectMutation(cause: unknown): MdbaseConnectError;
24
+ export declare function unknownMutationOutcome(cause: unknown): MdbaseConnectError;
@@ -1,4 +1,4 @@
1
- import { MdbaseConnectError } from "./errors.js";
1
+ import { MdbaseConnectError, connectError } from "./errors.js";
2
2
  import { bytesToBase64Url } from "./base64.js";
3
3
  export function loopbackRequest(init) {
4
4
  return { ...init, credentials: "omit", targetAddressSpace: "loopback" };
@@ -53,22 +53,26 @@ export function sameAuthorization(left, right) {
53
53
  export function throwIfCancelled(signal) {
54
54
  if (!signal?.aborted)
55
55
  return;
56
- throw new MdbaseConnectError("operation_cancelled", "The operation was cancelled before it changed the collection.", { recovery: "none", cause: signal.reason });
57
- }
58
- export function operationTransportError(error, signal, outcomeUnknown) {
56
+ throw connectError("operation_cancelled", "The operation was cancelled before it changed the collection.", { operationOutcome: "not_sent", cause: signal.reason });
57
+ }
58
+ export function operationTransportError(error, signal, outcomeUnknown, unavailableCode) {
59
+ // Problems raised before transport dispatch already describe the
60
+ // authoritative outcome. Do not overwrite them merely because an older
61
+ // pending mutation also exists in storage.
62
+ if (error instanceof MdbaseConnectError)
63
+ return error;
59
64
  if (signal?.aborted) {
60
- return new MdbaseConnectError("operation_cancelled", outcomeUnknown
61
- ? "Waiting was cancelled after the mutation was sent. Resume the pending mutation to recover its authoritative result."
62
- : "The operation was cancelled before it changed the collection.", {
63
- outcomeUnknown,
64
- recovery: outcomeUnknown ? "resolve_outcome" : "none",
65
- cause: error
66
- });
65
+ if (outcomeUnknown)
66
+ return unknownMutationOutcome(error);
67
+ return connectError("operation_cancelled", "The operation was cancelled before it changed the collection.", { operationOutcome: "not_sent", cause: error });
67
68
  }
68
69
  if (outcomeUnknown) {
69
- if (error instanceof MdbaseConnectError && error.outcomeUnknown)
70
- return error;
71
- return uncertainDirectMutation(error);
70
+ return unknownMutationOutcome(error);
71
+ }
72
+ if (error instanceof TypeError) {
73
+ return connectError(unavailableCode, unavailableCode === "hosted_provider_unavailable"
74
+ ? "The hosted collection provider is unavailable."
75
+ : "The Connect relay is unavailable.", { cause: error });
72
76
  }
73
77
  return error instanceof Error ? error : new Error(String(error));
74
78
  }
@@ -79,12 +83,12 @@ export function isCancellation(error, signal) {
79
83
  export function assertRenamePreview(input, preview) {
80
84
  if (preview.dry_run !== true || preview.would_rename !== true
81
85
  || preview.from !== input.from || preview.to !== input.to) {
82
- throw new MdbaseConnectError("invalid_preflight", "The rename preview does not match this mutation. Run the preview again.", { recovery: "fix_request" });
86
+ throw connectError("invalid_preflight", "The rename preview does not match this mutation. Run the preview again.");
83
87
  }
84
88
  }
85
89
  export function assertDeletePreview(input, preview) {
86
90
  if (preview.dry_run !== true || preview.would_delete !== true || preview.path !== input.path) {
87
- throw new MdbaseConnectError("invalid_preflight", "The delete preview does not match this mutation. Run the preview again.", { recovery: "fix_request" });
91
+ throw connectError("invalid_preflight", "The delete preview does not match this mutation. Run the preview again.");
88
92
  }
89
93
  }
90
94
  export function renameEstimate(input, preview) {
@@ -124,6 +128,6 @@ export function sortJson(value) {
124
128
  }
125
129
  return value;
126
130
  }
127
- export function uncertainDirectMutation(cause) {
128
- return new MdbaseConnectError("direct_outcome_unknown", "The direct write may have completed, and mdbase could not recover its receipt through the relay. Retry the exact same write to recover safely.", { cause });
131
+ export function unknownMutationOutcome(cause) {
132
+ return connectError("operation_outcome_unknown", "The write may have completed, but mdbase could not recover its authoritative result. Retry the exact same write to recover safely.", { operationOutcome: "unknown", cause });
129
133
  }