@needmoretruth/nmts-sdk 0.1.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 +176 -0
- package/LICENSE +202 -0
- package/LICENSING.md +45 -0
- package/README.md +241 -0
- package/dist/env.d.ts +15 -0
- package/dist/env.js +42 -0
- package/dist/get.d.ts +35 -0
- package/dist/get.js +116 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +13 -0
- package/dist/list.d.ts +39 -0
- package/dist/list.js +35 -0
- package/dist/nmts.d.ts +103 -0
- package/dist/nmts.js +157 -0
- package/dist/product.d.ts +11 -0
- package/dist/product.js +12 -0
- package/dist/put-wallet.d.ts +18 -0
- package/dist/put-wallet.js +116 -0
- package/dist/put.d.ts +151 -0
- package/dist/put.js +168 -0
- package/dist/root.d.ts +67 -0
- package/dist/root.js +76 -0
- package/dist/session.d.ts +54 -0
- package/dist/session.js +74 -0
- package/dist/wallets.d.ts +63 -0
- package/dist/wallets.js +129 -0
- package/examples/quickstart.mjs +16 -0
- package/package.json +62 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// One file in, bought with the account's OWN WALLET: the same seal-push-record path as the credit
|
|
2
|
+
// rail, with the storage bought on the Sui chain instead of out of the treasury. The shared types,
|
|
3
|
+
// the shared spellings and the credit rail itself are `put.ts`.
|
|
4
|
+
//
|
|
5
|
+
// ⛔ THIS IS THE FILE THAT SIGNS. `pay: "wallet"` spends WAL for the storage and SUI for the
|
|
6
|
+
// relay's tip and the chain fees, out of the wallet this account pays from — `wallet` when the
|
|
7
|
+
// caller named one, and otherwise the account's own number. None of it comes back and there is
|
|
8
|
+
// no agreement step — a library has nobody to ask — so calling `put` IS the agreement, exactly
|
|
9
|
+
// as the credit rail says.
|
|
10
|
+
//
|
|
11
|
+
// ⛔ THE ORDER IS THE SAFETY, AND IT IS NOT THIS FILE'S. The price, both balances and a shortfall
|
|
12
|
+
// all come before the first signature, and that order is the command-line tool's `walletPut`,
|
|
13
|
+
// whose chain, signers and wire are handed in as seams. A second copy of it here would be a
|
|
14
|
+
// second thing to keep right about money that cannot be got back.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ NO STANDING GIFT AND NO SPENDING LEDGER. Both are a person's act on one machine under one
|
|
17
|
+
// agreement; a library that kept either would be deciding for a caller who never asked it to.
|
|
18
|
+
import { folderIdFor, loadCrypto, walletPut, } from "@needmoretruth/nmts-cli";
|
|
19
|
+
import { readList } from "./list.js";
|
|
20
|
+
import { DEFAULT_PART_BYTES, destinationOf, pathOf, requireName, } from "./put.js";
|
|
21
|
+
import { withAccount } from "./session.js";
|
|
22
|
+
import { requireWalletIndex } from "./wallets.js";
|
|
23
|
+
/**
|
|
24
|
+
* Seal, buy with the account's OWN WALLET, push and record one file.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ NOBODY IS ASKED, BECAUSE THERE IS NOBODY TO ASK. The command-line tool prints the review and
|
|
27
|
+
* holds it against a standing wallet agreement; in a library the code that can spend is already
|
|
28
|
+
* in this process and the caller wrote the call, so calling this IS the agreement — exactly as
|
|
29
|
+
* the credit rail already says. What does not change is the order: the price, the balances and
|
|
30
|
+
* a shortfall all come before the first signature.
|
|
31
|
+
*/
|
|
32
|
+
export async function putSourceWithWallet(opened, source, name, options, seams = {}) {
|
|
33
|
+
requireName(name);
|
|
34
|
+
const destination = destinationOf(options.to);
|
|
35
|
+
// ⛔ A NAMED WALLET IS JUDGED BEFORE ANYTHING IS OPENED: a number that is not one is a call to
|
|
36
|
+
// fix, and refusing it here costs nobody the opening of a business's sealed store.
|
|
37
|
+
const named = options.wallet === undefined ? null : requireWalletIndex(options.wallet);
|
|
38
|
+
// ⛔ ONE BORROW FOR THE WHOLE RAIL. The code seals the file AND derives the wallet that signs,
|
|
39
|
+
// so `WalletPutContext.code` is the same borrowed copy the list was opened with rather than a
|
|
40
|
+
// second opening of a business's store partway through a call that is about to spend.
|
|
41
|
+
return withAccount(opened, async (held) => {
|
|
42
|
+
const { entries, padding: rule, activeWallet } = await readList(held);
|
|
43
|
+
const parentId = folderIdFor(options.to, entries, "Nothing was sent and nothing was signed.");
|
|
44
|
+
const outcome = await walletPut({
|
|
45
|
+
code: held.code,
|
|
46
|
+
apiKey: held.apiKey,
|
|
47
|
+
server: held.server,
|
|
48
|
+
network: held.network,
|
|
49
|
+
accountId: held.accountId,
|
|
50
|
+
crypt: await loadCrypto(),
|
|
51
|
+
partSize: options.partSize ?? DEFAULT_PART_BYTES,
|
|
52
|
+
rule,
|
|
53
|
+
// ⛔ RESOLVED BEFORE ANYTHING IS PRICED, out of the list this call just read — the address
|
|
54
|
+
// the review names has to be the address that signs, and asking for the number later
|
|
55
|
+
// would let those two differ.
|
|
56
|
+
wallet: named ?? activeWallet,
|
|
57
|
+
}, { source, name, parentId, destination }, {
|
|
58
|
+
...seams,
|
|
59
|
+
epochs: options.epochs,
|
|
60
|
+
storage: options.storage,
|
|
61
|
+
dryRun: options.dryRun,
|
|
62
|
+
onStep: options.onStep,
|
|
63
|
+
onProgress: options.onProgress,
|
|
64
|
+
});
|
|
65
|
+
const { review } = outcome;
|
|
66
|
+
if (outcome.kind === "review") {
|
|
67
|
+
return {
|
|
68
|
+
dryRun: true,
|
|
69
|
+
paid: "wallet",
|
|
70
|
+
name,
|
|
71
|
+
path: pathOf(destination, name),
|
|
72
|
+
bytes: review.bytes,
|
|
73
|
+
sealedBytes: review.sealedBytes,
|
|
74
|
+
parts: review.parts,
|
|
75
|
+
credits: 0,
|
|
76
|
+
wal: review.budget.walNeededFrost.toString(),
|
|
77
|
+
sui: review.budget.suiNeededMist.toString(),
|
|
78
|
+
epochs: review.epochs,
|
|
79
|
+
endEpoch: review.endEpoch,
|
|
80
|
+
storage: storageOf(review),
|
|
81
|
+
wallet: {
|
|
82
|
+
address: review.budget.address,
|
|
83
|
+
wal: review.budget.walFrost === null ? null : review.budget.walFrost.toString(),
|
|
84
|
+
sui: review.budget.suiMist === null ? null : review.budget.suiMist.toString(),
|
|
85
|
+
},
|
|
86
|
+
shortfall: review.budget.shortfall,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
dryRun: false,
|
|
91
|
+
paid: "wallet",
|
|
92
|
+
id: outcome.itemId,
|
|
93
|
+
name: outcome.savedAs,
|
|
94
|
+
path: pathOf(destination, outcome.savedAs),
|
|
95
|
+
bytes: review.bytes,
|
|
96
|
+
sealedBytes: review.sealedBytes,
|
|
97
|
+
parts: review.parts,
|
|
98
|
+
credits: 0,
|
|
99
|
+
// ⛔ A RESUMED RUN SIGNED NOTHING NOW, so it spent nothing now — the same answer `credits`
|
|
100
|
+
// gives on the other rail, and what a caller adding up a month of uploads needs it to say.
|
|
101
|
+
wal: outcome.resumed ? "0" : review.budget.walNeededFrost.toString(),
|
|
102
|
+
sui: outcome.resumed ? "0" : review.budget.suiNeededMist.toString(),
|
|
103
|
+
endEpoch: review.endEpoch,
|
|
104
|
+
resumed: outcome.resumed,
|
|
105
|
+
renamed: outcome.savedAs !== name,
|
|
106
|
+
fileListVersion: outcome.fileListVersion,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** The storage choice without the chain row it was picked from — the numbers a caller can use. */
|
|
111
|
+
function storageOf(review) {
|
|
112
|
+
const { storage } = review;
|
|
113
|
+
return storage.kind === "buy"
|
|
114
|
+
? { kind: "buy" }
|
|
115
|
+
: { kind: "reuse", objectId: storage.objectId, cutToBytes: storage.cutToBytes, leftoverBytes: storage.leftoverBytes };
|
|
116
|
+
}
|
package/dist/put.d.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { type BlobProtocol, type FileUploadStep, type PlaintextSource, type UploadApi } from "@needmoretruth/nmts-cli";
|
|
2
|
+
import { type Opened } from "./session.ts";
|
|
3
|
+
/** 64 MiB — the same part size the command-line tool uses when nobody says otherwise. */
|
|
4
|
+
export declare const DEFAULT_PART_BYTES: number;
|
|
5
|
+
/** The two things a credit-paid upload talks to: the server that sells storage, the relay. */
|
|
6
|
+
export interface UploadRail {
|
|
7
|
+
api: UploadApi;
|
|
8
|
+
protocol: BlobProtocol;
|
|
9
|
+
relayUrl: string;
|
|
10
|
+
/** The storage network's current epoch, when it could be read. */
|
|
11
|
+
currentEpoch: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface PutOptions {
|
|
14
|
+
/** The name it gets in the account. Defaults to the local file's own name. Required for bytes. */
|
|
15
|
+
name?: string | undefined;
|
|
16
|
+
/** Destination folder as `list()` prints it, `photos/2026`. The top of the account when absent. */
|
|
17
|
+
to?: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* How much of the file goes into one part, in bytes. Defaults to 64 MiB.
|
|
20
|
+
*
|
|
21
|
+
* Bigger parts mean fewer reservations against the account's daily allowance; smaller parts
|
|
22
|
+
* mean less memory and a shorter piece of work to lose when something goes wrong.
|
|
23
|
+
*/
|
|
24
|
+
partSize?: number | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Which money buys the storage: the account's credits (the default), or the account's own
|
|
27
|
+
* wallet — WAL for the storage and SUI for the relay's tip and the chain fees.
|
|
28
|
+
*/
|
|
29
|
+
pay?: "credits" | "wallet" | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* `pay: "wallet"` only: which of this key's wallets pays, by index. Absent = the one the account
|
|
32
|
+
* pays from. Naming one here is for this upload alone and writes nothing —
|
|
33
|
+
* `setActiveWallet()` is how an account changes which wallet pays.
|
|
34
|
+
*/
|
|
35
|
+
wallet?: number | undefined;
|
|
36
|
+
/** `pay: "wallet"` only: how many of the storage network's epochs to buy. Default 2. */
|
|
37
|
+
epochs?: number | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* `pay: "wallet"` only: use a storage resource the wallet already holds instead of buying new
|
|
40
|
+
* storage. `fit` cuts the smallest one that fits and leaves the rest free, `whole` binds one
|
|
41
|
+
* whole, and an object id names one. A held resource serves a one-part file.
|
|
42
|
+
*/
|
|
43
|
+
storage?: string | undefined;
|
|
44
|
+
/** Work out what it would cost and stop. Nothing is sealed, signed, sent or spent. */
|
|
45
|
+
dryRun?: boolean | undefined;
|
|
46
|
+
/** Told about each step as it starts, so a long upload visibly moves. */
|
|
47
|
+
onStep?: ((step: FileUploadStep) => void) | undefined;
|
|
48
|
+
/** Told as sealed bytes leave for the relay — the honest measure of an upload's progress. */
|
|
49
|
+
onProgress?: ((sent: number, total: number) => void) | undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Where a wallet-paid part's storage came from. */
|
|
52
|
+
export type PutStorage = {
|
|
53
|
+
kind: "buy";
|
|
54
|
+
} | {
|
|
55
|
+
kind: "reuse";
|
|
56
|
+
objectId: string;
|
|
57
|
+
/** Bytes the resource was cut down to first, or null when it was bound whole. */
|
|
58
|
+
cutToBytes: number | null;
|
|
59
|
+
/** What the resource holds beyond this file — left free by a cut, bound with it when whole. */
|
|
60
|
+
leftoverBytes: number;
|
|
61
|
+
};
|
|
62
|
+
/** What every answer about one upload says, whoever paid and whether or not it ran. */
|
|
63
|
+
interface PutFacts {
|
|
64
|
+
/** The name it got — numbered, `report (2).pdf`, if the requested one was taken. */
|
|
65
|
+
name: string;
|
|
66
|
+
/** Full path in the account. */
|
|
67
|
+
path: string;
|
|
68
|
+
/** Plaintext bytes. */
|
|
69
|
+
bytes: number;
|
|
70
|
+
/** Bytes the storage network holds for it, padding and sealing included. */
|
|
71
|
+
sealedBytes: number;
|
|
72
|
+
parts: number;
|
|
73
|
+
}
|
|
74
|
+
interface Uploaded extends PutFacts {
|
|
75
|
+
dryRun: false;
|
|
76
|
+
/** The server's id for the file. */
|
|
77
|
+
id: string;
|
|
78
|
+
resumed: boolean;
|
|
79
|
+
renamed: boolean;
|
|
80
|
+
/** The file-list version this write produced. */
|
|
81
|
+
fileListVersion: number;
|
|
82
|
+
}
|
|
83
|
+
export interface CreditsPut extends Uploaded {
|
|
84
|
+
paid: "credits";
|
|
85
|
+
/** Credits this call spent. 0 when it finished an upload an earlier call had already paid for. */
|
|
86
|
+
credits: number;
|
|
87
|
+
}
|
|
88
|
+
export interface WalletPut extends Uploaded {
|
|
89
|
+
paid: "wallet";
|
|
90
|
+
/** Always 0: a wallet-paid upload takes no credits from the account. */
|
|
91
|
+
credits: 0;
|
|
92
|
+
/**
|
|
93
|
+
* WAL that left the wallet, in FROST — the chain's smallest unit, as a decimal string because
|
|
94
|
+
* a JSON number would round it. "0" when this call resumed one an earlier call paid for.
|
|
95
|
+
*/
|
|
96
|
+
wal: string;
|
|
97
|
+
/** SUI that left the wallet, in MIST: the relay's tip plus the measured register fees. */
|
|
98
|
+
sui: string;
|
|
99
|
+
/** The epoch the storage runs to. */
|
|
100
|
+
endEpoch: number;
|
|
101
|
+
}
|
|
102
|
+
/** What `put()` answers. Narrow on `paid` to read the numbers of one rail. */
|
|
103
|
+
export type PutResult = CreditsPut | WalletPut;
|
|
104
|
+
interface Reviewed extends PutFacts {
|
|
105
|
+
/** Nothing was sealed, signed, sent or spent: this is what it would have cost. */
|
|
106
|
+
dryRun: true;
|
|
107
|
+
}
|
|
108
|
+
export interface CreditsReview extends Reviewed {
|
|
109
|
+
paid: "credits";
|
|
110
|
+
credits: number;
|
|
111
|
+
}
|
|
112
|
+
export interface WalletReview extends Reviewed {
|
|
113
|
+
paid: "wallet";
|
|
114
|
+
credits: 0;
|
|
115
|
+
/** WAL it would spend, in FROST, as a decimal string. */
|
|
116
|
+
wal: string;
|
|
117
|
+
/** SUI it would spend, in MIST: the relay's tip plus the measured register fees. */
|
|
118
|
+
sui: string;
|
|
119
|
+
epochs: number;
|
|
120
|
+
endEpoch: number;
|
|
121
|
+
storage: PutStorage;
|
|
122
|
+
/** The paying wallet: its address, and what it holds — null for a balance the chain would not say. */
|
|
123
|
+
wallet: {
|
|
124
|
+
address: string;
|
|
125
|
+
wal: string | null;
|
|
126
|
+
sui: string | null;
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Set when the wallet is short: the sentence naming both numbers. A real `put()` with these
|
|
130
|
+
* numbers would throw rather than sign.
|
|
131
|
+
*/
|
|
132
|
+
shortfall: string | null;
|
|
133
|
+
}
|
|
134
|
+
/** What `put({ dryRun: true })` answers. */
|
|
135
|
+
export type PutReview = CreditsReview | WalletReview;
|
|
136
|
+
/** Bytes already in memory, read as a source. Copies each piece it yields. */
|
|
137
|
+
export declare function bytesSource(bytes: Uint8Array): PlaintextSource;
|
|
138
|
+
/** The reservation key names the destination as typed; this is the one spelling of "as typed". */
|
|
139
|
+
export declare function destinationOf(to: string | undefined): string;
|
|
140
|
+
export declare function pathOf(destination: string, name: string): string;
|
|
141
|
+
export declare function requireName(name: string): void;
|
|
142
|
+
/**
|
|
143
|
+
* Seal, buy with CREDITS, push and record one file.
|
|
144
|
+
*
|
|
145
|
+
* `rail` is asked for after the price is known, because the relay's timeout is sized to the
|
|
146
|
+
* sealed length, and a rail built before the plan would have to guess it.
|
|
147
|
+
*/
|
|
148
|
+
export declare function putSource(opened: Opened, source: PlaintextSource, name: string, options: PutOptions, rail: (sealedBytes: number) => Promise<UploadRail>): Promise<CreditsPut | CreditsReview>;
|
|
149
|
+
/** A file path's own name, for `put("/tmp/x/report.pdf")`. */
|
|
150
|
+
export declare function nameOf(localPath: string): string;
|
|
151
|
+
export {};
|
package/dist/put.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// One file in, bought with the account's CREDITS: sealed here, its storage bought, the sealed bytes
|
|
2
|
+
// pushed, and the file named in the account's sealed list. The other rail — the account's own
|
|
3
|
+
// wallet — is `put-wallet.ts`; everything else about an upload is the same on both, and the answers
|
|
4
|
+
// both rails give live here because `put()` returns one of them and a caller reads them as one shape.
|
|
5
|
+
//
|
|
6
|
+
// ⛔ THE ONLY THING IN THIS PACKAGE THAT SPENDS, WITH ITS OTHER RAIL. Credits are storage a donation
|
|
7
|
+
// pool has already paid the network for. Neither rail's money comes back. There is no agreement
|
|
8
|
+
// step here as there is in the command-line tool — a library has nobody to ask — so calling `put`
|
|
9
|
+
// IS the agreement, and the README says so before it says how.
|
|
10
|
+
//
|
|
11
|
+
// ⛔ THE PLAINTEXT NEVER LEAVES THIS PROCESS. What goes to the storage network is the sealed
|
|
12
|
+
// stream; what goes to the server is its length and a name that is itself inside the sealed
|
|
13
|
+
// list. The machinery that keeps a half-finished upload from becoming money that bought nothing
|
|
14
|
+
// is the command-line tool's own (`uploadFile`); this file only wires it.
|
|
15
|
+
//
|
|
16
|
+
// ⛔ THE RAIL IS A PARAMETER. The server and the relay are handed in, so a test drives this exact
|
|
17
|
+
// code against fakes rather than a second copy of the order that decides when money moves.
|
|
18
|
+
//
|
|
19
|
+
// ⛔ NO SPENDING LEDGER. The command-line tool holds one, because a ledger counts what one machine
|
|
20
|
+
// signed under one agreement. A library that kept one would be deciding for a caller who never
|
|
21
|
+
// asked it to.
|
|
22
|
+
import { basename } from "node:path";
|
|
23
|
+
import { addEntry, clearItemRecord, clearReservation, CREDIT_BYTES, folderIdFor, NmtsError, partKeysOf, planAndPrice, setTrashed, UPLOAD_EPOCHS, uploadFile, } from "@needmoretruth/nmts-cli";
|
|
24
|
+
import { readList } from "./list.js";
|
|
25
|
+
import { withAccount, withDataKey } from "./session.js";
|
|
26
|
+
/** 64 MiB — the same part size the command-line tool uses when nobody says otherwise. */
|
|
27
|
+
export const DEFAULT_PART_BYTES = 64 * 2 ** 20;
|
|
28
|
+
/** Bytes already in memory, read as a source. Copies each piece it yields. */
|
|
29
|
+
export function bytesSource(bytes) {
|
|
30
|
+
return {
|
|
31
|
+
size: bytes.length,
|
|
32
|
+
async *read(offset, length) {
|
|
33
|
+
const end = Math.min(offset + length, bytes.length);
|
|
34
|
+
const chunk = 4 * 2 ** 20;
|
|
35
|
+
for (let at = offset; at < end; at += chunk) {
|
|
36
|
+
yield bytes.slice(at, Math.min(at + chunk, end));
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The reservation key names the destination as typed; this is the one spelling of "as typed". */
|
|
42
|
+
export function destinationOf(to) {
|
|
43
|
+
return (to ?? "").replace(/^\.?\//, "").replace(/\/$/, "");
|
|
44
|
+
}
|
|
45
|
+
export function pathOf(destination, name) {
|
|
46
|
+
return destination === "" ? name : `${destination}/${name}`;
|
|
47
|
+
}
|
|
48
|
+
export function requireName(name) {
|
|
49
|
+
if (name.length === 0) {
|
|
50
|
+
throw new NmtsError("The file has no name.", {
|
|
51
|
+
exitCode: 2,
|
|
52
|
+
nextStep: "Nothing was sent. Pass `name` — bytes in memory have no name of their own.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* ⛔ AN OPTION THAT WOULD BE IGNORED IS A REFUSAL, not a shrug. `epochs` and `storage` buy a term
|
|
58
|
+
* and a resource on the chain, and `wallet` says which wallet signs for them; credits buy a
|
|
59
|
+
* fixed term from the treasury, hold no resource and are signed for by nobody, so a caller who
|
|
60
|
+
* asked for any of the three and paid with credits did not get what they asked for.
|
|
61
|
+
*/
|
|
62
|
+
function refuseWalletOnlyOptions(options) {
|
|
63
|
+
const asked = options.wallet !== undefined
|
|
64
|
+
? "wallet"
|
|
65
|
+
: options.epochs !== undefined
|
|
66
|
+
? "epochs"
|
|
67
|
+
: options.storage !== undefined
|
|
68
|
+
? "storage"
|
|
69
|
+
: null;
|
|
70
|
+
if (asked === null)
|
|
71
|
+
return;
|
|
72
|
+
throw new NmtsError(`\`${asked}\` only applies with \`pay: "wallet"\`.`, {
|
|
73
|
+
exitCode: 2,
|
|
74
|
+
nextStep: `Nothing was sent and nothing was charged. Add \`pay: "wallet"\` to buy the storage from the account's own wallet, or drop \`${asked}\` to pay with credits.`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Seal, buy with CREDITS, push and record one file.
|
|
79
|
+
*
|
|
80
|
+
* `rail` is asked for after the price is known, because the relay's timeout is sized to the
|
|
81
|
+
* sealed length, and a rail built before the plan would have to guess it.
|
|
82
|
+
*/
|
|
83
|
+
export async function putSource(opened, source, name, options, rail) {
|
|
84
|
+
requireName(name);
|
|
85
|
+
refuseWalletOnlyOptions(options);
|
|
86
|
+
const destination = destinationOf(options.to);
|
|
87
|
+
const partSize = options.partSize ?? DEFAULT_PART_BYTES;
|
|
88
|
+
// ⛔ THE REFUSALS ABOVE COST NO KEY. Everything from here needs the account's code, and this is
|
|
89
|
+
// the one place this call borrows it — whoever holds it, and however many requests follow.
|
|
90
|
+
return withAccount(opened, async (held) => {
|
|
91
|
+
// ⛔ THE LIST IS READ BEFORE THE PRICE IS QUOTED. The rounding rule that hides a file's true size
|
|
92
|
+
// lives in the sealed list, it changes how many bytes are stored, and so it changes the price.
|
|
93
|
+
// And it is read before anything is sealed or paid for, so a destination that does not exist
|
|
94
|
+
// stops the upload while there is still nothing to lose.
|
|
95
|
+
const { entries, padding: rule } = await readList(held);
|
|
96
|
+
const parentId = folderIdFor(options.to, entries, "Nothing was sent and nothing was charged.");
|
|
97
|
+
const { plan, sealedBytes, credits } = planAndPrice(source.size, partSize, rule);
|
|
98
|
+
if (options.dryRun === true) {
|
|
99
|
+
// ⛔ Nothing above this line spent anything and nothing below it runs. The price is arithmetic
|
|
100
|
+
// on the file's size and the account's rounding rule — the same arithmetic the server does.
|
|
101
|
+
return { dryRun: true, paid: "credits", name, path: pathOf(destination, name), bytes: source.size, sealedBytes, parts: plan.length, credits };
|
|
102
|
+
}
|
|
103
|
+
const built = await rail(sealedBytes);
|
|
104
|
+
const result = await withDataKey(held.code, (crypt, dataKey) => uploadFile({
|
|
105
|
+
api: built.api,
|
|
106
|
+
protocol: built.protocol,
|
|
107
|
+
crypt,
|
|
108
|
+
dataKey,
|
|
109
|
+
source,
|
|
110
|
+
name,
|
|
111
|
+
parentId,
|
|
112
|
+
destination,
|
|
113
|
+
relayUrl: built.relayUrl,
|
|
114
|
+
epochs: UPLOAD_EPOCHS,
|
|
115
|
+
currentEpoch: built.currentEpoch,
|
|
116
|
+
partSize,
|
|
117
|
+
padding: { rule, unitBytes: CREDIT_BYTES },
|
|
118
|
+
...(options.onStep === undefined ? {} : { onStep: options.onStep }),
|
|
119
|
+
}));
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
// ⛔ FROM `result.entry`, NOT FROM THIS CALL. The key that opens the stored bytes is the key they
|
|
122
|
+
// were sealed with, which on a resume belongs to the call that sealed them.
|
|
123
|
+
const added = await addEntry({
|
|
124
|
+
server: held.server,
|
|
125
|
+
apiKey: held.apiKey,
|
|
126
|
+
code: held.code,
|
|
127
|
+
accountId: held.accountId,
|
|
128
|
+
entry: {
|
|
129
|
+
id: result.itemId,
|
|
130
|
+
parentId,
|
|
131
|
+
kind: 1,
|
|
132
|
+
name: result.entry.name,
|
|
133
|
+
size: result.entry.plaintextLen,
|
|
134
|
+
createdAt: now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
dekWrapped: result.entry.dekWrapped,
|
|
137
|
+
contentHashCt: result.entry.contentHashCt,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
// ⛔ ONLY NOW, AND EVERY PART. Until the entry is in the list the file is paid for and invisible,
|
|
141
|
+
// and the records are what let a second call finish the job without spending again.
|
|
142
|
+
clearItemRecord(result.fileKey);
|
|
143
|
+
for (const record of partKeysOf(result.fileKey, result.parts))
|
|
144
|
+
clearReservation(record);
|
|
145
|
+
// The displaced file, when this machine is set to overwrite, goes to the trash on the server
|
|
146
|
+
// only after the new one is in the list — until then the caller still had the file they started with.
|
|
147
|
+
if (added.replaced)
|
|
148
|
+
await setTrashed(held.server, held.apiKey, added.replaced.id, true);
|
|
149
|
+
return {
|
|
150
|
+
dryRun: false,
|
|
151
|
+
paid: "credits",
|
|
152
|
+
id: result.itemId,
|
|
153
|
+
name: added.name,
|
|
154
|
+
path: pathOf(destination, added.name),
|
|
155
|
+
bytes: source.size,
|
|
156
|
+
sealedBytes,
|
|
157
|
+
parts: plan.length,
|
|
158
|
+
credits: result.resumed ? 0 : credits,
|
|
159
|
+
resumed: result.resumed,
|
|
160
|
+
renamed: added.name !== name,
|
|
161
|
+
fileListVersion: added.seq,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** A file path's own name, for `put("/tmp/x/report.pdf")`. */
|
|
166
|
+
export function nameOf(localPath) {
|
|
167
|
+
return basename(localPath);
|
|
168
|
+
}
|
package/dist/root.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which process holds the key.
|
|
3
|
+
*
|
|
4
|
+
* A third, `gateway` — the key inside an S3-compatible gateway process a business runs — is not
|
|
5
|
+
* built yet. When it is, it is another implementation in this file and another row in the test
|
|
6
|
+
* registry, and no verb moves.
|
|
7
|
+
*/
|
|
8
|
+
export type RootMode = "device" | "managed";
|
|
9
|
+
/**
|
|
10
|
+
* Who the server is asked to believe is calling.
|
|
11
|
+
*
|
|
12
|
+
* One kind for now. A business acting for a person it has delegation for is `{ kind: "delegation";
|
|
13
|
+
* token: string }`, and it arrives with the Platform API rather than before it.
|
|
14
|
+
*/
|
|
15
|
+
export type Identity = {
|
|
16
|
+
kind: "api-key";
|
|
17
|
+
apiKey: string;
|
|
18
|
+
};
|
|
19
|
+
/** Something that holds one account's NMTS key and will lend it for the length of one piece of work. */
|
|
20
|
+
export interface Root {
|
|
21
|
+
readonly mode: RootMode;
|
|
22
|
+
readonly identity: Identity;
|
|
23
|
+
withCode<T>(use: (code: string) => Promise<T>): Promise<T>;
|
|
24
|
+
}
|
|
25
|
+
/** What the device root takes. Two secrets, because they do two different jobs — see the README. */
|
|
26
|
+
export interface Credentials {
|
|
27
|
+
/** Opens the files and derives the wallet. Never leaves this process. */
|
|
28
|
+
accountCode: string;
|
|
29
|
+
/** Makes the server answer. Opens nothing; can be revoked on the account screen. */
|
|
30
|
+
apiKey: string;
|
|
31
|
+
}
|
|
32
|
+
/** What the managed root takes: a way to open the sealed code, and the key the server answers to. */
|
|
33
|
+
export interface ManagedCredentials {
|
|
34
|
+
/**
|
|
35
|
+
* Opens the account's code out of wherever this business sealed it, and answers it.
|
|
36
|
+
*
|
|
37
|
+
* Called once for each call this package makes on the account, and never for anything else. How
|
|
38
|
+
* the code is sealed — a cloud key service, a master key, a hardware module — is the business's
|
|
39
|
+
* own decision and this package does not reach into it.
|
|
40
|
+
*/
|
|
41
|
+
openCode: () => Promise<string>;
|
|
42
|
+
/** Makes the server answer. Opens nothing; can be revoked on the account screen. */
|
|
43
|
+
apiKey: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A credential is text or it is a refusal.
|
|
47
|
+
*
|
|
48
|
+
* ⛔ THE REFUSAL NEVER CONTAINS THE VALUE. It names what was missing and what to do; a caller's log
|
|
49
|
+
* is not a place an account code should be able to reach.
|
|
50
|
+
*/
|
|
51
|
+
export declare function requireText(value: unknown, what: string, nextStep: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* The key is in THIS process: a browser, an app, a game client, a developer's own program.
|
|
54
|
+
*
|
|
55
|
+
* The code is checked when it is first used rather than here, so that making one of these does no
|
|
56
|
+
* work and a code that is not one fails on the first call, where the caller can act on it.
|
|
57
|
+
*/
|
|
58
|
+
export declare function deviceRoot(credentials: Credentials): Root;
|
|
59
|
+
/**
|
|
60
|
+
* The key is in a BUSINESS'S STORE: it sealed the code and opens it when something needs it.
|
|
61
|
+
*
|
|
62
|
+
* ⛔ ASKED EVERY TIME AND KEPT NOWHERE. This package holds no copy between calls, which is what
|
|
63
|
+
* makes the business's own store the only place the code lives at rest: revoking it there ends
|
|
64
|
+
* the access, and a long-running process that was asked once does not go on holding it. The cost
|
|
65
|
+
* is one `openCode` per call, and the test registry counts exactly that.
|
|
66
|
+
*/
|
|
67
|
+
export declare function managedRoot(source: ManagedCredentials): Root;
|
package/dist/root.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Who holds the NMTS key — the one thing that differs between a person's own program and a
|
|
2
|
+
// business running this for other people, and the only place in the package where it differs.
|
|
3
|
+
//
|
|
4
|
+
// ⛔ EVERY VERB TAKES A ROOT, AND THERE ARE TWO ROOTS, NOT TWO SETS OF VERBS. Everything an account
|
|
5
|
+
// can do is derived from its key, so the question "who holds it" has exactly one seam: which
|
|
6
|
+
// process the key is in. Splitting the verbs by mode instead would mean writing each one twice,
|
|
7
|
+
// and a verb written twice is a verb that ends up working for one holder and not the other.
|
|
8
|
+
// `test/roots.ts` walks every verb through both, so a verb that only works one way is a red test
|
|
9
|
+
// rather than a discovery made by whoever tried it second.
|
|
10
|
+
//
|
|
11
|
+
// ⛔ A JAVASCRIPT STRING CANNOT BE ZEROED, so `withCode` does what a runtime with immutable strings
|
|
12
|
+
// allows: it lends the code for one piece of work, keeps no reference of its own afterwards, and
|
|
13
|
+
// never stores what it opened. What is derived FROM the code is wiped — `withDataKey` in
|
|
14
|
+
// `session.ts` zeroes the derivation before it returns. The device root is the exception by
|
|
15
|
+
// definition: the caller handed it the code, so the caller's own object holds it.
|
|
16
|
+
//
|
|
17
|
+
// ⛔ NO SERVER AND NO NETWORK HERE. A root answers "who holds the key" and "who does the server
|
|
18
|
+
// think is calling". Where to talk is `NmtsOptions`, because the same key is the same account on
|
|
19
|
+
// any server, and a root carrying an address would be a key that only worked against one.
|
|
20
|
+
import { NmtsError } from "@needmoretruth/nmts-cli";
|
|
21
|
+
const CODE_NEXT_STEP = "Pass `accountCode` to Nmts.device(), or set NMTS_ACCOUNT_CODE_FILE and use `Nmts.fromEnv()`.";
|
|
22
|
+
const OPENED_NOTHING_NEXT_STEP = "Nothing was sent. `openCode` answered no account code, so there was nothing to open the account with.";
|
|
23
|
+
/**
|
|
24
|
+
* A credential is text or it is a refusal.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ THE REFUSAL NEVER CONTAINS THE VALUE. It names what was missing and what to do; a caller's log
|
|
27
|
+
* is not a place an account code should be able to reach.
|
|
28
|
+
*/
|
|
29
|
+
export function requireText(value, what, nextStep) {
|
|
30
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
31
|
+
throw new NmtsError(`No ${what} was given.`, { exitCode: 3, nextStep });
|
|
32
|
+
}
|
|
33
|
+
return value.trim();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The key is in THIS process: a browser, an app, a game client, a developer's own program.
|
|
37
|
+
*
|
|
38
|
+
* The code is checked when it is first used rather than here, so that making one of these does no
|
|
39
|
+
* work and a code that is not one fails on the first call, where the caller can act on it.
|
|
40
|
+
*/
|
|
41
|
+
export function deviceRoot(credentials) {
|
|
42
|
+
const { accountCode, apiKey } = credentials;
|
|
43
|
+
return {
|
|
44
|
+
mode: "device",
|
|
45
|
+
identity: { kind: "api-key", apiKey },
|
|
46
|
+
async withCode(use) {
|
|
47
|
+
return use(requireText(accountCode, "account code", CODE_NEXT_STEP));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The key is in a BUSINESS'S STORE: it sealed the code and opens it when something needs it.
|
|
53
|
+
*
|
|
54
|
+
* ⛔ ASKED EVERY TIME AND KEPT NOWHERE. This package holds no copy between calls, which is what
|
|
55
|
+
* makes the business's own store the only place the code lives at rest: revoking it there ends
|
|
56
|
+
* the access, and a long-running process that was asked once does not go on holding it. The cost
|
|
57
|
+
* is one `openCode` per call, and the test registry counts exactly that.
|
|
58
|
+
*/
|
|
59
|
+
export function managedRoot(source) {
|
|
60
|
+
const { openCode, apiKey } = source;
|
|
61
|
+
return {
|
|
62
|
+
mode: "managed",
|
|
63
|
+
identity: { kind: "api-key", apiKey },
|
|
64
|
+
async withCode(use) {
|
|
65
|
+
let code = requireText(await openCode(), "account code", OPENED_NOTHING_NEXT_STEP);
|
|
66
|
+
try {
|
|
67
|
+
return await use(code);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
// The local would go out of scope anyway. Dropping it here is what a reader can check, and
|
|
71
|
+
// what stops a later edit from quietly holding the code past the work that borrowed it.
|
|
72
|
+
code = null;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type CryptoGlue, type Network } from "@needmoretruth/nmts-cli";
|
|
2
|
+
import { type Root } from "./root.ts";
|
|
3
|
+
/** Where an account talks. Not the root's business: one key is one account on any server. */
|
|
4
|
+
export interface ServerOptions {
|
|
5
|
+
/** The NMTS server. Defaults to https://nmts.me. */
|
|
6
|
+
server?: string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* `mainnet` or `testnet`. Defaults to mainnet for the public server and must be stated for any
|
|
9
|
+
* other — guessing would look for files on a network they were never stored on.
|
|
10
|
+
*/
|
|
11
|
+
network?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
/** An account this process can act for: the root that holds its key, and where it talks. */
|
|
14
|
+
export interface Opened {
|
|
15
|
+
readonly root: Root;
|
|
16
|
+
readonly apiKey: string;
|
|
17
|
+
readonly server: string;
|
|
18
|
+
readonly network: Network;
|
|
19
|
+
}
|
|
20
|
+
/** The account WHILE one call holds its code — everything the library surface underneath takes. */
|
|
21
|
+
export interface Held {
|
|
22
|
+
readonly code: string;
|
|
23
|
+
readonly apiKey: string;
|
|
24
|
+
readonly server: string;
|
|
25
|
+
readonly network: Network;
|
|
26
|
+
readonly accountId: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Check what can be checked without the code, and work out where this account talks.
|
|
30
|
+
*
|
|
31
|
+
* The key is only checked for being present; the server is what judges a key, on the first request.
|
|
32
|
+
* Nothing here borrows the code, so a caller who got the key wrong is refused without a business's
|
|
33
|
+
* store having been opened for nothing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function openAccount(root: Root, options?: ServerOptions): Opened;
|
|
36
|
+
/**
|
|
37
|
+
* Borrow the account's code for the length of `body`, and work out the account it names.
|
|
38
|
+
*
|
|
39
|
+
* ⛔ ONE CALL, ONE BORROW. Every verb in this package wraps the whole of itself in this, so a
|
|
40
|
+
* managed root is asked to open its sealed code exactly once however many requests the verb makes.
|
|
41
|
+
*
|
|
42
|
+
* ⛔ THE ACCOUNT ID IS DERIVED HERE RATHER THAN KEPT, because deriving it is the only check there
|
|
43
|
+
* is that a code is one: nothing about a string says which account it opens until the engine has
|
|
44
|
+
* parsed it. It is a pure function of the code, and the code is only here.
|
|
45
|
+
*/
|
|
46
|
+
export declare function withAccount<T>(opened: Opened, body: (held: Held) => Promise<T>): Promise<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Run `body` with the account's data key, then wipe it.
|
|
49
|
+
*
|
|
50
|
+
* The data key wraps every file key in the account. It exists for exactly as long as the work
|
|
51
|
+
* that needs it, and the derivation output it came out of — which holds every other key too — is
|
|
52
|
+
* zeroed before `body` even starts.
|
|
53
|
+
*/
|
|
54
|
+
export declare function withDataKey<T>(code: string, body: (crypt: CryptoGlue, dataKey: Uint8Array) => Promise<T>): Promise<T>;
|