@needmoretruth/nmts-cli 0.35.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/AGENTS.md +11 -12
  2. package/CHANGELOG.md +26 -0
  3. package/README.ko.md +19 -21
  4. package/README.md +21 -22
  5. package/dist/account.js +2 -1
  6. package/dist/arg-options.d.ts +3 -1
  7. package/dist/artifact-about.d.ts +1 -1
  8. package/dist/autonomy.d.ts +3 -10
  9. package/dist/autonomy.js +15 -17
  10. package/dist/bytes.d.ts +15 -0
  11. package/dist/bytes.js +63 -0
  12. package/dist/collision.d.ts +5 -5
  13. package/dist/collision.js +19 -22
  14. package/dist/commands/delete-account.js +1 -1
  15. package/dist/commands/erase.js +1 -1
  16. package/dist/commands/get.js +1 -1
  17. package/dist/commands/listfile.js +1 -1
  18. package/dist/commands/mcp.js +5 -1
  19. package/dist/commands/mode.js +2 -2
  20. package/dist/commands/on-collision.d.ts +1 -1
  21. package/dist/commands/on-collision.js +3 -3
  22. package/dist/commands/platform.d.ts +9 -0
  23. package/dist/commands/platform.js +124 -0
  24. package/dist/commands/pull.js +1 -1
  25. package/dist/commands/push-wallet.js +2 -1
  26. package/dist/commands/push.js +6 -4
  27. package/dist/commands/put-wallet.js +5 -3
  28. package/dist/commands/put.js +8 -5
  29. package/dist/commands/receive.js +1 -1
  30. package/dist/commands/settings.js +1 -1
  31. package/dist/commands/support.js +3 -3
  32. package/dist/commands/unlock.js +2 -2
  33. package/dist/commands/wallet-hall.d.ts +4 -1
  34. package/dist/commands/wallet-hall.js +16 -8
  35. package/dist/commands/wallet-storage-ops.d.ts +4 -1
  36. package/dist/commands/wallet-storage-ops.js +7 -2
  37. package/dist/commands/wallet-swap.d.ts +3 -0
  38. package/dist/commands/wallet-swap.js +8 -4
  39. package/dist/commands/wallet.d.ts +1 -1
  40. package/dist/crypto.d.ts +8 -5
  41. package/dist/crypto.js +15 -61
  42. package/dist/download-sink-node.d.ts +49 -0
  43. package/dist/download-sink-node.js +204 -0
  44. package/dist/download-sink.d.ts +0 -48
  45. package/dist/download-sink.js +6 -199
  46. package/dist/download.js +5 -4
  47. package/dist/engine-node.d.ts +19 -0
  48. package/dist/engine-node.js +77 -0
  49. package/dist/env-vars.d.ts +20 -0
  50. package/dist/env-vars.js +27 -0
  51. package/dist/gate.js +1 -1
  52. package/dist/help.js +9 -4
  53. package/dist/host-contract.d.ts +7 -0
  54. package/dist/host-contract.js +95 -0
  55. package/dist/host-node.d.ts +27 -0
  56. package/dist/host-node.js +192 -0
  57. package/dist/host.d.ts +99 -0
  58. package/dist/host.js +59 -0
  59. package/dist/index.d.ts +6 -49
  60. package/dist/index.js +20 -45
  61. package/dist/main.js +7 -6
  62. package/dist/manifest-chunk-cache.d.ts +3 -3
  63. package/dist/manifest-chunk-cache.js +39 -44
  64. package/dist/manifest-chunk-flow.js +15 -14
  65. package/dist/manifest-write.js +1 -1
  66. package/dist/manifest.d.ts +2 -2
  67. package/dist/manifest.js +37 -34
  68. package/dist/mcp-tools/tiers.js +1 -1
  69. package/dist/network.d.ts +1 -1
  70. package/dist/network.js +6 -2
  71. package/dist/platform-sign.d.ts +143 -0
  72. package/dist/platform-sign.js +220 -0
  73. package/dist/portable.d.ts +59 -0
  74. package/dist/portable.js +82 -0
  75. package/dist/product.d.ts +1 -1
  76. package/dist/product.js +1 -1
  77. package/dist/progress-node.d.ts +2 -0
  78. package/dist/progress-node.js +14 -0
  79. package/dist/progress.d.ts +0 -1
  80. package/dist/progress.js +4 -8
  81. package/dist/redact.js +3 -2
  82. package/dist/registration.js +5 -2
  83. package/dist/risk.d.ts +7 -0
  84. package/dist/risk.js +10 -0
  85. package/dist/run-log.d.ts +4 -4
  86. package/dist/run-log.js +29 -36
  87. package/dist/seal.js +5 -4
  88. package/dist/server.d.ts +1 -1
  89. package/dist/server.js +6 -2
  90. package/dist/setup-questions.js +2 -2
  91. package/dist/upload-file-node.d.ts +3 -0
  92. package/dist/upload-file-node.js +38 -0
  93. package/dist/upload-file.d.ts +2 -2
  94. package/dist/upload-file.js +15 -45
  95. package/dist/upload-price-node.d.ts +8 -0
  96. package/dist/upload-price-node.js +37 -0
  97. package/dist/upload-price.d.ts +0 -8
  98. package/dist/upload-price.js +4 -33
  99. package/dist/upload-steps.js +3 -3
  100. package/dist/upload-store.d.ts +14 -14
  101. package/dist/upload-store.js +68 -75
  102. package/dist/upload-wallet-chain.js +3 -2
  103. package/dist/upload-wallet-put.js +2 -2
  104. package/dist/upload-wallet.js +16 -15
  105. package/dist/upload.js +16 -15
  106. package/dist/wallet-sign-seams.d.ts +12 -0
  107. package/dist/wallet-sign.js +10 -13
  108. package/dist/walrus-write.js +3 -2
  109. package/dist/walrus.d.ts +1 -16
  110. package/dist/walrus.js +11 -22
  111. package/docs/commands/credits.md +1 -1
  112. package/docs/commands/platform.md +14 -0
  113. package/docs/commands/support.md +1 -1
  114. package/docs/commands/trash.md +1 -1
  115. package/docs/commands/wallet.md +11 -7
  116. package/package.json +13 -2
@@ -6,46 +6,45 @@
6
6
  // rewrote one chunk costs one download instead of the whole list.
7
7
  //
8
8
  // ⛔ AND IT IS STILL CHECKED ON THE WAY OUT. The reader re-hashes what it reads from here before
9
- // opening it (`manifest-chunk-flow.ts`). A directory on this machine is not a trusted store:
10
- // whoever holds the machine can edit it, and the index is the only thing that says which bytes
11
- // belong to which version.
9
+ // opening it (`manifest-chunk-flow.ts`). A store on this machine is not a trusted one: whoever
10
+ // holds the machine can edit it, and the index is the only thing that says which bytes belong
11
+ // to which version.
12
12
  //
13
13
  // ⛔ WHAT IS STORED IS SEALED. These are the account's names, folders and file keys, sealed with
14
- // the NMTS key — the same bytes the server holds and cannot read. They are written 0600 in
15
- // a 0700 directory, beside the kept copy of the index and for the same reason.
14
+ // the NMTS key — the same bytes the server holds and cannot read. Where they land, and how
15
+ // private that place is, is the host's business: on this machine it is a 0600 file in a 0700
16
+ // directory, beside the kept copy of the index and for the same reason.
16
17
  //
17
18
  // ⚠ NOTHING HERE THROWS. A cache that cannot be read or written is a slower command, never a
18
19
  // broken one: every function answers "no copy" and the network path behind it does the work.
19
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
- import { join } from "node:path";
21
- import { configDir } from "./credentials.js";
22
- /** A chunk's name: base64url SHA-256, unpadded — 43 characters, and it becomes a file name. */
20
+ import { fromUtf8, utf8 } from "./bytes.js";
21
+ import { host } from "./host.js";
22
+ /** A chunk's name: base64url SHA-256, unpadded — 43 characters, and it becomes part of a key. */
23
23
  const NAME = /^[A-Za-z0-9_-]{43}$/;
24
24
  /**
25
- * ⛔ AN ACCOUNT ID BECOMES PART OF A PATH HERE, so it is CHECKED rather than trusted — the same
25
+ * ⛔ AN ACCOUNT ID BECOMES PART OF A KEY HERE, so it is CHECKED rather than trusted — the same
26
26
  * check `manifest.ts` makes on the kept list, and for the same reason: a value that reaches a
27
- * path join unchecked is how `..` becomes a write somewhere else.
27
+ * key unchecked is how `..` becomes a write somewhere else on a host that spells keys as paths.
28
28
  */
29
29
  const ACCOUNT = /^[A-Za-z0-9_-]{1,64}$/;
30
- /** Where one account's chunks live, or null when the id is not one this tool derived. */
31
- function dirFor(accountId) {
32
- if (!ACCOUNT.test(accountId))
33
- return null;
34
- return join(configDir(), "file-list-chunks", accountId);
30
+ /** The key prefix one account's chunks live under, or null when the id is not one this tool derived. */
31
+ function areaFor(accountId) {
32
+ return ACCOUNT.test(accountId) ? `chunks/${accountId}/` : null;
35
33
  }
36
- function pathFor(accountId, hash) {
37
- const dir = dirFor(accountId);
38
- if (dir === null || !NAME.test(hash))
39
- return null;
40
- return join(dir, `${hash}.ct`);
34
+ function keyFor(accountId, hash) {
35
+ const area = areaFor(accountId);
36
+ return area === null || !NAME.test(hash) ? null : `${area}${hash}`;
41
37
  }
42
38
  /** The sealed bytes this machine holds under that name, or null when it holds none. */
43
- export function readCachedChunk(accountId, hash) {
44
- const path = pathFor(accountId, hash);
45
- if (path === null)
39
+ export async function readCachedChunk(accountId, hash) {
40
+ const key = keyFor(accountId, hash);
41
+ if (key === null)
46
42
  return null;
47
43
  try {
48
- const text = readFileSync(path, "utf8").trim();
44
+ const held = await host().state.read(key);
45
+ if (held === undefined)
46
+ return null;
47
+ const text = fromUtf8(held).trim();
49
48
  return text === "" ? null : text;
50
49
  }
51
50
  catch {
@@ -53,18 +52,16 @@ export function readCachedChunk(accountId, hash) {
53
52
  }
54
53
  }
55
54
  /** Keep these sealed bytes under that name. Silent when the machine will not take them. */
56
- export function writeCachedChunk(accountId, hash, ct) {
57
- const dir = dirFor(accountId);
58
- const path = pathFor(accountId, hash);
59
- if (dir === null || path === null)
55
+ export async function writeCachedChunk(accountId, hash, ct) {
56
+ const key = keyFor(accountId, hash);
57
+ if (key === null)
60
58
  return;
61
59
  try {
62
- mkdirSync(dir, { recursive: true, mode: 0o700 });
63
- writeFileSync(path, `${ct}\n`, { mode: 0o600 });
60
+ await host().state.write(key, utf8(`${ct}\n`));
64
61
  }
65
62
  catch {
66
- // Out of space, read-only home, a directory somebody removed underneath: the next read of the
67
- // list fetches from the server instead, which is what this cache is an optimisation of.
63
+ // Out of space, a read-only home, a browser that would not open its database: the next read of
64
+ // the list fetches from the server instead, which is what this cache is an optimisation of.
68
65
  }
69
66
  }
70
67
  /**
@@ -76,27 +73,25 @@ export function writeCachedChunk(accountId, hash, ct) {
76
73
  * abandoned. Called after every complete read and every successful write, which is exactly when
77
74
  * "what the list names" is known.
78
75
  */
79
- export function pruneChunkCache(accountId, keep) {
80
- const dir = dirFor(accountId);
81
- if (dir === null)
76
+ export async function pruneChunkCache(accountId, keep) {
77
+ const area = areaFor(accountId);
78
+ if (area === null)
82
79
  return;
83
- let names;
80
+ let held;
84
81
  try {
85
- names = readdirSync(dir);
82
+ held = await host().state.keys(area);
86
83
  }
87
84
  catch {
88
85
  return;
89
86
  }
90
- for (const name of names) {
91
- if (!name.endsWith(".ct"))
92
- continue;
93
- if (keep.has(name.slice(0, -3)))
87
+ for (const key of held) {
88
+ if (keep.has(key.slice(area.length)))
94
89
  continue;
95
90
  try {
96
- rmSync(join(dir, name), { force: true });
91
+ await host().state.remove(key);
97
92
  }
98
93
  catch {
99
- // A copy that will not delete costs disk and nothing else; it is named by a hash, so it can
94
+ // A copy that will not delete costs space and nothing else; it is named by a hash, so it can
100
95
  // never be handed back as some other version.
101
96
  }
102
97
  }
@@ -18,12 +18,13 @@
18
18
  // PURE OF COMMANDS: it takes a derived key and a server, and knows nothing about what an edit
19
19
  // means. Deciding WHICH entries the list should hold is `manifest-write.ts`'s job; deciding which
20
20
  // entry goes in which chunk is the shared packer's.
21
+ import { fromBase64Url, toBase64Url } from "./bytes.js";
21
22
  import { request, ServerError } from "./api.js";
22
23
  import { AAD, DERIVED, loadCrypto } from "./crypto.js";
23
24
  import { NmtsError } from "./errors.js";
24
25
  import { pruneChunkCache, readCachedChunk, writeCachedChunk } from "./manifest-chunk-cache.js";
25
26
  import { keepTrying } from "./net-retry.js";
26
- import { registerNodeZstd } from "./zstd-node.js";
27
+ import { host } from "./host.js";
27
28
  import { AAD_FILE_LIST_CHUNK, chunkFingerprint, decodeChunk, decodeFileList, encodeChunk, encodeIndex, FILE_LIST_VERSION_CHUNKED, } from "./shared/lib/drive/manifest-chunks.js";
28
29
  import { packAll, repack } from "./shared/lib/drive/manifest-pack.js";
29
30
  /** How many chunk requests are in flight at once. Enough to fill a link, few enough to stay fair. */
@@ -42,10 +43,10 @@ export class ManifestChunkError extends NmtsError {
42
43
  }
43
44
  }
44
45
  function sealUnder(io, aad, body) {
45
- return Buffer.from(io.crypt.envelope_seal(io.key, utf8.encode(aad), body)).toString("base64url");
46
+ return toBase64Url(io.crypt.envelope_seal(io.key, utf8.encode(aad), body));
46
47
  }
47
48
  function openUnder(io, aad, ct) {
48
- return io.crypt.envelope_open(io.key, utf8.encode(aad), Buffer.from(ct, "base64url"));
49
+ return io.crypt.envelope_open(io.key, utf8.encode(aad), fromBase64Url(ct));
49
50
  }
50
51
  /** What the two chunk routes answer. Narrowed here rather than trusted. */
51
52
  function ctOf(answer) {
@@ -63,7 +64,7 @@ function ctOf(answer) {
63
64
  * is a version nobody will ask for again.
64
65
  */
65
66
  export async function openChunks(io, index) {
66
- registerNodeZstd();
67
+ await host().zstd.register();
67
68
  const refs = index.chunks;
68
69
  const out = new Array(refs.length);
69
70
  let next = 0;
@@ -78,12 +79,12 @@ export async function openChunks(io, index) {
78
79
  }
79
80
  };
80
81
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, refs.length) }, worker));
81
- pruneChunkCache(io.accountId, new Set(refs.map((c) => c.h)));
82
+ await pruneChunkCache(io.accountId, new Set(refs.map((c) => c.h)));
82
83
  return out;
83
84
  }
84
85
  /** One chunk: from this machine if it has it, else from the server — verified either way. */
85
86
  async function openOne(io, ref, indexSeq) {
86
- const cached = readCachedChunk(io.accountId, ref.h);
87
+ const cached = await readCachedChunk(io.accountId, ref.h);
87
88
  let ct = cached;
88
89
  if (ct === null) {
89
90
  try {
@@ -119,7 +120,7 @@ async function openOne(io, ref, indexSeq) {
119
120
  throw new ManifestChunkError(`Part of the file list holds ${doc.items.length} entries where the index says ${ref.n}.`);
120
121
  }
121
122
  if (cached === null)
122
- writeCachedChunk(io.accountId, ref.h, ct);
123
+ await writeCachedChunk(io.accountId, ref.h, ct);
123
124
  return doc.items;
124
125
  }
125
126
  /**
@@ -133,7 +134,7 @@ async function openOne(io, ref, indexSeq) {
133
134
  * the chunks written by the losing attempt are named by no index and the server sweeps them.
134
135
  */
135
136
  export async function writeChunkedList(io, plan) {
136
- registerNodeZstd();
137
+ await host().zstd.register();
137
138
  const packed = plan.previous.length > 0 ? repack(plan.previous, plan.entries) : packAll(plan.entries);
138
139
  const rows = [];
139
140
  const held = [];
@@ -168,7 +169,7 @@ export async function writeChunkedList(io, plan) {
168
169
  body.fill(0);
169
170
  const refs = rows.map((r) => r.h);
170
171
  const seq = await putIndex(io, plan.baseSeq, ct, refs, fresh);
171
- pruneChunkCache(io.accountId, new Set(refs));
172
+ await pruneChunkCache(io.accountId, new Set(refs));
172
173
  return { seq, ct, held };
173
174
  }
174
175
  /**
@@ -197,7 +198,7 @@ async function putIndex(io, baseSeq, ct, refs, fresh) {
197
198
  for (const name of refs) {
198
199
  if (again.has(name))
199
200
  continue;
200
- const kept = readCachedChunk(io.accountId, name);
201
+ const kept = await readCachedChunk(io.accountId, name);
201
202
  if (kept !== null)
202
203
  again.set(name, kept);
203
204
  }
@@ -231,7 +232,7 @@ async function putChunks(io, chunks) {
231
232
  token: io.apiKey,
232
233
  body: { ct },
233
234
  }), { retryable: (error) => error instanceof ServerError && error.status === 429 });
234
- writeCachedChunk(io.accountId, name, ct);
235
+ await writeCachedChunk(io.accountId, name, ct);
235
236
  }
236
237
  };
237
238
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker));
@@ -259,14 +260,14 @@ function seqOf(answer) {
259
260
  * refusing here would refuse in a case the command used to handle.
260
261
  */
261
262
  export async function namedChunks(code, ct) {
262
- registerNodeZstd();
263
+ await host().zstd.register();
263
264
  const crypt = await loadCrypto();
264
265
  const [from, to] = DERIVED.fileListKey;
265
266
  const derived = crypt.kdf_derive(crypt.account_code_parse(code));
266
267
  const key = derived.slice(from, to);
267
268
  derived.fill(0);
268
269
  try {
269
- const body = crypt.envelope_open(key, utf8.encode(AAD.fileList), Buffer.from(ct, "base64url"));
270
+ const body = crypt.envelope_open(key, utf8.encode(AAD.fileList), fromBase64Url(ct));
270
271
  const doc = await decodeFileList(body);
271
272
  body.fill(0);
272
273
  return doc.v === FILE_LIST_VERSION_CHUNKED ? doc.index.chunks.map((c) => c.h) : [];
@@ -292,7 +293,7 @@ export async function keptChunks(code, accountId, indexCt) {
292
293
  return null;
293
294
  const out = [];
294
295
  for (const name of names) {
295
- const ct = readCachedChunk(accountId, name);
296
+ const ct = await readCachedChunk(accountId, name);
296
297
  if (ct === null) {
297
298
  throw new NmtsError("This machine holds the file list's index but not all of its parts.", {
298
299
  exitCode: 4,
@@ -236,7 +236,7 @@ export async function addEntry(input) {
236
236
  let replaced;
237
237
  // ⛔ SETTLED ONCE, OUTSIDE THE RETRY LOOP. What the machine is set to and whether a mode is on
238
238
  // are facts about this run, not about the list version a compare-and-swap happened to read.
239
- const choice = decide(input.onCollision).choice;
239
+ const choice = (await decide(input.onCollision)).choice;
240
240
  const result = await applyManyToList(input, (entries) => {
241
241
  const plan = planAddition(entries, input.entry, choice);
242
242
  alreadyThere = plan.alreadyThere ?? null;
@@ -30,7 +30,7 @@ export interface KeptList {
30
30
  * write out a good copy because an old one is unreadable would be refusing the very thing it is
31
31
  * for.
32
32
  */
33
- export declare function readKeptList(accountId: string): KeptList | null;
33
+ export declare function readKeptList(accountId: string): Promise<KeptList | null>;
34
34
  /**
35
35
  * Record a version this machine WROTE, so the server cannot serve an older one back afterwards.
36
36
  *
@@ -40,7 +40,7 @@ export declare function readKeptList(accountId: string): KeptList | null;
40
40
  */
41
41
  export declare function recordWrittenList(accountId: string, seq: number, ct: string): Promise<void>;
42
42
  /** True when this machine has a record for the account — i.e. a rollback would be visible. */
43
- export declare function hasSeenBefore(accountId: string): boolean;
43
+ export declare function hasSeenBefore(accountId: string): Promise<boolean>;
44
44
  export interface FileList {
45
45
  /** null when the account has no list yet — a new account, not an error. */
46
46
  manifest: Manifest | null;
package/dist/manifest.js CHANGED
@@ -27,12 +27,11 @@
27
27
  // exactly as the single blob was, and the chunks are kept by name in the chunk store
28
28
  // (`manifest-chunk-cache.ts`), which is pruned to what the list just read names. So the copy is
29
29
  // still complete — it is simply in two places, and `nmts listfile` writes them out as one file.
30
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
- import { join } from "node:path";
32
30
  import { request } from "./api.js";
31
+ import { fromBase64Url, fromUtf8, toBase64Url, utf8 } from "./bytes.js";
33
32
  import { AAD, DERIVED, loadCrypto } from "./crypto.js";
34
- import { configDir } from "./credentials.js";
35
33
  import { NmtsError } from "./errors.js";
34
+ import { host } from "./host.js";
36
35
  import { openChunks } from "./manifest-chunk-flow.js";
37
36
  import { decodeFileList, FILE_LIST_VERSION_CHUNKED, } from "./shared/lib/drive/manifest-chunks.js";
38
37
  function asResponse(value) {
@@ -53,26 +52,26 @@ function asResponse(value) {
53
52
  }
54
53
  /** base64url SHA-256 of a sealed blob — the value a later list carries as its `prev`. */
55
54
  async function fingerprint(ct) {
56
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ct));
57
- return Buffer.from(digest).toString("base64url");
58
- }
59
- function statePath() {
60
- return join(configDir(), "file-list-state.json");
55
+ const digest = await crypto.subtle.digest("SHA-256", utf8(ct));
56
+ return toBase64Url(new Uint8Array(digest));
61
57
  }
58
+ /** The one key this machine's record of what it has seen lives under. */
59
+ const STATE_KEY = "manifest/state";
62
60
  /**
63
- * Where this machine keeps one account's sealed file list.
61
+ * The key this machine keeps one account's sealed file list under.
64
62
  *
65
- * ⛔ AN ACCOUNT ID BECOMES PART OF A PATH HERE, so it is CHECKED rather than trusted. Every id this
66
- * tool has comes from its own derivation and is base64url, but a value that reaches a path join
67
- * unchecked is how `..` becomes a write somewhere else, and the check costs one line.
63
+ * ⛔ AN ACCOUNT ID BECOMES PART OF A KEY HERE, so it is CHECKED rather than trusted. Every id this
64
+ * tool has comes from its own derivation and is base64url, but a value that reaches a key
65
+ * unchecked is how `..` becomes a write somewhere else on a host that spells keys as paths, and
66
+ * the check costs one line.
68
67
  */
69
- function keptListPath(accountId) {
68
+ function keptListKey(accountId) {
70
69
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(accountId)) {
71
70
  throw new NmtsError("That is not an account id this tool derived.", {
72
71
  nextStep: "Nothing was written. This is a fault in the tool rather than in the account.",
73
72
  });
74
73
  }
75
- return join(configDir(), `file-list-${accountId}.json`);
74
+ return `manifest/${accountId}`;
76
75
  }
77
76
  /** This machine's copy of one account's sealed file list. */
78
77
  /**
@@ -104,9 +103,12 @@ function isKeptList(value) {
104
103
  * write out a good copy because an old one is unreadable would be refusing the very thing it is
105
104
  * for.
106
105
  */
107
- export function readKeptList(accountId) {
106
+ export async function readKeptList(accountId) {
108
107
  try {
109
- const parsed = JSON.parse(readFileSync(keptListPath(accountId), "utf8"));
108
+ const held = await host().state.read(keptListKey(accountId));
109
+ if (held === undefined)
110
+ return null;
111
+ const parsed = JSON.parse(fromUtf8(held));
110
112
  return isKeptList(parsed) ? parsed : null;
111
113
  }
112
114
  catch {
@@ -121,17 +123,19 @@ export function readKeptList(accountId) {
121
123
  * a run that then read an older list from the server must not overwrite the newest copy with
122
124
  * it. The record refuses a lower version while it exists; this is what holds when it does not.
123
125
  */
124
- function writeKept(accountId, seq, ct) {
125
- const held = readKeptList(accountId);
126
+ async function writeKept(accountId, seq, ct) {
127
+ const held = await readKeptList(accountId);
126
128
  if (held !== null && held.seq > seq)
127
129
  return;
128
130
  const copy = { seq, savedAt: new Date().toISOString(), ct };
129
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
130
- writeFileSync(keptListPath(accountId), `${JSON.stringify(copy, null, 2)}\n`, { mode: 0o600 });
131
+ await host().state.write(keptListKey(accountId), utf8(`${JSON.stringify(copy, null, 2)}\n`));
131
132
  }
132
- function readSeen() {
133
+ async function readSeen() {
133
134
  try {
134
- const parsed = JSON.parse(readFileSync(statePath(), "utf8"));
135
+ const held = await host().state.read(STATE_KEY);
136
+ if (held === undefined)
137
+ return {};
138
+ const parsed = JSON.parse(fromUtf8(held));
135
139
  return typeof parsed === "object" && parsed !== null ? parsed : {};
136
140
  }
137
141
  catch {
@@ -147,14 +151,13 @@ function readSeen() {
147
151
  * would be two chances for a caller to update one and not the other — after which the machine
148
152
  * would hold a copy of one version while claiming to have seen another.
149
153
  */
150
- function writeSeen(accountId, seq, fp, ct) {
151
- const all = readSeen();
154
+ async function writeSeen(accountId, seq, fp, ct) {
155
+ const all = await readSeen();
152
156
  all[accountId] = { seq, fingerprint: fp };
153
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
154
- writeFileSync(statePath(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
157
+ await host().state.write(STATE_KEY, utf8(`${JSON.stringify(all, null, 2)}\n`));
155
158
  // The record goes first: it is the safety device, and a machine that failed to keep a copy must
156
159
  // still refuse an older list afterwards.
157
- writeKept(accountId, seq, ct);
160
+ await writeKept(accountId, seq, ct);
158
161
  }
159
162
  /**
160
163
  * Record a version this machine WROTE, so the server cannot serve an older one back afterwards.
@@ -164,11 +167,11 @@ function writeSeen(accountId, seq, fp, ct) {
164
167
  * a rollback.
165
168
  */
166
169
  export async function recordWrittenList(accountId, seq, ct) {
167
- writeSeen(accountId, seq, await fingerprint(ct), ct);
170
+ await writeSeen(accountId, seq, await fingerprint(ct), ct);
168
171
  }
169
172
  /** True when this machine has a record for the account — i.e. a rollback would be visible. */
170
- export function hasSeenBefore(accountId) {
171
- return existsSync(statePath()) && accountId in readSeen();
173
+ export async function hasSeenBefore(accountId) {
174
+ return accountId in (await readSeen());
172
175
  }
173
176
  /**
174
177
  * Fetch and open the account's file list.
@@ -178,10 +181,10 @@ export function hasSeenBefore(accountId) {
178
181
  */
179
182
  export async function readFileList(base, apiKey, accountCode, accountId) {
180
183
  const answer = asResponse(await request(base, "/v1/manifest", { token: apiKey }));
181
- const first = !hasSeenBefore(accountId);
184
+ const first = !(await hasSeenBefore(accountId));
182
185
  if (answer.state === "absent")
183
186
  return { manifest: null, firstTimeOnThisMachine: first };
184
- const seen = readSeen()[accountId];
187
+ const seen = (await readSeen())[accountId];
185
188
  if (seen !== undefined && answer.seq < seen.seq) {
186
189
  throw new NmtsError(`The server offered file-list version ${answer.seq}; this machine already saw ${seen.seq}.`, {
187
190
  nextStep: "Nothing was changed. A list that goes backwards means an older copy is being served, " +
@@ -214,7 +217,7 @@ export async function readFileList(base, apiKey, accountCode, accountId) {
214
217
  try {
215
218
  let body;
216
219
  try {
217
- body = crypt.envelope_open(key, new TextEncoder().encode(AAD.fileList), Buffer.from(answer.ct, "base64url"));
220
+ body = crypt.envelope_open(key, utf8(AAD.fileList), fromBase64Url(answer.ct));
218
221
  }
219
222
  catch {
220
223
  throw new NmtsError("The file list did not open with this account's key.", {
@@ -252,7 +255,7 @@ export async function readFileList(base, apiKey, accountCode, accountId) {
252
255
  // ⛔ THE VERSION THAT IS KEPT IS THE SEALED ONE, and so are the bytes it came out of. Believing
253
256
  // the server's column here would let it decide which copy this machine keeps. The bytes are
254
257
  // the INDEX at version 2; its chunks are kept beside it, by name, in the chunk store.
255
- writeSeen(accountId, manifest.seq, fp, answer.ct);
258
+ await writeSeen(accountId, manifest.seq, fp, answer.ct);
256
259
  return out;
257
260
  }
258
261
  finally {
@@ -141,7 +141,7 @@ export function withTiers(tools, asker) {
141
141
  ...tool,
142
142
  description: `${tierLine(tier)}${tool.description}`,
143
143
  annotations: annotationsOf(tool.name),
144
- run: async (args) => (await passTool(tool.name, args, currentMode(), asker())) ?? (await tool.run(args)),
144
+ run: async (args) => (await passTool(tool.name, args, await currentMode(), asker())) ?? (await tool.run(args)),
145
145
  };
146
146
  });
147
147
  }
package/dist/network.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export declare const NETWORKS: readonly ["mainnet", "testnet"];
2
2
  export type Network = (typeof NETWORKS)[number];
3
- export declare const NETWORK_ENV_VAR = "NMTS_NETWORK";
3
+ export { NETWORK_ENV_VAR } from "./env-vars.ts";
4
4
  /**
5
5
  * Decide the network for this run.
6
6
  *
package/dist/network.js CHANGED
@@ -9,10 +9,14 @@
9
9
  // ⛔ SO THE RULE HERE IS: the live server implies mainnet, and ANY other server must say which
10
10
  // network it is. There is no fallback. A tool that has to guess where somebody's files are
11
11
  // should stop instead.
12
+ import { NETWORK_ENV_VAR } from "./env-vars.js";
12
13
  import { NmtsError } from "./errors.js";
14
+ import { host } from "./host.js";
13
15
  import { DEFAULT_SERVER } from "./server.js";
14
16
  export const NETWORKS = ["mainnet", "testnet"];
15
- export const NETWORK_ENV_VAR = "NMTS_NETWORK";
17
+ // Re-exported: the name itself lives in a module with no imports, because `nmts --help`
18
+ // prints it (`env-vars.ts`).
19
+ export { NETWORK_ENV_VAR } from "./env-vars.js";
16
20
  function isNetwork(value) {
17
21
  return NETWORKS.includes(value);
18
22
  }
@@ -23,7 +27,7 @@ function isNetwork(value) {
23
27
  * make: the live server is mainnet. Anything else and it refuses.
24
28
  */
25
29
  export function resolveNetwork(server, explicit) {
26
- const stated = explicit ?? process.env[NETWORK_ENV_VAR];
30
+ const stated = explicit ?? host().env(NETWORK_ENV_VAR);
27
31
  if (stated !== undefined && stated.length > 0) {
28
32
  if (!isNetwork(stated)) {
29
33
  throw new NmtsError(`Not a network: ${stated}`, {
@@ -0,0 +1,143 @@
1
+ /** The fixed prefix of a business signature. `1` is the format version, so a later shape is `bs2_`. */
2
+ export declare const BUSINESS_PREFIX = "nmts_bs1_";
3
+ /** …and of a delegation token. */
4
+ export declare const DELEGATION_PREFIX = "nmts_dt1_";
5
+ /** What a business signature covers, before the moment, the nonce, the method, the path and the digest. */
6
+ export declare const BUSINESS_CONTEXT = "nmts/p1/business/v1";
7
+ /** What a business signs over a delegation payload. */
8
+ export declare const DELEGATION_CONTEXT = "nmts/p1/delegation/v1";
9
+ /** What the NEW key signs over the old one when a business replaces its key. */
10
+ export declare const ROTATE_CONTEXT = "nmts/p1/rotate/v1";
11
+ /** Ed25519 key and signature lengths, named because a parser and a refusal both state them. */
12
+ export declare const PUBKEY_LEN = 32;
13
+ export declare const PRIVATE_KEY_LEN = 32;
14
+ export declare const SIGNATURE_LEN = 64;
15
+ /**
16
+ * The random half of a delegation payload, and of a business signature: 16 bytes each, so two
17
+ * credentials made in the same second for the same thing are still different strings.
18
+ */
19
+ export declare const NONCE_LEN = 16;
20
+ /** The longest life a delegation token may be minted with — thirty days. */
21
+ export declare const DELEGATION_MAX_TTL_SECS = 2592000;
22
+ /**
23
+ * What a delegation token may open, by name.
24
+ *
25
+ * ⛔ THE FIRST THREE ARE AN API KEY'S OWN SCOPES, value for value. One vocabulary, so that a
26
+ * business asking for "read and write" asks for the number a person's key already means by it.
27
+ * `register` is the fourth and opens exactly one door: making the account the token names.
28
+ */
29
+ export declare const SCOPE_BITS: {
30
+ readonly files_read: 1;
31
+ readonly files_write: 2;
32
+ readonly storage_spend: 4;
33
+ readonly register: 8;
34
+ };
35
+ /** One of the four names above. */
36
+ export type ScopeName = keyof typeof SCOPE_BITS;
37
+ /** Every bit a token may carry. */
38
+ export declare const SCOPE_ALL: number;
39
+ /** A business's key pair, both halves base64url. The private half never travels. */
40
+ export interface BusinessKeyPair {
41
+ /** 32 bytes, base64url. This is what is registered, and it is not a secret. */
42
+ publicKey: string;
43
+ /** 32 bytes, base64url. ⛔ The only copy: nothing can derive it back from the public half. */
44
+ privateKey: string;
45
+ }
46
+ /**
47
+ * A new key pair, from the runtime's own random source.
48
+ *
49
+ * ⚠ The randomness is the curve library's, which is the platform's `crypto.getRandomValues`. There
50
+ * is no second source here: a key drawn from anything weaker is a business somebody else can
51
+ * speak for.
52
+ */
53
+ export declare function generateBusinessKeys(): BusinessKeyPair;
54
+ /** The public half of a private key, so a caller never has to keep the two in step by hand. */
55
+ export declare function businessPublicKey(privateKey: string): string;
56
+ /**
57
+ * The bytes a business signature covers.
58
+ *
59
+ * `path` is the request path as it is sent, with no query string: no Platform door takes one, and
60
+ * a signature that did not cover a parameter the server then read would be a signature over half
61
+ * the request.
62
+ *
63
+ * ⚠ THE NONCE IS SIGNED AS THE TEXT THAT TRAVELS, base64url, on its own line after the moment —
64
+ * so the sender and the receiver agree about the field without either re-encoding it.
65
+ */
66
+ export declare function businessSigningInput(ts: number, nonce: string, method: string, path: string, body: Uint8Array): Uint8Array;
67
+ /** The bytes a delegation signature covers: the context, then the payload exactly as it travels. */
68
+ export declare function delegationSigningInput(payload: Uint8Array): Uint8Array;
69
+ /** The bytes the new key signs during a rotation: the context, then the key being replaced. */
70
+ export declare function rotationSigningInput(oldPublicKey: string): Uint8Array;
71
+ /** What one signed request is about. */
72
+ export interface BusinessRequest {
73
+ /** The business account's public id, base64url — what the server knows it by. */
74
+ accountId: string;
75
+ privateKey: string;
76
+ method: string;
77
+ /** The path as it will be sent, `/p1/business`. */
78
+ path: string;
79
+ /** The request body exactly as it will be sent. Empty for a GET. */
80
+ body?: Uint8Array | undefined;
81
+ /** Unix seconds. Absent = now. The server accepts a window either side of its own clock. */
82
+ at?: number | undefined;
83
+ /**
84
+ * The 16 random bytes this one request carries, base64url.
85
+ *
86
+ * ⚠ FOR A TEST THAT NEEDS THE SAME CREDENTIAL TWICE, and for nothing else. Left out, it is drawn
87
+ * from the runtime's random source, which is what makes two identical requests two requests.
88
+ */
89
+ nonce?: string | undefined;
90
+ }
91
+ /**
92
+ * The whole `Authorization: Bearer` value for one request to a Platform door.
93
+ *
94
+ * ⛔ ONE SIGNATURE, ONE REQUEST. The server remembers each accepted signature for the length of its
95
+ * clock window and refuses a second presentation of the same one, so a value from here is not a
96
+ * credential to keep — it is made for the request it is about and spent on it.
97
+ *
98
+ * ⛔ AND THAT IS WHY THE NONCE IS HERE. Ed25519 is deterministic and the moment is whole seconds,
99
+ * so two identical requests inside one second would otherwise be the same bytes — and the
100
+ * second of them would be refused as a replay of the first.
101
+ */
102
+ export declare function signBusinessRequest(request: BusinessRequest): string;
103
+ /** What one delegation token says. */
104
+ export interface DelegationRequest {
105
+ /** The business's own account id, base64url. */
106
+ business: string;
107
+ /** The account id of the user this token speaks for, base64url. */
108
+ user: string;
109
+ privateKey: string;
110
+ /** What the token may do. At least one; an empty set opens nothing. */
111
+ scope: readonly ScopeName[];
112
+ /** How long it lasts, in seconds. At most [`DELEGATION_MAX_TTL_SECS`]. */
113
+ ttlSecs: number;
114
+ /** Unix seconds this life is counted from. Absent = now. */
115
+ at?: number | undefined;
116
+ /**
117
+ * The 16 random bytes in the payload, base64url.
118
+ *
119
+ * ⚠ FOR A TEST THAT NEEDS THE SAME TOKEN TWICE, and for nothing else. Left out, it is drawn from
120
+ * the runtime's random source, which is what makes two tokens with identical fields differ.
121
+ */
122
+ nonce?: string | undefined;
123
+ }
124
+ /**
125
+ * Mint one delegation token.
126
+ *
127
+ * ⛔ THE LIFE IS REFUSED HERE AS WELL AS THERE. A token longer than the ceiling is refused by the
128
+ * server as malformed, which reaches the business as an authentication failure on somebody
129
+ * else's request; refusing it where it is made names the mistake to the program that made it.
130
+ */
131
+ export declare function mintDelegation(request: DelegationRequest): string;
132
+ /**
133
+ * The proof the new key makes of itself when a business replaces its key.
134
+ *
135
+ * ⛔ THE OLD KEY IS IN THE MESSAGE, so this proof is about THIS replacement and cannot be lifted
136
+ * onto another. The request carrying it is signed by the old key, so a rotation needs both
137
+ * halves in one hand.
138
+ */
139
+ export declare function rotationProof(oldPublicKey: string, newPrivateKey: string): string;
140
+ /** The bitmask these scope names make. */
141
+ export declare function scopeMask(scopes: readonly ScopeName[]): number;
142
+ /** Does this signature hold over these bytes? Here so a caller can check its own work offline. */
143
+ export declare function signatureHolds(publicKey: string, message: Uint8Array, signature: string): boolean;