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,161 @@
1
+ /**
2
+ * Routes: /api/quotes/*, /api/bridge-requests/*
3
+ *
4
+ * Server-side quote and bridge-request management.
5
+ * When CLI agents use APE_CLAW_TELEMETRY_URL, they POST/GET quotes and
6
+ * bridge requests here instead of reading/writing local state files.
7
+ * This ensures daily spend caps are enforced globally.
8
+ */
9
+
10
+ import { getStorage } from "../storage/index.mjs";
11
+ import { verifyClawbot } from "../../lib/clawbots.mjs";
12
+ import { collectBody } from "../middleware/body-limit.mjs";
13
+
14
+ function requireAgentAuth(req, res) {
15
+ const agentId = String(req.headers["x-agent-id"] || "").trim();
16
+ const agentToken = String(req.headers["x-agent-token"] || "").trim();
17
+ if (!agentId || !agentToken) {
18
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
19
+ res.end(JSON.stringify({ error: "missing credentials" }));
20
+ return null;
21
+ }
22
+ const v = verifyClawbot({ agentId, agentToken });
23
+ if (!v.verified) {
24
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
25
+ res.end(JSON.stringify({ error: "not verified", reason: v.reason }));
26
+ return null;
27
+ }
28
+ return agentId;
29
+ }
30
+
31
+ // ── Quotes ──
32
+
33
+ export async function handleCreateQuote(req, res) {
34
+ const agentId = requireAgentAuth(req, res);
35
+ if (!agentId) return;
36
+ const raw = await collectBody(req, res);
37
+ if (raw === null) return;
38
+ try {
39
+ const body = JSON.parse(raw);
40
+ const quoteId = body.quoteId || `q_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
41
+ const store = getStorage();
42
+ const data = { ...body, quoteId, agentId, createdAt: new Date().toISOString() };
43
+ store.saveQuote(quoteId, data);
44
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
45
+ return res.end(JSON.stringify({ ok: true, quote: data }));
46
+ } catch (err) {
47
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
48
+ return res.end(JSON.stringify({ error: err.message }));
49
+ }
50
+ }
51
+
52
+ export function handleGetQuote(req, res, reqUrl) {
53
+ const agentId = requireAgentAuth(req, res);
54
+ if (!agentId) return;
55
+ const quoteId = reqUrl.pathname.split("/").pop();
56
+ const store = getStorage();
57
+ const quote = store.getQuote(quoteId);
58
+ if (!quote) {
59
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
60
+ return res.end(JSON.stringify({ error: "quote not found" }));
61
+ }
62
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
63
+ return res.end(JSON.stringify({ ok: true, quote }));
64
+ }
65
+
66
+ export async function handlePatchQuote(req, res, reqUrl) {
67
+ const agentId = requireAgentAuth(req, res);
68
+ if (!agentId) return;
69
+ const quoteId = reqUrl.pathname.split("/").pop();
70
+ const raw = await collectBody(req, res);
71
+ if (raw === null) return;
72
+ try {
73
+ const patch = JSON.parse(raw);
74
+ const store = getStorage();
75
+ const updated = store.updateQuote(quoteId, patch);
76
+ if (!updated) {
77
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
78
+ return res.end(JSON.stringify({ error: "quote not found" }));
79
+ }
80
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
81
+ return res.end(JSON.stringify({ ok: true, quote: updated }));
82
+ } catch (err) {
83
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
84
+ return res.end(JSON.stringify({ error: err.message }));
85
+ }
86
+ }
87
+
88
+ export function handleQuotesSpendToday(req, res) {
89
+ const agentId = requireAgentAuth(req, res);
90
+ if (!agentId) return;
91
+ const store = getStorage();
92
+ const spent = store.getQuotesSpendToday();
93
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
94
+ return res.end(JSON.stringify({ ok: true, spentToday: spent }));
95
+ }
96
+
97
+ // ── Bridge requests ──
98
+
99
+ export async function handleCreateBridgeRequest(req, res) {
100
+ const agentId = requireAgentAuth(req, res);
101
+ if (!agentId) return;
102
+ const raw = await collectBody(req, res);
103
+ if (raw === null) return;
104
+ try {
105
+ const body = JSON.parse(raw);
106
+ const requestId = body.requestId || `br_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
107
+ const store = getStorage();
108
+ const data = { ...body, requestId, agentId, createdAt: new Date().toISOString() };
109
+ store.saveBridgeRequest(requestId, data);
110
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
111
+ return res.end(JSON.stringify({ ok: true, bridgeRequest: data }));
112
+ } catch (err) {
113
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
114
+ return res.end(JSON.stringify({ error: err.message }));
115
+ }
116
+ }
117
+
118
+ export function handleGetBridgeRequest(req, res, reqUrl) {
119
+ const agentId = requireAgentAuth(req, res);
120
+ if (!agentId) return;
121
+ const requestId = reqUrl.pathname.split("/").pop();
122
+ const store = getStorage();
123
+ const request = store.getBridgeRequest(requestId);
124
+ if (!request) {
125
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
126
+ return res.end(JSON.stringify({ error: "bridge request not found" }));
127
+ }
128
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
129
+ return res.end(JSON.stringify({ ok: true, bridgeRequest: request }));
130
+ }
131
+
132
+ export async function handlePatchBridgeRequest(req, res, reqUrl) {
133
+ const agentId = requireAgentAuth(req, res);
134
+ if (!agentId) return;
135
+ const requestId = reqUrl.pathname.split("/").pop();
136
+ const raw = await collectBody(req, res);
137
+ if (raw === null) return;
138
+ try {
139
+ const patch = JSON.parse(raw);
140
+ const store = getStorage();
141
+ const updated = store.updateBridgeRequest(requestId, patch);
142
+ if (!updated) {
143
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
144
+ return res.end(JSON.stringify({ error: "bridge request not found" }));
145
+ }
146
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
147
+ return res.end(JSON.stringify({ ok: true, bridgeRequest: updated }));
148
+ } catch (err) {
149
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
150
+ return res.end(JSON.stringify({ error: err.message }));
151
+ }
152
+ }
153
+
154
+ export function handleBridgeSpendToday(req, res) {
155
+ const agentId = requireAgentAuth(req, res);
156
+ if (!agentId) return;
157
+ const store = getStorage();
158
+ const spent = store.getBridgeSpendToday();
159
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
160
+ return res.end(JSON.stringify({ ok: true, spentToday: spent }));
161
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Routes: /api/skills/*, /api/skillcards/*, /skillcards/*
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { ROOT as PROJECT_ROOT } from "../../lib/paths.mjs";
8
+ import { getStorage } from "../storage/index.mjs";
9
+ import { requireSkillWriteAuth } from "../middleware/auth.mjs";
10
+ import { collectBody } from "../middleware/body-limit.mjs";
11
+
12
+ function toSlug(input) {
13
+ return String(input || "").toLowerCase().trim()
14
+ .replace(/®/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15
+ }
16
+
17
+ function safeVersion(v) {
18
+ const s = String(v || "").trim();
19
+ if (!s) return "";
20
+ if (!/^[0-9]+(\.[0-9]+){0,3}([\-+][0-9A-Za-z._-]+)?$/.test(s)) return "";
21
+ return s;
22
+ }
23
+
24
+ export function handleSkillsSearch(req, res, reqUrl) {
25
+ try {
26
+ const store = getStorage();
27
+ const query = String(reqUrl.searchParams.get("q") || "").trim().toLowerCase();
28
+ const sourceFilter = String(reqUrl.searchParams.get("source") || "").trim().toLowerCase();
29
+ const vettedFilter = String(reqUrl.searchParams.get("vetted") || "").trim();
30
+ const page = Math.max(1, Number(reqUrl.searchParams.get("page") || 1));
31
+ const limit = Math.min(5000, Math.max(1, Number(reqUrl.searchParams.get("limit") || 50)));
32
+ let results = store.getMergedSkillIndex();
33
+ if (sourceFilter && ["seed", "imported", "user"].includes(sourceFilter)) results = results.filter((s) => s.source === sourceFilter);
34
+ if (vettedFilter === "1") results = results.filter((s) => s.vettedOk === true);
35
+ if (query) {
36
+ results = results.filter((s) => {
37
+ const n = String(s.name || "").toLowerCase();
38
+ const sl = String(s.slug || "").toLowerCase();
39
+ const d = String(s.description || "").toLowerCase();
40
+ return n.includes(query) || sl.includes(query) || d.includes(query);
41
+ });
42
+ }
43
+ const total = results.length;
44
+ const pages = Math.ceil(total / limit);
45
+ const start = (page - 1) * limit;
46
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
47
+ return res.end(JSON.stringify({ ok: true, total, page, limit, pages, results: results.slice(start, start + limit) }));
48
+ } catch (err) {
49
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
50
+ return res.end(JSON.stringify({ ok: false, error: err.message || "search failed" }));
51
+ }
52
+ }
53
+
54
+ export function handleSkillsGet(req, res, reqUrl) {
55
+ try {
56
+ const store = getStorage();
57
+ const slug = String(reqUrl.searchParams.get("slug") || "").trim();
58
+ if (!slug) { res.writeHead(400, { "content-type": "application/json; charset=utf-8" }); return res.end(JSON.stringify({ ok: false, error: "missing slug" })); }
59
+ const all = store.getMergedSkillIndex();
60
+ const match = all.find((s) => s.slug === slug);
61
+ if (!match) { res.writeHead(404, { "content-type": "application/json; charset=utf-8" }); return res.end(JSON.stringify({ ok: false, error: "skill not found" })); }
62
+ let fullCard = null;
63
+ if (match.fileName) {
64
+ const fp = store.resolveSkillFilePath(match.source, match.fileName);
65
+ if (fp) try { fullCard = JSON.parse(fs.readFileSync(fp, "utf8")); } catch {}
66
+ }
67
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
68
+ return res.end(JSON.stringify({ ok: true, skill: match, card: fullCard }));
69
+ } catch (err) {
70
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
71
+ return res.end(JSON.stringify({ ok: false, error: err.message || "get failed" }));
72
+ }
73
+ }
74
+
75
+ export function handleSkillsStats(req, res) {
76
+ try {
77
+ const store = getStorage();
78
+ const all = store.getMergedSkillIndex();
79
+ const seed = all.filter((s) => s.source === "seed").length;
80
+ const imported = all.filter((s) => s.source === "imported").length;
81
+ const user = all.filter((s) => s.source === "user").length;
82
+ const vetted = all.filter((s) => s.vettedOk === true).length;
83
+ const onchain = all.filter((s) => s.onchainTokenId != null).length;
84
+ let recent = all.filter((s) => s.addedAt).sort((a, b) => new Date(b.addedAt) - new Date(a.addedAt));
85
+ if (recent.length === 0) recent = all.slice(-20).reverse();
86
+ recent = recent.slice(0, 10).map((s) => ({
87
+ name: s.name, slug: s.slug, source: s.source, addedAt: s.addedAt,
88
+ riskTier: s.riskTier, description: String(s.description || "").slice(0, 150),
89
+ onchainTokenId: s.onchainTokenId ?? null,
90
+ }));
91
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
92
+ return res.end(JSON.stringify({ ok: true, total: all.length, seed, imported, user, vetted, onchain, recent }));
93
+ } catch (err) {
94
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
95
+ return res.end(JSON.stringify({ ok: false, error: err.message || "stats failed" }));
96
+ }
97
+ }
98
+
99
+ export function handleSkillcardsUserGet(req, res) {
100
+ try {
101
+ const store = getStorage();
102
+ const raw = store.getUserSkillsIndex();
103
+ const skills = Array.isArray(raw?.skills) ? raw.skills : [];
104
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
105
+ return res.end(JSON.stringify({ ok: true, skills }));
106
+ } catch (err) {
107
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
108
+ return res.end(JSON.stringify({ ok: false, error: err.message || "failed to load index" }));
109
+ }
110
+ }
111
+
112
+ export function handleSkillcardsAuthCheck(req, res) {
113
+ const auth = requireSkillWriteAuth(req);
114
+ res.writeHead(auth.ok ? 200 : 401, { "content-type": "application/json; charset=utf-8" });
115
+ return res.end(JSON.stringify({ ok: auth.ok, mode: auth.mode, agentId: auth.agentId }));
116
+ }
117
+
118
+ export async function handleSkillcardsUserAdd(req, res) {
119
+ const auth = requireSkillWriteAuth(req);
120
+ if (!auth.ok) {
121
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
122
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized (set x-agent-id/x-agent-token or x-registration-key)" }));
123
+ }
124
+ const raw = await collectBody(req, res);
125
+ if (raw === null) return;
126
+ try {
127
+ const body = JSON.parse(raw);
128
+ const skillcard = body?.skillcard || body?.card || body;
129
+ if (!skillcard || typeof skillcard !== "object") throw new Error("missing skillcard object");
130
+ const name = String(skillcard.name || "").trim();
131
+ if (!name) throw new Error("skillcard.name required");
132
+ const slug = toSlug(skillcard.slug || name);
133
+ if (!slug) throw new Error("skillcard.slug required");
134
+ const version = safeVersion(skillcard.version || "1.0.0");
135
+ if (!version) throw new Error("skillcard.version invalid (expected semver-ish)");
136
+ const desc = String(skillcard.description || "").trim();
137
+ const riskTierRaw = Number(skillcard?.constraints?.riskTier ?? skillcard?.riskTier ?? 2);
138
+ const riskTier = Number.isFinite(riskTierRaw) ? Math.max(1, Math.min(3, Math.round(riskTierRaw))) : 2;
139
+ const createdAt = new Date().toISOString();
140
+ const sourceUrl = String(body?.sourceUrl || skillcard?.provenance?.sourceUrl || "").trim();
141
+ const fileName = `${slug}.v${version}.json`;
142
+ const store = getStorage();
143
+ store.writeUserSkillFile(fileName, { ...skillcard, slug, version, name, description: desc });
144
+ const idx = store.getUserSkillsIndex();
145
+ const skills = Array.isArray(idx?.skills) ? idx.skills : [];
146
+ const entry = { fileName, name, slug, version, description: desc, riskTier, sourceUrl, createdAt, addedBy: auth.mode, addedByAgentId: auth.agentId };
147
+ const next = skills.filter((s) => String(s?.fileName || "") !== fileName);
148
+ next.unshift(entry);
149
+ store.writeUserSkillsIndex({ skills: next });
150
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
151
+ return res.end(JSON.stringify({ ok: true, entry, fileHref: `/skillcards/user/${encodeURIComponent(fileName)}` }));
152
+ } catch (err) {
153
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
154
+ return res.end(JSON.stringify({ ok: false, error: err.message || "invalid request" }));
155
+ }
156
+ }
157
+
158
+ export async function handleSkillcardsUserDelete(req, res) {
159
+ const auth = requireSkillWriteAuth(req);
160
+ if (!auth.ok) {
161
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
162
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
163
+ }
164
+ const raw = await collectBody(req, res);
165
+ if (raw === null) return;
166
+ try {
167
+ const body = JSON.parse(raw);
168
+ const fileName = String(body?.fileName || "").trim();
169
+ if (!fileName || fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) throw new Error("invalid fileName");
170
+ const store = getStorage();
171
+ store.deleteUserSkillFile(fileName);
172
+ const idx = store.getUserSkillsIndex();
173
+ const skills = Array.isArray(idx?.skills) ? idx.skills : [];
174
+ store.writeUserSkillsIndex({ skills: skills.filter((s) => String(s?.fileName || "") !== fileName) });
175
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
176
+ return res.end(JSON.stringify({ ok: true }));
177
+ } catch (err) {
178
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
179
+ return res.end(JSON.stringify({ ok: false, error: err.message || "invalid request" }));
180
+ }
181
+ }
182
+
183
+ export async function handleSkillcardsUserMarkOnchain(req, res) {
184
+ const auth = requireSkillWriteAuth(req);
185
+ if (!auth.ok) {
186
+ res.writeHead(401, { "content-type": "application/json; charset=utf-8" });
187
+ return res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
188
+ }
189
+ const raw = await collectBody(req, res);
190
+ if (raw === null) return;
191
+ try {
192
+ const body = JSON.parse(raw);
193
+ const fileName = String(body?.fileName || "").trim();
194
+ if (!fileName || fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) throw new Error("invalid fileName");
195
+ const skillIdNum = Number(body?.skillId);
196
+ if (!Number.isFinite(skillIdNum) || skillIdNum <= 0) throw new Error("invalid skillId");
197
+ const txHash = String(body?.txHash || "").trim();
198
+ const store = getStorage();
199
+ const idx = store.getUserSkillsIndex();
200
+ const skills = Array.isArray(idx?.skills) ? idx.skills : [];
201
+ let found = false;
202
+ const next = skills.map((s) => {
203
+ if (String(s?.fileName || "") !== fileName) return s;
204
+ found = true;
205
+ return { ...s, onchain: { skillId: Math.floor(skillIdNum), txHash, markedAt: new Date().toISOString() } };
206
+ });
207
+ if (!found) throw new Error("skill not found");
208
+ store.writeUserSkillsIndex({ skills: next });
209
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
210
+ return res.end(JSON.stringify({ ok: true }));
211
+ } catch (err) {
212
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
213
+ return res.end(JSON.stringify({ ok: false, error: err.message || "invalid request" }));
214
+ }
215
+ }
216
+
217
+ export function handleSkillcardFile(req, res, pathname) {
218
+ const store = getStorage();
219
+ const segments = pathname.slice("/skillcards/".length).split("/");
220
+ const bucket = segments[0];
221
+ const fileName = segments.length > 1 ? decodeURIComponent(segments.slice(1).join("/")) : "";
222
+ const ALLOWED_BUCKETS = {
223
+ user: store.SKILLCARDS_USER_DIR,
224
+ imported: path.join(PROJECT_ROOT, "skillcards", "imported"),
225
+ seed: store.SKILLCARDS_SEED_DIR,
226
+ };
227
+ const baseDir = ALLOWED_BUCKETS[bucket];
228
+ if (!baseDir || !fileName || fileName.includes("..") || fileName.includes("\\")) {
229
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
230
+ return res.end(JSON.stringify({ error: "invalid file" }));
231
+ }
232
+ const filePath = path.join(baseDir, fileName);
233
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
234
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
235
+ return res.end(JSON.stringify({ error: "not found" }));
236
+ }
237
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
238
+ return fs.createReadStream(filePath).pipe(res);
239
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Routes: static file serving and local UX rewrites
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { ROOT as PROJECT_ROOT, POLICY_PATH, ALLOWLIST_PATH, OPENSEA_OVERRIDES_PATH } from "../../lib/paths.mjs";
8
+
9
+ const OPENSEA_API_BASE = "https://api.opensea.io/api/v2";
10
+ const ICON_CACHE_TTL_MS = 10 * 60 * 1000;
11
+ let allowlistIconCache = { expiresAt: 0, data: null, inFlight: null };
12
+
13
+ const REWRITES = {
14
+ "/ui": "/ui/index.html", "/app": "/ui/index.html",
15
+ "/docs": "/ui/docs.html", "/pod": "/ui/pod.html", "/skills": "/ui/skills.html",
16
+ "/favicon-lobster.png": "/ui/favicon-lobster.png",
17
+ "/ui/favicon.svg": "/ui/favicon.svg", "/ui/favicon-32.png": "/ui/favicon-32.png",
18
+ "/ui/favicon-180.png": "/ui/favicon-180.png", "/ui/favicon-192.png": "/ui/favicon-192.png",
19
+ };
20
+
21
+ const MIME_TYPES = {
22
+ ".html": "text/html", ".css": "text/css", ".js": "application/javascript",
23
+ ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg",
24
+ ".svg": "image/svg+xml", ".ico": "image/x-icon", ".webp": "image/webp",
25
+ ".md": "text/plain; charset=utf-8", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2",
26
+ };
27
+
28
+ function toSlug(input) {
29
+ return String(input || "").toLowerCase().trim()
30
+ .replace(/®/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
31
+ }
32
+ function unique(items) { return [...new Set(items.filter(Boolean))]; }
33
+ function buildSlugCandidates(item, overrides = {}) {
34
+ const raw = String(item?.name || "");
35
+ const base = toSlug(raw);
36
+ const fromOverride = overrides[raw] || overrides[base] || [];
37
+ return unique([
38
+ item?.slug, base, base.replace(/-on-apechain$/, ""), base.replace(/-on-ape$/, ""),
39
+ base.replace(/-/g, ""),
40
+ ...(Array.isArray(fromOverride) ? fromOverride : [fromOverride]),
41
+ ]);
42
+ }
43
+ async function fetchJson(url, headers = {}) {
44
+ const res = await fetch(url, { headers });
45
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
46
+ return res.json();
47
+ }
48
+ function extractCollectionImage(payload) {
49
+ const c = payload?.collection || payload || {};
50
+ return c?.image_url || c?.imageUrl || c?.banner_image_url || c?.bannerImageUrl || null;
51
+ }
52
+ async function resolveCollectionIcon(item, headers, overrides) {
53
+ const candidates = buildSlugCandidates(item, overrides);
54
+ for (const slug of candidates) {
55
+ try {
56
+ const data = await fetchJson(`${OPENSEA_API_BASE}/collections/${encodeURIComponent(slug)}`, headers);
57
+ const imageUrl = extractCollectionImage(data);
58
+ if (imageUrl) return { imageUrl, openseaSlug: slug };
59
+ } catch {}
60
+ try {
61
+ const nftData = await fetchJson(`${OPENSEA_API_BASE}/collection/${encodeURIComponent(slug)}/nfts?limit=1`, headers);
62
+ const first = Array.isArray(nftData?.nfts) ? nftData.nfts[0] : null;
63
+ const imageUrl = first?.image_url || first?.display_image_url || first?.imageUrl || null;
64
+ if (imageUrl) return { imageUrl, openseaSlug: slug };
65
+ } catch {}
66
+ }
67
+ return { imageUrl: null, openseaSlug: null };
68
+ }
69
+ async function mapWithConcurrency(items, limit, mapper) {
70
+ const out = new Array(items.length);
71
+ let i = 0;
72
+ async function worker() { while (i < items.length) { const idx = i++; out[idx] = await mapper(items[idx], idx); } }
73
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, () => worker()));
74
+ return out;
75
+ }
76
+
77
+ async function getAllowlistWithIcons() {
78
+ const now = Date.now();
79
+ if (allowlistIconCache.data && allowlistIconCache.expiresAt > now) return allowlistIconCache.data;
80
+ if (allowlistIconCache.inFlight) return allowlistIconCache.inFlight;
81
+ allowlistIconCache.inFlight = (async () => {
82
+ const raw = fs.readFileSync(ALLOWLIST_PATH, "utf8");
83
+ const allowlist = JSON.parse(raw);
84
+ const key = process.env.OPENSEA_API_KEY || "";
85
+ if (!key) {
86
+ const plain = allowlist.map((c) => ({ ...c, imageUrl: null, openseaSlug: c.slug || null }));
87
+ allowlistIconCache = { expiresAt: now + ICON_CACHE_TTL_MS, data: plain, inFlight: null };
88
+ return plain;
89
+ }
90
+ let overrides = {};
91
+ if (fs.existsSync(OPENSEA_OVERRIDES_PATH)) {
92
+ try { overrides = JSON.parse(fs.readFileSync(OPENSEA_OVERRIDES_PATH, "utf8")); } catch { overrides = {}; }
93
+ }
94
+ const headers = { "x-api-key": key, accept: "application/json" };
95
+ const enriched = await mapWithConcurrency(allowlist, 8, async (item) => {
96
+ const icon = await resolveCollectionIcon(item, headers, overrides);
97
+ return { ...item, imageUrl: icon.imageUrl, openseaSlug: icon.openseaSlug || item.slug || null };
98
+ });
99
+ allowlistIconCache = { expiresAt: Date.now() + ICON_CACHE_TTL_MS, data: enriched, inFlight: null };
100
+ return enriched;
101
+ })();
102
+ try { return await allowlistIconCache.inFlight; } finally {
103
+ if (allowlistIconCache.inFlight && allowlistIconCache.expiresAt < Date.now()) allowlistIconCache.inFlight = null;
104
+ }
105
+ }
106
+
107
+ export function handleAllowlist(req, res) {
108
+ getAllowlistWithIcons()
109
+ .then((data) => {
110
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
111
+ res.end(JSON.stringify(data));
112
+ })
113
+ .catch((err) => {
114
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
115
+ res.end(JSON.stringify({ error: err.message }));
116
+ });
117
+ }
118
+
119
+ export function handlePolicy(req, res) {
120
+ if (!fs.existsSync(POLICY_PATH)) {
121
+ res.writeHead(404, { "content-type": "application/json" });
122
+ return res.end(JSON.stringify({ error: "not found" }));
123
+ }
124
+ const raw = fs.readFileSync(POLICY_PATH, "utf8");
125
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
126
+ return res.end(raw);
127
+ }
128
+
129
+ export function handleRewrite(req, res, pathname) {
130
+ const cleanPath = String(pathname || "").replace(/\/+$/, "").toLowerCase() || pathname;
131
+ const rewrite = REWRITES[pathname] || REWRITES[String(pathname || "").replace(/\/+$/, "")] || REWRITES[cleanPath] || "";
132
+ if (!rewrite) return false;
133
+ const p = path.join(PROJECT_ROOT, rewrite);
134
+ if (!fs.existsSync(p)) { res.writeHead(404); res.end(`missing: ${rewrite}`); return true; }
135
+ const ext = path.extname(p).toLowerCase();
136
+ const mime = ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "application/octet-stream";
137
+ res.writeHead(200, { "content-type": mime });
138
+ fs.createReadStream(p).pipe(res);
139
+ return true;
140
+ }
141
+
142
+ export function handleIndex(req, res) {
143
+ const landingPath = path.join(PROJECT_ROOT, "index.html");
144
+ const uiPath = path.join(PROJECT_ROOT, "ui", "index.html");
145
+ const p = fs.existsSync(landingPath) ? landingPath : uiPath;
146
+ if (!fs.existsSync(p)) { res.writeHead(404); return res.end("index.html not found"); }
147
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
148
+ return fs.createReadStream(p).pipe(res);
149
+ }
150
+
151
+ export function handleStaticFile(req, res, pathname) {
152
+ const safePath = decodeURIComponent(pathname);
153
+ if (safePath.includes("..") || safePath.includes("~")) return false;
154
+ const filePath = path.join(PROJECT_ROOT, safePath);
155
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return false;
156
+ const ext = path.extname(filePath).toLowerCase();
157
+ const mime = MIME_TYPES[ext] || "application/octet-stream";
158
+ res.writeHead(200, { "content-type": mime });
159
+ fs.createReadStream(filePath).pipe(res);
160
+ return true;
161
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Routes: /api/v2/*
3
+ */
4
+
5
+ import { createPublicClient, getContract, http as viemHttp, keccak256, toHex } from "viem";
6
+ import { ReceiptRegistry_ABI } from "../../lib/v2-onchain-abi.mjs";
7
+ import { getStorage } from "../storage/index.mjs";
8
+ import { resolveV2ReceiptReadConfig } from "./health.mjs";
9
+
10
+ const bigintReplacer = (_k, v) => (typeof v === "bigint" ? v.toString() : v);
11
+
12
+ export async function handleV2ReceiptGet(req, res, reqUrl) {
13
+ const traceId = String(reqUrl.searchParams.get("traceId") || reqUrl.searchParams.get("trace") || "").trim();
14
+ if (!traceId) {
15
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
16
+ return res.end(JSON.stringify({ ok: false, error: "missing traceId" }));
17
+ }
18
+ const cfg = resolveV2ReceiptReadConfig();
19
+ if (!cfg.ok) {
20
+ res.writeHead(501, { "content-type": "application/json; charset=utf-8" });
21
+ return res.end(JSON.stringify({ ok: false, error: cfg.reason, inferredRpc: cfg.inferredRpc || false }));
22
+ }
23
+ try {
24
+ const publicClient = createPublicClient({ transport: viemHttp(cfg.rpcUrl) });
25
+ const receipts = getContract({ address: cfg.receiptsAddress, abi: ReceiptRegistry_ABI, client: { public: publicClient } });
26
+ const traceIdHash = keccak256(toHex(traceId));
27
+ const isRecorded = await receipts.read.isRecorded([traceIdHash]);
28
+ const receipt = isRecorded ? await receipts.read.getReceipt([traceIdHash]) : null;
29
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
30
+ res.end(JSON.stringify({ ok: true, traceId, traceIdHash, isRecorded: Boolean(isRecorded), receipt }, bigintReplacer));
31
+ } catch (err) {
32
+ if (res.headersSent || res.writableEnded) return;
33
+ res.writeHead(502, { "content-type": "application/json; charset=utf-8" });
34
+ res.end(JSON.stringify({ ok: false, error: err?.message || "receipt read failed" }));
35
+ }
36
+ }
37
+
38
+ export function handleV2Config(req, res) {
39
+ const store = getStorage();
40
+ const rec = store.resolveV2DeploymentRecord();
41
+ const v2Cfg = resolveV2ReceiptReadConfig();
42
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
43
+ return res.end(JSON.stringify({
44
+ ok: true, deployment: rec, receiptsRead: v2Cfg,
45
+ podVault: rec?.podVault || null, agentAccount: rec?.agentAccount || null,
46
+ record: rec, ts: new Date().toISOString(),
47
+ }, bigintReplacer));
48
+ }