@needmoretruth/nmts-cli 0.38.1 → 0.40.0
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 +14 -35
- package/CHANGELOG.md +16 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/dist/artifact-about.d.ts +1 -1
- package/dist/commands/erase.js +12 -107
- package/dist/commands/extend.d.ts +3 -3
- package/dist/commands/extend.js +52 -161
- package/dist/commands/wallet-storage-ops.d.ts +2 -2
- package/dist/commands/wallet-storage-ops.js +108 -134
- package/dist/commands/wallet-storage.d.ts +3 -8
- package/dist/commands/wallet-storage.js +16 -37
- package/dist/drive-erase.d.ts +83 -0
- package/dist/drive-erase.js +161 -0
- package/dist/platform-sign.d.ts +9 -1
- package/dist/platform-sign.js +9 -1
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/storage-control/extend.d.ts +124 -0
- package/dist/storage-control/extend.js +189 -0
- package/dist/storage-control/hints.d.ts +20 -0
- package/dist/storage-control/hints.js +11 -0
- package/dist/storage-control/list.d.ts +55 -0
- package/dist/storage-control/list.js +71 -0
- package/dist/storage-control/reshape.d.ts +120 -0
- package/dist/storage-control/reshape.js +188 -0
- package/dist/storage-control-chain.d.ts +1 -1
- package/dist/storage-control.d.ts +13 -0
- package/dist/storage-control.js +24 -0
- package/dist/wallet-storage-chain.d.ts +1 -1
- package/package.json +9 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Erasing files for good, without a terminal: the targets resolved, the treasury's storage let go,
|
|
2
|
+
// the server's records destroyed and the entries taken out of the sealed list — decided, done and
|
|
3
|
+
// handed back rather than printed.
|
|
4
|
+
//
|
|
5
|
+
// ⛔ ONE IMPLEMENTATION, TWO CALLERS, WHICH IS THE WHOLE REASON THIS FILE EXISTS — the argument
|
|
6
|
+
// `drive-edit.ts` makes one verb family over. `commands/erase.ts` is a terminal's shape: it
|
|
7
|
+
// prints the list, asks for the typed sentence and answers an exit code. The SDK is somebody
|
|
8
|
+
// else's program and needs the same act with none of those. A second implementation of "what
|
|
9
|
+
// order do these three destructions happen in" would be a second place for the rules below to
|
|
10
|
+
// be got right, and the copy nobody re-reads is the one that quietly disagrees — about the one
|
|
11
|
+
// verb in this package that nothing can undo.
|
|
12
|
+
//
|
|
13
|
+
// ⛔ NOTHING HERE WRITES TO A STREAM, PICKS AN EXIT CODE OR ASKS ANYBODY ANYTHING. The confirmation
|
|
14
|
+
// is the caller's: a terminal types a sentence, a program passes one. This file is reached only
|
|
15
|
+
// after somebody decided, and it destroys what it is given.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ THE SERVER GOES FIRST AND THE LIST LAST, which is the order the trash keeps and for the same
|
|
18
|
+
// reason: a row erased before its list entry leaves a file the person can see and never open.
|
|
19
|
+
// A release that FAILS stops the run before a single row is touched — nothing is erased behind
|
|
20
|
+
// one. A release the server REFUSES ("that storage was bought by the wallet, not by credits")
|
|
21
|
+
// is an answer rather than a failure: it is reported on that file and the erase goes on.
|
|
22
|
+
//
|
|
23
|
+
// ⛔ THE PROOF IS THE CALLER'S TO BUILD AND IS NOT DERIVED HERE. Both doors ask for the account
|
|
24
|
+
// code's own proof, and WHETHER this run may make one is a policy question with two different
|
|
25
|
+
// answers — the command line asks for an agreement first (`account-proof.ts`), a library caller
|
|
26
|
+
// has already made that decision by calling. So it arrives as a value. Nothing here reaches for
|
|
27
|
+
// `node:`: the SDK's browser entry bundles what this exports.
|
|
28
|
+
//
|
|
29
|
+
// ⚠ THE PIECES IT BORROWS COME FROM `drive-edit/` DIRECTLY, the way that folder's own files borrow
|
|
30
|
+
// from each other. This is a sibling of that family rather than an outside caller.
|
|
31
|
+
import { request, ServerError } from "./api.js";
|
|
32
|
+
import { DriveEditError, resolving } from "./drive-edit/errors.js";
|
|
33
|
+
import { filesUnder, uniqueById } from "./drive-edit/tree.js";
|
|
34
|
+
import { buildIndex, fullPathOf, KIND_FILE } from "./drive-paths.js";
|
|
35
|
+
import { NmtsError } from "./errors.js";
|
|
36
|
+
import { readFileList } from "./manifest.js";
|
|
37
|
+
import { applyToList, batchTargets } from "./manifest-write.js";
|
|
38
|
+
import { BINARY_NAME } from "./product.js";
|
|
39
|
+
/** The server takes at most this many ids in one erase (`ERASE_BATCH_MAX`). */
|
|
40
|
+
const BATCH = 200;
|
|
41
|
+
/** The refusal a no-deposit release gets when the balance cannot cover the doubled fee. */
|
|
42
|
+
const FEE_INSUFFICIENT = "DEPOSIT_FEE_INSUFFICIENT";
|
|
43
|
+
/**
|
|
44
|
+
* Work out what erasing these paths would destroy, without destroying anything.
|
|
45
|
+
*
|
|
46
|
+
* ⛔ ITS OWN STEP BECAUSE ONE CALLER HAS TO SHOW THE LIST BEFORE IT ASKS. `nmts erase` prints every
|
|
47
|
+
* file it is about to destroy and then waits for a typed sentence; folding this into the act
|
|
48
|
+
* would leave the terminal with nothing to print, and reading the list twice would leave the
|
|
49
|
+
* two reads free to disagree about what is in it.
|
|
50
|
+
*/
|
|
51
|
+
export async function planErase(input, paths) {
|
|
52
|
+
const list = await readFileList(input.server, input.apiKey, input.code, input.accountId);
|
|
53
|
+
const entries = list.manifest?.entries ?? [];
|
|
54
|
+
const index = buildIndex(entries);
|
|
55
|
+
const targets = resolving(() => batchTargets(entries, paths, { includeTrashed: true, nothingHappened: "Nothing was erased." }));
|
|
56
|
+
const files = uniqueById(targets.flatMap((t) => (t.kind === KIND_FILE ? [t] : filesUnder(entries, t.id))));
|
|
57
|
+
const going = uniqueById([...targets, ...files]);
|
|
58
|
+
if (files.length === 0) {
|
|
59
|
+
throw new DriveEditError("NOT_FOUND", `Nothing named holds a file; empty folders are removed with \`${BINARY_NAME} rm\`.`, {
|
|
60
|
+
exitCode: 4,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
files: files.map((f) => ({ id: f.id, path: fullPathOf(index, f) })),
|
|
65
|
+
going: going.map((e) => e.id),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Destroy what the plan names. ⛔ Irreversible, and nothing below asks whether the caller meant it.
|
|
70
|
+
*/
|
|
71
|
+
export async function eraseFiles(input, plan, options = {}) {
|
|
72
|
+
const releases = [];
|
|
73
|
+
if (options.releaseStorage === true) {
|
|
74
|
+
for (const f of plan.files) {
|
|
75
|
+
const path = f.path;
|
|
76
|
+
try {
|
|
77
|
+
const reply = await request(input.server, `/v1/items/${encodeURIComponent(f.id)}/release-storage`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
body: {},
|
|
80
|
+
token: input.apiKey,
|
|
81
|
+
accountProof: input.accountProof,
|
|
82
|
+
});
|
|
83
|
+
releases.push({ path, refused: null, ...counts(reply), ...fee(reply) });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// ⛔ THE ONE REFUSAL THAT STOPS THE RUN. A file with no deposit pays twice the chain fee
|
|
87
|
+
// out of the balance, and a balance that cannot cover it means the release did not
|
|
88
|
+
// happen — erasing behind it would destroy the account's key to bytes that are still
|
|
89
|
+
// being served and still being paid for. Nothing is erased, and the two numbers say
|
|
90
|
+
// exactly how far short the balance is.
|
|
91
|
+
if (error instanceof ServerError && error.code === FEE_INSUFFICIENT) {
|
|
92
|
+
throw new NmtsError(`${path}: releasing its storage costs ${amount(error, "needed_credits")} credits and this ` +
|
|
93
|
+
`account has ${amount(error, "balance_credits")}. It has no deposit, so the fee comes out of the balance.`, {
|
|
94
|
+
exitCode: 4,
|
|
95
|
+
nextStep: `Nothing was erased. Buy credits and run this again, or leave --release-storage off ` +
|
|
96
|
+
`to erase the file and let its storage run out on its own.`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// ⚠ "Not ours to destroy" is an answer, not a failure: the storage was bought by the
|
|
100
|
+
// wallet, and the erase goes on. A refusal of the KEY or the proof, and anything the
|
|
101
|
+
// server could not do, stops the run before a row is touched.
|
|
102
|
+
if (error instanceof ServerError && error.status !== 401 && error.status !== 403 && error.status < 500) {
|
|
103
|
+
releases.push({ path, released: 0, alreadyReleased: 0, failed: 0, feeCredits: 0, fromDeposit: false, refused: error.message });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let erased = 0;
|
|
111
|
+
for (let i = 0; i < plan.files.length; i += BATCH) {
|
|
112
|
+
const reply = await request(input.server, "/v1/items/erase", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
body: { item_ids: plan.files.slice(i, i + BATCH).map((f) => f.id) },
|
|
115
|
+
token: input.apiKey,
|
|
116
|
+
accountProof: input.accountProof,
|
|
117
|
+
});
|
|
118
|
+
erased += typeof reply === "object" && reply !== null && typeof Reflect.get(reply, "erased") === "number"
|
|
119
|
+
? Number(Reflect.get(reply, "erased"))
|
|
120
|
+
: 0;
|
|
121
|
+
}
|
|
122
|
+
// ⛔ THE LIST GOES LAST, and re-decided against the list as it is on this attempt: only the
|
|
123
|
+
// ids this run erased leave it, whatever another device wrote in between.
|
|
124
|
+
const ids = new Set(plan.going);
|
|
125
|
+
const result = await applyToList(input, (current) => {
|
|
126
|
+
const still = current.filter((e) => ids.has(e.id)).map((e) => e.id);
|
|
127
|
+
return still.length === 0 ? null : { op: "purge", ids: still };
|
|
128
|
+
});
|
|
129
|
+
return { erased, files: [...plan.files], releases, seq: result.seq };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Resolve these paths and destroy what they name, in one call.
|
|
133
|
+
*
|
|
134
|
+
* ⚠ FOR A CALLER THAT HAS ALREADY DECIDED. Anything that shows a person what is about to go should
|
|
135
|
+
* use `planErase` first, so what it shows is what it then destroys.
|
|
136
|
+
*/
|
|
137
|
+
export async function erasePaths(input, paths, options = {}) {
|
|
138
|
+
return eraseFiles(input, await planErase(input, paths), options);
|
|
139
|
+
}
|
|
140
|
+
/** The three counts a release answers with, read defensively. */
|
|
141
|
+
function counts(reply) {
|
|
142
|
+
const n = (name) => {
|
|
143
|
+
const v = typeof reply === "object" && reply !== null ? Reflect.get(reply, name) : undefined;
|
|
144
|
+
return typeof v === "number" ? v : 0;
|
|
145
|
+
};
|
|
146
|
+
return { released: n("released"), alreadyReleased: n("already_released"), failed: n("failed") };
|
|
147
|
+
}
|
|
148
|
+
/** What it cost, read the same defensive way. An older server says neither, which reads as 0. */
|
|
149
|
+
function fee(reply) {
|
|
150
|
+
const at = (name) => typeof reply === "object" && reply !== null ? Reflect.get(reply, name) : undefined;
|
|
151
|
+
const charged = at("fee_credits");
|
|
152
|
+
return {
|
|
153
|
+
feeCredits: typeof charged === "number" ? charged : 0,
|
|
154
|
+
fromDeposit: at("from_deposit") === true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** One credit amount out of a refusal's details, or `?` when the server did not name it. */
|
|
158
|
+
function amount(error, field) {
|
|
159
|
+
const value = error.details[field];
|
|
160
|
+
return typeof value === "number" ? String(value) : "?";
|
|
161
|
+
}
|
package/dist/platform-sign.d.ts
CHANGED
|
@@ -25,14 +25,22 @@ export declare const DELEGATION_MAX_TTL_SECS = 2592000;
|
|
|
25
25
|
* ⛔ THE FIRST THREE ARE AN API KEY'S OWN SCOPES, value for value. One vocabulary, so that a
|
|
26
26
|
* business asking for "read and write" asks for the number a person's key already means by it.
|
|
27
27
|
* `register` is the fourth and opens exactly one door: making the account the token names.
|
|
28
|
+
*
|
|
29
|
+
* ⛔ `files_erase` IS THE FIFTH, AND AN API KEY HAS NO BIT OF THAT NAME. It opens the two acts
|
|
30
|
+
* nothing undoes: erasing a file's record and this account's key to it, and destroying the
|
|
31
|
+
* treasury's storage under a file. Its own name rather than `files_write`, so a token minted so
|
|
32
|
+
* an app can upload cannot destroy — and it does not stand alone: both doors also ask for the
|
|
33
|
+
* account code's own proof on the same request, which is an act only whoever holds the code can
|
|
34
|
+
* perform. The token is the business's permission; the proof is the key holder's.
|
|
28
35
|
*/
|
|
29
36
|
export declare const SCOPE_BITS: {
|
|
30
37
|
readonly files_read: 1;
|
|
31
38
|
readonly files_write: 2;
|
|
32
39
|
readonly storage_spend: 4;
|
|
33
40
|
readonly register: 8;
|
|
41
|
+
readonly files_erase: 16;
|
|
34
42
|
};
|
|
35
|
-
/** One of the
|
|
43
|
+
/** One of the five names above. */
|
|
36
44
|
export type ScopeName = keyof typeof SCOPE_BITS;
|
|
37
45
|
/** Every bit a token may carry. */
|
|
38
46
|
export declare const SCOPE_ALL: number;
|
package/dist/platform-sign.js
CHANGED
|
@@ -53,15 +53,23 @@ export const DELEGATION_MAX_TTL_SECS = 2_592_000;
|
|
|
53
53
|
* ⛔ THE FIRST THREE ARE AN API KEY'S OWN SCOPES, value for value. One vocabulary, so that a
|
|
54
54
|
* business asking for "read and write" asks for the number a person's key already means by it.
|
|
55
55
|
* `register` is the fourth and opens exactly one door: making the account the token names.
|
|
56
|
+
*
|
|
57
|
+
* ⛔ `files_erase` IS THE FIFTH, AND AN API KEY HAS NO BIT OF THAT NAME. It opens the two acts
|
|
58
|
+
* nothing undoes: erasing a file's record and this account's key to it, and destroying the
|
|
59
|
+
* treasury's storage under a file. Its own name rather than `files_write`, so a token minted so
|
|
60
|
+
* an app can upload cannot destroy — and it does not stand alone: both doors also ask for the
|
|
61
|
+
* account code's own proof on the same request, which is an act only whoever holds the code can
|
|
62
|
+
* perform. The token is the business's permission; the proof is the key holder's.
|
|
56
63
|
*/
|
|
57
64
|
export const SCOPE_BITS = {
|
|
58
65
|
files_read: 1,
|
|
59
66
|
files_write: 2,
|
|
60
67
|
storage_spend: 4,
|
|
61
68
|
register: 8,
|
|
69
|
+
files_erase: 16,
|
|
62
70
|
};
|
|
63
71
|
/** Every bit a token may carry. */
|
|
64
|
-
export const SCOPE_ALL = SCOPE_BITS.files_read | SCOPE_BITS.files_write | SCOPE_BITS.storage_spend | SCOPE_BITS.register;
|
|
72
|
+
export const SCOPE_ALL = SCOPE_BITS.files_read | SCOPE_BITS.files_write | SCOPE_BITS.storage_spend | SCOPE_BITS.register | SCOPE_BITS.files_erase;
|
|
65
73
|
/**
|
|
66
74
|
* A new key pair, from the runtime's own random source.
|
|
67
75
|
*
|
package/dist/product.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare const BINARY_NAME = "nmts";
|
|
|
9
9
|
* beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
|
|
10
10
|
* command importing the entry point is a cycle waiting to bite.
|
|
11
11
|
*/
|
|
12
|
-
export declare const VERSION = "0.
|
|
12
|
+
export declare const VERSION = "0.40.0";
|
|
13
13
|
/** Where the product lives, for messages that need to send somebody somewhere real. */
|
|
14
14
|
export declare const HOME_URL = "https://nmts.me";
|
|
15
15
|
/** The source, so a person holding only the built program can find what it was built from. */
|
package/dist/product.js
CHANGED
|
@@ -18,7 +18,7 @@ export const BINARY_NAME = "nmts";
|
|
|
18
18
|
* beside it. Here rather than in `main.ts` because the MCP server has to say it too, and a
|
|
19
19
|
* command importing the entry point is a cycle waiting to bite.
|
|
20
20
|
*/
|
|
21
|
-
export const VERSION = "0.
|
|
21
|
+
export const VERSION = "0.40.0";
|
|
22
22
|
/** Where the product lives, for messages that need to send somebody somewhere real. */
|
|
23
23
|
export const HOME_URL = "https://nmts.me";
|
|
24
24
|
/** The source, so a person holding only the built program can find what it was built from. */
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type Budget } from "../extend-budget.ts";
|
|
2
|
+
import { type DaysLeft, type ExpiryStage } from "../expiry.ts";
|
|
3
|
+
import { type ExtendPreview, type ExtendReads, type SignExtension } from "../extend-plan.ts";
|
|
4
|
+
import type { StorageHints } from "./hints.ts";
|
|
5
|
+
import { type AccountSettings } from "../shared/lib/drive/manifest-settings.ts";
|
|
6
|
+
/** Where to talk, what opens the list, and whose list it is. */
|
|
7
|
+
export interface ExtendInput {
|
|
8
|
+
server: string;
|
|
9
|
+
apiKey: string;
|
|
10
|
+
/** ⛔ The NMTS key. It opens the list and derives the wallet that pays; it goes nowhere else. */
|
|
11
|
+
code: string;
|
|
12
|
+
accountId: string;
|
|
13
|
+
network: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Everything one run worked out, in one shape.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ ONE OBJECT SO TWO ANSWERS CANNOT DISAGREE. The words a person reads and the JSON a program
|
|
19
|
+
* reads are built from this and from nothing else; when they were assembled separately the day
|
|
20
|
+
* count in one of them was the count before the extension and in the other the count after.
|
|
21
|
+
*/
|
|
22
|
+
export interface ExtendFacts {
|
|
23
|
+
file: string;
|
|
24
|
+
itemId: string;
|
|
25
|
+
network: string;
|
|
26
|
+
epoch: number;
|
|
27
|
+
endEpoch: number;
|
|
28
|
+
epochs: number;
|
|
29
|
+
newEndEpoch: number;
|
|
30
|
+
daysLeft: DaysLeft;
|
|
31
|
+
daysLeftAfter: DaysLeft;
|
|
32
|
+
blobs: number;
|
|
33
|
+
/** ⚠ A STRING: base units run past what a JSON number keeps without losing digits. */
|
|
34
|
+
priceFrost: string;
|
|
35
|
+
priceWal: string;
|
|
36
|
+
/** ⛔ Said in the machine-readable answer too. A program spending WAL should not have to infer it. */
|
|
37
|
+
paidFrom: "wallet";
|
|
38
|
+
filesOnTheSameBlobs: number;
|
|
39
|
+
partsThatCannotBeExtended: number;
|
|
40
|
+
/** The address that would sign. */
|
|
41
|
+
wallet: string;
|
|
42
|
+
/** What it holds, as amounts — null when the chain could not say. ⛔ Never zero for unread. */
|
|
43
|
+
walletWal: string | null;
|
|
44
|
+
walletSui: string | null;
|
|
45
|
+
/** The chain fee a dry run measured — null when it could not be measured. */
|
|
46
|
+
feeMist: string | null;
|
|
47
|
+
feeSui: string | null;
|
|
48
|
+
}
|
|
49
|
+
/** What one extension would buy, and everything the decision to buy it rests on. */
|
|
50
|
+
export interface ExtendPlan {
|
|
51
|
+
facts: ExtendFacts;
|
|
52
|
+
itemId: string;
|
|
53
|
+
/** The file's full path in the account, as the list spells it. */
|
|
54
|
+
path: string;
|
|
55
|
+
/** The blobs the transaction would name. */
|
|
56
|
+
objectIds: readonly string[];
|
|
57
|
+
epochs: number;
|
|
58
|
+
/**
|
|
59
|
+
* ⚠ THE WALLET'S NUMBER. `facts.wallet` is that wallet's address — the one the price was measured
|
|
60
|
+
* against, and the one that signs.
|
|
61
|
+
*/
|
|
62
|
+
wallet: number;
|
|
63
|
+
/**
|
|
64
|
+
* How close this file is to running out.
|
|
65
|
+
*
|
|
66
|
+
* ⛔ `later` IS NOT A REFUSAL HERE. Extending early loses nothing — the epochs are added to what
|
|
67
|
+
* is left — so whether to spend now for time a file does not need yet is the caller's to
|
|
68
|
+
* decide, and each surface names its own way of saying yes.
|
|
69
|
+
*/
|
|
70
|
+
stage: ExpiryStage;
|
|
71
|
+
budget: Budget;
|
|
72
|
+
/** The sealed list's settings, as read for this price. */
|
|
73
|
+
settings: AccountSettings | undefined;
|
|
74
|
+
}
|
|
75
|
+
/** The chain reads. ⚠ A SEAM, NOT AN OPTION — no flag and no caller argument reaches it. */
|
|
76
|
+
export interface ExtendPlanSeams {
|
|
77
|
+
readChain?: ((network: string) => Promise<ExtendReads> | ExtendReads) | undefined;
|
|
78
|
+
/** How many of the storage network's epochs to add. Default `DEFAULT_EXTEND_EPOCHS`. */
|
|
79
|
+
epochs?: string | number | undefined;
|
|
80
|
+
/** Which of this key's wallets pays. Absent = the account's own number, out of the list read here. */
|
|
81
|
+
wallet?: number | undefined;
|
|
82
|
+
/** The instant to measure against. Passed in so one run reports one moment. */
|
|
83
|
+
now: number;
|
|
84
|
+
/** What this caller wants said in a refusal instead of the neutral sentence. */
|
|
85
|
+
hints?: StorageHints | undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Work out what extending this file would buy and what it would cost, without signing anything.
|
|
89
|
+
*
|
|
90
|
+
* ⛔ NO KEY IS DERIVED FOR SIGNING AND NO SIGNER IS LOADED. Everything here is a read, which is
|
|
91
|
+
* what lets a price be asked for without an agreement being asked for first.
|
|
92
|
+
*/
|
|
93
|
+
export declare function planExtension(input: ExtendInput, target: string, seams: ExtendPlanSeams): Promise<ExtendPlan>;
|
|
94
|
+
/** What one extension did. ⛔ By the time this exists, the storage IS extended and paid for. */
|
|
95
|
+
export interface ExtendOutcome {
|
|
96
|
+
/** The transaction digest — what the server records as the replay guard. */
|
|
97
|
+
digest: string;
|
|
98
|
+
/** Whether the server wrote the new date down. */
|
|
99
|
+
recorded: boolean;
|
|
100
|
+
/** True when the server had already recorded this digest, so nothing was written twice. */
|
|
101
|
+
replay: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Why the date was not written down, in the words the failure gave. Null when it was.
|
|
104
|
+
*
|
|
105
|
+
* ⛔ IT IS NOT AN EXCEPTION, and that is the whole point: the money is already spent, so a caller
|
|
106
|
+
* that saw a throw here would reasonably try again — and trying again pays again.
|
|
107
|
+
*/
|
|
108
|
+
notRecorded: string | null;
|
|
109
|
+
}
|
|
110
|
+
/** The signature, and the one moment a caller may want between it and the server. ⚠ Seams. */
|
|
111
|
+
export interface ExtendApplySeams {
|
|
112
|
+
sign?: SignExtension | undefined;
|
|
113
|
+
/** Told the instant the signature exists — before the server is asked to write the date down. */
|
|
114
|
+
onSigned?: ((digest: string) => void) | undefined;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Buy it. ⛔ THIS SIGNS, and nothing below this line can be undone by anybody, NMTS included.
|
|
118
|
+
*
|
|
119
|
+
* Nothing here asks whether the caller meant it: the price, both balances and any shortfall are in
|
|
120
|
+
* the plan, and calling this is the answer.
|
|
121
|
+
*/
|
|
122
|
+
export declare function applyExtension(input: ExtendInput, plan: ExtendPlan, seams?: ExtendApplySeams): Promise<ExtendOutcome>;
|
|
123
|
+
/** Why a file has nothing to extend, said as the two different things it can be. */
|
|
124
|
+
export declare function nothingToExtend(preview: ExtendPreview): string;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Buying more storage time for one file that is already stored — worked out, signed and handed
|
|
2
|
+
// back rather than printed.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ ONE IMPLEMENTATION, TWO CALLERS. `nmts extend` is a terminal over this: the sentences a person
|
|
5
|
+
// reads, the agreement this machine keeps, the exit code. The SDK is somebody else's program and
|
|
6
|
+
// needs the same act with none of those. What must not be written twice is the ORDER below,
|
|
7
|
+
// because every step of it is about money that does not come back.
|
|
8
|
+
//
|
|
9
|
+
// ⛔ IT PRICES BEFORE IT SPENDS, ALWAYS. `planExtension` reads, judges and quotes; it holds no key
|
|
10
|
+
// and signs nothing, so a caller that only wants a price never comes near `applyExtension`.
|
|
11
|
+
//
|
|
12
|
+
// ⛔ THE SERVER DOES NOT EXTEND ANYTHING. `POST /v1/items/{id}/extended` means "record an extension
|
|
13
|
+
// the device already signed": the storage is bought by the time it is called, so a failure there
|
|
14
|
+
// is a failure to WRITE THE DATE DOWN and comes back as itself. Reporting it as a failure to
|
|
15
|
+
// extend would invite a second run, and a second run pays again.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ AND THE CHAIN IS THE AUTHORITY ON WHEN A LEASE ENDS, not the server's `expiry_epoch`. The
|
|
18
|
+
// server's answer is used for one thing: knowing WHICH blobs to ask the chain about.
|
|
19
|
+
//
|
|
20
|
+
// ⚠ Nothing here reaches for `node:`, and the chain and the signer are loaded only when no seam was
|
|
21
|
+
// supplied.
|
|
22
|
+
import { request } from "../api.js";
|
|
23
|
+
import { buildIndex, entryAt, fullPathOf, KIND_FILE, normalisePath } from "../drive-paths.js";
|
|
24
|
+
import { NmtsError } from "../errors.js";
|
|
25
|
+
import { budgetFacts, readBudget } from "../extend-budget.js";
|
|
26
|
+
import { daysLeftUntilEpoch, stageOf } from "../expiry.js";
|
|
27
|
+
import { asExtendPreview, chooseEpochs, headroom, soonestEnd, } from "../extend-plan.js";
|
|
28
|
+
import { isRecord } from "../guards.js";
|
|
29
|
+
import { readFileList } from "../manifest.js";
|
|
30
|
+
import { activeWalletOf } from "../shared/lib/drive/manifest-settings.js";
|
|
31
|
+
import { coinAmount, walletAddress } from "../wallet.js";
|
|
32
|
+
/**
|
|
33
|
+
* Work out what extending this file would buy and what it would cost, without signing anything.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ NO KEY IS DERIVED FOR SIGNING AND NO SIGNER IS LOADED. Everything here is a read, which is
|
|
36
|
+
* what lets a price be asked for without an agreement being asked for first.
|
|
37
|
+
*/
|
|
38
|
+
export async function planExtension(input, target, seams) {
|
|
39
|
+
const list = await readFileList(input.server, input.apiKey, input.code, input.accountId);
|
|
40
|
+
if (list.manifest === null) {
|
|
41
|
+
throw new NmtsError("This account has no file list, so there is nothing to extend.", { exitCode: 4 });
|
|
42
|
+
}
|
|
43
|
+
const entries = list.manifest.entries;
|
|
44
|
+
const settings = list.manifest.settings;
|
|
45
|
+
const entry = entryAt(entries, normalisePath(target), {
|
|
46
|
+
nothingHappened: "Nothing was signed and nothing was charged.",
|
|
47
|
+
});
|
|
48
|
+
if (entry.kind !== KIND_FILE) {
|
|
49
|
+
throw new NmtsError(`No file at "${fullPathOf(buildIndex(entries), entry)}".`, {
|
|
50
|
+
exitCode: 4,
|
|
51
|
+
nextStep: "That is a folder. Nothing was signed and nothing was charged — storage is bought per " +
|
|
52
|
+
"file, so this takes one file at a time.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const path = fullPathOf(buildIndex(entries), entry);
|
|
56
|
+
// ⛔ THE SERVER SAYS WHICH BLOBS, AND NOTHING ELSE. Its `expiry_epoch` is client-reported and
|
|
57
|
+
// advisory; anything that spends money reads the chain's own answer below.
|
|
58
|
+
const preview = asExtendPreview(await request(input.server, `/v1/items/${encodeURIComponent(entry.id)}/extend-preview`, {
|
|
59
|
+
token: input.apiKey,
|
|
60
|
+
}));
|
|
61
|
+
if (preview.targets.length === 0) {
|
|
62
|
+
throw new NmtsError(`Nothing on "${path}" can be extended from here.`, {
|
|
63
|
+
exitCode: 4,
|
|
64
|
+
nextStep: nothingToExtend(preview),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const reads = await (seams.readChain ?? defaultReads)(input.network);
|
|
68
|
+
const window = await reads.readWindow();
|
|
69
|
+
if (window === null) {
|
|
70
|
+
// ⛔ Not "nothing needs extending". The two look identical from outside and mean opposite things.
|
|
71
|
+
throw new NmtsError(`The ${input.network} storage network could not be read.`, {
|
|
72
|
+
exitCode: 1,
|
|
73
|
+
nextStep: `Nothing was signed and nothing was charged. Which epoch the network is in, and how far ` +
|
|
74
|
+
`ahead it will sell, are facts only the chain has — this will not spend against a ` +
|
|
75
|
+
`guess. Try again, or name a different Sui node in NMTS_SUI_RPC.`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const clock = window.clock;
|
|
79
|
+
const objectIds = preview.targets.map((t) => t.objectId);
|
|
80
|
+
const leases = await reads.readLeases(objectIds);
|
|
81
|
+
const endEpoch = soonestEnd(leases);
|
|
82
|
+
if (endEpoch === null) {
|
|
83
|
+
throw new NmtsError(`The chain holds no storage term for "${path}".`, {
|
|
84
|
+
exitCode: 4,
|
|
85
|
+
nextStep: nothingToExtend(preview),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const stage = stageOf(clock, endEpoch, seams.now);
|
|
89
|
+
if (stage === "lapsed") {
|
|
90
|
+
throw new NmtsError(`The storage term for "${path}" has already ended.`, {
|
|
91
|
+
exitCode: 4,
|
|
92
|
+
nextStep: seams.hints?.lapsed ??
|
|
93
|
+
`Nothing was signed and nothing was charged. A lease is extended before it ends — once it ` +
|
|
94
|
+
`is over there is no storage object left to extend, and the bytes may already be gone. ` +
|
|
95
|
+
`Fetching the file is what says whether they can still be read.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const epochs = chooseEpochs(seams.epochs, headroom(leases, clock.current, window.maxAhead));
|
|
99
|
+
const newEndEpoch = endEpoch + epochs;
|
|
100
|
+
const before = daysLeftUntilEpoch(clock, endEpoch, seams.now);
|
|
101
|
+
const after = daysLeftUntilEpoch(clock, newEndEpoch, seams.now);
|
|
102
|
+
// ⛔ A COST THAT COULD NOT BE COMPUTED MUST NOT BECOME A COST OF ZERO. `quote` rejects rather
|
|
103
|
+
// than defaulting, and that rejection stops this run before anything is agreed to.
|
|
104
|
+
const frost = await reads.quote(leases, epochs);
|
|
105
|
+
const cohort = Math.max(0, ...preview.targets.map((t) => t.sharedItems));
|
|
106
|
+
const unreachable = preview.treasuryParts + preview.untrackedParts;
|
|
107
|
+
// ⛔ WHICH WALLET PAYS comes out of the list this run already read, before the price is measured
|
|
108
|
+
// against a balance: the address below is the one that will sign.
|
|
109
|
+
const wallet = seams.wallet ?? activeWalletOf(settings);
|
|
110
|
+
const address = await walletAddress(input.code, wallet);
|
|
111
|
+
const budget = await readBudget(reads, { address, objectIds, epochs, priceFrost: frost });
|
|
112
|
+
return {
|
|
113
|
+
facts: {
|
|
114
|
+
file: path,
|
|
115
|
+
itemId: entry.id,
|
|
116
|
+
network: input.network,
|
|
117
|
+
epoch: clock.current,
|
|
118
|
+
endEpoch,
|
|
119
|
+
epochs,
|
|
120
|
+
newEndEpoch,
|
|
121
|
+
daysLeft: before,
|
|
122
|
+
daysLeftAfter: after,
|
|
123
|
+
blobs: leases.length,
|
|
124
|
+
priceFrost: frost.toString(),
|
|
125
|
+
priceWal: coinAmount(frost),
|
|
126
|
+
paidFrom: "wallet",
|
|
127
|
+
filesOnTheSameBlobs: cohort,
|
|
128
|
+
partsThatCannotBeExtended: unreachable,
|
|
129
|
+
...budgetFacts(budget),
|
|
130
|
+
},
|
|
131
|
+
itemId: entry.id,
|
|
132
|
+
path,
|
|
133
|
+
objectIds,
|
|
134
|
+
epochs,
|
|
135
|
+
wallet,
|
|
136
|
+
stage,
|
|
137
|
+
budget,
|
|
138
|
+
settings,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Buy it. ⛔ THIS SIGNS, and nothing below this line can be undone by anybody, NMTS included.
|
|
143
|
+
*
|
|
144
|
+
* Nothing here asks whether the caller meant it: the price, both balances and any shortfall are in
|
|
145
|
+
* the plan, and calling this is the answer.
|
|
146
|
+
*/
|
|
147
|
+
export async function applyExtension(input, plan, seams = {}) {
|
|
148
|
+
const sign = seams.sign ?? (await import("../wallet-sign.js")).signExtension;
|
|
149
|
+
const digest = await sign({
|
|
150
|
+
network: input.network,
|
|
151
|
+
code: input.code,
|
|
152
|
+
wallet: plan.wallet,
|
|
153
|
+
objectIds: plan.objectIds,
|
|
154
|
+
epochs: plan.epochs,
|
|
155
|
+
});
|
|
156
|
+
seams.onSigned?.(digest);
|
|
157
|
+
// From here the storage IS extended. Recording it is bookkeeping, and a failure to record must
|
|
158
|
+
// never be reported as a failure to extend — that reading invites a second run, which pays again.
|
|
159
|
+
try {
|
|
160
|
+
const recorded = await request(input.server, `/v1/items/${encodeURIComponent(plan.itemId)}/extended`, { method: "POST", token: input.apiKey, body: { epochs: plan.epochs, tx_digest: digest } });
|
|
161
|
+
return { digest, recorded: true, replay: isRecord(recorded) && recorded["replay"] === true, notRecorded: null };
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
digest,
|
|
166
|
+
recorded: false,
|
|
167
|
+
replay: false,
|
|
168
|
+
notRecorded: error instanceof Error ? error.message : String(error),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Why a file has nothing to extend, said as the two different things it can be. */
|
|
173
|
+
export function nothingToExtend(preview) {
|
|
174
|
+
const parts = [];
|
|
175
|
+
if (preview.treasuryParts > 0) {
|
|
176
|
+
parts.push(`${preview.treasuryParts} part${preview.treasuryParts === 1 ? " is" : "s are"} on storage NMTS ` +
|
|
177
|
+
`paid for, which this account cannot extend`);
|
|
178
|
+
}
|
|
179
|
+
if (preview.untrackedParts > 0) {
|
|
180
|
+
parts.push(`${preview.untrackedParts} part${preview.untrackedParts === 1 ? " has" : "s have"} no ` +
|
|
181
|
+
`recorded storage object, so there is nothing to name on the chain`);
|
|
182
|
+
}
|
|
183
|
+
const why = parts.length === 0 ? "The server lists no storage object for it." : `${parts.join(", and ")}.`;
|
|
184
|
+
return `Nothing was signed and nothing was charged. ${why} Opening the account in a browser shows what it is stored on.`;
|
|
185
|
+
}
|
|
186
|
+
/** The real chain reads. Imported only when no seam was supplied — it loads the storage SDK. */
|
|
187
|
+
async function defaultReads(network) {
|
|
188
|
+
return (await import("../extend-chain.js")).extendReads(network);
|
|
189
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { NmtsError } from "../errors.ts";
|
|
2
|
+
export interface StorageHints {
|
|
3
|
+
/**
|
|
4
|
+
* The `nextStep` for a chain that did not answer. The chain's own cause is added after it, so
|
|
5
|
+
* this is the part before `Cause: …` and nothing else.
|
|
6
|
+
*/
|
|
7
|
+
cannotRead?: string | undefined;
|
|
8
|
+
/** The `nextStep` for a resource this wallet does not hold free. */
|
|
9
|
+
notHeld?: string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The whole refusal for a cut that would keep nothing or everything.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ A FUNCTION BECAUSE THE NUMBER IS THE CHAIN'S: `limit` is the resource's own size, already in
|
|
14
|
+
* the units a person reads, or its own number of epochs. What the refusal calls the thing that
|
|
15
|
+
* was too big — a flag, a field — is the caller's to say.
|
|
16
|
+
*/
|
|
17
|
+
cut?: ((what: "size" | "period", limit: string) => NmtsError) | undefined;
|
|
18
|
+
/** The `nextStep` for a storage term that has already ended. */
|
|
19
|
+
lapsed?: string | undefined;
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The words a caller puts in a refusal, where the neutral sentence is not the one to say.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ A TERMINAL NAMES ITS OWN COMMANDS AND A LIBRARY NAMES NONE. "`nmts env` says which network was
|
|
4
|
+
// asked" is the right next step for somebody at a prompt and the wrong one inside somebody
|
|
5
|
+
// else's server, where that program may not exist. So the JUDGEMENT is written once, here, and
|
|
6
|
+
// the sentence that follows it belongs to whoever asked — the same division this whole folder
|
|
7
|
+
// keeps between what is decided and what is printed.
|
|
8
|
+
//
|
|
9
|
+
// ⚠ EVERY FIELD IS OPTIONAL, and what is left out is answered with a sentence that names nothing
|
|
10
|
+
// outside the caller's own program. The SDK passes none of them.
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Network } from "../network.ts";
|
|
2
|
+
import type { StorageResource } from "../shared/lib/storage-control/chain.ts";
|
|
3
|
+
import type { StorageHints } from "./hints.ts";
|
|
4
|
+
/** What the chain said: the resources, and which epoch it is (null when the clock could not be read). */
|
|
5
|
+
export interface StorageRead {
|
|
6
|
+
items: readonly StorageResource[];
|
|
7
|
+
currentEpoch: number | null;
|
|
8
|
+
}
|
|
9
|
+
/** Where a resource stands against the current epoch. */
|
|
10
|
+
export type StorageStatus = "lapsed" | "notYet" | "usable";
|
|
11
|
+
/** One resource, with where it stands. */
|
|
12
|
+
export interface StorageItem extends StorageResource {
|
|
13
|
+
/**
|
|
14
|
+
* ⛔ NULL IS "THE EPOCH COULD NOT BE READ", never "lapsed". Which epoch the network is in is a
|
|
15
|
+
* fact only the chain has, and guessing it would mark a resource somebody paid for as over.
|
|
16
|
+
*/
|
|
17
|
+
status: StorageStatus | null;
|
|
18
|
+
}
|
|
19
|
+
/** One wallet's storage, as anything asking about it needs it. */
|
|
20
|
+
export interface StorageListing {
|
|
21
|
+
address: string;
|
|
22
|
+
network: Network;
|
|
23
|
+
currentEpoch: number | null;
|
|
24
|
+
/** Usable first, then largest, then furthest ahead — left as read when there is no epoch. */
|
|
25
|
+
items: readonly StorageItem[];
|
|
26
|
+
/** The usable sizes added up — null when the epoch could not be read. */
|
|
27
|
+
usableBytes: number | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* How the resources are read.
|
|
31
|
+
*
|
|
32
|
+
* ⚠ A SEAM, NOT AN OPTION — no flag and no caller argument reaches it. A test that talked to a live
|
|
33
|
+
* storage network could not run offline and could never be asked to hold a lapsed resource.
|
|
34
|
+
*/
|
|
35
|
+
export type ReadWalletStorage = (network: Network, address: string) => Promise<StorageRead>;
|
|
36
|
+
/** Every free storage resource one address holds, in the order a person reads them. */
|
|
37
|
+
export declare function listStorage(input: {
|
|
38
|
+
network: Network;
|
|
39
|
+
address: string;
|
|
40
|
+
}, read?: ReadWalletStorage, hints?: StorageHints): Promise<StorageListing>;
|
|
41
|
+
/**
|
|
42
|
+
* The one refusal for a chain that did not answer.
|
|
43
|
+
*
|
|
44
|
+
* ⛔ IT IS NOT AN EMPTY LIST. Holding no resource is normal; failing to read is a reason to look
|
|
45
|
+
* again, and the chain's own words are carried so that whoever reads it knows which it was.
|
|
46
|
+
*/
|
|
47
|
+
export declare function readOrRefuse(read: () => Promise<StorageRead>, hints?: StorageHints): Promise<StorageRead>;
|
|
48
|
+
/**
|
|
49
|
+
* Bytes as a person reads them: binary units, two decimals, whole bytes below a KiB.
|
|
50
|
+
*
|
|
51
|
+
* ⚠ HERE RATHER THAN BESIDE THE SCREEN THAT PRINTS THEM, because the refusals in `reshape.ts` name
|
|
52
|
+
* a resource's size too, and a refusal that said `4294967296` about a resource a listing calls
|
|
53
|
+
* `4.00 GiB` would be two spellings of one number in front of the same person.
|
|
54
|
+
*/
|
|
55
|
+
export declare function formatBytes(bytes: number): string;
|