@mdbase-dev/connect 0.1.0-beta.21 → 0.1.0-beta.23
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/README.md +108 -38
- package/dist/authorization-url.d.ts +2 -1
- package/dist/authorization-url.js +5 -7
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +3 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/collection-client.d.ts +34 -31
- package/dist/collection-client.js +107 -61
- package/dist/connect.d.ts +4 -3
- package/dist/connect.js +53 -38
- package/dist/connection-file-transport.d.ts +31 -0
- package/dist/connection-file-transport.js +42 -0
- package/dist/connection-notifications.js +21 -21
- package/dist/connection-transport.d.ts +2 -0
- package/dist/connection-transport.js +95 -37
- package/dist/connection-types.d.ts +2 -1
- package/dist/connection.d.ts +49 -45
- package/dist/connection.js +73 -33
- package/dist/crypto.d.ts +5 -6
- package/dist/crypto.js +13 -13
- package/dist/errors.d.ts +38 -24
- package/dist/errors.js +130 -71
- package/dist/file-crypto.d.ts +25 -0
- package/dist/file-crypto.js +132 -0
- package/dist/file-sha256.d.ts +12 -0
- package/dist/file-sha256.js +143 -0
- package/dist/file-stream-source.d.ts +17 -0
- package/dist/file-stream-source.js +157 -0
- package/dist/files.d.ts +91 -0
- package/dist/files.js +644 -0
- package/dist/hosted-file-request.d.ts +6 -0
- package/dist/hosted-file-request.js +63 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/internal-types.d.ts +3 -1
- package/dist/local-file-transport.d.ts +30 -0
- package/dist/local-file-transport.js +367 -0
- package/dist/notifications.js +5 -5
- package/dist/operation-helpers.d.ts +2 -2
- package/dist/operation-helpers.js +22 -18
- package/dist/operation-types.d.ts +3 -4
- package/dist/outcomes.d.ts +51 -0
- package/dist/outcomes.js +168 -0
- package/dist/runtime-utils.d.ts +6 -1
- package/dist/runtime-utils.js +90 -11
- package/dist/selection.js +3 -3
- package/dist/session.d.ts +9 -8
- package/dist/session.js +31 -20
- 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";
|
package/dist/internal-types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/notifications.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { connectError } from "./errors.js";
|
|
2
2
|
export function parseMdbasePushPayload(value) {
|
|
3
3
|
if (!value || typeof value !== "object") {
|
|
4
|
-
throw
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
|
128
|
-
return
|
|
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
|
}
|