@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.
@@ -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
+ }
@@ -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 four names above. */
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;
@@ -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.38.1";
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.38.1";
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;