@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.
- package/AGENTS.md +11 -12
- package/CHANGELOG.md +26 -0
- package/README.ko.md +19 -21
- package/README.md +21 -22
- package/dist/account.js +2 -1
- package/dist/arg-options.d.ts +3 -1
- package/dist/artifact-about.d.ts +1 -1
- package/dist/autonomy.d.ts +3 -10
- package/dist/autonomy.js +15 -17
- package/dist/bytes.d.ts +15 -0
- package/dist/bytes.js +63 -0
- package/dist/collision.d.ts +5 -5
- package/dist/collision.js +19 -22
- package/dist/commands/delete-account.js +1 -1
- package/dist/commands/erase.js +1 -1
- package/dist/commands/get.js +1 -1
- package/dist/commands/listfile.js +1 -1
- package/dist/commands/mcp.js +5 -1
- package/dist/commands/mode.js +2 -2
- package/dist/commands/on-collision.d.ts +1 -1
- package/dist/commands/on-collision.js +3 -3
- package/dist/commands/platform.d.ts +9 -0
- package/dist/commands/platform.js +124 -0
- package/dist/commands/pull.js +1 -1
- package/dist/commands/push-wallet.js +2 -1
- package/dist/commands/push.js +6 -4
- package/dist/commands/put-wallet.js +5 -3
- package/dist/commands/put.js +8 -5
- package/dist/commands/receive.js +1 -1
- package/dist/commands/settings.js +1 -1
- package/dist/commands/support.js +3 -3
- package/dist/commands/unlock.js +2 -2
- package/dist/commands/wallet-hall.d.ts +4 -1
- package/dist/commands/wallet-hall.js +16 -8
- package/dist/commands/wallet-storage-ops.d.ts +4 -1
- package/dist/commands/wallet-storage-ops.js +7 -2
- package/dist/commands/wallet-swap.d.ts +3 -0
- package/dist/commands/wallet-swap.js +8 -4
- package/dist/commands/wallet.d.ts +1 -1
- package/dist/crypto.d.ts +8 -5
- package/dist/crypto.js +15 -61
- package/dist/download-sink-node.d.ts +49 -0
- package/dist/download-sink-node.js +204 -0
- package/dist/download-sink.d.ts +0 -48
- package/dist/download-sink.js +6 -199
- package/dist/download.js +5 -4
- package/dist/engine-node.d.ts +19 -0
- package/dist/engine-node.js +77 -0
- package/dist/env-vars.d.ts +20 -0
- package/dist/env-vars.js +27 -0
- package/dist/gate.js +1 -1
- package/dist/help.js +9 -4
- package/dist/host-contract.d.ts +7 -0
- package/dist/host-contract.js +95 -0
- package/dist/host-node.d.ts +27 -0
- package/dist/host-node.js +192 -0
- package/dist/host.d.ts +99 -0
- package/dist/host.js +59 -0
- package/dist/index.d.ts +6 -49
- package/dist/index.js +20 -45
- package/dist/main.js +7 -6
- package/dist/manifest-chunk-cache.d.ts +3 -3
- package/dist/manifest-chunk-cache.js +39 -44
- package/dist/manifest-chunk-flow.js +15 -14
- package/dist/manifest-write.js +1 -1
- package/dist/manifest.d.ts +2 -2
- package/dist/manifest.js +37 -34
- package/dist/mcp-tools/tiers.js +1 -1
- package/dist/network.d.ts +1 -1
- package/dist/network.js +6 -2
- package/dist/platform-sign.d.ts +143 -0
- package/dist/platform-sign.js +220 -0
- package/dist/portable.d.ts +59 -0
- package/dist/portable.js +82 -0
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/progress-node.d.ts +2 -0
- package/dist/progress-node.js +14 -0
- package/dist/progress.d.ts +0 -1
- package/dist/progress.js +4 -8
- package/dist/redact.js +3 -2
- package/dist/registration.js +5 -2
- package/dist/risk.d.ts +7 -0
- package/dist/risk.js +10 -0
- package/dist/run-log.d.ts +4 -4
- package/dist/run-log.js +29 -36
- package/dist/seal.js +5 -4
- package/dist/server.d.ts +1 -1
- package/dist/server.js +6 -2
- package/dist/setup-questions.js +2 -2
- package/dist/upload-file-node.d.ts +3 -0
- package/dist/upload-file-node.js +38 -0
- package/dist/upload-file.d.ts +2 -2
- package/dist/upload-file.js +15 -45
- package/dist/upload-price-node.d.ts +8 -0
- package/dist/upload-price-node.js +37 -0
- package/dist/upload-price.d.ts +0 -8
- package/dist/upload-price.js +4 -33
- package/dist/upload-steps.js +3 -3
- package/dist/upload-store.d.ts +14 -14
- package/dist/upload-store.js +68 -75
- package/dist/upload-wallet-chain.js +3 -2
- package/dist/upload-wallet-put.js +2 -2
- package/dist/upload-wallet.js +16 -15
- package/dist/upload.js +16 -15
- package/dist/wallet-sign-seams.d.ts +12 -0
- package/dist/wallet-sign.js +10 -13
- package/dist/walrus-write.js +3 -2
- package/dist/walrus.d.ts +1 -16
- package/dist/walrus.js +11 -22
- package/docs/commands/credits.md +1 -1
- package/docs/commands/platform.md +14 -0
- package/docs/commands/support.md +1 -1
- package/docs/commands/trash.md +1 -1
- package/docs/commands/wallet.md +11 -7
- 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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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.
|
|
15
|
-
//
|
|
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 {
|
|
20
|
-
import {
|
|
21
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
31
|
-
function
|
|
32
|
-
|
|
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
|
|
37
|
-
const
|
|
38
|
-
|
|
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
|
|
45
|
-
if (
|
|
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
|
|
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
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
81
|
-
if (
|
|
76
|
+
export async function pruneChunkCache(accountId, keep) {
|
|
77
|
+
const area = areaFor(accountId);
|
|
78
|
+
if (area === null)
|
|
82
79
|
return;
|
|
83
|
-
let
|
|
80
|
+
let held;
|
|
84
81
|
try {
|
|
85
|
-
|
|
82
|
+
held = await host().state.keys(area);
|
|
86
83
|
}
|
|
87
84
|
catch {
|
|
88
85
|
return;
|
|
89
86
|
}
|
|
90
|
-
for (const
|
|
91
|
-
if (
|
|
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
|
-
|
|
91
|
+
await host().state.remove(key);
|
|
97
92
|
}
|
|
98
93
|
catch {
|
|
99
|
-
// A copy that will not delete costs
|
|
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 {
|
|
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
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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,
|
package/dist/manifest-write.js
CHANGED
|
@@ -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;
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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",
|
|
57
|
-
return
|
|
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
|
-
*
|
|
61
|
+
* The key this machine keeps one account's sealed file list under.
|
|
64
62
|
*
|
|
65
|
-
* ⛔ AN ACCOUNT ID BECOMES PART OF A
|
|
66
|
-
* tool has comes from its own derivation and is base64url, but a value that reaches a
|
|
67
|
-
* unchecked is how `..` becomes a write somewhere else
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 {
|
package/dist/mcp-tools/tiers.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ??
|
|
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;
|