@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
@@ -0,0 +1,242 @@
1
+ // One local file, from a path to a committed item — however many parts that takes.
2
+ //
3
+ // ⛔ THIS IS WHERE A FILE BECOMES SEVERAL BLOBS. `upload.ts` buys and fills ONE part; this drives
4
+ // the plan, one part at a time, and then commits them together. Splitting it that way is what
5
+ // lets a file be larger than memory: the file is read a slice at a time and only one part's
6
+ // sealed bytes are ever live.
7
+ //
8
+ // ⛔ ONE FILE IS ONE COMMIT. The server derives an item's size from the sum of its parts and
9
+ // refuses a set whose indices are not a contiguous 0..n, so there is no such thing as
10
+ // committing half a file. A run that stops between parts has bought and filled storage that
11
+ // nothing in the account can see yet, and the record on disk is what turns that into a resume
12
+ // rather than a loss.
13
+ //
14
+ // ⛔ THE FILE IS READ TWICE AND THAT IS DELIBERATE. The first pass computes two things at once —
15
+ // the reservation key, and the SHA-256 the account will check the contents against — because
16
+ // both are needed BEFORE the first part is sealed. The second pass is the sealing itself. The
17
+ // alternative is holding the file, which is the thing this module exists to avoid.
18
+ import { createHash } from "node:crypto";
19
+ import { open } from "node:fs/promises";
20
+ import { AAD } from "./crypto.js";
21
+ import { NmtsError } from "./errors.js";
22
+ import { fileSecrets, sealPart } from "./seal.js";
23
+ import { planParts } from "./shared/lib/upload/part-plan.js";
24
+ import { paddedPlaintextLen, } from "./shared/lib/crypto/size-padding.js";
25
+ import { NCF3_SHAPE } from "./seal.js";
26
+ import { buyAndPushPart, entryOf } from "./upload.js";
27
+ import { commitItem } from "./upload-steps.js";
28
+ import { finishReservationKey, partKey, readItemRecord, readReservationBytes, readReservationRecord, startReservationKey, } from "./upload-store.js";
29
+ /** How much plaintext is handed to the engine at a time. Matches the format's own chunk size. */
30
+ const READ_CHUNK_BYTES = 4 * 2 ** 20;
31
+ /** Read a file off the disk, a chunk at a time. */
32
+ export function fileSource(path, size) {
33
+ return {
34
+ size,
35
+ async *read(offset, length) {
36
+ const handle = await open(path, "r");
37
+ try {
38
+ const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, length));
39
+ let at = 0;
40
+ while (at < length) {
41
+ const want = Math.min(buffer.length, length - at);
42
+ const { bytesRead } = await handle.read(buffer, 0, want, offset + at);
43
+ if (bytesRead === 0) {
44
+ // ⛔ SHORT IS NOT DONE. The plan was made from the size this file had when it was
45
+ // measured; a read that ends early means it shrank underneath us, and sealing what
46
+ // arrived would declare a length the bytes do not match.
47
+ throw new NmtsError(`${path} ended after ${at} of ${length} bytes.`, {
48
+ nextStep: "Nothing was sent. The file changed while it was being read.",
49
+ });
50
+ }
51
+ at += bytesRead;
52
+ yield new Uint8Array(buffer.subarray(0, bytesRead));
53
+ }
54
+ }
55
+ finally {
56
+ await handle.close();
57
+ }
58
+ },
59
+ };
60
+ }
61
+ /**
62
+ * Upload one file and return what the caller must write into the account's file list.
63
+ *
64
+ * ⛔ IT DOES NOT WRITE THE FILE LIST, and the caller must — before clearing the records. A
65
+ * committed file the list does not name is invisible and, to the person, indistinguishable from
66
+ * one that never uploaded.
67
+ */
68
+ export async function uploadFile(input) {
69
+ const { dataKey, source, onStep } = input;
70
+ if (source.size <= 0) {
71
+ throw new NmtsError("An empty file cannot be uploaded.", {
72
+ nextStep: "The storage network has nothing to store and would refuse the reservation.",
73
+ });
74
+ }
75
+ const plan = planParts(source.size, input.partSize);
76
+ onStep?.({ step: "planning", parts: plan.length, partSize: input.partSize });
77
+ // ── pass one: the reservation key and the content hash, from a single read ──
78
+ onStep?.({ step: "hashing", parts: plan.length });
79
+ const keyHash = startReservationKey(dataKey);
80
+ const contentDigest = await hashWhole(source, keyHash);
81
+ const fileKey = finishReservationKey(keyHash, input.name, input.destination);
82
+ // ⛔ ALREADY COMMITTED STOPS HERE, BEFORE ANY PART IS TOUCHED. A file that got as far as
83
+ // `POST /v1/items` exists and is paid for; all that can still be missing is the account's own
84
+ // list. Asking the server about every part again would be a round trip per part to learn
85
+ // something the record already says.
86
+ const committed = readItemRecord(fileKey);
87
+ if (committed?.itemId !== undefined) {
88
+ const entry = recordedEntry(fileKey, plan.length, source.size);
89
+ if (entry !== null) {
90
+ return { itemId: committed.itemId, resumed: true, ledgerIds: [], fileKey, parts: plan.length, entry };
91
+ }
92
+ }
93
+ // ── the file's secrets: from the record if one exists, otherwise made now ──
94
+ const secrets = openSecrets(input, fileKey, plan.length, contentDigest);
95
+ try {
96
+ const entry = {
97
+ name: input.name,
98
+ parentId: input.parentId,
99
+ plaintextLen: source.size,
100
+ dekWrapped: secrets.dekWrapped,
101
+ contentHashCt: secrets.contentHashCt,
102
+ };
103
+ const paid = [];
104
+ for (const range of plan) {
105
+ const key = partKey(fileKey, range.partIndex);
106
+ const stored = readReservationRecord(key);
107
+ // ⛔ A PART THAT IS WRITTEN DOWN IS NEVER SEALED AGAIN. Its bytes are a particular sealing
108
+ // the treasury may already have paid to register; a fresh one is a different blob.
109
+ // ⛔ THE LAST PART ONLY. Every reader recovers the parts' real lengths from the file's size
110
+ // and what each stream declares, and that answer is unique only because the earlier parts
111
+ // are exactly full. Padding one of them would not fail here — it would fail years later,
112
+ // as a download that wrote padding into the middle of a file.
113
+ const isLast = range.partIndex === plan.length - 1;
114
+ const sealFrom = isLast
115
+ ? paddedPlaintextLen(range.length, input.padding.rule, {
116
+ unitBytes: input.padding.unitBytes,
117
+ shape: NCF3_SHAPE,
118
+ })
119
+ : range.length;
120
+ const sealed = stored === null
121
+ ? await sealPartOf(input, secrets.dek, range, plan.length, sealFrom)
122
+ : readReservationBytes(key);
123
+ paid.push(await buyAndPushPart({
124
+ api: input.api,
125
+ protocol: input.protocol,
126
+ key,
127
+ sealed,
128
+ relayUrl: input.relayUrl,
129
+ epochs: input.epochs,
130
+ currentEpoch: input.currentEpoch,
131
+ // ⚠ The length the STREAM declares, which for a padded last part is more than the file
132
+ // contributes. The list entry keeps the file's real size; this is what was sealed.
133
+ part: { index: range.partIndex, total: plan.length, plaintextLen: sealFrom },
134
+ entry,
135
+ onStep: (step) => onStep?.({ ...step, partIndex: range.partIndex, parts: plan.length }),
136
+ }));
137
+ }
138
+ const itemId = await commitItem({
139
+ api: input.api,
140
+ epochs: input.epochs,
141
+ currentEpoch: input.currentEpoch,
142
+ entry,
143
+ onStep: (step) => onStep?.({ ...step, partIndex: plan.length - 1, parts: plan.length }),
144
+ }, fileKey, paid);
145
+ return {
146
+ itemId,
147
+ resumed: paid.every((part) => part.resumed),
148
+ ledgerIds: paid.map((part) => part.ledgerId),
149
+ fileKey,
150
+ parts: plan.length,
151
+ entry,
152
+ };
153
+ }
154
+ finally {
155
+ secrets.dek.fill(0);
156
+ }
157
+ }
158
+ /** The file key's records, so a caller can clear them once the list is written. */
159
+ export function partKeysOf(fileKey, parts) {
160
+ return Array.from({ length: parts }, (_, index) => partKey(fileKey, index));
161
+ }
162
+ /** Read the whole file once, feeding the key hash and returning the content digest. */
163
+ async function hashWhole(source, keyHash) {
164
+ const content = createHash("sha256");
165
+ for await (const chunk of source.read(0, source.size)) {
166
+ keyHash.update(chunk);
167
+ content.update(chunk);
168
+ }
169
+ return new Uint8Array(content.digest());
170
+ }
171
+ /**
172
+ * The file's key and sealed content hash — from a written-down part if there is one.
173
+ *
174
+ * ⛔ A RESUMED RUN MUST NOT MAKE A NEW KEY. Every part of this file was sealed under one key, and
175
+ * the bytes already on the network cannot be re-sealed. Unwrapping the recorded one is what
176
+ * makes the parts still to come belong to the same file.
177
+ */
178
+ function openSecrets(input, fileKey, parts, contentDigest) {
179
+ for (const key of partKeysOf(fileKey, parts)) {
180
+ const record = readReservationRecord(key);
181
+ if (record === null)
182
+ continue;
183
+ const wrapped = new Uint8Array(Buffer.from(record.dekWrapped, "base64url"));
184
+ const dek = input.crypt.envelope_open(input.dataKey, new TextEncoder().encode(AAD.dekWrap), wrapped);
185
+ return { dek, dekWrapped: record.dekWrapped, contentHashCt: record.contentHashCt };
186
+ }
187
+ return fileSecrets(input.crypt, input.dataKey, contentDigest);
188
+ }
189
+ async function sealPartOf(input, dek, range, parts, sealFrom) {
190
+ input.onStep?.({
191
+ step: "sealing",
192
+ partIndex: range.partIndex,
193
+ parts,
194
+ bytes: sealFrom,
195
+ });
196
+ return sealPart(input.crypt, dek, padded(input.source.read(range.offset, range.length), range.length, sealFrom), {
197
+ index: range.partIndex,
198
+ total: parts,
199
+ plaintextLen: sealFrom,
200
+ });
201
+ }
202
+ /**
203
+ * The part's bytes, followed by zeros up to the length it will declare.
204
+ *
205
+ * ⛔ ZEROS APPENDED TO THE PLAINTEXT, NOT BYTES TACKED ONTO THE STORED STREAM. A stream's header is
206
+ * authenticated but not encrypted, so padding added after sealing would leave the real length
207
+ * legible in the header of a public object — which is the exact thing this is for.
208
+ */
209
+ async function* padded(chunks, real, sealFrom) {
210
+ for await (const chunk of chunks)
211
+ yield chunk;
212
+ let left = sealFrom - real;
213
+ const zeros = new Uint8Array(Math.min(left, 1 << 20));
214
+ while (left > 0) {
215
+ const take = Math.min(left, zeros.length);
216
+ yield zeros.subarray(0, take);
217
+ left -= take;
218
+ }
219
+ }
220
+ export { entryOf };
221
+ /**
222
+ * The file list entry a written-down part remembers.
223
+ *
224
+ * ⛔ FROM THE RECORD, NEVER FROM THE RUN. The key that opens the stored bytes is the key they were
225
+ * sealed with. A run that wrote its own freshly generated one into the list would produce a
226
+ * file that is paid for, present, correctly named and impossible to open.
227
+ */
228
+ function recordedEntry(fileKey, parts, size) {
229
+ for (const key of partKeysOf(fileKey, parts)) {
230
+ const record = readReservationRecord(key);
231
+ if (record === null)
232
+ continue;
233
+ return {
234
+ name: record.name,
235
+ parentId: record.parentId,
236
+ plaintextLen: size,
237
+ dekWrapped: record.dekWrapped,
238
+ contentHashCt: record.contentHashCt,
239
+ };
240
+ }
241
+ return null;
242
+ }
@@ -0,0 +1,117 @@
1
+ // What an upload will cost, and how the file is cut up to pay for it.
2
+ //
3
+ // ⛔ SPLIT OUT OF `put.ts` SO THAT FILE STAYS READABLE IN ONE SITTING. Everything here is
4
+ // arithmetic over numbers — no network, no crypto, no disk beyond one `stat` — which is what
5
+ // lets `--dry-run` answer without reading a very large file, and what lets a test drive the
6
+ // price without an account.
7
+ import { statSync } from "node:fs";
8
+ import { NmtsError } from "./errors.js";
9
+ import { DEFAULT_PART_BYTES, NCF3_SHAPE, sealedLenFor } from "./seal.js";
10
+ import { paddedPlaintextLen } from "./shared/lib/crypto/size-padding.js";
11
+ import { planParts } from "./shared/lib/upload/part-plan.js";
12
+ /**
13
+ * Storage term, in storage-network epochs.
14
+ *
15
+ * ⛔ NOT CHOOSABLE HERE, and that is a property of credits rather than of this tool: one credit is
16
+ * defined as one mebibyte for exactly this term. Offering a duration picker would be offering
17
+ * to spend a multiple of a unit the credit surface does not speak in. A browser's own-wallet
18
+ * upload has a picker because it is paying in WAL, which does divide.
19
+ */
20
+ export const UPLOAD_EPOCHS = 2;
21
+ /** Bytes one credit stores for `UPLOAD_EPOCHS`. */
22
+ export const CREDIT_BYTES = 1024 * 1024;
23
+ /**
24
+ * What this upload will cost, in credits — the same arithmetic the server does.
25
+ *
26
+ * Printed BEFORE anything is spent so the number can be compared with what the account screen
27
+ * shows afterwards. The server is still the authority; this is a quote, not a promise.
28
+ */
29
+ export function creditsFor(sealedBytes, epochs = UPLOAD_EPOCHS) {
30
+ const mib = Math.ceil(sealedBytes / CREDIT_BYTES);
31
+ return Math.ceil((mib * epochs) / UPLOAD_EPOCHS);
32
+ }
33
+ /**
34
+ * How big the local file is, with the two refusals that are worth their own words.
35
+ *
36
+ * ⛔ IT IS NOT READ HERE. A file large enough to need several parts is a file too large to hold,
37
+ * and the size is all that is needed to plan the upload and quote its price. The bytes are read
38
+ * later, a slice at a time, by the part that is being sealed.
39
+ */
40
+ export function measureLocal(path) {
41
+ let stat;
42
+ try {
43
+ stat = statSync(path);
44
+ }
45
+ catch {
46
+ throw new NmtsError(`There is no file at ${path}.`, { exitCode: 4 });
47
+ }
48
+ if (stat.isDirectory()) {
49
+ throw new NmtsError(`${path} is a folder.`, {
50
+ exitCode: 4,
51
+ nextStep: "This version uploads one file at a time.",
52
+ });
53
+ }
54
+ if (stat.size === 0) {
55
+ throw new NmtsError(`${path} is empty.`, {
56
+ exitCode: 4,
57
+ nextStep: "The storage network has nothing to store and would refuse the reservation.",
58
+ });
59
+ }
60
+ return stat.size;
61
+ }
62
+ /**
63
+ * How much of the file goes into one part.
64
+ *
65
+ * ⛔ THE SAME NUMBER ON A RESUME OR NOTHING MATCHES. The parts already written down were sealed
66
+ * and paid for at one size; a second run that split the file differently would be asking to
67
+ * push different bytes under reservations that bought the first ones. `buyAndPushPart` refuses
68
+ * that rather than doing it, and this is where the number comes from.
69
+ */
70
+ export function partSizeFor(chosen) {
71
+ if (chosen === undefined)
72
+ return DEFAULT_PART_BYTES;
73
+ const bytes = typeof chosen === "number" ? chosen : parseSize(chosen);
74
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) {
75
+ throw new NmtsError(`A part size must be a positive whole number of bytes: ${String(chosen)}.`, {
76
+ exitCode: 2,
77
+ nextStep: "Nothing was sent. Give a number of bytes, or a number with KiB, MiB or GiB.",
78
+ });
79
+ }
80
+ return bytes;
81
+ }
82
+ /**
83
+ * A byte count, plainly or with a unit.
84
+ *
85
+ * ⛔ EXACT NUMBERS ARE ALWAYS ACCEPTED. The units are a convenience on top, never instead: a person
86
+ * who knows exactly what their machine can hold has to be able to say exactly that.
87
+ */
88
+ function parseSize(text) {
89
+ const match = /^(\d+)\s*(B|KiB|MiB|GiB|K|M|G)?$/i.exec(text.trim());
90
+ if (match === null || match[1] === undefined)
91
+ return Number.NaN;
92
+ const scale = { b: 1, k: 2 ** 10, kib: 2 ** 10, m: 2 ** 20, mib: 2 ** 20, g: 2 ** 30, gib: 2 ** 30 };
93
+ const unit = (match[2] ?? "B").toLowerCase();
94
+ const factor = scale[unit];
95
+ if (factor === undefined)
96
+ return Number.NaN;
97
+ return Number(match[1]) * factor;
98
+ }
99
+ /**
100
+ * The plan, and what it will cost — one function so the price and the sealing cannot disagree.
101
+ *
102
+ * ⛔ ONLY THE LAST PART IS ROUNDED UP. The earlier ones are exactly the part size, which is what
103
+ * lets a reader work out where the padding is. Quoting any other way would price a file
104
+ * differently from how it is actually stored.
105
+ */
106
+ export function planAndPrice(size, partSize, rule) {
107
+ const plan = planParts(size, partSize);
108
+ const sealFor = (range) => range.partIndex === plan.length - 1
109
+ ? paddedPlaintextLen(range.length, rule, { unitBytes: CREDIT_BYTES, shape: NCF3_SHAPE })
110
+ : range.length;
111
+ return {
112
+ plan,
113
+ sealFor,
114
+ sealedBytes: plan.reduce((sum, range) => sum + sealedLenFor(sealFor(range)), 0),
115
+ credits: plan.reduce((sum, range) => sum + creditsFor(sealedLenFor(sealFor(range))), 0),
116
+ };
117
+ }
@@ -0,0 +1,111 @@
1
+ // The two steps after the money has moved: get the bytes onto the network, and make the file real.
2
+ //
3
+ // ⛔ SPLIT OUT OF `upload.ts` SO THAT FILE STAYS READABLE IN ONE SITTING. It is the file that
4
+ // decides what gets bought; these are the steps it drives afterwards, and both of them run in a
5
+ // world where the account has ALREADY PAID. Every failure message below therefore has to say so
6
+ // — "try again" is honest advice before the reserve and misleading after it.
7
+ import { readItemRecord, writeItemRecord } from "./upload-store.js";
8
+ import { UploadError, } from "./upload-wire.js";
9
+ /** ITEM PARTS: the storage shape this tool writes — one blob per part, the file its own set. */
10
+ const STORAGE_KIND_DEDICATED_BLOB = 0;
11
+ /** Walrus. The only network with an upload path. */
12
+ const NETWORK_WALRUS = 0;
13
+ /** Only this account can read it. Sharing is a separate act, made in a browser. */
14
+ const VISIBILITY_PERSONAL = 0;
15
+ function why(error) {
16
+ return error instanceof Error ? error.message : String(error);
17
+ }
18
+ /** Push ONE part's bytes at the relay and report the certificate. Commits nothing. */
19
+ export async function pushPart(input, step) {
20
+ input.onStep?.({ step: "uploading", relayUrl: step.relayUrl, bytes: step.sealed.length });
21
+ let certificate;
22
+ try {
23
+ certificate = await input.protocol.uploadToRelay({
24
+ blobId: step.blobId,
25
+ bytes: step.sealed,
26
+ nonce: step.nonce,
27
+ registerTxDigest: step.registerTxDigest,
28
+ blobObjectId: step.blobObjectId,
29
+ });
30
+ }
31
+ catch (error) {
32
+ throw new UploadError({
33
+ phase: "uploading",
34
+ message: `Uploading to the storage network failed: ${why(error)}`,
35
+ paid: true,
36
+ nextStep: "The storage is already bought. Running the same command again pushes the same bytes to " +
37
+ "the same relay and costs nothing more.",
38
+ });
39
+ }
40
+ input.onStep?.({ step: "certifying" });
41
+ try {
42
+ await input.api.uploaded(step.ledgerId, certificate);
43
+ }
44
+ catch (error) {
45
+ throw new UploadError({
46
+ phase: "certify",
47
+ message: why(error),
48
+ paid: true,
49
+ nextStep: "The bytes are on the network. Certifying moves no money, so running the same command " +
50
+ "again simply finishes the job.",
51
+ });
52
+ }
53
+ }
54
+ /**
55
+ * Make the file real: one `POST /v1/items` naming EVERY part and the reservation that paid for it.
56
+ *
57
+ * ⛔ ONE CALL FOR THE WHOLE FILE. The server derives the item's size as the sum of the parts'
58
+ * sealed lengths and refuses a set whose indices are not a contiguous 0..n — so a file cannot
59
+ * be committed a piece at a time, and a part left out is not a smaller file, it is a rejection.
60
+ *
61
+ * ⛔ EVERY FACT HERE COMES OFF A RECORD. The parts were read back from what was written down
62
+ * before each reservation; the key and the content hash come from whichever record the caller
63
+ * read them from. A resumed run that used its own freshly generated key would produce a file
64
+ * that is paid for, present, correctly named — and impossible to open.
65
+ */
66
+ export async function commitItem(input, fileKey, parts) {
67
+ input.onStep?.({ step: "committing" });
68
+ // Advisory only — the chain is the authority on a blob's life. 0 when this machine could not
69
+ // read the epoch clock: a number we do not have is not a number to invent.
70
+ const expiryEpoch = input.currentEpoch === null ? 0 : input.currentEpoch + input.epochs;
71
+ const previous = readItemRecord(fileKey);
72
+ // ⛔ ALREADY COMMITTED IS NOT COMMITTED AGAIN. The record outlives the commit precisely so a run
73
+ // that died before writing the file list does not make a second file out of storage that is
74
+ // already named.
75
+ if (previous?.itemId !== undefined)
76
+ return previous.itemId;
77
+ const attempt = previous?.attempt ?? 0;
78
+ writeItemRecord(fileKey, { attempt });
79
+ let view;
80
+ try {
81
+ view = await input.api.createItem({
82
+ size: parts.reduce((sum, part) => sum + part.sealedLen, 0),
83
+ dek_wrapped: input.entry.dekWrapped,
84
+ content_hash_ct: input.entry.contentHashCt,
85
+ visibility: VISIBILITY_PERSONAL,
86
+ parts: parts.map((part) => ({
87
+ part_index: part.partIndex,
88
+ storage_kind: STORAGE_KIND_DEDICATED_BLOB,
89
+ network: NETWORK_WALRUS,
90
+ blob_id: part.blobId,
91
+ sealed_len: part.sealedLen,
92
+ expiry_epoch: expiryEpoch,
93
+ sponsored_ledger_id: part.ledgerId,
94
+ })),
95
+ }, `nmts-cli-commit-${fileKey}-${attempt}`);
96
+ }
97
+ catch (error) {
98
+ throw new UploadError({
99
+ phase: "committing",
100
+ message: `The file is stored but saving it to the drive failed: ${why(error)}`,
101
+ paid: true,
102
+ nextStep: "Nothing more will be spent. Running the same command again commits the same stored " +
103
+ "bytes — the retry is recognised and does not make a second file.",
104
+ });
105
+ }
106
+ // ⛔ Written down before returning: from here on the file EXISTS and is paid for, and the only
107
+ // thing still missing is the account's own list. Losing the record now would make it
108
+ // unreachable.
109
+ writeItemRecord(fileKey, { attempt, itemId: view.id });
110
+ return view.id;
111
+ }