@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.
- 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,132 @@
|
|
|
1
|
+
import { FILE_TRANSFER_PROTOCOL_VERSION, decodeFileFrame, encodeFileFrame, fileFrameAuthenticatedData } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { RelayCryptoError, deriveP256SharedSecret, validateGrantEncryption } from "./crypto.js";
|
|
3
|
+
const FILE_KEY_INFO = "mdbase-connect file chunk key v1";
|
|
4
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
5
|
+
export class FileTransferCryptoError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "FileTransferCryptoError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Grant-bound, direction-separated cipher for one resumable file transfer. */
|
|
14
|
+
export class GrantFileTransferCipher {
|
|
15
|
+
binding;
|
|
16
|
+
key;
|
|
17
|
+
constructor(binding, key) {
|
|
18
|
+
this.binding = binding;
|
|
19
|
+
this.key = key;
|
|
20
|
+
}
|
|
21
|
+
static async open(store, handle, binding) {
|
|
22
|
+
validateFileTransferBinding(binding);
|
|
23
|
+
const record = await store.get(handle);
|
|
24
|
+
if (!record || record.agreementPublicKey !== binding.encryption.application_agreement_public_key) {
|
|
25
|
+
throw new RelayCryptoError("missing_grant_key", "The encrypted grant key is unavailable or does not match the file transfer grant.");
|
|
26
|
+
}
|
|
27
|
+
const sharedSecret = await deriveP256SharedSecret(record.agreementPrivateKey, binding.encryption.connector_agreement_public_key);
|
|
28
|
+
return GrantFileTransferCipher.fromSharedSecret(sharedSecret, binding);
|
|
29
|
+
}
|
|
30
|
+
/** @internal Enables non-browser key-agreement adapters and compatibility tests. */
|
|
31
|
+
static async fromSharedSecret(sharedSecret, binding) {
|
|
32
|
+
validateFileTransferBinding(binding);
|
|
33
|
+
const key = await deriveFileTransferKey(sharedSecret, binding);
|
|
34
|
+
return new GrantFileTransferCipher(binding, key);
|
|
35
|
+
}
|
|
36
|
+
async encryptChunk(kind, header, plaintext) {
|
|
37
|
+
this.validateHeader(header);
|
|
38
|
+
if (plaintext.byteLength !== header.plaintext_length) {
|
|
39
|
+
throw new FileTransferCryptoError("plaintext_length_mismatch", "File chunk plaintext length does not match its authenticated header.");
|
|
40
|
+
}
|
|
41
|
+
const authenticatedData = fileFrameAuthenticatedData(kind, header);
|
|
42
|
+
const payload = await crypto.subtle.encrypt({
|
|
43
|
+
name: "AES-GCM",
|
|
44
|
+
iv: chunkNonce(header.chunk_index),
|
|
45
|
+
additionalData: arrayBuffer(authenticatedData),
|
|
46
|
+
tagLength: 128
|
|
47
|
+
}, this.key, arrayBuffer(plaintext));
|
|
48
|
+
return encodeFileFrame({ kind, header, payload: new Uint8Array(payload) });
|
|
49
|
+
}
|
|
50
|
+
async decryptChunk(encoded) {
|
|
51
|
+
const frame = decodeFileFrame(encoded);
|
|
52
|
+
this.validateHeader(frame.header);
|
|
53
|
+
const authenticatedData = fileFrameAuthenticatedData(frame.kind, frame.header);
|
|
54
|
+
try {
|
|
55
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
56
|
+
name: "AES-GCM",
|
|
57
|
+
iv: chunkNonce(frame.header.chunk_index),
|
|
58
|
+
additionalData: arrayBuffer(authenticatedData),
|
|
59
|
+
tagLength: 128
|
|
60
|
+
}, this.key, arrayBuffer(frame.payload));
|
|
61
|
+
return new Uint8Array(plaintext);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new FileTransferCryptoError("authentication_failed", "The encrypted file chunk could not be authenticated.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
validateHeader(header) {
|
|
68
|
+
const encryption = this.binding.encryption;
|
|
69
|
+
if (header.protocol_version !== FILE_TRANSFER_PROTOCOL_VERSION
|
|
70
|
+
|| header.protection !== "grant_aead_v1"
|
|
71
|
+
|| header.grant_id !== this.binding.grantId
|
|
72
|
+
|| header.authority_id !== this.binding.authorityId
|
|
73
|
+
|| header.collection_id !== encryption.collection_id
|
|
74
|
+
|| header.transfer_id !== this.binding.transferId
|
|
75
|
+
|| header.direction !== this.binding.direction
|
|
76
|
+
|| header.scope_epoch !== encryption.scope_epoch
|
|
77
|
+
|| header.key_id !== encryption.key_id) {
|
|
78
|
+
throw new FileTransferCryptoError("header_binding_mismatch", "The file frame does not match its transfer encryption binding.");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** @internal Deterministic HKDF profile shared with connector authorities. */
|
|
83
|
+
export async function deriveFileTransferKey(sharedSecret, binding) {
|
|
84
|
+
const contextBytes = new TextEncoder().encode(fileTransferContext(binding));
|
|
85
|
+
const salt = await crypto.subtle.digest("SHA-256", contextBytes);
|
|
86
|
+
const material = await crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, ["deriveKey"]);
|
|
87
|
+
return crypto.subtle.deriveKey({
|
|
88
|
+
name: "HKDF",
|
|
89
|
+
hash: "SHA-256",
|
|
90
|
+
salt,
|
|
91
|
+
info: new TextEncoder().encode(FILE_KEY_INFO)
|
|
92
|
+
}, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
93
|
+
}
|
|
94
|
+
function validateFileTransferBinding(binding) {
|
|
95
|
+
validateGrantEncryption(binding.encryption);
|
|
96
|
+
if (!UUID_PATTERN.test(binding.grantId)
|
|
97
|
+
|| !UUID_PATTERN.test(binding.applicationId)
|
|
98
|
+
|| !UUID_PATTERN.test(binding.authorityId)
|
|
99
|
+
|| !UUID_PATTERN.test(binding.transferId)
|
|
100
|
+
|| (binding.direction !== "upload" && binding.direction !== "download")) {
|
|
101
|
+
throw new FileTransferCryptoError("invalid_binding", "The file transfer encryption binding is invalid.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function fileTransferContext(binding) {
|
|
105
|
+
const encryption = binding.encryption;
|
|
106
|
+
return [
|
|
107
|
+
"mdbase-connect",
|
|
108
|
+
"file-transfer",
|
|
109
|
+
FILE_TRANSFER_PROTOCOL_VERSION,
|
|
110
|
+
encryption.suite,
|
|
111
|
+
binding.grantId,
|
|
112
|
+
binding.applicationId,
|
|
113
|
+
encryption.connector_id,
|
|
114
|
+
binding.authorityId,
|
|
115
|
+
encryption.collection_id,
|
|
116
|
+
encryption.scope_epoch,
|
|
117
|
+
encryption.key_id,
|
|
118
|
+
binding.transferId,
|
|
119
|
+
binding.direction
|
|
120
|
+
].join("|");
|
|
121
|
+
}
|
|
122
|
+
function chunkNonce(index) {
|
|
123
|
+
if (!Number.isSafeInteger(index) || index < 0) {
|
|
124
|
+
throw new FileTransferCryptoError("invalid_chunk_index", "The file chunk index is invalid.");
|
|
125
|
+
}
|
|
126
|
+
const nonce = new Uint8Array(new ArrayBuffer(12));
|
|
127
|
+
new DataView(nonce.buffer).setBigUint64(4, BigInt(index), false);
|
|
128
|
+
return nonce;
|
|
129
|
+
}
|
|
130
|
+
function arrayBuffer(bytes) {
|
|
131
|
+
return Uint8Array.from(bytes).buffer;
|
|
132
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Small dependency-free incremental SHA-256 for Blob and stream hashing. */
|
|
2
|
+
export declare class IncrementalSha256 {
|
|
3
|
+
private readonly state;
|
|
4
|
+
private readonly block;
|
|
5
|
+
private readonly words;
|
|
6
|
+
private blockLength;
|
|
7
|
+
private byteLength;
|
|
8
|
+
private finished;
|
|
9
|
+
update(input: Uint8Array): this;
|
|
10
|
+
digestHex(): string;
|
|
11
|
+
private compress;
|
|
12
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const ROUND_CONSTANTS = new Uint32Array([
|
|
2
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
|
3
|
+
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
4
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
|
5
|
+
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
6
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
|
7
|
+
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
8
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
|
9
|
+
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
10
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
11
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
12
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
|
13
|
+
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
14
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
|
15
|
+
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
16
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
17
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
|
18
|
+
]);
|
|
19
|
+
const INITIAL_STATE = new Uint32Array([
|
|
20
|
+
0x6a09e667,
|
|
21
|
+
0xbb67ae85,
|
|
22
|
+
0x3c6ef372,
|
|
23
|
+
0xa54ff53a,
|
|
24
|
+
0x510e527f,
|
|
25
|
+
0x9b05688c,
|
|
26
|
+
0x1f83d9ab,
|
|
27
|
+
0x5be0cd19
|
|
28
|
+
]);
|
|
29
|
+
/** Small dependency-free incremental SHA-256 for Blob and stream hashing. */
|
|
30
|
+
export class IncrementalSha256 {
|
|
31
|
+
state = INITIAL_STATE.slice();
|
|
32
|
+
block = new Uint8Array(64);
|
|
33
|
+
words = new Uint32Array(64);
|
|
34
|
+
blockLength = 0;
|
|
35
|
+
byteLength = 0;
|
|
36
|
+
finished = false;
|
|
37
|
+
update(input) {
|
|
38
|
+
if (this.finished)
|
|
39
|
+
throw new Error("SHA-256 digest is already finalized.");
|
|
40
|
+
this.byteLength += input.byteLength;
|
|
41
|
+
if (!Number.isSafeInteger(this.byteLength)) {
|
|
42
|
+
throw new Error("SHA-256 input exceeds the safe browser byte range.");
|
|
43
|
+
}
|
|
44
|
+
let offset = 0;
|
|
45
|
+
if (this.blockLength > 0) {
|
|
46
|
+
const length = Math.min(64 - this.blockLength, input.byteLength);
|
|
47
|
+
this.block.set(input.subarray(0, length), this.blockLength);
|
|
48
|
+
this.blockLength += length;
|
|
49
|
+
offset += length;
|
|
50
|
+
if (this.blockLength === 64) {
|
|
51
|
+
this.compress(this.block);
|
|
52
|
+
this.blockLength = 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
while (offset + 64 <= input.byteLength) {
|
|
56
|
+
this.compress(input.subarray(offset, offset + 64));
|
|
57
|
+
offset += 64;
|
|
58
|
+
}
|
|
59
|
+
if (offset < input.byteLength) {
|
|
60
|
+
this.block.set(input.subarray(offset), 0);
|
|
61
|
+
this.blockLength = input.byteLength - offset;
|
|
62
|
+
}
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
digestHex() {
|
|
66
|
+
if (this.finished)
|
|
67
|
+
throw new Error("SHA-256 digest is already finalized.");
|
|
68
|
+
this.finished = true;
|
|
69
|
+
this.block[this.blockLength] = 0x80;
|
|
70
|
+
this.block.fill(0, this.blockLength + 1);
|
|
71
|
+
if (this.blockLength >= 56) {
|
|
72
|
+
this.compress(this.block);
|
|
73
|
+
this.block.fill(0);
|
|
74
|
+
}
|
|
75
|
+
const bitLength = this.byteLength * 8;
|
|
76
|
+
const high = Math.floor(bitLength / 0x1_0000_0000);
|
|
77
|
+
const low = bitLength >>> 0;
|
|
78
|
+
writeU32(this.block, 56, high);
|
|
79
|
+
writeU32(this.block, 60, low);
|
|
80
|
+
this.compress(this.block);
|
|
81
|
+
return [...this.state]
|
|
82
|
+
.map((word) => word.toString(16).padStart(8, "0"))
|
|
83
|
+
.join("");
|
|
84
|
+
}
|
|
85
|
+
compress(block) {
|
|
86
|
+
const words = this.words;
|
|
87
|
+
for (let index = 0; index < 16; index += 1) {
|
|
88
|
+
const offset = index * 4;
|
|
89
|
+
words[index] = ((block[offset] << 24)
|
|
90
|
+
| (block[offset + 1] << 16)
|
|
91
|
+
| (block[offset + 2] << 8)
|
|
92
|
+
| block[offset + 3]) >>> 0;
|
|
93
|
+
}
|
|
94
|
+
for (let index = 16; index < 64; index += 1) {
|
|
95
|
+
const left = words[index - 15];
|
|
96
|
+
const right = words[index - 2];
|
|
97
|
+
const sigma0 = rotateRight(left, 7) ^ rotateRight(left, 18) ^ (left >>> 3);
|
|
98
|
+
const sigma1 = rotateRight(right, 17) ^ rotateRight(right, 19) ^ (right >>> 10);
|
|
99
|
+
words[index] = (words[index - 16] + sigma0 + words[index - 7] + sigma1) >>> 0;
|
|
100
|
+
}
|
|
101
|
+
let a = this.state[0];
|
|
102
|
+
let b = this.state[1];
|
|
103
|
+
let c = this.state[2];
|
|
104
|
+
let d = this.state[3];
|
|
105
|
+
let e = this.state[4];
|
|
106
|
+
let f = this.state[5];
|
|
107
|
+
let g = this.state[6];
|
|
108
|
+
let h = this.state[7];
|
|
109
|
+
for (let index = 0; index < 64; index += 1) {
|
|
110
|
+
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
|
111
|
+
const choice = (e & f) ^ (~e & g);
|
|
112
|
+
const temp1 = (h + sum1 + choice + ROUND_CONSTANTS[index] + words[index]) >>> 0;
|
|
113
|
+
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
|
114
|
+
const majority = (a & b) ^ (a & c) ^ (b & c);
|
|
115
|
+
const temp2 = (sum0 + majority) >>> 0;
|
|
116
|
+
h = g;
|
|
117
|
+
g = f;
|
|
118
|
+
f = e;
|
|
119
|
+
e = (d + temp1) >>> 0;
|
|
120
|
+
d = c;
|
|
121
|
+
c = b;
|
|
122
|
+
b = a;
|
|
123
|
+
a = (temp1 + temp2) >>> 0;
|
|
124
|
+
}
|
|
125
|
+
this.state[0] = (this.state[0] + a) >>> 0;
|
|
126
|
+
this.state[1] = (this.state[1] + b) >>> 0;
|
|
127
|
+
this.state[2] = (this.state[2] + c) >>> 0;
|
|
128
|
+
this.state[3] = (this.state[3] + d) >>> 0;
|
|
129
|
+
this.state[4] = (this.state[4] + e) >>> 0;
|
|
130
|
+
this.state[5] = (this.state[5] + f) >>> 0;
|
|
131
|
+
this.state[6] = (this.state[6] + g) >>> 0;
|
|
132
|
+
this.state[7] = (this.state[7] + h) >>> 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function rotateRight(value, amount) {
|
|
136
|
+
return (value >>> amount) | (value << (32 - amount));
|
|
137
|
+
}
|
|
138
|
+
function writeU32(target, offset, value) {
|
|
139
|
+
target[offset] = value >>> 24;
|
|
140
|
+
target[offset + 1] = value >>> 16;
|
|
141
|
+
target[offset + 2] = value >>> 8;
|
|
142
|
+
target[offset + 3] = value;
|
|
143
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Converts a one-shot byte source into exact upload parts without read-ahead. */
|
|
2
|
+
export declare class BinaryPartReader {
|
|
3
|
+
private readonly signal?;
|
|
4
|
+
private readonly iterator;
|
|
5
|
+
private remainder;
|
|
6
|
+
private remainderOffset;
|
|
7
|
+
private maxSourceChunkBytes;
|
|
8
|
+
private iteratorClosing;
|
|
9
|
+
private ended;
|
|
10
|
+
constructor(source: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>, signal?: AbortSignal | undefined);
|
|
11
|
+
setMaxSourceChunkBytes(value: number): void;
|
|
12
|
+
read(length: number): Promise<Uint8Array<ArrayBuffer>>;
|
|
13
|
+
expectEnd(): Promise<void>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
private nextSourceChunk;
|
|
16
|
+
private closeIterator;
|
|
17
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { connectError } from "./errors.js";
|
|
2
|
+
/** Converts a one-shot byte source into exact upload parts without read-ahead. */
|
|
3
|
+
export class BinaryPartReader {
|
|
4
|
+
signal;
|
|
5
|
+
iterator;
|
|
6
|
+
remainder = new Uint8Array();
|
|
7
|
+
remainderOffset = 0;
|
|
8
|
+
maxSourceChunkBytes = null;
|
|
9
|
+
iteratorClosing = false;
|
|
10
|
+
ended = false;
|
|
11
|
+
constructor(source, signal) {
|
|
12
|
+
this.signal = signal;
|
|
13
|
+
this.iterator = sourceIterator(source);
|
|
14
|
+
}
|
|
15
|
+
setMaxSourceChunkBytes(value) {
|
|
16
|
+
this.maxSourceChunkBytes = value;
|
|
17
|
+
}
|
|
18
|
+
async read(length) {
|
|
19
|
+
const output = new Uint8Array(length);
|
|
20
|
+
let offset = 0;
|
|
21
|
+
while (offset < length) {
|
|
22
|
+
if (this.remainderOffset === this.remainder.byteLength) {
|
|
23
|
+
const next = await this.nextSourceChunk();
|
|
24
|
+
if (next.done) {
|
|
25
|
+
this.ended = true;
|
|
26
|
+
throw connectError("invalid_request", "Streamed file bytes ended before the declared size.");
|
|
27
|
+
}
|
|
28
|
+
if (!(next.value instanceof Uint8Array)) {
|
|
29
|
+
throw connectError("invalid_request", "A streamed file chunk was not a Uint8Array.");
|
|
30
|
+
}
|
|
31
|
+
if (this.maxSourceChunkBytes === null) {
|
|
32
|
+
throw new Error("Stream source was read before upload negotiation completed.");
|
|
33
|
+
}
|
|
34
|
+
if (next.value.byteLength > this.maxSourceChunkBytes) {
|
|
35
|
+
throw connectError("invalid_request", `A streamed file chunk exceeded the negotiated ${this.maxSourceChunkBytes}-byte upload part size.`);
|
|
36
|
+
}
|
|
37
|
+
this.remainder = next.value;
|
|
38
|
+
this.remainderOffset = 0;
|
|
39
|
+
if (this.remainder.byteLength === 0)
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const count = Math.min(length - offset, this.remainder.byteLength - this.remainderOffset);
|
|
43
|
+
output.set(this.remainder.subarray(this.remainderOffset, this.remainderOffset + count), offset);
|
|
44
|
+
offset += count;
|
|
45
|
+
this.remainderOffset += count;
|
|
46
|
+
}
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
async expectEnd() {
|
|
50
|
+
if (this.remainderOffset < this.remainder.byteLength) {
|
|
51
|
+
throw connectError("invalid_request", "Streamed file bytes exceed the declared size.");
|
|
52
|
+
}
|
|
53
|
+
while (!this.ended) {
|
|
54
|
+
const next = await this.nextSourceChunk();
|
|
55
|
+
if (next.done) {
|
|
56
|
+
this.ended = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!(next.value instanceof Uint8Array)) {
|
|
60
|
+
throw connectError("invalid_request", "A streamed file chunk was not a Uint8Array.");
|
|
61
|
+
}
|
|
62
|
+
if (next.value.byteLength > 0) {
|
|
63
|
+
throw connectError("invalid_request", "Streamed file bytes exceed the declared size.");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async close() {
|
|
68
|
+
this.ended = true;
|
|
69
|
+
this.closeIterator();
|
|
70
|
+
}
|
|
71
|
+
async nextSourceChunk() {
|
|
72
|
+
throwIfAborted(this.signal);
|
|
73
|
+
const pending = this.iterator.next();
|
|
74
|
+
const signal = this.signal;
|
|
75
|
+
if (!signal)
|
|
76
|
+
return pending;
|
|
77
|
+
let removeAbortListener = () => { };
|
|
78
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
79
|
+
const abort = () => {
|
|
80
|
+
this.closeIterator();
|
|
81
|
+
reject(cancelledError(signal));
|
|
82
|
+
};
|
|
83
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
84
|
+
removeAbortListener = () => signal.removeEventListener("abort", abort);
|
|
85
|
+
if (signal.aborted)
|
|
86
|
+
abort();
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
const next = await Promise.race([pending, cancelled]);
|
|
90
|
+
throwIfAborted(signal);
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
removeAbortListener();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
closeIterator() {
|
|
98
|
+
if (this.iteratorClosing)
|
|
99
|
+
return;
|
|
100
|
+
this.iteratorClosing = true;
|
|
101
|
+
try {
|
|
102
|
+
const closing = this.iterator.return?.();
|
|
103
|
+
void closing?.catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Cancellation and transfer cleanup must not wait for a broken source.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function sourceIterator(source) {
|
|
111
|
+
if (typeof source.getReader === "function") {
|
|
112
|
+
const reader = source.getReader();
|
|
113
|
+
let released = false;
|
|
114
|
+
const release = () => {
|
|
115
|
+
if (!released) {
|
|
116
|
+
released = true;
|
|
117
|
+
reader.releaseLock();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
async next() {
|
|
122
|
+
if (released)
|
|
123
|
+
return { done: true, value: undefined };
|
|
124
|
+
const next = await reader.read();
|
|
125
|
+
if (next.done)
|
|
126
|
+
release();
|
|
127
|
+
return next;
|
|
128
|
+
},
|
|
129
|
+
async return() {
|
|
130
|
+
if (!released) {
|
|
131
|
+
try {
|
|
132
|
+
await reader.cancel();
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
release();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { done: true, value: undefined };
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const iterable = source;
|
|
143
|
+
if (typeof iterable[Symbol.asyncIterator] !== "function") {
|
|
144
|
+
throw connectError("invalid_request", "Streamed files require a readable byte stream.");
|
|
145
|
+
}
|
|
146
|
+
return iterable[Symbol.asyncIterator]();
|
|
147
|
+
}
|
|
148
|
+
function throwIfAborted(signal) {
|
|
149
|
+
if (signal?.aborted)
|
|
150
|
+
throw cancelledError(signal);
|
|
151
|
+
}
|
|
152
|
+
function cancelledError(signal) {
|
|
153
|
+
return connectError("operation_cancelled", "The file transfer was cancelled.", {
|
|
154
|
+
operationOutcome: "not_sent",
|
|
155
|
+
cause: signal?.reason
|
|
156
|
+
});
|
|
157
|
+
}
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { CollectionFileDescriptor, DeleteFileReceipt, FileCapability, FileTransferSession } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
export type MdbaseFileSource = Blob | ArrayBuffer | ArrayBufferView;
|
|
3
|
+
export interface MdbaseFileStreamSource {
|
|
4
|
+
/** Exact plaintext byte length. */
|
|
5
|
+
size: number;
|
|
6
|
+
/** SHA-256 commitment verified by both this client and the authority. */
|
|
7
|
+
contentDigest: `sha256:${string}`;
|
|
8
|
+
/**
|
|
9
|
+
* A one-shot byte source. Each yielded chunk must fit within the upload part
|
|
10
|
+
* size negotiated by the authority; ordinary browser and Node streams do.
|
|
11
|
+
*/
|
|
12
|
+
stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>;
|
|
13
|
+
mediaType?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface MdbaseFileProgress {
|
|
16
|
+
phase: "hashing" | "uploading" | "downloading";
|
|
17
|
+
transferredBytes: number;
|
|
18
|
+
totalBytes: number;
|
|
19
|
+
}
|
|
20
|
+
export interface MdbaseFileListOptions {
|
|
21
|
+
folder?: string;
|
|
22
|
+
pageSize?: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
export interface MdbaseFileUploadOptions {
|
|
26
|
+
mediaType?: string;
|
|
27
|
+
ifRevision?: string;
|
|
28
|
+
concurrency?: number;
|
|
29
|
+
/** Stable retry key. Reuse it after an ambiguous failure to resume or replay commit. */
|
|
30
|
+
transferId?: string;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
onProgress?: (progress: MdbaseFileProgress) => void;
|
|
33
|
+
}
|
|
34
|
+
export type MdbaseFileStreamUploadOptions = Omit<MdbaseFileUploadOptions, "concurrency">;
|
|
35
|
+
export interface MdbaseFileDownloadOptions {
|
|
36
|
+
concurrency?: number;
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
onProgress?: (progress: MdbaseFileProgress) => void;
|
|
39
|
+
}
|
|
40
|
+
export interface MdbaseFileMoveOptions {
|
|
41
|
+
ifRevision?: string;
|
|
42
|
+
/** Reuse after an ambiguous network failure to receive the original receipt. */
|
|
43
|
+
mutationId?: string;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
export interface MdbaseFileDeleteOptions {
|
|
47
|
+
ifRevision?: string;
|
|
48
|
+
/** Reuse after an ambiguous network failure to receive the original receipt. */
|
|
49
|
+
mutationId?: string;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}
|
|
52
|
+
/** Internal transport seam used by direct and relayed encrypted chunk delivery. */
|
|
53
|
+
export interface MdbaseFramedFileTransport {
|
|
54
|
+
uploadChunk(session: FileTransferSession, chunkIndex: number, bytes: Uint8Array, signal?: AbortSignal): Promise<void>;
|
|
55
|
+
downloadChunk(session: FileTransferSession, chunkIndex: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
56
|
+
}
|
|
57
|
+
/** Internal authenticated hosted-object delivery seam. */
|
|
58
|
+
export interface MdbaseHostedFileTransport {
|
|
59
|
+
downloadPart(session: FileTransferSession, partIndex: number, expectedLength: number, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>;
|
|
60
|
+
}
|
|
61
|
+
type ControlRequest = <Result>(method: "GET" | "POST" | "DELETE", path?: string, input?: unknown, signal?: AbortSignal) => Promise<Result>;
|
|
62
|
+
/** Ergonomic facade over resumable, authority-specific file delivery. */
|
|
63
|
+
export declare class MdbaseFileClient {
|
|
64
|
+
private readonly capability;
|
|
65
|
+
private readonly request;
|
|
66
|
+
private readonly framed?;
|
|
67
|
+
private readonly hosted?;
|
|
68
|
+
constructor(capability: () => FileCapability | null, request: ControlRequest, framed?: MdbaseFramedFileTransport | undefined, hosted?: MdbaseHostedFileTransport | undefined);
|
|
69
|
+
list(options?: MdbaseFileListOptions): AsyncGenerator<CollectionFileDescriptor>;
|
|
70
|
+
upload(path: string, source: MdbaseFileSource, options?: MdbaseFileUploadOptions): Promise<CollectionFileDescriptor>;
|
|
71
|
+
/**
|
|
72
|
+
* Upload a one-shot byte stream without accumulating multiple file parts.
|
|
73
|
+
* SDK memory is bounded by one assembled negotiated part plus at most one
|
|
74
|
+
* source chunk; a single-part upload may therefore hold the complete file.
|
|
75
|
+
* Reuse `transferId` with a newly opened source after an ambiguous failure.
|
|
76
|
+
*/
|
|
77
|
+
uploadStream(path: string, source: MdbaseFileStreamSource, options?: MdbaseFileStreamUploadOptions): Promise<CollectionFileDescriptor>;
|
|
78
|
+
private uploadKnownSource;
|
|
79
|
+
download(file: CollectionFileDescriptor, options?: MdbaseFileDownloadOptions): Promise<Blob>;
|
|
80
|
+
/** Download and verify a file with network backpressure and bounded buffering. */
|
|
81
|
+
downloadStream(file: CollectionFileDescriptor, options?: MdbaseFileDownloadOptions): Promise<ReadableStream<Uint8Array>>;
|
|
82
|
+
downloadBytes(file: CollectionFileDescriptor, options?: MdbaseFileDownloadOptions): Promise<Uint8Array>;
|
|
83
|
+
move(file: CollectionFileDescriptor, path: string, options?: MdbaseFileMoveOptions): Promise<CollectionFileDescriptor>;
|
|
84
|
+
delete(file: CollectionFileDescriptor, options?: MdbaseFileDeleteOptions): Promise<DeleteFileReceipt>;
|
|
85
|
+
private uploadPart;
|
|
86
|
+
private tryReplayUploadCommit;
|
|
87
|
+
private commitUpload;
|
|
88
|
+
private requireAction;
|
|
89
|
+
private abort;
|
|
90
|
+
}
|
|
91
|
+
export {};
|