@iicp/client 0.7.36 → 0.7.38
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 +24 -0
- package/dist/backends/base.d.ts +8 -0
- package/dist/backends/base.d.ts.map +1 -1
- package/dist/backends/base.js +144 -2
- package/dist/backends/base.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +518 -2
- package/dist/cli.js.map +1 -1
- package/dist/delegation.d.ts +13 -0
- package/dist/delegation.d.ts.map +1 -1
- package/dist/delegation.js +19 -0
- package/dist/delegation.js.map +1 -1
- package/dist/identity.d.ts +34 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +79 -2
- package/dist/identity.js.map +1 -1
- package/dist/iicp_tcp.d.ts +8 -0
- package/dist/iicp_tcp.d.ts.map +1 -1
- package/dist/iicp_tcp.js +9 -0
- package/dist/iicp_tcp.js.map +1 -1
- package/dist/nat_detection.d.ts.map +1 -1
- package/dist/nat_detection.js +24 -0
- package/dist/nat_detection.js.map +1 -1
- package/dist/node.d.ts +14 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +0 -0
- package/dist/node.js.map +1 -1
- package/dist/operator_crypto.d.ts +32 -0
- package/dist/operator_crypto.d.ts.map +1 -0
- package/dist/operator_crypto.js +78 -0
- package/dist/operator_crypto.js.map +1 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
exports.applySavedNode = applySavedNode;
|
|
5
|
+
exports.verifyCreditAwards = verifyCreditAwards;
|
|
5
6
|
exports.main = main;
|
|
6
7
|
// SPDX-License-Identifier: Apache-2.0
|
|
7
8
|
/**
|
|
@@ -35,6 +36,7 @@ const cip_policy_js_1 = require("./cip_policy.js");
|
|
|
35
36
|
const instance_lock_js_1 = require("./instance_lock.js");
|
|
36
37
|
const index_js_1 = require("./backends/index.js");
|
|
37
38
|
const identity_js_1 = require("./identity.js");
|
|
39
|
+
const delegation_js_1 = require("./delegation.js");
|
|
38
40
|
function envOr(name, fallback) {
|
|
39
41
|
const v = process.env[name];
|
|
40
42
|
return v ?? fallback;
|
|
@@ -58,7 +60,11 @@ function printHelp() {
|
|
|
58
60
|
` init Interactive wizard — set up operator + first node config\n` +
|
|
59
61
|
` list List node configs saved under ~/.iicp/nodes/\n` +
|
|
60
62
|
` serve Register and serve a node\n` +
|
|
61
|
-
` query <prompt> Discover mesh nodes and submit a chat task\n
|
|
63
|
+
` query <prompt> Discover mesh nodes and submit a chat task\n` +
|
|
64
|
+
` credits Show this node's earned / spent / balance credits\n` +
|
|
65
|
+
` operator rename <name> Change your public display_name (signed by your operator key)\n` +
|
|
66
|
+
` operator encrypt Password-encrypt the operator secret at rest ($IICP_OPERATOR_PASSPHRASE)\n` +
|
|
67
|
+
` operator decrypt Remove at-rest encryption of the operator secret\n\n` +
|
|
62
68
|
`Run an IICP provider node backed by an OpenAI-compatible server.\n\n` +
|
|
63
69
|
`serve required (flag or env):\n` +
|
|
64
70
|
` --model NAME IICP_BACKEND_MODEL — model name (e.g. qwen2.5:0.5b)\n` +
|
|
@@ -540,6 +546,20 @@ async function runServe(opts) {
|
|
|
540
546
|
}
|
|
541
547
|
const backendFlavor = await detectBackendFlavor(opts.backendUrl, opts.backendApiKey, opts.backendType);
|
|
542
548
|
process.stderr.write(`backend detected: ${backendFlavor}\n`);
|
|
549
|
+
// #463/#464 — bind the operator identity: issue a delegation FROM the (key-backed) operator
|
|
550
|
+
// identity for this node and advertise the public display_name. The directory verifies the
|
|
551
|
+
// delegation (operator_pub == operator_id) and records the operator. Never sends the secret/contact.
|
|
552
|
+
const _op = (0, identity_js_1.loadOperator)();
|
|
553
|
+
let _opDelegation;
|
|
554
|
+
let _opDisplayName;
|
|
555
|
+
let _opCreatedAt;
|
|
556
|
+
let _opIntegrityHash;
|
|
557
|
+
if (_op && (0, identity_js_1.operatorIsKeyBacked)(_op)) {
|
|
558
|
+
_opDelegation = (0, delegation_js_1.issueDelegation)((0, identity_js_1.operatorSigningKey)(_op), nodeId);
|
|
559
|
+
_opDisplayName = _op.display_name || undefined;
|
|
560
|
+
_opCreatedAt = _op.created_at;
|
|
561
|
+
_opIntegrityHash = _op.operator_integrity_hash || undefined;
|
|
562
|
+
}
|
|
543
563
|
const node = new node_js_1.IicpNode({
|
|
544
564
|
nodeId,
|
|
545
565
|
endpoint: publicEndpoint,
|
|
@@ -550,6 +570,10 @@ async function runServe(opts) {
|
|
|
550
570
|
directoryUrl: opts.directoryUrl,
|
|
551
571
|
maxConcurrent: opts.maxConcurrent,
|
|
552
572
|
relayWorkerEndpoint: opts.relayWorkerEndpoint || undefined,
|
|
573
|
+
operatorDelegation: _opDelegation,
|
|
574
|
+
operatorDisplayName: _opDisplayName,
|
|
575
|
+
operatorCreatedAt: _opCreatedAt,
|
|
576
|
+
operatorIntegrityHash: _opIntegrityHash,
|
|
553
577
|
});
|
|
554
578
|
// Apply collected NAT profile (covers both auto-detect and tier-0 IPv6 cases).
|
|
555
579
|
if (natProfile) {
|
|
@@ -654,6 +678,18 @@ async function runServe(opts) {
|
|
|
654
678
|
"Set IICP_PUBLIC_ENDPOINT=<url> or IICP_RELAY_WORKER_ENDPOINT=<host>:<port> to register.");
|
|
655
679
|
opts.skipRegistration = true;
|
|
656
680
|
}
|
|
681
|
+
// #457 / ADR-040 — advertise the native IICP binary transport. serve() multiplexes it
|
|
682
|
+
// onto the SAME socket as HTTP (first-byte detection), so transport_endpoint shares the
|
|
683
|
+
// endpoint's host:port with the iicp:// scheme. Set from the FINAL endpoint (after NAT
|
|
684
|
+
// profile application); register() only sends it when registering (skipRegistration gates
|
|
685
|
+
// the non-routable case) → advertise-when-reachable. Opt out with IICP_DISABLE_NATIVE_TRANSPORT=1.
|
|
686
|
+
if (!opts.skipRegistration && process.env["IICP_DISABLE_NATIVE_TRANSPORT"] !== "1") {
|
|
687
|
+
const finalEndpoint = node["_cfg"].endpoint;
|
|
688
|
+
const nativeEndpoint = finalEndpoint ? (0, node_js_1.deriveNativeEndpoint)(finalEndpoint) : null;
|
|
689
|
+
if (nativeEndpoint) {
|
|
690
|
+
node["_cfg"].transportEndpoint = nativeEndpoint;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
657
693
|
// #404 — register with bounded backoff retry. On persistent failure, pass an
|
|
658
694
|
// empty token (NOT undefined) so the heartbeat loop still starts and re-registers
|
|
659
695
|
// on the first 401 (#399 path) once the directory is reachable — the self-healing
|
|
@@ -666,6 +702,20 @@ async function runServe(opts) {
|
|
|
666
702
|
// eslint-disable-next-line no-console
|
|
667
703
|
console.log(`[iicp-node] registered as ${nodeId} (token=${(token ?? "").slice(0, 8)}…)`);
|
|
668
704
|
(0, node_log_js_1.writeNodeEvent)(nodeId, "register_ok", `endpoint=${opts.publicEndpoint || `http://localhost:${opts.port}`}`, logDir);
|
|
705
|
+
// #456 — cache the token in the saved config so `iicp-node credits` can
|
|
706
|
+
// authenticate later without re-registering (best-effort).
|
|
707
|
+
if (opts.node && token) {
|
|
708
|
+
const saved = (0, identity_js_1.loadNode)(opts.node);
|
|
709
|
+
if (saved) {
|
|
710
|
+
saved.node_token = token;
|
|
711
|
+
try {
|
|
712
|
+
(0, identity_js_1.saveNode)(saved);
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
/* best-effort cache */
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
669
719
|
break;
|
|
670
720
|
}
|
|
671
721
|
catch (exc) {
|
|
@@ -765,7 +815,8 @@ async function runQuery(argv) {
|
|
|
765
815
|
intent,
|
|
766
816
|
payload,
|
|
767
817
|
});
|
|
768
|
-
|
|
818
|
+
// Spec terminal success status is "success" (was "completed"); accept both.
|
|
819
|
+
if ((resp.status === "success" || resp.status === "completed") && resp.result) {
|
|
769
820
|
const res = resp.result;
|
|
770
821
|
const content = typeof res["content"] === "string"
|
|
771
822
|
? res["content"]
|
|
@@ -787,6 +838,467 @@ async function runQuery(argv) {
|
|
|
787
838
|
return 1;
|
|
788
839
|
}
|
|
789
840
|
}
|
|
841
|
+
/** SPKI DER prefix for an Ed25519 public key (12 bytes); + 32 raw key bytes = a parseable SPKI key. */
|
|
842
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
843
|
+
/** #458 hash-chain genesis root: SHA256_hex("iicp:dir:event-log:genesis:v1"). The prev_hash
|
|
844
|
+
* of a genesis/legacy event; bound into the signing input the directory verifies against. */
|
|
845
|
+
const EVENT_LOG_GENESIS_ROOT = "c44802bedf3e63b5a3f1634c5d19263634f92f26dd15401b09b06dd53a80cf9d";
|
|
846
|
+
function isLNum(v) {
|
|
847
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && "__num" in v;
|
|
848
|
+
}
|
|
849
|
+
/** Minimal recursive-descent JSON parse that preserves number literals verbatim. */
|
|
850
|
+
function losslessParse(text) {
|
|
851
|
+
let i = 0;
|
|
852
|
+
const ws = () => {
|
|
853
|
+
while (i < text.length && (text[i] === " " || text[i] === "\t" || text[i] === "\n" || text[i] === "\r"))
|
|
854
|
+
i++;
|
|
855
|
+
};
|
|
856
|
+
const parseStr = () => {
|
|
857
|
+
i++; // opening quote
|
|
858
|
+
let s = "";
|
|
859
|
+
while (text[i] !== '"') {
|
|
860
|
+
const ch = text[i];
|
|
861
|
+
if (ch === "\\") {
|
|
862
|
+
const e = text[++i];
|
|
863
|
+
if (e === "u") {
|
|
864
|
+
s += String.fromCharCode(parseInt(text.slice(i + 1, i + 5), 16));
|
|
865
|
+
i += 5;
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
const map = {
|
|
869
|
+
'"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t",
|
|
870
|
+
};
|
|
871
|
+
s += map[e] ?? e;
|
|
872
|
+
i++;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
s += ch;
|
|
877
|
+
i++;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
i++; // closing quote
|
|
881
|
+
return s;
|
|
882
|
+
};
|
|
883
|
+
const parseVal = () => {
|
|
884
|
+
ws();
|
|
885
|
+
const c = text[i];
|
|
886
|
+
if (c === "{") {
|
|
887
|
+
const o = {};
|
|
888
|
+
i++;
|
|
889
|
+
ws();
|
|
890
|
+
if (text[i] === "}") {
|
|
891
|
+
i++;
|
|
892
|
+
return o;
|
|
893
|
+
}
|
|
894
|
+
for (;;) {
|
|
895
|
+
ws();
|
|
896
|
+
const key = parseStr();
|
|
897
|
+
ws();
|
|
898
|
+
i++; // ':'
|
|
899
|
+
o[key] = parseVal();
|
|
900
|
+
ws();
|
|
901
|
+
if (text[i] === ",") {
|
|
902
|
+
i++;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
i++; // '}'
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
return o;
|
|
909
|
+
}
|
|
910
|
+
if (c === "[") {
|
|
911
|
+
const a = [];
|
|
912
|
+
i++;
|
|
913
|
+
ws();
|
|
914
|
+
if (text[i] === "]") {
|
|
915
|
+
i++;
|
|
916
|
+
return a;
|
|
917
|
+
}
|
|
918
|
+
for (;;) {
|
|
919
|
+
a.push(parseVal());
|
|
920
|
+
ws();
|
|
921
|
+
if (text[i] === ",") {
|
|
922
|
+
i++;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
i++; // ']'
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
return a;
|
|
929
|
+
}
|
|
930
|
+
if (c === '"')
|
|
931
|
+
return parseStr();
|
|
932
|
+
if (c === "t") {
|
|
933
|
+
i += 4;
|
|
934
|
+
return true;
|
|
935
|
+
}
|
|
936
|
+
if (c === "f") {
|
|
937
|
+
i += 5;
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
if (c === "n") {
|
|
941
|
+
i += 4;
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
const start = i;
|
|
945
|
+
while (i < text.length && "+-0123456789.eE".includes(text[i]))
|
|
946
|
+
i++;
|
|
947
|
+
return { __num: text.slice(start, i) };
|
|
948
|
+
};
|
|
949
|
+
return parseVal();
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Canonical JSON byte-for-byte matching the directory (`federation.rs` / PHP `json_encode`):
|
|
953
|
+
* recursive lexicographic key-sort, no whitespace, `/` and non-ASCII unescaped (JS
|
|
954
|
+
* `JSON.stringify` already matches for strings), numbers emitted as their source literal.
|
|
955
|
+
*/
|
|
956
|
+
function canonicalJson(node) {
|
|
957
|
+
if (node === null || typeof node === "boolean" || typeof node === "string")
|
|
958
|
+
return JSON.stringify(node);
|
|
959
|
+
if (isLNum(node))
|
|
960
|
+
return node.__num;
|
|
961
|
+
if (Array.isArray(node))
|
|
962
|
+
return "[" + node.map(canonicalJson).join(",") + "]";
|
|
963
|
+
const keys = Object.keys(node).sort();
|
|
964
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(node[k])).join(",") + "}";
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* #456 --verify: cryptographically confirm this node's CREDIT_AWARD income against the
|
|
968
|
+
* directory's signed event log (defends against a lying directory). Resolves the directory's
|
|
969
|
+
* Ed25519 key from /.well-known/did.json and re-derives + verifies each award signature.
|
|
970
|
+
* Free-tier CREDIT_ALLOCATION is unsigned by design, so it is not counted here (no cry-wolf).
|
|
971
|
+
*/
|
|
972
|
+
async function verifyCreditAwards(directoryUrl, nodeId) {
|
|
973
|
+
const origin = new URL(directoryUrl).origin; // did.json lives at the host root, not under /api
|
|
974
|
+
const didResp = await fetch(`${origin}/.well-known/did.json`, { signal: AbortSignal.timeout(20000) });
|
|
975
|
+
const did = (await didResp.json());
|
|
976
|
+
const x = did.verificationMethod?.[0]?.publicKeyJwk?.x;
|
|
977
|
+
if (!x)
|
|
978
|
+
throw new Error("directory did.json has no Ed25519 verification key");
|
|
979
|
+
const raw = Buffer.from(x, "base64url");
|
|
980
|
+
if (raw.length !== 32)
|
|
981
|
+
throw new Error(`bad Ed25519 key length ${raw.length}`);
|
|
982
|
+
const pubKey = (0, node_crypto_1.createPublicKey)({
|
|
983
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
|
|
984
|
+
format: "der",
|
|
985
|
+
type: "spki",
|
|
986
|
+
});
|
|
987
|
+
const base = directoryUrl.replace(/\/+$/, "");
|
|
988
|
+
let sum = 0;
|
|
989
|
+
let verified = 0;
|
|
990
|
+
let failed = 0;
|
|
991
|
+
let since = 0;
|
|
992
|
+
for (;;) {
|
|
993
|
+
const url = `${base}/v1/events?event_types=CREDIT_AWARD&since_seq=${since}&limit=500`;
|
|
994
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(20000) });
|
|
995
|
+
const tree = losslessParse(await resp.text());
|
|
996
|
+
const events = tree["events"] ?? [];
|
|
997
|
+
if (events.length === 0)
|
|
998
|
+
break;
|
|
999
|
+
let maxSeq = since;
|
|
1000
|
+
for (const evRaw of events) {
|
|
1001
|
+
const ev = evRaw;
|
|
1002
|
+
const seqNode = ev["seq"];
|
|
1003
|
+
const seq = isLNum(seqNode) ? Number(seqNode.__num) : 0;
|
|
1004
|
+
maxSeq = Math.max(maxSeq, seq);
|
|
1005
|
+
if (ev["event_type"] !== "CREDIT_AWARD" || ev["node_id"] !== nodeId)
|
|
1006
|
+
continue;
|
|
1007
|
+
const sig = ev["sig"];
|
|
1008
|
+
if (typeof sig !== "string")
|
|
1009
|
+
continue;
|
|
1010
|
+
const payload = ev["payload"];
|
|
1011
|
+
const payloadHash = (0, node_crypto_1.createHash)("sha256").update(canonicalJson(payload)).digest("hex");
|
|
1012
|
+
const eventId = typeof ev["event_id"] === "string" ? ev["event_id"] : "";
|
|
1013
|
+
const tsNode = ev["ts_ms"];
|
|
1014
|
+
const tsMs = isLNum(tsNode) ? Number(tsNode.__num) : 0;
|
|
1015
|
+
// #458: prev_hash (tamper-evident chain) is bound into the signing input; the directory
|
|
1016
|
+
// serves it per event, defaulting to GENESIS_ROOT for a genesis/legacy event.
|
|
1017
|
+
const prevHash = typeof ev["prev_hash"] === "string" ? ev["prev_hash"] : EVENT_LOG_GENESIS_ROOT;
|
|
1018
|
+
const msg = (0, node_crypto_1.createHash)("sha256")
|
|
1019
|
+
.update(`${eventId}:CREDIT_AWARD:${seq}:${tsMs}:${payloadHash}:${prevHash}`)
|
|
1020
|
+
.digest();
|
|
1021
|
+
const sigBuf = Buffer.from(sig, "hex");
|
|
1022
|
+
if (sigBuf.length === 64 && (0, node_crypto_1.verify)(null, msg, pubKey, sigBuf)) {
|
|
1023
|
+
verified++;
|
|
1024
|
+
const amt = typeof payload === "object" && payload !== null && !Array.isArray(payload)
|
|
1025
|
+
? payload["amount"]
|
|
1026
|
+
: undefined;
|
|
1027
|
+
if (amt !== undefined && isLNum(amt))
|
|
1028
|
+
sum += Number(amt.__num);
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
failed++;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (events.length < 500 || maxSeq <= since)
|
|
1035
|
+
break;
|
|
1036
|
+
since = maxSeq;
|
|
1037
|
+
}
|
|
1038
|
+
return { sum, verified, failed };
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* `iicp-node credits` (#456) — earned / spent / balance from the directory's
|
|
1042
|
+
* reconcile-checked GET /v1/credits/summary. Figures come authenticated from the
|
|
1043
|
+
* directory (not the local config), so editing the saved file cannot inflate them;
|
|
1044
|
+
* `reconciles` flags a ledger that does not add up.
|
|
1045
|
+
*/
|
|
1046
|
+
async function runCredits(argv) {
|
|
1047
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
1048
|
+
args: argv,
|
|
1049
|
+
options: {
|
|
1050
|
+
node: { type: "string" },
|
|
1051
|
+
"node-id": { type: "string" },
|
|
1052
|
+
token: { type: "string" },
|
|
1053
|
+
"directory-url": { type: "string" },
|
|
1054
|
+
json: { type: "boolean" },
|
|
1055
|
+
verify: { type: "boolean" },
|
|
1056
|
+
},
|
|
1057
|
+
allowPositionals: false,
|
|
1058
|
+
});
|
|
1059
|
+
const nodeName = values["node"];
|
|
1060
|
+
let directoryUrl = values["directory-url"];
|
|
1061
|
+
let nodeId = values["node-id"];
|
|
1062
|
+
let token = values["token"] ?? process.env["IICP_NODE_TOKEN"];
|
|
1063
|
+
if (nodeName) {
|
|
1064
|
+
const saved = (0, identity_js_1.loadNode)(nodeName);
|
|
1065
|
+
if (!saved) {
|
|
1066
|
+
process.stderr.write(`ERROR: no saved config at ~/.iicp/nodes/${nodeName}.json — run \`iicp-node init\` / \`serve\` first.\n`);
|
|
1067
|
+
return 1;
|
|
1068
|
+
}
|
|
1069
|
+
directoryUrl = directoryUrl ?? saved.directory_url;
|
|
1070
|
+
nodeId = nodeId ?? saved.node_id;
|
|
1071
|
+
token = token ?? saved.node_token;
|
|
1072
|
+
}
|
|
1073
|
+
directoryUrl = directoryUrl ?? process.env["IICP_DIRECTORY_URL"] ?? "https://iicp.network/api";
|
|
1074
|
+
if (!nodeId) {
|
|
1075
|
+
process.stderr.write("ERROR: node_id required (use --node NAME or --node-id ID)\n");
|
|
1076
|
+
return 1;
|
|
1077
|
+
}
|
|
1078
|
+
if (!token) {
|
|
1079
|
+
process.stderr.write("ERROR: no node_token — run `iicp-node serve` once (it caches the token), or pass --token / $IICP_NODE_TOKEN\n");
|
|
1080
|
+
return 1;
|
|
1081
|
+
}
|
|
1082
|
+
const url = `${directoryUrl.replace(/\/+$/, "")}/v1/credits/summary`;
|
|
1083
|
+
let resp;
|
|
1084
|
+
try {
|
|
1085
|
+
resp = await fetch(url, {
|
|
1086
|
+
headers: { Authorization: `Bearer ${token}`, "X-Node-Id": nodeId },
|
|
1087
|
+
signal: AbortSignal.timeout(15000),
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
catch (e) {
|
|
1091
|
+
process.stderr.write(`ERROR: request failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1092
|
+
return 1;
|
|
1093
|
+
}
|
|
1094
|
+
let body;
|
|
1095
|
+
try {
|
|
1096
|
+
body = (await resp.json());
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
process.stderr.write(`ERROR: bad response (HTTP ${resp.status})\n`);
|
|
1100
|
+
return 1;
|
|
1101
|
+
}
|
|
1102
|
+
if (!resp.ok) {
|
|
1103
|
+
const err = body["error"];
|
|
1104
|
+
process.stderr.write(`ERROR: HTTP ${resp.status}: ${err?.message ?? "request rejected"}\n`);
|
|
1105
|
+
return 1;
|
|
1106
|
+
}
|
|
1107
|
+
if (values["json"]) {
|
|
1108
|
+
process.stdout.write(JSON.stringify(body, null, 2) + "\n");
|
|
1109
|
+
return 0;
|
|
1110
|
+
}
|
|
1111
|
+
const earned = Number(body["total_earned"] ?? 0);
|
|
1112
|
+
const spent = Number(body["total_spent"] ?? 0);
|
|
1113
|
+
const balance = Number(body["balance"] ?? 0);
|
|
1114
|
+
const tx = Number(body["tx_count"] ?? 0);
|
|
1115
|
+
const reconciles = Boolean(body["reconciles"]);
|
|
1116
|
+
const tpc = Number(body["tokens_per_credit"] ?? 1000);
|
|
1117
|
+
const pad = (n) => n.toFixed(3).padStart(12);
|
|
1118
|
+
const check = reconciles ? "✓ reconciles" : "✗ DOES NOT RECONCILE";
|
|
1119
|
+
process.stdout.write(`IICP credits — ${nodeName ?? nodeId}\n`);
|
|
1120
|
+
process.stdout.write(` Earned (income) ${pad(earned)}\n`);
|
|
1121
|
+
process.stdout.write(` Spent ${pad(spent)}\n`);
|
|
1122
|
+
process.stdout.write(" ─────────────────────────────\n");
|
|
1123
|
+
process.stdout.write(` Balance ${pad(balance)} ${check} (≈ ${Math.trunc(balance * tpc)} tokens)\n`);
|
|
1124
|
+
process.stdout.write(` ${tx} transactions · \`iicp-node credits --json\` for raw\n`);
|
|
1125
|
+
if (!reconciles) {
|
|
1126
|
+
process.stderr.write("[iicp-node] WARNING: balance != earned − spent — the ledger does not reconcile; do not trust these figures.\n");
|
|
1127
|
+
}
|
|
1128
|
+
if (values["verify"]) {
|
|
1129
|
+
process.stdout.write(" ── cryptographic verification (signed CREDIT_AWARD log) ──\n");
|
|
1130
|
+
let v;
|
|
1131
|
+
try {
|
|
1132
|
+
v = await verifyCreditAwards(directoryUrl, nodeId);
|
|
1133
|
+
}
|
|
1134
|
+
catch (e) {
|
|
1135
|
+
process.stderr.write(`[iicp-node] --verify failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1136
|
+
return 1;
|
|
1137
|
+
}
|
|
1138
|
+
if (v.failed > 0) {
|
|
1139
|
+
process.stderr.write(`[iicp-node] ✗ ${v.failed} award event(s) FAILED Ed25519 verification — ` +
|
|
1140
|
+
"tampered or inconsistent event log. Do NOT trust these figures.\n");
|
|
1141
|
+
return 1;
|
|
1142
|
+
}
|
|
1143
|
+
process.stdout.write(` ✓ ${v.verified} award(s) cryptographically verified · ${v.sum.toFixed(3)} credits ` +
|
|
1144
|
+
"(Ed25519, signed by the directory)\n");
|
|
1145
|
+
const freeTier = earned - v.sum;
|
|
1146
|
+
if (freeTier > 0.0001) {
|
|
1147
|
+
process.stdout.write(` · ${freeTier.toFixed(3)} credits are free-tier allocation ` +
|
|
1148
|
+
"(directory-granted, not signed task awards)\n");
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return 0;
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Resolve a passphrase: $IICP_OPERATOR_PASSPHRASE if set (headless/CI), else an interactive
|
|
1155
|
+
* readline prompt (this command is operator-run, so a prompt is fine here — only `serve` must
|
|
1156
|
+
* stay non-interactive). For `confirm`, the prompt is asked twice and must match.
|
|
1157
|
+
*/
|
|
1158
|
+
async function operatorPassphrase(prompt, confirm) {
|
|
1159
|
+
const env = process.env["IICP_OPERATOR_PASSPHRASE"];
|
|
1160
|
+
if (env)
|
|
1161
|
+
return env;
|
|
1162
|
+
const { createInterface } = await import("node:readline/promises");
|
|
1163
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1164
|
+
try {
|
|
1165
|
+
const pw = (await rl.question(prompt)).trim();
|
|
1166
|
+
if (confirm && pw !== (await rl.question("Confirm passphrase: ")).trim()) {
|
|
1167
|
+
process.stderr.write("ERROR: passphrases do not match.\n");
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
return pw || null;
|
|
1171
|
+
}
|
|
1172
|
+
finally {
|
|
1173
|
+
rl.close();
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
/** `iicp-node operator encrypt` (#460) — seal the operator secret at rest under a passphrase. */
|
|
1177
|
+
async function runOperatorEncrypt() {
|
|
1178
|
+
const op = (0, identity_js_1.loadOperator)();
|
|
1179
|
+
if (!op) {
|
|
1180
|
+
process.stderr.write("ERROR: no operator identity — run `iicp-node init` first.\n");
|
|
1181
|
+
return 1;
|
|
1182
|
+
}
|
|
1183
|
+
if ((0, identity_js_1.operatorIsEncrypted)(op)) {
|
|
1184
|
+
process.stdout.write("Operator secret is already encrypted at rest.\n");
|
|
1185
|
+
return 0;
|
|
1186
|
+
}
|
|
1187
|
+
if (!(0, identity_js_1.operatorIsKeyBacked)(op)) {
|
|
1188
|
+
process.stderr.write("ERROR: legacy keyless operator identity has nothing to encrypt (#464).\n");
|
|
1189
|
+
return 1;
|
|
1190
|
+
}
|
|
1191
|
+
const pw = await operatorPassphrase("New operator passphrase: ", true);
|
|
1192
|
+
if (!pw) {
|
|
1193
|
+
process.stderr.write("ERROR: a non-empty passphrase is required.\n");
|
|
1194
|
+
return 1;
|
|
1195
|
+
}
|
|
1196
|
+
(0, identity_js_1.saveOperator)((0, identity_js_1.operatorEncryptAtRest)(op, pw));
|
|
1197
|
+
process.stdout.write("Operator secret encrypted at rest (AES-256-GCM / PBKDF2). Set $IICP_OPERATOR_PASSPHRASE " +
|
|
1198
|
+
"to unlock it headlessly during `serve`.\n");
|
|
1199
|
+
return 0;
|
|
1200
|
+
}
|
|
1201
|
+
/** `iicp-node operator decrypt` (#460) — restore the plaintext secret at rest. */
|
|
1202
|
+
async function runOperatorDecrypt() {
|
|
1203
|
+
const op = (0, identity_js_1.loadOperator)();
|
|
1204
|
+
if (!op) {
|
|
1205
|
+
process.stderr.write("ERROR: no operator identity — run `iicp-node init` first.\n");
|
|
1206
|
+
return 1;
|
|
1207
|
+
}
|
|
1208
|
+
if (!(0, identity_js_1.operatorIsEncrypted)(op)) {
|
|
1209
|
+
process.stdout.write("Operator secret is already stored in plaintext.\n");
|
|
1210
|
+
return 0;
|
|
1211
|
+
}
|
|
1212
|
+
const pw = await operatorPassphrase("Operator passphrase: ", false);
|
|
1213
|
+
if (!pw) {
|
|
1214
|
+
process.stderr.write("ERROR: a passphrase is required to decrypt.\n");
|
|
1215
|
+
return 1;
|
|
1216
|
+
}
|
|
1217
|
+
try {
|
|
1218
|
+
(0, identity_js_1.saveOperator)((0, identity_js_1.operatorDecryptAtRest)(op, pw));
|
|
1219
|
+
}
|
|
1220
|
+
catch (e) {
|
|
1221
|
+
process.stderr.write(`ERROR: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1222
|
+
return 1;
|
|
1223
|
+
}
|
|
1224
|
+
process.stdout.write("Operator secret decrypted (now stored in plaintext at rest).\n");
|
|
1225
|
+
return 0;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* `iicp-node operator rename <name>` (#460) — change the public, mutable display_name over
|
|
1229
|
+
* the immutable operator_id. The operator signs the canonical rename bytes with their own
|
|
1230
|
+
* key, so the directory authenticates the change by signature alone (no node token); one
|
|
1231
|
+
* signed call updates the single operator record, reflected on every node + the leaderboard.
|
|
1232
|
+
* Updates the local operator.json on success. Never sends the secret/contact.
|
|
1233
|
+
*/
|
|
1234
|
+
async function runOperator(argv) {
|
|
1235
|
+
const sub = argv[0];
|
|
1236
|
+
if (sub === "encrypt")
|
|
1237
|
+
return runOperatorEncrypt();
|
|
1238
|
+
if (sub === "decrypt")
|
|
1239
|
+
return runOperatorDecrypt();
|
|
1240
|
+
if (sub !== "rename") {
|
|
1241
|
+
process.stderr.write(`unknown operator subcommand: ${sub ?? "(none)"}\n`);
|
|
1242
|
+
return 2;
|
|
1243
|
+
}
|
|
1244
|
+
const { values, positionals } = (0, node_util_1.parseArgs)({
|
|
1245
|
+
args: argv.slice(1),
|
|
1246
|
+
options: { "directory-url": { type: "string" } },
|
|
1247
|
+
allowPositionals: true,
|
|
1248
|
+
});
|
|
1249
|
+
const name = positionals[0];
|
|
1250
|
+
// eslint-disable-next-line no-control-regex
|
|
1251
|
+
if (!name || name.length > 64 || /[\u0000-\u001f\u007f]/.test(name)) {
|
|
1252
|
+
process.stderr.write("ERROR: display name must be 1-64 chars with no control characters.\n");
|
|
1253
|
+
return 1;
|
|
1254
|
+
}
|
|
1255
|
+
const op = (0, identity_js_1.loadOperator)();
|
|
1256
|
+
if (!op) {
|
|
1257
|
+
process.stderr.write("ERROR: no operator identity — run `iicp-node init` first.\n");
|
|
1258
|
+
return 1;
|
|
1259
|
+
}
|
|
1260
|
+
if (!(0, identity_js_1.operatorIsKeyBacked)(op)) {
|
|
1261
|
+
process.stderr.write("ERROR: legacy keyless operator identity (operator_id is a UUID, not a key) — " +
|
|
1262
|
+
"cannot sign a rename. Regenerate with a key-backed identity (#464).\n");
|
|
1263
|
+
return 1;
|
|
1264
|
+
}
|
|
1265
|
+
const directoryUrl = values["directory-url"] ??
|
|
1266
|
+
process.env["IICP_DIRECTORY_URL"] ??
|
|
1267
|
+
"https://iicp.network/api";
|
|
1268
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
1269
|
+
const sig = (0, delegation_js_1.signRename)((0, identity_js_1.operatorSigningKey)(op), name, op.operator_id, ts);
|
|
1270
|
+
const url = `${directoryUrl.replace(/\/+$/, "")}/v1/operator/rename`;
|
|
1271
|
+
let resp;
|
|
1272
|
+
try {
|
|
1273
|
+
resp = await fetch(url, {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers: { "Content-Type": "application/json" },
|
|
1276
|
+
body: JSON.stringify({ operator_pub: op.operator_id, display_name: name, ts, sig }),
|
|
1277
|
+
signal: AbortSignal.timeout(15000),
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
catch (e) {
|
|
1281
|
+
process.stderr.write(`ERROR: request failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1282
|
+
return 1;
|
|
1283
|
+
}
|
|
1284
|
+
let body;
|
|
1285
|
+
try {
|
|
1286
|
+
body = (await resp.json());
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
body = {};
|
|
1290
|
+
}
|
|
1291
|
+
if (!resp.ok) {
|
|
1292
|
+
const err = body["error"];
|
|
1293
|
+
process.stderr.write(`ERROR: HTTP ${resp.status}: ${err?.message ?? "request rejected"}\n`);
|
|
1294
|
+
return 1;
|
|
1295
|
+
}
|
|
1296
|
+
// Persist the new name locally so the next `serve` re-asserts it at register.
|
|
1297
|
+
op.display_name = body["display_name"] ?? name;
|
|
1298
|
+
(0, identity_js_1.saveOperator)(op);
|
|
1299
|
+
process.stdout.write(`Renamed operator display_name to ${JSON.stringify(op.display_name)}.\n`);
|
|
1300
|
+
return 0;
|
|
1301
|
+
}
|
|
790
1302
|
async function main(argv = process.argv.slice(2)) {
|
|
791
1303
|
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
|
|
792
1304
|
printHelp();
|
|
@@ -803,6 +1315,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
803
1315
|
return runList();
|
|
804
1316
|
if (cmd === "query")
|
|
805
1317
|
return runQuery(argv.slice(1));
|
|
1318
|
+
if (cmd === "credits")
|
|
1319
|
+
return runCredits(argv.slice(1));
|
|
1320
|
+
if (cmd === "operator")
|
|
1321
|
+
return runOperator(argv.slice(1));
|
|
806
1322
|
if (cmd !== "serve") {
|
|
807
1323
|
process.stderr.write(`unknown command: ${cmd}\n`);
|
|
808
1324
|
printHelp();
|