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