@needmoretruth/nmts-cli 0.36.3 → 0.38.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 +22 -0
- package/CHANGELOG.md +25 -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 +1 -1
- package/dist/commands/organise.d.ts +5 -27
- package/dist/commands/organise.js +33 -193
- package/dist/commands/push.js +1 -1
- package/dist/commands/s3.d.ts +0 -10
- package/dist/commands/s3.js +46 -121
- package/dist/commands/trash.d.ts +0 -9
- package/dist/commands/trash.js +23 -223
- package/dist/drive-edit/errors.d.ts +44 -0
- package/dist/drive-edit/errors.js +65 -0
- package/dist/drive-edit/folders.d.ts +29 -0
- package/dist/drive-edit/folders.js +98 -0
- package/dist/drive-edit/move.d.ts +66 -0
- package/dist/drive-edit/move.js +119 -0
- package/dist/drive-edit/trash.d.ts +53 -0
- package/dist/drive-edit/trash.js +190 -0
- package/dist/drive-edit/tree.d.ts +15 -0
- package/dist/drive-edit/tree.js +77 -0
- package/dist/drive-edit.d.ts +9 -0
- package/dist/drive-edit.js +23 -0
- package/dist/product.d.ts +1 -1
- package/dist/product.js +1 -1
- package/dist/s3/contract.d.ts +80 -0
- package/dist/s3/contract.js +3 -0
- package/dist/s3/drive.d.ts +108 -0
- package/dist/s3/drive.js +136 -0
- package/dist/s3/listing.d.ts +8 -1
- package/dist/s3/listing.js +8 -1
- package/dist/s3/routes.d.ts +4 -0
- package/dist/s3/routes.js +249 -0
- package/dist/s3/server.d.ts +15 -53
- package/dist/s3/server.js +18 -222
- package/dist/s3/sigv4.d.ts +26 -0
- package/dist/s3/sigv4.js +37 -0
- package/dist/s3/xml.d.ts +8 -1
- package/dist/s3/xml.js +13 -4
- package/dist/s3-gateway.d.ts +8 -0
- package/dist/s3-gateway.js +13 -0
- package/docs/commands/create.md +2 -2
- package/docs/commands/env.md +1 -1
- package/docs/commands/extend.md +0 -3
- package/docs/commands/login.md +1 -1
- package/docs/commands/logout.md +1 -1
- package/docs/commands/marks.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/put.md +2 -2
- package/docs/commands/wallet.md +7 -9
- package/docs/commands/whoami.md +2 -2
- package/package.json +9 -1
package/dist/s3/server.d.ts
CHANGED
|
@@ -1,58 +1,20 @@
|
|
|
1
|
-
import { type Server } from "node:http";
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
import { type DriveObject } from "./listing.ts";
|
|
6
|
-
import { type GatewayCredential } from "./sigv4.ts";
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import type { GatewayOptions } from "./contract.ts";
|
|
3
|
+
import type { GatewayCredential } from "./sigv4.ts";
|
|
4
|
+
export type { DriveSource, DriveWriter, GatewayOptions } from "./contract.ts";
|
|
7
5
|
/** Where the drive is served. Loopback, always — see the note above. */
|
|
8
6
|
export declare const BIND_ADDRESS = "127.0.0.1";
|
|
9
|
-
export interface DriveSource {
|
|
10
|
-
/** The account's live file list. Called per request; the caller decides what to cache. */
|
|
11
|
-
entries(): Promise<readonly ManifestEntry[]>;
|
|
12
|
-
/**
|
|
13
|
-
* Fetch, decrypt and deliver one file into the sink.
|
|
14
|
-
*
|
|
15
|
-
* ⛔ INJECTED RATHER THAN IMPORTED so this server can be driven by a real S3 client in a test
|
|
16
|
-
* without an account, a network and somebody's credits. A gateway whose only test is an
|
|
17
|
-
* end-to-end one is a gateway whose refusals are never tested at all.
|
|
18
|
-
*/
|
|
19
|
-
fetch(object: DriveObject, sink: PlaintextSink): Promise<void>;
|
|
20
|
-
/**
|
|
21
|
-
* How to change the drive, when this machine has agreed to spending.
|
|
22
|
-
*
|
|
23
|
-
* ⛔ ABSENT MEANS READ ONLY, AND THAT IS A REFUSAL RATHER THAN A GAP. Uploading spends credits,
|
|
24
|
-
* which is one of the three things this tool asks a person about once per machine, and a
|
|
25
|
-
* gateway cannot ask: its stdin is not a terminal and the caller is a program. So the
|
|
26
|
-
* agreement has to exist beforehand, and where it does not, every write says so.
|
|
27
|
-
*/
|
|
28
|
-
readonly write?: DriveWriter;
|
|
29
|
-
}
|
|
30
|
-
export interface DriveWriter {
|
|
31
|
-
/** Store `body` at this key. `size` is the byte count the client declared. */
|
|
32
|
-
put(key: string, body: Readable, size: number): Promise<void>;
|
|
33
|
-
/** Send one file to the trash, where it stays recoverable for thirty days. */
|
|
34
|
-
trash(object: DriveObject): Promise<void>;
|
|
35
|
-
/**
|
|
36
|
-
* Staging for uploads that arrive in pieces. Absent means this gateway refuses them.
|
|
37
|
-
*
|
|
38
|
-
* ⚠ Separate from `put` because the pieces have to land somewhere before they are one file, and
|
|
39
|
-
* where that is belongs to whoever is running this rather than to the protocol.
|
|
40
|
-
*/
|
|
41
|
-
readonly multipart?: {
|
|
42
|
-
begin(key: string): Promise<string>;
|
|
43
|
-
part(uploadId: string, partNumber: number, body: Readable, size: number, expectedSha256: string | null): Promise<string>;
|
|
44
|
-
complete(uploadId: string): Promise<string>;
|
|
45
|
-
abort(uploadId: string): Promise<void>;
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
export interface GatewayOptions {
|
|
49
|
-
readonly credential: GatewayCredential;
|
|
50
|
-
readonly source: DriveSource;
|
|
51
|
-
/** Called with one line whenever a request is answered, so a person can watch what a tool does. */
|
|
52
|
-
readonly log?: (line: string) => void;
|
|
53
|
-
/** Passed in so a test can hold the clock still. */
|
|
54
|
-
readonly now?: () => number;
|
|
55
|
-
}
|
|
56
7
|
/** A random pair, made fresh every time the gateway starts and stored nowhere. */
|
|
57
8
|
export declare function newCredential(): GatewayCredential;
|
|
9
|
+
/** A plain Node request handler, so this can be mounted in somebody else's server. */
|
|
10
|
+
export type GatewayHandler = (req: IncomingMessage, res: ServerResponse) => void;
|
|
11
|
+
/**
|
|
12
|
+
* The gateway as a handler, which is the form that listens to nothing.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ SEPARATE FROM `createGateway` BECAUSE WHO LISTENS IS NOT THIS FILE'S DECISION. The
|
|
15
|
+
* command-line tool binds loopback and says why at the top of this file; a business mounting
|
|
16
|
+
* this behind its own TLS has already made that decision, and a library that opened a socket of
|
|
17
|
+
* its own would be making it again, differently.
|
|
18
|
+
*/
|
|
19
|
+
export declare function gatewayHandler(options: GatewayOptions): GatewayHandler;
|
|
58
20
|
export declare function createGateway(options: GatewayOptions): Server;
|
package/dist/s3/server.js
CHANGED
|
@@ -6,18 +6,12 @@
|
|
|
6
6
|
// account -- and the key it checks was printed on somebody's terminal. This is the same call the
|
|
7
7
|
// rest of the system made on 2026-08-20 when every container port was pulled back to loopback.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// that never happened.
|
|
9
|
+
// ⚠ WHAT EACH REQUEST IS ANSWERED WITH IS IN `routes.ts`, and what a caller has to hand this in
|
|
10
|
+
// `contract.ts`. What is here is the socket and the pair: the two things that decide who can
|
|
11
|
+
// reach the drive at all.
|
|
13
12
|
import { createServer } from "node:http";
|
|
14
13
|
import { randomBytes } from "node:crypto";
|
|
15
|
-
import {
|
|
16
|
-
import { handleMultipart, isMultipartRequest } from "./multipart.js";
|
|
17
|
-
import { responseSink } from "./response-sink.js";
|
|
18
|
-
import { isKeyConflict } from "./same-file.js";
|
|
19
|
-
import { STREAMING_PAYLOAD, STREAMING_PAYLOAD_TRAILER, verifySignature, } from "./sigv4.js";
|
|
20
|
-
import { errorXml, listBucketsXml, listObjectsXml } from "./xml.js";
|
|
14
|
+
import { fail, handle } from "./routes.js";
|
|
21
15
|
/** Where the drive is served. Loopback, always — see the note above. */
|
|
22
16
|
export const BIND_ADDRESS = "127.0.0.1";
|
|
23
17
|
/** A random pair, made fresh every time the gateway starts and stored nowhere. */
|
|
@@ -29,217 +23,16 @@ export function newCredential() {
|
|
|
29
23
|
id += letters[byte % letters.length] ?? "A";
|
|
30
24
|
return { accessKeyId: id.slice(0, 20), secretAccessKey: randomBytes(30).toString("base64url") };
|
|
31
25
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return { bucket: decodeURIComponent(trimmed), key: "" };
|
|
43
|
-
return { bucket: decodeURIComponent(trimmed.slice(0, at)), key: decodeURIComponent(trimmed.slice(at + 1)) };
|
|
44
|
-
}
|
|
45
|
-
function headerOf(req, name) {
|
|
46
|
-
const raw = req.headers[name];
|
|
47
|
-
return Array.isArray(raw) ? raw.join(",") : raw;
|
|
48
|
-
}
|
|
49
|
-
/** The one sentence a write gets when this machine has not agreed to spending. */
|
|
50
|
-
function readOnlyBecause() {
|
|
51
|
-
return ("This gateway is read only. Uploading spends credits, and this machine has not agreed to " +
|
|
52
|
-
"spending — `nmts consent grant spend`, run by the person whose account this is, is what " +
|
|
53
|
-
"changes that. Nothing was written.");
|
|
54
|
-
}
|
|
55
|
-
function objectHeaders(object) {
|
|
56
|
-
return {
|
|
57
|
-
"content-type": "application/octet-stream",
|
|
58
|
-
"last-modified": new Date(object.entry.updatedAt).toUTCString(),
|
|
59
|
-
etag: object.etag,
|
|
60
|
-
"accept-ranges": "none",
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
async function handle(req, res, options) {
|
|
64
|
-
const url = req.url ?? "/";
|
|
65
|
-
const at = url.indexOf("?");
|
|
66
|
-
const pathname = at < 0 ? url : url.slice(0, at);
|
|
67
|
-
const query = new URLSearchParams(at < 0 ? "" : url.slice(at + 1));
|
|
68
|
-
const method = (req.method ?? "GET").toUpperCase();
|
|
69
|
-
const verdict = verifySignature({ method, url, headers: req.headers }, options.credential, options.now?.() ?? Date.now());
|
|
70
|
-
if (!verdict.ok) {
|
|
71
|
-
fail(res, verdict.code === "InvalidAccessKeyId" ? 403 : 403, verdict.code, verdict.message, pathname);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const { bucket, key } = splitPath(pathname);
|
|
75
|
-
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
|
76
|
-
const body = listBucketsXml(BUCKET, new Date(0).toISOString());
|
|
77
|
-
res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
|
|
78
|
-
res.end(method === "HEAD" ? undefined : body);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
if (bucket !== BUCKET) {
|
|
82
|
-
fail(res, 404, "NoSuchBucket", `This gateway serves one bucket, named ${BUCKET}.`, pathname);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
// ⛔ MEASURED, NOT GUESSED: rclone's first act when copying a file is to create the bucket, and a
|
|
86
|
-
// refusal here ends the copy before the upload is ever attempted. The bucket exists, so the
|
|
87
|
-
// honest answer to "make it" is that it is made.
|
|
88
|
-
if (key === "" && method === "PUT") {
|
|
89
|
-
res.writeHead(200, { "content-length": "0" });
|
|
90
|
-
res.end();
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
const entries = await options.source.entries();
|
|
94
|
-
if (key === "" && (method === "GET" || method === "HEAD")) {
|
|
95
|
-
const objects = objectsOf(entries);
|
|
96
|
-
const listing = listObjects(objects, folderPrefixesOf(entries), {
|
|
97
|
-
prefix: query.get("prefix") ?? "",
|
|
98
|
-
delimiter: query.get("delimiter") ?? "",
|
|
99
|
-
maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
|
|
100
|
-
after: query.get("continuation-token") ?? query.get("start-after") ?? query.get("marker"),
|
|
101
|
-
});
|
|
102
|
-
const body = listObjectsXml({
|
|
103
|
-
bucket: BUCKET,
|
|
104
|
-
prefix: query.get("prefix") ?? "",
|
|
105
|
-
delimiter: query.get("delimiter") ?? "",
|
|
106
|
-
maxKeys: Number(query.get("max-keys") ?? MAX_KEYS_LIMIT) || MAX_KEYS_LIMIT,
|
|
107
|
-
v2: query.get("list-type") === "2",
|
|
108
|
-
contents: listing.contents,
|
|
109
|
-
commonPrefixes: listing.commonPrefixes,
|
|
110
|
-
truncated: listing.truncated,
|
|
111
|
-
next: listing.next,
|
|
112
|
-
encodingType: query.get("encoding-type"),
|
|
113
|
-
});
|
|
114
|
-
res.writeHead(200, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
|
|
115
|
-
res.end(method === "HEAD" ? undefined : body);
|
|
116
|
-
options.log?.(`${method} list prefix=${query.get("prefix") ?? ""} → ${listing.contents.length} keys`);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (method === "HEAD" || method === "GET") {
|
|
120
|
-
const object = objectsOf(entries).find((o) => o.key === key);
|
|
121
|
-
if (object === undefined) {
|
|
122
|
-
fail(res, 404, "NoSuchKey", "This account's file list has no such file.", pathname);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
if (method === "HEAD") {
|
|
126
|
-
res.writeHead(200, { ...objectHeaders(object), "content-length": String(object.size) });
|
|
127
|
-
res.end();
|
|
128
|
-
options.log?.(`HEAD ${key}`);
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
if (object.entry.dekWrapped === undefined) {
|
|
132
|
-
fail(res, 500, "InternalError", "That entry has no key in the file list.", pathname);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const sink = responseSink(res, { headers: objectHeaders(object) });
|
|
136
|
-
try {
|
|
137
|
-
await options.source.fetch(object, sink);
|
|
138
|
-
options.log?.(`GET ${key} → ${object.size} bytes`);
|
|
139
|
-
}
|
|
140
|
-
catch (error) {
|
|
141
|
-
await sink.abandon();
|
|
142
|
-
if (!res.headersSent) {
|
|
143
|
-
fail(res, 502, "InternalError", error instanceof Error ? error.message : String(error), pathname);
|
|
144
|
-
}
|
|
145
|
-
options.log?.(`GET ${key} → failed`);
|
|
146
|
-
}
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
const writer = options.source.write;
|
|
150
|
-
// ⛔ WHETHER A TAKEN KEY IS A CONFLICT IS NOT DECIDED HERE.
|
|
151
|
-
// It used to be, on the strength of the NAME alone, and both upload paths carried their own
|
|
152
|
-
// copy of that check. The question is now about CONTENT — is the file arriving the file
|
|
153
|
-
// already there — and it cannot be answered until the bytes have arrived, so it is answered
|
|
154
|
-
// once, by the writer, at the point both paths meet. What reaches this layer is the verdict:
|
|
155
|
-
// a writer that returns normally means the key now holds these bytes (whether it had to send
|
|
156
|
-
// them or they were already there), and one that throws a conflict means something else is at
|
|
157
|
-
// that key. ⭐ The status matters: 409 is a request the drive declined, 500 is a fault of ours.
|
|
158
|
-
const refuseConflict = (error) => {
|
|
159
|
-
fail(res, 409, "InvalidRequest", error instanceof Error ? error.message : String(error), pathname);
|
|
160
|
-
};
|
|
161
|
-
if (isMultipartRequest(method, query) && key !== "") {
|
|
162
|
-
if (writer === undefined) {
|
|
163
|
-
fail(res, 501, "NotImplemented", readOnlyBecause(), pathname);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
const handled = await handleMultipart({
|
|
167
|
-
req,
|
|
168
|
-
res,
|
|
169
|
-
bucket: BUCKET,
|
|
170
|
-
key,
|
|
171
|
-
method,
|
|
172
|
-
query,
|
|
173
|
-
writer,
|
|
174
|
-
payloadHash: /^[0-9a-f]{64}$/.test(verdict.payloadHash) ? verdict.payloadHash : null,
|
|
175
|
-
fail: (status, code, message) => fail(res, status, code, message, pathname),
|
|
176
|
-
...(options.log === undefined ? {} : { log: options.log }),
|
|
177
|
-
});
|
|
178
|
-
if (handled)
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
if (method === "PUT" && key !== "") {
|
|
182
|
-
if (writer === undefined) {
|
|
183
|
-
fail(res, 501, "NotImplemented", readOnlyBecause(), pathname);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
const declared = headerOf(req, "x-amz-content-sha256");
|
|
187
|
-
if (declared === STREAMING_PAYLOAD || declared === STREAMING_PAYLOAD_TRAILER) {
|
|
188
|
-
fail(res, 501, "NotImplemented", "This gateway does not read chunk-signed uploads yet. Tell the client to send the body " +
|
|
189
|
-
"unsigned (the AWS CLI calls this --no-sign-payload on http endpoints; rclone already " +
|
|
190
|
-
"does it).", pathname);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
const length = Number(headerOf(req, "content-length") ?? "");
|
|
194
|
-
if (!Number.isInteger(length) || length < 0) {
|
|
195
|
-
fail(res, 411, "MissingContentLength", "This gateway needs to know the size before it starts.", pathname);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
await writer.put(key, req, length);
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
if (isKeyConflict(error)) {
|
|
203
|
-
refuseConflict(error);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
res.writeHead(200, { "content-length": "0" });
|
|
210
|
-
res.end();
|
|
211
|
-
options.log?.(`PUT ${key} → ${length} bytes`);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (method === "DELETE" && key !== "") {
|
|
215
|
-
if (writer === undefined) {
|
|
216
|
-
fail(res, 501, "NotImplemented", readOnlyBecause(), pathname);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
const object = objectsOf(entries).find((o) => o.key === key);
|
|
220
|
-
if (object === undefined) {
|
|
221
|
-
// S3 answers 204 for a key that is not there, and clients rely on it: a sync that deletes
|
|
222
|
-
// the same key twice must not fail the second time.
|
|
223
|
-
res.writeHead(204);
|
|
224
|
-
res.end();
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
try {
|
|
228
|
-
await writer.trash(object);
|
|
229
|
-
}
|
|
230
|
-
catch (error) {
|
|
231
|
-
fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), pathname);
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
res.writeHead(204);
|
|
235
|
-
res.end();
|
|
236
|
-
options.log?.(`DELETE ${key} → trash`);
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
fail(res, 501, "NotImplemented", `This gateway does not answer ${method} on that address.`, pathname);
|
|
240
|
-
}
|
|
241
|
-
export function createGateway(options) {
|
|
242
|
-
return createServer((req, res) => {
|
|
26
|
+
/**
|
|
27
|
+
* The gateway as a handler, which is the form that listens to nothing.
|
|
28
|
+
*
|
|
29
|
+
* ⛔ SEPARATE FROM `createGateway` BECAUSE WHO LISTENS IS NOT THIS FILE'S DECISION. The
|
|
30
|
+
* command-line tool binds loopback and says why at the top of this file; a business mounting
|
|
31
|
+
* this behind its own TLS has already made that decision, and a library that opened a socket of
|
|
32
|
+
* its own would be making it again, differently.
|
|
33
|
+
*/
|
|
34
|
+
export function gatewayHandler(options) {
|
|
35
|
+
return (req, res) => {
|
|
243
36
|
handle(req, res, options).catch((error) => {
|
|
244
37
|
if (!res.headersSent) {
|
|
245
38
|
fail(res, 500, "InternalError", error instanceof Error ? error.message : String(error), req.url ?? "/");
|
|
@@ -248,5 +41,8 @@ export function createGateway(options) {
|
|
|
248
41
|
res.destroy();
|
|
249
42
|
}
|
|
250
43
|
});
|
|
251
|
-
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function createGateway(options) {
|
|
47
|
+
return createServer(gatewayHandler(options));
|
|
252
48
|
}
|
package/dist/s3/sigv4.d.ts
CHANGED
|
@@ -13,6 +13,14 @@ export interface IncomingRequest {
|
|
|
13
13
|
export interface GatewayCredential {
|
|
14
14
|
readonly accessKeyId: string;
|
|
15
15
|
readonly secretAccessKey: string;
|
|
16
|
+
/**
|
|
17
|
+
* The only buckets this pair may touch. Absent means every bucket the gateway serves.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ IT IS WHAT KEEPS ONE CUSTOMER'S KEY OFF ANOTHER CUSTOMER'S BUCKET. A gateway in front of
|
|
20
|
+
* many accounts hands each caller its own pair, and without this every pair would open every
|
|
21
|
+
* account the resolver knows.
|
|
22
|
+
*/
|
|
23
|
+
readonly buckets?: readonly string[] | undefined;
|
|
16
24
|
}
|
|
17
25
|
export type Verified = {
|
|
18
26
|
readonly ok: true;
|
|
@@ -22,6 +30,16 @@ export type Verified = {
|
|
|
22
30
|
readonly code: string;
|
|
23
31
|
readonly message: string;
|
|
24
32
|
};
|
|
33
|
+
/** What `verifyAgainst` answers: the same verdict, plus which of the pairs signed. */
|
|
34
|
+
export type VerifiedAgainst = {
|
|
35
|
+
readonly ok: true;
|
|
36
|
+
readonly payloadHash: string;
|
|
37
|
+
readonly credential: GatewayCredential;
|
|
38
|
+
} | {
|
|
39
|
+
readonly ok: false;
|
|
40
|
+
readonly code: string;
|
|
41
|
+
readonly message: string;
|
|
42
|
+
};
|
|
25
43
|
/** `k=v&k2=v2` in the order AWS wants: encoded, sorted by key and then by value. */
|
|
26
44
|
export declare function canonicalQuery(rawQuery: string): string;
|
|
27
45
|
interface AuthorizationParts {
|
|
@@ -43,4 +61,12 @@ export declare function amzDateToMs(stamp: string | undefined): number | null;
|
|
|
43
61
|
* skew rule at all, and that rule is the one that stops a captured request being replayed tomorrow.
|
|
44
62
|
*/
|
|
45
63
|
export declare function verifySignature(request: IncomingRequest, credential: GatewayCredential, now: number): Verified;
|
|
64
|
+
/**
|
|
65
|
+
* The whole check, against every pair a gateway answers to: which one signed, and whether it did.
|
|
66
|
+
*
|
|
67
|
+
* ⚠ THE THREE REFUSALS ARE DIFFERENT ON PURPOSE. "No authorization header at all", "a key this
|
|
68
|
+
* gateway does not have" and "a signature that does not hold" are three different things for
|
|
69
|
+
* whoever is reading a client's logs, and none of them says anything about what is in the drive.
|
|
70
|
+
*/
|
|
71
|
+
export declare function verifyAgainst(request: IncomingRequest, credentials: readonly GatewayCredential[], now: number): VerifiedAgainst;
|
|
46
72
|
export {};
|
package/dist/s3/sigv4.js
CHANGED
|
@@ -21,6 +21,7 @@ export const MAX_CLOCK_SKEW_MS = 15 * 60 * 1000;
|
|
|
21
21
|
export const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
22
22
|
export const STREAMING_PAYLOAD = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD";
|
|
23
23
|
export const STREAMING_PAYLOAD_TRAILER = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER";
|
|
24
|
+
/** One refusal, shaped so it satisfies both verdict types — neither of which has an `ok: true`. */
|
|
24
25
|
function refuse(code, message) {
|
|
25
26
|
return { ok: false, code, message };
|
|
26
27
|
}
|
|
@@ -166,3 +167,39 @@ export function verifySignature(request, credential, now) {
|
|
|
166
167
|
}
|
|
167
168
|
return { ok: true, payloadHash };
|
|
168
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Which of a gateway's pairs the request named, without telling the clock how many there are.
|
|
172
|
+
*
|
|
173
|
+
* ⛔ EVERY PAIR IS COMPARED AND THE LOOP DOES NOT STOP EARLY. An access key id is not a secret --
|
|
174
|
+
* it travels in the header in the clear -- but a scan that returned at the first match would
|
|
175
|
+
* take a length of time that says WHERE in the list a key sits, and that is a fact about the
|
|
176
|
+
* gateway's customers rather than about the request.
|
|
177
|
+
*/
|
|
178
|
+
function named(credentials, accessKeyId) {
|
|
179
|
+
const wanted = Buffer.from(accessKeyId, "utf8");
|
|
180
|
+
let found = null;
|
|
181
|
+
for (const candidate of credentials) {
|
|
182
|
+
const id = Buffer.from(candidate.accessKeyId, "utf8");
|
|
183
|
+
if (id.length === wanted.length && timingSafeEqual(id, wanted))
|
|
184
|
+
found = candidate;
|
|
185
|
+
}
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The whole check, against every pair a gateway answers to: which one signed, and whether it did.
|
|
190
|
+
*
|
|
191
|
+
* ⚠ THE THREE REFUSALS ARE DIFFERENT ON PURPOSE. "No authorization header at all", "a key this
|
|
192
|
+
* gateway does not have" and "a signature that does not hold" are three different things for
|
|
193
|
+
* whoever is reading a client's logs, and none of them says anything about what is in the drive.
|
|
194
|
+
*/
|
|
195
|
+
export function verifyAgainst(request, credentials, now) {
|
|
196
|
+
const auth = parseAuthorization(headerValue(request.headers, "authorization"));
|
|
197
|
+
if (auth === null)
|
|
198
|
+
return refuse("AccessDenied", "no AWS Signature Version 4 authorization header");
|
|
199
|
+
const credential = named(credentials, auth.accessKeyId);
|
|
200
|
+
if (credential === null) {
|
|
201
|
+
return refuse("InvalidAccessKeyId", "that access key is not one this gateway answers to");
|
|
202
|
+
}
|
|
203
|
+
const verdict = verifySignature(request, credential, now);
|
|
204
|
+
return verdict.ok ? { ok: true, payloadHash: verdict.payloadHash, credential } : verdict;
|
|
205
|
+
}
|
package/dist/s3/xml.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/** The five characters XML cannot carry raw. */
|
|
2
2
|
export declare function escapeXml(value: string): string;
|
|
3
3
|
export declare function errorXml(code: string, message: string, resource: string): string;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The answer to `ListBuckets`.
|
|
6
|
+
*
|
|
7
|
+
* ⚠ AN EMPTY LIST IS A LEGAL ANSWER AND EVERY CLIENT HANDLES IT. A gateway in front of a
|
|
8
|
+
* business's own lookup cannot enumerate that business's customers, and naming none is the true
|
|
9
|
+
* answer there — the caller reaches its own bucket by asking for it by name.
|
|
10
|
+
*/
|
|
11
|
+
export declare function listBucketsXml(buckets: readonly string[], createdAt: string): string;
|
|
5
12
|
export declare function initiateUploadXml(bucket: string, key: string, uploadId: string): string;
|
|
6
13
|
export declare function completeUploadXml(bucket: string, key: string, etag: string): string;
|
|
7
14
|
export interface ObjectRow {
|
package/dist/s3/xml.js
CHANGED
|
@@ -23,11 +23,20 @@ export function errorXml(code, message, resource) {
|
|
|
23
23
|
return (`${HEAD}<Error><Code>${escapeXml(code)}</Code><Message>${escapeXml(message)}</Message>` +
|
|
24
24
|
`<Resource>${escapeXml(resource)}</Resource></Error>`);
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The answer to `ListBuckets`.
|
|
28
|
+
*
|
|
29
|
+
* ⚠ AN EMPTY LIST IS A LEGAL ANSWER AND EVERY CLIENT HANDLES IT. A gateway in front of a
|
|
30
|
+
* business's own lookup cannot enumerate that business's customers, and naming none is the true
|
|
31
|
+
* answer there — the caller reaches its own bucket by asking for it by name.
|
|
32
|
+
*/
|
|
33
|
+
export function listBucketsXml(buckets, createdAt) {
|
|
34
|
+
const rows = buckets
|
|
35
|
+
.map((bucket) => `<Bucket><Name>${escapeXml(bucket)}</Name>` +
|
|
36
|
+
`<CreationDate>${escapeXml(createdAt)}</CreationDate></Bucket>`)
|
|
37
|
+
.join("");
|
|
27
38
|
return (`${HEAD}<ListAllMyBucketsResult xmlns="${NS}"><Owner><ID>nmts</ID>` +
|
|
28
|
-
`<DisplayName>nmts</DisplayName></Owner><Buckets
|
|
29
|
-
`<Name>${escapeXml(bucket)}</Name><CreationDate>${escapeXml(createdAt)}</CreationDate>` +
|
|
30
|
-
`</Bucket></Buckets></ListAllMyBucketsResult>`);
|
|
39
|
+
`<DisplayName>nmts</DisplayName></Owner><Buckets>${rows}</Buckets></ListAllMyBucketsResult>`);
|
|
31
40
|
}
|
|
32
41
|
export function initiateUploadXml(bucket, key, uploadId) {
|
|
33
42
|
return (`${HEAD}<InitiateMultipartUploadResult xmlns="${NS}">` +
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createGateway, gatewayHandler } from "./s3/server.ts";
|
|
2
|
+
export type { DriveSource, DriveWriter, GatewayHandler, GatewayOptions } from "./s3/server.ts";
|
|
3
|
+
export { createDriveSource, fetchObject, placeOf, LIST_CACHE_MS } from "./s3/drive.ts";
|
|
4
|
+
export type { DriveAccount, DriveSourceOptions, ObjectReader } from "./s3/drive.ts";
|
|
5
|
+
export { createStaging } from "./s3/staging.ts";
|
|
6
|
+
export type { Staging, StoreFile } from "./s3/staging.ts";
|
|
7
|
+
export type { GatewayCredential } from "./s3/sigv4.ts";
|
|
8
|
+
export type { DriveObject } from "./s3/listing.ts";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The S3 gateway as a library: the server `nmts s3` runs, for somebody else's infrastructure.
|
|
2
|
+
//
|
|
3
|
+
// ⛔ ONE IMPLEMENTATION, AND THIS IS THE DOOR TO IT. A business putting NMTS behind its own storage
|
|
4
|
+
// adapters needs the protocol this package already speaks, in its own process, in front of
|
|
5
|
+
// whichever of its customers' accounts a request names. A copy of the server in the SDK would
|
|
6
|
+
// be a second place for a signature check, a listing and a multipart upload to be got right,
|
|
7
|
+
// and the copy nobody re-reads is the one that quietly disagrees.
|
|
8
|
+
//
|
|
9
|
+
// ⚠ WHAT IS NOT HERE: the bucket name `nmts s3` uses, the address it binds, and the pair it prints
|
|
10
|
+
// when it starts. Those are that command's answers to questions a business answers for itself.
|
|
11
|
+
export { createGateway, gatewayHandler } from "./s3/server.js";
|
|
12
|
+
export { createDriveSource, fetchObject, placeOf, LIST_CACHE_MS } from "./s3/drive.js";
|
|
13
|
+
export { createStaging } from "./s3/staging.js";
|
package/docs/commands/create.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Commands: create
|
|
4
4
|
Tiers: create=high
|
|
5
5
|
|
|
6
|
-
Signs in with one account's key and creates another, printing the new
|
|
7
|
-
print it again, because the server stores a one-way verifier and never the NMTS key. This is how a
|
|
6
|
+
Signs in with one account's key and creates another, printing the new NMTS key once — nothing
|
|
7
|
+
can print it again, because the server stores a one-way verifier and never the NMTS key. This is how a
|
|
8
8
|
service that keeps its customers' files in NMTS gives each customer a drive; the first account of
|
|
9
9
|
all has to be made in a browser. It needs a key with `files:write` and a live human check behind
|
|
10
10
|
it, and the server allows two a day and five a week per key.
|
package/docs/commands/env.md
CHANGED
|
@@ -8,7 +8,7 @@ gives the same thing to parse. It reports the operating system; whether this is
|
|
|
8
8
|
container and whether root here is root on the host; whether a file written here can be kept
|
|
9
9
|
private (measured, not guessed); whether there is a terminal and whether a browser could be
|
|
10
10
|
opened; whether an NMTS key and an API key were found and where each came from; if the stored
|
|
11
|
-
|
|
11
|
+
NMTS key is sealed, whether a passphrase is actually reachable; which agent left a marker here; and
|
|
12
12
|
what the version check last found. The `advice` it returns is written to be repeated to the person
|
|
13
13
|
as-is — do that when something in it is a `warn`.
|
|
14
14
|
|
package/docs/commands/extend.md
CHANGED
|
@@ -15,9 +15,6 @@ nmts extend notes/report.pdf --epochs 4 # how many epochs to add (default 2)
|
|
|
15
15
|
If the account holds a standing share (`nmts tip`), that share of the WAL just paid goes to the
|
|
16
16
|
developer right after the extension, without a question.
|
|
17
17
|
|
|
18
|
-
```sh
|
|
19
|
-
```
|
|
20
|
-
|
|
21
18
|
Both forms print the price in WAL, the chain fee in SUI measured by a dry run of the exact
|
|
22
19
|
transaction (`null` in `--json` when it could not be measured, never 0), and what the wallet
|
|
23
20
|
holds. A wallet short of either exits 4 with the two numbers before anything else; a balance the
|
package/docs/commands/login.md
CHANGED
|
@@ -7,7 +7,7 @@ Tiers: login=none · login.plain=high(unsafe-code-storage) · login.env=high(pla
|
|
|
7
7
|
(`NMTS_API_KEY_FILE`, `NMTS_API_KEY`) with the server, and stores that too. It prints the key's
|
|
8
8
|
public handle and never the key. It does not replace a stored key unless the run says so.
|
|
9
9
|
|
|
10
|
-
Every later command needs the passphrase, from `NMTS_PASSPHRASE` or a terminal. A sealed
|
|
10
|
+
Every later command needs the passphrase, from `NMTS_PASSPHRASE` or a terminal. A sealed key with
|
|
11
11
|
no passphrase in reach is not a usable credential; `nmts env` says which case this machine is in.
|
|
12
12
|
|
|
13
13
|
Two other shapes exist and both are locked until a person opens them once, at a terminal:
|
package/docs/commands/logout.md
CHANGED
|
@@ -5,7 +5,7 @@ Tiers: logout=none
|
|
|
5
5
|
|
|
6
6
|
Removes the NMTS key and API key this tool stored on this machine. Nothing on the server
|
|
7
7
|
changes: the key stays valid until revoked (`nmts key revoke`, or the account screen), and the
|
|
8
|
-
account is untouched.
|
|
8
|
+
account is untouched. An NMTS key that came from an environment variable or a file is not touched
|
|
9
9
|
either — this only forgets what `nmts login` wrote.
|
|
10
10
|
|
|
11
11
|
Run it when a machine is handed over or a task is finished on a machine the person does not keep.
|
package/docs/commands/marks.md
CHANGED
|
@@ -5,5 +5,5 @@ Tiers: star=none · unstar=none · pin=none · unpin=none · label=none · unlab
|
|
|
5
5
|
|
|
6
6
|
Free and reversible; nothing here asks. `star <files>` gathers files in favourites, `pin <files>`
|
|
7
7
|
holds them at the top of their folder, and `label <name> <files>` attaches a word you choose; the
|
|
8
|
-
`un-` forms take each off. A label exists while a file
|
|
8
|
+
`un-` forms take each off. A label exists while a file has it. `label --rename <old> <new>`
|
|
9
9
|
renames a label on every file that carries it; `unlabel <name> --all` takes it off all of them.
|
package/docs/commands/mcp.md
CHANGED
|
@@ -28,6 +28,6 @@ codex mcp add nmts -- nmts mcp --out /where/files/should/land
|
|
|
28
28
|
opencode mcp add nmts -- nmts mcp --out /where/files/should/land
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
A sealed stored
|
|
31
|
+
A sealed stored NMTS key is opened once, at startup; `nmts mcp` never prompts, so a sealed key with
|
|
32
32
|
no `NMTS_PASSPHRASE` exits 3 at startup rather than hang. Arguments are checked against what each
|
|
33
33
|
tool declares — `"dry_run": "true"` is a refusal, not an upload.
|
package/docs/commands/put.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Commands: put
|
|
4
4
|
Tiers: put=medium · put.wallet=high(wallet)
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
`put` spends credits: one per started mebibyte, and the price is printed before the upload
|
|
7
|
+
starts. In the default mode it asks first (answer at the terminal, or run with `--yes`);
|
|
8
8
|
in an auto mode it runs when you judge it is what the person wants.
|
|
9
9
|
|
|
10
10
|
```sh
|
package/docs/commands/wallet.md
CHANGED
|
@@ -74,12 +74,10 @@ Three coin subcommands sign, and every signed transaction is irreversible by any
|
|
|
74
74
|
explorer. It is outside the wallet unlock and its ceiling. A standing share of every WAL
|
|
75
75
|
payment is `nmts tip`, not a gift on its own.
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
address. Reading which wallet that is opens the file list, so naming yourself needs an API key
|
|
85
|
-
where reading the hall needs none.
|
|
77
|
+
`wallet hall` prints the gift hall of fame — the developer, then the ten largest senders as read
|
|
78
|
+
from the public chain, with the rest of the list at nmts.me/hall; reading it signs nothing and
|
|
79
|
+
needs no NMTS key. `--name <name>` (1 to 24 characters, no links, not an address) signs a
|
|
80
|
+
short message with the wallet this account pays from — the wallet a gift left from — so the
|
|
81
|
+
server shows that name beside its address, and `--remove` puts the entry back to a shortened
|
|
82
|
+
address. Reading which wallet that is opens the file list, so naming yourself needs an API key
|
|
83
|
+
where reading the hall needs none.
|
package/docs/commands/whoami.md
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
Commands: whoami
|
|
4
4
|
Tiers: whoami=none · whoami.reveal=high(reveal)
|
|
5
5
|
|
|
6
|
-
Prints the account id the stored
|
|
6
|
+
Prints the account id the stored NMTS key belongs to, offline, with no server call. `--json` gives it
|
|
7
7
|
to a program.
|
|
8
8
|
|
|
9
9
|
`--reveal` prints the NMTS key itself. An agent never needs this — the tool already holds the
|
|
10
|
-
|
|
10
|
+
key — so it is locked until a person runs `nmts unlock reveal` at a terminal, and then asked about
|
|
11
11
|
on every run in every mode but skip-permissions. Never write the NMTS key anywhere it can be read
|
|
12
12
|
again: a log, a commit, a file, a message. It is the only key to the account and cannot be rotated.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@needmoretruth/nmts-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.1",
|
|
4
4
|
"description": "Command-line and MCP access to NMTS (NeedMoreTruthStorage, https://nmts.me): open-source, end-to-end encrypted storage on the Walrus network. No fee to NMTS; you pay the network from your own wallet. For people and for their agents.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -77,6 +77,14 @@
|
|
|
77
77
|
"types": "./dist/drive-paths.d.ts",
|
|
78
78
|
"default": "./dist/drive-paths.js"
|
|
79
79
|
},
|
|
80
|
+
"./drive-edit": {
|
|
81
|
+
"types": "./dist/drive-edit.d.ts",
|
|
82
|
+
"default": "./dist/drive-edit.js"
|
|
83
|
+
},
|
|
84
|
+
"./s3-gateway": {
|
|
85
|
+
"types": "./dist/s3-gateway.d.ts",
|
|
86
|
+
"default": "./dist/s3-gateway.js"
|
|
87
|
+
},
|
|
80
88
|
"./upload-file": {
|
|
81
89
|
"types": "./dist/upload-file.d.ts",
|
|
82
90
|
"default": "./dist/upload-file.js"
|