@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,114 @@
1
+ // The FILE a person keeps — a self-describing wrapper around one sealed recovery list.
2
+ //
3
+ // ⛔ ONE FORMAT, NOT TWO. `format`, `version` and the `.nmtsmap` extension are what a reader
4
+ // matches on, and somebody recovering an account may hold copies written by a browser and by
5
+ // this command months apart. The shape below is RECOVERY-MANIFEST.md §5 and the standalone
6
+ // program's `recovery/src/mapfile.rs` reads it; a second spelling of the same artefact would
7
+ // mean whoever helps them has two formats to work out instead of one, at the worst moment.
8
+ //
9
+ // ⛔ WHY A WRAPPER AND NOT THE BARE ENVELOPE. On the storage network the list is the raw envelope
10
+ // and its address says what it is. A file in somebody's Downloads folder has no such context —
11
+ // years later it has to explain itself. `sealed` is byte-for-byte the SAME envelope a
12
+ // storage-network copy would hold, so one reader reads either.
13
+ //
14
+ // ⛔ NOTHING SECRET IN THE HEADER, AND THAT INCLUDES SIZES. No file name, no count, no total. A
15
+ // leaked wrapper must not say what is inside it, or even how much.
16
+ //
17
+ // ⛔ THE ACCOUNT CODE IS NOT IN IT. This file plus the code is the account; the artefact that DOES
18
+ // carry both is the recovery kit, and it says so about itself in every language it is written.
19
+ //
20
+ // ⚠ WHAT DIFFERS FROM THE BROWSER'S COPY, and none of it is a format difference:
21
+ // · The finder's note is English only. §5 says v2 carries both languages, and the reason is
22
+ // sound — which language the finder reads is not something the moment of saving can know. The
23
+ // Korean half lives in the site's message bundles, which this package does not import, and
24
+ // `cli/src/list-file.ts` set the precedent for the sibling artefact: ship the English and say
25
+ // so rather than invent a translation. The four English sentences below ARE the site's, word
26
+ // for word, so the two artefacts do not describe themselves differently. ⚠ A reader never
27
+ // reads `note` at all (`recovery/src/mapfile.rs` ignores it), so nothing refuses this file.
28
+ // · `about.app_version` names THIS PROGRAM rather than the site release — the field's own
29
+ // contract, and how a person holding two copies tells which program wrote each.
30
+ // · `min_tool` is written from the table below, which is restated because it is a claim about a
31
+ // THIRD program's versions and cannot be derived from anything here.
32
+ import { artifactAbout } from "./artifact-about.js";
33
+ /** Wrapper format identifier, checked on read before anything is attempted. */
34
+ export const MAP_FILE_FORMAT = "nmts-recovery-map";
35
+ /**
36
+ * Wrapper version — the SHELL's version, independent of the NRM version inside.
37
+ *
38
+ * ⛔ NOT RAISED FOR ANYTHING THIS TOOL ADDS. `MAX_WRAPPER_VERSION` in the standalone program is a
39
+ * CEILING: a shell numbered higher than a build knows is refused outright, unread. So a bump is
40
+ * a wall in front of every reader already in somebody's hands, never a courtesy.
41
+ */
42
+ export const MAP_FILE_VERSION = 2;
43
+ /** Filename extension. Deliberately not the file-list copy's: the two must not be confusable. */
44
+ export const MAP_FILE_EXTENSION = "nmtsmap";
45
+ /**
46
+ * The lowest `nmts-recovery` version that reads a document of a given NRM version.
47
+ *
48
+ * ⛔ IT SITS BESIDE `nrm` RATHER THAN REPLACING IT. `nrm` says which forms the document uses,
49
+ * which is the right question for any reader — ours or a stranger's re-implementation. It is
50
+ * the wrong question for the person holding this file during a recovery, whose actual question
51
+ * is "what do I need to download".
52
+ *
53
+ * ⚠ A CLAIM ABOUT A DIFFERENT PROGRAM, so it cannot be derived — it is measured and written down.
54
+ * 0.1.0 was the first published build and its ceiling was NRM-2; 0.2.0 raised it and added
55
+ * padded parts (NRM-4). A version this table does not know is answered with the newest entry,
56
+ * because a form this build can write is a form the build published alongside it reads.
57
+ *
58
+ * ⛔ AND IT RESCUES NOBODY. Knowing you need 0.2.0 does not help if 0.2.0 does not exist, which is
59
+ * why the program still ships BEFORE a new form is switched on. What it buys is a refusal a
60
+ * person can act on, not one they can survive.
61
+ */
62
+ const MIN_TOOL_FOR_NRM = [
63
+ { upTo: 2, version: "0.1.0" },
64
+ { upTo: 4, version: "0.2.0" },
65
+ ];
66
+ /** The version to stamp for a document declaring `nrm`. */
67
+ export function minimumToolVersion(nrm) {
68
+ const row = MIN_TOOL_FOR_NRM.find((r) => nrm <= r.upTo) ?? MIN_TOOL_FOR_NRM.at(-1);
69
+ // The table is a literal with entries; the fallback exists so this cannot be a non-null
70
+ // assertion, which is banned here and would be a promise about a value rather than a check.
71
+ return row === undefined ? "0.1.0" : row.version;
72
+ }
73
+ /**
74
+ * Plain-language lines for whoever finds this file with no other context.
75
+ *
76
+ * ⚠ COPIED WORD FOR WORD from the site's `mapFileNoteEn1..4`. They are product copy and this is
77
+ * not the place to rewrite them; if they change there, they change here.
78
+ */
79
+ const NOTE = [
80
+ `This file is a recovery list from NMTS (nmts.me). Encrypted inside is the record of where ` +
81
+ `this account's files are kept on the public storage network (Walrus).`,
82
+ `It opens only with the account code this list was made for. File names and counts are not ` +
83
+ `visible from the outside.`,
84
+ `The original file list lives on the NMTS server. If the server can no longer be reached, this ` +
85
+ `list together with the account code carries the information needed to find the files still ` +
86
+ `on the storage network and open them.`,
87
+ `A list with a higher number in its filename is newer — keep the newest. Store this file ` +
88
+ `somewhere other than the account code. The program that reads it is at ` +
89
+ `github.com/needmoretruth/nmts-recovery.`,
90
+ ];
91
+ /** Build the on-disk document and the filename to offer it under. */
92
+ export function buildRecoveryMapFile(input) {
93
+ const doc = {
94
+ format: MAP_FILE_FORMAT,
95
+ version: MAP_FILE_VERSION,
96
+ nrm: input.nrm,
97
+ seq: input.seq,
98
+ generated_at: input.generatedAt,
99
+ account_id: input.accountId,
100
+ sealed: input.sealed,
101
+ min_tool: minimumToolVersion(input.nrm),
102
+ note: [...NOTE],
103
+ about: artifactAbout("recovery-list"),
104
+ };
105
+ // A short slug keeps several accounts' files apart without printing the whole id in a filename
106
+ // that shows up in screenshots and file managers.
107
+ const slug = input.accountId.replace(/[^A-Za-z0-9]/g, "").slice(0, 8) || "account";
108
+ // ⚠ THE SEQUENCE IS IN THE NAME AS WELL AS THE BODY, zero-padded: somebody with three of these
109
+ // in a folder needs to know which is newest without opening any of them.
110
+ return {
111
+ filename: `nmts-recovery-map-${slug}-${String(input.seq).padStart(4, "0")}.${MAP_FILE_EXTENSION}`,
112
+ content: `${JSON.stringify(doc, null, 2)}\n`,
113
+ };
114
+ }
@@ -0,0 +1,191 @@
1
+ // The RECOVERY LIST DOCUMENT — the exact JSON shape that gets sealed, and nothing else.
2
+ //
3
+ // ⛔ THIS FORMAT HAS TWO INDEPENDENT IMPLEMENTATIONS AND THIS IS A THIRD WRITER. The browser
4
+ // writes it (`web/src/lib/recovery/manifest-doc.ts`), the standalone recovery program reads it
5
+ // in Rust (`crypto/src/manifest.rs`), and the authority both answer to is
6
+ // `docs/RECOVERY-MANIFEST.md` §2. A writer that drifts is not discovered by a failing test — it
7
+ // is discovered years later, by somebody who has nothing left but this file.
8
+ //
9
+ // ⛔ FIELD NAMES ARE snake_case AND THAT IS NOT THIS CODEBASE'S STYLE. They are the wire format.
10
+ //
11
+ // ⛔ NOTHING HERE TOUCHES A KEY. Callers hand in file keys and content hashes already opened and
12
+ // re-encoded as base64url; opening them is the builder's job, one layer up.
13
+ //
14
+ // ⚠ RESTATED FROM THE BROWSER'S ENCODER, WHICH THIS PACKAGE CANNOT IMPORT — the two trees share no
15
+ // code by design, and the browser's copy pulls in its own types tree. What is restated is the
16
+ // SHAPE and the four refusals below; the arithmetic that decides how much of a padded part is
17
+ // real is not restated at all, because `shared/lib/crypto/size-padding.ts` is a byte-for-byte
18
+ // copy of the browser's own file and a gate compares them.
19
+ import { NmtsError } from "./errors.js";
20
+ /** The newest NRM version this writer knows how to emit. */
21
+ export const NRM_VERSION_LATEST = 4;
22
+ /** The first NRM version in which every part carries `part_index`. */
23
+ export const NRM_VERSION_WITH_PART_INDEX = 2;
24
+ /** The first NRM version in which a quilt placement may be `{ identifier }` alone. */
25
+ export const NRM_VERSION_WITH_OWN_QUILT = 3;
26
+ /** The first NRM version in which a part may carry `padded_len`. */
27
+ export const NRM_VERSION_WITH_PADDING = 4;
28
+ /** Practical ceiling from RECOVERY-MANIFEST.md §1 — beyond this the format needs chunk framing. */
29
+ export const MANIFEST_ITEM_SOFT_CAP = 100_000;
30
+ /** Which form a placement is. One narrowing point, so "exactly one of the two" is decided here. */
31
+ export function isOwnQuilt(quilt) {
32
+ return quilt.identifier !== undefined;
33
+ }
34
+ /**
35
+ * Thrown when the input cannot produce a document a recovery tool could use.
36
+ *
37
+ * ⛔ IT IS AN `NmtsError`, NOT A BARE `Error`. Anything that reaches the top of this program as a
38
+ * bare error prints its message with no next step and exits with the generic code — and the
39
+ * generic code is the one an agent retries. A discrepancy here is never worth retrying: the
40
+ * server has to change, or the account does. Exit 4 is "the command exists and could not do
41
+ * it", which is exactly what happened.
42
+ */
43
+ export class RecoveryListProblem extends NmtsError {
44
+ constructor(message) {
45
+ super(message, {
46
+ exitCode: 4,
47
+ nextStep: "Nothing was written, and that is deliberate: a list quietly missing files would tell you " +
48
+ "you are covered when you are not. Retrying will not change it. The account screen at " +
49
+ "nmts.me builds the same list from the same rows and will report the same thing.",
50
+ });
51
+ this.name = "RecoveryListProblem";
52
+ }
53
+ }
54
+ /**
55
+ * The lowest `v` a document holding these items may honestly declare.
56
+ *
57
+ * ⛔ A WRITER STAMPS THIS, NOT THE NEWEST NUMBER IT KNOWS. People already hold copies of the
58
+ * standalone recovery program, and a build only knows the forms that existed when it was made.
59
+ * Every version number in this format is a CEILING in every published build: a document
60
+ * declaring a number higher than a build knows is REFUSED, unread. So stamping `4` for no
61
+ * reason other than the calendar would be a wall in front of a reader that would have
62
+ * understood every byte of it.
63
+ */
64
+ export function minimumVersion(items) {
65
+ if (items.some((it) => it.parts.some((p) => p.padded_len !== undefined))) {
66
+ return NRM_VERSION_WITH_PADDING;
67
+ }
68
+ const ownQuilt = items.some((it) => it.quilt !== undefined && isOwnQuilt(it.quilt));
69
+ return ownQuilt ? NRM_VERSION_WITH_OWN_QUILT : NRM_VERSION_WITH_PART_INDEX;
70
+ }
71
+ /**
72
+ * Assemble the document.
73
+ *
74
+ * Rejects rather than emits a list that would mislead somebody in a recovery: a file with no parts
75
+ * has nothing to fetch, a part list that does not add up to the file's size is missing or repeating
76
+ * bytes, a part that says it is somewhere other than where it sits contradicts itself, and a
77
+ * `seq` below 1 is not a version at all. The hostile-input version of the middle two is caught one
78
+ * layer up, where the size being compared against comes from a source the server cannot write —
79
+ * but the cost of shipping any of them is that somebody believes they are covered when they are
80
+ * not, so they are checked here too rather than assumed.
81
+ */
82
+ export function buildRecoveryListDoc(input) {
83
+ if (!Number.isSafeInteger(input.seq) || input.seq < 1) {
84
+ throw new RecoveryListProblem(`seq must be a whole number of at least 1 (got ${input.seq})`);
85
+ }
86
+ if (input.items.length > MANIFEST_ITEM_SOFT_CAP) {
87
+ throw new RecoveryListProblem(`a recovery list of ${input.items.length} files is above the format's cap of ${MANIFEST_ITEM_SOFT_CAP}`);
88
+ }
89
+ const items = input.items.map((it) => {
90
+ if (it.parts.length === 0) {
91
+ throw new RecoveryListProblem(`item ${it.id} has no parts — nothing to recover from`);
92
+ }
93
+ const partBytes = it.parts.reduce((sum, p) => sum + p.plaintext_len, 0);
94
+ if (partBytes !== it.size) {
95
+ throw new RecoveryListProblem(`item ${it.id} is ${it.size} bytes but its ${it.parts.length} parts hold ${partBytes}`);
96
+ }
97
+ // Where the bytes are, decided once per item. The reader's copy of these two rules is
98
+ // `crypto/src/manifest.rs::check_quilt_placement`: a document this emits and that parser
99
+ // refuses would be discovered on the one day it cannot be repaired.
100
+ if (it.quilt !== undefined && isOwnQuilt(it.quilt)) {
101
+ const first = it.parts[0];
102
+ if (it.parts.length !== 1 || first === undefined || first.blob_id !== undefined) {
103
+ throw new RecoveryListProblem(`item ${it.id}: an own-quilt item must be exactly one part with no blob_id`);
104
+ }
105
+ }
106
+ else {
107
+ const missing = it.parts.findIndex((p) => p.blob_id === undefined);
108
+ if (missing >= 0) {
109
+ throw new RecoveryListProblem(`item ${it.id}: the part at position ${missing} has no blob_id and no own-quilt placement`);
110
+ }
111
+ }
112
+ const item = {
113
+ id: it.id,
114
+ name: it.name,
115
+ path: it.path,
116
+ size: it.size,
117
+ dek: it.dek,
118
+ kind: "file",
119
+ // Absent, never null: the format says a field that was not recorded is not there, and a
120
+ // reader must not have to tell two spellings of "not recorded" apart.
121
+ ...(it.createdAt ? { created_at: it.createdAt } : {}),
122
+ ...(it.updatedAt ? { updated_at: it.updatedAt } : {}),
123
+ parts: it.parts.map((p, i) => {
124
+ // ⛔ THE POSITION IS THE VALUE. Taking `part_index` from the array rather than from the
125
+ // caller's field is what makes the two impossible to disagree about in the written
126
+ // document; a caller that states its own number is checked against the position, because
127
+ // a disagreement means the order it handed in is not the order it meant.
128
+ if (p.part_index !== undefined && p.part_index !== i) {
129
+ throw new RecoveryListProblem(`item ${it.id}: the part at position ${i} says it is part ${p.part_index}`);
130
+ }
131
+ const part = p.blob_id === undefined
132
+ ? { part_index: i, plaintext_len: p.plaintext_len }
133
+ : { part_index: i, blob_id: p.blob_id, plaintext_len: p.plaintext_len };
134
+ // Strictly larger, or absent. Equal is not padding — writing it would make two identical
135
+ // lists differ in their bytes — and smaller is a stream that could not hold the part.
136
+ if (p.padded_len !== undefined) {
137
+ if (!Number.isSafeInteger(p.padded_len) || p.padded_len <= p.plaintext_len) {
138
+ throw new RecoveryListProblem(`item ${it.id}: the part at position ${i} contributes ${p.plaintext_len} bytes ` +
139
+ `out of a padded ${p.padded_len}`);
140
+ }
141
+ part.padded_len = p.padded_len;
142
+ }
143
+ if (p.sui_object_id)
144
+ part.sui_object_id = p.sui_object_id;
145
+ if (p.network)
146
+ part.network = p.network;
147
+ return part;
148
+ }),
149
+ };
150
+ if (it.contentHash)
151
+ item.content_hash = it.contentHash;
152
+ // Rebuilt rather than passed through, so a caller that supplied an explicit `undefined`
153
+ // beside the form it meant cannot put an extra key in the byte output.
154
+ if (it.quilt !== undefined) {
155
+ item.quilt = isOwnQuilt(it.quilt)
156
+ ? { identifier: it.quilt.identifier }
157
+ : { quilt_blob_id: it.quilt.quilt_blob_id, patch_id: it.quilt.patch_id };
158
+ }
159
+ return item;
160
+ });
161
+ return {
162
+ v: minimumVersion(items),
163
+ seq: input.seq,
164
+ // Kept as an explicit null so a reader can tell the head of the chain apart from a writer that
165
+ // never implemented chaining.
166
+ prev_manifest_blob_id: input.prevBlobId,
167
+ generated_at: input.generatedAt,
168
+ account_id: input.accountId,
169
+ // ⛔ Omitted when absent rather than written as null, and NOT a reason to raise `v`: every
170
+ // field in it is additive and its absence changes no meaning, so the recovery builds
171
+ // already in people's hands read a document carrying it exactly as one without it.
172
+ ...(input.meta ? { meta: input.meta } : {}),
173
+ items,
174
+ };
175
+ }
176
+ /** Finish a draft once the document's contents are known. */
177
+ export function withTotals(draft, totals) {
178
+ return { ...draft, totals };
179
+ }
180
+ /** The `meta` block for this build, minus the totals. */
181
+ export function recoveryDocMeta(about, storage) {
182
+ return {
183
+ product: about.product,
184
+ product_url: about.product_url,
185
+ app_version: about.app_version,
186
+ tool: about.tool,
187
+ tool_url: about.tool_url,
188
+ spec_url: about.spec_url,
189
+ storage,
190
+ };
191
+ }
@@ -0,0 +1,229 @@
1
+ // Where the standalone recovery program's executables come from, and which one this machine needs.
2
+ //
3
+ // ⛔ THE ASSET NAMES ARE A CONTRACT WITH ANOTHER REPOSITORY'S RELEASE WORKFLOW. That workflow
4
+ // builds one executable per platform and attaches it under exactly the name in the table below.
5
+ // They are not decoration and they are not guessed here: a name that drifts from the workflow's
6
+ // becomes a 404, and a 404 on this path reads as "there is no release" to somebody who has just
7
+ // lost access to their files. That is the worst sentence this command could say, and it would
8
+ // be a lie. `deploy/check-recovery-assets.mjs` set-compares this table against the workflow so
9
+ // the copy cannot go stale quietly.
10
+ //
11
+ // ⛔ RAW EXECUTABLES, NOT ARCHIVES, and that is the release's decision rather than a convenience.
12
+ // This tool has no archive reader; adding one would mean a dependency in a program whose whole
13
+ // claim is that you can read what it does. One file per platform, downloaded and run.
14
+ //
15
+ // ⛔ NO NEAR MATCHES. A machine whose platform is not in the table is told so, told which ones are
16
+ // published, and told how to build from source. Handing an aarch64 machine an x86_64 executable
17
+ // produces "cannot execute binary file", which sends a person debugging their shell instead of
18
+ // reading one honest sentence.
19
+ /**
20
+ * The standalone program, by the name it is published under.
21
+ *
22
+ * ⛔ ONE SPELLING FOR THE WHOLE PACKAGE. It is the executable's file name, the prefix of every
23
+ * asset in the table below, and the word the sealed file list writes into its own header so
24
+ * that whoever finds that file years later knows what reads it. Two copies of this string is
25
+ * how a rename lands in one of those places and not the others.
26
+ */
27
+ export const RECOVERY_TOOL = "nmts-recovery";
28
+ /**
29
+ * Where that program's source and its releases are.
30
+ *
31
+ * ⚠ Printed as text into a file that has no links, and used to build the download addresses. It
32
+ * is the same one either way, so it is written down once.
33
+ */
34
+ export const RECOVERY_TOOL_URL = "https://github.com/needmoretruth/nmts-recovery";
35
+ /**
36
+ * Every executable the release workflow attaches, in the order it builds them.
37
+ *
38
+ * ⚠ macOS carries the marketing word in brackets because "aarch64" and "x86_64" are not what
39
+ * anybody's machine calls itself on that platform — the person reading a refusal has to be able
40
+ * to tell whether their own machine is in this list.
41
+ */
42
+ export const PUBLISHED = [
43
+ { platform: "linux", arch: "x64", asset: "nmts-recovery-linux-x86_64", label: "Linux x86_64" },
44
+ { platform: "linux", arch: "arm64", asset: "nmts-recovery-linux-aarch64", label: "Linux aarch64" },
45
+ {
46
+ platform: "darwin",
47
+ arch: "arm64",
48
+ asset: "nmts-recovery-macos-aarch64",
49
+ label: "macOS aarch64 (Apple silicon)",
50
+ },
51
+ { platform: "darwin", arch: "x64", asset: "nmts-recovery-macos-x86_64", label: "macOS x86_64 (Intel)" },
52
+ {
53
+ platform: "win32",
54
+ arch: "x64",
55
+ asset: "nmts-recovery-windows-x86_64.exe",
56
+ label: "Windows x86_64",
57
+ },
58
+ ];
59
+ /** The checksum file the release attaches beside the executables. */
60
+ export const SUMS_FILE = "SHA256SUMS";
61
+ /** The one this machine can run, or null when the release publishes nothing for it. */
62
+ export function executableFor(platform, arch) {
63
+ return PUBLISHED.find((e) => e.platform === platform && e.arch === arch) ?? null;
64
+ }
65
+ /** The published platforms, for telling somebody their own is not among them. */
66
+ export function publishedLabels() {
67
+ return PUBLISHED.map((e) => e.label);
68
+ }
69
+ /**
70
+ * The exact commands that build this program from source.
71
+ *
72
+ * ⛔ EXACT, NOT "BUILD IT YOURSELF". Somebody reading this is on an unusual machine and has
73
+ * already lost access to their files; "see the repository" is a research task, and three lines
74
+ * they can paste is not.
75
+ */
76
+ export function buildFromSource(sourceUrl) {
77
+ // The directory `git clone` makes is the last segment of the address, so it is read from the
78
+ // address rather than written again: the two cannot then disagree.
79
+ const cloned = sourceUrl.replace(/\/+$/, "").split("/").pop() ?? RECOVERY_TOOL;
80
+ return [`git clone ${sourceUrl}`, `cd ${cloned}/recovery`, `cargo build --release`];
81
+ }
82
+ /**
83
+ * What checking the bytes against SHA256SUMS shows, and what it does not.
84
+ *
85
+ * ⛔ BOTH SENTENCES, ALWAYS TOGETHER, AND WORDED ONCE. They are printed by the command and
86
+ * repeated in the help text, and if they were written twice one copy would eventually start
87
+ * promising more than the other. There is no "verified", no "trusted" and no "safe" in either
88
+ * of them on purpose: a checksum published by the same release as the file it describes proves
89
+ * the two agree, and nothing whatsoever about who produced them.
90
+ */
91
+ export const CHECK_PROVES = `The check compares what arrived against the release's own ${SUMS_FILE}, which shows these ` +
92
+ `bytes are the bytes that release published.`;
93
+ /** The other half. Never printed without the one above it. */
94
+ export const CHECK_DOES_NOT_PROVE = `It does not show who published that release: anybody able to push a tag to that repository can ` +
95
+ `produce both the file and its line in ${SUMS_FILE}.`;
96
+ /** `SHA256SUMS` for whatever the source repository's newest release is. */
97
+ export function sumsUrl(sourceUrl) {
98
+ return `${sourceUrl.replace(/\/+$/, "")}/releases/latest/download/${SUMS_FILE}`;
99
+ }
100
+ /** One asset of one NAMED release. Never "latest": the tag is resolved once and then reused. */
101
+ export function assetUrl(sourceUrl, tag, asset) {
102
+ return `${sourceUrl.replace(/\/+$/, "")}/releases/download/${encodeURIComponent(tag)}/${asset}`;
103
+ }
104
+ /**
105
+ * Tag names this accepts.
106
+ *
107
+ * ⛔ THE TAG COMES OFF THE WIRE AND GOES BACK INTO A URL, so its shape is checked rather than
108
+ * trusted. A redirect that answered with `../../somewhere` would otherwise decide which path
109
+ * the second request asks for.
110
+ */
111
+ const TAG_SHAPE = /^[A-Za-z0-9._+-]+$/;
112
+ /**
113
+ * The release tag a URL names, or null when it does not name one.
114
+ *
115
+ * Split into segments rather than matched with a regex because the file name can contain the
116
+ * characters a regex cares about (`nmts-recovery-windows-x86_64.exe`), and an escaping mistake
117
+ * there would be a silently looser check.
118
+ */
119
+ export function tagFromUrl(url, filename) {
120
+ let segments;
121
+ try {
122
+ segments = new URL(url).pathname.split("/").filter((part) => part !== "");
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ const last = segments.length - 1;
128
+ if (last < 3)
129
+ return null;
130
+ const name = segments[last];
131
+ const tag = segments[last - 1];
132
+ if (name === undefined || tag === undefined)
133
+ return null;
134
+ if (segments[last - 2] !== "download" || segments[last - 3] !== "releases")
135
+ return null;
136
+ let decodedName;
137
+ let decodedTag;
138
+ try {
139
+ decodedName = decodeURIComponent(name);
140
+ decodedTag = decodeURIComponent(tag);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ if (decodedName !== filename)
146
+ return null;
147
+ if (!TAG_SHAPE.test(decodedTag) || decodedTag.includes(".."))
148
+ return null;
149
+ return decodedTag;
150
+ }
151
+ /**
152
+ * The tag the redirect chain resolved to, or null.
153
+ *
154
+ * ⛔ THE WHOLE CHAIN, NOT THE LAST HOP. `…/releases/latest/download/SHA256SUMS` redirects to the
155
+ * tagged address and that one redirects again to wherever the bytes are actually served from,
156
+ * which carries no tag at all. Reading only where the chain ended would find nothing.
157
+ */
158
+ export function tagFromChain(chain, filename) {
159
+ let found = null;
160
+ for (const url of chain) {
161
+ const tag = tagFromUrl(url, filename);
162
+ if (tag !== null)
163
+ found = tag;
164
+ }
165
+ return found;
166
+ }
167
+ /**
168
+ * The hash `SHA256SUMS` gives for one asset.
169
+ *
170
+ * ⛔ TWO LINES FOR ONE NAME IS A REFUSAL, NOT A FIRST-WINS. A file listed twice with different
171
+ * hashes has no answer, and picking one would mean this command decides which of two claims to
172
+ * act on. It has no basis for that, so it says so instead.
173
+ */
174
+ export function hashFromSums(sums, asset) {
175
+ let found = null;
176
+ let repeated = false;
177
+ let malformed = false;
178
+ for (const raw of sums.split("\n")) {
179
+ // `sha256sum` writes "<hex> <name>", with a `*` before the name when it read in binary mode.
180
+ const match = /^([0-9a-fA-F]+)[ \t]+\*?(.+)$/.exec(raw.trim());
181
+ if (match === null)
182
+ continue;
183
+ const hex = match[1];
184
+ const name = match[2];
185
+ if (hex === undefined || name === undefined)
186
+ continue;
187
+ if (name.trim() !== asset)
188
+ continue;
189
+ if (hex.length !== 64) {
190
+ malformed = true;
191
+ continue;
192
+ }
193
+ if (found !== null) {
194
+ repeated = true;
195
+ continue;
196
+ }
197
+ found = hex.toLowerCase();
198
+ }
199
+ if (repeated)
200
+ return { found: false, why: "repeated" };
201
+ if (found !== null)
202
+ return { found: true, hash: found };
203
+ if (malformed)
204
+ return { found: false, why: "malformed" };
205
+ return { found: false, why: "missing" };
206
+ }
207
+ /**
208
+ * Break one long sentence into lines a terminal will not fold in the middle of a word.
209
+ *
210
+ * ⚠ The two sentences above are written once, as prose, so the help text and the command cannot
211
+ * drift apart. Prose has to be wrapped somewhere, and doing it here keeps the wording out of it.
212
+ */
213
+ export function wrapText(text, width = 88) {
214
+ const lines = [];
215
+ let line = "";
216
+ for (const word of text.split(" ")) {
217
+ if (line === "")
218
+ line = word;
219
+ else if (line.length + 1 + word.length <= width)
220
+ line = `${line} ${word}`;
221
+ else {
222
+ lines.push(line);
223
+ line = word;
224
+ }
225
+ }
226
+ if (line !== "")
227
+ lines.push(line);
228
+ return lines;
229
+ }
@@ -0,0 +1,63 @@
1
+ // Which recovery list this machine wrote last, so the next one gets a higher number.
2
+ //
3
+ // ⛔ WHY IT IS REMEMBERED HERE AND NOT ASKED FOR. The account's recorded sequence is reported by
4
+ // `GET /v1/account`, and that route is closed to an API key outright (`Reach::Never`) — it is
5
+ // the door that also deletes the account. So a tool holding a key cannot read the number it has
6
+ // to beat, and the only honest alternatives are to remember its own or to guess. It remembers.
7
+ //
8
+ // ⛔ WHAT THAT COSTS, SAID PLAINLY. A list written from a browser, or from another machine, moves
9
+ // the server's number without moving this one. The next run here then offers a number the
10
+ // server has already passed, the server refuses it — that is what its `seq` guard is for — and
11
+ // the refusal is reported rather than worked around. The attempt is recorded either way, so a
12
+ // second run offers a higher number and the two converge instead of looping.
13
+ //
14
+ // ⛔ IT IS NOT A SECRET AND IT IS STILL WRITTEN 0600. It holds an account id and a counter. The
15
+ // mode matches every other file this tool keeps beside it: one mode is easier to keep right
16
+ // than two.
17
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { configDir } from "./credentials.js";
20
+ import { NmtsError } from "./errors.js";
21
+ import { isRecord } from "./guards.js";
22
+ /**
23
+ * Where one account's counter lives.
24
+ *
25
+ * ⛔ AN ACCOUNT ID BECOMES PART OF A PATH HERE, so it is CHECKED rather than trusted. Every id this
26
+ * tool has comes from its own derivation and is base64url, but a value that reaches a path join
27
+ * unchecked is how `..` becomes a write somewhere else, and the check costs one line.
28
+ */
29
+ function seqPath(accountId) {
30
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(accountId)) {
31
+ throw new NmtsError("That is not an account id this tool derived.", {
32
+ nextStep: "Nothing was written. This is a fault in the tool rather than in the account.",
33
+ });
34
+ }
35
+ return join(configDir(), `recovery-seq-${accountId}.json`);
36
+ }
37
+ /** The highest sequence this machine has offered for an account, or 0 when it has offered none. */
38
+ export function lastOfferedSeq(accountId) {
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(seqPath(accountId), "utf8"));
41
+ if (!isRecord(parsed))
42
+ return 0;
43
+ const seq = parsed["seq"];
44
+ // A record that cannot be read counts as none, which is where a first run already stands.
45
+ return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : 0;
46
+ }
47
+ catch {
48
+ return 0;
49
+ }
50
+ }
51
+ /**
52
+ * Write down a sequence this machine OFFERED — whether or not the server took it.
53
+ *
54
+ * ⛔ OFFERED, NOT ACCEPTED, AND THAT IS THE POINT. Recording only the accepted ones would make a
55
+ * refused number the number the next run offers again, and every run after it: the same
56
+ * refusal for ever. Recording the attempt is what makes a second run get past it.
57
+ */
58
+ export function rememberOfferedSeq(accountId, seq) {
59
+ if (seq <= lastOfferedSeq(accountId))
60
+ return;
61
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
62
+ writeFileSync(seqPath(accountId), `${JSON.stringify({ seq }, null, 2)}\n`, { mode: 0o600 });
63
+ }