arc402-cli 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -188,6 +188,9 @@ function checkRateLimit(ip: string): boolean {
188
188
  return bucket.count <= RATE_LIMIT;
189
189
  }
190
190
 
191
+ // Cleanup stale rate limit entries every 5 minutes to prevent unbounded growth
192
+ let rateLimitCleanupInterval: ReturnType<typeof setInterval> | null = null;
193
+
191
194
  // ─── Body size limit ──────────────────────────────────────────────────────────
192
195
 
193
196
  const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
@@ -588,6 +591,14 @@ export async function runDaemon(foreground = false): Promise<void> {
588
591
  }
589
592
  }, 30_000);
590
593
 
594
+ // Rate limit map cleanup — every 5 minutes (prevents unbounded growth)
595
+ rateLimitCleanupInterval = setInterval(() => {
596
+ const now = Date.now();
597
+ for (const [ip, bucket] of rateLimitMap) {
598
+ if (bucket.resetTime < now) rateLimitMap.delete(ip);
599
+ }
600
+ }, 5 * 60 * 1000);
601
+
591
602
  // Balance monitor — every 5 minutes
592
603
  const balanceInterval = setInterval(async () => {
593
604
  try {
@@ -617,6 +628,26 @@ export async function runDaemon(foreground = false): Promise<void> {
617
628
  // ── Start HTTP relay server (public endpoint) ────────────────────────────
618
629
  const httpPort = config.relay.listen_port ?? 4402;
619
630
 
631
+ /**
632
+ * Optionally verifies X-ARC402-Signature against the request body.
633
+ * Logs the result but never rejects — unsigned requests are accepted for backwards compat.
634
+ */
635
+ function verifyRequestSignature(body: string, req: http.IncomingMessage): void {
636
+ const sig = req.headers["x-arc402-signature"] as string | undefined;
637
+ if (!sig) return;
638
+ const claimedSigner = req.headers["x-arc402-signer"] as string | undefined;
639
+ try {
640
+ const recovered = ethers.verifyMessage(body, sig);
641
+ if (claimedSigner && recovered.toLowerCase() !== claimedSigner.toLowerCase()) {
642
+ log({ event: "sig_mismatch", claimed: claimedSigner, recovered });
643
+ } else {
644
+ log({ event: "sig_verified", signer: recovered });
645
+ }
646
+ } catch {
647
+ log({ event: "sig_invalid" });
648
+ }
649
+ }
650
+
620
651
  /**
621
652
  * Read request body with a size cap. Destroys the request and sends 413
622
653
  * if the body exceeds MAX_BODY_SIZE. Returns null in that case.
@@ -643,11 +674,22 @@ export async function runDaemon(foreground = false): Promise<void> {
643
674
 
644
675
  const PUBLIC_GET_PATHS = new Set(["/", "/health", "/agent", "/capabilities", "/status"]);
645
676
 
677
+ // CORS whitelist — localhost for local tooling, arc402.xyz for the web app
678
+ const CORS_WHITELIST = new Set(["localhost", "127.0.0.1", "arc402.xyz", "app.arc402.xyz"]);
679
+
646
680
  const httpServer = http.createServer(async (req, res) => {
647
- // CORS headers
648
- res.setHeader("Access-Control-Allow-Origin", "*");
649
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
650
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
681
+ // CORS — only reflect origin header if it's in the whitelist
682
+ const origin = (req.headers["origin"] ?? "") as string;
683
+ if (origin) {
684
+ try {
685
+ const { hostname } = new URL(origin);
686
+ if (CORS_WHITELIST.has(hostname)) {
687
+ res.setHeader("Access-Control-Allow-Origin", origin);
688
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
689
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
690
+ }
691
+ } catch { /* ignore invalid origin */ }
692
+ }
651
693
  if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
652
694
 
653
695
  const url = new URL(req.url || "/", `http://localhost:${httpPort}`);
@@ -707,6 +749,7 @@ export async function runDaemon(foreground = false): Promise<void> {
707
749
  if (pathname === "/hire" && req.method === "POST") {
708
750
  const body = await readBody(req, res);
709
751
  if (body === null) return;
752
+ verifyRequestSignature(body, req);
710
753
  try {
711
754
  const msg = JSON.parse(body) as Record<string, unknown>;
712
755
 
@@ -787,6 +830,7 @@ export async function runDaemon(foreground = false): Promise<void> {
787
830
  if (pathname === "/handshake" && req.method === "POST") {
788
831
  const body = await readBody(req, res);
789
832
  if (body === null) return;
833
+ verifyRequestSignature(body, req);
790
834
  try {
791
835
  const msg = JSON.parse(body);
792
836
  log({ event: "handshake_received", from: msg.from, type: msg.type, note: msg.note });
@@ -824,6 +868,7 @@ export async function runDaemon(foreground = false): Promise<void> {
824
868
  if (pathname === "/message" && req.method === "POST") {
825
869
  const body = await readBody(req, res);
826
870
  if (body === null) return;
871
+ verifyRequestSignature(body, req);
827
872
  try {
828
873
  const msg = JSON.parse(body) as Record<string, unknown>;
829
874
  const from = String(msg.from ?? "");
@@ -844,6 +889,7 @@ export async function runDaemon(foreground = false): Promise<void> {
844
889
  if (pathname === "/delivery" && req.method === "POST") {
845
890
  const body = await readBody(req, res);
846
891
  if (body === null) return;
892
+ verifyRequestSignature(body, req);
847
893
  try {
848
894
  const msg = JSON.parse(body) as Record<string, unknown>;
849
895
  const agreementId = String(msg.agreementId ?? msg.agreement_id ?? "");
@@ -961,21 +1007,25 @@ export async function runDaemon(foreground = false): Promise<void> {
961
1007
  return;
962
1008
  }
963
1009
 
964
- // GET /status — health with active agreement count
1010
+ // GET /status — health with active agreement count (sensitive counts only for authenticated)
965
1011
  if (pathname === "/status" && req.method === "GET") {
966
- const activeList = db.listActiveHireRequests();
967
- const pendingList = db.listPendingHireRequests();
968
- res.writeHead(200, { "Content-Type": "application/json" });
969
- res.end(JSON.stringify({
1012
+ const statusAuth = (req.headers["authorization"] ?? "") as string;
1013
+ const statusToken = statusAuth.startsWith("Bearer ") ? statusAuth.slice(7) : "";
1014
+ const statusAuthed = statusToken === apiToken;
1015
+ const statusPayload: Record<string, unknown> = {
970
1016
  protocol: "arc-402",
971
1017
  version: "0.3.0",
972
1018
  agent: config.wallet.contract_address,
973
1019
  status: "online",
974
1020
  uptime: Math.floor((Date.now() - ipcCtx.startTime) / 1000),
975
- active_agreements: activeList.length,
976
- pending_approval: pendingList.length,
977
1021
  capabilities: config.policy.allowed_capabilities,
978
- }));
1022
+ };
1023
+ if (statusAuthed) {
1024
+ statusPayload.active_agreements = db.listActiveHireRequests().length;
1025
+ statusPayload.pending_approval = db.listPendingHireRequests().length;
1026
+ }
1027
+ res.writeHead(200, { "Content-Type": "application/json" });
1028
+ res.end(JSON.stringify(statusPayload));
979
1029
  return;
980
1030
  }
981
1031
 
@@ -1011,6 +1061,7 @@ export async function runDaemon(foreground = false): Promise<void> {
1011
1061
  if (relayInterval) clearInterval(relayInterval);
1012
1062
  clearInterval(timeoutInterval);
1013
1063
  clearInterval(balanceInterval);
1064
+ if (rateLimitCleanupInterval) clearInterval(rateLimitCleanupInterval);
1014
1065
 
1015
1066
  // Close HTTP + IPC
1016
1067
  httpServer.close();
@@ -92,11 +92,13 @@ export async function resolveAgentEndpoint(
92
92
  * POSTs JSON payload to {endpoint}{path}. Returns true on success.
93
93
  * Never throws — logs a warning on failure.
94
94
  * Validates endpoint URL for SSRF before connecting.
95
+ * If signingKey is provided, signs the payload and adds X-ARC402-Signature / X-ARC402-Signer headers.
95
96
  */
96
97
  export async function notifyAgent(
97
98
  endpoint: string,
98
99
  path: string,
99
- payload: Record<string, unknown>
100
+ payload: Record<string, unknown>,
101
+ signingKey?: string
100
102
  ): Promise<boolean> {
101
103
  if (!endpoint) return false;
102
104
  try {
@@ -106,10 +108,18 @@ export async function notifyAgent(
106
108
  return false;
107
109
  }
108
110
  try {
111
+ const body = JSON.stringify(payload);
112
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
113
+ if (signingKey) {
114
+ const wallet = new ethers.Wallet(signingKey);
115
+ const signature = await wallet.signMessage(body);
116
+ headers["X-ARC402-Signature"] = signature;
117
+ headers["X-ARC402-Signer"] = wallet.address;
118
+ }
109
119
  const res = await fetch(`${endpoint}${path}`, {
110
120
  method: "POST",
111
- headers: { "Content-Type": "application/json" },
112
- body: JSON.stringify(payload),
121
+ headers,
122
+ body,
113
123
  });
114
124
  return res.ok;
115
125
  } catch (err) {
package/src/index.ts CHANGED
@@ -1,6 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import { createProgram } from "./program";
3
3
  import { startREPL } from "./repl";
4
+ import { configExists, loadConfig, saveConfig } from "./config";
5
+
6
+ // ── Upgrade safety check ────────────────────────────────────────────────────
7
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
8
+ const currentVersion: string = (require("../package.json") as { version: string }).version;
9
+
10
+ function checkUpgrade(): void {
11
+ if (!configExists()) return;
12
+ try {
13
+ const config = loadConfig();
14
+ const prev = config.lastCliVersion;
15
+ if (prev && prev !== currentVersion) {
16
+ // Compare semver loosely — just print if different
17
+ console.log(`◈ Upgraded from ${prev} → ${currentVersion}`);
18
+ }
19
+ if (config.lastCliVersion !== currentVersion) {
20
+ // Never overwrite existing fields — only update lastCliVersion
21
+ saveConfig({ ...config, lastCliVersion: currentVersion });
22
+ }
23
+ } catch {
24
+ // Never crash on upgrade check
25
+ }
26
+ }
4
27
 
5
28
  const printMode = process.argv.includes("--print");
6
29
 
@@ -11,6 +34,7 @@ if (printMode) {
11
34
  process.env["NO_COLOR"] = "1";
12
35
  process.env["FORCE_COLOR"] = "0";
13
36
  process.env["ARC402_PRINT"] = "1";
37
+ checkUpgrade();
14
38
  const program = createProgram();
15
39
  void program.parseAsync(process.argv).then(() => process.exit(0)).catch((e: unknown) => {
16
40
  console.error(e instanceof Error ? e.message : String(e));
@@ -18,9 +42,11 @@ if (printMode) {
18
42
  });
19
43
  } else if (process.argv.length <= 2) {
20
44
  // No subcommand — enter interactive REPL
45
+ checkUpgrade();
21
46
  void startREPL();
22
47
  } else {
23
48
  // One-shot mode — arc402 wallet deploy still works as usual
49
+ checkUpgrade();
24
50
  const program = createProgram();
25
51
  program.parse(process.argv);
26
52
  }
package/src/program.ts CHANGED
@@ -32,6 +32,7 @@ import { registerMigrateCommands } from "./commands/migrate";
32
32
  import { registerFeedCommand } from "./commands/feed";
33
33
  import { registerArenaCommands } from "./commands/arena";
34
34
  import { registerWatchCommand } from "./commands/watch";
35
+ import { registerBackupCommand } from "./commands/backup";
35
36
  import reputation from "./commands/reputation.js";
36
37
  import policy from "./commands/policy.js";
37
38
 
@@ -78,6 +79,7 @@ export function createProgram(): Command {
78
79
  registerFeedCommand(program);
79
80
  registerArenaCommands(program);
80
81
  registerWatchCommand(program);
82
+ registerBackupCommand(program);
81
83
  program.addCommand(reputation);
82
84
  program.addCommand(policy);
83
85