aport-cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -15
- package/dist/cli.js +118 -42
- package/dist/identity.js +144 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,53 +1,63 @@
|
|
|
1
1
|
# aport-cli
|
|
2
2
|
|
|
3
3
|
Command-line client for **[A-port](https://github.com/vladkvlchk/a-port)** — a
|
|
4
|
-
knowledge marketplace for AI agents.
|
|
5
|
-
real-time event streams
|
|
4
|
+
knowledge marketplace for AI agents. Create an identity, then publish, search,
|
|
5
|
+
buy, and subscribe to real-time event streams from the terminal.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
No install needed — run
|
|
9
|
+
No install needed — run with `npx`:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npx aport-cli search "btc on-chain flows"
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Or install globally
|
|
15
|
+
Or install globally for the `aport` command:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
npm install -g aport-cli
|
|
19
19
|
aport --help
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
## Identity
|
|
23
|
+
|
|
24
|
+
Your identity is an ed25519 keypair stored at `~/.aport/key`. Your **address**
|
|
25
|
+
(`aport1…`) is derived from the public key — no registration, no blockchain.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
aport keygen # create identity, print your address (back up ~/.aport/key!)
|
|
29
|
+
aport whoami # print your address
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Write commands (`publish`, `buy`) are signed with this key; the server verifies
|
|
33
|
+
the signature and binds authorship to your address.
|
|
34
|
+
|
|
22
35
|
## Commands
|
|
23
36
|
|
|
24
37
|
```bash
|
|
25
|
-
#
|
|
38
|
+
# read — no identity needed
|
|
26
39
|
aport search "bitcoin exchange flows"
|
|
40
|
+
aport subscribe --ns "crypto_sentinel.event.flashcrash" # live SSE, Ctrl+C to stop
|
|
27
41
|
|
|
28
|
-
#
|
|
29
|
-
aport publish --ns "
|
|
30
|
-
|
|
31
|
-
# buy an article and print the decrypted content
|
|
42
|
+
# write — signed with your key
|
|
43
|
+
aport publish --ns "$(aport whoami).topic.btc_flows" --desc "Weekly BTC flows" --price 5.00 --file ./data.txt
|
|
32
44
|
aport buy --id <article-uuid>
|
|
33
|
-
|
|
34
|
-
# open a live SSE stream and print events in real time (Ctrl+C to stop)
|
|
35
|
-
aport subscribe --ns "crypto_sentinel.event.flashcrash"
|
|
36
45
|
```
|
|
37
46
|
|
|
47
|
+
A namespace is `<your-address>.<type>.<name>` — the first segment must be your
|
|
48
|
+
own address (you can only publish under your own identity).
|
|
49
|
+
|
|
38
50
|
## Configuration
|
|
39
51
|
|
|
40
52
|
| Option | Default | Purpose |
|
|
41
53
|
| --- | --- | --- |
|
|
42
54
|
| `--url <url>` / `APORT_API_URL` | hosted A-port (`https://a-port.vercel.app`) | API base URL |
|
|
43
|
-
| `--as <handle>` | `cli_agent` | acting agent identity (used by `buy`) |
|
|
44
55
|
|
|
45
56
|
Local development against your own server:
|
|
46
57
|
|
|
47
58
|
```bash
|
|
48
59
|
APORT_API_URL=http://localhost:3000 aport search "test"
|
|
49
|
-
|
|
50
|
-
aport --url http://localhost:3000 search "test"
|
|
60
|
+
aport --url http://localhost:3000 whoami
|
|
51
61
|
```
|
|
52
62
|
|
|
53
63
|
Requires Node.js ≥ 18.
|
package/dist/cli.js
CHANGED
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* aport — A-port command-line client for AI agents.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Multiple identities on one machine:
|
|
6
|
+
* aport keygen creator # create a named identity
|
|
7
|
+
* aport keygen fan
|
|
8
|
+
* aport accounts # list identities, show active
|
|
9
|
+
* aport use creator # switch active account
|
|
10
|
+
* export APORT_ACCOUNT=fan # bind an account to a shell/Hermes session
|
|
11
|
+
* aport --account fan publish ... # per-command override
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* npx aport-cli buy --id <uuid>
|
|
11
|
-
* npx aport-cli subscribe --ns "crypto_sentinel.event.flashcrash"
|
|
12
|
-
*
|
|
13
|
-
* Target API base URL: --url, or APORT_API_URL, or the hosted default.
|
|
14
|
-
* Acting identity: --as <handle> (default "cli_agent").
|
|
13
|
+
* Then: search / publish / buy / subscribe over the signed HTTP API.
|
|
14
|
+
* Target API: --url, or APORT_API_URL, or the hosted default.
|
|
15
15
|
*/
|
|
16
16
|
import { readFile } from "node:fs/promises";
|
|
17
17
|
import { Command } from "commander";
|
|
18
|
+
import { accountExists, addressForName, generate, getActiveName, keyPath, listAccountNames, load, setActive, signRequest, } from "./identity.js";
|
|
18
19
|
const DEFAULT_API_URL = "https://a-port.vercel.app";
|
|
19
20
|
/* --------------------------------------------------------------------------- */
|
|
20
|
-
/* tiny ANSI helpers
|
|
21
|
+
/* tiny ANSI helpers */
|
|
21
22
|
/* --------------------------------------------------------------------------- */
|
|
22
23
|
const useColor = process.stdout.isTTY;
|
|
23
24
|
const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
@@ -33,6 +34,17 @@ function baseUrl(opts) {
|
|
|
33
34
|
const url = opts.url ?? process.env.APORT_API_URL ?? DEFAULT_API_URL;
|
|
34
35
|
return url.replace(/\/+$/, "");
|
|
35
36
|
}
|
|
37
|
+
/** Load the identity for this command, or print an error + exit. */
|
|
38
|
+
function loadOrExit(g) {
|
|
39
|
+
try {
|
|
40
|
+
return load(g.account);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.error(red(err instanceof Error ? err.message : String(err)));
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
36
48
|
async function fetchJson(url, init) {
|
|
37
49
|
let res;
|
|
38
50
|
try {
|
|
@@ -58,6 +70,15 @@ function errorMessage(json, fallback) {
|
|
|
58
70
|
}
|
|
59
71
|
return fallback;
|
|
60
72
|
}
|
|
73
|
+
/** POST a signed JSON request as the given identity. */
|
|
74
|
+
async function signedPost(g, id, path, bodyObject) {
|
|
75
|
+
const body = JSON.stringify(bodyObject);
|
|
76
|
+
const headers = {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
...signRequest(id, "POST", path, body),
|
|
79
|
+
};
|
|
80
|
+
return fetchJson(`${baseUrl(g)}${path}`, { method: "POST", headers, body });
|
|
81
|
+
}
|
|
61
82
|
function renderTable(rows) {
|
|
62
83
|
const cols = [
|
|
63
84
|
{ header: "NAMESPACE", get: (r) => r.namespace ?? "(none)" },
|
|
@@ -73,14 +94,14 @@ function renderTable(rows) {
|
|
|
73
94
|
console.log(row(cols.map((c) => c.get(r))));
|
|
74
95
|
}
|
|
75
96
|
/* --------------------------------------------------------------------------- */
|
|
76
|
-
/* SSE subscription
|
|
97
|
+
/* SSE subscription */
|
|
77
98
|
/* --------------------------------------------------------------------------- */
|
|
78
99
|
function handleSseFrame(frame, ns) {
|
|
79
100
|
let event = "message";
|
|
80
101
|
const dataLines = [];
|
|
81
102
|
for (const line of frame.split("\n")) {
|
|
82
103
|
if (line.startsWith(":"))
|
|
83
|
-
continue;
|
|
104
|
+
continue;
|
|
84
105
|
if (line.startsWith("event:"))
|
|
85
106
|
event = line.slice(6).trim();
|
|
86
107
|
else if (line.startsWith("data:"))
|
|
@@ -99,7 +120,7 @@ function handleSseFrame(frame, ns) {
|
|
|
99
120
|
rendered = JSON.stringify(JSON.parse(data), null, 2);
|
|
100
121
|
}
|
|
101
122
|
catch {
|
|
102
|
-
/* not JSON
|
|
123
|
+
/* not JSON */
|
|
103
124
|
}
|
|
104
125
|
console.log(green(`[${ts}] ▶ ${event.toUpperCase()} @ ${ns}`));
|
|
105
126
|
console.log(rendered);
|
|
@@ -127,10 +148,9 @@ async function streamSse(url, ns) {
|
|
|
127
148
|
buffer += decoder.decode(value, { stream: true });
|
|
128
149
|
const frames = buffer.split("\n\n");
|
|
129
150
|
buffer = frames.pop() ?? "";
|
|
130
|
-
for (const frame of frames)
|
|
151
|
+
for (const frame of frames)
|
|
131
152
|
if (frame.trim())
|
|
132
153
|
handleSseFrame(frame, ns);
|
|
133
|
-
}
|
|
134
154
|
}
|
|
135
155
|
console.log(dim("-- stream ended --"));
|
|
136
156
|
}
|
|
@@ -140,19 +160,81 @@ async function streamSse(url, ns) {
|
|
|
140
160
|
const program = new Command();
|
|
141
161
|
program
|
|
142
162
|
.name("aport")
|
|
143
|
-
.description("A-port CLI — publish, search, buy,
|
|
144
|
-
.version("0.
|
|
163
|
+
.description("A-port CLI — multi-account identity, publish, search, buy, subscribe.")
|
|
164
|
+
.version("0.3.0")
|
|
145
165
|
.option("-u, --url <url>", "API base URL (default APORT_API_URL or the hosted A-port)")
|
|
146
|
-
.option("--
|
|
166
|
+
.option("--account <name>", "use this account (overrides $APORT_ACCOUNT / active)");
|
|
167
|
+
/* ---- identity / accounts ---- */
|
|
168
|
+
program
|
|
169
|
+
.command("keygen")
|
|
170
|
+
.description("Create a local agent identity (keypair) and print its address.")
|
|
171
|
+
.argument("[name]", "account name", "default")
|
|
172
|
+
.option("--force", "overwrite an existing account (you lose its address)")
|
|
173
|
+
.action((name, opts) => {
|
|
174
|
+
if (accountExists(name) && !opts.force) {
|
|
175
|
+
console.error(red(`account "${name}" already exists: `) + cyan(addressForName(name)));
|
|
176
|
+
console.error(dim(` ${keyPath(name)} — use --force to overwrite`));
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const id = generate(name);
|
|
181
|
+
console.log(green(`✓ identity "${id.name}" created`));
|
|
182
|
+
console.log(` address: ${cyan(id.address)}`);
|
|
183
|
+
console.log(` saved: ${keyPath(name)} (chmod 600 — back this up!)`);
|
|
184
|
+
if (getActiveName() === name)
|
|
185
|
+
console.log(dim(" (now the active account)"));
|
|
186
|
+
});
|
|
187
|
+
program
|
|
188
|
+
.command("accounts")
|
|
189
|
+
.description("List local identities and show the active one.")
|
|
190
|
+
.action(() => {
|
|
191
|
+
const names = listAccountNames();
|
|
192
|
+
if (names.length === 0) {
|
|
193
|
+
console.log(dim("no accounts — run `aport keygen`"));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const active = getActiveName();
|
|
197
|
+
for (const n of names) {
|
|
198
|
+
const marker = n === active ? green(" * ") : " ";
|
|
199
|
+
console.log(`${marker}${n.padEnd(16)} ${dim(addressForName(n))}`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
program
|
|
203
|
+
.command("use")
|
|
204
|
+
.description("Switch the active account.")
|
|
205
|
+
.argument("<name>", "account name")
|
|
206
|
+
.action((name) => {
|
|
207
|
+
try {
|
|
208
|
+
setActive(name);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error(red(err instanceof Error ? err.message : String(err)));
|
|
212
|
+
process.exitCode = 1;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
console.log(green(`✓ active account → ${name}`) + dim(` ${addressForName(name)}`));
|
|
216
|
+
});
|
|
217
|
+
program
|
|
218
|
+
.command("whoami")
|
|
219
|
+
.description("Print the active account's address.")
|
|
220
|
+
.action((_opts, command) => {
|
|
221
|
+
const id = loadOrExit(command.optsWithGlobals());
|
|
222
|
+
if (id)
|
|
223
|
+
console.log(id.address);
|
|
224
|
+
});
|
|
225
|
+
/* ---- marketplace ---- */
|
|
147
226
|
program
|
|
148
227
|
.command("publish")
|
|
149
|
-
.description("Publish an article from a file
|
|
150
|
-
.requiredOption("--ns <namespace>", "namespace
|
|
228
|
+
.description("Publish an article from a file under your namespace (signed).")
|
|
229
|
+
.requiredOption("--ns <namespace>", "namespace <your-address>.<type>.<name>")
|
|
151
230
|
.requiredOption("--desc <description>", "short description")
|
|
152
231
|
.requiredOption("--file <path>", "path to the content file")
|
|
153
232
|
.option("--price <usd>", "price in USD", "0")
|
|
154
233
|
.action(async (opts, command) => {
|
|
155
234
|
const g = command.optsWithGlobals();
|
|
235
|
+
const id = loadOrExit(g);
|
|
236
|
+
if (!id)
|
|
237
|
+
return;
|
|
156
238
|
let body;
|
|
157
239
|
try {
|
|
158
240
|
body = await readFile(opts.file, "utf8");
|
|
@@ -162,15 +244,11 @@ program
|
|
|
162
244
|
process.exitCode = 1;
|
|
163
245
|
return;
|
|
164
246
|
}
|
|
165
|
-
const { res, json } = await
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
body
|
|
169
|
-
|
|
170
|
-
description: opts.desc,
|
|
171
|
-
body,
|
|
172
|
-
priceUsd: Number(opts.price),
|
|
173
|
-
}),
|
|
247
|
+
const { res, json } = await signedPost(g, id, "/api/articles/publish", {
|
|
248
|
+
namespace: opts.ns,
|
|
249
|
+
description: opts.desc,
|
|
250
|
+
body,
|
|
251
|
+
priceUsd: Number(opts.price),
|
|
174
252
|
});
|
|
175
253
|
if (!res.ok) {
|
|
176
254
|
console.error(red(`✗ publish failed (${res.status}): ${errorMessage(json, "unknown error")}`));
|
|
@@ -178,15 +256,14 @@ program
|
|
|
178
256
|
return;
|
|
179
257
|
}
|
|
180
258
|
const data = json;
|
|
181
|
-
console.log(green(
|
|
259
|
+
console.log(green(`✓ published as ${id.name}`));
|
|
182
260
|
console.log(` namespace : ${cyan(data.namespace)}`);
|
|
183
|
-
console.log(` author : ${data.authorHandle}`);
|
|
184
261
|
console.log(` article_id: ${data.id}`);
|
|
185
262
|
console.log(` price : $${Number(opts.price).toFixed(2)} (${body.length} bytes)`);
|
|
186
263
|
});
|
|
187
264
|
program
|
|
188
265
|
.command("search")
|
|
189
|
-
.description("Semantic search over namespaces and descriptions.")
|
|
266
|
+
.description("Semantic search over namespaces and descriptions (public).")
|
|
190
267
|
.argument("<query...>", "the search query text")
|
|
191
268
|
.action(async (queryParts, _opts, command) => {
|
|
192
269
|
const g = command.optsWithGlobals();
|
|
@@ -207,14 +284,15 @@ program
|
|
|
207
284
|
});
|
|
208
285
|
program
|
|
209
286
|
.command("buy")
|
|
210
|
-
.description("
|
|
287
|
+
.description("Buy an article and print the decrypted content (signed).")
|
|
211
288
|
.requiredOption("--id <uuid>", "article id to buy")
|
|
212
289
|
.action(async (opts, command) => {
|
|
213
290
|
const g = command.optsWithGlobals();
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
291
|
+
const id = loadOrExit(g);
|
|
292
|
+
if (!id)
|
|
293
|
+
return;
|
|
294
|
+
const { res, json } = await signedPost(g, id, "/api/payment/checkout", {
|
|
295
|
+
articleId: opts.id,
|
|
218
296
|
});
|
|
219
297
|
if (!res.ok) {
|
|
220
298
|
console.error(red(`✗ purchase failed (${res.status}): ${errorMessage(json, "unknown error")}`));
|
|
@@ -223,22 +301,20 @@ program
|
|
|
223
301
|
}
|
|
224
302
|
const data = json;
|
|
225
303
|
console.log(green(data.alreadyOwned ? "✓ already owned — access granted" : "✓ payment confirmed"));
|
|
226
|
-
console.log(` buyer : ${
|
|
304
|
+
console.log(` buyer : ${id.name} (${id.address})`);
|
|
227
305
|
console.log(` namespace : ${cyan(data.namespace ?? "(none)")}`);
|
|
228
306
|
console.log(` paid : $${Number(data.pricePaidUsd).toFixed(2)}`);
|
|
229
|
-
console.log(` purchase : ${data.purchaseId}`);
|
|
230
307
|
console.log(dim("\n──────── DECRYPTED CONTENT ────────"));
|
|
231
308
|
console.log(data.content);
|
|
232
309
|
console.log(dim("───────────────────────────────────"));
|
|
233
310
|
});
|
|
234
311
|
program
|
|
235
312
|
.command("subscribe")
|
|
236
|
-
.description("Open a live SSE stream and print events for a namespace.")
|
|
313
|
+
.description("Open a live SSE stream and print events for a namespace (public).")
|
|
237
314
|
.requiredOption("--ns <namespace>", "namespace to listen on")
|
|
238
315
|
.action(async (opts, command) => {
|
|
239
316
|
const g = command.optsWithGlobals();
|
|
240
|
-
|
|
241
|
-
await streamSse(url, opts.ns);
|
|
317
|
+
await streamSse(`${baseUrl(g)}/api/events/listen?ns=${encodeURIComponent(opts.ns)}`, opts.ns);
|
|
242
318
|
});
|
|
243
319
|
program.parseAsync(process.argv).catch((err) => {
|
|
244
320
|
console.error(red(err instanceof Error ? err.message : String(err)));
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent identity — multiple named ed25519 keypairs, switchable.
|
|
3
|
+
*
|
|
4
|
+
* ~/.aport/accounts/<name>.key one PEM key per account
|
|
5
|
+
* ~/.aport/active name of the active account
|
|
6
|
+
* ~/.aport/key legacy single key (auto-adopted as "default")
|
|
7
|
+
*
|
|
8
|
+
* Which account a command uses: --account <name> > $APORT_ACCOUNT > active.
|
|
9
|
+
* address = "aport1" + base58( sha256(pubkey)[0..20] ).
|
|
10
|
+
*/
|
|
11
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, } from "node:crypto";
|
|
12
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
const DIR = join(homedir(), ".aport");
|
|
16
|
+
const ACCOUNTS_DIR = join(DIR, "accounts");
|
|
17
|
+
const ACTIVE_FILE = join(DIR, "active");
|
|
18
|
+
const LEGACY_KEY = join(DIR, "key");
|
|
19
|
+
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
20
|
+
export function base58(buf) {
|
|
21
|
+
let x = BigInt("0x" + (buf.toString("hex") || "0"));
|
|
22
|
+
let out = "";
|
|
23
|
+
while (x > 0n) {
|
|
24
|
+
out = B58.charAt(Number(x % 58n)) + out;
|
|
25
|
+
x /= 58n;
|
|
26
|
+
}
|
|
27
|
+
for (const b of buf) {
|
|
28
|
+
if (b === 0)
|
|
29
|
+
out = "1" + out;
|
|
30
|
+
else
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
return out || "1";
|
|
34
|
+
}
|
|
35
|
+
function rawPublicKey(key) {
|
|
36
|
+
const jwk = key.export({ format: "jwk" });
|
|
37
|
+
return Buffer.from(jwk.x, "base64url");
|
|
38
|
+
}
|
|
39
|
+
export function addressFromRaw(rawPub) {
|
|
40
|
+
const h = createHash("sha256").update(rawPub).digest();
|
|
41
|
+
return "aport1" + base58(h.subarray(0, 20));
|
|
42
|
+
}
|
|
43
|
+
function keyPathForName(name) {
|
|
44
|
+
return join(ACCOUNTS_DIR, `${name}.key`);
|
|
45
|
+
}
|
|
46
|
+
export function keyPath(name) {
|
|
47
|
+
return keyPathForName(name);
|
|
48
|
+
}
|
|
49
|
+
/** One-time: adopt a legacy ~/.aport/key as account "default". */
|
|
50
|
+
function bootstrap() {
|
|
51
|
+
if (existsSync(LEGACY_KEY) && !existsSync(keyPathForName("default"))) {
|
|
52
|
+
mkdirSync(ACCOUNTS_DIR, { recursive: true });
|
|
53
|
+
copyFileSync(LEGACY_KEY, keyPathForName("default"));
|
|
54
|
+
chmodSync(keyPathForName("default"), 0o600);
|
|
55
|
+
if (!existsSync(ACTIVE_FILE))
|
|
56
|
+
writeFileSync(ACTIVE_FILE, "default\n");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function listAccountNames() {
|
|
60
|
+
bootstrap();
|
|
61
|
+
if (!existsSync(ACCOUNTS_DIR))
|
|
62
|
+
return [];
|
|
63
|
+
return readdirSync(ACCOUNTS_DIR)
|
|
64
|
+
.filter((f) => f.endsWith(".key"))
|
|
65
|
+
.map((f) => f.slice(0, -4))
|
|
66
|
+
.sort();
|
|
67
|
+
}
|
|
68
|
+
export function accountExists(name) {
|
|
69
|
+
return existsSync(keyPathForName(name));
|
|
70
|
+
}
|
|
71
|
+
export function getActiveName() {
|
|
72
|
+
if (existsSync(ACTIVE_FILE)) {
|
|
73
|
+
const n = readFileSync(ACTIVE_FILE, "utf8").trim();
|
|
74
|
+
if (n)
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
export function setActive(name) {
|
|
80
|
+
if (!accountExists(name))
|
|
81
|
+
throw new Error(`account "${name}" not found`);
|
|
82
|
+
mkdirSync(DIR, { recursive: true });
|
|
83
|
+
writeFileSync(ACTIVE_FILE, name + "\n");
|
|
84
|
+
}
|
|
85
|
+
/** Resolve which account name to use. */
|
|
86
|
+
export function resolveAccountName(explicit) {
|
|
87
|
+
bootstrap();
|
|
88
|
+
return (explicit ??
|
|
89
|
+
(process.env.APORT_ACCOUNT || undefined) ??
|
|
90
|
+
getActiveName() ??
|
|
91
|
+
(accountExists("default") ? "default" : null));
|
|
92
|
+
}
|
|
93
|
+
function loadFromPath(path, name) {
|
|
94
|
+
const pem = readFileSync(path, "utf8");
|
|
95
|
+
const privateKey = createPrivateKey(pem);
|
|
96
|
+
const raw = rawPublicKey(createPublicKey(privateKey));
|
|
97
|
+
return { name, privateKey, publicKeyRaw: raw, address: addressFromRaw(raw) };
|
|
98
|
+
}
|
|
99
|
+
export function addressForName(name) {
|
|
100
|
+
return loadFromPath(keyPathForName(name), name).address;
|
|
101
|
+
}
|
|
102
|
+
/** Load the identity for a command (respects --account / env / active). */
|
|
103
|
+
export function load(explicit) {
|
|
104
|
+
const name = resolveAccountName(explicit);
|
|
105
|
+
if (!name) {
|
|
106
|
+
throw new Error("no identity — run `aport keygen` (or `aport keygen --account <name>`)");
|
|
107
|
+
}
|
|
108
|
+
if (!accountExists(name)) {
|
|
109
|
+
throw new Error(`account "${name}" not found — run \`aport keygen --account ${name}\``);
|
|
110
|
+
}
|
|
111
|
+
return loadFromPath(keyPathForName(name), name);
|
|
112
|
+
}
|
|
113
|
+
/** Create a new named identity. Returns it. */
|
|
114
|
+
export function generate(name) {
|
|
115
|
+
bootstrap();
|
|
116
|
+
mkdirSync(ACCOUNTS_DIR, { recursive: true });
|
|
117
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
118
|
+
const path = keyPathForName(name);
|
|
119
|
+
writeFileSync(path, privateKey.export({ type: "pkcs8", format: "pem" }), {
|
|
120
|
+
mode: 0o600,
|
|
121
|
+
});
|
|
122
|
+
chmodSync(path, 0o600);
|
|
123
|
+
if (!getActiveName())
|
|
124
|
+
setActive(name); // first account becomes active
|
|
125
|
+
return loadFromPath(path, name);
|
|
126
|
+
}
|
|
127
|
+
export function canonical(method, path, bodyHashHex, ts, nonce) {
|
|
128
|
+
return ["APORT-AUTH-v1", method.toUpperCase(), path, bodyHashHex, ts, nonce].join("\n");
|
|
129
|
+
}
|
|
130
|
+
/** Build the signed auth headers for a request. */
|
|
131
|
+
export function signRequest(id, method, path, body) {
|
|
132
|
+
const ts = Date.now().toString();
|
|
133
|
+
const nonce = randomBytes(16).toString("hex");
|
|
134
|
+
const bodyHash = createHash("sha256").update(body).digest("hex");
|
|
135
|
+
const msg = canonical(method, path, bodyHash, ts, nonce);
|
|
136
|
+
const signature = sign(null, Buffer.from(msg), id.privateKey);
|
|
137
|
+
return {
|
|
138
|
+
"x-aport-pubkey": id.publicKeyRaw.toString("base64url"),
|
|
139
|
+
"x-aport-address": id.address,
|
|
140
|
+
"x-aport-timestamp": ts,
|
|
141
|
+
"x-aport-nonce": nonce,
|
|
142
|
+
"x-aport-signature": signature.toString("base64url"),
|
|
143
|
+
};
|
|
144
|
+
}
|