@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.
Files changed (54) hide show
  1. package/AGENTS.md +22 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/dist/artifact-about.d.ts +1 -1
  6. package/dist/commands/erase.js +1 -1
  7. package/dist/commands/organise.d.ts +5 -27
  8. package/dist/commands/organise.js +33 -193
  9. package/dist/commands/push.js +1 -1
  10. package/dist/commands/s3.d.ts +0 -10
  11. package/dist/commands/s3.js +46 -121
  12. package/dist/commands/trash.d.ts +0 -9
  13. package/dist/commands/trash.js +23 -223
  14. package/dist/drive-edit/errors.d.ts +44 -0
  15. package/dist/drive-edit/errors.js +65 -0
  16. package/dist/drive-edit/folders.d.ts +29 -0
  17. package/dist/drive-edit/folders.js +98 -0
  18. package/dist/drive-edit/move.d.ts +66 -0
  19. package/dist/drive-edit/move.js +119 -0
  20. package/dist/drive-edit/trash.d.ts +53 -0
  21. package/dist/drive-edit/trash.js +190 -0
  22. package/dist/drive-edit/tree.d.ts +15 -0
  23. package/dist/drive-edit/tree.js +77 -0
  24. package/dist/drive-edit.d.ts +9 -0
  25. package/dist/drive-edit.js +23 -0
  26. package/dist/product.d.ts +1 -1
  27. package/dist/product.js +1 -1
  28. package/dist/s3/contract.d.ts +80 -0
  29. package/dist/s3/contract.js +3 -0
  30. package/dist/s3/drive.d.ts +108 -0
  31. package/dist/s3/drive.js +136 -0
  32. package/dist/s3/listing.d.ts +8 -1
  33. package/dist/s3/listing.js +8 -1
  34. package/dist/s3/routes.d.ts +4 -0
  35. package/dist/s3/routes.js +249 -0
  36. package/dist/s3/server.d.ts +15 -53
  37. package/dist/s3/server.js +18 -222
  38. package/dist/s3/sigv4.d.ts +26 -0
  39. package/dist/s3/sigv4.js +37 -0
  40. package/dist/s3/xml.d.ts +8 -1
  41. package/dist/s3/xml.js +13 -4
  42. package/dist/s3-gateway.d.ts +8 -0
  43. package/dist/s3-gateway.js +13 -0
  44. package/docs/commands/create.md +2 -2
  45. package/docs/commands/env.md +1 -1
  46. package/docs/commands/extend.md +0 -3
  47. package/docs/commands/login.md +1 -1
  48. package/docs/commands/logout.md +1 -1
  49. package/docs/commands/marks.md +1 -1
  50. package/docs/commands/mcp.md +1 -1
  51. package/docs/commands/put.md +2 -2
  52. package/docs/commands/wallet.md +7 -9
  53. package/docs/commands/whoami.md +2 -2
  54. package/package.json +9 -1
@@ -1,58 +1,20 @@
1
- import { type Server } from "node:http";
2
- import type { Readable } from "node:stream";
3
- import type { PlaintextSink } from "../download-sink.ts";
4
- import type { ManifestEntry } from "../shared/lib/drive/manifest-codec.ts";
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
- // WHAT IS NOT ANSWERED IS REFUSED, LOUDLY. An S3 client that asks for something this gateway does
10
- // not do gets 501 and a sentence naming what it does do. The alternative -- answering an empty
11
- // listing, or a 200 with nothing behind it -- is how a backup tool reports success over a backup
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 { BUCKET, listObjects, objectsOf, folderPrefixesOf, MAX_KEYS_LIMIT } from "./listing.js";
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
- function fail(res, status, code, message, resource) {
33
- const body = errorXml(code, message, resource);
34
- res.writeHead(status, { "content-type": "application/xml", "content-length": String(Buffer.byteLength(body)) });
35
- res.end(body);
36
- }
37
- /** `/drive/photos/a.jpg` bucket `drive`, key `photos/a.jpg`. */
38
- function splitPath(pathname) {
39
- const trimmed = pathname.replace(/^\//, "");
40
- const at = trimmed.indexOf("/");
41
- if (at < 0)
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
  }
@@ -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
- export declare function listBucketsXml(bucket: string, createdAt: string): string;
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
- export function listBucketsXml(bucket, createdAt) {
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><Bucket>` +
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";
@@ -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 code once — nothing can
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.
@@ -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
- code is sealed, whether a passphrase is actually reachable; which agent left a marker here; and
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
 
@@ -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
@@ -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 code with
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:
@@ -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. A code that came from an environment variable or a file is not touched
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.
@@ -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 wears it. `label --rename <old> <new>`
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.
@@ -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 code is opened once, at startup; `nmts mcp` never prompts, so a sealed code with
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.
@@ -3,8 +3,8 @@
3
3
  Commands: put
4
4
  Tiers: put=medium · put.wallet=high(wallet)
5
5
 
6
- This is the command that spends. The price is one credit per started mebibyte, printed before the
7
- upload starts. In the default mode it asks first (answer at the terminal, or run with `--yes`);
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
@@ -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
- One more reads the gifts back, and one flag on it publishes a name:
78
-
79
- - `wallet hall` prints the gift hall of fame the developer, then the ten largest senders as read
80
- from the public chain, with the rest of the list at nmts.me/hall; reading it signs nothing and
81
- needs no NMTS key. `--name <name>` (1 to 24 characters, no links, not an address) signs a
82
- short message with the wallet this account pays from the wallet a gift left from so the
83
- server shows that name beside its address, and `--remove` puts the entry back to a shortened
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.
@@ -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 code belongs to, offline, with no server call. `--json` gives it
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
- code — so it is locked until a person runs `nmts unlock reveal` at a terminal, and then asked about
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.36.3",
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"