@mdbase-dev/connect 0.1.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +696 -0
- package/dist/advanced.d.ts +9 -0
- package/dist/advanced.js +6 -0
- package/dist/application-contract.d.ts +22 -0
- package/dist/application-contract.js +75 -0
- package/dist/application-identity.d.ts +34 -0
- package/dist/application-identity.js +148 -0
- package/dist/application-session.d.ts +167 -0
- package/dist/application-session.js +722 -0
- package/dist/async.d.ts +1 -0
- package/dist/async.js +19 -0
- package/dist/authority-proof.d.ts +3 -0
- package/dist/authority-proof.js +24 -0
- package/dist/authority-sync-request.d.ts +3 -0
- package/dist/authority-sync-request.js +20 -0
- package/dist/authorization-refresh.d.ts +14 -0
- package/dist/authorization-refresh.js +41 -0
- package/dist/authorization-types.d.ts +7 -0
- package/dist/authorization-types.js +1 -0
- package/dist/authorization-url.d.ts +5 -0
- package/dist/authorization-url.js +34 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +21 -0
- package/dist/browser/integrity.json +4 -0
- package/dist/browser/mdbase-connect.min.js +3 -0
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +5 -0
- package/dist/capabilities.d.ts +24 -0
- package/dist/capabilities.js +91 -0
- package/dist/collection-client.d.ts +66 -0
- package/dist/collection-client.js +686 -0
- package/dist/connect-authorization-helpers.d.ts +4 -0
- package/dist/connect-authorization-helpers.js +25 -0
- package/dist/connect-options.d.ts +35 -0
- package/dist/connect-options.js +1 -0
- package/dist/connect.d.ts +66 -0
- package/dist/connect.js +711 -0
- package/dist/connection-file-transport.d.ts +34 -0
- package/dist/connection-file-transport.js +85 -0
- package/dist/connection-index.d.ts +3 -0
- package/dist/connection-index.js +20 -0
- package/dist/connection-notifications.d.ts +31 -0
- package/dist/connection-notifications.js +197 -0
- package/dist/connection-transport-internals.d.ts +24 -0
- package/dist/connection-transport-internals.js +1 -0
- package/dist/connection-transport.d.ts +64 -0
- package/dist/connection-transport.js +725 -0
- package/dist/connection-types.d.ts +38 -0
- package/dist/connection-types.js +1 -0
- package/dist/connection.d.ts +175 -0
- package/dist/connection.js +606 -0
- package/dist/crypto-entry.d.ts +7 -0
- package/dist/crypto-entry.js +4 -0
- package/dist/crypto.d.ts +63 -0
- package/dist/crypto.js +409 -0
- package/dist/direct-access.d.ts +3 -0
- package/dist/direct-access.js +29 -0
- package/dist/errors.d.ts +45 -0
- package/dist/errors.js +142 -0
- package/dist/external-store.d.ts +10 -0
- package/dist/external-store.js +10 -0
- 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/file-transfer-internals.d.ts +23 -0
- package/dist/file-transfer-internals.js +228 -0
- package/dist/files.d.ts +115 -0
- package/dist/files.js +563 -0
- package/dist/fresh-encrypted-request.d.ts +4 -0
- package/dist/fresh-encrypted-request.js +28 -0
- package/dist/grant-key-leases.d.ts +24 -0
- package/dist/grant-key-leases.js +144 -0
- package/dist/hosted-file-request.d.ts +6 -0
- package/dist/hosted-file-request.js +63 -0
- package/dist/hosted-operation-response.d.ts +5 -0
- package/dist/hosted-operation-response.js +26 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +10 -0
- package/dist/internal-types.d.ts +100 -0
- package/dist/internal-types.js +1 -0
- package/dist/local-file-transport.d.ts +37 -0
- package/dist/local-file-transport.js +457 -0
- package/dist/mdbase-connect.d.ts +28 -0
- package/dist/mdbase-connect.js +53 -0
- package/dist/notifications.d.ts +48 -0
- package/dist/notifications.js +52 -0
- package/dist/operation-helpers.d.ts +27 -0
- package/dist/operation-helpers.js +144 -0
- package/dist/operation-types.d.ts +707 -0
- package/dist/operation-types.js +1 -0
- package/dist/outcomes.d.ts +51 -0
- package/dist/outcomes.js +194 -0
- package/dist/pending-mutation-store.d.ts +13 -0
- package/dist/pending-mutation-store.js +64 -0
- package/dist/pending-recovery-transports.d.ts +5 -0
- package/dist/pending-recovery-transports.js +52 -0
- package/dist/popup-authorization.d.ts +31 -0
- package/dist/popup-authorization.js +100 -0
- package/dist/query-pagination.d.ts +6 -0
- package/dist/query-pagination.js +119 -0
- package/dist/request-budget.d.ts +34 -0
- package/dist/request-budget.js +104 -0
- package/dist/request-coordinator.d.ts +29 -0
- package/dist/request-coordinator.js +213 -0
- package/dist/retired-grant-keys.d.ts +9 -0
- package/dist/retired-grant-keys.js +56 -0
- package/dist/runtime-utils.d.ts +52 -0
- package/dist/runtime-utils.js +368 -0
- package/dist/saved-view-pagination.d.ts +6 -0
- package/dist/saved-view-pagination.js +76 -0
- package/dist/selection.d.ts +48 -0
- package/dist/selection.js +112 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +569 -0
- package/dist/shared-operation.d.ts +7 -0
- package/dist/shared-operation.js +41 -0
- package/dist/stored-token.d.ts +10 -0
- package/dist/stored-token.js +64 -0
- package/dist/structured-readiness.d.ts +77 -0
- package/dist/structured-readiness.js +126 -0
- package/dist/token-response.d.ts +12 -0
- package/dist/token-response.js +81 -0
- package/dist/transient-retry.d.ts +22 -0
- package/dist/transient-retry.js +59 -0
- package/dist/watch-policy.d.ts +8 -0
- package/dist/watch-policy.js +12 -0
- package/package.json +51 -0
package/dist/files.js
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { FILE_PROTOCOL_VERSION } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { abortableDelay } from "./async.js";
|
|
3
|
+
import { MdbaseConnectError, connectError } from "./errors.js";
|
|
4
|
+
import { IncrementalSha256 } from "./file-sha256.js";
|
|
5
|
+
import { BinaryPartReader } from "./file-stream-source.js";
|
|
6
|
+
import { MAX_BUFFERED_DOWNLOAD_BYTES, MAX_OBJECT_ATTEMPTS, SHA256_DIGEST, UUID, browserObjectHeaders, chunkLength, hashBlob, mapConcurrent, normalizeFileError, requirePreparedPart, requireTransferSession, requireTransferStatus, retryChunk, sourceBlob, throwIfAborted, uploadBodyBytes, uploadBodyLength, validConcurrency, validPageSize } from "./file-transfer-internals.js";
|
|
7
|
+
import { createRequestBudget, resolveConnectTimeouts, withCooperativeRequestBudget } from "./request-budget.js";
|
|
8
|
+
/** Ergonomic facade over resumable, authority-specific file delivery. */
|
|
9
|
+
export class MdbaseFileClient {
|
|
10
|
+
capability;
|
|
11
|
+
request;
|
|
12
|
+
framed;
|
|
13
|
+
hosted;
|
|
14
|
+
timeouts;
|
|
15
|
+
constructor(capability, request, framed, hosted, timeouts = resolveConnectTimeouts()) {
|
|
16
|
+
this.capability = capability;
|
|
17
|
+
this.request = request;
|
|
18
|
+
this.framed = framed;
|
|
19
|
+
this.hosted = hosted;
|
|
20
|
+
this.timeouts = timeouts;
|
|
21
|
+
}
|
|
22
|
+
async *list(options = {}) {
|
|
23
|
+
const budget = createRequestBudget(options, this.timeouts.fileIndexMs);
|
|
24
|
+
const signal = budget.signal;
|
|
25
|
+
try {
|
|
26
|
+
this.requireAction("list");
|
|
27
|
+
let after;
|
|
28
|
+
do {
|
|
29
|
+
throwIfAborted(signal);
|
|
30
|
+
const query = new URLSearchParams({
|
|
31
|
+
protocol_version: String(FILE_PROTOCOL_VERSION),
|
|
32
|
+
...(options.folder ? { folder: options.folder } : {}),
|
|
33
|
+
...(after ? { after } : {}),
|
|
34
|
+
...(options.pageSize ? { limit: String(validPageSize(options.pageSize)) } : {})
|
|
35
|
+
});
|
|
36
|
+
let page;
|
|
37
|
+
while (true) {
|
|
38
|
+
try {
|
|
39
|
+
page = await this.request("GET", `?${query.toString()}`, undefined, signal);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
const normalized = normalizeFileError(error);
|
|
44
|
+
if (normalized.code !== "file_index_warming")
|
|
45
|
+
throw normalized;
|
|
46
|
+
options.onIndexing?.();
|
|
47
|
+
await abortableDelay(500, signal);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (page.protocol_version !== 1 || page.type !== "files_page" || !Array.isArray(page.files)) {
|
|
51
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file page.");
|
|
52
|
+
}
|
|
53
|
+
for (const file of page.files) {
|
|
54
|
+
throwIfAborted(signal);
|
|
55
|
+
yield clientFileDescriptor(file);
|
|
56
|
+
}
|
|
57
|
+
after = page.next;
|
|
58
|
+
} while (after);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
budget.dispose();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async upload(path, source, options = {}) {
|
|
65
|
+
return withCooperativeRequestBudget(options, this.timeouts.uploadMs, (budget) => this.uploadWithinBudget(path, source, { ...options, signal: budget.signal, timeoutMs: null }));
|
|
66
|
+
}
|
|
67
|
+
async uploadWithinBudget(path, source, options) {
|
|
68
|
+
this.requireAction(options.ifRevision ? "replace" : "add");
|
|
69
|
+
const blob = sourceBlob(source, options.mediaType);
|
|
70
|
+
const concurrency = validConcurrency(options.concurrency);
|
|
71
|
+
const digest = await hashBlob(blob, options.signal, (transferredBytes) => {
|
|
72
|
+
options.onProgress?.({
|
|
73
|
+
phase: "hashing",
|
|
74
|
+
transferredBytes,
|
|
75
|
+
totalBytes: blob.size
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return this.uploadKnownSource(path, blob.size, `sha256:${digest}`, (options.mediaType ?? blob.type) || undefined, options, concurrency, async (_partIndex, offset, length) => blob.slice(offset, offset + length));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Upload a one-shot byte stream without accumulating multiple file parts.
|
|
82
|
+
* SDK memory is bounded by one assembled negotiated part plus at most one
|
|
83
|
+
* source chunk; a single-part upload may therefore hold the complete file.
|
|
84
|
+
* Reuse `transferId` with a newly opened source after an ambiguous failure.
|
|
85
|
+
*/
|
|
86
|
+
async uploadStream(path, source, options = {}) {
|
|
87
|
+
return withCooperativeRequestBudget(options, this.timeouts.uploadMs, (budget) => this.uploadStreamWithinBudget(path, source, {
|
|
88
|
+
...options,
|
|
89
|
+
signal: budget.signal,
|
|
90
|
+
timeoutMs: null
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
async uploadStreamWithinBudget(path, source, options) {
|
|
94
|
+
this.requireAction(options.ifRevision ? "replace" : "add");
|
|
95
|
+
if (!Number.isSafeInteger(source.size) || source.size < 0) {
|
|
96
|
+
throw connectError("invalid_request", "Streamed file size must be a non-negative safe integer.");
|
|
97
|
+
}
|
|
98
|
+
if (!SHA256_DIGEST.test(source.contentDigest)) {
|
|
99
|
+
throw connectError("invalid_request", "Streamed files require a lowercase SHA-256 content digest.");
|
|
100
|
+
}
|
|
101
|
+
const reader = new BinaryPartReader(source.stream, options.signal);
|
|
102
|
+
const hash = new IncrementalSha256();
|
|
103
|
+
return this.uploadKnownSource(path, source.size, source.contentDigest, options.mediaType ?? source.mediaType, options, 1, async (_partIndex, _offset, length) => {
|
|
104
|
+
const bytes = await reader.read(length);
|
|
105
|
+
hash.update(bytes);
|
|
106
|
+
return bytes;
|
|
107
|
+
}, async () => {
|
|
108
|
+
await reader.expectEnd();
|
|
109
|
+
if (`sha256:${hash.digestHex()}` !== source.contentDigest) {
|
|
110
|
+
throw connectError("invalid_request", "Streamed file bytes do not match the declared SHA-256 digest.");
|
|
111
|
+
}
|
|
112
|
+
}, () => reader.close(), (partSize) => reader.setMaxSourceChunkBytes(partSize));
|
|
113
|
+
}
|
|
114
|
+
async uploadKnownSource(path, size, contentDigest, mediaType, options, concurrency, readPart, finishSource, closeSource, prepareSource) {
|
|
115
|
+
const transferId = options.transferId ?? crypto.randomUUID();
|
|
116
|
+
if (!UUID.test(transferId)) {
|
|
117
|
+
throw connectError("invalid_request", "File transfer ID must be a UUID.");
|
|
118
|
+
}
|
|
119
|
+
let committed = false;
|
|
120
|
+
try {
|
|
121
|
+
const session = await this.request("POST", "uploads", {
|
|
122
|
+
protocol_version: FILE_PROTOCOL_VERSION,
|
|
123
|
+
type: "open_file_upload",
|
|
124
|
+
transfer_id: transferId,
|
|
125
|
+
path,
|
|
126
|
+
size,
|
|
127
|
+
content_digest: contentDigest,
|
|
128
|
+
...(mediaType ? { media_type: mediaType } : {}),
|
|
129
|
+
...(options.ifRevision ? { if_revision: options.ifRevision } : {})
|
|
130
|
+
}, options.signal);
|
|
131
|
+
requireTransferSession(session, transferId, "upload");
|
|
132
|
+
if (session.total_size !== size || session.strategy.kind === "object_ranges") {
|
|
133
|
+
throw connectError("invalid_operation_response", "The authority returned an incompatible upload strategy.");
|
|
134
|
+
}
|
|
135
|
+
const framed = session.strategy.kind === "framed_chunks";
|
|
136
|
+
if (framed && !this.framed) {
|
|
137
|
+
throw connectError("invalid_operation_response", "Encrypted file chunk delivery is unavailable.");
|
|
138
|
+
}
|
|
139
|
+
const partSize = session.strategy.kind === "object_put"
|
|
140
|
+
? Math.max(1, size)
|
|
141
|
+
: session.strategy.kind === "framed_chunks"
|
|
142
|
+
? session.strategy.chunk_size
|
|
143
|
+
: session.strategy.part_size;
|
|
144
|
+
const partCount = session.strategy.kind === "object_put"
|
|
145
|
+
? 1
|
|
146
|
+
: Math.ceil(size / partSize);
|
|
147
|
+
const status = await this.request("GET", `transfers/${encodeURIComponent(transferId)}`, undefined, options.signal);
|
|
148
|
+
requireTransferStatus(status, session);
|
|
149
|
+
if (status.state !== "open" && status.state !== "committed") {
|
|
150
|
+
throw connectError("invalid_operation_response", `The authority cannot resume a ${status.state} file transfer.`);
|
|
151
|
+
}
|
|
152
|
+
prepareSource?.(partSize);
|
|
153
|
+
if (!framed && status.received.length === partCount) {
|
|
154
|
+
const replay = await this.tryReplayUploadCommit(transferId, session.strategy.kind === "object_multipart" ? status.uploaded_parts : undefined, options.signal);
|
|
155
|
+
if (replay) {
|
|
156
|
+
committed = true;
|
|
157
|
+
return clientFileDescriptor(replay.file);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const received = new Set(status.received);
|
|
161
|
+
const uploadedParts = new Map(status.uploaded_parts.map((part) => [part.part_number - 1, part]));
|
|
162
|
+
let transferredBytes = [...received].reduce((total, index) => total + chunkLength(size, partSize, index), 0);
|
|
163
|
+
const parts = await mapConcurrent(partCount, concurrency, async (partIndex) => {
|
|
164
|
+
const offset = partIndex * partSize;
|
|
165
|
+
const length = Math.min(partSize, Math.max(0, size - offset));
|
|
166
|
+
const part = await readPart(partIndex, offset, length);
|
|
167
|
+
if (uploadBodyLength(part) !== length) {
|
|
168
|
+
throw connectError("invalid_request", "Streamed file bytes ended before the declared size.");
|
|
169
|
+
}
|
|
170
|
+
if (received.has(partIndex))
|
|
171
|
+
return uploadedParts.get(partIndex) ?? null;
|
|
172
|
+
const uploaded = framed
|
|
173
|
+
? await retryChunk(async () => this.framed.uploadChunk(session, partIndex, await uploadBodyBytes(part), options.signal), options.signal).then(() => null)
|
|
174
|
+
: await this.uploadPart(transferId, partIndex, offset, part, length, session.strategy.kind === "object_multipart", options.signal);
|
|
175
|
+
transferredBytes += length;
|
|
176
|
+
options.onProgress?.({
|
|
177
|
+
phase: "uploading",
|
|
178
|
+
transferredBytes,
|
|
179
|
+
totalBytes: size
|
|
180
|
+
});
|
|
181
|
+
return uploaded;
|
|
182
|
+
});
|
|
183
|
+
await finishSource?.();
|
|
184
|
+
const receipt = await this.commitUpload(transferId, session.strategy.kind === "object_multipart"
|
|
185
|
+
? parts.filter((part) => part !== null)
|
|
186
|
+
: undefined, options.signal);
|
|
187
|
+
if (receipt.protocol_version !== 1
|
|
188
|
+
|| receipt.type !== "file_upload_committed"
|
|
189
|
+
|| receipt.transfer_id !== transferId
|
|
190
|
+
|| receipt.file.path !== path
|
|
191
|
+
|| receipt.file.size !== size
|
|
192
|
+
|| receipt.file.content_digest !== contentDigest) {
|
|
193
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file receipt.");
|
|
194
|
+
}
|
|
195
|
+
committed = true;
|
|
196
|
+
return clientFileDescriptor(receipt.file);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
throw normalizeFileError(error);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await closeSource?.().catch(() => undefined);
|
|
203
|
+
if (!committed && options.transferId === undefined)
|
|
204
|
+
await this.abort(transferId);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async download(file, options = {}) {
|
|
208
|
+
if (file.size > MAX_BUFFERED_DOWNLOAD_BYTES) {
|
|
209
|
+
throw connectError("invalid_request", `download() buffers at most ${MAX_BUFFERED_DOWNLOAD_BYTES} bytes; use downloadStream() for larger files.`);
|
|
210
|
+
}
|
|
211
|
+
const stream = await this.downloadStream(file, options);
|
|
212
|
+
const chunks = [];
|
|
213
|
+
const reader = stream.getReader();
|
|
214
|
+
while (true) {
|
|
215
|
+
const result = await reader.read();
|
|
216
|
+
if (result.done)
|
|
217
|
+
break;
|
|
218
|
+
chunks.push(result.value);
|
|
219
|
+
}
|
|
220
|
+
return new Blob(chunks.map((chunk) => {
|
|
221
|
+
const copy = new Uint8Array(chunk.byteLength);
|
|
222
|
+
copy.set(chunk);
|
|
223
|
+
return copy.buffer;
|
|
224
|
+
}), { type: file.mediaType ?? "" });
|
|
225
|
+
}
|
|
226
|
+
/** Download and verify a file with network backpressure and bounded buffering. */
|
|
227
|
+
async downloadStream(file, options = {}) {
|
|
228
|
+
return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.openDownloadStream(file, options, budget.signal));
|
|
229
|
+
}
|
|
230
|
+
async openDownloadStream(file, options, startupSignal) {
|
|
231
|
+
this.requireAction("read");
|
|
232
|
+
const concurrency = validConcurrency(options.concurrency);
|
|
233
|
+
const transferId = crypto.randomUUID();
|
|
234
|
+
let session;
|
|
235
|
+
try {
|
|
236
|
+
session = await this.request("POST", "downloads", {
|
|
237
|
+
protocol_version: FILE_PROTOCOL_VERSION,
|
|
238
|
+
type: "open_file_download",
|
|
239
|
+
transfer_id: transferId,
|
|
240
|
+
file_id: file.fileId,
|
|
241
|
+
revision: file.revision
|
|
242
|
+
}, startupSignal);
|
|
243
|
+
requireTransferSession(session, transferId, "download");
|
|
244
|
+
if (session.total_size !== file.size
|
|
245
|
+
|| session.strategy.kind !== "object_ranges"
|
|
246
|
+
&& session.strategy.kind !== "framed_chunks") {
|
|
247
|
+
throw connectError("invalid_operation_response", "The authority returned an incompatible download session.");
|
|
248
|
+
}
|
|
249
|
+
if (session.strategy.kind === "framed_chunks" && !this.framed) {
|
|
250
|
+
throw connectError("invalid_operation_response", "Encrypted file chunk delivery is unavailable.");
|
|
251
|
+
}
|
|
252
|
+
if (session.strategy.kind === "object_ranges" && file.size > 0 && !this.hosted) {
|
|
253
|
+
throw connectError("invalid_operation_response", "Authenticated hosted file delivery is unavailable.");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
await this.abort(transferId);
|
|
258
|
+
throw normalizeFileError(error);
|
|
259
|
+
}
|
|
260
|
+
const partSize = session.strategy.kind === "framed_chunks"
|
|
261
|
+
? session.strategy.chunk_size
|
|
262
|
+
: session.strategy.part_size;
|
|
263
|
+
const partCount = Math.ceil(file.size / partSize);
|
|
264
|
+
const hash = new IncrementalSha256();
|
|
265
|
+
let partIndex = 0;
|
|
266
|
+
let partRemaining = 0;
|
|
267
|
+
let hostedReader = null;
|
|
268
|
+
let receivedBytes = 0;
|
|
269
|
+
let closed = false;
|
|
270
|
+
const deliveryController = new AbortController();
|
|
271
|
+
const deliverySignal = options.signal
|
|
272
|
+
? AbortSignal.any([options.signal, deliveryController.signal])
|
|
273
|
+
: deliveryController.signal;
|
|
274
|
+
const framedChunks = new Map();
|
|
275
|
+
let nextFramedPart = 0;
|
|
276
|
+
const queueFramedChunks = () => {
|
|
277
|
+
if (session.strategy.kind !== "framed_chunks")
|
|
278
|
+
return;
|
|
279
|
+
while (nextFramedPart < partCount && framedChunks.size < concurrency) {
|
|
280
|
+
const index = nextFramedPart;
|
|
281
|
+
nextFramedPart += 1;
|
|
282
|
+
const pending = retryChunk(() => this.framed.downloadChunk(session, index, deliverySignal), deliverySignal).then((chunk) => ({ ok: true, chunk }), (error) => ({ ok: false, error }));
|
|
283
|
+
framedChunks.set(index, pending);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const finish = async () => {
|
|
287
|
+
if (closed)
|
|
288
|
+
return;
|
|
289
|
+
closed = true;
|
|
290
|
+
deliveryController.abort();
|
|
291
|
+
await hostedReader?.cancel().catch(() => undefined);
|
|
292
|
+
hostedReader = null;
|
|
293
|
+
await Promise.all(framedChunks.values());
|
|
294
|
+
framedChunks.clear();
|
|
295
|
+
await this.abort(transferId);
|
|
296
|
+
};
|
|
297
|
+
const verify = () => {
|
|
298
|
+
if (receivedBytes !== file.size || `sha256:${hash.digestHex()}` !== file.contentDigest) {
|
|
299
|
+
throw connectError("invalid_operation_response", "Downloaded file bytes failed integrity verification.");
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
return new ReadableStream({
|
|
303
|
+
pull: async (controller) => {
|
|
304
|
+
try {
|
|
305
|
+
throwIfAborted(deliverySignal);
|
|
306
|
+
if (session.strategy.kind === "object_ranges") {
|
|
307
|
+
while (true) {
|
|
308
|
+
if (!hostedReader) {
|
|
309
|
+
if (partIndex >= partCount) {
|
|
310
|
+
verify();
|
|
311
|
+
controller.close();
|
|
312
|
+
void finish();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const offset = partIndex * partSize;
|
|
316
|
+
partRemaining = Math.min(partSize, file.size - offset);
|
|
317
|
+
const body = await retryChunk(() => this.hosted.downloadPart(session, partIndex, partRemaining, deliverySignal), deliverySignal);
|
|
318
|
+
hostedReader = body.getReader();
|
|
319
|
+
}
|
|
320
|
+
const next = await hostedReader.read();
|
|
321
|
+
if (next.done) {
|
|
322
|
+
hostedReader.releaseLock();
|
|
323
|
+
hostedReader = null;
|
|
324
|
+
if (partRemaining !== 0) {
|
|
325
|
+
throw connectError("invalid_operation_response", "A hosted file range ended before its declared byte length.");
|
|
326
|
+
}
|
|
327
|
+
partIndex += 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!(next.value instanceof Uint8Array)) {
|
|
331
|
+
throw connectError("invalid_operation_response", "A hosted file range returned a non-byte chunk.");
|
|
332
|
+
}
|
|
333
|
+
if (next.value.byteLength === 0)
|
|
334
|
+
continue;
|
|
335
|
+
if (next.value.byteLength > partRemaining) {
|
|
336
|
+
throw connectError("invalid_operation_response", "A hosted file range exceeded its declared byte length.");
|
|
337
|
+
}
|
|
338
|
+
partRemaining -= next.value.byteLength;
|
|
339
|
+
hash.update(next.value);
|
|
340
|
+
receivedBytes += next.value.byteLength;
|
|
341
|
+
options.onProgress?.({
|
|
342
|
+
phase: "downloading",
|
|
343
|
+
transferredBytes: receivedBytes,
|
|
344
|
+
totalBytes: file.size
|
|
345
|
+
});
|
|
346
|
+
controller.enqueue(next.value);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (partIndex >= partCount) {
|
|
351
|
+
verify();
|
|
352
|
+
controller.close();
|
|
353
|
+
void finish();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const offset = partIndex * partSize;
|
|
357
|
+
const length = Math.min(partSize, file.size - offset);
|
|
358
|
+
queueFramedChunks();
|
|
359
|
+
const pending = framedChunks.get(partIndex);
|
|
360
|
+
if (!pending) {
|
|
361
|
+
throw connectError("invalid_operation_response", "A file chunk was not scheduled.");
|
|
362
|
+
}
|
|
363
|
+
const result = await pending;
|
|
364
|
+
framedChunks.delete(partIndex);
|
|
365
|
+
if (!result.ok)
|
|
366
|
+
throw result.error;
|
|
367
|
+
const chunk = result.chunk;
|
|
368
|
+
if (chunk.byteLength !== length) {
|
|
369
|
+
throw connectError("invalid_operation_response", "A file chunk had the wrong byte length.");
|
|
370
|
+
}
|
|
371
|
+
hash.update(chunk);
|
|
372
|
+
receivedBytes += chunk.byteLength;
|
|
373
|
+
partIndex += 1;
|
|
374
|
+
queueFramedChunks();
|
|
375
|
+
options.onProgress?.({
|
|
376
|
+
phase: "downloading",
|
|
377
|
+
transferredBytes: receivedBytes,
|
|
378
|
+
totalBytes: file.size
|
|
379
|
+
});
|
|
380
|
+
if (partIndex === partCount) {
|
|
381
|
+
verify();
|
|
382
|
+
}
|
|
383
|
+
controller.enqueue(chunk);
|
|
384
|
+
if (partIndex === partCount) {
|
|
385
|
+
controller.close();
|
|
386
|
+
void finish();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
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;
|
|
396
|
+
await finish();
|
|
397
|
+
controller.error(normalizeFileError(failure));
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
cancel: finish
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async downloadBytes(file, options = {}) {
|
|
404
|
+
return new Uint8Array(await (await this.download(file, options)).arrayBuffer());
|
|
405
|
+
}
|
|
406
|
+
async move(file, path, options = {}) {
|
|
407
|
+
return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.moveWithinBudget(file, path, { ...options, signal: budget.signal, timeoutMs: null }));
|
|
408
|
+
}
|
|
409
|
+
async moveWithinBudget(file, path, options) {
|
|
410
|
+
this.requireAction("move");
|
|
411
|
+
const mutationId = options.mutationId ?? crypto.randomUUID();
|
|
412
|
+
let receipt;
|
|
413
|
+
try {
|
|
414
|
+
receipt = await this.request("POST", `${encodeURIComponent(file.fileId)}/move`, {
|
|
415
|
+
protocol_version: FILE_PROTOCOL_VERSION,
|
|
416
|
+
type: "move_file",
|
|
417
|
+
mutation_id: mutationId,
|
|
418
|
+
file_id: file.fileId,
|
|
419
|
+
if_revision: options.ifRevision ?? file.revision,
|
|
420
|
+
from_path: file.path,
|
|
421
|
+
path,
|
|
422
|
+
// Reserved for a future protocol that can update record links
|
|
423
|
+
// atomically with a file move. Protocol v1 must remain false.
|
|
424
|
+
update_references: false
|
|
425
|
+
}, options.signal);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
throw normalizeFileError(error);
|
|
429
|
+
}
|
|
430
|
+
if (receipt.protocol_version !== 1
|
|
431
|
+
|| receipt.type !== "file_moved"
|
|
432
|
+
|| receipt.mutation_id !== mutationId
|
|
433
|
+
|| receipt.file.file_id !== file.fileId
|
|
434
|
+
|| receipt.file.path !== path) {
|
|
435
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file move receipt.");
|
|
436
|
+
}
|
|
437
|
+
return clientFileDescriptor(receipt.file);
|
|
438
|
+
}
|
|
439
|
+
async delete(file, options = {}) {
|
|
440
|
+
return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.deleteWithinBudget(file, { ...options, signal: budget.signal, timeoutMs: null }));
|
|
441
|
+
}
|
|
442
|
+
async deleteWithinBudget(file, options) {
|
|
443
|
+
this.requireAction("delete");
|
|
444
|
+
const mutationId = options.mutationId ?? crypto.randomUUID();
|
|
445
|
+
let receipt;
|
|
446
|
+
try {
|
|
447
|
+
receipt = await this.request("POST", `${encodeURIComponent(file.fileId)}/delete`, {
|
|
448
|
+
protocol_version: FILE_PROTOCOL_VERSION,
|
|
449
|
+
type: "delete_file",
|
|
450
|
+
mutation_id: mutationId,
|
|
451
|
+
file_id: file.fileId,
|
|
452
|
+
if_revision: options.ifRevision ?? file.revision,
|
|
453
|
+
path: file.path
|
|
454
|
+
}, options.signal);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
throw normalizeFileError(error);
|
|
458
|
+
}
|
|
459
|
+
if (receipt.protocol_version !== 1
|
|
460
|
+
|| receipt.type !== "file_deleted"
|
|
461
|
+
|| receipt.mutation_id !== mutationId
|
|
462
|
+
|| receipt.file_id !== file.fileId
|
|
463
|
+
|| receipt.previous_path !== file.path) {
|
|
464
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file delete receipt.");
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
protocolVersion: receipt.protocol_version,
|
|
468
|
+
type: receipt.type,
|
|
469
|
+
mutationId: receipt.mutation_id,
|
|
470
|
+
fileId: receipt.file_id,
|
|
471
|
+
previousPath: receipt.previous_path,
|
|
472
|
+
revision: receipt.revision
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
async uploadPart(transferId, partIndex, offset, body, contentLength, requireEtag, signal) {
|
|
476
|
+
for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
|
|
477
|
+
throwIfAborted(signal);
|
|
478
|
+
try {
|
|
479
|
+
const prepared = await this.request("POST", `uploads/${encodeURIComponent(transferId)}/parts`, {
|
|
480
|
+
protocol_version: FILE_PROTOCOL_VERSION,
|
|
481
|
+
type: "prepare_file_upload_part",
|
|
482
|
+
transfer_id: transferId,
|
|
483
|
+
part_number: partIndex + 1,
|
|
484
|
+
content_length: contentLength
|
|
485
|
+
}, signal);
|
|
486
|
+
requirePreparedPart(prepared, transferId, partIndex, offset, contentLength, "PUT");
|
|
487
|
+
const response = await fetch(prepared.url, {
|
|
488
|
+
method: prepared.method,
|
|
489
|
+
headers: browserObjectHeaders(prepared.headers),
|
|
490
|
+
body,
|
|
491
|
+
redirect: "error",
|
|
492
|
+
signal
|
|
493
|
+
});
|
|
494
|
+
if (!response.ok)
|
|
495
|
+
throw new Error(`Object upload failed with HTTP ${response.status}.`);
|
|
496
|
+
const etag = response.headers.get("etag");
|
|
497
|
+
if (requireEtag && !etag) {
|
|
498
|
+
throw new Error("Multipart object upload did not return an ETag.");
|
|
499
|
+
}
|
|
500
|
+
return { part_number: partIndex + 1, etag: etag ?? "" };
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (signal?.aborted)
|
|
504
|
+
throw error;
|
|
505
|
+
if (attempt === MAX_OBJECT_ATTEMPTS)
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
throw new Error("Unreachable upload retry state.");
|
|
510
|
+
}
|
|
511
|
+
async tryReplayUploadCommit(transferId, parts, signal) {
|
|
512
|
+
try {
|
|
513
|
+
return await this.commitUpload(transferId, parts, signal);
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
if (error instanceof MdbaseConnectError && error.code === "file_upload_incomplete") {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
throw error;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async commitUpload(transferId, parts, signal) {
|
|
523
|
+
let lastError;
|
|
524
|
+
for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
|
|
525
|
+
throwIfAborted(signal);
|
|
526
|
+
try {
|
|
527
|
+
return await this.request("POST", `uploads/${encodeURIComponent(transferId)}/commit`, {
|
|
528
|
+
protocol_version: FILE_PROTOCOL_VERSION,
|
|
529
|
+
type: "commit_file_upload",
|
|
530
|
+
transfer_id: transferId,
|
|
531
|
+
...(parts === undefined ? {} : { parts })
|
|
532
|
+
}, signal);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
lastError = error;
|
|
536
|
+
if (signal?.aborted || error instanceof MdbaseConnectError || attempt === MAX_OBJECT_ATTEMPTS) {
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
throw lastError;
|
|
542
|
+
}
|
|
543
|
+
requireAction(action) {
|
|
544
|
+
if (!this.capability()?.actions.includes(action)) {
|
|
545
|
+
throw connectError("not_authorized", `This connection is not authorized to ${action} files.`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async abort(transferId) {
|
|
549
|
+
await this.request("DELETE", `transfers/${encodeURIComponent(transferId)}`).catch(() => undefined);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function clientFileDescriptor(file) {
|
|
553
|
+
return {
|
|
554
|
+
fileId: file.file_id,
|
|
555
|
+
path: file.path,
|
|
556
|
+
revision: file.revision,
|
|
557
|
+
contentDigest: file.content_digest,
|
|
558
|
+
size: file.size,
|
|
559
|
+
...(file.media_type ? { mediaType: file.media_type } : {}),
|
|
560
|
+
mediaClass: file.media_class,
|
|
561
|
+
modifiedAt: file.modified_at
|
|
562
|
+
};
|
|
563
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CollectionOperation, EncryptedRelayOperationRequest } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { type GrantKeyStore } from "./crypto.js";
|
|
3
|
+
import type { StoredToken } from "./internal-types.js";
|
|
4
|
+
export declare function freshEncryptedRequest(keyStore: GrantKeyStore, token: StoredToken, operation: CollectionOperation, input: unknown, deadlineUnixMs?: number): Promise<EncryptedRelayOperationRequest>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { encryptRelayRequest, RelayCryptoError } from "./crypto.js";
|
|
2
|
+
import { connectError, serverConnectError } from "./errors.js";
|
|
3
|
+
export async function freshEncryptedRequest(keyStore, token, operation, input, deadlineUnixMs) {
|
|
4
|
+
if (!token.encryption || !token.grantId || !token.keyHandle) {
|
|
5
|
+
throw connectError("authorization_binding_incompatible", "The refreshed authorization no longer supports encrypted connector operations.", {
|
|
6
|
+
details: {
|
|
7
|
+
contract: "grant_encryption",
|
|
8
|
+
required: [1],
|
|
9
|
+
supported: token.encryption ? [token.encryption.protocol_version] : [],
|
|
10
|
+
peer: "connector",
|
|
11
|
+
operation
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return await encryptRelayRequest(keyStore, token.keyHandle, {
|
|
17
|
+
grantId: token.grantId,
|
|
18
|
+
applicationId: token.clientId,
|
|
19
|
+
encryption: token.encryption
|
|
20
|
+
}, operation, input, undefined, deadlineUnixMs);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error instanceof RelayCryptoError) {
|
|
24
|
+
throw serverConnectError(error.code, error.message);
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { StoredToken } from "./internal-types.js";
|
|
2
|
+
export declare class GrantKeyLeaseRegistry {
|
|
3
|
+
private readonly namespace;
|
|
4
|
+
private readonly onFinalRelease;
|
|
5
|
+
private readonly leases;
|
|
6
|
+
private readonly locks;
|
|
7
|
+
readonly retainAutomaticallyRetiredKeys: boolean;
|
|
8
|
+
constructor(namespace: string, onFinalRelease: (collectionId: string, keyHandle: string) => void);
|
|
9
|
+
acquire(collectionId: string, keyHandle: string, signal?: AbortSignal): Promise<() => void>;
|
|
10
|
+
deleteExclusive(collectionId: string, keyHandle: string, action: () => void | Promise<void>): void;
|
|
11
|
+
has(collectionId: string, keyHandle: string): boolean;
|
|
12
|
+
handles(collectionId: string): Iterable<string>;
|
|
13
|
+
private acquireLocal;
|
|
14
|
+
private lockName;
|
|
15
|
+
}
|
|
16
|
+
export declare class GrantKeyLeaseSet {
|
|
17
|
+
private readonly collectionId;
|
|
18
|
+
private readonly acquire;
|
|
19
|
+
private readonly releases;
|
|
20
|
+
constructor(collectionId: string, acquire: (collectionId: string, keyHandle: string, signal?: AbortSignal) => Promise<() => void>);
|
|
21
|
+
retain(tokenOrHandle: StoredToken | string | null | undefined, signal?: AbortSignal): Promise<void>;
|
|
22
|
+
release(): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function retainCurrentGrantToken(readCurrent: () => StoredToken | null, leases: GrantKeyLeaseSet, signal?: AbortSignal): Promise<StoredToken | null>;
|