ape-claw 0.1.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.
Files changed (114) hide show
  1. package/.cursor/skills/ape-claw/SKILL.md +322 -0
  2. package/LICENSE +21 -0
  3. package/README.md +826 -0
  4. package/allowlists/opensea-slug-overrides.json +13 -0
  5. package/allowlists/recommended.apechain.json +322 -0
  6. package/config/clawbots.example.json +3 -0
  7. package/config/policy.example.json +27 -0
  8. package/data/starter-pack-bundle.json +1 -0
  9. package/data/starter-pack.json +495 -0
  10. package/docs/ACP_BOUNTIES.md +108 -0
  11. package/docs/APECLAW_V2_ALPHA.md +206 -0
  12. package/docs/AUTONOMY_AND_SUBSTRATE.md +69 -0
  13. package/docs/CLAWBOTS_AND_INVITES.md +102 -0
  14. package/docs/CLI_GUIDE.md +124 -0
  15. package/docs/CONTRIBUTING.md +130 -0
  16. package/docs/DASHBOARD_GUIDE.md +108 -0
  17. package/docs/GLOBAL_BACKEND.md +145 -0
  18. package/docs/ONCHAIN_V2_GUIDE.md +140 -0
  19. package/docs/PRODUCT_OVERVIEW.md +127 -0
  20. package/docs/README.md +40 -0
  21. package/docs/SKILLCARDS_AND_IMPORTER.md +147 -0
  22. package/docs/STARTER_PACK.md +297 -0
  23. package/docs/SUPPORTED_NETWORKS.md +58 -0
  24. package/docs/TELEMETRY_AND_EVENTS.md +103 -0
  25. package/docs/THE_POD_RUNNER.md +198 -0
  26. package/docs/V1_WORKFLOWS.md +108 -0
  27. package/docs/V2_ONCHAIN_SKILLS.md +157 -0
  28. package/docs/WEB4_PLAN_STATUS.md +95 -0
  29. package/docs/WEB4_SWARM_MODEL.md +104 -0
  30. package/docs/archive/AUTONOMY_AND_SUBSTRATE.md +66 -0
  31. package/docs/archive/WEB4_PLAN_STATUS.md +93 -0
  32. package/docs/archive/WEB4_SWARM_MODEL.md +98 -0
  33. package/docs/developer/01-architecture.md +345 -0
  34. package/docs/developer/02-contracts.md +1034 -0
  35. package/docs/developer/03-writing-modules.md +513 -0
  36. package/docs/developer/04-skillcard-spec.md +336 -0
  37. package/docs/developer/05-backend-api.md +1079 -0
  38. package/docs/developer/06-telemetry.md +798 -0
  39. package/docs/developer/07-testing.md +546 -0
  40. package/docs/developer/08-contributing.md +211 -0
  41. package/docs/operator/01-quickstart.md +49 -0
  42. package/docs/operator/02-dashboard.md +174 -0
  43. package/docs/operator/03-cli-reference.md +818 -0
  44. package/docs/operator/04-skills-library.md +169 -0
  45. package/docs/operator/05-pod-operations.md +314 -0
  46. package/docs/operator/06-deployment.md +299 -0
  47. package/docs/operator/07-safety-and-policy.md +311 -0
  48. package/docs/operator/08-troubleshooting.md +457 -0
  49. package/docs/operator/09-env-reference.md +238 -0
  50. package/docs/social/STARTER_PACK_THREAD.md +209 -0
  51. package/package.json +77 -0
  52. package/skillcards/import-sources.json +93 -0
  53. package/skillcards/seed/acp-bounty-poll.v1.json +38 -0
  54. package/skillcards/seed/acp-bounty-post.v1.json +55 -0
  55. package/skillcards/seed/acp-browse.v1.json +41 -0
  56. package/skillcards/seed/acp-fulfill-and-route.v1.json +56 -0
  57. package/skillcards/seed/apeclaw-bridge-relay.v1.json +46 -0
  58. package/skillcards/seed/apeclaw-nft-autobuy.v1.json +60 -0
  59. package/skillcards/seed/apeclaw-receipt-recorder.v1.json +64 -0
  60. package/skillcards/seed/humanizer.v1.json +74 -0
  61. package/skillcards/seed/otherside-navigator.v1.json +116 -0
  62. package/skillcards/seed/stonkbrokers-launcher.v1.json +280 -0
  63. package/skillcards/seed/walkie-p2p.v1.json +66 -0
  64. package/src/cli/index.mjs +8 -0
  65. package/src/cli.mjs +1929 -0
  66. package/src/lib/bridge-relay.mjs +294 -0
  67. package/src/lib/clawbots.mjs +94 -0
  68. package/src/lib/io.mjs +36 -0
  69. package/src/lib/market.mjs +233 -0
  70. package/src/lib/nft-opensea.mjs +159 -0
  71. package/src/lib/paths.mjs +17 -0
  72. package/src/lib/pod-init.mjs +40 -0
  73. package/src/lib/policy.mjs +112 -0
  74. package/src/lib/rpc.mjs +49 -0
  75. package/src/lib/telemetry.mjs +92 -0
  76. package/src/lib/v2-onchain-abi.mjs +294 -0
  77. package/src/lib/v2-skillcard.mjs +27 -0
  78. package/src/server/index.mjs +169 -0
  79. package/src/server/logger.mjs +21 -0
  80. package/src/server/middleware/auth.mjs +90 -0
  81. package/src/server/middleware/body-limit.mjs +35 -0
  82. package/src/server/middleware/cors.mjs +33 -0
  83. package/src/server/middleware/rate-limit.mjs +44 -0
  84. package/src/server/routes/chat.mjs +178 -0
  85. package/src/server/routes/clawbots.mjs +182 -0
  86. package/src/server/routes/events.mjs +95 -0
  87. package/src/server/routes/health.mjs +72 -0
  88. package/src/server/routes/pod.mjs +64 -0
  89. package/src/server/routes/quotes.mjs +161 -0
  90. package/src/server/routes/skills.mjs +239 -0
  91. package/src/server/routes/static.mjs +161 -0
  92. package/src/server/routes/v2.mjs +48 -0
  93. package/src/server/sse.mjs +73 -0
  94. package/src/server/storage/file-backend.mjs +295 -0
  95. package/src/server/storage/index.mjs +37 -0
  96. package/src/server/storage/sqlite-backend.mjs +380 -0
  97. package/src/telemetry-server.mjs +1604 -0
  98. package/ui/css/dashboard.css +792 -0
  99. package/ui/css/skills.css +689 -0
  100. package/ui/docs.html +840 -0
  101. package/ui/favicon-180.png +0 -0
  102. package/ui/favicon-192.png +0 -0
  103. package/ui/favicon-32.png +0 -0
  104. package/ui/favicon-lobster.png +0 -0
  105. package/ui/favicon.svg +10 -0
  106. package/ui/index.html +2957 -0
  107. package/ui/js/dashboard.js +1766 -0
  108. package/ui/js/skills.js +1621 -0
  109. package/ui/pod.html +909 -0
  110. package/ui/shared/motion.css +286 -0
  111. package/ui/shared/motion.js +170 -0
  112. package/ui/shared/sidebar-nav.css +379 -0
  113. package/ui/shared/sidebar-nav.js +137 -0
  114. package/ui/skills.html +2879 -0
@@ -0,0 +1,44 @@
1
+ const buckets = new Map();
2
+
3
+ function clientKey(req, prefix) {
4
+ const xff = String(req.headers["x-forwarded-for"] || "").trim();
5
+ const ip = xff ? xff.split(",")[0].trim() : String(req.socket?.remoteAddress || "unknown");
6
+ return `${prefix}:${ip}`;
7
+ }
8
+
9
+ /**
10
+ * Returns true (and sends 429) if the request exceeds the rate limit.
11
+ * Returns false if the request is within limits.
12
+ */
13
+ export function checkRateLimit(req, res, { limit = 60, windowMs = 60_000, keyPrefix = "default" } = {}) {
14
+ const key = clientKey(req, keyPrefix);
15
+ const now = Date.now();
16
+ let bucket = buckets.get(key);
17
+
18
+ if (!bucket || now - bucket.windowStart > windowMs) {
19
+ bucket = { windowStart: now, count: 0 };
20
+ buckets.set(key, bucket);
21
+ }
22
+
23
+ bucket.count++;
24
+
25
+ if (bucket.count > limit) {
26
+ const retryAfter = Math.ceil((bucket.windowStart + windowMs - now) / 1000);
27
+ res.writeHead(429, {
28
+ "content-type": "application/json",
29
+ "retry-after": String(retryAfter),
30
+ });
31
+ res.end(JSON.stringify({ error: "rate limit exceeded", retryAfterSeconds: retryAfter }));
32
+ return true;
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+ // Purge stale buckets every 5 minutes
39
+ setInterval(() => {
40
+ const cutoff = Date.now() - 300_000;
41
+ for (const [key, bucket] of buckets) {
42
+ if (bucket.windowStart < cutoff) buckets.delete(key);
43
+ }
44
+ }, 300_000).unref();
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Routes: /api/chat/*, /api/chat/stream (SSE)
3
+ */
4
+
5
+ import { getStorage } from "../storage/index.mjs";
6
+ import { addChatClient } from "../sse.mjs";
7
+ import { resolveChatAuth } from "../middleware/auth.mjs";
8
+ import { collectBody } from "../middleware/body-limit.mjs";
9
+
10
+ function normalizeRoomName(input) {
11
+ const raw = String(input || "general").toLowerCase().trim()
12
+ .replace(/[^a-z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
13
+ return raw || "general";
14
+ }
15
+
16
+ function materializeChatMessages(entries, room = "all") {
17
+ const targetRoom = normalizeRoomName(room);
18
+ const byId = new Map();
19
+ const ordered = [];
20
+
21
+ for (const e of entries) {
22
+ if (String(e.type || "message") !== "message") continue;
23
+ const msg = {
24
+ id: e.id, type: "message", agentId: e.agentId, agentName: e.agentName,
25
+ identityProvider: e.identityProvider, identityMeta: e.identityMeta || {},
26
+ room: normalizeRoomName(e.room || "general"), text: e.text, ts: e.ts,
27
+ replyTo: e.replyTo || null, reactions: {}, reactionUsers: {},
28
+ };
29
+ byId.set(msg.id, msg);
30
+ ordered.push(msg);
31
+ }
32
+
33
+ for (const e of entries) {
34
+ if (String(e.type || "") !== "reaction") continue;
35
+ const msg = byId.get(e.messageId);
36
+ if (!msg) continue;
37
+ const emoji = String(e.emoji || "").trim();
38
+ const agentId = String(e.agentId || "").trim();
39
+ if (!emoji || !agentId) continue;
40
+ const current = new Set(msg.reactionUsers[emoji] || []);
41
+ if (current.has(agentId)) current.delete(agentId);
42
+ else current.add(agentId);
43
+ msg.reactionUsers[emoji] = [...current];
44
+ msg.reactions[emoji] = msg.reactionUsers[emoji].length;
45
+ }
46
+
47
+ return targetRoom === "all" ? ordered : ordered.filter((m) => normalizeRoomName(m.room) === targetRoom);
48
+ }
49
+
50
+ export function handleChatStream(req, res, reqUrl) {
51
+ const room = normalizeRoomName(reqUrl.searchParams.get("room") || "all");
52
+ res.writeHead(200, {
53
+ "content-type": "text/event-stream",
54
+ "cache-control": "no-cache",
55
+ connection: "keep-alive",
56
+ });
57
+ res.write("\n");
58
+ const remove = addChatClient(res, room);
59
+ req.on("close", remove);
60
+ }
61
+
62
+ export function handleChatGet(req, res, reqUrl) {
63
+ const store = getStorage();
64
+ const room = normalizeRoomName(reqUrl.searchParams.get("room") || "all");
65
+ const limit = Math.max(1, Math.min(500, Number(reqUrl.searchParams.get("limit") || 100)));
66
+ const entries = store.readChatEntries();
67
+ const messages = materializeChatMessages(entries, room).slice(-limit);
68
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
69
+ return res.end(JSON.stringify({ room, limit, messages }));
70
+ }
71
+
72
+ export function handleChatRooms(req, res, reqUrl) {
73
+ const store = getStorage();
74
+ const limit = Math.max(1, Math.min(200, Number(reqUrl.searchParams.get("limit") || 50)));
75
+ const parsed = materializeChatMessages(store.readChatEntries(), "all");
76
+ const byRoom = new Map();
77
+ for (const m of parsed) {
78
+ const room = normalizeRoomName(m.room || "general");
79
+ const prev = byRoom.get(room) || { room, count: 0, lastTs: null, lastMessage: "", participants: new Set() };
80
+ prev.count += 1;
81
+ prev.lastTs = m.ts || prev.lastTs;
82
+ prev.lastMessage = m.text || prev.lastMessage;
83
+ if (m.agentId) prev.participants.add(m.agentId);
84
+ byRoom.set(room, prev);
85
+ }
86
+ const rooms = [...byRoom.values()]
87
+ .map((r) => ({ room: r.room, count: r.count, lastTs: r.lastTs, lastMessage: r.lastMessage, participants: r.participants.size }))
88
+ .sort((a, b) => String(b.lastTs || "").localeCompare(String(a.lastTs || "")))
89
+ .slice(0, limit);
90
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
91
+ return res.end(JSON.stringify({ count: rooms.length, rooms }));
92
+ }
93
+
94
+ export async function handleChatPost(req, res, reqUrl) {
95
+ const raw = await collectBody(req, res);
96
+ if (raw === null) return;
97
+ try {
98
+ const body = JSON.parse(raw);
99
+ const store = getStorage();
100
+ const room = normalizeRoomName(body.room || reqUrl.searchParams.get("room") || "general");
101
+ const text = String(body.text || "").trim();
102
+ const replyTo = String(body.replyTo || "").trim();
103
+
104
+ if (!text || text.length > 500) {
105
+ res.writeHead(400, { "content-type": "application/json" });
106
+ return res.end(JSON.stringify({ error: "message must be 1-500 characters" }));
107
+ }
108
+ const authRes = await resolveChatAuth(req, body);
109
+ if (!authRes.ok) {
110
+ res.writeHead(authRes.status || 403, { "content-type": "application/json" });
111
+ return res.end(JSON.stringify({ error: authRes.error, reason: authRes.reason }));
112
+ }
113
+ const auth = authRes.auth;
114
+
115
+ if (replyTo) {
116
+ const existing = materializeChatMessages(store.readChatEntries(), room);
117
+ const parent = existing.find((m) => m.id === replyTo);
118
+ if (!parent) {
119
+ res.writeHead(400, { "content-type": "application/json" });
120
+ return res.end(JSON.stringify({ error: "reply target not found in this room" }));
121
+ }
122
+ }
123
+
124
+ const msg = {
125
+ id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
126
+ type: "message", agentId: auth.id, agentName: auth.name,
127
+ identityProvider: auth.provider, identityMeta: auth.meta,
128
+ room, text, replyTo: replyTo || null, reactions: {}, reactionUsers: {},
129
+ ts: new Date().toISOString(),
130
+ };
131
+ store.appendChat(msg);
132
+
133
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
134
+ return res.end(JSON.stringify({ ok: true, message: msg }));
135
+ } catch {
136
+ res.writeHead(400, { "content-type": "application/json" });
137
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
138
+ }
139
+ }
140
+
141
+ export async function handleChatReact(req, res, reqUrl) {
142
+ const raw = await collectBody(req, res);
143
+ if (raw === null) return;
144
+ try {
145
+ const body = JSON.parse(raw);
146
+ const store = getStorage();
147
+ const room = normalizeRoomName(body.room || reqUrl.searchParams.get("room") || "general");
148
+ const messageId = String(body.messageId || "").trim();
149
+ const emoji = String(body.emoji || "").trim().slice(0, 8);
150
+ if (!messageId || !emoji) {
151
+ res.writeHead(400, { "content-type": "application/json" });
152
+ return res.end(JSON.stringify({ error: "messageId and emoji are required" }));
153
+ }
154
+ const authRes = await resolveChatAuth(req, body);
155
+ if (!authRes.ok) {
156
+ res.writeHead(authRes.status || 403, { "content-type": "application/json" });
157
+ return res.end(JSON.stringify({ error: authRes.error, reason: authRes.reason }));
158
+ }
159
+ const auth = authRes.auth;
160
+ const existing = materializeChatMessages(store.readChatEntries(), room);
161
+ const parent = existing.find((m) => m.id === messageId);
162
+ if (!parent) {
163
+ res.writeHead(404, { "content-type": "application/json" });
164
+ return res.end(JSON.stringify({ error: "message not found in this room" }));
165
+ }
166
+ const evt = {
167
+ id: `react_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
168
+ type: "reaction", room, messageId, emoji,
169
+ agentId: auth.id, agentName: auth.name, ts: new Date().toISOString(),
170
+ };
171
+ store.appendChat(evt);
172
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
173
+ return res.end(JSON.stringify({ ok: true, reaction: evt }));
174
+ } catch {
175
+ res.writeHead(400, { "content-type": "application/json" });
176
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
177
+ }
178
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Routes: /api/clawbots/*, /api/invites/*
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { CLAWBOTS_PATH } from "../../lib/paths.mjs";
8
+ import { verifyClawbot, registerClawbot } from "../../lib/clawbots.mjs";
9
+ import { getStorage } from "../storage/index.mjs";
10
+ import { getRegistrationKey } from "../middleware/auth.mjs";
11
+ import { collectBody } from "../middleware/body-limit.mjs";
12
+
13
+ const OPEN_REGISTRATION = /^(1|true|yes|on)$/i.test(String(process.env.APE_CLAW_OPEN_REGISTRATION || "").trim());
14
+ const REGISTRATION_COOLDOWN_MS = Math.max(0, Number(process.env.APE_CLAW_REGISTRATION_COOLDOWN_MS || 10000));
15
+ const INVITE_TTL_MS = Math.max(60_000, Number(process.env.APE_CLAW_INVITE_TTL_MS || 24 * 60 * 60 * 1000));
16
+ const INVITE_MAX_USES = Math.max(1, Number(process.env.APE_CLAW_INVITE_MAX_USES || 5));
17
+ const registrationByIp = new Map();
18
+
19
+ function sha256(input) { return createHash("sha256").update(String(input)).digest("hex"); }
20
+
21
+ function clientIpFromReq(req) {
22
+ const xff = String(req.headers["x-forwarded-for"] || "").trim();
23
+ if (xff) return xff.split(",")[0].trim();
24
+ return String(req.socket?.remoteAddress || "").trim() || "unknown";
25
+ }
26
+
27
+ function mintInvite({ ttlMs = INVITE_TTL_MS, uses = 1 } = {}) {
28
+ const store = getStorage();
29
+ const safeUses = Math.max(1, Math.min(INVITE_MAX_USES, Number(uses) || 1));
30
+ const safeTtl = Math.max(60_000, Math.min(7 * 24 * 60 * 60 * 1000, Number(ttlMs) || INVITE_TTL_MS));
31
+ const token = `inv_${randomUUID().replace(/-/g, "")}`;
32
+ const tokenHash = sha256(token);
33
+ const now = Date.now();
34
+ const invites = store.readInvites();
35
+ invites.invites[tokenHash] = {
36
+ createdAt: new Date(now).toISOString(),
37
+ expiresAt: new Date(now + safeTtl).toISOString(),
38
+ usesRemaining: safeUses,
39
+ };
40
+ store.writeInvites(invites);
41
+ return { token, tokenHash, expiresAt: invites.invites[tokenHash].expiresAt, usesRemaining: safeUses };
42
+ }
43
+
44
+ function consumeInvite(inviteToken) {
45
+ const store = getStorage();
46
+ const token = String(inviteToken || "").trim();
47
+ if (!token) return { ok: false, reason: "missing invite" };
48
+ const tokenHash = sha256(token);
49
+ const invites = store.readInvites();
50
+ const row = invites.invites?.[tokenHash];
51
+ if (!row) return { ok: false, reason: "invite not found" };
52
+ const now = Date.now();
53
+ const exp = new Date(row.expiresAt || 0).getTime();
54
+ if (!exp || exp <= now) return { ok: false, reason: "invite expired" };
55
+ const remaining = Number(row.usesRemaining || 0);
56
+ if (remaining <= 0) return { ok: false, reason: "invite exhausted" };
57
+ invites.invites[tokenHash] = { ...row, usesRemaining: remaining - 1, lastUsedAt: new Date(now).toISOString() };
58
+ store.writeInvites(invites);
59
+ return { ok: true };
60
+ }
61
+
62
+ export function handleClawbotsList(req, res) {
63
+ if (!fs.existsSync(CLAWBOTS_PATH)) {
64
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
65
+ return res.end(JSON.stringify({ count: 0, clawbots: [], sharedKeyConfigured: false }));
66
+ }
67
+ try {
68
+ const raw = JSON.parse(fs.readFileSync(CLAWBOTS_PATH, "utf8"));
69
+ const agents = raw.agents || {};
70
+ const clawbots = Object.entries(agents).map(([id, a]) => ({
71
+ agentId: id, name: a.name || id, enabled: a.enabled !== false, createdAt: a.createdAt || null,
72
+ }));
73
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
74
+ const sharedKeyConfigured = Boolean(raw.sharedOpenseaApiKey || process.env.APE_CLAW_SHARED_OPENSEA_KEY);
75
+ return res.end(JSON.stringify({ count: clawbots.length, clawbots, sharedKeyConfigured }));
76
+ } catch (err) {
77
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
78
+ return res.end(JSON.stringify({ error: err.message }));
79
+ }
80
+ }
81
+
82
+ export function handleClawbotsVerify(req, res) {
83
+ const headerAgentId = String(req.headers["x-agent-id"] || "").trim();
84
+ const headerAgentToken = String(req.headers["x-agent-token"] || "").trim();
85
+ if (!headerAgentId || !headerAgentToken) {
86
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
87
+ return res.end(JSON.stringify({ ok: false, error: "missing credentials: x-agent-id + x-agent-token are required" }));
88
+ }
89
+ const verification = verifyClawbot({ agentId: headerAgentId, agentToken: headerAgentToken });
90
+ if (!verification.verified) {
91
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
92
+ return res.end(JSON.stringify({ ok: false, error: "not verified", reason: verification.reason }));
93
+ }
94
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
95
+ return res.end(JSON.stringify({
96
+ ok: true, verified: true, agent: verification.agent, sharedOpenseaApiKey: verification.sharedOpenseaApiKey || "",
97
+ }));
98
+ }
99
+
100
+ export async function handleInviteCreate(req, res) {
101
+ const REGISTRATION_KEY = getRegistrationKey();
102
+ const raw = await collectBody(req, res);
103
+ if (raw === null) return;
104
+ try {
105
+ const body = JSON.parse(raw);
106
+ if (!REGISTRATION_KEY) {
107
+ res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
108
+ return res.end(JSON.stringify({ error: "invite creation disabled: backend missing APE_CLAW_REGISTRATION_KEY" }));
109
+ }
110
+ const providedKey = String(req.headers["x-registration-key"] || "").trim();
111
+ if (!providedKey || providedKey !== REGISTRATION_KEY) {
112
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
113
+ return res.end(JSON.stringify({ error: "invalid registration key" }));
114
+ }
115
+ const invite = mintInvite({ ttlMs: body?.ttlMs, uses: body?.uses });
116
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
117
+ return res.end(JSON.stringify({
118
+ ok: true, invite: invite.token, expiresAt: invite.expiresAt, usesRemaining: invite.usesRemaining,
119
+ note: "Share this invite privately. It can be redeemed via clawbot register --invite <token>.",
120
+ }));
121
+ } catch {
122
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
123
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
124
+ }
125
+ }
126
+
127
+ export async function handleClawbotsRegister(req, res) {
128
+ const REGISTRATION_KEY = getRegistrationKey();
129
+ const raw = await collectBody(req, res);
130
+ if (raw === null) return;
131
+ try {
132
+ const body = JSON.parse(raw);
133
+ const inviteToken = String(body?.invite || "").trim();
134
+ const inviteOk = inviteToken ? consumeInvite(inviteToken) : { ok: false, reason: "missing invite" };
135
+
136
+ if (!REGISTRATION_KEY && !OPEN_REGISTRATION && !inviteOk.ok) {
137
+ res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
138
+ return res.end(JSON.stringify({ error: "registration is disabled: use an invite, set APE_CLAW_REGISTRATION_KEY, or enable APE_CLAW_OPEN_REGISTRATION" }));
139
+ }
140
+ const hasValidKey = (() => {
141
+ if (!REGISTRATION_KEY) return false;
142
+ const providedKey = String(req.headers["x-registration-key"] || "").trim();
143
+ return Boolean(providedKey) && providedKey === REGISTRATION_KEY;
144
+ })();
145
+ if (!OPEN_REGISTRATION && !hasValidKey && !inviteOk.ok) {
146
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
147
+ return res.end(JSON.stringify({ error: "registration not allowed (missing invite or invalid registration key)" }));
148
+ }
149
+ if (OPEN_REGISTRATION && !hasValidKey && REGISTRATION_COOLDOWN_MS > 0) {
150
+ const ip = clientIpFromReq(req);
151
+ const now = Date.now();
152
+ const last = Number(registrationByIp.get(ip) || 0);
153
+ if (last && now - last < REGISTRATION_COOLDOWN_MS) {
154
+ const waitMs = REGISTRATION_COOLDOWN_MS - (now - last);
155
+ res.writeHead(429, { "content-type": "application/json; charset=utf-8" });
156
+ return res.end(JSON.stringify({ error: "registration rate limited", retryAfterMs: waitMs }));
157
+ }
158
+ registrationByIp.set(ip, now);
159
+ }
160
+
161
+ const agentId = String(body?.agentId || "").trim();
162
+ const displayName = String(body?.name || agentId || "").trim();
163
+ if (!agentId) {
164
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
165
+ return res.end(JSON.stringify({ error: "agentId is required" }));
166
+ }
167
+ try {
168
+ const reg = registerClawbot({ agentId, displayName });
169
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
170
+ return res.end(JSON.stringify({
171
+ registered: true, agentId: reg.agentId, name: reg.displayName, token: reg.token,
172
+ note: "Save this token — it is shown only once. Use as APE_CLAW_AGENT_TOKEN or --agent-token.",
173
+ }));
174
+ } catch (err) {
175
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
176
+ return res.end(JSON.stringify({ error: err.message || "registration failed" }));
177
+ }
178
+ } catch {
179
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
180
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
181
+ }
182
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Routes: /events (SSE), /events/backlog, POST /api/events
3
+ */
4
+
5
+ import { verifyClawbot } from "../../lib/clawbots.mjs";
6
+ import { getStorage } from "../storage/index.mjs";
7
+ import { addTelemetryClient, nextEventId } from "../sse.mjs";
8
+ import { collectBody } from "../middleware/body-limit.mjs";
9
+
10
+ export function handleEventsSse(req, res) {
11
+ res.writeHead(200, {
12
+ "content-type": "text/event-stream",
13
+ "cache-control": "no-cache",
14
+ connection: "keep-alive",
15
+ });
16
+ res.write("\n");
17
+
18
+ const lastEventId = req.headers["last-event-id"];
19
+ if (lastEventId) {
20
+ const store = getStorage();
21
+ const backlog = store.getEventBacklog(300);
22
+ for (const evt of backlog) {
23
+ const id = nextEventId();
24
+ res.write(`id: ${id}\ndata: ${JSON.stringify(evt)}\n\n`);
25
+ }
26
+ }
27
+
28
+ const remove = addTelemetryClient(res);
29
+ req.on("close", remove);
30
+ }
31
+
32
+ export function handleEventsBacklog(req, res, reqUrl) {
33
+ const store = getStorage();
34
+ const limit = Math.max(1, Math.min(1000, Number(reqUrl?.searchParams?.get("limit") || 300)));
35
+ const since = reqUrl?.searchParams?.get("since") || "";
36
+ let events = store.getEventBacklog(limit);
37
+ if (since) {
38
+ events = events.filter((e) => e.ts > since);
39
+ }
40
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
41
+ res.end(JSON.stringify({ events }));
42
+ }
43
+
44
+ export async function handlePostEvent(req, res) {
45
+ const raw = await collectBody(req, res);
46
+ if (raw === null) return;
47
+ let body;
48
+ try { body = JSON.parse(raw); } catch {
49
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
50
+ return res.end(JSON.stringify({ error: "invalid JSON body" }));
51
+ }
52
+
53
+ const eventType = String(body?.eventType || "").trim();
54
+ if (!eventType) {
55
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
56
+ return res.end(JSON.stringify({ error: "eventType is required" }));
57
+ }
58
+
59
+ const headerAgentId = String(req.headers["x-agent-id"] || "").trim();
60
+ const headerAgentToken = String(req.headers["x-agent-token"] || "").trim();
61
+ if (!headerAgentId || !headerAgentToken) {
62
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
63
+ return res.end(JSON.stringify({ error: "missing credentials: x-agent-id + x-agent-token are required" }));
64
+ }
65
+ const verification = verifyClawbot({ agentId: headerAgentId, agentToken: headerAgentToken });
66
+ if (!verification.verified) {
67
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
68
+ return res.end(JSON.stringify({ error: "not verified", reason: verification.reason }));
69
+ }
70
+
71
+ const evt = {
72
+ v: Number(body?.v || 1),
73
+ ts: typeof body?.ts === "string" ? body.ts
74
+ : (typeof body?.ts === "number" && Number.isFinite(body.ts)) ? new Date(body.ts * 1000).toISOString()
75
+ : new Date().toISOString(),
76
+ eventType,
77
+ agentId: headerAgentId,
78
+ sessionId: String(body?.sessionId || "remote-session"),
79
+ traceId: String(body?.traceId || `trace_${Date.now()}`),
80
+ command: String(body?.command || ""),
81
+ dryRun: Boolean(body?.dryRun),
82
+ chainId: Number(body?.chainId || 33139),
83
+ payload: (body?.payload || body?.data) && typeof (body?.payload || body?.data) === "object" ? (body?.payload || body?.data) : {},
84
+ result: body?.result && typeof body.result === "object" ? body.result : {},
85
+ ok: body?.ok !== false,
86
+ error: body?.error || null,
87
+ ...(body?.source ? { source: String(body.source) } : {}),
88
+ };
89
+
90
+ const store = getStorage();
91
+ store.appendEvent(evt);
92
+
93
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
94
+ return res.end(JSON.stringify({ ok: true, event: evt }));
95
+ }
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs";
2
+ import { EVENTS_PATH, CHAT_PATH, POLICY_PATH, ALLOWLIST_PATH, CLAWBOTS_PATH, INVITES_PATH } from "../../lib/paths.mjs";
3
+ import { getStorage } from "../storage/index.mjs";
4
+ import { getRegistrationKey, getMoltbookAppKey, getMoltbookApiBase } from "../middleware/auth.mjs";
5
+
6
+ const PORT = Number(process.env.APE_CLAW_UI_PORT || 8787);
7
+ const ROOT = (await import("../../lib/paths.mjs")).ROOT;
8
+ const OPEN_REGISTRATION = /^(1|true|yes|on)$/i.test(String(process.env.APE_CLAW_OPEN_REGISTRATION || "").trim());
9
+ const REGISTRATION_COOLDOWN_MS = Math.max(0, Number(process.env.APE_CLAW_REGISTRATION_COOLDOWN_MS || 10000));
10
+ const INVITE_TTL_MS = Math.max(60_000, Number(process.env.APE_CLAW_INVITE_TTL_MS || 24 * 60 * 60 * 1000));
11
+ const INVITE_MAX_USES = Math.max(1, Number(process.env.APE_CLAW_INVITE_MAX_USES || 5));
12
+
13
+ function resolveV2ReceiptReadConfig() {
14
+ const store = getStorage();
15
+ const fromEnvRpc = String(process.env.APE_CLAW_V2_RPC_URL || process.env.RPC_URL_33139 || "").trim();
16
+ const fromEnvReceipts = String(process.env.APE_CLAW_V2_RECEIPT_REGISTRY || "").trim();
17
+ const rec = store.resolveV2DeploymentRecord();
18
+ const receiptsAddress = fromEnvReceipts || String(rec?.receipts || "").trim();
19
+ let rpcUrl = fromEnvRpc;
20
+ let inferredRpc = false;
21
+ if (!rpcUrl && rec && Number(rec.chainId) === 31337) {
22
+ rpcUrl = "http://127.0.0.1:8545";
23
+ inferredRpc = true;
24
+ }
25
+ if (!rpcUrl || !receiptsAddress) {
26
+ return { ok: false, rpcUrl: rpcUrl || "", receiptsAddress: receiptsAddress || "", inferredRpc, reason: "missing v2 config" };
27
+ }
28
+ return { ok: true, rpcUrl, receiptsAddress, inferredRpc };
29
+ }
30
+
31
+ export { resolveV2ReceiptReadConfig };
32
+
33
+ export function handleHealth(req, res) {
34
+ const store = getStorage();
35
+ const v2Cfg = resolveV2ReceiptReadConfig();
36
+ const skillcardsUserIndexPath = store.SKILLCARDS_USER_DIR
37
+ ? `${store.SKILLCARDS_USER_DIR}/index.json`
38
+ : "";
39
+ const payload = {
40
+ ok: true,
41
+ service: "ape-claw-telemetry",
42
+ port: PORT,
43
+ root: ROOT,
44
+ paths: {
45
+ events: EVENTS_PATH, chat: CHAT_PATH, policy: POLICY_PATH,
46
+ allowlist: ALLOWLIST_PATH, clawbots: CLAWBOTS_PATH, invites: INVITES_PATH,
47
+ skillcardsUserIndex: skillcardsUserIndexPath,
48
+ },
49
+ counts: {
50
+ eventsBytes: fs.existsSync(EVENTS_PATH) ? fs.statSync(EVENTS_PATH).size : 0,
51
+ chatBytes: fs.existsSync(CHAT_PATH) ? fs.statSync(CHAT_PATH).size : 0,
52
+ },
53
+ identity: {
54
+ moltbookEnabled: Boolean(getMoltbookAppKey()),
55
+ moltbookApiBase: getMoltbookApiBase(),
56
+ registrationEnabled: Boolean(getRegistrationKey()),
57
+ openRegistration: OPEN_REGISTRATION,
58
+ registrationCooldownMs: REGISTRATION_COOLDOWN_MS,
59
+ inviteTtlMs: INVITE_TTL_MS,
60
+ inviteMaxUses: INVITE_MAX_USES,
61
+ },
62
+ v2: {
63
+ rpcUrl: v2Cfg.ok ? v2Cfg.rpcUrl : (v2Cfg.rpcUrl || null),
64
+ receiptRegistry: v2Cfg.ok ? v2Cfg.receiptsAddress : (v2Cfg.receiptsAddress || null),
65
+ inferredRpc: Boolean(v2Cfg.inferredRpc),
66
+ configured: Boolean(v2Cfg.ok),
67
+ },
68
+ ts: new Date().toISOString(),
69
+ };
70
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
71
+ return res.end(JSON.stringify(payload));
72
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Routes: /api/pod/*
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { ensureDir } from "../../lib/io.mjs";
8
+ import { getStorage } from "../storage/index.mjs";
9
+ import { requireSkillWriteAuth } from "../middleware/auth.mjs";
10
+
11
+ function getPodStatus() {
12
+ const store = getStorage();
13
+ const workspacePath = store.findPodWorkspaceDir();
14
+ if (!workspacePath) return { ok: true, status: "not-initialized", workspacePath: null };
15
+
16
+ const agentsMdPath = path.join(workspacePath, "AGENTS.md");
17
+ const tasksPath = path.join(workspacePath, "memory", "active-tasks.md");
18
+ const stopFlagPath = path.join(workspacePath, "stop.flag");
19
+ const heartbeatPath = path.join(workspacePath, "state", "last-heartbeat.json");
20
+
21
+ const hasAgentsMd = fs.existsSync(agentsMdPath);
22
+ const hasTasks = fs.existsSync(tasksPath);
23
+ const stopped = fs.existsSync(stopFlagPath);
24
+
25
+ let lastHeartbeat = null;
26
+ if (fs.existsSync(heartbeatPath)) {
27
+ try { lastHeartbeat = JSON.parse(fs.readFileSync(heartbeatPath, "utf8"))?.timestamp || null; } catch {}
28
+ }
29
+
30
+ return {
31
+ ok: true,
32
+ status: hasAgentsMd ? (stopped ? "stopped" : "running") : "not-initialized",
33
+ workspacePath, hasAgentsMd, hasTasks, stopped, lastHeartbeat,
34
+ };
35
+ }
36
+
37
+ export function handlePodStatus(req, res) {
38
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
39
+ return res.end(JSON.stringify(getPodStatus()));
40
+ }
41
+
42
+ export function handlePodStop(req, res) {
43
+ const auth = requireSkillWriteAuth(req);
44
+ if (!auth.ok) {
45
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
46
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized (set x-registration-key or x-agent-id/x-agent-token)" }));
47
+ }
48
+ const store = getStorage();
49
+ const workspacePath = store.findPodWorkspaceDir();
50
+ if (!workspacePath) {
51
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
52
+ return res.end(JSON.stringify({ ok: false, error: "pod workspace not found" }));
53
+ }
54
+ try {
55
+ ensureDir(workspacePath);
56
+ const stopFlagPath = path.join(workspacePath, "stop.flag");
57
+ fs.writeFileSync(stopFlagPath, new Date().toISOString() + "\n");
58
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
59
+ return res.end(JSON.stringify({ ok: true, action: "stop", flagPath: stopFlagPath }));
60
+ } catch (err) {
61
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
62
+ return res.end(JSON.stringify({ ok: false, error: err.message || "failed to create stop flag" }));
63
+ }
64
+ }