@mdbase-dev/connect-protocol 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/dist/connect-problems.generated.d.ts +1067 -0
- package/dist/connect-problems.generated.js +364 -0
- package/dist/files.d.ts +253 -0
- package/dist/files.js +417 -0
- package/dist/index.d.ts +97 -16
- package/dist/index.js +6 -1
- package/package.json +2 -2
- package/schemas/catalog.json +3 -0
- package/schemas/connect-problem.v1.schema.json +3893 -0
- package/schemas/connect-problems.v1.catalog.json +238 -0
- package/schemas/connect-protocol.v1.schema.json +15 -26
- package/schemas/encrypted-relay.v1.schema.json +3 -11
- package/schemas/files.v1.schema.json +502 -0
- package/schemas/mdbase-app.schema.json +48 -0
- package/schemas/sync.v1.schema.json +189 -4
package/dist/files.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
export const FILE_PROTOCOL_VERSION = 1;
|
|
2
|
+
export const FILE_TRANSFER_PROTOCOL_VERSION = 1;
|
|
3
|
+
export const DEFAULT_FILE_CHUNK_BYTES = 1024 * 1024;
|
|
4
|
+
export const MAX_FILE_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
export const MAX_FILE_FRAME_HEADER_BYTES = 16 * 1024;
|
|
6
|
+
export const FILE_FRAME_PREFIX_BYTES = 16;
|
|
7
|
+
export const FILE_FRAME_MAGIC = "MDBF";
|
|
8
|
+
export const MAX_FILE_FRAME_BYTES = FILE_FRAME_PREFIX_BYTES
|
|
9
|
+
+ MAX_FILE_FRAME_HEADER_BYTES
|
|
10
|
+
+ MAX_FILE_CHUNK_BYTES
|
|
11
|
+
+ 16;
|
|
12
|
+
export const RELAY_FILE_PROTOCOL_VERSION = 1;
|
|
13
|
+
export const RELAY_FILE_PREFIX_BYTES = 16;
|
|
14
|
+
export const MAX_RELAY_FILE_HEADER_BYTES = 1024;
|
|
15
|
+
export const MAX_RELAY_FILE_PAYLOAD_BYTES = MAX_FILE_FRAME_BYTES;
|
|
16
|
+
export const RELAY_FILE_MAGIC = "MDBR";
|
|
17
|
+
const FILE_FRAME_MAGIC_BYTES = new Uint8Array([0x4d, 0x44, 0x42, 0x46]);
|
|
18
|
+
const FILE_FRAME_VERSION = 1;
|
|
19
|
+
const FILE_FRAME_FLAGS = 0;
|
|
20
|
+
const RELAY_FILE_MAGIC_BYTES = new Uint8Array([0x4d, 0x44, 0x42, 0x52]);
|
|
21
|
+
const RELAY_FILE_FRAME_VERSION = 1;
|
|
22
|
+
const RELAY_FILE_FRAME_FLAGS = 0;
|
|
23
|
+
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;
|
|
24
|
+
export class FileFrameError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.name = "FileFrameError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class RelayFileFrameError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.name = "RelayFileFrameError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function frameKindCode(kind) {
|
|
41
|
+
return kind === "upload_chunk" ? 1 : 2;
|
|
42
|
+
}
|
|
43
|
+
function frameKindFromCode(code) {
|
|
44
|
+
if (code === 1)
|
|
45
|
+
return "upload_chunk";
|
|
46
|
+
if (code === 2)
|
|
47
|
+
return "download_chunk";
|
|
48
|
+
throw new FileFrameError("invalid_kind", `Unknown file frame kind ${code}`);
|
|
49
|
+
}
|
|
50
|
+
function expectObject(value) {
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
52
|
+
throw new FileFrameError("invalid_header", "File frame header must be a JSON object");
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
function expectString(object, name) {
|
|
57
|
+
const value = object[name];
|
|
58
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
59
|
+
throw new FileFrameError("invalid_header", `File frame header ${name} must be a non-empty string`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function expectUuid(object, name) {
|
|
64
|
+
const value = expectString(object, name);
|
|
65
|
+
if (!UUID_PATTERN.test(value)) {
|
|
66
|
+
throw new FileFrameError("invalid_header", `File frame header ${name} must be a UUID`);
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function expectInteger(object, name, maximum = Number.MAX_SAFE_INTEGER) {
|
|
71
|
+
const value = object[name];
|
|
72
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
|
73
|
+
throw new FileFrameError("invalid_header", `File frame header ${name} is outside its allowed range`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function canonicalHeader(header) {
|
|
78
|
+
if (header.protocol_version !== FILE_TRANSFER_PROTOCOL_VERSION) {
|
|
79
|
+
throw new FileFrameError("unsupported_version", `Unsupported file transfer protocol ${header.protocol_version}`);
|
|
80
|
+
}
|
|
81
|
+
if (header.protection !== "grant_aead_v1" && header.protection !== "transport_tls") {
|
|
82
|
+
throw new FileFrameError("invalid_header", "Unknown file transfer protection");
|
|
83
|
+
}
|
|
84
|
+
if (header.direction !== "upload" && header.direction !== "download") {
|
|
85
|
+
throw new FileFrameError("invalid_header", "Unknown file transfer direction");
|
|
86
|
+
}
|
|
87
|
+
for (const [name, value] of [
|
|
88
|
+
["grant_id", header.grant_id],
|
|
89
|
+
["authority_id", header.authority_id],
|
|
90
|
+
["collection_id", header.collection_id],
|
|
91
|
+
["transfer_id", header.transfer_id]
|
|
92
|
+
]) {
|
|
93
|
+
if (!UUID_PATTERN.test(value)) {
|
|
94
|
+
throw new FileFrameError("invalid_header", `File frame header ${name} must be a UUID`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const [name, value, maximum] of [
|
|
98
|
+
["chunk_size", header.chunk_size, MAX_FILE_CHUNK_BYTES],
|
|
99
|
+
["chunk_index", header.chunk_index, Number.MAX_SAFE_INTEGER],
|
|
100
|
+
["offset", header.offset, Number.MAX_SAFE_INTEGER],
|
|
101
|
+
["plaintext_length", header.plaintext_length, MAX_FILE_CHUNK_BYTES],
|
|
102
|
+
["total_size", header.total_size, Number.MAX_SAFE_INTEGER]
|
|
103
|
+
]) {
|
|
104
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
|
|
105
|
+
throw new FileFrameError("invalid_header", `File frame header ${name} is outside its allowed range`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (header.chunk_size < 64 * 1024) {
|
|
109
|
+
throw new FileFrameError("invalid_header", "File frame chunk_size is below the protocol minimum");
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isSafeInteger(header.scope_epoch) || header.scope_epoch < 1) {
|
|
112
|
+
throw new FileFrameError("invalid_header", "File frame header scope_epoch must be a positive integer");
|
|
113
|
+
}
|
|
114
|
+
if (header.protection === "grant_aead_v1" && !header.key_id) {
|
|
115
|
+
throw new FileFrameError("invalid_header", "AEAD-protected file frames require key_id");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
protocol_version: FILE_TRANSFER_PROTOCOL_VERSION,
|
|
119
|
+
protection: header.protection,
|
|
120
|
+
grant_id: header.grant_id,
|
|
121
|
+
authority_id: header.authority_id,
|
|
122
|
+
collection_id: header.collection_id,
|
|
123
|
+
transfer_id: header.transfer_id,
|
|
124
|
+
direction: header.direction,
|
|
125
|
+
chunk_size: header.chunk_size,
|
|
126
|
+
chunk_index: header.chunk_index,
|
|
127
|
+
offset: header.offset,
|
|
128
|
+
plaintext_length: header.plaintext_length,
|
|
129
|
+
total_size: header.total_size,
|
|
130
|
+
scope_epoch: header.scope_epoch,
|
|
131
|
+
...(header.key_id === undefined ? {} : { key_id: header.key_id })
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function parseHeader(bytes) {
|
|
135
|
+
let source;
|
|
136
|
+
try {
|
|
137
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new FileFrameError("invalid_header", "File frame header is not valid UTF-8");
|
|
141
|
+
}
|
|
142
|
+
let object;
|
|
143
|
+
try {
|
|
144
|
+
object = expectObject(JSON.parse(source));
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof FileFrameError)
|
|
148
|
+
throw error;
|
|
149
|
+
throw new FileFrameError("invalid_header", "File frame header is not valid JSON");
|
|
150
|
+
}
|
|
151
|
+
const allowed = new Set([
|
|
152
|
+
"protocol_version", "protection", "grant_id", "authority_id", "collection_id",
|
|
153
|
+
"transfer_id", "direction", "chunk_size", "chunk_index", "offset", "plaintext_length",
|
|
154
|
+
"total_size", "scope_epoch", "key_id"
|
|
155
|
+
]);
|
|
156
|
+
if (Object.keys(object).some((key) => !allowed.has(key))) {
|
|
157
|
+
throw new FileFrameError("invalid_header", "File frame header contains an unknown field");
|
|
158
|
+
}
|
|
159
|
+
const protocolVersion = expectInteger(object, "protocol_version");
|
|
160
|
+
if (protocolVersion !== FILE_TRANSFER_PROTOCOL_VERSION) {
|
|
161
|
+
throw new FileFrameError("unsupported_version", `Unsupported file transfer protocol ${protocolVersion}`);
|
|
162
|
+
}
|
|
163
|
+
const protection = expectString(object, "protection");
|
|
164
|
+
const direction = expectString(object, "direction");
|
|
165
|
+
const keyId = object.key_id === undefined ? undefined : expectString(object, "key_id");
|
|
166
|
+
const header = canonicalHeader({
|
|
167
|
+
protocol_version: FILE_TRANSFER_PROTOCOL_VERSION,
|
|
168
|
+
protection: protection,
|
|
169
|
+
grant_id: expectUuid(object, "grant_id"),
|
|
170
|
+
authority_id: expectUuid(object, "authority_id"),
|
|
171
|
+
collection_id: expectUuid(object, "collection_id"),
|
|
172
|
+
transfer_id: expectUuid(object, "transfer_id"),
|
|
173
|
+
direction: direction,
|
|
174
|
+
chunk_size: expectInteger(object, "chunk_size", MAX_FILE_CHUNK_BYTES),
|
|
175
|
+
chunk_index: expectInteger(object, "chunk_index"),
|
|
176
|
+
offset: expectInteger(object, "offset"),
|
|
177
|
+
plaintext_length: expectInteger(object, "plaintext_length", MAX_FILE_CHUNK_BYTES),
|
|
178
|
+
total_size: expectInteger(object, "total_size"),
|
|
179
|
+
scope_epoch: expectInteger(object, "scope_epoch"),
|
|
180
|
+
...(keyId === undefined ? {} : { key_id: keyId })
|
|
181
|
+
});
|
|
182
|
+
if (JSON.stringify(header) !== source) {
|
|
183
|
+
throw new FileFrameError("invalid_header", "File frame header must use the canonical field order without duplicate fields or whitespace");
|
|
184
|
+
}
|
|
185
|
+
return header;
|
|
186
|
+
}
|
|
187
|
+
function validateFrameSemantics(kind, header, payloadLength) {
|
|
188
|
+
const expectedDirection = kind === "upload_chunk" ? "upload" : "download";
|
|
189
|
+
if (header.direction !== expectedDirection) {
|
|
190
|
+
throw new FileFrameError("invalid_header", `Frame kind ${kind} requires direction ${expectedDirection}`);
|
|
191
|
+
}
|
|
192
|
+
const expectedOffset = header.chunk_index * header.chunk_size;
|
|
193
|
+
if (!Number.isSafeInteger(expectedOffset) || header.offset !== expectedOffset) {
|
|
194
|
+
throw new FileFrameError("invalid_header", "Chunk offset does not match its index and protocol chunk size");
|
|
195
|
+
}
|
|
196
|
+
if (header.offset + header.plaintext_length > header.total_size) {
|
|
197
|
+
throw new FileFrameError("invalid_header", "Chunk extends past the declared transfer size");
|
|
198
|
+
}
|
|
199
|
+
const expectedPayloadLength = header.protection === "grant_aead_v1"
|
|
200
|
+
? header.plaintext_length + 16
|
|
201
|
+
: header.plaintext_length;
|
|
202
|
+
if (payloadLength !== expectedPayloadLength) {
|
|
203
|
+
throw new FileFrameError("invalid_length", "Payload length does not match the protected plaintext length");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function expectedFileFramePayloadLength(header) {
|
|
207
|
+
return header.plaintext_length + (header.protection === "grant_aead_v1" ? 16 : 0);
|
|
208
|
+
}
|
|
209
|
+
/** Prefix and canonical header authenticated by the file chunk AEAD profile. */
|
|
210
|
+
export function fileFrameAuthenticatedData(kind, input) {
|
|
211
|
+
const header = canonicalHeader(input);
|
|
212
|
+
const payloadLength = expectedFileFramePayloadLength(header);
|
|
213
|
+
validateFrameSemantics(kind, header, payloadLength);
|
|
214
|
+
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
|
|
215
|
+
if (headerBytes.byteLength > MAX_FILE_FRAME_HEADER_BYTES) {
|
|
216
|
+
throw new FileFrameError("limit_exceeded", "File frame header exceeds the protocol limit");
|
|
217
|
+
}
|
|
218
|
+
const output = new Uint8Array(FILE_FRAME_PREFIX_BYTES + headerBytes.byteLength);
|
|
219
|
+
output.set(FILE_FRAME_MAGIC_BYTES, 0);
|
|
220
|
+
const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
|
|
221
|
+
view.setUint8(4, FILE_FRAME_VERSION);
|
|
222
|
+
view.setUint8(5, frameKindCode(kind));
|
|
223
|
+
view.setUint16(6, FILE_FRAME_FLAGS, false);
|
|
224
|
+
view.setUint32(8, headerBytes.byteLength, false);
|
|
225
|
+
view.setUint32(12, payloadLength, false);
|
|
226
|
+
output.set(headerBytes, FILE_FRAME_PREFIX_BYTES);
|
|
227
|
+
return output;
|
|
228
|
+
}
|
|
229
|
+
export function encodeFileFrame(frame) {
|
|
230
|
+
const header = canonicalHeader(frame.header);
|
|
231
|
+
validateFrameSemantics(frame.kind, header, frame.payload.byteLength);
|
|
232
|
+
if (frame.payload.byteLength > MAX_FILE_CHUNK_BYTES + 16) {
|
|
233
|
+
throw new FileFrameError("limit_exceeded", "File frame payload exceeds the protocol limit");
|
|
234
|
+
}
|
|
235
|
+
const authenticatedData = fileFrameAuthenticatedData(frame.kind, header);
|
|
236
|
+
const output = new Uint8Array(authenticatedData.byteLength + frame.payload.byteLength);
|
|
237
|
+
output.set(authenticatedData, 0);
|
|
238
|
+
output.set(frame.payload, authenticatedData.byteLength);
|
|
239
|
+
return output;
|
|
240
|
+
}
|
|
241
|
+
export function decodeFileFrame(bytes, limits = {}) {
|
|
242
|
+
if (bytes.byteLength < FILE_FRAME_PREFIX_BYTES) {
|
|
243
|
+
throw new FileFrameError("invalid_length", "File frame is shorter than its fixed prefix");
|
|
244
|
+
}
|
|
245
|
+
if (!FILE_FRAME_MAGIC_BYTES.every((byte, index) => bytes[index] === byte)) {
|
|
246
|
+
throw new FileFrameError("invalid_magic", "File frame magic is not MDBF");
|
|
247
|
+
}
|
|
248
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
249
|
+
const version = view.getUint8(4);
|
|
250
|
+
if (version !== FILE_FRAME_VERSION) {
|
|
251
|
+
throw new FileFrameError("unsupported_version", `Unsupported file frame version ${version}`);
|
|
252
|
+
}
|
|
253
|
+
const kind = frameKindFromCode(view.getUint8(5));
|
|
254
|
+
if (view.getUint16(6, false) !== FILE_FRAME_FLAGS) {
|
|
255
|
+
throw new FileFrameError("unsupported_flags", "File frame uses unsupported flags");
|
|
256
|
+
}
|
|
257
|
+
const headerLength = view.getUint32(8, false);
|
|
258
|
+
const payloadLength = view.getUint32(12, false);
|
|
259
|
+
const maxHeaderBytes = limits.maxHeaderBytes ?? MAX_FILE_FRAME_HEADER_BYTES;
|
|
260
|
+
const maxPayloadBytes = limits.maxPayloadBytes ?? MAX_FILE_CHUNK_BYTES + 16;
|
|
261
|
+
if (headerLength === 0) {
|
|
262
|
+
throw new FileFrameError("invalid_length", "File frame header must not be empty");
|
|
263
|
+
}
|
|
264
|
+
if (headerLength > maxHeaderBytes || payloadLength > maxPayloadBytes) {
|
|
265
|
+
throw new FileFrameError("limit_exceeded", "File frame exceeds the configured decode limits");
|
|
266
|
+
}
|
|
267
|
+
const expectedLength = FILE_FRAME_PREFIX_BYTES + headerLength + payloadLength;
|
|
268
|
+
if (!Number.isSafeInteger(expectedLength) || expectedLength !== bytes.byteLength) {
|
|
269
|
+
throw new FileFrameError("invalid_length", "File frame lengths do not match the supplied bytes");
|
|
270
|
+
}
|
|
271
|
+
const headerEnd = FILE_FRAME_PREFIX_BYTES + headerLength;
|
|
272
|
+
const header = parseHeader(bytes.subarray(FILE_FRAME_PREFIX_BYTES, headerEnd));
|
|
273
|
+
validateFrameSemantics(kind, header, payloadLength);
|
|
274
|
+
return { kind, header, payload: bytes.slice(headerEnd) };
|
|
275
|
+
}
|
|
276
|
+
export function encodeRelayFileFrame(frame) {
|
|
277
|
+
const header = canonicalRelayHeader(frame.header);
|
|
278
|
+
validateRelayFrame(frame.kind, header, frame.payload.byteLength);
|
|
279
|
+
const headerBytes = new TextEncoder().encode(JSON.stringify(header));
|
|
280
|
+
if (headerBytes.byteLength > MAX_RELAY_FILE_HEADER_BYTES
|
|
281
|
+
|| frame.payload.byteLength > MAX_RELAY_FILE_PAYLOAD_BYTES) {
|
|
282
|
+
throw new RelayFileFrameError("limit_exceeded", "Relay file frame exceeds protocol limits");
|
|
283
|
+
}
|
|
284
|
+
const output = new Uint8Array(RELAY_FILE_PREFIX_BYTES + headerBytes.byteLength + frame.payload.byteLength);
|
|
285
|
+
output.set(RELAY_FILE_MAGIC_BYTES, 0);
|
|
286
|
+
const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
|
|
287
|
+
view.setUint8(4, RELAY_FILE_FRAME_VERSION);
|
|
288
|
+
view.setUint8(5, relayFileKindCode(frame.kind));
|
|
289
|
+
view.setUint16(6, RELAY_FILE_FRAME_FLAGS, false);
|
|
290
|
+
view.setUint32(8, headerBytes.byteLength, false);
|
|
291
|
+
view.setUint32(12, frame.payload.byteLength, false);
|
|
292
|
+
output.set(headerBytes, RELAY_FILE_PREFIX_BYTES);
|
|
293
|
+
output.set(frame.payload, RELAY_FILE_PREFIX_BYTES + headerBytes.byteLength);
|
|
294
|
+
return output;
|
|
295
|
+
}
|
|
296
|
+
export function decodeRelayFileFrame(bytes) {
|
|
297
|
+
if (bytes.byteLength < RELAY_FILE_PREFIX_BYTES) {
|
|
298
|
+
throw new RelayFileFrameError("invalid_length", "Relay file frame is shorter than its prefix");
|
|
299
|
+
}
|
|
300
|
+
if (!RELAY_FILE_MAGIC_BYTES.every((byte, index) => bytes[index] === byte)) {
|
|
301
|
+
throw new RelayFileFrameError("invalid_magic", "Relay file frame magic is not MDBR");
|
|
302
|
+
}
|
|
303
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
304
|
+
if (view.getUint8(4) !== RELAY_FILE_FRAME_VERSION) {
|
|
305
|
+
throw new RelayFileFrameError("unsupported_version", "Unsupported relay file frame version");
|
|
306
|
+
}
|
|
307
|
+
const kind = relayFileKindFromCode(view.getUint8(5));
|
|
308
|
+
if (view.getUint16(6, false) !== RELAY_FILE_FRAME_FLAGS) {
|
|
309
|
+
throw new RelayFileFrameError("unsupported_flags", "Relay file frame uses unsupported flags");
|
|
310
|
+
}
|
|
311
|
+
const headerLength = view.getUint32(8, false);
|
|
312
|
+
const payloadLength = view.getUint32(12, false);
|
|
313
|
+
if (headerLength === 0) {
|
|
314
|
+
throw new RelayFileFrameError("invalid_length", "Relay file frame header must not be empty");
|
|
315
|
+
}
|
|
316
|
+
if (headerLength > MAX_RELAY_FILE_HEADER_BYTES
|
|
317
|
+
|| payloadLength > MAX_RELAY_FILE_PAYLOAD_BYTES) {
|
|
318
|
+
throw new RelayFileFrameError("limit_exceeded", "Relay file frame exceeds protocol limits");
|
|
319
|
+
}
|
|
320
|
+
const expectedLength = RELAY_FILE_PREFIX_BYTES + headerLength + payloadLength;
|
|
321
|
+
if (!Number.isSafeInteger(expectedLength) || expectedLength !== bytes.byteLength) {
|
|
322
|
+
throw new RelayFileFrameError("invalid_length", "Relay file frame lengths do not match its bytes");
|
|
323
|
+
}
|
|
324
|
+
const headerEnd = RELAY_FILE_PREFIX_BYTES + headerLength;
|
|
325
|
+
const headerBytes = bytes.subarray(RELAY_FILE_PREFIX_BYTES, headerEnd);
|
|
326
|
+
let source;
|
|
327
|
+
let parsed;
|
|
328
|
+
try {
|
|
329
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(headerBytes);
|
|
330
|
+
parsed = JSON.parse(source);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
throw new RelayFileFrameError("invalid_header", "Relay file frame header is not valid JSON");
|
|
334
|
+
}
|
|
335
|
+
const header = parseRelayHeader(parsed);
|
|
336
|
+
if (JSON.stringify(header) !== source) {
|
|
337
|
+
throw new RelayFileFrameError("invalid_header", "Relay file frame header is not canonical JSON");
|
|
338
|
+
}
|
|
339
|
+
validateRelayFrame(kind, header, payloadLength);
|
|
340
|
+
return { kind, header, payload: bytes.slice(headerEnd) };
|
|
341
|
+
}
|
|
342
|
+
function canonicalRelayHeader(header) {
|
|
343
|
+
if (header.protocol_version !== RELAY_FILE_PROTOCOL_VERSION) {
|
|
344
|
+
throw new RelayFileFrameError("unsupported_version", "Unsupported relay file protocol");
|
|
345
|
+
}
|
|
346
|
+
if (!relayFileKinds.includes(header.type)) {
|
|
347
|
+
throw new RelayFileFrameError("invalid_kind", "Unknown relay file message type");
|
|
348
|
+
}
|
|
349
|
+
for (const [name, value] of [
|
|
350
|
+
["request_id", header.request_id],
|
|
351
|
+
["grant_id", header.grant_id],
|
|
352
|
+
["transfer_id", header.transfer_id]
|
|
353
|
+
]) {
|
|
354
|
+
if (!UUID_PATTERN.test(value)) {
|
|
355
|
+
throw new RelayFileFrameError("invalid_header", `Relay file header ${name} must be a UUID`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (!Number.isSafeInteger(header.chunk_index) || header.chunk_index < 0) {
|
|
359
|
+
throw new RelayFileFrameError("invalid_header", "Relay file chunk index is invalid");
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
protocol_version: RELAY_FILE_PROTOCOL_VERSION,
|
|
363
|
+
type: header.type,
|
|
364
|
+
request_id: header.request_id,
|
|
365
|
+
grant_id: header.grant_id,
|
|
366
|
+
transfer_id: header.transfer_id,
|
|
367
|
+
chunk_index: header.chunk_index
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function parseRelayHeader(value) {
|
|
371
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
372
|
+
throw new RelayFileFrameError("invalid_header", "Relay file header must be an object");
|
|
373
|
+
}
|
|
374
|
+
const object = value;
|
|
375
|
+
const allowed = new Set([
|
|
376
|
+
"protocol_version", "type", "request_id", "grant_id", "transfer_id", "chunk_index"
|
|
377
|
+
]);
|
|
378
|
+
if (Object.keys(object).some((key) => !allowed.has(key))) {
|
|
379
|
+
throw new RelayFileFrameError("invalid_header", "Relay file header contains an unknown field");
|
|
380
|
+
}
|
|
381
|
+
return canonicalRelayHeader({
|
|
382
|
+
protocol_version: object.protocol_version,
|
|
383
|
+
type: object.type,
|
|
384
|
+
request_id: object.request_id,
|
|
385
|
+
grant_id: object.grant_id,
|
|
386
|
+
transfer_id: object.transfer_id,
|
|
387
|
+
chunk_index: object.chunk_index
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const relayFileKinds = [
|
|
391
|
+
"upload_chunk",
|
|
392
|
+
"upload_acknowledged",
|
|
393
|
+
"download_request",
|
|
394
|
+
"download_chunk",
|
|
395
|
+
"rejected"
|
|
396
|
+
];
|
|
397
|
+
function relayFileKindCode(kind) {
|
|
398
|
+
const index = relayFileKinds.indexOf(kind);
|
|
399
|
+
if (index < 0)
|
|
400
|
+
throw new RelayFileFrameError("invalid_kind", "Unknown relay file frame kind");
|
|
401
|
+
return index + 1;
|
|
402
|
+
}
|
|
403
|
+
function relayFileKindFromCode(code) {
|
|
404
|
+
const kind = relayFileKinds[code - 1];
|
|
405
|
+
if (!kind)
|
|
406
|
+
throw new RelayFileFrameError("invalid_kind", `Unknown relay file frame kind ${code}`);
|
|
407
|
+
return kind;
|
|
408
|
+
}
|
|
409
|
+
function validateRelayFrame(kind, header, payloadLength) {
|
|
410
|
+
if (kind !== header.type) {
|
|
411
|
+
throw new RelayFileFrameError("invalid_header", "Relay file kind does not match its header");
|
|
412
|
+
}
|
|
413
|
+
const carriesPayload = kind === "upload_chunk" || kind === "download_chunk";
|
|
414
|
+
if (carriesPayload !== (payloadLength > 0)) {
|
|
415
|
+
throw new RelayFileFrameError("invalid_length", "Relay file payload does not match its kind");
|
|
416
|
+
}
|
|
417
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import type { ConnectProblem } from "./connect-problems.generated.js";
|
|
2
|
+
import type { ApplicationFileRequirement, CollectionFileDescriptor, FileCapability } from "./files.js";
|
|
3
|
+
export * from "./connect-problems.generated.js";
|
|
4
|
+
export * from "./files.js";
|
|
1
5
|
export declare const CONTROL_PROTOCOL_VERSION: 1;
|
|
2
6
|
export declare const ENCRYPTED_RELAY_PROTOCOL_VERSION: 1;
|
|
3
7
|
export declare const LOOPBACK_PROTOCOL_VERSION: 1;
|
|
@@ -5,8 +9,9 @@ export declare const DEFAULT_LOOPBACK_PORT: 28485;
|
|
|
5
9
|
export declare const RELAY_ENCRYPTION_SUITE: "P256-HKDF-SHA256-AES256GCM";
|
|
6
10
|
export declare const SYNC_PROTOCOL_VERSION: 1;
|
|
7
11
|
export declare const CONTRACT_SETUP_CAPABILITY: "contract-setup-v1";
|
|
12
|
+
export declare const FILE_RELAY_CAPABILITY: "file-relay-v1";
|
|
8
13
|
export declare const RELAY_REQUIRED_CAPABILITIES: readonly ["authorization-activation", "encrypted-relay", "policy-ack"];
|
|
9
|
-
export declare const RELAY_CAPABILITIES: readonly ["authorization-activation", "encrypted-relay", "policy-ack", "contract-setup-v1"];
|
|
14
|
+
export declare const RELAY_CAPABILITIES: readonly ["authorization-activation", "encrypted-relay", "policy-ack", "contract-setup-v1", "file-relay-v1"];
|
|
10
15
|
export declare const AUTHORITY_PROOF_VERSION: 1;
|
|
11
16
|
export declare const AUTHORITY_PROOF_ALGORITHM: "P256-SHA256";
|
|
12
17
|
export declare const AUTHORITY_PROOF_DOMAIN: "mdbase-authority-request-proof-v1";
|
|
@@ -29,6 +34,7 @@ export declare const CONNECT_SCHEMA_IDS: {
|
|
|
29
34
|
readonly eventActionInterop: "https://mdbase.dev/schemas/interop/v0.1/profile.schema.json";
|
|
30
35
|
readonly protocol: "https://mdbase.dev/connect/schemas/connect-protocol.v1.json";
|
|
31
36
|
readonly encryptedRelay: "https://mdbase.dev/connect/schemas/encrypted-relay.v1.json";
|
|
37
|
+
readonly files: "https://mdbase.dev/connect/schemas/files.v1.json";
|
|
32
38
|
readonly sync: "https://mdbase.dev/connect/schemas/sync.v1.json";
|
|
33
39
|
};
|
|
34
40
|
export type CollectionOperation = "describe" | "changes" | "read" | "query" | "list_views" | "execute_view" | "read_view_source" | "create_view_source" | "update_view_source" | "delete_view_source" | "validate" | "create" | "update" | "delete" | "rename" | "read_type" | "create_type" | "update_type" | "install_type_pack" | "list_timers" | "put_timer" | "cancel_timer" | "reconcile_timers" | "sync";
|
|
@@ -143,6 +149,8 @@ export interface ApplicationRequirements {
|
|
|
143
149
|
access?: "contract" | "full_collection";
|
|
144
150
|
/** Restrict authorization to durable provider-backed collections. */
|
|
145
151
|
collection_kind?: "hosted";
|
|
152
|
+
/** First-class non-Markdown file access requested independently of records. */
|
|
153
|
+
files?: ApplicationFileRequirement;
|
|
146
154
|
}
|
|
147
155
|
export interface TypePackManifestResource {
|
|
148
156
|
kind: "contract" | "type" | "schema";
|
|
@@ -217,17 +225,19 @@ export interface RelayOperationRequest {
|
|
|
217
225
|
operation: CollectionOperation;
|
|
218
226
|
input: unknown;
|
|
219
227
|
}
|
|
220
|
-
export
|
|
228
|
+
export type RelayOperationResponse = {
|
|
221
229
|
type: "operation_response";
|
|
222
230
|
protocol_version: 1;
|
|
223
231
|
request_id: string;
|
|
224
|
-
ok:
|
|
225
|
-
result
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
ok: true;
|
|
233
|
+
result: unknown;
|
|
234
|
+
} | {
|
|
235
|
+
type: "operation_response";
|
|
236
|
+
protocol_version: 1;
|
|
237
|
+
request_id: string;
|
|
238
|
+
ok: false;
|
|
239
|
+
problem: ConnectProblem;
|
|
240
|
+
};
|
|
231
241
|
export interface MdbaseOperationRequest<Input = unknown> {
|
|
232
242
|
protocol_version: 1;
|
|
233
243
|
request_id: string;
|
|
@@ -242,11 +252,7 @@ export type MdbaseOperationResponse<Result = unknown> = {
|
|
|
242
252
|
protocol_version: 1;
|
|
243
253
|
request_id: string;
|
|
244
254
|
ok: false;
|
|
245
|
-
|
|
246
|
-
code: string;
|
|
247
|
-
message: string;
|
|
248
|
-
details?: unknown;
|
|
249
|
-
};
|
|
255
|
+
problem: ConnectProblem;
|
|
250
256
|
};
|
|
251
257
|
export interface GrantPolicy {
|
|
252
258
|
id: string;
|
|
@@ -268,6 +274,7 @@ export interface GrantPolicy {
|
|
|
268
274
|
notification_criteria?: NotificationCriterion[];
|
|
269
275
|
created_at: string;
|
|
270
276
|
encryption?: GrantEncryption;
|
|
277
|
+
file_capability?: FileCapability;
|
|
271
278
|
}
|
|
272
279
|
export interface GrantEncryption {
|
|
273
280
|
protocol_version: 1;
|
|
@@ -287,12 +294,14 @@ export interface EncryptedRelayEnvelope {
|
|
|
287
294
|
application_id: string;
|
|
288
295
|
connector_id: string;
|
|
289
296
|
collection_id: string;
|
|
290
|
-
operation:
|
|
297
|
+
operation: EncryptedRelayOperation;
|
|
291
298
|
scope_epoch: number;
|
|
292
299
|
key_id: string;
|
|
293
300
|
counter: string;
|
|
294
301
|
ciphertext: string;
|
|
295
302
|
}
|
|
303
|
+
/** Encrypted control namespaces share grant authentication but not record permissions. */
|
|
304
|
+
export type EncryptedRelayOperation = CollectionOperation | "file_control";
|
|
296
305
|
export interface SyncRecord<Frontmatter extends JsonObject = JsonObject> {
|
|
297
306
|
record_id: string;
|
|
298
307
|
path: string;
|
|
@@ -327,6 +336,7 @@ export interface AuthoritySnapshot<Frontmatter extends JsonObject = JsonObject>
|
|
|
327
336
|
manifest_digest: string;
|
|
328
337
|
resources: SyncCollectionResources;
|
|
329
338
|
records: Array<AuthoritySnapshotRecord<Frontmatter>>;
|
|
339
|
+
files: CollectionFileDescriptor[];
|
|
330
340
|
}
|
|
331
341
|
export interface AuthorityImportManifest {
|
|
332
342
|
protocol_version: 1;
|
|
@@ -336,6 +346,8 @@ export interface AuthorityImportManifest {
|
|
|
336
346
|
manifest_digest: string;
|
|
337
347
|
resources: SyncCollectionResources;
|
|
338
348
|
record_count: number;
|
|
349
|
+
file_count: number;
|
|
350
|
+
files: CollectionFileDescriptor[];
|
|
339
351
|
}
|
|
340
352
|
export interface AuthorityImportRecord {
|
|
341
353
|
record_id: string;
|
|
@@ -350,6 +362,7 @@ export interface AuthorityImportSnapshot {
|
|
|
350
362
|
manifest_digest: string;
|
|
351
363
|
resources: SyncCollectionResources;
|
|
352
364
|
records: AuthorityImportRecord[];
|
|
365
|
+
files: CollectionFileDescriptor[];
|
|
353
366
|
}
|
|
354
367
|
export interface AuthorityImportRecordPage {
|
|
355
368
|
protocol_version: 1;
|
|
@@ -380,6 +393,16 @@ export interface SyncSnapshotRecord<Frontmatter extends JsonObject = JsonObject>
|
|
|
380
393
|
/** Exact bytes whose SHA-256 revision and parsed metadata match this record. */
|
|
381
394
|
document: string;
|
|
382
395
|
}
|
|
396
|
+
/** Manifest-only snapshot page. File bytes are fetched by digest via the file data plane. */
|
|
397
|
+
export interface SyncFileSnapshotPage {
|
|
398
|
+
protocol_version: 1;
|
|
399
|
+
type: "file_snapshot_page";
|
|
400
|
+
snapshot_id: string;
|
|
401
|
+
scope_epoch: number;
|
|
402
|
+
cursor: number;
|
|
403
|
+
files: CollectionFileDescriptor[];
|
|
404
|
+
next_page?: string;
|
|
405
|
+
}
|
|
383
406
|
export type SyncChange<Frontmatter extends JsonObject = JsonObject> = {
|
|
384
407
|
sequence: number;
|
|
385
408
|
type: "put";
|
|
@@ -390,6 +413,16 @@ export type SyncChange<Frontmatter extends JsonObject = JsonObject> = {
|
|
|
390
413
|
record_id: string;
|
|
391
414
|
previous_path: string;
|
|
392
415
|
revision: string;
|
|
416
|
+
} | {
|
|
417
|
+
sequence: number;
|
|
418
|
+
type: "file_put";
|
|
419
|
+
file: CollectionFileDescriptor;
|
|
420
|
+
} | {
|
|
421
|
+
sequence: number;
|
|
422
|
+
type: "file_remove";
|
|
423
|
+
file_id: string;
|
|
424
|
+
previous_path: string;
|
|
425
|
+
revision: string;
|
|
393
426
|
};
|
|
394
427
|
export interface SyncChangesPage<Frontmatter extends JsonObject = JsonObject> {
|
|
395
428
|
protocol_version: 1;
|
|
@@ -411,6 +444,31 @@ export interface SyncMutation {
|
|
|
411
444
|
created_at: string;
|
|
412
445
|
causal_predecessor?: string;
|
|
413
446
|
}
|
|
447
|
+
interface SyncFileMutationBase {
|
|
448
|
+
mutation_id: string;
|
|
449
|
+
replica_id: string;
|
|
450
|
+
scope_epoch: number;
|
|
451
|
+
file_id: string;
|
|
452
|
+
created_at: string;
|
|
453
|
+
causal_predecessor?: string;
|
|
454
|
+
}
|
|
455
|
+
export type SyncFileMutation = (SyncFileMutationBase & {
|
|
456
|
+
operation: "file_put";
|
|
457
|
+
base_revision?: string;
|
|
458
|
+
path: string;
|
|
459
|
+
transfer_id: string;
|
|
460
|
+
content_digest: `sha256:${string}`;
|
|
461
|
+
size: number;
|
|
462
|
+
media_type?: string;
|
|
463
|
+
}) | (SyncFileMutationBase & {
|
|
464
|
+
operation: "file_move";
|
|
465
|
+
base_revision: string;
|
|
466
|
+
path: string;
|
|
467
|
+
update_references: boolean;
|
|
468
|
+
}) | (SyncFileMutationBase & {
|
|
469
|
+
operation: "file_delete";
|
|
470
|
+
base_revision: string;
|
|
471
|
+
});
|
|
414
472
|
export interface SyncConflict<Frontmatter extends JsonObject = JsonObject> {
|
|
415
473
|
record_id: string;
|
|
416
474
|
mutation: SyncMutation;
|
|
@@ -434,6 +492,29 @@ export type SyncMutationReceipt<Frontmatter extends JsonObject = JsonObject> = {
|
|
|
434
492
|
message: string;
|
|
435
493
|
};
|
|
436
494
|
};
|
|
495
|
+
export interface SyncFileConflict {
|
|
496
|
+
file_id: string;
|
|
497
|
+
mutation: SyncFileMutation;
|
|
498
|
+
current?: CollectionFileDescriptor;
|
|
499
|
+
current_revision?: string;
|
|
500
|
+
}
|
|
501
|
+
export type SyncFileMutationReceipt = {
|
|
502
|
+
mutation_id: string;
|
|
503
|
+
status: "file_applied" | "file_previously_applied";
|
|
504
|
+
sequence: number;
|
|
505
|
+
file?: CollectionFileDescriptor;
|
|
506
|
+
} | {
|
|
507
|
+
mutation_id: string;
|
|
508
|
+
status: "file_conflicted";
|
|
509
|
+
conflict: SyncFileConflict;
|
|
510
|
+
} | {
|
|
511
|
+
mutation_id: string;
|
|
512
|
+
status: "file_rejected";
|
|
513
|
+
error: {
|
|
514
|
+
code: string;
|
|
515
|
+
message: string;
|
|
516
|
+
};
|
|
517
|
+
};
|
|
437
518
|
export type EncryptedRelayOperationRequest = EncryptedRelayEnvelope & {
|
|
438
519
|
type: "encrypted_operation_request";
|
|
439
520
|
};
|
|
@@ -516,6 +597,7 @@ export interface ConnectorCollection {
|
|
|
516
597
|
}
|
|
517
598
|
export type JsonObject = Record<string, unknown>;
|
|
518
599
|
export interface MdbaseDiagnostic {
|
|
600
|
+
[key: string]: unknown;
|
|
519
601
|
severity: "error" | "warning" | "info";
|
|
520
602
|
code: string;
|
|
521
603
|
message: string;
|
|
@@ -753,4 +835,3 @@ export interface CollectionChangesPage {
|
|
|
753
835
|
has_more: boolean;
|
|
754
836
|
reset: boolean;
|
|
755
837
|
}
|
|
756
|
-
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export * from "./connect-problems.generated.js";
|
|
2
|
+
export * from "./files.js";
|
|
1
3
|
export const CONTROL_PROTOCOL_VERSION = 1;
|
|
2
4
|
export const ENCRYPTED_RELAY_PROTOCOL_VERSION = 1;
|
|
3
5
|
export const LOOPBACK_PROTOCOL_VERSION = 1;
|
|
@@ -5,6 +7,7 @@ export const DEFAULT_LOOPBACK_PORT = 28_485;
|
|
|
5
7
|
export const RELAY_ENCRYPTION_SUITE = "P256-HKDF-SHA256-AES256GCM";
|
|
6
8
|
export const SYNC_PROTOCOL_VERSION = 1;
|
|
7
9
|
export const CONTRACT_SETUP_CAPABILITY = "contract-setup-v1";
|
|
10
|
+
export const FILE_RELAY_CAPABILITY = "file-relay-v1";
|
|
8
11
|
export const RELAY_REQUIRED_CAPABILITIES = [
|
|
9
12
|
"authorization-activation",
|
|
10
13
|
"encrypted-relay",
|
|
@@ -12,7 +15,8 @@ export const RELAY_REQUIRED_CAPABILITIES = [
|
|
|
12
15
|
];
|
|
13
16
|
export const RELAY_CAPABILITIES = [
|
|
14
17
|
...RELAY_REQUIRED_CAPABILITIES,
|
|
15
|
-
CONTRACT_SETUP_CAPABILITY
|
|
18
|
+
CONTRACT_SETUP_CAPABILITY,
|
|
19
|
+
FILE_RELAY_CAPABILITY
|
|
16
20
|
];
|
|
17
21
|
export const AUTHORITY_PROOF_VERSION = 1;
|
|
18
22
|
export const AUTHORITY_PROOF_ALGORITHM = "P256-SHA256";
|
|
@@ -53,5 +57,6 @@ export const CONNECT_SCHEMA_IDS = {
|
|
|
53
57
|
eventActionInterop: "https://mdbase.dev/schemas/interop/v0.1/profile.schema.json",
|
|
54
58
|
protocol: "https://mdbase.dev/connect/schemas/connect-protocol.v1.json",
|
|
55
59
|
encryptedRelay: "https://mdbase.dev/connect/schemas/encrypted-relay.v1.json",
|
|
60
|
+
files: "https://mdbase.dev/connect/schemas/files.v1.json",
|
|
56
61
|
sync: "https://mdbase.dev/connect/schemas/sync.v1.json"
|
|
57
62
|
};
|