@needmoretruth/nmts-cli 0.17.2

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.
Files changed (151) hide show
  1. package/AGENTS.md +591 -0
  2. package/LICENSE +202 -0
  3. package/LICENSING.md +49 -0
  4. package/README.ko.md +641 -0
  5. package/README.md +657 -0
  6. package/dist/account-proof.js +78 -0
  7. package/dist/account.js +75 -0
  8. package/dist/api-key.js +310 -0
  9. package/dist/api.js +271 -0
  10. package/dist/args.js +137 -0
  11. package/dist/artifact-about.js +70 -0
  12. package/dist/autonomy.js +98 -0
  13. package/dist/code-access.js +76 -0
  14. package/dist/code-vault.js +233 -0
  15. package/dist/collision.js +133 -0
  16. package/dist/commands/balance.js +93 -0
  17. package/dist/commands/consent.js +77 -0
  18. package/dist/commands/create.js +356 -0
  19. package/dist/commands/env.js +136 -0
  20. package/dist/commands/expiring.js +167 -0
  21. package/dist/commands/extend.js +236 -0
  22. package/dist/commands/get.js +130 -0
  23. package/dist/commands/kit.js +136 -0
  24. package/dist/commands/listfile.js +105 -0
  25. package/dist/commands/login.js +335 -0
  26. package/dist/commands/logout.js +33 -0
  27. package/dist/commands/ls.js +202 -0
  28. package/dist/commands/marks.js +176 -0
  29. package/dist/commands/mcp.js +139 -0
  30. package/dist/commands/mode.js +54 -0
  31. package/dist/commands/on-collision.js +45 -0
  32. package/dist/commands/organise.js +296 -0
  33. package/dist/commands/public-code.js +89 -0
  34. package/dist/commands/pull.js +185 -0
  35. package/dist/commands/push.js +278 -0
  36. package/dist/commands/put.js +282 -0
  37. package/dist/commands/rebuild.js +160 -0
  38. package/dist/commands/receive.js +125 -0
  39. package/dist/commands/recovery-list.js +104 -0
  40. package/dist/commands/recovery.js +291 -0
  41. package/dist/commands/s3.js +241 -0
  42. package/dist/commands/settings.js +28 -0
  43. package/dist/commands/share.js +276 -0
  44. package/dist/commands/sweep.js +240 -0
  45. package/dist/commands/trash.js +288 -0
  46. package/dist/commands/trial.js +264 -0
  47. package/dist/commands/update.js +169 -0
  48. package/dist/commands/usage.js +84 -0
  49. package/dist/commands/verify.js +331 -0
  50. package/dist/commands/wallet.js +122 -0
  51. package/dist/commands/whoami.js +33 -0
  52. package/dist/consent.js +200 -0
  53. package/dist/credentials.js +311 -0
  54. package/dist/crypto-surface.js +54 -0
  55. package/dist/crypto.js +137 -0
  56. package/dist/download-part.js +144 -0
  57. package/dist/download-sink.js +213 -0
  58. package/dist/download.js +163 -0
  59. package/dist/drive-paths.js +170 -0
  60. package/dist/environment.js +190 -0
  61. package/dist/errors.js +71 -0
  62. package/dist/exit.js +107 -0
  63. package/dist/expiry.js +115 -0
  64. package/dist/extend-chain.js +137 -0
  65. package/dist/extend-plan.js +149 -0
  66. package/dist/extend-sign.js +130 -0
  67. package/dist/guards.js +15 -0
  68. package/dist/help.js +192 -0
  69. package/dist/human-check.js +54 -0
  70. package/dist/item-trash.js +25 -0
  71. package/dist/kit-file.js +133 -0
  72. package/dist/list-file.js +71 -0
  73. package/dist/list-view-find.js +71 -0
  74. package/dist/list-view-order.js +96 -0
  75. package/dist/main.js +394 -0
  76. package/dist/manifest-create.js +81 -0
  77. package/dist/manifest-write.js +244 -0
  78. package/dist/manifest.js +213 -0
  79. package/dist/mark-render.js +45 -0
  80. package/dist/mcp-args.js +154 -0
  81. package/dist/mcp-tools/context.js +50 -0
  82. package/dist/mcp-tools/files.js +133 -0
  83. package/dist/mcp-tools/organise.js +128 -0
  84. package/dist/mcp-tools/reads.js +89 -0
  85. package/dist/mcp-tools/share.js +61 -0
  86. package/dist/mcp.js +142 -0
  87. package/dist/net-retry.js +84 -0
  88. package/dist/network.js +43 -0
  89. package/dist/notice.js +45 -0
  90. package/dist/product.js +38 -0
  91. package/dist/progress.js +110 -0
  92. package/dist/prompt.js +145 -0
  93. package/dist/rebuild.js +255 -0
  94. package/dist/recovery-assemble.js +148 -0
  95. package/dist/recovery-build.js +260 -0
  96. package/dist/recovery-map-file.js +114 -0
  97. package/dist/recovery-map.js +191 -0
  98. package/dist/recovery-release.js +229 -0
  99. package/dist/recovery-seq.js +63 -0
  100. package/dist/recovery-source.js +143 -0
  101. package/dist/registration.js +83 -0
  102. package/dist/s3/listing.js +134 -0
  103. package/dist/s3/multipart.js +104 -0
  104. package/dist/s3/response-sink.js +41 -0
  105. package/dist/s3/same-file.js +117 -0
  106. package/dist/s3/server.js +252 -0
  107. package/dist/s3/sigv4.js +168 -0
  108. package/dist/s3/staging.js +87 -0
  109. package/dist/s3/xml.js +80 -0
  110. package/dist/safe-path.js +89 -0
  111. package/dist/seal.js +206 -0
  112. package/dist/secret-reader.js +112 -0
  113. package/dist/server.js +33 -0
  114. package/dist/session.js +49 -0
  115. package/dist/setup-questions.js +31 -0
  116. package/dist/share.js +195 -0
  117. package/dist/shared/lib/crypto/size-padding.js +126 -0
  118. package/dist/shared/lib/drive/manifest-codec.js +287 -0
  119. package/dist/shared/lib/drive/manifest-index.js +235 -0
  120. package/dist/shared/lib/drive/manifest-ops.js +267 -0
  121. package/dist/shared/lib/drive/manifest-settings.js +65 -0
  122. package/dist/shared/lib/drive/name-conflict.js +121 -0
  123. package/dist/shared/lib/drive/unique-name.js +48 -0
  124. package/dist/shared/lib/extend/epochs.js +256 -0
  125. package/dist/shared/lib/net/retry-budget.js +95 -0
  126. package/dist/shared/lib/share/shared-file-info.js +65 -0
  127. package/dist/shared/lib/storage-network.js +64 -0
  128. package/dist/shared/lib/upload/part-plan.js +28 -0
  129. package/dist/stdout.js +109 -0
  130. package/dist/trash-sweep.js +123 -0
  131. package/dist/units.js +19 -0
  132. package/dist/update-check.js +164 -0
  133. package/dist/update-source.js +143 -0
  134. package/dist/upload-api.js +98 -0
  135. package/dist/upload-file.js +242 -0
  136. package/dist/upload-price.js +117 -0
  137. package/dist/upload-steps.js +111 -0
  138. package/dist/upload-store.js +252 -0
  139. package/dist/upload-wire.js +25 -0
  140. package/dist/upload.js +294 -0
  141. package/dist/usage-report.js +54 -0
  142. package/dist/wallet-chain.js +76 -0
  143. package/dist/wallet.js +209 -0
  144. package/dist/walrus-write.js +188 -0
  145. package/dist/walrus.js +156 -0
  146. package/package.json +56 -0
  147. package/vendor/nmts-crypto/nmts_crypto_wasm.d.ts +469 -0
  148. package/vendor/nmts-crypto/nmts_crypto_wasm.js +1300 -0
  149. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm +0 -0
  150. package/vendor/nmts-crypto/nmts_crypto_wasm_bg.wasm.d.ts +55 -0
  151. package/vendor/nmts-crypto/package.json +17 -0
package/dist/share.js ADDED
@@ -0,0 +1,195 @@
1
+ // Sharing one file with one other account: what gets sealed, in what order, and why that order.
2
+ //
3
+ // ⛔ NOTHING HERE TRUSTS THE SERVER WITH A KEY. A share hands a recipient the file's own key,
4
+ // wrapped so that only they can open it. The server stores an opaque envelope and three sealed
5
+ // fields; it can open none of them, and it cannot tell whether a share it stored is one that
6
+ // was actually made.
7
+ //
8
+ // ⛔ THE ORDER IS LOAD-BEARING. The name and the digest are sealed FIRST, and the exact bytes that
9
+ // result are hashed into the key that wraps the file key. So a name sealed afterwards, or one
10
+ // byte different from what is actually sent, produces an envelope the recipient cannot open.
11
+ // That is what stops a server rewriting the name a file arrived under: it would have to produce
12
+ // a wrapping key it does not have.
13
+ //
14
+ // ⛔ OPENING IT IS THE AUTHENTICATION. There is no separate signature to check. The sender's own
15
+ // secret is inside the key agreement, so an envelope that opens at all could only have been
16
+ // made by the account it names. The claimed sender is therefore printed only AFTER the open
17
+ // succeeds — before that it is a claim, and printing a claim as a fact is how somebody trusts
18
+ // a file that was not sent by who it says.
19
+ import { AAD, DERIVED } from "./crypto.js";
20
+ import { NmtsError } from "./errors.js";
21
+ import { decodeSharedFileInfo, encodeSharedFileInfo, } from "./shared/lib/share/shared-file-info.js";
22
+ const encoder = new TextEncoder();
23
+ /** Exact sizes the server checks. Named here so a wrong one is caught before a round trip. */
24
+ const ENVELOPE_LEN = 1240;
25
+ const DIGEST_ENVELOPE_LEN = 104;
26
+ const IDENTITY_LEN = 4989;
27
+ /** Derive everything sharing needs from an account code. */
28
+ export function shareKeysOf(crypt, code) {
29
+ const derived = crypt.kdf_derive(crypt.account_code_parse(code));
30
+ const slice = (range) => derived.slice(range[0], range[1]);
31
+ const kemSeed = slice(DERIVED.shareKemSeed);
32
+ const authSecret = slice(DERIVED.shareAuthSecret);
33
+ const sigSeed = slice(DERIVED.shareSigSeed);
34
+ const address = slice(DERIVED.shareAddress);
35
+ derived.fill(0);
36
+ const identity = crypt.share_public_key(kemSeed, authSecret, sigSeed);
37
+ if (identity.length !== IDENTITY_LEN) {
38
+ throw new NmtsError(`This account's sharing identity came out ${identity.length} bytes.`, {
39
+ nextStep: "Nothing was sent. The crypto engine and this tool disagree about the format.",
40
+ });
41
+ }
42
+ return {
43
+ kemSeed,
44
+ authSecret,
45
+ sigSeed,
46
+ address,
47
+ identity,
48
+ display: crypt.share_address_display(address),
49
+ wipe() {
50
+ kemSeed.fill(0);
51
+ authSecret.fill(0);
52
+ sigSeed.fill(0);
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * Seal one file for one recipient.
58
+ *
59
+ * `recipientIdentity` is checked against `recipientAddress` inside the engine before anything is
60
+ * encrypted to it — length, fingerprint, self-signature and key decoding — which is why both are
61
+ * arguments and why there is no form of this that takes the identity alone. A tool that fetched an
62
+ * identity and wrapped to it without naming the address it asked for would hand a readable key to
63
+ * whoever answered.
64
+ */
65
+ export function sealShare(crypt, input) {
66
+ if (input.digest.length !== 32) {
67
+ throw new NmtsError("This file has no recorded content hash, so it cannot be shared.", {
68
+ nextStep: "A share carries a hash the recipient checks the bytes against. Without one there is " +
69
+ "nothing to check, and a share that proves nothing is not one this tool will make.",
70
+ });
71
+ }
72
+ // ⛔ SEALED FIRST, AND THESE EXACT BYTES ARE WHAT GETS SENT. See the module note.
73
+ const nameCt = crypt.envelope_seal(input.dek, encoder.encode(AAD.shareName), encoder.encode(encodeSharedFileInfo({ name: input.name, size: input.size })));
74
+ const digestCt = crypt.envelope_seal(input.dek, encoder.encode(AAD.shareContentHash), input.digest);
75
+ if (digestCt.length !== DIGEST_ENVELOPE_LEN) {
76
+ throw new NmtsError(`A sealed content hash came out ${digestCt.length} bytes.`);
77
+ }
78
+ const envelope = crypt.share_wrap_dek(input.keys.authSecret, input.keys.sigSeed, input.recipientIdentity, input.recipientAddress, input.dek, input.itemId, nameCt, digestCt);
79
+ if (envelope.length !== ENVELOPE_LEN) {
80
+ throw new NmtsError(`A share envelope came out ${envelope.length} bytes.`);
81
+ }
82
+ return {
83
+ dek_share_ct: Buffer.from(envelope).toString("base64url"),
84
+ name_share_ct: Buffer.from(nameCt).toString("base64url"),
85
+ content_hash_share_ct: Buffer.from(digestCt).toString("base64url"),
86
+ };
87
+ }
88
+ /**
89
+ * Open one received share.
90
+ *
91
+ * ⛔ A ROW THAT WILL NOT OPEN IS STILL RETURNED. Dropping it would tell the account it was sent
92
+ * less than it was, and the honest answer to "this one will not open" is to say so on its own
93
+ * line — not to leave a gap somebody has no way to notice.
94
+ */
95
+ export function openReceived(crypt, keys, row) {
96
+ const base = {
97
+ id: row.id,
98
+ itemId: row.item_id,
99
+ createdAt: row.created_at,
100
+ digestCt: row.content_hash_share_ct,
101
+ };
102
+ const unopened = (problem) => ({
103
+ ...base,
104
+ name: null,
105
+ size: null,
106
+ sender: null,
107
+ dek: null,
108
+ problem,
109
+ });
110
+ if (row.sender_public_key === undefined || row.sender_public_key === "") {
111
+ // The sender's identity is what the open is checked against. Without it there is nothing to
112
+ // authenticate against, and an unauthenticated open is not one worth doing.
113
+ return unopened("the sender's published identity is not available");
114
+ }
115
+ const envelope = new Uint8Array(Buffer.from(row.dek_share_ct, "base64url"));
116
+ const nameCt = new Uint8Array(Buffer.from(row.name_share_ct, "base64url"));
117
+ const digestCt = new Uint8Array(Buffer.from(row.content_hash_share_ct, "base64url"));
118
+ const senderPublic = new Uint8Array(Buffer.from(row.sender_public_key, "base64url"));
119
+ let dek;
120
+ try {
121
+ dek = crypt.share_unwrap_dek(keys.kemSeed, keys.authSecret, keys.sigSeed, senderPublic, envelope, row.item_id, nameCt, digestCt);
122
+ }
123
+ catch {
124
+ return unopened("it did not open with this account's keys");
125
+ }
126
+ // ⛔ ONLY NOW. Before the unwrap succeeded this was a claim printed next to a file name, which is
127
+ // exactly how somebody comes to trust a file that was not sent by who it says.
128
+ let sender;
129
+ try {
130
+ sender = crypt.share_address_display(crypt.share_claimed_sender(envelope));
131
+ }
132
+ catch {
133
+ dek.fill(0);
134
+ return unopened("the sender it names is not a readable address");
135
+ }
136
+ let info;
137
+ try {
138
+ info = decodeSharedFileInfo(new TextDecoder().decode(crypt.envelope_open(dek, encoder.encode(AAD.shareName), nameCt)));
139
+ }
140
+ catch {
141
+ dek.fill(0);
142
+ return unopened("the file's name did not open");
143
+ }
144
+ return {
145
+ ...base,
146
+ name: info.name,
147
+ size: info.size ?? null,
148
+ sender,
149
+ dek,
150
+ problem: null,
151
+ };
152
+ }
153
+ /** The digest a recipient checks the downloaded bytes against. */
154
+ export function openSharedDigest(crypt, dek, digestCt) {
155
+ try {
156
+ return crypt.envelope_open(dek, encoder.encode(AAD.shareContentHash), new Uint8Array(Buffer.from(digestCt, "base64url")));
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ /**
163
+ * Turn what a person typed into the 16 bytes behind a public code.
164
+ *
165
+ * ⛔ A TYPO FAILS HERE, NOT AS A LOOKUP. Sending a mistyped address to the server would ask it a
166
+ * question about somebody who might exist, and the answer is not ours to collect.
167
+ */
168
+ export function addressFromTyped(crypt, typed) {
169
+ try {
170
+ return crypt.share_address_parse(typed.trim());
171
+ }
172
+ catch {
173
+ throw new NmtsError(`"${typed.trim()}" is not a public code.`, {
174
+ exitCode: 2,
175
+ nextStep: "Nothing was sent. An address has a check symbol built in, so this was caught here " +
176
+ "rather than by asking the server about it.",
177
+ });
178
+ }
179
+ }
180
+ /**
181
+ * Check that an identity the server handed back is the one that was asked for.
182
+ *
183
+ * The engine checks this again inside the wrap, and this exists so the refusal says WHICH thing
184
+ * was wrong rather than failing inside a sealing step.
185
+ */
186
+ export function identityMatches(crypt, identity, address) {
187
+ if (identity.length !== IDENTITY_LEN)
188
+ return false;
189
+ try {
190
+ return Buffer.from(crypt.share_address_of(identity)).equals(Buffer.from(address));
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ }
@@ -0,0 +1,126 @@
1
+ // Rounding a file's stored size up, so the exact length of what somebody stored is not published.
2
+ //
3
+ // A sealed stream states the length it was sealed from, in the clear, at a fixed place in its
4
+ // header. That header is authenticated but not encrypted — it has to be readable to be decrypted
5
+ // against — so on a public storage network the exact byte length of every stored file is legible to
6
+ // anyone who fetches it. Padding is what takes that away: seal from a rounded-up length, and the
7
+ // number on the wire is the rounded one.
8
+ //
9
+ // ⛔ ONLY THE FINAL PART OF A FILE MAY BE PADDED. A reader recovers each part's real length from
10
+ // the file's own size and the lengths the stored streams declare, and that answer is unique only
11
+ // because every earlier part is exactly full. Padding one of them would not be caught here — it
12
+ // would be caught years later, by a download that wrote padding into the middle of a file.
13
+ //
14
+ // ⛔ TWO PROGRAMS MUST ROUND THE SAME WAY. This is copied verbatim into other programs; a file
15
+ // uploaded by one and one uploaded by the other must not be tellable apart by the shape of their
16
+ // lengths, or the padding has replaced one fingerprint with another.
17
+ //
18
+ // It depends on nothing, and the two numbers it needs about the sealing format are arguments rather
19
+ // than imports, so that the program using it supplies the ones its own sealing actually uses.
20
+ /**
21
+ * Padmé: round up to a multiple of 2^(E−S), where E = floor(log2 L) and S = floor(log2 E)+1.
22
+ *
23
+ * About 32 possible lengths per doubling, for roughly 1% more storage. Lengths below 4 are
24
+ * returned unchanged — the step there would be a single byte, which hides nothing.
25
+ */
26
+ export function padmeLen(len) {
27
+ if (len < 4)
28
+ return len;
29
+ const e = Math.floor(Math.log2(len));
30
+ const s = Math.floor(Math.log2(e)) + 1;
31
+ const z = e - s;
32
+ if (z <= 0)
33
+ return len;
34
+ const step = 2 ** z;
35
+ return Math.ceil(len / step) * step;
36
+ }
37
+ /** The next power of two at or above `len`: one possible length per doubling. Below 2, unchanged. */
38
+ export function pow2Len(len) {
39
+ if (len < 2)
40
+ return len;
41
+ return 2 ** Math.ceil(Math.log2(len));
42
+ }
43
+ /** Chunks a stream of this plaintext length splits into. Empty is one chunk, not zero. */
44
+ export function chunkCount(plaintextLen, shape) {
45
+ return plaintextLen === 0 ? 1 : Math.ceil(plaintextLen / shape.chunkSize);
46
+ }
47
+ /** How many bytes one sealed stream of this plaintext length occupies. */
48
+ export function sealedLenFor(plaintextLen, shape) {
49
+ return shape.headerLen + plaintextLen + shape.tagLen * chunkCount(plaintextLen, shape);
50
+ }
51
+ /**
52
+ * The largest plaintext length whose sealed stream still fits inside bytes already paid for.
53
+ *
54
+ * Storage for a file of its own is charged in whole units, so every byte between the real sealed
55
+ * length and the next unit boundary has already been bought. Filling them is the one padding that
56
+ * costs nothing at all, which is why it applies before any rule and whichever rule was chosen.
57
+ *
58
+ * `unitBytes` is the billing unit. Pass 0 when the bytes are not billed that way — a file sharing a
59
+ * stored object with others is billed alongside them, and there is no free room to claim.
60
+ */
61
+ export function freeCeiling(len, unitBytes, shape) {
62
+ if (unitBytes <= 0)
63
+ return len;
64
+ const paidBytes = Math.ceil(sealedLenFor(len, shape) / unitBytes) * unitBytes;
65
+ // Invert the sealed-length arithmetic conservatively: assume the padded stream needs one more
66
+ // chunk than the real one might, then walk back until the sealed length actually fits. At most
67
+ // two steps.
68
+ let chunks = Math.max(1, Math.ceil((paidBytes - shape.headerLen) / shape.chunkSize));
69
+ for (;;) {
70
+ const candidate = paidBytes - shape.headerLen - shape.tagLen * chunks;
71
+ if (candidate >= len && sealedLenFor(candidate, shape) <= paidBytes)
72
+ return candidate;
73
+ if (chunks <= 1)
74
+ return len;
75
+ chunks -= 1;
76
+ }
77
+ }
78
+ /**
79
+ * The plaintext length to seal a final part from. Never smaller than `len`.
80
+ *
81
+ * ⛔ FINAL PART ONLY — see the module note.
82
+ */
83
+ export function paddedPlaintextLen(len, rule, options) {
84
+ if (!Number.isSafeInteger(len) || len < 0) {
85
+ throw new RangeError(`padded length needs a non-negative safe integer, got ${len}`);
86
+ }
87
+ const byRule = rule === "pow2" ? pow2Len(len) : padmeLen(len);
88
+ return Math.max(len, byRule, freeCeiling(len, options.unitBytes, options.shape));
89
+ }
90
+ /**
91
+ * Each part's REAL contributed length, from the file's real size and what each stored stream
92
+ * declares. The read side of the same contract, and the only place that arithmetic is written.
93
+ *
94
+ * Throws rather than repairing. Numbers that do not reconcile would hand back a file with bytes
95
+ * missing or padding in the middle of it, and neither announces itself afterwards.
96
+ */
97
+ export function keepLengths(size, streamLens) {
98
+ if (!Number.isSafeInteger(size) || size < 0) {
99
+ throw new RangeError(`a file size must be a non-negative safe integer, got ${size}`);
100
+ }
101
+ if (streamLens.length === 0) {
102
+ throw new RangeError("a file with no parts has nothing to keep");
103
+ }
104
+ const keep = [];
105
+ let remaining = size;
106
+ for (let i = 0; i < streamLens.length; i += 1) {
107
+ const declared = streamLens[i] ?? 0;
108
+ if (!Number.isSafeInteger(declared) || declared < 0) {
109
+ throw new RangeError(`part ${i} declares a length that is not a byte count: ${declared}`);
110
+ }
111
+ const isLast = i === streamLens.length - 1;
112
+ const take = isLast ? remaining : declared;
113
+ if (take > declared) {
114
+ throw new RangeError(`part ${i} must contribute ${take} bytes but its stream only holds ${declared}`);
115
+ }
116
+ if (take > remaining) {
117
+ throw new RangeError(`part ${i} contributes ${take} bytes but only ${remaining} of the file are left`);
118
+ }
119
+ keep.push(take);
120
+ remaining -= take;
121
+ }
122
+ // No total check follows, and none is possible to write honestly: the last part is GIVEN whatever
123
+ // is left, so the sum is `size` by construction. A file whose parts fall short shows up one line
124
+ // above, as a last part whose stream cannot hold what remains.
125
+ return keep;
126
+ }
@@ -0,0 +1,287 @@
1
+ // NMF-1 — the on-the-wire form of the sealed file list (CRYPTO-FORMAT-NCF3.md §6). ⚠ PUBLISHED —
2
+ // copied byte-for-byte into the `nmts` command-line package; keep comments self-contained English.
3
+ //
4
+ // This module is ONLY the byte format: entries ⇄ the plaintext that gets sealed. It performs no
5
+ // I/O, holds no key and never touches the network, so it is unit-testable without a browser and
6
+ // is the single place the format is written down in TypeScript.
7
+ //
8
+ // SHAPE: `flag(1) || json` where flag 0x00 = raw UTF-8 JSON and 0x01 = gzip of it. The flag lives
9
+ // INSIDE the sealed plaintext: beside it, it would be an unauthenticated input an attacker
10
+ // could flip, and the server would be able to see which form was used.
11
+ //
12
+ // FIELD NAMES ARE ONE OR TWO LETTERS on purpose. This blob is rewritten in full on every change
13
+ // and downloaded on every cold start, so the key strings are a real fraction of its size — at
14
+ // ~10k entries, spelling them out costs hundreds of kilobytes per save on someone's phone.
15
+ // Readability lives in ManifestEntry below, which is what the rest of the app actually uses.
16
+ // Relative, with the extension, because `node --test` type-strips this module directly for the
17
+ // codec round-trip suite and resolves no path aliases.
18
+ import { NETWORK_WHEN_UNRECORDED } from "../storage-network.js";
19
+ import { settingsFromWire, settingsToWire, TEXT_SCALE_DEFAULT_PCT, TEXT_SCALE_MAX_PCT, TEXT_SCALE_MIN_PCT, } from "./manifest-settings.js";
20
+ export { TEXT_SCALE_DEFAULT_PCT, TEXT_SCALE_MAX_PCT, TEXT_SCALE_MIN_PCT };
21
+ /**
22
+ * Current format version this build writes.
23
+ *
24
+ * STAYS 1 WHEN OPTIONAL FIELDS ARE ADDED (favorite · pinned · labels · account settings landed
25
+ * this way). The reader
26
+ * below refuses any version it does not know, so bumping would lock every already-open tab out of
27
+ * the drive to buy nothing: an older build ignores fields it has never heard of and renders the
28
+ * list correctly. The cost of that choice is real and bounded — an older build that SAVES rewrites
29
+ * the entries without the marks it dropped, so a stale tab can clear stars and labels. Bump only for
30
+ * a change an old build would MISREAD, not one it would merely not show.
31
+ */
32
+ export const MANIFEST_FORMAT_VERSION = 1;
33
+ /** base64url SHA-256 of a sealed blob — the value a later list carries as its `prev`. */
34
+ export async function manifestFingerprint(ct) {
35
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ct));
36
+ let bin = "";
37
+ for (const b of new Uint8Array(digest))
38
+ bin += String.fromCharCode(b);
39
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40
+ }
41
+ const FLAG_RAW = 0x00;
42
+ const FLAG_GZIP = 0x01;
43
+ function toWire(e) {
44
+ const w = {
45
+ i: e.id,
46
+ p: e.parentId,
47
+ k: e.kind,
48
+ n: e.name,
49
+ s: e.size,
50
+ c: e.createdAt,
51
+ u: e.updatedAt,
52
+ };
53
+ // Optional fields are OMITTED rather than written as null: "absent" and "present but empty"
54
+ // must stay distinguishable, and omitting is also the cheaper encoding.
55
+ if (e.deletedAt !== undefined)
56
+ w.d = e.deletedAt;
57
+ if (e.dekWrapped !== undefined)
58
+ w.w = e.dekWrapped;
59
+ if (e.contentHashCt !== undefined)
60
+ w.h = e.contentHashCt;
61
+ // Marks are written only when set, and `labels` only when it holds something: an empty array
62
+ // would cost bytes on every entry of every save to say exactly what its absence already says.
63
+ if (e.favorite)
64
+ w.f = 1;
65
+ if (e.pinned)
66
+ w.pn = 1;
67
+ if (e.labels && e.labels.length > 0)
68
+ w.l = e.labels;
69
+ // Walrus is written as absence: it is what every entry that lacks the field already means, so
70
+ // spelling it out would cost bytes on every file of every save to say nothing new.
71
+ if (e.network !== undefined && e.network !== NETWORK_WHEN_UNRECORDED)
72
+ w.sn = e.network;
73
+ if (e.shares && e.shares.length > 0) {
74
+ w.sh = e.shares.map((r) => (r.revoked ? { a: r.address, t: r.at, r: 1 } : { a: r.address, t: r.at }));
75
+ }
76
+ return w;
77
+ }
78
+ function fromWire(w) {
79
+ const e = {
80
+ id: w.i,
81
+ parentId: w.p ?? null,
82
+ kind: w.k,
83
+ name: w.n,
84
+ size: w.s,
85
+ createdAt: w.c,
86
+ updatedAt: w.u,
87
+ };
88
+ if (w.d !== undefined)
89
+ e.deletedAt = w.d;
90
+ if (w.w !== undefined)
91
+ e.dekWrapped = w.w;
92
+ if (w.h !== undefined)
93
+ e.contentHashCt = w.h;
94
+ if (w.f === 1)
95
+ e.favorite = true;
96
+ if (w.pn === 1)
97
+ e.pinned = true;
98
+ // Defensive: another build (or a partially applied edit) could leave a non-array or blank
99
+ // entries here. Labels drive a whole navigation surface, so anything unusable is dropped rather
100
+ // than rendered as a label the person cannot select, rename or remove.
101
+ if (Array.isArray(w.l)) {
102
+ const clean = w.l.filter((s) => typeof s === "string" && s.trim() !== "");
103
+ if (clean.length > 0)
104
+ e.labels = clean;
105
+ }
106
+ // Carried through even when this build does not recognise the code: dropping it would REWRITE
107
+ // the entry without it on the next save, turning "stored somewhere I do not know" into
108
+ // "stored on Walrus" — a claim about someone else's bytes that nothing would ever correct.
109
+ if (typeof w.sn === "number")
110
+ e.network = w.sn;
111
+ // Defensive in the same way labels are, and for a sharper reason: a receipt with a blank address
112
+ // or a broken instant would be compared against the server's rows and could produce a warning
113
+ // about a share nobody ever made. Anything unusable is dropped — a receipt that cannot be
114
+ // checked says nothing, and saying nothing is the honest outcome.
115
+ if (Array.isArray(w.sh)) {
116
+ const clean = [];
117
+ for (const raw of w.sh) {
118
+ if (!raw || typeof raw !== "object")
119
+ continue;
120
+ const { a, t, r } = raw;
121
+ if (typeof a !== "string" || a === "")
122
+ continue;
123
+ if (typeof t !== "number" || !Number.isFinite(t))
124
+ continue;
125
+ clean.push(r === 1 ? { address: a, at: t, revoked: true } : { address: a, at: t });
126
+ }
127
+ if (clean.length > 0)
128
+ e.shares = clean;
129
+ }
130
+ return e;
131
+ }
132
+ /** Thrown when the plaintext is not a manifest this build can read. */
133
+ export class ManifestFormatError extends Error {
134
+ constructor(message) {
135
+ super(message);
136
+ this.name = "ManifestFormatError";
137
+ }
138
+ }
139
+ /**
140
+ * Entries → the plaintext to seal.
141
+ *
142
+ * Compresses when the platform offers `CompressionStream`, which is the normal case and roughly
143
+ * halves the blob (names and repeated JSON keys compress well). When it does not, the raw form is
144
+ * written instead of failing: an older browser must still be able to save its drive.
145
+ */
146
+ export async function encodeManifest(entries, seq, prev, settings) {
147
+ if (!Number.isSafeInteger(seq) || seq < 1) {
148
+ throw new ManifestFormatError(`manifest seq must be a positive integer, got ${seq}`);
149
+ }
150
+ // Version 1 has nothing before it; every later version must name what it continued from, or the
151
+ // fork check has a hole exactly where a fork would be introduced.
152
+ if (seq > 1 && !prev) {
153
+ throw new ManifestFormatError(`manifest seq ${seq} must name the version it was built on`);
154
+ }
155
+ const st = settingsToWire(settings);
156
+ const wire = {
157
+ v: MANIFEST_FORMAT_VERSION,
158
+ seq,
159
+ ...(prev ? { p: prev } : {}),
160
+ items: entries.map(toWire),
161
+ ...(st ? { st } : {}),
162
+ };
163
+ const json = new TextEncoder().encode(JSON.stringify(wire));
164
+ const gz = await gzip(json);
165
+ return gz ? withFlag(FLAG_GZIP, gz) : withFlag(FLAG_RAW, json);
166
+ }
167
+ /**
168
+ * Sealed plaintext → entries.
169
+ *
170
+ * Throws `ManifestFormatError` on anything it cannot read — including a version from the future.
171
+ * Callers must NOT treat a throw as "the drive is empty": it is the signal to try the retained
172
+ * previous version, because rendering an empty drive invites the user to re-upload everything.
173
+ */
174
+ export async function decodeManifest(body) {
175
+ if (body.length < 1)
176
+ throw new ManifestFormatError("empty manifest body");
177
+ const flag = body[0];
178
+ const rest = body.subarray(1);
179
+ let json;
180
+ if (flag === FLAG_RAW) {
181
+ json = rest;
182
+ }
183
+ else if (flag === FLAG_GZIP) {
184
+ const out = await gunzip(rest);
185
+ if (!out)
186
+ throw new ManifestFormatError("manifest is gzipped but this platform cannot expand it");
187
+ json = out;
188
+ }
189
+ else {
190
+ throw new ManifestFormatError(`unknown manifest compression flag ${flag}`);
191
+ }
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(json));
195
+ }
196
+ catch {
197
+ throw new ManifestFormatError("manifest body is not valid JSON");
198
+ }
199
+ if (!parsed || typeof parsed !== "object") {
200
+ throw new ManifestFormatError("manifest body is not an object");
201
+ }
202
+ const doc = parsed;
203
+ if (doc.v !== MANIFEST_FORMAT_VERSION) {
204
+ // A newer version means another device wrote a format this build predates. Refusing is the
205
+ // safe answer: guessing at unknown fields and then SAVING would drop whatever it did not
206
+ // understand, silently destroying entries the other device could still read.
207
+ throw new ManifestFormatError(`unsupported manifest version ${String(doc.v)}`);
208
+ }
209
+ if (!Array.isArray(doc.items)) {
210
+ throw new ManifestFormatError("manifest has no item list");
211
+ }
212
+ // The sealed version is required. Treating a missing one as "0" or "unknown" would reopen
213
+ // exactly the hole this field closes: the server could strip its way back to an unchecked read.
214
+ if (!Number.isSafeInteger(doc.seq) || doc.seq < 1) {
215
+ throw new ManifestFormatError(`manifest has no sealed version (seq=${String(doc.seq)})`);
216
+ }
217
+ const seq = doc.seq;
218
+ if (seq > 1 && typeof doc.p !== "string") {
219
+ throw new ManifestFormatError(`manifest ${seq} does not say what it was built on`);
220
+ }
221
+ const settings = settingsFromWire(doc.st);
222
+ return {
223
+ v: doc.v,
224
+ seq,
225
+ ...(typeof doc.p === "string" ? { prev: doc.p } : {}),
226
+ entries: doc.items.map(fromWire),
227
+ ...(settings ? { settings } : {}),
228
+ };
229
+ }
230
+ /** Prepend the compression flag byte. */
231
+ function withFlag(flag, body) {
232
+ const out = new Uint8Array(body.length + 1);
233
+ out[0] = flag;
234
+ out.set(body, 1);
235
+ return out;
236
+ }
237
+ /** gzip, or null when the platform has no `CompressionStream`. */
238
+ async function gzip(bytes) {
239
+ const C = globalThis.CompressionStream;
240
+ if (!C)
241
+ return null;
242
+ return collect(streamThrough(bytes, new C("gzip")));
243
+ }
244
+ /** gunzip, or null when the platform has no `DecompressionStream`. */
245
+ async function gunzip(bytes) {
246
+ const D = globalThis.DecompressionStream;
247
+ if (!D)
248
+ return null;
249
+ return collect(streamThrough(bytes, new D("gzip")));
250
+ }
251
+ function streamThrough(bytes,
252
+ // `CompressionStream`'s writable side is typed `BufferSource`, its readable side `Uint8Array`.
253
+ // The pair is spelled out here rather than constrained to one element type so both directions
254
+ // type-check without an `as` on the transform itself.
255
+ transform) {
256
+ const source = new ReadableStream({
257
+ start(controller) {
258
+ // Copied into a view over a plain ArrayBuffer: a `Uint8Array` may sit on a
259
+ // SharedArrayBuffer, which the stream's `BufferSource` input does not accept. One copy of
260
+ // an already-serialised list is negligible next to the compression pass that follows.
261
+ const owned = new Uint8Array(new ArrayBuffer(bytes.length));
262
+ owned.set(bytes);
263
+ controller.enqueue(owned);
264
+ controller.close();
265
+ },
266
+ });
267
+ return source.pipeThrough(transform);
268
+ }
269
+ async function collect(stream) {
270
+ const reader = stream.getReader();
271
+ const chunks = [];
272
+ let total = 0;
273
+ for (;;) {
274
+ const { done, value } = await reader.read();
275
+ if (done)
276
+ break;
277
+ chunks.push(value);
278
+ total += value.length;
279
+ }
280
+ const out = new Uint8Array(total);
281
+ let at = 0;
282
+ for (const c of chunks) {
283
+ out.set(c, at);
284
+ at += c.length;
285
+ }
286
+ return out;
287
+ }