Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/chunks.js

50.2 KiB, 1 run

created by r2519314175:1347, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — content-addressed chunk transport (chunks.js)
3 ------------------------------------------------------------
4 The large half of a user's work. Cross-device sync ships a
5 small encrypted manifest through /api/sync; a file too large to
6 sit inside that parcel is offloaded here instead, split into
7 content-addressed chunks the gateway holds but cannot read.
8
9 A file is read from disk in slices and each slice is sealed on its
10 own with the account's AES-GCM key, then addressed by the SHA-256
11 of its ciphertext — the one hash WebCrypto and the Rust gateway
12 both compute, so the gateway can verify an upload without ever
13 opening it. To recover the file, each piece is fetched, decrypted
14 and written straight out.
15
16 NOTHING HOLDS A WHOLE FILE, on either path. That is what allows a
17 file far larger than the tab could carry, and what allows a file
18 that is not text at all: there is no string step anywhere here.
19 Peak memory is one chunk plus one upload batch.
20
21 STABLE ADDRESSES. A seal draws a fresh IV each call, so encrypting
22 the same bytes twice yields different ciphertext and a different
23 address. Without help, changing one byte of a large file would
24 re-upload all of it. So a map from plaintext chunk hash to stored
25 address lets an unchanged chunk keep its address; only what really
26 changed is sealed again. The map is a cache, checked against the
27 gateway before use, so a stale entry costs one upload and never a
28 missing file.
29
30 TIERS. A commit tags each file free or paid. This matters at one
31 moment and it is the worst one: at the end of grace the gateway
32 evicts the paid tier and keeps the free one, so tagging everything
33 paid would lose a lapsed account its whole store rather than its
34 overflow. The plan comes from cloud.js, most recently used first,
35 drawn against the allowance the gateway reports.
36
37 THE SWEEP FLOOR. A commit declares the live set and the gateway
38 deletes everything it does not name, which is the most destructive
39 thing the gateway does on this file's say-so — and it used to obey
40 without question. It no longer will: a sweep that would take more
41 than half the account's chunks deletes NOTHING and comes back
42 `sweep_held_back`/`sweep_held`/`sweep_token`. See `commit` for what
43 this client does with that, and why it does not simply say yes.
44
45 AND WHAT IT LEAVES STANDING IS NOW SOMETHING A PERSON CAN ANSWER. The
46 notice in the rail's status strip is a button: it asks, and then re-sends
47 the parked commit with its token. The parked commit is written to localStorage,
48 because only the gateway can mint that token and a reload used to throw
49 it away — leaving chunks nobody refers to in a store nobody sweeps, on
50 an account that is billed for them.
51 ============================================================ */
52(function () {
53 'use strict';
54
55 var PATH = '/api/chunk';
56 var HAVE_BATCH = 64; // Upload at most this many pieces per request.
57
58 function log(/* ...args */) {
59 try { if (window.console && console.debug) console.debug.apply(console, ['[chunks]'].concat([].slice.call(arguments))); }
60 catch (e) { /* ignore */ }
61 }
62
63 // ── Byte helpers ───────────────────────────────────────────
64
65 /// Unpadded base64url of a byte array, matching the gateway's
66 /// `util::b64url_encode` (URL_SAFE_NO_PAD).
67 function b64urlEncode(bytes) {
68 // In blocks, not byte by byte: a chunk can be megabytes now, and appending
69 // a character at a time to build the binary string is the slowest part of
70 // an upload. The block size stays well under the argument limit of
71 // `apply`, which is what a single call would otherwise hit.
72 var bin = '', CH = 0x8000;
73 for (var i = 0; i < bytes.length; i += CH) {
74 bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
75 }
76 return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
77 }
78
79 /// Bytes from an unpadded base64url string.
80 function b64urlDecode(s) {
81 var t = s.replace(/-/g, '+').replace(/_/g, '/');
82 while (t.length % 4) t += '=';
83 var bin = atob(t), out = new Uint8Array(bin.length);
84 for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
85 return out;
86 }
87
88 /// Lowercase-hex SHA-256 of a byte array, the content address.
89 async function sha256Hex(bytes) {
90 var d = await crypto.subtle.digest('SHA-256', bytes);
91 var b = new Uint8Array(d), s = '';
92 for (var i = 0; i < b.length; i++) {
93 s += (b[i] >>> 4).toString(16);
94 s += (b[i] & 15).toString(16);
95 }
96 return s;
97 }
98
99 // ── Transport ──────────────────────────────────────────────
100 // Like sync.js: a private wrapper returning {status, json}; a chunk op's
101 // 4xx is an outcome to read, not an error to throw.
102 //
103 // THROUGH `DaimondGateway.gwFetch`, WHICH IS THE ONE COPY OF THE 401 RULE.
104 // This file called `fetch` directly and did nothing whatever about a lapsed
105 // session — the sixth copy of a wrapper that five siblings each answered a
106 // 401 in, and the worst of the six to have left out. The gateway's session
107 // lives an hour and nothing renewed it, so an hour into a sitting every op
108 // here was refused: `missing()` reports EVERY address as missing, so the next
109 // sync re-encrypts and re-uploads the whole corpus; `putChunks()` throws
110 // `chunk put failed: 401`; and the commit that lets the gateway sweep never
111 // lands, so the account's garbage is never collected. None of it was said
112 // anywhere.
113 //
114 // SAFE TO REPEAT. `handle_impl` (gateway/src/handlers/chunk.rs) takes the
115 // session in its FIRST statement — before the method check, before the body
116 // is parsed, before `op` is even read — so a 401 is proof that nothing
117 // happened: nothing stored, nothing swept, no index recorded. The second
118 // attempt cannot duplicate a side effect the first never had, and the body is
119 // a string, so the options object is reused as given.
120 //
121 // LATE-BOUND, NEVER CAPTURED. `DaimondGateway` is looked up on the global at
122 // every call rather than held in a local at load. index.html loads gateway.js
123 // at 790 and this file at 791, so the order holds today; a property lookup
124 // means it goes on holding whatever that order becomes, and it is what lets
125 // the renewal be replaced without every caller keeping a reference to the old
126 // one. `clientApi()` is read the same way: this file used to carry its own
127 // copy of the number, and two constants that have to match are two constants
128 // that will eventually not.
129 async function call(body) {
130 var r = await DaimondGateway.gwFetch(PATH, {
131 method: 'POST',
132 credentials: 'same-origin',
133 headers: {
134 'content-type': 'application/json',
135 'x-daimond-api': String(DaimondGateway.clientApi()),
136 },
137 body: JSON.stringify(body),
138 });
139 var j = null;
140 try { j = await r.json(); } catch (e) { j = null; }
141 return { status: r.status, json: j };
142 }
143
144 /// Of the given addresses, those the gateway does not hold.
145 async function missing(addrs) {
146 if (!addrs.length) return [];
147 var res = await call({ op: 'have', addrs: addrs });
148 if (res.status !== 200 || !res.json || !Array.isArray(res.json.missing)) return addrs.slice();
149 return res.json.missing;
150 }
151
152 /// Upload a batch of {addr, blob} pieces.
153 ///
154 /// THE GATEWAY'S OWN SENTENCE IS KEPT. This threw `chunk put failed: 507` and
155 /// dropped `res.json.error` on the floor, which is the difference between a
156 /// person being told "This account has reached its cloud storage limit. Delete
157 /// something, or ask for more room." and being shown a number they cannot look
158 /// up. The gateway composes four such sentences on this one route — two 507s
159 /// (the account's ceiling and the store's), a 503 and a 413 — and each of them
160 /// names the remedy, which a status code by definition cannot. Same shape as
161 /// every other refusal in this app: `(j && j.error) || ('HTTP ' + status)`,
162 /// see gateway.js, tools.js, mail.js and web.js.
163 ///
164 /// AND IT IS SAID, not merely thrown. `collectChunked` in daimond.js catches
165 /// every offload failure and discards it ("retry next sync, index unharmed"),
166 /// so an exception carrying a perfect sentence still reaches nobody. This file
167 /// already owns a chip and an event for standing facts; a refused upload is
168 /// one, and the most consequential kind, because the user's work has stopped
169 /// travelling. `standRefused` is what puts it in front of them.
170 async function putChunks(chunks) {
171 for (var i = 0; i < chunks.length; i += HAVE_BATCH) {
172 var slice = chunks.slice(i, i + HAVE_BATCH);
173 var res = await call({ op: 'put', chunks: slice });
174 if (res.status !== 200 || !res.json || !res.json.ok) {
175 var msg = (res.json && (res.json.error || res.json.message))
176 || ('HTTP ' + res.status);
177 standRefused(msg, res.status);
178 var e = new Error(msg);
179 e.status = res.status; // for a caller that wants to branch on it.
180 throw e;
181 }
182 }
183 // A batch that landed is proof the refusal has lifted: the ceiling was
184 // raised, or something was deleted, or the store stopped being busy.
185 // Nothing else clears it, because nothing else knows.
186 clearRefused();
187 }
188
189 /// Fetch one chunk's ciphertext bytes by address, or null if the gateway no
190 /// longer holds it (evicted overflow, say).
191 async function getChunk(addr) {
192 var res = await call({ op: 'get', addr: addr });
193 if (res.status !== 200 || !res.json || !res.json.present || !res.json.blob) return null;
194 return b64urlDecode(res.json.blob);
195 }
196
197 // ── The chunk map ──────────────────────────────────────────
198 // Plaintext chunk hash -> the content address its ciphertext was stored
199 // under. Because a seal draws a fresh IV every time, encrypting the same
200 // bytes twice yields different ciphertext and a different address; without
201 // this map, changing one byte of a large file would re-upload all of it.
202 // With it, only the chunks that actually changed are re-encrypted.
203 //
204 // It is a CACHE, never a source of truth: an address is used only after the
205 // gateway confirms it still holds it, so a stale entry costs one re-upload
206 // and never a missing file.
207 // Entries are `plaintextHash -> [address, ciphertextSize]`. The size is kept
208 // because a reused chunk is never re-encrypted, so its length would otherwise
209 // be unknown — and the manifest's sizes are what the gateway bills against.
210 var MAP_KEY = 'daimond-chunk-map';
211 var MAP_MAX = 5000; // entries; roughly 600 KB of localStorage, which is shared.
212
213 function readMap() {
214 try { return JSON.parse(localStorage.getItem(MAP_KEY) || '{}') || {}; }
215 catch (e) { return {}; }
216 }
217 function writeMap(m) {
218 var keys = Object.keys(m);
219 if (keys.length > MAP_MAX) {
220 // No access times here, so drop the oldest insertions -- object key
221 // order. Losing an entry costs a re-upload, nothing worse.
222 var trimmed = {};
223 keys.slice(keys.length - MAP_MAX).forEach(function (k) { trimmed[k] = m[k]; });
224 m = trimmed;
225 }
226 try { localStorage.setItem(MAP_KEY, JSON.stringify(m)); } catch (e) { /* quota: rebuilt next time */ }
227 }
228
229 // ── Surviving a passphrase change ──────────────────────────
230 //
231 // THIS FILE SEALS AT REST, NOT IN FLIGHT, so it takes part. `offloadFile`
232 // wraps each chunk with `DaimondIdentity.wrapBytes` and the ciphertext then
233 // LIVES ON THE GATEWAY, addressed by its own hash, for as long as the file is
234 // in the cloud store. A passphrase change re-derives the key, and every chunk
235 // already up there is sealed under the old one for ever.
236 //
237 // What this participant can do, and what it cannot, stated plainly because the
238 // gap is the interesting part:
239 //
240 // - IT CANNOT RE-WRAP THEM. That would mean downloading, decrypting and
241 // re-uploading the whole corpus, which can be gigabytes, over a dialog the
242 // user is waiting on — and it could not finish the job anyway, since a
243 // file this device does not hold locally cannot be re-sealed from here at
244 // all.
245 // - IT MUST NOT LET THE OLD CIPHERTEXT BE REUSED. The map from plaintext
246 // chunk hash to stored address is what makes an unchanged chunk keep its
247 // address, and the gateway still holds every one of them — so without this,
248 // a re-offload would cheerfully point the new manifest at chunks nothing
249 // can open, and the file would stay unreadable no matter how many times it
250 // was synced. The map goes.
251 // - AND THE FILES MUST ACTUALLY BE OFFLOADED AGAIN, or dropping the map
252 // changes nothing: `collectChunked` in daimond.js skips a file whose
253 // manifest matches its size and mtime, which is every file that has not
254 // been edited. So the drop is recorded, and that one skip is suspended for
255 // the next sync round only. The price of a passphrase change is one
256 // re-upload of the large files this device holds. It is paid once.
257 //
258 // What remains lost is honest to say: a large file that no device still holds
259 // locally cannot be recovered after a passphrase change, because the only copy
260 // is sealed under a key nobody has any more.
261
262 /// Set for as long as a re-offload is owed. In localStorage, not a variable,
263 /// because the change and the sync that answers it are usually separated by a
264 /// reload.
265 var STALE_KEY = 'daimond-chunk-stale';
266
267 /// Is a re-offload owed after a passphrase change?
268 function staleSinceRekey() {
269 try { return localStorage.getItem(STALE_KEY) === '1'; } catch (e) { return false; }
270 }
271
272 /// The re-offload has been made. Called by the round that made it, never by
273 /// the round that owed it.
274 function clearStale() {
275 try { localStorage.removeItem(STALE_KEY); } catch (e) { /* private mode */ }
276 }
277
278 /// Forget every address sealed under the passphrase that has just gone.
279 ///
280 /// Nothing is read out beforehand: the map holds no secret, only hashes, and
281 /// the plaintext is the file on disk.
282 function forgetMapAfterRekey() {
283 try { localStorage.removeItem(MAP_KEY); } catch (e) { /* private mode: nothing was cached */ }
284 try { localStorage.setItem(STALE_KEY, '1'); } catch (e) { /* quota: one skipped re-offload */ }
285 log('passphrase changed: chunk map dropped, large files will be offloaded again');
286 return { failed: [] };
287 }
288
289 if (window.DaimondRekey) {
290 DaimondRekey.register({
291 name: 'chunks',
292 reseal: forgetMapAfterRekey,
293 });
294 }
295
296 // ── Offload ────────────────────────────────────────────────
297
298 /// The chunk size for a file of `size` bytes.
299 ///
300 /// Small chunks localise an edit; large ones keep the manifest short, and the
301 /// manifest travels inside the sync blob, which has its own ceiling. A
302 /// gigabyte at 256 KiB would be four thousand entries. The largest must stay
303 /// under what the gateway accepts for one chunk.
304 function chunkSizeFor(size) {
305 if (size <= 64 * 1024 * 1024) return 256 * 1024;
306 if (size <= 512 * 1024 * 1024) return 1024 * 1024;
307 return 4 * 1024 * 1024;
308 }
309
310 /// How many ciphertext bytes to gather before sending a batch. Bounds both
311 /// the request and what is held in memory at once.
312 var UPLOAD_BATCH_BYTES = 4 * 1024 * 1024;
313
314 /// Offload one file from disk, a piece at a time, and return its manifest.
315 ///
316 /// `file` is a File (from an OPFS handle), so the bytes are read in slices
317 /// and NOTHING here ever holds the whole thing: peak memory is one chunk plus
318 /// one upload batch. That is what lets a file be far larger than the tab
319 /// could otherwise carry, and what lets it be binary — there is no text step
320 /// anywhere in this path.
321 ///
322 /// Two passes over the file. The first hashes each plaintext chunk, which
323 /// both fingerprints the file and finds the chunks already in the store. The
324 /// second encrypts and uploads only what is genuinely missing.
325 async function offloadFile(path, file) {
326 var size = file.size;
327 var CH = chunkSizeFor(size);
328 var n = Math.max(1, Math.ceil(size / CH));
329 var map = readMap();
330
331 // Pass one: fingerprint every chunk.
332 var phash = [], i, off, len;
333 for (i = 0; i < n; i++) {
334 off = i * CH;
335 len = Math.min(CH, size - off);
336 var slice = new Uint8Array(await file.slice(off, off + Math.max(0, len)).arrayBuffer());
337 phash.push(await sha256Hex(slice));
338 }
339 // The file's identity is the hash of its chunk hashes -- computed without
340 // ever holding the file, which a plain hash of the contents could not be.
341 var key = await sha256Hex(new TextEncoder().encode(phash.join('')));
342
343 // Which of the addresses we think we already have does the gateway still
344 // hold? Anything it has swept must be re-encrypted and sent again.
345 var known = [];
346 phash.forEach(function (h) { if (map[h]) known.push(map[h][0]); });
347 var gone = {};
348 (await missing(known)).forEach(function (a) { gone[a] = 1; });
349
350 // Pass two: encrypt and upload only what is missing.
351 var chunks = [], batch = [], batchBytes = 0, reused = 0;
352 for (i = 0; i < n; i++) {
353 var have = map[phash[i]];
354 if (have && !gone[have[0]]) {
355 chunks.push({ addr: have[0], size: have[1] | 0 });
356 reused++;
357 continue;
358 }
359 off = i * CH;
360 len = Math.min(CH, size - off);
361 var plain = new Uint8Array(await file.slice(off, off + Math.max(0, len)).arrayBuffer());
362 var ct = await DaimondIdentity.wrapBytes(plain);
363 var addr = await sha256Hex(ct);
364 map[phash[i]] = [addr, ct.length];
365 chunks.push({ addr: addr, size: ct.length });
366 batch.push({ addr: addr, blob: b64urlEncode(ct) });
367 batchBytes += ct.length;
368 if (batchBytes >= UPLOAD_BATCH_BYTES) { await putChunks(batch); batch = []; batchBytes = 0; }
369 }
370 if (batch.length) await putChunks(batch);
371 writeMap(map);
372
373 log('offloaded', path, n, 'chunks,', reused, 'reused');
374 return {
375 v: 2,
376 size: size, // plaintext bytes, so a reader knows the file.
377 key: key,
378 chunks: chunks,
379 };
380 }
381
382 /// Offload an in-memory byte array and return its manifest, the same shape
383 /// and the same stable-address machinery `offloadFile` gives a file on disk.
384 ///
385 /// A Blob answers everything `offloadFile` asks of its argument — `.size`,
386 /// `.slice(a, b)` returning something with `.arrayBuffer()` — so the whole
387 /// two-pass body is reused verbatim: the plaintext-hash→address map still
388 /// makes an unchanged payload keep its addresses, which is what lets a
389 /// Diamond or a transcript that has not moved produce a byte-identical
390 /// manifest between two collects. The one thing a Blob has not got is a name,
391 /// so `label` is passed only for the log line.
392 ///
393 /// Peak memory is the bytes plus one chunk plus one upload batch. The caller
394 /// is expected to hand this ONE item's bytes at a time and let them go, so a
395 /// store of many Diamonds is never in memory at once — that is the whole
396 /// reason the payload moves out of the inline parcel.
397 async function offloadBytes(label, bytes) {
398 return await offloadFile(label, new Blob([bytes]));
399 }
400
401 // ── Materialise ────────────────────────────────────────────
402
403 /// Recover the bytes an `offloadBytes` manifest names: each chunk is fetched,
404 /// unsealed on its own and appended, and the joined array returned — or null
405 /// if any piece is no longer held, so the caller leaves the item absent
406 /// rather than acting on a truncated one.
407 ///
408 /// The seal-per-chunk twin of `materialiseV1`, which recovers the original
409 /// whole-file scheme where one seal covered the lot. Only ONE item's chunks
410 /// are held here, so this is affordable for a single Diamond or transcript
411 /// and is never asked for the whole store at once.
412 async function materialiseBytes(manifest) {
413 if (!manifest || !Array.isArray(manifest.chunks)) return null;
414 var parts = [], total = 0;
415 for (var i = 0; i < manifest.chunks.length; i++) {
416 var bytes = await getChunk(manifest.chunks[i].addr);
417 if (bytes === null) { log('materialiseBytes: missing chunk', manifest.chunks[i].addr); return null; }
418 var plain;
419 try { plain = await DaimondIdentity.unwrapBytes(bytes); }
420 catch (e) { log('materialiseBytes: unwrap failed'); return null; }
421 parts.push(plain); total += plain.length;
422 }
423 var out = new Uint8Array(total), at = 0;
424 parts.forEach(function (p) { out.set(p, at); at += p.length; });
425 return out;
426 }
427
428 /// Stream a file back from its manifest, handing each decrypted piece to
429 /// `write` in order. Returns true on success; false if any piece is no longer
430 /// held, so the caller can leave the file absent rather than write a
431 /// truncated one.
432 ///
433 /// Nothing accumulates: one chunk is in memory at a time, and the caller
434 /// writes it straight to disk.
435 async function materialiseStream(manifest, write) {
436 if (!manifest || !Array.isArray(manifest.chunks)) return false;
437 for (var i = 0; i < manifest.chunks.length; i++) {
438 var bytes = await getChunk(manifest.chunks[i].addr);
439 if (bytes === null) { log('materialise: missing chunk', manifest.chunks[i].addr); return false; }
440 var plain;
441 try { plain = await DaimondIdentity.unwrapBytes(bytes); }
442 catch (e) { log('materialise: unwrap failed'); return false; }
443 await write(plain);
444 }
445 return true;
446 }
447
448 /// Recover a file sealed by the ORIGINAL whole-file scheme: one seal over the
449 /// entire text, base64, then split. Kept because accounts hold files stored
450 /// that way; nothing writes this shape any more.
451 async function materialiseV1(manifest) {
452 if (!manifest || !Array.isArray(manifest.chunks)) return null;
453 var total = 0, parts = [];
454 for (var i = 0; i < manifest.chunks.length; i++) {
455 var bytes = await getChunk(manifest.chunks[i].addr);
456 if (bytes === null) { log('materialise: missing chunk', manifest.chunks[i].addr); return null; }
457 parts.push(bytes); total += bytes.length;
458 }
459 var joined = new Uint8Array(total), at = 0;
460 parts.forEach(function (p) { joined.set(p, at); at += p.length; });
461 var W = new TextDecoder().decode(joined);
462 try { return await DaimondIdentity.unwrap(W); }
463 catch (e) { log('materialise: unwrap failed'); return null; }
464 }
465
466 // ── A sweep the gateway would not carry out ────────────────
467 //
468 // `sweep_chunks` had no floor: it deleted whatever a commit did not name, on
469 // one request, and a client bug did exactly that to a real account. It now
470 // refuses any sweep that would take more than half the chunks an account
471 // holds. Nothing is deleted, the commit still succeeds and the index is still
472 // recorded — the reply simply carries `sweep_held_back`, `sweep_held` and a
473 // `sweep_token`, and repeating the IDENTICAL commit with that token carries
474 // the deletion out. The token is a digest of the account and the sorted
475 // doomed addresses, so if either set moves by one chunk in between it no
476 // longer matches and the sweep is held back again.
477 //
478 // SHOULD THIS CLIENT SIMPLY SAY YES? Not unconditionally, and not for the
479 // reason it is tempting to give. A second request does NOT make this client
480 // think again: `manifests` is `DaimondCloud.index()`, a merged structure read
481 // out of localStorage, so re-deriving it a moment later yields the same
482 // answer it yielded the first time. The only genuinely independent opinion in
483 // the interlock is the gateway's own re-read of what it holds, and that
484 // catches one thing — another device having uploaded in between. So an
485 // unconditional yes really would reduce the floor to a formality.
486 //
487 // Nor can it be a permanent no. A wholesale rewrite of one large file
488 // re-chunks all of it, so a legitimate sweep over half the account is
489 // ORDINARY rather than exceptional; and the storage ceilings are computed
490 // from the committed index rather than from the chunks actually held, so
491 // held-back chunks are charged to no cap at all. Never confirming means
492 // garbage that grows without bound and is billed to nobody.
493 //
494 // WHAT IS CHECKED, AND WHAT IT PROTECTS. Nothing this client can compute
495 // distinguishes a large deletion that is right from a narrow index that is
496 // wrong — the shape is identical, which is exactly why the floor lives on the
497 // server. So the conditions below do not try to; each rules out one way the
498 // question could be being asked by something that is not in a position to ask
499 // it, and everything else is reported rather than decided:
500 //
501 // 1. The device may still declare a live set AT THE MOMENT OF CONFIRMING.
502 // `syncMayCommitChunks()` is false whenever the workspace is not
503 // syncable, and such a device holds only its own view of the index. sync
504 // checks it before the first commit; this checks it again immediately
505 // before a destructive second request, which is the window where it can
506 // change.
507 // 2. The index names something. An index naming NOTHING is the sharpest
508 // form of a client that knows nothing declaring the account empty, and
509 // it is what a workspace that failed to enumerate produces. This client
510 // never confirms one, whatever else is true.
511 // 3. The gateway's arithmetic and this client's agree: every chunk the
512 // account holds is either one this commit named live or one it named
513 // doomed (`sweep_held - sweep_held_back === entries.length`). Where the
514 // two parties cannot agree on the size of the corpus, this client does
515 // not insist on the largest deletion the gateway will accept.
516 //
517 // Once per commit, never a loop, and a sweep that is not collected is left
518 // standing on the chip rather than swallowed.
519 var heldSweep = null; // {body, n, m, token, why, at} while a deletion stands uncollected.
520 var confirmed = 0; // Large sweeps this page has carried out, for the verifier.
521 var refusal = null; // {msg, status, at} while the gateway is turning uploads away.
522 var persisted = false; // Did the standing deletion actually reach localStorage?
523
524 /// What the app says, in the reader's language, falling back to English while
525 /// a key has no entry anywhere.
526 ///
527 /// The twin of `tOr` in daimond.js and of `t` in legal.js: `DaimondI18n.t`
528 /// answers with the KEY when the table has no entry, which would put
529 /// `chunks.sweep_confirm_ok` on a button. The keys added with the control
530 /// below are new and the locale tables are another lane's file, so each
531 /// carries the English it means and the tables can catch up without this file
532 /// ever showing a bare key.
533 function t(k, fallback, v) {
534 var i18n = window.DaimondI18n;
535 if (i18n && i18n.has && i18n.has(k)) return i18n.t(k, v);
536 return fallback == null ? k : fallback;
537 }
538
539 // ── A standing deletion outlives the page that found it ────
540 //
541 // `heldSweep` used to be a module variable and nothing else, which made the
542 // whole feature a thing you had to be looking at to use: reload, and the body
543 // and the token were gone. That matters more than losing a notice, because
544 // THIS CLIENT CANNOT MINT A TOKEN. Only the gateway does, only in answer to a
545 // commit, and only over the doomed set as it stood at that moment. So a
546 // dropped token is not re-derivable here at all; it comes back when the next
547 // commit round is held back in the same way, and on a device where sync is
548 // not running — no Pro, a safe start, an unmerged workspace — that round never
549 // comes. Meanwhile the chunks nobody refers to sit in the account's store, and
550 // the storage ceilings are computed from the committed index rather than from
551 // what is held, so they are charged to no cap and swept by nothing.
552 //
553 // WHAT IS WRITTEN. The commit body — content addresses, ciphertext sizes and a
554 // one-letter tier — plus the token and the two counts. Hashes and numbers: the
555 // same class of thing the chunk map beside it already keeps, and nothing that
556 // says what any file is or contains.
557 //
558 // WHAT IT DOES NOT PROMISE. A restored token is only as good as the account it
559 // was minted against. If another device has uploaded or committed since, the
560 // gateway's digest no longer matches and the sweep is simply held back again
561 // with a fresh token, which `standHeld` records. That is the interlock working,
562 // not a failure, and it is why a stale entry is safe to keep rather than
563 // something that has to be expired on a timer.
564 var HELD_KEY = 'daimond-chunk-held';
565 /// Roughly two thousand addresses. Above this the entry stays in memory only:
566 /// localStorage is five megabytes for the whole origin and shared with the
567 /// chunk map, the chats' spill and everything else, and evicting somebody's
568 /// work to remember a deletion would be the wrong trade. `state().persisted`
569 /// says which of the two happened rather than leaving it to be guessed.
570 var HELD_MAX_BYTES = 256 * 1024;
571
572 /// Whose deletion this is: the identity fingerprint, which is a prefix of the
573 /// public key's digest and therefore not a secret.
574 ///
575 /// STORED BECAUSE THE KEY OUTLIVES THE ACCOUNT. `forgetIdentity` in daimond.js
576 /// sweeps a NAMED list of `daimond-*` keys for the primary account, and the
577 /// primary's keys are un-namespaced — so anything not on that list is
578 /// inherited whole by the next identity made in this browser. A held sweep is
579 /// a commit body for an account that no longer exists: it would paint a chip
580 /// for a stranger and, if pressed, send a token the gateway can only refuse.
581 /// Binding the record to the fingerprint answers that here rather than by
582 /// adding a line to a list in another lane's file — and it answers the same
583 /// question for a restored backup and for switching accounts.
584 function whoseFp() {
585 try { return (window.DaimondIdentity && DaimondIdentity.fingerprint()) || ''; }
586 catch (e) { return ''; }
587 }
588
589 function writeHeld() {
590 persisted = false;
591 if (!heldSweep) { try { localStorage.removeItem(HELD_KEY); } catch (e) { /* private mode */ } return; }
592 var s;
593 try { s = JSON.stringify({
594 body: heldSweep.body,
595 n: heldSweep.n,
596 m: heldSweep.m,
597 token: heldSweep.token,
598 why: heldSweep.why,
599 at: heldSweep.at,
600 fp: whoseFp(),
601 }); } catch (e) { return; }
602 if (s.length > HELD_MAX_BYTES) {
603 log('standing deletion too large to persist (', s.length, 'bytes ) — held in memory only');
604 try { localStorage.removeItem(HELD_KEY); } catch (e) { /* private mode */ }
605 return;
606 }
607 try { localStorage.setItem(HELD_KEY, s); persisted = true; }
608 catch (e) { /* quota or private mode: it stands for this sitting only */ }
609 }
610
611 /// The standing deletion this device last recorded, or null.
612 ///
613 /// Shape-checked rather than trusted: a half-written or hand-edited entry
614 /// would otherwise become a commit body, and the one thing this file must
615 /// never do is send a deletion it cannot account for.
616 function readHeld() {
617 var raw;
618 try { raw = localStorage.getItem(HELD_KEY); } catch (e) { return null; }
619 if (!raw) return null;
620 var h;
621 try { h = JSON.parse(raw); } catch (e) { return null; }
622 if (!h || typeof h !== 'object') return null;
623 if (!h.body || h.body.op !== 'commit' || !Array.isArray(h.body.chunks)) return null;
624 if (typeof h.token !== 'string' || !h.token) return null;
625 // Somebody else's deletion, or nobody's. Dropped rather than shown: see
626 // `whoseFp`.
627 var fp = whoseFp();
628 if (!fp || h.fp !== fp) {
629 try { localStorage.removeItem(HELD_KEY); } catch (e) { /* private mode */ }
630 return null;
631 }
632 return {
633 body: h.body,
634 n: h.n | 0,
635 m: h.m | 0,
636 token: h.token,
637 why: typeof h.why === 'string' ? h.why : '',
638 at: typeof h.at === 'number' ? h.at : 0,
639 };
640 }
641
642 /// The chip that says something is standing, drawn beside sync's.
643 ///
644 /// It is sync's chip in everything but ownership: same row, same shape, same
645 /// `stalled` colour, standing rather than fading, reason on hover, and never
646 /// a dialog over the app unasked. It is a separate element only because
647 /// `setStatus` is private to sync.js and a held-back sweep outlives the round
648 /// that found it — sync paints "Synced" the instant `commit` returns, so
649 /// anything this file wrote into that chip would live for no time at all.
650 ///
651 /// A BUTTON, AND THAT IS THE FIX. It was a `role="status"` div: a permanent
652 /// amber pill saying a deletion was standing, with nothing anywhere in the app
653 /// that could carry the deletion out. `confirmHeldSweep` — the only code that
654 /// re-sends the body with its token — had no production caller at all, so the
655 /// notice was the whole feature. The chip is where the fact already lives and
656 /// the rail's status strip is where this app already puts the state of the
657 /// machine "at a glance and without asking", one row per question, each row
658 /// that can be acted on a button that goes there. So the control goes here.
659 /// It was in the TOP BAR until 2026-08-28, beside `#update-chip`; that row
660 /// turned out to be a row of targets a standing notice must not appear in.
661 ///
662 /// The Credits drawer was the other candidate — it is where the app answers
663 /// "what account have I got and what does it cost me", and cloud storage is
664 /// part of that answer. It is not used, for a plain reason: `drawCredits`
665 /// belongs to daimond.js, its one published extension point
666 /// (`DaimondCredits.render`) is already taken by passcode.js, and a second
667 /// surface for a fact that is already on screen is a second thing to keep in
668 /// step. One control, where the notice is.
669 ///
670 /// `aria-live` rather than `role="status"`: the live region has to move to the
671 /// button, because a button containing a status region announces the region
672 /// and leaves the control unnamed.
673 var _chip = null;
674 function chip() {
675 if (_chip) return _chip;
676 // IN THE SYNC ROW, and for the same reason sync itself moved out of the top
677 // bar on 2026-08-28: there this was a pill that appeared among the icon
678 // buttons and pushed the whole chip row 98px sideways when it did. A notice
679 // nobody asked for must not move what somebody is reaching for.
680 //
681 // IN THAT ROW AND NOT BESIDE IT. A row of its own at the end of the strip
682 // was the first answer and it was the same fault one surface over: the
683 // strip grew when the notice arrived and the rail's own controls moved with
684 // it -- caught by `dev/verify_sweep_seen.mjs`'s ANCHOR family, which asks
685 // exactly this question of every transient it can find. `#astat-sync` is
686 // permanent and always holds one line of text, so a second occupant sharing
687 // it costs no height at all. It is the right row on the merits too: this
688 // notice "is sync's chip in everything but ownership", as the comment below
689 // says, and what it is about is whether this account's work is travelling.
690 var host = document.getElementById('astat-sync')
691 || document.getElementById('admin-status') || document.querySelector('.admin-status');
692 if (!host) return null;
693 if (!document.getElementById('chunk-status-styles')) {
694 var st = document.createElement('style');
695 st.id = 'chunk-status-styles';
696 st.textContent =
697 // `flex: 0 1 auto`, not a width: it SHARES the sync row, so when both
698 // have something to say each takes what it needs and ellipsises,
699 // and the row is one line either way.
700 '#chunk-chip{display:none;flex:0 1 auto;min-width:0;align-items:center;gap:6px;text-align:left;' +
701 // NO PADDING OF ITS OWN. It is an occupant of `#astat-sync`, not a row:
702 // its 3px top and bottom made that row 36px where every other row in
703 // the strip is 30, and the six pixels moved ten controls above it.
704 'font:inherit;font-size:var(--fs-xs);padding:0;border:none;background:none;' +
705 'border-radius:var(--radius-sm);min-width:0;' +
706 // A deletion that did not happen is not an error and not a success:
707 // something is standing that the operator can act on, which is what
708 // --warn is for everywhere else in the app.
709 'color:var(--warn);cursor:pointer}' +
710 '#chunk-chip:hover,#chunk-chip:focus-visible{background:var(--bg-hover)}' +
711 '#chunk-chip .ctext{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
712 '#chunk-chip .cdot{flex:none;width:7px;height:7px;border-radius:50%;background:currentColor}';
713 document.head.appendChild(st);
714 }
715 var c = document.createElement('button');
716 c.id = 'chunk-chip';
717 c.type = 'button'; // never submit an enclosing form.
718 c.setAttribute('aria-live', 'polite');
719 c.innerHTML = '<span class="cdot" aria-hidden="true"></span><span class="ctext"></span>';
720 c.addEventListener('click', function () { onChipClick(); });
721 host.appendChild(c);
722 _chip = c;
723 return c;
724 }
725
726 /// The reason sentence for the deletion that is standing.
727 function heldReason() {
728 return t('chunks.sweep_held_reason', 'Cloud storage holds pieces that no file '
729 + 'on this account still refers to. They have NOT been deleted, because no '
730 + 'single request may remove more than half of what is stored.',
731 { n: heldSweep ? heldSweep.n : 0, m: heldSweep ? heldSweep.m : 0 });
732 }
733
734 /// Show or hide the standing notice, and tell anything else that is watching.
735 ///
736 /// A refused upload outranks a held-back deletion, because the two are not
737 /// equally urgent: one means the user's work has stopped travelling, the other
738 /// means some space has not been reclaimed yet. They compose rather than
739 /// compete — "delete something, or ask for more room" and a deletion waiting to
740 /// be authorised are the same conversation — so when both stand the chip says
741 /// the refusal and the dialog behind it carries both.
742 ///
743 /// The event mirrors gateway.js's `daimond:credits`: one place owns the fact
744 /// and announces it, rather than every panel that wants it polling for it.
745 function draw() {
746 var c = chip();
747 if (c) {
748 if (!heldSweep && !refusal) c.style.display = 'none';
749 else {
750 var text = refusal
751 ? t('chunks.upload_refused', 'Uploads paused')
752 : t('chunks.sweep_held', 'Cleanup paused');
753 var title = refusal ? refusal.msg : heldReason();
754 c.querySelector('.ctext').textContent = text;
755 c.title = title;
756 c.setAttribute('aria-label', text + '. ' + title);
757 c.style.display = 'flex';
758 }
759 }
760 try {
761 window.dispatchEvent(new CustomEvent('daimond:chunks', { detail: state() }));
762 } catch (e) { /* no window to tell */ }
763 }
764
765 /// The chip was pressed. Say what is standing, and offer the one act that
766 /// answers it.
767 ///
768 /// ASKED, ALWAYS. `confirmHeldSweep` deletes on a person's word rather than on
769 /// the engine's, so the word has to be given here rather than assumed from a
770 /// tap on a status row. With no dialog to ask through — a stripped build,
771 /// or this script running without the module — nothing is deleted: a
772 /// destructive act with no way to put the question is not carried out. It is
773 /// not escalated to `window.confirm` either, which is an OS box with the origin
774 /// in its title and is exactly what `DaimondCore.confirm` exists to replace.
775 async function onChipClick() {
776 var core = window.DaimondCore;
777 if (!core || !core.confirm) { log('no dialog available; the chip cannot ask'); return; }
778 if (!heldSweep) {
779 // A refusal on its own is a notice, not a question: there is nothing
780 // here for the user to authorise. `cancelLabel: null` drops the second
781 // button, which is how daimond.js's dialog draws one.
782 if (!refusal) return;
783 try {
784 await core.confirm(refusal.msg, t('common.close', 'Close'), {
785 title: t('chunks.upload_refused_title', 'Cloud storage refused an upload'),
786 danger: false,
787 cancelLabel: null,
788 });
789 } catch (e) { /* the dialog went with a redraw */ }
790 return;
791 }
792 var message = (refusal ? refusal.msg + '\n\n' : '') + heldReason() + '\n\n'
793 + t('chunks.sweep_confirm_ask',
794 'Delete them now? Nothing you can still see is touched, and the space is freed.');
795 var yes = false;
796 try {
797 yes = await core.confirm(message,
798 t('chunks.sweep_confirm_ok', 'Delete them'),
799 { title: t('chunks.sweep_confirm_title', 'Free the unreferenced pieces?') });
800 } catch (e) { yes = false; } // no answer is not a yes.
801 if (!yes) return;
802 await confirmHeldSweep();
803 }
804
805 /// Note a deletion the gateway would not carry out and this client would not
806 /// insist on. `why` is the short reason, for the verifier and the log.
807 ///
808 /// The commit body is kept beside it, because the only way to carry the
809 /// deletion out later is to send that body again unchanged: the token names
810 /// the doomed set, which the gateway derives from the live set in the body,
811 /// so a rebuilt one would name a different deletion.
812 function standHeld(body, n, m, token, why) {
813 heldSweep = { body: body, n: n, m: m, token: token, why: why, at: Date.now() };
814 writeHeld();
815 log('sweep held back:', n, 'of', m, 'chunks not deleted —', why);
816 draw();
817 }
818
819 /// Nothing is standing any more: the commit that just ran collected whatever
820 /// the last one could not.
821 function noteCollected() {
822 if (!heldSweep) return;
823 heldSweep = null;
824 writeHeld();
825 draw();
826 }
827
828 /// The gateway turned an upload away, in its own words.
829 ///
830 /// NOT PERSISTED, and the asymmetry with `heldSweep` is the point rather than
831 /// an oversight. A refusal is re-derived by the very next upload attempt: the
832 /// ceiling is still there, the store is still full, and the sentence comes back
833 /// unchanged. A sweep token is not re-derivable by this client at all. Keeping
834 /// a refusal across a reload would therefore only risk showing a ceiling that
835 /// has since been raised.
836 function standRefused(msg, status) {
837 refusal = { msg: String(msg || ''), status: status | 0, at: Date.now() };
838 log('upload refused:', status, msg);
839 draw();
840 }
841
842 /// Uploads are working again.
843 function clearRefused() {
844 if (!refusal) return;
845 refusal = null;
846 draw();
847 }
848
849 /// The gateway names the free allowance it grants, so the next tiering can be
850 /// honest about which files fit inside it.
851 function noteAllowance(j) {
852 if (window.DaimondCloud && j && typeof j.free_allowance === 'number') {
853 DaimondCloud.setAllowance(j.free_allowance);
854 }
855 }
856
857 /// Why this client will not confirm a held-back sweep, or '' when it will.
858 /// The three conditions are argued at the head of this section.
859 function refusalToConfirm(entries, n, m) {
860 var may = window.DaimondCore && DaimondCore.syncMayCommitChunks
861 && DaimondCore.syncMayCommitChunks();
862 if (!may) return 'not_merged';
863 if (!entries.length) return 'names_nothing';
864 if (m - n !== entries.length) return 'unaccounted';
865 return '';
866 }
867
868 /// Answer a held-back sweep: confirm it if this client may, and otherwise
869 /// leave it standing and say so. Returns the reply to hand back to sync.
870 async function answerHeldBack(body, j) {
871 var n = j.sweep_held_back | 0, m = j.sweep_held | 0;
872 var why = refusalToConfirm(body.chunks, n, m);
873 if (why) { standHeld(body, n, m, j.sweep_token, why); return j; }
874
875 // The IDENTICAL commit, quoting what the gateway said it was about to
876 // delete. Identical to the byte: the token is over the doomed set, which
877 // the gateway derives from the live set in this body, so a body that
878 // differed anywhere in `chunks` would name a different deletion and be
879 // held back again.
880 body.sweep_token = j.sweep_token;
881 var res;
882 try { res = await call(body); }
883 finally { delete body.sweep_token; }
884 if (res.status !== 200 || !res.json || !res.json.ok) {
885 standHeld(body, n, m, j.sweep_token, 'refused');
886 return j;
887 }
888 noteAllowance(res.json);
889 if (res.json.sweep_token) {
890 // Held back a second time: the account's chunks moved between the two
891 // requests, so the token no longer names this deletion. One attempt
892 // only — a loop here would be a client insisting until it got its way,
893 // which is the behaviour the floor exists to stop.
894 standHeld(body, res.json.sweep_held_back | 0, res.json.sweep_held | 0,
895 res.json.sweep_token, 'moved');
896 return res.json;
897 }
898 confirmed++;
899 noteCollected();
900 log('confirmed a large sweep:', res.json.swept, 'of', m, 'chunks removed');
901 return res.json;
902 }
903
904 /// Declare the live, tiered chunk set to the gateway and let it sweep
905 /// everything unreferenced. `manifests` is the map of `{path: manifest}` in
906 /// the state just pushed; every chunk it names is committed as paid overflow.
907 ///
908 /// `blobVersion` is the sync blob version this set was derived from. The
909 /// gateway refuses to sweep for a device naming a version older than the one
910 /// it holds, because such a device cannot know about a file another device
911 /// added — and a sweep on its word would delete that file's chunks.
912 ///
913 /// A sweep over the gateway's floor comes back undone, with a token: see
914 /// `answerHeldBack` for when this client confirms one and when it does not.
915 async function commit(manifests, blobVersion, tiers) {
916 var seen = {}, entries = [];
917 Object.keys(manifests || {}).forEach(function (path) {
918 var m = manifests[path];
919 if (!m || !Array.isArray(m.chunks)) return;
920 // A file's chunks carry its tier. Tagging everything paid would mean a
921 // lapsed account lost its whole store at the end of grace instead of
922 // only its overflow, which is the opposite of the promise.
923 var tier = (tiers && tiers[path] === 'f') ? 'f' : 'p';
924 m.chunks.forEach(function (c) {
925 if (seen[c.addr]) return; // dedup across files.
926 seen[c.addr] = 1;
927 entries.push({ addr: c.addr, size: c.size | 0, tier: tier });
928 });
929 });
930 var body = { op: 'commit', chunks: entries };
931 if (typeof blobVersion === 'number') body.blob_version = blobVersion | 0;
932 var res = await call(body);
933 if (res.status !== 200 || !res.json || !res.json.ok) { log('commit failed', res.status); return null; }
934 noteAllowance(res.json);
935 if (res.json.sweep_token) return await answerHeldBack(body, res.json);
936 noteCollected();
937 log('committed', entries.length, 'live chunks; swept', res.json.swept);
938 return res.json;
939 }
940
941 /// Carry out a deletion this client declined to confirm on its own.
942 ///
943 /// The escape hatch for the one case the conditions above deliberately never
944 /// clear by themselves — an index that names nothing, on an account that
945 /// really has been emptied — and the only thing in this file that deletes on
946 /// a person's word rather than the engine's. It re-sends the commit exactly
947 /// as it stood, so a set that has moved since is refused by the token.
948 ///
949 /// Reached from the chip, through `onChipClick`, which asks first.
950 async function confirmHeldSweep() {
951 if (!heldSweep || !heldSweep.body) return null;
952 var body = heldSweep.body;
953 body.sweep_token = heldSweep.token;
954 var res;
955 try { res = await call(body); }
956 finally { delete body.sweep_token; }
957 if (res.status !== 200 || !res.json || !res.json.ok) {
958 // The person asked for this, so they are told why it did not happen —
959 // in the gateway's own words, which name the remedy. A stale
960 // `blob_version` answers 409 with "pull, merge and commit again", and
961 // returning a bare null left them pressing a chip that did nothing.
962 standRefused((res.json && (res.json.error || res.json.message))
963 || ('HTTP ' + res.status), res.status);
964 return null;
965 }
966 clearRefused();
967 noteAllowance(res.json);
968 if (res.json.sweep_token) {
969 standHeld(body, res.json.sweep_held_back | 0, res.json.sweep_held | 0,
970 res.json.sweep_token, 'moved');
971 return res.json;
972 }
973 confirmed++;
974 noteCollected();
975 return res.json;
976 }
977
978 /// What this file would say if asked — the same facts the chip shows, in
979 /// words, for anything that needs them other than as a coloured pill.
980 function state() {
981 return {
982 /// A deletion the gateway would not carry out and this client did not
983 /// insist on. Standing until a commit collects it.
984 heldBack: heldSweep ? heldSweep.n : 0,
985 held: heldSweep ? heldSweep.m : 0,
986 /// '' | 'not_merged' | 'names_nothing' | 'unaccounted' | 'refused' | 'moved'
987 why: heldSweep ? heldSweep.why : '',
988 since: heldSweep ? heldSweep.at : 0,
989 standing: !!heldSweep,
990 /// Did the standing deletion reach localStorage, so a reload keeps it?
991 /// False when nothing is standing, and false when the body was too
992 /// large to store — which is a real difference and not a detail.
993 persisted: persisted && !!heldSweep,
994 /// The gateway's own sentence for the upload it last turned away, or
995 /// ''. Its words, not a status code: it is the half that names the
996 /// remedy.
997 refused: refusal ? refusal.msg : '',
998 refusedStatus: refusal ? refusal.status : 0,
999 /// Large sweeps this page has confirmed, for the verifier.
1000 confirmed: confirmed,
1001 };
1002 }
1003
1004 // ── Boot ───────────────────────────────────────────────────
1005 //
1006 // Pick up a deletion an earlier sitting left standing, and paint it. Read ONCE,
1007 // here: a re-read on every repaint would overwrite a sweep that is standing in
1008 // memory because it was too large to store, which is the one case where the two
1009 // disagree.
1010 //
1011 // `draw` is then registered with `DaimondI18n.onChange`, which fires when the
1012 // locale table lands as well as on a language change — so the first paint may
1013 // carry the English fallbacks and the second carries the reader's own words.
1014 (function restore() {
1015 heldSweep = readHeld();
1016 if (heldSweep) {
1017 persisted = true;
1018 log('a deletion was left standing:', heldSweep.n, 'of', heldSweep.m, '—', heldSweep.why);
1019 }
1020 draw();
1021 if (window.DaimondI18n && DaimondI18n.onChange) DaimondI18n.onChange(draw);
1022 })();
1023
1024 // ── Public surface ─────────────────────────────────────────
1025 window.DaimondChunks = {
1026 offloadFile: offloadFile, // (path, File) -> manifest v2
1027 offloadBytes: offloadBytes, // (label, Uint8Array) -> manifest v2
1028 materialiseStream: materialiseStream, // (manifest, write) -> bool
1029 materialiseBytes: materialiseBytes, // (manifest) -> Uint8Array|null, one item whole
1030 materialiseV1: materialiseV1, // (manifest) -> text|null, old files only
1031 chunkSizeFor: chunkSizeFor,
1032 commit: commit, // ({path: manifest}, version) -> {swept,...}|null
1033 /// A deletion this client declined to confirm, carried out on a person's
1034 /// word. Reached from the notice in the rail's status strip, which asks
1035 /// first; exported
1036 /// as well so a verifier can drive the act without driving a dialog.
1037 confirmHeldSweep: confirmHeldSweep,
1038 state: state,
1039 /// Is a re-offload owed because the passphrase changed? Read by
1040 /// `collectChunked` in daimond.js, which suspends its unchanged-file skip
1041 /// for exactly one round when it is set, and clears it when that round is
1042 /// done. See "Surviving a passphrase change" above.
1043 staleSinceRekey: staleSinceRekey,
1044 clearStale: clearStale,
1045 // exposed for tests/tools:
1046 _b64urlEncode: b64urlEncode,
1047 _b64urlDecode: b64urlDecode,
1048 _sha256Hex: sha256Hex,
1049 };
1050})();