mycomesh-consumer 0.3.0 → 0.5.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 +19 -13
- package/networks/mycomesh-v11-sepolia.json +1 -0
- package/package.json +2 -1
- package/src/account.mjs +93 -0
- package/src/cli.mjs +81 -53
- package/src/console.mjs +104 -0
- package/src/consumer.mjs +72 -18
- package/src/disputes.mjs +29 -1
- package/src/probes.mjs +70 -0
- package/src/reputation.mjs +86 -0
- package/src/tenants.mjs +69 -0
- package/src/tlspin.mjs +36 -0
- package/src/wallet.mjs +10 -2
- package/src/web/console.html +360 -0
package/README.md
CHANGED
|
@@ -8,26 +8,32 @@ Requires Node.js 20 or newer. No Docker or Python.
|
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install --global mycomesh-consumer
|
|
11
|
-
mycomesh-consumer init
|
|
12
|
-
mycomesh-consumer setup --
|
|
13
|
-
mycomesh-consumer request "What is MycoMesh?"
|
|
14
|
-
mycomesh-consumer serve
|
|
11
|
+
mycomesh-consumer init # payment key + password-protected owner wallet
|
|
12
|
+
mycomesh-consumer setup --deposit 20000000
|
|
13
|
+
mycomesh-consumer request --stream "What is MycoMesh?"
|
|
14
|
+
mycomesh-consumer serve # http://127.0.0.1:8110/v1 (responses, chat/completions, models; live streaming)
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
- `init` creates the payment key in `~/.mycomesh/v11
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
- `init` creates the payment key and an owner wallet in `~/.mycomesh/v11`. The wallet is an
|
|
18
|
+
Ethereum V3 keystore (MetaMask can import it) protected by a password from
|
|
19
|
+
`MYCOMESH_WALLET_PASSWORD` or a terminal prompt; `--owner-key-file` uses your own key instead.
|
|
20
|
+
- `setup --deposit UNITS` deposits tUSDC and authorizes the payment key up to `--max-per-request`
|
|
21
|
+
units per request. On the testnet it first tops the wallet up from the faucet (gas ETH and tUSDC);
|
|
22
|
+
`faucet` does that on its own.
|
|
23
|
+
- `balance` shows the deposit.
|
|
22
24
|
- `request "..." [--model gpt-5.5] [--provider SIGNER]` prints the verified answer,
|
|
23
25
|
the fee and the settlement key.
|
|
24
|
-
- `serve` runs
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
- `serve` runs the local web console at `http://127.0.0.1:8110/` (chat, wallet, deposit and
|
|
27
|
+
withdrawal, request history and disputes, network status) and an OpenAI-compatible endpoint for
|
|
28
|
+
Codex and other clients; set `MYCOMESH_CONSUMER_API_KEY` to require a bearer token. Only pages from
|
|
29
|
+
this machine can call it: other websites and DNS-rebinding hosts are refused.
|
|
30
|
+
- `withdraw [UNITS]` requests a withdrawal; run it again after the delay to receive the funds.
|
|
31
|
+
- `dispute <settlement-key|last> --statement "..."`
|
|
27
32
|
reveals a recorded request and response to a randomly drawn Provider-AI jury
|
|
28
33
|
within the 24-hour dispute window. The 1 tUSDC reporter bond is returned and a
|
|
29
34
|
bounty paid when fraud is confirmed.
|
|
30
35
|
|
|
31
36
|
Each response is decrypted locally and checked against the Provider-signed
|
|
32
|
-
receipt before it is shown.
|
|
37
|
+
receipt before it is shown; streamed text is checked against it too. Relays come from the bundled
|
|
38
|
+
manifest and the on-chain Relay directory. The bundled network manifest is
|
|
33
39
|
`networks/mycomesh-v11-sepolia.json`; use `--network FILE` for another.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"stablecoin": "0xbce27efad4191277167fe76819581a97959048cf",
|
|
7
7
|
"registry": "0x0ff3210e8e8abf5f5bfa73a39e60db7416b6c8c7",
|
|
8
8
|
"relay_directory": "0xa0aaad9393f51cb5786c69f152133a174588bdab",
|
|
9
|
+
"probe_ledger": "0x30055eae6645cce8754f834a17689d20a3795ca3",
|
|
9
10
|
"deployment_block": 11809185,
|
|
10
11
|
"rpc_urls": [
|
|
11
12
|
"https://ethereum-sepolia-rpc.publicnode.com",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mycomesh-consumer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MycoMesh V11 Consumer: a custodied deposit, relay-blind encrypted requests to any Provider, and a local OpenAI-compatible endpoint.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"bin/mycomesh-consumer.mjs",
|
|
18
18
|
"src/*.mjs",
|
|
19
|
+
"src/web/*.html",
|
|
19
20
|
"networks/mycomesh-v11-sepolia.json",
|
|
20
21
|
"networks/mycomesh-testnet-ca.crt",
|
|
21
22
|
"README.md"
|
package/src/account.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Consumer account operations shared by the CLI and the local web console.
|
|
2
|
+
import { addressOf, encodeCall } from "./eip712.mjs";
|
|
3
|
+
import { rpcCall, sendTransaction } from "./chain.mjs";
|
|
4
|
+
import { directoryRelays, httpJson } from "./consumer.mjs";
|
|
5
|
+
import { buildEvidence, evidenceHash, openDisputeCall, reportId } from "./disputes.mjs";
|
|
6
|
+
|
|
7
|
+
export const STATUSES = ["none", "pending", "disputed", "released", "confirmed", "dismissed", "timed_out", "jury_unavailable", "voided"];
|
|
8
|
+
|
|
9
|
+
async function words(network, to, signature, pairs = []) {
|
|
10
|
+
const raw = (await rpcCall(network.rpc_urls, "eth_call", [{ to, data: encodeCall(signature, pairs) }, "latest"])).slice(2);
|
|
11
|
+
return Array.from({ length: raw.length / 64 }, (_, i) => BigInt(`0x${raw.slice(i * 64, i * 64 + 64)}`));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Balances of the owner wallet and the payment key's grant; amounts are strings of base units. */
|
|
15
|
+
export async function walletStatus(network, owner, key) {
|
|
16
|
+
const [eth, [tokens], [deposit], withdrawal, grant, params] = await Promise.all([
|
|
17
|
+
rpcCall(network.rpc_urls, "eth_getBalance", [owner, "latest"]),
|
|
18
|
+
words(network, network.stablecoin, "balanceOf(address)", [["address", owner]]),
|
|
19
|
+
words(network, network.settlement, "availableBalance(address)", [["address", owner]]),
|
|
20
|
+
words(network, network.settlement, "withdrawals(address)", [["address", owner]]),
|
|
21
|
+
key ? words(network, network.settlement, "keyGrants(address)", [["address", key]]) : Promise.resolve(null),
|
|
22
|
+
words(network, network.settlement, "params()"),
|
|
23
|
+
]);
|
|
24
|
+
return {
|
|
25
|
+
owner, key, eth_wei: BigInt(eth).toString(), wallet_tokens: tokens.toString(), deposit: deposit.toString(),
|
|
26
|
+
withdrawal: { amount: withdrawal[0].toString(), available_at: Number(withdrawal[1]) },
|
|
27
|
+
grant: grant && { active: Boolean(grant[3]) && `0x${grant[0].toString(16).padStart(40, "0")}` === owner.toLowerCase(),
|
|
28
|
+
max_per_request: grant[1].toString() },
|
|
29
|
+
dispute_window: Number(params[0]), reporter_bond: params[3].toString(), faucet: Boolean(network.faucet_url),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function faucet(network, address) {
|
|
34
|
+
if (!network.faucet_url) throw new Error("this network has no faucet");
|
|
35
|
+
const reply = await httpJson(`${network.faucet_url}/v11/faucet`, { method: "POST", body: { address }, ca: network.tls_ca, timeoutMs: 400_000 });
|
|
36
|
+
if (reply.status !== 200) throw new Error(`faucet: ${reply.body?.error || reply.status}`);
|
|
37
|
+
return reply.body;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deposit and grant the payment key; on testnets, top the wallet up from the faucet first. */
|
|
41
|
+
export async function setup(network, { ownerPrivate, key, deposit, limit, forceFaucet = false, log = () => {} }) {
|
|
42
|
+
const owner = addressOf(ownerPrivate);
|
|
43
|
+
const status = await walletStatus(network, owner, key);
|
|
44
|
+
if (network.faucet_url && (forceFaucet || BigInt(status.eth_wei) < 2_000_000_000_000_000n || BigInt(status.wallet_tokens) < deposit)) {
|
|
45
|
+
log(`funding ${owner} from the testnet faucet...`);
|
|
46
|
+
await faucet(network, owner);
|
|
47
|
+
}
|
|
48
|
+
const send = (to, data) => sendTransaction(network.rpc_urls, ownerPrivate, { to, data });
|
|
49
|
+
await send(network.stablecoin, encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", deposit]]));
|
|
50
|
+
await send(network.settlement, encodeCall("deposit(uint256)", [["uint", deposit]]));
|
|
51
|
+
if (!status.grant?.active || BigInt(status.grant.max_per_request) !== limit) {
|
|
52
|
+
await send(network.settlement, encodeCall("registerKey(address,uint256,uint64)", [["address", key], ["uint", limit], ["uint", 0]]));
|
|
53
|
+
}
|
|
54
|
+
return { owner, key, deposit: deposit.toString(), max_per_request: limit.toString() };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Withdrawals take two steps: request (funds stop backing requests), then withdraw after the delay. */
|
|
58
|
+
export async function requestWithdrawal(network, ownerPrivate, amount) {
|
|
59
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement,
|
|
60
|
+
data: encodeCall("requestWithdrawal(uint256)", [["uint", amount]]) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function completeWithdrawal(network, ownerPrivate) {
|
|
64
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement, data: encodeCall("withdraw()", []) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function settlementStatus(network, key) {
|
|
68
|
+
const fields = await words(network, network.settlement, "settlementInfo(bytes32)", [["bytes32", key]]);
|
|
69
|
+
return { status: STATUSES[Number(fields[14])] || "unknown", release_at: Number(fields[13]), fee: fields[10].toString() };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Commit the evidence on-chain (with the reporter bond), then hand it to every Relay's dispute desk. */
|
|
73
|
+
export async function dispute(network, ownerPrivate, record, { reasonCode, statement }) {
|
|
74
|
+
const evidence = buildEvidence(record, { reasonCode, statement });
|
|
75
|
+
const digest = evidenceHash(evidence);
|
|
76
|
+
const [bond] = (await words(network, network.settlement, "params()")).slice(3, 4);
|
|
77
|
+
if (bond > 0n) {
|
|
78
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.stablecoin,
|
|
79
|
+
data: encodeCall("approve(address,uint256)", [["address", network.settlement], ["uint", bond]]) });
|
|
80
|
+
}
|
|
81
|
+
await sendTransaction(network.rpc_urls, ownerPrivate, { to: network.settlement, data: openDisputeCall(record.settlement_key, digest) });
|
|
82
|
+
let relays = network.relays;
|
|
83
|
+
try { relays = [...relays, ...await directoryRelays(network)]; } catch {}
|
|
84
|
+
const accepted = [];
|
|
85
|
+
for (const relay of relays) {
|
|
86
|
+
try {
|
|
87
|
+
const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, pin: relay.pin, timeoutMs: 30_000 });
|
|
88
|
+
if (reply.status === 200) accepted.push(relay.url);
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
return { settlement_key: record.settlement_key, evidence_hash: digest,
|
|
92
|
+
report_id: reportId(record.settlement_key, addressOf(ownerPrivate), digest), relays_accepted: accepted };
|
|
93
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -5,11 +5,15 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
-
import { addressOf,
|
|
9
|
-
import { availableBalance
|
|
10
|
-
import {
|
|
8
|
+
import { addressOf, settlementKey } from "./eip712.mjs";
|
|
9
|
+
import { availableBalance } from "./chain.mjs";
|
|
10
|
+
import { completeWithdrawal, dispute, faucet, requestWithdrawal, setup, walletStatus } from "./account.mjs";
|
|
11
|
+
import { Consumer, loadNetwork, serveConsumer } from "./consumer.mjs";
|
|
11
12
|
import { createWallet, ownerAddress, ownerKey, walletAddress } from "./wallet.mjs";
|
|
12
|
-
import {
|
|
13
|
+
import { loadRequest } from "./disputes.mjs";
|
|
14
|
+
import { addTenant, loadTenants, revokeTenant, setBudget, tenantKey, tenantStatus } from "./tenants.mjs";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { statSync } from "node:fs";
|
|
13
17
|
|
|
14
18
|
const DEFAULT_NETWORK = join(dirname(fileURLToPath(import.meta.url)), "../networks/mycomesh-v11-sepolia.json");
|
|
15
19
|
|
|
@@ -23,9 +27,13 @@ const USAGE = `Usage: mycomesh-consumer <command> [options]
|
|
|
23
27
|
deposit into the settlement contract and authorize the payment key
|
|
24
28
|
(on testnets, tops the wallet up from the faucet first)
|
|
25
29
|
balance [--owner ADDR] show the custodied deposit
|
|
30
|
+
withdraw [UNITS] request a withdrawal of the deposit; run again after the delay to receive it
|
|
26
31
|
request "prompt" [--stream]
|
|
27
32
|
send one request and print the verified answer
|
|
28
|
-
serve [--port 8110] run the local OpenAI-compatible endpoint
|
|
33
|
+
serve [--port 8110] run the local web console and OpenAI-compatible endpoint (default)
|
|
34
|
+
tenant add NAME [--budget UNITS] [--max-per-request UNITS]
|
|
35
|
+
multi-tenant accounts: a payment key and API key per tenant, capped on-chain
|
|
36
|
+
tenant list | tenant budget NAME UNITS | tenant revoke NAME
|
|
29
37
|
dispute <settlement-key|last> [--reason CODE] [--statement TEXT]
|
|
30
38
|
reveal a recorded request and response to a Provider-AI jury
|
|
31
39
|
(within 24 hours; the reporter bond is returned if fraud is confirmed)
|
|
@@ -66,7 +74,8 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
66
74
|
port: { type: "string", default: "8110" }, host: { type: "string", default: "127.0.0.1" },
|
|
67
75
|
"max-output-tokens": { type: "string", default: "4096" }, help: { type: "boolean" },
|
|
68
76
|
reason: { type: "string", default: "unrelated_response" }, faucet: { type: "boolean" }, provider: { type: "string" },
|
|
69
|
-
statement: { type: "string", default: "" }, stream: { type: "boolean" },
|
|
77
|
+
statement: { type: "string", default: "" }, stream: { type: "boolean" }, budget: { type: "string" },
|
|
78
|
+
"no-browser": { type: "boolean" },
|
|
70
79
|
},
|
|
71
80
|
});
|
|
72
81
|
const [command = "serve", ...rest] = positionals;
|
|
@@ -88,38 +97,37 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
88
97
|
return 0;
|
|
89
98
|
}
|
|
90
99
|
const network = loadNetwork(values.network || process.env.MYCOMESH_NETWORK || DEFAULT_NETWORK);
|
|
91
|
-
const faucet = async (address) => {
|
|
92
|
-
if (!network.faucet_url) throw new Error("this network has no faucet");
|
|
93
|
-
const reply = await httpJson(`${network.faucet_url}/v11/faucet`, { method: "POST", body: { address }, ca: network.tls_ca, timeoutMs: 400_000 });
|
|
94
|
-
if (reply.status !== 200) throw new Error(`faucet: ${reply.body?.error || reply.status}`);
|
|
95
|
-
return reply.body;
|
|
96
|
-
};
|
|
97
100
|
if (command === "faucet") {
|
|
98
101
|
const address = values.owner || ownerAddress(values["owner-key-file"], dir) || walletAddress(await createWallet(dir));
|
|
99
|
-
stdout.write(`${JSON.stringify(await faucet(address))}\n`);
|
|
102
|
+
stdout.write(`${JSON.stringify(await faucet(network, address))}\n`);
|
|
100
103
|
return 0;
|
|
101
104
|
}
|
|
102
105
|
if (command === "setup") {
|
|
103
106
|
const keyPrivate = paymentKey(dir);
|
|
104
107
|
if (!values.deposit) throw new Error("--deposit UNITS is required");
|
|
105
108
|
if (!values["owner-key-file"]) await createWallet(dir);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
109
|
+
const result = await setup(network, {
|
|
110
|
+
ownerPrivate: await ownerKey(values["owner-key-file"], dir), key: addressOf(keyPrivate), deposit: BigInt(values.deposit),
|
|
111
|
+
limit: BigInt(values["max-per-request"] || values["max-fee"]), forceFaucet: values.faucet,
|
|
112
|
+
log: (line) => stdout.write(`${line}\n`),
|
|
113
|
+
});
|
|
114
|
+
stdout.write(`deposited ${result.deposit} and authorized ${result.key} up to ${result.max_per_request} per request\n`);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
if (command === "withdraw") {
|
|
116
118
|
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
const status = await walletStatus(network, addressOf(owner), null);
|
|
120
|
+
if (BigInt(status.withdrawal.amount) > 0n) {
|
|
121
|
+
if (status.withdrawal.available_at > Math.floor(Date.now() / 1000)) {
|
|
122
|
+
throw new Error(`the withdrawal of ${status.withdrawal.amount} unlocks at ${new Date(status.withdrawal.available_at * 1000).toISOString()}`);
|
|
123
|
+
}
|
|
124
|
+
await completeWithdrawal(network, owner);
|
|
125
|
+
stdout.write(`withdrew ${status.withdrawal.amount}\n`);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
const amount = BigInt(rest[0] || status.deposit);
|
|
129
|
+
await requestWithdrawal(network, owner, amount);
|
|
130
|
+
stdout.write(`requested a withdrawal of ${amount}; run \`withdraw\` again after the delay\n`);
|
|
123
131
|
return 0;
|
|
124
132
|
}
|
|
125
133
|
if (command === "balance") {
|
|
@@ -130,31 +138,28 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
130
138
|
}
|
|
131
139
|
if (command === "dispute") {
|
|
132
140
|
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
for (const relay of relays) {
|
|
148
|
-
try {
|
|
149
|
-
const reply = await httpJson(`${relay.url}/v11/evidence`, { method: "POST", body: evidence, ca: network.tls_ca, timeoutMs: 30_000 });
|
|
150
|
-
if (reply.status === 200) accepted.push(relay.url);
|
|
151
|
-
} catch {}
|
|
141
|
+
const result = await dispute(network, owner, loadRequest(dir, rest[0] || "last"),
|
|
142
|
+
{ reasonCode: values.reason, statement: values.statement || rest.slice(1).join(" ") });
|
|
143
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
144
|
+
return result.relays_accepted.length ? 0 : 2;
|
|
145
|
+
}
|
|
146
|
+
if (command === "tenant") {
|
|
147
|
+
const [action, name, amount] = rest;
|
|
148
|
+
if (action === "list") { stdout.write(`${JSON.stringify(await tenantStatus(network, dir), null, 2)}\n`); return 0; }
|
|
149
|
+
const owner = await ownerKey(values["owner-key-file"], dir);
|
|
150
|
+
if (action === "add") {
|
|
151
|
+
const created = await addTenant(network, dir, owner, name, {
|
|
152
|
+
maxPerRequest: BigInt(values["max-per-request"] || values["max-fee"]), budget: BigInt(values.budget || 0) });
|
|
153
|
+
stdout.write(`${JSON.stringify(created)}\nThe API key is shown once; give it to the tenant (Authorization: Bearer ...).\n`);
|
|
154
|
+
return 0;
|
|
152
155
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
const tenant = loadTenants(dir)[name];
|
|
157
|
+
if (!tenant) throw new Error(`no tenant ${name}`);
|
|
158
|
+
if (action === "budget") { await setBudget(network, owner, tenant.key, BigInt(amount)); stdout.write(`budget of ${name} set to ${amount}\n`); return 0; }
|
|
159
|
+
if (action === "revoke") { await revokeTenant(network, dir, owner, name); stdout.write(`revoked ${name}\n`); return 0; }
|
|
160
|
+
throw new Error("tenant add | list | budget | revoke");
|
|
156
161
|
}
|
|
157
|
-
const keyPrivate = paymentKey(dir);
|
|
162
|
+
const keyPrivate = paymentKey(dir, command === "serve");
|
|
158
163
|
const consumer = new Consumer({ network, keyPrivate, maxFee: Number(values["max-fee"]), journalDir: dir });
|
|
159
164
|
if (command === "request") {
|
|
160
165
|
const { response, receipt } = await consumer.request({
|
|
@@ -169,9 +174,32 @@ export async function main(argv = process.argv.slice(2), { stdout = process.stdo
|
|
|
169
174
|
return 0;
|
|
170
175
|
}
|
|
171
176
|
if (command === "serve") {
|
|
172
|
-
|
|
177
|
+
// Tenants are reloaded when tenants.json changes, so `tenant add` works while serving.
|
|
178
|
+
let cache = { mtime: -1, byHash: {} };
|
|
179
|
+
const tenants = () => {
|
|
180
|
+
const path = join(dir, "tenants.json");
|
|
181
|
+
const mtime = existsSync(path) ? statSync(path).mtimeMs : 0;
|
|
182
|
+
if (mtime !== cache.mtime) {
|
|
183
|
+
const byHash = {};
|
|
184
|
+
for (const [name, tenant] of Object.entries(loadTenants(dir))) {
|
|
185
|
+
if (tenant.revoked) continue;
|
|
186
|
+
byHash[tenant.api_key_sha256] = new Consumer({ network, keyPrivate: tenantKey(dir, name), maxFee: Number(tenant.max_per_request),
|
|
187
|
+
journalDir: join(dir, "tenants", name) });
|
|
188
|
+
}
|
|
189
|
+
cache = { mtime, byHash };
|
|
190
|
+
}
|
|
191
|
+
return cache.byHash;
|
|
192
|
+
};
|
|
193
|
+
const server = await serveConsumer(consumer, { host: values.host, port: Number(values.port),
|
|
194
|
+
apiKey: process.env.MYCOMESH_CONSUMER_API_KEY, dataDir: dir, tenants });
|
|
173
195
|
const { port } = server.address();
|
|
196
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
174
197
|
stdout.write(`MycoMesh V11 Consumer on http://${values.host}:${port}/v1 (payment key ${consumer.key})\n`);
|
|
198
|
+
stdout.write(`Web console: ${url}\n`);
|
|
199
|
+
if (!values["no-browser"] && process.stdout.isTTY && !process.env.CI) {
|
|
200
|
+
const opener = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
201
|
+
spawn(opener[0], opener[1], { stdio: "ignore", detached: true }).on("error", () => {}).unref();
|
|
202
|
+
}
|
|
175
203
|
return new Promise(() => {});
|
|
176
204
|
}
|
|
177
205
|
throw new Error(`unknown command ${command}\n${USAGE}`);
|
package/src/console.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The local web console: the Bitcoin-node model, a UI served by this machine to this machine.
|
|
2
|
+
// The browser only ever talks to 127.0.0.1; this process reaches the network and verifies everything.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { addressOf } from "./eip712.mjs";
|
|
5
|
+
import { completeWithdrawal, dispute, faucet, requestWithdrawal, settlementStatus, setup, walletStatus } from "./account.mjs";
|
|
6
|
+
import { listRequests, loadRequest } from "./disputes.mjs";
|
|
7
|
+
import { outputText } from "./protocol.mjs";
|
|
8
|
+
import { createWallet, ownerAddress, unlockWallet, walletAddress } from "./wallet.mjs";
|
|
9
|
+
import { addTenant, loadTenants, revokeTenant, setBudget, tenantStatus } from "./tenants.mjs";
|
|
10
|
+
|
|
11
|
+
const PAGE = new URL("./web/console.html", import.meta.url);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Only this machine's own pages may call the local node: reject other Host
|
|
15
|
+
* names (DNS rebinding) and cross-site Origins (a web page spending the deposit).
|
|
16
|
+
*/
|
|
17
|
+
export function localRequestAllowed(req, port) {
|
|
18
|
+
const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
19
|
+
if (!allowed.has(String(req.headers.host || ""))) return false;
|
|
20
|
+
const origin = req.headers.origin;
|
|
21
|
+
return !origin || allowed.has(origin.replace(/^http:\/\//, ""));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function consoleRoutes({ consumer, dataDir }) {
|
|
25
|
+
const network = consumer.network;
|
|
26
|
+
const owner = () => ownerAddress(undefined, dataDir);
|
|
27
|
+
const withWallet = (body) => unlockWallet(dataDir, body.password);
|
|
28
|
+
const units = (value, name) => {
|
|
29
|
+
if (!/^[0-9]+$/.test(String(value ?? ""))) throw Object.assign(new Error(`${name} must be a whole number of base units`), { status: 400 });
|
|
30
|
+
return BigInt(value);
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
"GET /": () => ({ type: "text/html; charset=utf-8", body: readFileSync(PAGE, "utf8") }),
|
|
34
|
+
"GET /api/status": async () => {
|
|
35
|
+
const address = owner();
|
|
36
|
+
return {
|
|
37
|
+
network: { id: network.network_id, chain_id: network.chain_id, settlement: network.settlement, faucet: Boolean(network.faucet_url) },
|
|
38
|
+
payment_key: consumer.key, max_fee: String(consumer.maxFee),
|
|
39
|
+
wallet: address ? await walletStatus(network, address, consumer.key) : null,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
"GET /api/network": async () => {
|
|
43
|
+
const relays = [];
|
|
44
|
+
for (const relay of await consumer.relays()) {
|
|
45
|
+
let health = null; let providers = [];
|
|
46
|
+
try { health = (await consumer.fetchJson(`${relay.url}/health`, { ca: network.tls_ca, pin: relay.pin, timeoutMs: 5_000 })).body; } catch {}
|
|
47
|
+
try { providers = await consumer.providers(relay); } catch {}
|
|
48
|
+
relays.push({ url: relay.url, signer: relay.signer, pinned: Boolean(relay.pin), healthy: Boolean(health?.ok), providers: providers.map((p) => ({
|
|
49
|
+
signer: p.provider_signer, models: p.models, prices: p.prices, capacity: p.capacity })) });
|
|
50
|
+
}
|
|
51
|
+
const records = await consumer.reputations().catch(() => ({}));
|
|
52
|
+
for (const relay of relays) for (const provider of relay.providers) provider.reputation = records[provider.signer] || null;
|
|
53
|
+
return { relays };
|
|
54
|
+
},
|
|
55
|
+
"GET /api/history": async () => {
|
|
56
|
+
const entries = listRequests(dataDir, outputText, 50);
|
|
57
|
+
await Promise.all(entries.map(async (entry) => {
|
|
58
|
+
try { Object.assign(entry, await settlementStatus(network, entry.settlement_key)); } catch { entry.status = "unknown"; }
|
|
59
|
+
}));
|
|
60
|
+
return { entries };
|
|
61
|
+
},
|
|
62
|
+
"POST /api/wallet": async (body) => ({ owner: walletAddress(await createWallet(dataDir, body.password)) }),
|
|
63
|
+
"POST /api/faucet": async () => {
|
|
64
|
+
const address = owner();
|
|
65
|
+
if (!address) throw Object.assign(new Error("create the wallet first"), { status: 400 });
|
|
66
|
+
return faucet(network, address);
|
|
67
|
+
},
|
|
68
|
+
"POST /api/setup": async (body) => setup(network, {
|
|
69
|
+
ownerPrivate: withWallet(body), key: consumer.key, deposit: units(body.deposit, "deposit"),
|
|
70
|
+
limit: units(body.max_per_request ?? consumer.maxFee, "max_per_request"),
|
|
71
|
+
}),
|
|
72
|
+
"POST /api/withdraw": async (body) => {
|
|
73
|
+
const ownerPrivate = withWallet(body);
|
|
74
|
+
const status = await walletStatus(network, addressOf(ownerPrivate), null);
|
|
75
|
+
if (BigInt(status.withdrawal.amount) > 0n) {
|
|
76
|
+
if (status.withdrawal.available_at > Math.floor(Date.now() / 1000)) {
|
|
77
|
+
throw Object.assign(new Error("the pending withdrawal is still locked"), { status: 409 });
|
|
78
|
+
}
|
|
79
|
+
await completeWithdrawal(network, ownerPrivate);
|
|
80
|
+
return { withdrawn: status.withdrawal.amount };
|
|
81
|
+
}
|
|
82
|
+
const amount = body.amount ? units(body.amount, "amount") : BigInt(status.deposit);
|
|
83
|
+
await requestWithdrawal(network, ownerPrivate, amount);
|
|
84
|
+
return { requested: amount.toString() };
|
|
85
|
+
},
|
|
86
|
+
"GET /api/tenants": async () => ({ tenants: await tenantStatus(network, dataDir) }),
|
|
87
|
+
"POST /api/tenants": async (body) => addTenant(network, dataDir, withWallet(body), String(body.name || ""), {
|
|
88
|
+
maxPerRequest: units(body.max_per_request ?? consumer.maxFee, "max_per_request"),
|
|
89
|
+
budget: body.budget ? units(body.budget, "budget") : 0n,
|
|
90
|
+
}),
|
|
91
|
+
"POST /api/tenants/budget": async (body) => {
|
|
92
|
+
const tenant = loadTenants(dataDir)[String(body.name)];
|
|
93
|
+
if (!tenant) throw Object.assign(new Error("no such tenant"), { status: 404 });
|
|
94
|
+
await setBudget(network, withWallet(body), tenant.key, units(body.budget, "budget"));
|
|
95
|
+
return { name: body.name, budget: String(body.budget) };
|
|
96
|
+
},
|
|
97
|
+
"POST /api/tenants/revoke": async (body) => {
|
|
98
|
+
await revokeTenant(network, dataDir, withWallet(body), String(body.name));
|
|
99
|
+
return { revoked: body.name };
|
|
100
|
+
},
|
|
101
|
+
"POST /api/dispute": async (body) => dispute(network, withWallet(body), loadRequest(dataDir, String(body.settlement_key)),
|
|
102
|
+
{ reasonCode: String(body.reason_code || "unrelated_response"), statement: String(body.statement || "") }),
|
|
103
|
+
};
|
|
104
|
+
}
|