@filefreak.io/sdk 0.2.0 → 0.3.1

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 CHANGED
@@ -6,8 +6,8 @@ later and in browsers. No dependencies.
6
6
  Every endpoint is a function, typed from the same table the API is built
7
7
  from. Around them: uploads sent straight to storage in parts and carried on
8
8
  after an interruption, downloads fetched in ranges, listings and changes read
9
- to the end, files under Shard Protection read through their maps, what a
10
- share link holds saved with no key, and requests sent again only when that
9
+ to the end, files under Shard Protection sealed as they are sent and read
10
+ through their maps, what a share link holds saved with no key, and requests sent again only when that
11
11
  cannot make anything twice. The reference is at
12
12
  https://filefreak.io/developers/reference.
13
13
 
@@ -34,6 +34,10 @@ than memory is fine. `toPath` writes beside the file and gives it its name
34
34
  only once every byte is there: a download that fails leaves nothing behind.
35
35
  A file under Shard Protection has no address, and `download` and `bytes`
36
36
  read it through its map instead, each fragment checked as it arrives.
37
+ Into a folder under Shard Protection, `upload` seals each part with the
38
+ file's own key before it is sent, so none of the file is ever plain in
39
+ storage. The key is asked of the API for the upload and kept nowhere, and
40
+ an upload carried on asks which fragments storage has before it sends any.
37
41
 
38
42
  Keep keys on a server. A key sent from a web page is refused.
39
43
 
@@ -25,6 +25,8 @@ export interface Item {
25
25
  clientSha256: string | null;
26
26
  /** Whether the file is under Shard Protection: kept as sealed fragments, and read through its map (files.shards) rather than from an address. */
27
27
  protected: boolean;
28
+ /** Its own Shard Protection setting. inherit follows the nearest folder above it that has one, and with none the drive's default: the app's, which its owner turns on from the app's page, or for a device the account's. The nearest setting wins. A file is converted within a few minutes of a change, and protected says when it has been. */
29
+ protection: "on" | "off" | "inherit";
28
30
  createdAt: string;
29
31
  updatedAt: string;
30
32
  }
@@ -214,6 +216,8 @@ export type FilesUpdateBody = {
214
216
  parentId?: string | null;
215
217
  /** public, private, or inherit to follow the folder it is in. */
216
218
  visibility?: "private" | "public" | "inherit";
219
+ /** on, off, or inherit to follow the folder it is in. */
220
+ protection?: "on" | "off" | "inherit";
217
221
  };
218
222
  /** What PATCH /v1/files/:id answers with. */
219
223
  export type FilesUpdateAnswer = {
@@ -304,6 +308,8 @@ export type UploadsBeginBody = {
304
308
  /** The block's SHA-256 in hex. */
305
309
  sha256: string;
306
310
  }>;
311
+ /** true when this program can seal fragments as it sends them, as the SDK and the CLI do. Into a protected place the answer's shards is then set, and each part is a fragment. */
312
+ protectable?: boolean;
307
313
  };
308
314
  /** What POST /v1/uploads answers with. */
309
315
  export type UploadsBeginAnswer = {
@@ -312,6 +318,15 @@ export type UploadsBeginAnswer = {
312
318
  parts: Array<Part>;
313
319
  /** For an upload begun with blocks: the parts not to send, because storage has them already, from the file this replaces, or because they repeat a block an earlier part sends. Empty otherwise. Nothing is taken from the file this replaces when the key may not read it, when the file has not been opened or changed in 90 days, or when copying it would take the account past 1000 GB copied this way in an hour: every block is sent then. */
314
320
  reused: Array<number>;
321
+ /** Set when the upload is sealed as it is sent: how the file is cut into fragments. The key to seal them with is given by uploads.get. Null for an upload sent as it is. */
322
+ shards: {
323
+ /** The content's own id, which each fragment's seal is bound to with its place and the count. */
324
+ contentId: string;
325
+ /** How many bytes of the file each fragment holds, except the last, which holds what remains. */
326
+ fragmentBytes: number;
327
+ /** How many fragments, one to each part. */
328
+ count: number;
329
+ } | null;
315
330
  };
316
331
  /** What GET /v1/uploads/:id answers with. */
317
332
  export type UploadsGetAnswer = {
@@ -325,6 +340,17 @@ export type UploadsGetAnswer = {
325
340
  }> | null;
326
341
  /** As in the answer that began the upload: the parts not to send. */
327
342
  reused: Array<number>;
343
+ /** For an upload sealed as it is sent: how the file is cut, and the key to seal each fragment with. Null for an upload sent as it is, and for one that has ended. */
344
+ shards: {
345
+ /** The content's own id, which each fragment's seal is bound to with its place and the count. */
346
+ contentId: string;
347
+ /** The file's own key, in base64: 32 bytes, which open its fragments. In this answer and nowhere else. */
348
+ key: string;
349
+ /** How many bytes of the file each fragment holds, except the last, which holds what remains. */
350
+ fragmentBytes: number;
351
+ /** How many fragments, one to each part. */
352
+ count: number;
353
+ } | null;
328
354
  };
329
355
  /** What GET /v1/uploads/:id/parts takes in its query. */
330
356
  export type UploadsPartsQuery = {
@@ -336,6 +362,20 @@ export type UploadsPartsQuery = {
336
362
  export type UploadsPartsAnswer = {
337
363
  parts: Array<Part>;
338
364
  };
365
+ /** What POST /v1/uploads/:id/fragments takes as its body. */
366
+ export type UploadsFragmentsBody = {
367
+ /** The places of fragments that have been sent: up to 100 at a time. */
368
+ places: Array<number>;
369
+ };
370
+ /** What POST /v1/uploads/:id/fragments answers with. */
371
+ export type UploadsFragmentsAnswer = {
372
+ /** At their own names, the size their places hold, and recorded. Never send them again. */
373
+ recorded: Array<number>;
374
+ /** Still being copied, or being confirmed by another request. Ask again shortly. */
375
+ pending: Array<number>;
376
+ /** Not in storage, or not their size and removed. Seal each again, under a new nonce, and send and confirm it. */
377
+ resend: Array<number>;
378
+ };
339
379
  /** What POST /v1/uploads/:id/complete takes as its body. */
340
380
  export type UploadsCompleteBody = {
341
381
  /** The file's SHA-256 in hex, as you worked it out. Kept with the file as your word, since this service never sees the bytes, and given back as clientSha256, so a copy can be checked later without reading the file again. */
@@ -396,8 +436,8 @@ export type UsageGetAnswer = {
396
436
  plan: string;
397
437
  storageUsedBytes: number;
398
438
  storageIncludedBytes: number;
399
- /** Where uploads are refused: what the plan includes, plus what the account's spending limit allows beyond it. */
400
- storageLimitBytes: number;
439
+ /** Where uploads are refused: what the plan includes, plus what the account's spending limit allows beyond it. Null when nothing is: Pro that FileFreak has given the account has no storage limit. */
440
+ storageLimitBytes: number | null;
401
441
  downloadedBytesThisMonth: number;
402
442
  downloadsIncludedBytes: number;
403
443
  apiCallsThisMonth: number;
@@ -642,6 +682,12 @@ export declare const OPERATIONS: {
642
682
  readonly idempotent: false;
643
683
  readonly repeatable: false;
644
684
  };
685
+ readonly "uploads.fragments": {
686
+ readonly method: "POST";
687
+ readonly path: "/v1/uploads/:id/fragments";
688
+ readonly idempotent: false;
689
+ readonly repeatable: true;
690
+ };
645
691
  readonly "uploads.complete": {
646
692
  readonly method: "POST";
647
693
  readonly path: "/v1/uploads/:id/complete";
@@ -803,6 +849,8 @@ export declare function bind(call: Call): {
803
849
  *
804
850
  * Any of the three, in one request. They are applied in that order, each as its own change: if the second is refused the first has still happened, and the answer says which was refused. Moving a folder moves everything in it and is one change however much that is. Making a folder public makes everything in it public, except what has a setting of its own.
805
851
  *
852
+ * protection is a fourth, applied last: on puts the file or folder under Shard Protection, off takes it out, and inherit has it follow the folder it is in. A folder's setting reaches everything in it that has none of its own. Turning it on is refused while the account's plan does not include it (402 shard_protection_needs_pro); turning it off never is. Nothing is converted in this request: the service converts each file within a few minutes, and its protected says when. A key confined to a folder decides about what is in it, and not about the folder.
853
+ *
806
854
  * PATCH /v1/files/:id. Needs the scope files:write.
807
855
  */
808
856
  update: (id: string, body: FilesUpdateBody, options?: CallOptions) => Promise<FilesUpdateAnswer>;
@@ -889,7 +937,7 @@ export declare function bind(call: Call): {
889
937
  *
890
938
  * The room is set aside now, so an upload that would not fit is refused before a byte is sent. The parts can be sent by anything that can make a PUT request, including a browser that has never seen the API key: hand it the addresses.
891
939
  *
892
- * Into a folder under Shard Protection, which its owner turns on from the website, a file lands as an ordinary file, as it would anywhere else, and the service protects it within a few minutes: its content, and every kept version, are spread over the storage lanes as fragments. Until then it can be read like any other file. From then on it is read through its map (files.shards), and its clientSha256 is null. An upload from a program is not sealed as it is sent yet.
940
+ * Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
893
941
  *
894
942
  * POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
895
943
  */
@@ -897,7 +945,7 @@ export declare function bind(call: Call): {
897
945
  /**
898
946
  * An upload, and which parts have arrived.
899
947
  *
900
- * For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed.
948
+ * For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed. For an upload sealed as it is sent, uploadedParts are the fragments confirmed and recorded, and shards gives the key, which this API keeps nowhere else. Confirm any fragment that may have been sent before sending it again. This answer is never kept, by this API or anything between it and the caller.
901
949
  *
902
950
  * GET /v1/uploads/:id. Needs the scope files:write.
903
951
  */
@@ -905,11 +953,19 @@ export declare function bind(call: Call): {
905
953
  /**
906
954
  * Addresses for more parts.
907
955
  *
908
- * Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out.
956
+ * Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out. For an upload sealed as it is sent, each number is a fragment's place, and its address takes the sealed fragment, with its Content-MD5. Confirm fragments with uploads.fragments once they are sent.
909
957
  *
910
958
  * GET /v1/uploads/:id/parts. Needs the scope files:write.
911
959
  */
912
960
  parts: (id: string, query?: UploadsPartsQuery, options?: CallOptions) => Promise<UploadsPartsAnswer>;
961
+ /**
962
+ * Confirm sealed fragments.
963
+ *
964
+ * Confirms fragments of a sealed upload that have been sent. Each is copied inside storage to its own name, measured, and recorded once it is the size its place holds. recorded: done, never send it again. pending: storage is still copying it, or another confirmation of it is under way, so ask again shortly. resend: not there, or not its size and removed, so seal it again, under a new nonce, send it and confirm it. Safe to send again.
965
+ *
966
+ * POST /v1/uploads/:id/fragments. Needs the scope files:write. Safe to send again.
967
+ */
968
+ fragments: (id: string, body: UploadsFragmentsBody, options?: CallOptions) => Promise<UploadsFragmentsAnswer>;
913
969
  /**
914
970
  * Finish an upload.
915
971
  *
package/dist/generated.js CHANGED
@@ -17,6 +17,7 @@ export const OPERATIONS = {
17
17
  "uploads.begin": { method: "POST", path: "/v1/uploads", idempotent: true, repeatable: false },
18
18
  "uploads.get": { method: "GET", path: "/v1/uploads/:id", idempotent: false, repeatable: false },
19
19
  "uploads.parts": { method: "GET", path: "/v1/uploads/:id/parts", idempotent: false, repeatable: false },
20
+ "uploads.fragments": { method: "POST", path: "/v1/uploads/:id/fragments", idempotent: false, repeatable: true },
20
21
  "uploads.complete": { method: "POST", path: "/v1/uploads/:id/complete", idempotent: false, repeatable: true },
21
22
  "uploads.cancel": { method: "DELETE", path: "/v1/uploads/:id", idempotent: false, repeatable: false },
22
23
  "files.downloadUrl": { method: "GET", path: "/v1/files/:id/download", idempotent: false, repeatable: false },
@@ -71,6 +72,8 @@ export function bind(call) {
71
72
  *
72
73
  * Any of the three, in one request. They are applied in that order, each as its own change: if the second is refused the first has still happened, and the answer says which was refused. Moving a folder moves everything in it and is one change however much that is. Making a folder public makes everything in it public, except what has a setting of its own.
73
74
  *
75
+ * protection is a fourth, applied last: on puts the file or folder under Shard Protection, off takes it out, and inherit has it follow the folder it is in. A folder's setting reaches everything in it that has none of its own. Turning it on is refused while the account's plan does not include it (402 shard_protection_needs_pro); turning it off never is. Nothing is converted in this request: the service converts each file within a few minutes, and its protected says when. A key confined to a folder decides about what is in it, and not about the folder.
76
+ *
74
77
  * PATCH /v1/files/:id. Needs the scope files:write.
75
78
  */
76
79
  update: (id, body, options) => call("files.update", { params: { id }, body }, options),
@@ -157,7 +160,7 @@ export function bind(call) {
157
160
  *
158
161
  * The room is set aside now, so an upload that would not fit is refused before a byte is sent. The parts can be sent by anything that can make a PUT request, including a browser that has never seen the API key: hand it the addresses.
159
162
  *
160
- * Into a folder under Shard Protection, which its owner turns on from the website, a file lands as an ordinary file, as it would anywhere else, and the service protects it within a few minutes: its content, and every kept version, are spread over the storage lanes as fragments. Until then it can be read like any other file. From then on it is read through its map (files.shards), and its clientSha256 is null. An upload from a program is not sealed as it is sent yet.
163
+ * Into a place under Shard Protection, an upload begun with protectable true is sealed as it is sent. A place is protected by the setting nearest it (files.update's protection), or with none by the drive's default: an app's, which its owner turns on from the app's page, or for a device the account's. The answer's shards then says how the file is cut, and uploads.get gives its key. Each part is a fragment: the file's bytes from its number times fragmentBytes, sealed with that key as the SDK's sealFragment does, under a nonce of its own, and sent to the part's address. uploads.fragments confirms them, and completing makes the file, protected from its first byte, with a null clientSha256. A sealed upload sets blocks aside: none is kept, and none reused. Begun without protectable, a file lands as an ordinary file and the service protects it within a few minutes; from then on it is read through its map (files.shards).
161
164
  *
162
165
  * POST /v1/uploads. Needs the scope files:write. Honours an Idempotency-Key.
163
166
  */
@@ -165,7 +168,7 @@ export function bind(call) {
165
168
  /**
166
169
  * An upload, and which parts have arrived.
167
170
  *
168
- * For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed.
171
+ * For carrying on after an interruption: storage itself says which parts it has, so nothing the client remembered is needed. For an upload sealed as it is sent, uploadedParts are the fragments confirmed and recorded, and shards gives the key, which this API keeps nowhere else. Confirm any fragment that may have been sent before sending it again. This answer is never kept, by this API or anything between it and the caller.
169
172
  *
170
173
  * GET /v1/uploads/:id. Needs the scope files:write.
171
174
  */
@@ -173,11 +176,19 @@ export function bind(call) {
173
176
  /**
174
177
  * Addresses for more parts.
175
178
  *
176
- * Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out.
179
+ * Addresses are given out a window at a time and each lasts half an hour. Ask again for any that have run out. For an upload sealed as it is sent, each number is a fragment's place, and its address takes the sealed fragment, with its Content-MD5. Confirm fragments with uploads.fragments once they are sent.
177
180
  *
178
181
  * GET /v1/uploads/:id/parts. Needs the scope files:write.
179
182
  */
180
183
  parts: (id, query, options) => call("uploads.parts", { params: { id }, query }, options),
184
+ /**
185
+ * Confirm sealed fragments.
186
+ *
187
+ * Confirms fragments of a sealed upload that have been sent. Each is copied inside storage to its own name, measured, and recorded once it is the size its place holds. recorded: done, never send it again. pending: storage is still copying it, or another confirmation of it is under way, so ask again shortly. resend: not there, or not its size and removed, so seal it again, under a new nonce, send it and confirm it. Safe to send again.
188
+ *
189
+ * POST /v1/uploads/:id/fragments. Needs the scope files:write. Safe to send again.
190
+ */
191
+ fragments: (id, body, options) => call("uploads.fragments", { params: { id }, body }, options),
181
192
  /**
182
193
  * Finish an upload.
183
194
  *
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export { FileFreak, Walk, type DownloadOptions, type FollowOptions } from "./cli
2
2
  export * from "./generated.js";
3
3
  export { get, type GetOptions, type Got, type Into, type LinkedFile } from "./links.js";
4
4
  export { type Sink } from "./ranges.js";
5
- export { openFragment, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, ShardError, shardKey, ShardLayout, type FragmentAt, type ShardErrorCode, type ShardKey, } from "./shards.js";
5
+ export { checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, type FileKeys, type FragmentAt, type Outline, type OutlineBlock, type ShardErrorCode, type ShardKey, } from "./shards.js";
6
6
  export { FileFreakError, type CallOptions, type ClientOptions, type TokenSource } from "./transport.js";
7
7
  export { UploadStopped, type Source, type UploadOptions } from "./uploads.js";
package/dist/index.js CHANGED
@@ -12,6 +12,6 @@
12
12
  export { FileFreak, Walk } from "./client.js";
13
13
  export * from "./generated.js";
14
14
  export { get } from "./links.js";
15
- export { openFragment, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, ShardError, shardKey, ShardLayout, } from "./shards.js";
15
+ export { checkOutline, fileKeys, MOST_BLOCK_BYTES, openBlock, openFragment, outlineMac, sealBlock, sealFragment, SHARD_OVERHEAD, SHARD_VERSION, SHARD_VERSION_2, ShardError, shardKey, ShardLayout, } from "./shards.js";
16
16
  export { FileFreakError } from "./transport.js";
17
17
  export { UploadStopped } from "./uploads.js";
package/dist/shards.d.ts CHANGED
@@ -4,9 +4,10 @@ export declare const SHARD_OVERHEAD = 29;
4
4
  /**
5
5
  * Why a fragment or a description was refused. A reader checks in this
6
6
  * order, and says the first that fails: short, version, length,
7
- * authentication. layout is a description of a file no fragment can match.
7
+ * authentication, and in version 2 id. layout is a description of a file no
8
+ * fragment can match; outline is a version-2 outline whose MAC does not hold.
8
9
  */
9
- export type ShardErrorCode = "short" | "version" | "length" | "authentication" | "layout";
10
+ export type ShardErrorCode = "short" | "version" | "length" | "authentication" | "id" | "layout" | "outline";
10
11
  export declare class ShardError extends Error {
11
12
  readonly code: ShardErrorCode;
12
13
  constructor(code: ShardErrorCode, message: string);
@@ -43,17 +44,75 @@ export declare function shardKey(raw: Uint8Array): Promise<ShardKey>;
43
44
  /** The bytes a fragment's tag also covers: the label, the content id, the place and the count. */
44
45
  export declare function associatedData(at: FragmentAt): Uint8Array<ArrayBuffer>;
45
46
  /** One fragment of a file, encrypted and ready to store, under a nonce of its own. */
46
- export declare function sealFragment(key: ShardKey, at: FragmentAt, plaintext: Uint8Array): Promise<Uint8Array>;
47
+ export declare function sealFragment(key: ShardKey, at: FragmentAt, plaintext: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
47
48
  /**
48
49
  * sealFragment with the nonce chosen by the caller, which only the test
49
50
  * files may do: two fragments sealed under one key with one nonce give away
50
51
  * the difference between them, and the key's authentication with it. It is
51
52
  * not exported from the package.
52
53
  */
53
- export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext: Uint8Array, nonce: Uint8Array): Promise<Uint8Array>;
54
+ export declare function sealWithNonce(key: ShardKey, at: FragmentAt, plaintext: Uint8Array, nonce: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
54
55
  /**
55
56
  * A stored fragment's bytes of the file, once it is known to be this file's
56
57
  * fragment at this place, unchanged. Nothing of a fragment that fails is
57
58
  * returned: WebCrypto gives no plaintext until the tag has been checked.
58
59
  */
59
60
  export declare function openFragment(key: ShardKey, at: FragmentAt, blob: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
61
+ export declare const SHARD_VERSION_2 = 2;
62
+ /** The most bytes of a file one block holds, as the API takes blocks. */
63
+ export declare const MOST_BLOCK_BYTES: number;
64
+ /** A block id: the HMAC-SHA256 of its bytes under the file's block key. */
65
+ export declare const BLOCK_ID_BYTES = 32;
66
+ /**
67
+ * A file's key in version 2, as the three keys derived from it: never the
68
+ * key itself, so that sealing, naming blocks and vouching for the outline
69
+ * cannot be played against each other.
70
+ */
71
+ export interface FileKeys {
72
+ seal: ShardKey;
73
+ block: ShardKey;
74
+ outline: ShardKey;
75
+ }
76
+ /** One block as an outline gives it: its id and how many bytes of the file it holds. */
77
+ export interface OutlineBlock {
78
+ id: Uint8Array;
79
+ length: number;
80
+ }
81
+ /** What a content in version 2 is, as its outline gives it, before any block is fetched. */
82
+ export interface Outline {
83
+ contentId: string;
84
+ size: number;
85
+ blocks: OutlineBlock[];
86
+ }
87
+ /** The three keys of a file's key: HKDF-SHA256 with no salt, each named by its use. */
88
+ export declare function fileKeys(raw: Uint8Array): Promise<FileKeys>;
89
+ /** A block's id: what the map holds in place of a hash anybody could make of the same bytes. */
90
+ export declare function blockId(keys: FileKeys, plaintext: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
91
+ /** The bytes a block's tag also covers: the label and its id, and nothing about where it is, so that a later version can keep it as it is. */
92
+ export declare function blockAssociatedData(id: Uint8Array): Uint8Array<ArrayBuffer>;
93
+ /** One block, encrypted and ready to store under a nonce of its own, with its id. */
94
+ export declare function sealBlock(keys: FileKeys, plaintext: Uint8Array): Promise<{
95
+ id: Uint8Array<ArrayBuffer>;
96
+ blob: Uint8Array<ArrayBuffer>;
97
+ }>;
98
+ /**
99
+ * sealBlock with the id and the nonce chosen by the caller, which only the
100
+ * test files may do, as with sealWithNonce. Not exported from the package.
101
+ */
102
+ export declare function sealBlockWithNonce(keys: FileKeys, id: Uint8Array, plaintext: Uint8Array, nonce: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
103
+ /**
104
+ * A stored block's bytes of the file, once it is known to be the block the
105
+ * outline names, unchanged: in this order, short, version, length,
106
+ * authentication (the tag, which binds it to its id), and id (that its
107
+ * bytes are the ones the id was made of). Nothing of a block that fails is
108
+ * returned.
109
+ */
110
+ export declare function openBlock(keys: FileKeys, block: OutlineBlock, blob: Uint8Array): Promise<Uint8Array<ArrayBuffer>>;
111
+ /** The outline's MAC, which the writer sends with the upload's completion and every reader checks before it releases a byte. */
112
+ export declare function outlineMac(keys: FileKeys, outline: Outline): Promise<Uint8Array<ArrayBuffer>>;
113
+ /**
114
+ * Refuse an outline unless its MAC holds under the file's key: whoever has
115
+ * the map and not the key cannot reorder, drop, repeat or swap a content's
116
+ * blocks, or give one content's blocks as another's.
117
+ */
118
+ export declare function checkOutline(keys: FileKeys, outline: Outline, mac: Uint8Array): Promise<void>;
package/dist/shards.js CHANGED
@@ -1,9 +1,13 @@
1
- // The shard format, version 1: how a file under Shard Protection is kept, as
2
- // fragments each encrypted on its own. docs/reference/shard-format.md is the
3
- // definition, and docs/reference/shard-format-v1.json holds the test files
4
- // this is checked against (test/shards.test.ts), as the CLI's copy is: a
5
- // writer here whose bytes differ by one from the CLI's makes files the CLI
6
- // cannot read, and the reverse.
1
+ // The shard format: how a file under Shard Protection is kept, as fragments
2
+ // each encrypted on its own. Version 1 cuts a content into fragments of one
3
+ // size, each sealed to its content's own key, id, place and count. Version 2
4
+ // cuts it into blocks by content, sealed under a key the file's versions
5
+ // share, so that a block a new version still has can be kept as it is.
6
+ // docs/reference/shard-format.md is the definition, and
7
+ // docs/reference/shard-format-v1.json and -v2.json hold the test files this
8
+ // is checked against (test/shards.test.ts), as the CLI's copy is: a writer
9
+ // here whose bytes differ by one from the CLI's makes files the CLI cannot
10
+ // read, and the reverse.
7
11
  //
8
12
  // WebCrypto, so the same code runs in a page, in a worker and in Node.
9
13
  export const SHARD_VERSION = 1;
@@ -128,6 +132,158 @@ export async function openFragment(key, at, blob) {
128
132
  }
129
133
  return new Uint8Array(plain);
130
134
  }
135
+ // ---------------------------------------------------------------------------
136
+ // Version 2: blocks cut by content, under a file's key that versions share
137
+ // ---------------------------------------------------------------------------
138
+ export const SHARD_VERSION_2 = 2;
139
+ /** The most bytes of a file one block holds, as the API takes blocks. */
140
+ export const MOST_BLOCK_BYTES = 64 * 1024 * 1024;
141
+ /** A block id: the HMAC-SHA256 of its bytes under the file's block key. */
142
+ export const BLOCK_ID_BYTES = 32;
143
+ const LABEL_2 = new TextEncoder().encode("filefreak-shard-v2");
144
+ const OUTLINE_LABEL = new TextEncoder().encode("filefreak-outline-v2");
145
+ /** The three keys of a file's key: HKDF-SHA256 with no salt, each named by its use. */
146
+ export async function fileKeys(raw) {
147
+ if (raw.length !== 32)
148
+ throw new RangeError(`A shard key is 32 bytes, not ${raw.length}.`);
149
+ const base = await crypto.subtle.importKey("raw", new Uint8Array(raw), "HKDF", false, ["deriveBits"]);
150
+ const derive = (use) => crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode(`filefreak-shard-v2 ${use}`) }, base, 256);
151
+ const [seal, block, outline] = await Promise.all([derive("seal"), derive("block"), derive("outline")]);
152
+ const hmac = (raw) => crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
153
+ return {
154
+ seal: await crypto.subtle.importKey("raw", seal, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]),
155
+ block: await hmac(block),
156
+ outline: await hmac(outline),
157
+ };
158
+ }
159
+ /** A block's id: what the map holds in place of a hash anybody could make of the same bytes. */
160
+ export async function blockId(keys, plaintext) {
161
+ return new Uint8Array(await crypto.subtle.sign("HMAC", keys.block, new Uint8Array(plaintext)));
162
+ }
163
+ /** The bytes a block's tag also covers: the label and its id, and nothing about where it is, so that a later version can keep it as it is. */
164
+ export function blockAssociatedData(id) {
165
+ if (id.length !== BLOCK_ID_BYTES)
166
+ throw new RangeError(`A block id is ${BLOCK_ID_BYTES} bytes, not ${id.length}.`);
167
+ const out = new Uint8Array(LABEL_2.length + BLOCK_ID_BYTES);
168
+ out.set(LABEL_2, 0);
169
+ out.set(id, LABEL_2.length);
170
+ return out;
171
+ }
172
+ /** One block, encrypted and ready to store under a nonce of its own, with its id. */
173
+ export async function sealBlock(keys, plaintext) {
174
+ const id = await blockId(keys, plaintext);
175
+ return { id, blob: await sealBlockWithNonce(keys, id, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES))) };
176
+ }
177
+ /**
178
+ * sealBlock with the id and the nonce chosen by the caller, which only the
179
+ * test files may do, as with sealWithNonce. Not exported from the package.
180
+ */
181
+ export async function sealBlockWithNonce(keys, id, plaintext, nonce) {
182
+ if (plaintext.length > MOST_BLOCK_BYTES)
183
+ throw new ShardError("length", `A block holds at most ${MOST_BLOCK_BYTES} bytes of the file, not ${plaintext.length}.`);
184
+ if (nonce.length !== NONCE_BYTES)
185
+ throw new RangeError(`A nonce is ${NONCE_BYTES} bytes, not ${nonce.length}.`);
186
+ const iv = new Uint8Array(nonce);
187
+ const sealed = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: blockAssociatedData(id), tagLength: 128 }, keys.seal, new Uint8Array(plaintext));
188
+ const out = new Uint8Array(HEADER_BYTES + sealed.byteLength);
189
+ out[0] = SHARD_VERSION_2;
190
+ out.set(iv, 1);
191
+ out.set(new Uint8Array(sealed), HEADER_BYTES);
192
+ return out;
193
+ }
194
+ /**
195
+ * A stored block's bytes of the file, once it is known to be the block the
196
+ * outline names, unchanged: in this order, short, version, length,
197
+ * authentication (the tag, which binds it to its id), and id (that its
198
+ * bytes are the ones the id was made of). Nothing of a block that fails is
199
+ * returned.
200
+ */
201
+ export async function openBlock(keys, block, blob) {
202
+ const named = hexOf(block.id).slice(0, 12);
203
+ if (blob.length < SHARD_OVERHEAD) {
204
+ throw new ShardError("short", `Block ${named} is ${blob.length} bytes, shorter than any block can be (${SHARD_OVERHEAD}).`);
205
+ }
206
+ if (blob[0] !== SHARD_VERSION_2) {
207
+ throw new ShardError("version", `Block ${named} is in version ${blob[0]} of the format, where version ${SHARD_VERSION_2} was expected.`);
208
+ }
209
+ if (blob.length !== block.length + SHARD_OVERHEAD) {
210
+ throw new ShardError("length", `Block ${named} is ${blob.length} bytes, and holding ${block.length} bytes of the file it would be ${block.length + SHARD_OVERHEAD}.`);
211
+ }
212
+ let plain;
213
+ try {
214
+ plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: new Uint8Array(blob.subarray(1, HEADER_BYTES)), additionalData: blockAssociatedData(block.id), tagLength: 128 }, keys.seal, new Uint8Array(blob.subarray(HEADER_BYTES)));
215
+ }
216
+ catch (e) {
217
+ if (e instanceof DOMException && e.name === "OperationError") {
218
+ throw new ShardError("authentication", `Block ${named} is not what was written as this file's block ${named}: it has been changed, or it belongs to another file or block.`);
219
+ }
220
+ throw e;
221
+ }
222
+ const bytes = new Uint8Array(plain);
223
+ // The tag says somebody with the key sealed these bytes under this id; this says the id is theirs.
224
+ if (!(await crypto.subtle.verify("HMAC", keys.block, new Uint8Array(block.id), bytes))) {
225
+ throw new ShardError("id", `Block ${named} opens, but its bytes are not the ones its id was made of.`);
226
+ }
227
+ return bytes;
228
+ }
229
+ /** Refuse an outline that describes no file: no blocks, a block too long, an empty block in a file that is not empty, or lengths that do not add up. */
230
+ function checkShape(outline) {
231
+ const { size, blocks } = outline;
232
+ if (!Number.isSafeInteger(size) || size < 0)
233
+ throw new ShardError("layout", `A file's size is a whole number of bytes, not ${size}.`);
234
+ if (blocks.length === 0)
235
+ throw new ShardError("layout", "An outline names at least one block: an empty file is one empty block.");
236
+ if (blocks.length > MOST_FRAGMENTS)
237
+ throw new ShardError("layout", `An outline of ${blocks.length} blocks is more than the format can number.`);
238
+ let total = 0;
239
+ for (const [place, b] of blocks.entries()) {
240
+ if (b.id.length !== BLOCK_ID_BYTES)
241
+ throw new ShardError("layout", `Block ${place}'s id is ${b.id.length} bytes, not ${BLOCK_ID_BYTES}.`);
242
+ if (!Number.isSafeInteger(b.length) || b.length < 0 || b.length > MOST_BLOCK_BYTES) {
243
+ throw new ShardError("layout", `Block ${place} holds ${b.length} bytes, outside 0 to ${MOST_BLOCK_BYTES}.`);
244
+ }
245
+ if (b.length === 0 && size > 0)
246
+ throw new ShardError("layout", `Block ${place} is empty, and only an empty file has an empty block.`);
247
+ total += b.length;
248
+ }
249
+ if (total !== size)
250
+ throw new ShardError("layout", `The blocks hold ${total} bytes, and the file is ${size}.`);
251
+ }
252
+ /** The bytes the outline's MAC is over: the label, the content id, the size, the count, and each block's id and length in order. */
253
+ function outlineBytes(outline) {
254
+ const head = OUTLINE_LABEL.length + 16 + 8 + 4;
255
+ const out = new Uint8Array(head + outline.blocks.length * (BLOCK_ID_BYTES + 4));
256
+ const view = new DataView(out.buffer);
257
+ out.set(OUTLINE_LABEL, 0);
258
+ out.set(uuidBytes(outline.contentId), OUTLINE_LABEL.length);
259
+ view.setBigUint64(OUTLINE_LABEL.length + 16, BigInt(outline.size), false);
260
+ view.setUint32(OUTLINE_LABEL.length + 24, outline.blocks.length, false);
261
+ for (const [i, b] of outline.blocks.entries()) {
262
+ const at = head + i * (BLOCK_ID_BYTES + 4);
263
+ out.set(b.id, at);
264
+ view.setUint32(at + BLOCK_ID_BYTES, b.length, false);
265
+ }
266
+ return out;
267
+ }
268
+ /** The outline's MAC, which the writer sends with the upload's completion and every reader checks before it releases a byte. */
269
+ export async function outlineMac(keys, outline) {
270
+ checkShape(outline);
271
+ return new Uint8Array(await crypto.subtle.sign("HMAC", keys.outline, outlineBytes(outline)));
272
+ }
273
+ /**
274
+ * Refuse an outline unless its MAC holds under the file's key: whoever has
275
+ * the map and not the key cannot reorder, drop, repeat or swap a content's
276
+ * blocks, or give one content's blocks as another's.
277
+ */
278
+ export async function checkOutline(keys, outline, mac) {
279
+ checkShape(outline);
280
+ if (!(await crypto.subtle.verify("HMAC", keys.outline, new Uint8Array(mac), outlineBytes(outline)))) {
281
+ throw new ShardError("outline", "The file's outline is not what was written for it: its blocks have been reordered, dropped or swapped, or it is another file's.");
282
+ }
283
+ }
284
+ function hexOf(b) {
285
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
286
+ }
131
287
  function uuidBytes(id) {
132
288
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
133
289
  throw new RangeError(`A content id is a UUID, and ${JSON.stringify(id)} is not one.`);
package/dist/uploads.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
1
+ import type { FilesGetAnswer, Item, Upload, UploadsBeginAnswer, UploadsBeginBody, UploadsCompleteAnswer, UploadsCompleteBody, UploadsFragmentsAnswer, UploadsFragmentsBody, UploadsGetAnswer, UploadsPartsAnswer, UploadsPartsQuery } from "./generated.js";
2
2
  import { type CallOptions } from "./transport.js";
3
3
  /** What can be uploaded. A File's name and type are used when none are given. */
4
4
  export type Source = Blob | ArrayBuffer | Uint8Array;
@@ -45,6 +45,7 @@ export interface UploadCalls {
45
45
  begin(body: UploadsBeginBody, options?: CallOptions): Promise<UploadsBeginAnswer>;
46
46
  get(id: string, options?: CallOptions): Promise<UploadsGetAnswer>;
47
47
  parts(id: string, query?: UploadsPartsQuery, options?: CallOptions): Promise<UploadsPartsAnswer>;
48
+ fragments(id: string, body: UploadsFragmentsBody, options?: CallOptions): Promise<UploadsFragmentsAnswer>;
48
49
  complete(id: string, body?: UploadsCompleteBody, options?: CallOptions): Promise<UploadsCompleteAnswer>;
49
50
  file(id: string, options?: CallOptions): Promise<FilesGetAnswer>;
50
51
  }
package/dist/uploads.js CHANGED
@@ -17,7 +17,21 @@
17
17
  //
18
18
  // A part is read into memory whole to be hashed and sent, so the memory an
19
19
  // upload needs is about partBytes times the parts sent at once.
20
+ //
21
+ // SEALED AS IT IS SENT (plan 04, F6 step 4, answer 46). Every begin says the
22
+ // SDK can seal (protectable), as the website and the CLI do. Into a place
23
+ // under Shard Protection the API answers with shards, and each part is then
24
+ // a fragment: the file's bytes at that place, sealed with the file's own key
25
+ // (sealFragment, a nonce of its own each time) before it is hashed and sent.
26
+ // The key comes from uploads.get, whose answer nothing keeps, and is held
27
+ // only while the upload runs. Fragments sent are confirmed (uploads.fragments)
28
+ // as they go, at most one confirmation at a time: each is copied inside
29
+ // storage to its own name and recorded, or is to be sealed again and sent.
30
+ // Carrying on, every fragment not recorded is confirmed before it is sent
31
+ // again, since storage may hold it already. A fragment is up to 64 MiB and
32
+ // sealed in memory, so the memory a sealed upload needs is the same.
20
33
  import { base64, Md5 } from "./md5.js";
34
+ import { sealFragment, ShardError, shardKey, ShardLayout } from "./shards.js";
21
35
  import { FileFreakError, sleep } from "./transport.js";
22
36
  const MIB = 1024 * 1024;
23
37
  /** The API's bounds on a part. */
@@ -31,6 +45,14 @@ const PART_ATTEMPTS = 6;
31
45
  const ADDRESS_MARGIN_MS = 60_000;
32
46
  /** How long to go on asking while storage puts a very large file together. */
33
47
  const ASSEMBLY_PATIENCE_MS = 30 * 60_000;
48
+ /** Fragments sent are confirmed this often, or as soon as this many are waiting, and at most this many at once (the API's window). */
49
+ const CONFIRM_MS = 2000;
50
+ const CONFIRM_AT = 32;
51
+ const CONFIRM_MOST = 100;
52
+ /** How long confirming may answer only "still copying" before the upload stops and says so. */
53
+ const CONFIRM_PATIENCE_MS = 5 * 60_000;
54
+ /** How many times fragments storage did not keep are sealed again and sent before the upload stops. */
55
+ const SEALED_ROUNDS = 4;
34
56
  /** An upload that stopped part way. What arrived stays in storage until the upload expires: carry on with `resume: uploadId`, or cancel it. */
35
57
  export class UploadStopped extends Error {
36
58
  uploadId;
@@ -41,6 +63,34 @@ export class UploadStopped extends Error {
41
63
  this.name = "UploadStopped";
42
64
  }
43
65
  }
66
+ /**
67
+ * The key and the cut of a sealed upload, from what uploads.get says, with
68
+ * every fragment it has not recorded waiting to be confirmed: one sent
69
+ * before, by this process or another, may be in storage already.
70
+ */
71
+ async function sealingOf(state, size) {
72
+ const shards = state.shards;
73
+ if (!shards)
74
+ throw new FileFreakError(0, "unreadable", "The API began a sealed upload and then gave no key for it.");
75
+ let layout;
76
+ let raw;
77
+ try {
78
+ layout = new ShardLayout(size, shards.fragmentBytes);
79
+ raw = Uint8Array.from(atob(shards.key), (c) => c.charCodeAt(0));
80
+ }
81
+ catch (e) {
82
+ throw Object.assign(new FileFreakError(0, "unreadable", "The API answered with a sealed upload whose cut or key could not be read."), { cause: e });
83
+ }
84
+ if (layout.count !== shards.count || layout.count !== state.upload.partCount || raw.length !== 32) {
85
+ throw new FileFreakError(0, "layout", `The API cut this file into ${shards.count} fragments, where ${size} bytes in fragments of ${shards.fragmentBytes} make ${layout.count}.`);
86
+ }
87
+ const recorded = new Set(state.uploadedParts);
88
+ const waiting = new Set();
89
+ for (let n = 0; n < layout.count; n++)
90
+ if (!recorded.has(n))
91
+ waiting.add(n);
92
+ return { key: await shardKey(raw), contentId: shards.contentId, layout, recorded, waiting };
93
+ }
44
94
  /** The part size to ask for: 32 MiB, halved while that would leave fewer parts than are sent at once. */
45
95
  export function partSizeFor(fileBytes, atOnce) {
46
96
  let size = 32 * MIB;
@@ -81,7 +131,8 @@ export async function upload(calls, fetcher, source, options = {}) {
81
131
  if (state.blocks) {
82
132
  throw new FileFreakError(400, "cut_by_content", "That upload was begun with its file cut by content, which this SDK does not do. Carry it on with the program that began it, or cancel it and begin again.");
83
133
  }
84
- job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0 };
134
+ const sealing = state.shards ? await sealingOf(state, size) : null;
135
+ job = { source, size, upload: state.upload, addresses: new Map(), fetching: null, done: new Set(state.uploadedParts), sentBytes: 0, sealing };
85
136
  }
86
137
  else {
87
138
  const name = options.name ?? (typeof File !== "undefined" && source instanceof File ? source.name : undefined);
@@ -93,6 +144,8 @@ export async function upload(calls, fetcher, source, options = {}) {
93
144
  sizeBytes: size,
94
145
  parentId: options.parentId ?? null,
95
146
  partBytes: Math.max(LEAST_PART, Math.min(MOST_PART, options.partBytes ?? partSizeFor(size, atOnce))),
147
+ // Into a protected place, the parts are fragments this sends sealed.
148
+ protectable: true,
96
149
  };
97
150
  if (options.onConflict)
98
151
  body.onConflict = options.onConflict;
@@ -103,12 +156,23 @@ export async function upload(calls, fetcher, source, options = {}) {
103
156
  if (options.imageSizes)
104
157
  body.imageSizes = options.imageSizes;
105
158
  const begun = await calls.begin(body, { signal, ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}) });
106
- job = { source, size, upload: begun.upload, addresses: new Map(begun.parts.map((p) => [p.number, p])), fetching: null, done: new Set(), sentBytes: 0 };
159
+ job = { source, size, upload: begun.upload, addresses: new Map(begun.parts.map((p) => [p.number, p])), fetching: null, done: new Set(), sentBytes: 0, sealing: null };
160
+ if (begun.shards) {
161
+ // Begin's answer is kept by the API under its Idempotency-Key, so the key is asked for here.
162
+ try {
163
+ job.sealing = await sealingOf(await calls.get(begun.upload.id, call), size);
164
+ }
165
+ catch (e) {
166
+ throw new UploadStopped(begun.upload.id, e);
167
+ }
168
+ // Nothing of this upload can have been sent yet.
169
+ job.sealing.waiting.clear();
170
+ }
107
171
  }
108
172
  options.onBegun?.(job.upload);
109
173
  try {
110
174
  tally(job, options);
111
- await sendParts(calls, fetcher, job, atOnce, options);
175
+ await deliver(calls, fetcher, job, atOnce, options);
112
176
  return await finish(calls, fetcher, job, atOnce, options);
113
177
  }
114
178
  catch (e) {
@@ -124,6 +188,71 @@ function tally(job, options) {
124
188
  job.sentBytes = sent;
125
189
  options.onProgress?.(sent, job.size);
126
190
  }
191
+ /** Every part storage does not have, sent: as they are for a plain upload, and confirmed until each is recorded for a sealed one. */
192
+ async function deliver(calls, fetcher, job, atOnce, options) {
193
+ const sealing = job.sealing;
194
+ if (!sealing)
195
+ return await sendParts(calls, fetcher, job, atOnce, options);
196
+ for (let round = 0;; round++) {
197
+ // Whatever may be in storage already is asked about before it is sent again.
198
+ await confirmAll(calls, job, sealing, options);
199
+ if (sealing.recorded.size === job.upload.partCount)
200
+ return;
201
+ // Round 0 sends each fragment once; each later round sends again what storage did not keep.
202
+ if (round > SEALED_ROUNDS) {
203
+ const left = job.upload.partCount - sealing.recorded.size;
204
+ throw new FileFreakError(0, "fragments_refused", `Storage did not keep ${left} of this file's fragments after ${SEALED_ROUNDS + 1} tries.`);
205
+ }
206
+ let sending = true;
207
+ const sent = sendParts(calls, fetcher, job, atOnce, options).finally(() => (sending = false));
208
+ await Promise.all([sent, confirmWhile(calls, job, sealing, () => sending, options)]);
209
+ }
210
+ }
211
+ /** While fragments are being sent: those sent are confirmed every CONFIRM_MS, or as soon as CONFIRM_AT are waiting. */
212
+ async function confirmWhile(calls, job, sealing, sending, options) {
213
+ let last = Date.now();
214
+ while (sending()) {
215
+ await sleep(100, options.signal);
216
+ if (!sending())
217
+ return;
218
+ if (sealing.waiting.size >= CONFIRM_AT || (sealing.waiting.size > 0 && Date.now() - last >= CONFIRM_MS)) {
219
+ await confirmSome(calls, job, sealing, options);
220
+ last = Date.now();
221
+ }
222
+ }
223
+ }
224
+ /** Every fragment waiting asked about until none is: each recorded, or to be sealed again and sent. */
225
+ async function confirmAll(calls, job, sealing, options) {
226
+ let heard = Date.now();
227
+ while (sealing.waiting.size > 0) {
228
+ if (await confirmSome(calls, job, sealing, options)) {
229
+ heard = Date.now();
230
+ continue;
231
+ }
232
+ if (Date.now() - heard > CONFIRM_PATIENCE_MS) {
233
+ throw new FileFreakError(0, "still_copying", "Storage has not finished taking part of this file. Nothing is lost: carry it on with resume in a few minutes.");
234
+ }
235
+ // Only fragments storage is still copying, or another confirmation holds, are left.
236
+ await sleep(CONFIRM_MS, options.signal);
237
+ }
238
+ }
239
+ /** Ask about the first CONFIRM_MOST fragments waiting. True when one was recorded or is to be sent again: either way it stops waiting. */
240
+ async function confirmSome(calls, job, sealing, options) {
241
+ const places = [...sealing.waiting].sort((a, b) => a - b).slice(0, CONFIRM_MOST);
242
+ const answer = await calls.fragments(job.upload.id, { places }, { signal: options.signal });
243
+ for (const n of answer.recorded) {
244
+ sealing.recorded.add(n);
245
+ sealing.waiting.delete(n);
246
+ job.done.add(n);
247
+ }
248
+ for (const n of answer.resend) {
249
+ sealing.waiting.delete(n);
250
+ // Sent again, sealed again: never the same nonce over other bytes.
251
+ job.done.delete(n);
252
+ }
253
+ tally(job, options);
254
+ return answer.recorded.length > 0 || answer.resend.length > 0;
255
+ }
127
256
  async function sendParts(calls, fetcher, job, atOnce, options) {
128
257
  const todo = [];
129
258
  for (let n = 0; n < job.upload.partCount; n++)
@@ -146,6 +275,7 @@ async function sendParts(calls, fetcher, job, atOnce, options) {
146
275
  return;
147
276
  await sendPart(calls, fetcher, job, n, stop.signal);
148
277
  job.done.add(n);
278
+ job.sealing?.waiting.add(n);
149
279
  tally(job, options);
150
280
  }
151
281
  };
@@ -160,16 +290,27 @@ async function sendParts(calls, fetcher, job, atOnce, options) {
160
290
  }
161
291
  }
162
292
  async function sendPart(calls, fetcher, job, n, signal) {
163
- const from = n * job.upload.partBytes;
293
+ const sealing = job.sealing;
294
+ const { start: from, end: to } = sealing ? sealing.layout.range(n) : { start: n * job.upload.partBytes, end: Math.min(job.size, (n + 1) * job.upload.partBytes) };
164
295
  let bytes;
165
296
  try {
166
- bytes = await bytesOf(job.source, from, Math.min(job.size, from + job.upload.partBytes));
297
+ bytes = await bytesOf(job.source, from, to);
167
298
  }
168
299
  catch (e) {
169
300
  // Browsers and Node both refuse to read a file that changed after it was
170
301
  // opened, which is what keeps one from going up half old and half new.
171
302
  throw Object.assign(new FileFreakError(0, "unreadable", "The file could not be read. It may have been changed, moved or deleted since it was opened: open it again, unchanged, to carry on."), { cause: e });
172
303
  }
304
+ if (sealing) {
305
+ try {
306
+ bytes = await sealFragment(sealing.key, { contentId: sealing.contentId, layout: sealing.layout, place: n }, bytes);
307
+ }
308
+ catch (e) {
309
+ if (e instanceof ShardError)
310
+ throw Object.assign(new FileFreakError(0, "layout", e.message), { cause: e });
311
+ throw e;
312
+ }
313
+ }
173
314
  const md5 = base64(new Md5().update(bytes).digest());
174
315
  for (let attempt = 1;; attempt++) {
175
316
  const part = await address(calls, job, n, signal);
@@ -249,8 +390,13 @@ async function finish(calls, fetcher, job, atOnce, options) {
249
390
  if (e instanceof FileFreakError && e.code === "upload_incomplete" && round++ < 2) {
250
391
  const have = Array.isArray(e.body?.uploadedParts) ? e.body.uploadedParts.filter((n) => typeof n === "number") : [];
251
392
  job.done = new Set(have);
393
+ if (job.sealing) {
394
+ // The fragments recorded: any other may be in storage, and is confirmed first.
395
+ job.sealing.recorded = new Set(have);
396
+ job.sealing.waiting = new Set(Array.from({ length: job.upload.partCount }, (_, n) => n).filter((n) => !job.sealing.recorded.has(n)));
397
+ }
252
398
  tally(job, options);
253
- await sendParts(calls, fetcher, job, atOnce, options);
399
+ await deliver(calls, fetcher, job, atOnce, options);
254
400
  continue;
255
401
  }
256
402
  throw e;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@filefreak.io/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "FileFreak's developer API for Node and the browser: every endpoint, uploads straight to storage in parts, downloads in ranges, and the changes to follow.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://filefreak.io/developers/reference",