@finchip.ai/foga 0.6.0-alpha.6 → 0.6.0-alpha.7
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 +31 -14
- package/dist/a2a/card.js +54 -0
- package/dist/a2a/server.js +276 -0
- package/dist/a2a/types.js +20 -0
- package/dist/build/bundle.js +2 -1
- package/dist/cli-cell.js +147 -16
- package/dist/cli.js +138 -5
- package/dist/demo/birth.js +4 -4
- package/dist/demo/quickstart.js +60 -7
- package/dist/genesis/operator-scope.js +1 -0
- package/dist/http/access.js +105 -0
- package/dist/http/server.js +65 -43
- package/dist/http/signed.js +136 -0
- package/dist/mcp/server.js +27 -1
- package/dist/node/config.js +34 -1
- package/dist/node/documents.js +298 -0
- package/dist/node/preflight.js +208 -0
- package/dist/node/wallet.js +118 -0
- package/dist/tools/documents.js +74 -0
- package/dist/tools/index.js +2 -1
- package/dist/world/sourcify.js +165 -0
- package/docs/a2a.md +31 -0
- package/docs/design-memo.md +17 -6
- package/docs/documents.md +25 -0
- package/docs/examples/README.md +2 -2
- package/docs/mcp.md +16 -11
- package/docs/node.md +28 -10
- package/docs/quickstart.md +30 -21
- package/docs/wallet.md +60 -0
- package/docs/world.md +12 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -12,6 +12,7 @@ export const SCOPE_GROUPS = {
|
|
|
12
12
|
{ contract: "8444/ARDOnchainRegistry", fn: "register(bytes32,address,uint256,bytes32,string,string)" },
|
|
13
13
|
{ contract: "8444/ARDOnchainRegistry", fn: "updateDescriptor(bytes32,bytes32,string,string)" },
|
|
14
14
|
{ contract: "8454/AUFUnitRegistry", fn: "registerUnit(address,bytes32,string,(uint256,uint32)[])" },
|
|
15
|
+
{ contract: "8454/AUFUnitRegistry", fn: "publishVersion(uint256,bytes32,string,(uint256,uint32)[])" },
|
|
15
16
|
{ contract: "8447/ERC8004Eligibility", fn: "declareAgent(uint256)" },
|
|
16
17
|
],
|
|
17
18
|
standing: [{ contract: "8419/KYARegistry", fn: "attestWithProof((bytes32,bytes),bytes32,bytes,bytes,string)" }],
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { getAddress } from "ethers";
|
|
3
|
+
import { accountSubject } from "../core/kya.js";
|
|
4
|
+
import { ACTION_RANK, PUBLIC_CALLER } from "../tools/index.js";
|
|
5
|
+
import { SIGNED_SCHEME, verifyAuthorization } from "./signed.js";
|
|
6
|
+
const KEY_PREFIX = "foga";
|
|
7
|
+
/** Make an API key for a grant: shown once, stored only as its hash. */
|
|
8
|
+
export function newApiKey(id) {
|
|
9
|
+
const key = `${KEY_PREFIX}.${id}.${randomBytes(24).toString("base64url")}`;
|
|
10
|
+
return { key, sha256: "0x" + createHash("sha256").update(key).digest("hex") };
|
|
11
|
+
}
|
|
12
|
+
/** The caller an Authorization header establishes: the public without one, the grant of a valid key, or null for an invalid key. */
|
|
13
|
+
export function callerFromAuthorization(cfg, header) {
|
|
14
|
+
if (!header)
|
|
15
|
+
return { ...PUBLIC_CALLER, entrance: "http" };
|
|
16
|
+
const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
17
|
+
if (!m)
|
|
18
|
+
return null;
|
|
19
|
+
const key = m[1];
|
|
20
|
+
const parts = key.split(".");
|
|
21
|
+
if (parts.length !== 3 || parts[0] !== KEY_PREFIX)
|
|
22
|
+
return null;
|
|
23
|
+
const grant = cfg.access.keys.find((k) => k.id === parts[1]);
|
|
24
|
+
if (!grant)
|
|
25
|
+
return null;
|
|
26
|
+
const given = Buffer.from(createHash("sha256").update(key).digest("hex"), "hex");
|
|
27
|
+
const stored = Buffer.from(grant.sha256.slice(2), "hex");
|
|
28
|
+
if (given.length !== stored.length || !timingSafeEqual(given, stored))
|
|
29
|
+
return null;
|
|
30
|
+
return { id: grant.id, entrance: "http", level: grant.level, visibility: grant.visibility };
|
|
31
|
+
}
|
|
32
|
+
export async function resolveCaller(i) {
|
|
33
|
+
const { node } = i;
|
|
34
|
+
const cfg = node.config;
|
|
35
|
+
const header = i.authorization?.trim();
|
|
36
|
+
if (!header)
|
|
37
|
+
return { caller: { ...PUBLIC_CALLER, entrance: "http" }, via: "anonymous" };
|
|
38
|
+
if (/^bearer\s/i.test(header)) {
|
|
39
|
+
const caller = callerFromAuthorization(cfg, header);
|
|
40
|
+
if (!caller)
|
|
41
|
+
return { caller: null, status: 401, reason: "the Authorization header carries no key this node knows" };
|
|
42
|
+
return { caller, via: "key" };
|
|
43
|
+
}
|
|
44
|
+
if (new RegExp(`^${SIGNED_SCHEME}\\s`, "i").test(header)) {
|
|
45
|
+
if (!node.cell)
|
|
46
|
+
return { caller: null, status: 403, reason: "this node has no cell to sign for yet" };
|
|
47
|
+
const v = verifyAuthorization(header, { chainId: node.chainId, cell: node.cell.address, method: i.method, path: i.path, body: i.body, now: i.now, nonces: i.nonces });
|
|
48
|
+
if (!v.ok)
|
|
49
|
+
return { caller: null, status: 401, reason: `signature refused: ${v.reason}` };
|
|
50
|
+
const grant = await grantFor(node, v.envelope);
|
|
51
|
+
if (!grant)
|
|
52
|
+
return { caller: null, status: 403, reason: "this node does not accept signatures (access.signers.default is none)" };
|
|
53
|
+
let level = grant.level;
|
|
54
|
+
if (!v.bound && cfg.access.signers.actsNeedRequestBinding && ACTION_RANK[level] >= ACTION_RANK.act)
|
|
55
|
+
level = "build";
|
|
56
|
+
return { caller: { id: v.envelope.signer, entrance: "http", level, visibility: grant.visibility }, via: "signature", signer: v.envelope };
|
|
57
|
+
}
|
|
58
|
+
return { caller: null, status: 401, reason: `the Authorization scheme is not one this node accepts (Bearer <key> or ${SIGNED_SCHEME} <envelope>)` };
|
|
59
|
+
}
|
|
60
|
+
/** The grant a verified signer gets, from the cell's roles, the configuration's grants, its standing, or the default. */
|
|
61
|
+
export async function grantFor(node, e) {
|
|
62
|
+
const cfg = node.config;
|
|
63
|
+
const signer = getAddress(e.signer);
|
|
64
|
+
const roles = [
|
|
65
|
+
["operator", cfg.identity.operator],
|
|
66
|
+
["owner", cfg.identity.owner],
|
|
67
|
+
];
|
|
68
|
+
for (const [name, address] of roles)
|
|
69
|
+
if (getAddress(address) === signer)
|
|
70
|
+
return { level: "act", visibility: "operator", why: `the cell's ${name}` };
|
|
71
|
+
const s = cfg.access.signers;
|
|
72
|
+
const byAddress = s.allow.find((g) => g.address && getAddress(g.address) === signer);
|
|
73
|
+
if (byAddress)
|
|
74
|
+
return { level: byAddress.level, visibility: byAddress.visibility, why: `granted by address${byAddress.note ? ` (${byAddress.note})` : ""}` };
|
|
75
|
+
if (e.agentId) {
|
|
76
|
+
const byAgent = s.allow.find((g) => g.agentId && g.agentId === e.agentId);
|
|
77
|
+
if (byAgent && (await actsForAgent(node, signer, e.agentId)))
|
|
78
|
+
return { level: byAgent.level, visibility: byAgent.visibility, why: `granted as ERC-8004 agent ${e.agentId}` };
|
|
79
|
+
}
|
|
80
|
+
if (s.standing && cfg.identity.standing) {
|
|
81
|
+
try {
|
|
82
|
+
const subject = accountSubject(node.chainId, signer);
|
|
83
|
+
const meets = await node.kernel.kya.check(subject, cfg.identity.standing.schemeId, s.standing.minLevel, cfg.identity.standing.issuers);
|
|
84
|
+
if (meets)
|
|
85
|
+
return { level: s.standing.level, visibility: s.standing.visibility, why: `KYA standing at level ${s.standing.minLevel} or above` };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* no standing readable: the default applies */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (s.default === "none")
|
|
92
|
+
return null;
|
|
93
|
+
return { level: s.default, visibility: "caller", why: "any valid signer" };
|
|
94
|
+
}
|
|
95
|
+
/** Whether `signer` is the owner or the agent wallet of ERC-8004 agent `agentId` on this world's registry. */
|
|
96
|
+
export async function actsForAgent(node, signer, agentId) {
|
|
97
|
+
try {
|
|
98
|
+
const id = BigInt(agentId);
|
|
99
|
+
const [owner, wallet] = await Promise.all([node.kernel.identity.ownerOf(id).catch(() => null), node.kernel.identity.agentWallet(id).catch(() => null)]);
|
|
100
|
+
return [owner, wallet].some((a) => a && getAddress(a) === getAddress(signer));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/http/server.js
CHANGED
|
@@ -1,34 +1,15 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
1
|
import { createServer } from "node:http";
|
|
3
2
|
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { A2AServer } from "../a2a/server.js";
|
|
6
|
+
import { A2A_ERRORS, A2A_WELL_KNOWN } from "../a2a/types.js";
|
|
4
7
|
import { buildMcpServer } from "../mcp/server.js";
|
|
8
|
+
import { DOCUMENT_FILES, documentsDir, readManifest } from "../node/documents.js";
|
|
5
9
|
import { PUBLIC_CALLER } from "../tools/index.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
const key = `${KEY_PREFIX}.${id}.${randomBytes(24).toString("base64url")}`;
|
|
10
|
-
return { key, sha256: "0x" + createHash("sha256").update(key).digest("hex") };
|
|
11
|
-
}
|
|
12
|
-
/** The caller an Authorization header establishes: the public without one, the grant of a valid key, or null for an invalid key. */
|
|
13
|
-
export function callerFromAuthorization(cfg, header) {
|
|
14
|
-
if (!header)
|
|
15
|
-
return { ...PUBLIC_CALLER, entrance: "http" };
|
|
16
|
-
const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
17
|
-
if (!m)
|
|
18
|
-
return null;
|
|
19
|
-
const key = m[1];
|
|
20
|
-
const parts = key.split(".");
|
|
21
|
-
if (parts.length !== 3 || parts[0] !== KEY_PREFIX)
|
|
22
|
-
return null;
|
|
23
|
-
const grant = cfg.access.keys.find((k) => k.id === parts[1]);
|
|
24
|
-
if (!grant)
|
|
25
|
-
return null;
|
|
26
|
-
const given = Buffer.from(createHash("sha256").update(key).digest("hex"), "hex");
|
|
27
|
-
const stored = Buffer.from(grant.sha256.slice(2), "hex");
|
|
28
|
-
if (given.length !== stored.length || !timingSafeEqual(given, stored))
|
|
29
|
-
return null;
|
|
30
|
-
return { id: grant.id, entrance: "http", level: grant.level, visibility: grant.visibility };
|
|
31
|
-
}
|
|
10
|
+
import { resolveCaller } from "./access.js";
|
|
11
|
+
import { NonceCache } from "./signed.js";
|
|
12
|
+
export { callerFromAuthorization, newApiKey } from "./access.js";
|
|
32
13
|
/** A token bucket per client address: `limit` requests per minute, refilled continuously. */
|
|
33
14
|
class RateLimiter {
|
|
34
15
|
perMinute;
|
|
@@ -98,6 +79,8 @@ export function createHttpServer(o) {
|
|
|
98
79
|
const http = cfg.runtime.http;
|
|
99
80
|
const log = o.log ?? node.log;
|
|
100
81
|
const limiter = new RateLimiter(http.rateLimitPerMinute);
|
|
82
|
+
const nonces = new NonceCache();
|
|
83
|
+
const a2a = new A2AServer(node, catalog, log);
|
|
101
84
|
let loopback = false;
|
|
102
85
|
const mcp = createMcpHandler((ctx) => {
|
|
103
86
|
const caller = ctx.authInfo?.extra?.caller ?? { ...PUBLIC_CALLER, entrance: "http" };
|
|
@@ -120,19 +103,21 @@ export function createHttpServer(o) {
|
|
|
120
103
|
if (!originHost || !allowedOrigins.includes(originHost))
|
|
121
104
|
return json(res, 403, { ok: false, gate: "origin", reason: "Origin not allowed" });
|
|
122
105
|
}
|
|
123
|
-
// who is calling
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
106
|
+
// the body first (a signature may be bound to it), then who is calling
|
|
107
|
+
const bodyless = method === "GET" || method === "HEAD" || method === "DELETE";
|
|
108
|
+
const body = bodyless ? null : await readBody(req, http.maxBodyBytes);
|
|
109
|
+
if (body === null && !bodyless)
|
|
110
|
+
return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
|
|
111
|
+
const resolved = await resolveCaller({ node, authorization: req.headers.authorization, method, path: url.pathname + url.search, body: body ? new Uint8Array(body) : null, nonces });
|
|
112
|
+
if (!resolved.caller)
|
|
113
|
+
return json(res, resolved.status, { ok: false, gate: "permission", reason: resolved.reason });
|
|
114
|
+
const caller = resolved.caller;
|
|
127
115
|
if (url.pathname === "/mcp") {
|
|
128
|
-
//
|
|
116
|
+
// headers pass through, the caller rides in authInfo
|
|
129
117
|
const headers = new Headers();
|
|
130
118
|
for (const [k, v] of Object.entries(req.headers))
|
|
131
119
|
if (v !== undefined && k !== "authorization")
|
|
132
120
|
headers.set(k, Array.isArray(v) ? v.join(", ") : v);
|
|
133
|
-
const body = method === "GET" || method === "HEAD" || method === "DELETE" ? undefined : await readBody(req, http.maxBodyBytes);
|
|
134
|
-
if (body === null)
|
|
135
|
-
return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
|
|
136
121
|
const request = new Request(url, { method, headers, body: body ? new Uint8Array(body) : undefined });
|
|
137
122
|
const authInfo = { token: caller.id, clientId: caller.id, scopes: [caller.level], extra: { caller } };
|
|
138
123
|
const response = await mcp.fetch(request, { authInfo });
|
|
@@ -146,6 +131,24 @@ export function createHttpServer(o) {
|
|
|
146
131
|
res.end();
|
|
147
132
|
return;
|
|
148
133
|
}
|
|
134
|
+
// A2A 1.0, the JSON-RPC binding: the version as a header (or the A2A-Version query parameter), JSON in and out
|
|
135
|
+
if (url.pathname === "/a2a") {
|
|
136
|
+
if (method !== "POST")
|
|
137
|
+
return json(res, 405, { jsonrpc: "2.0", id: null, error: { code: A2A_ERRORS.InvalidRequestError, message: "POST a JSON-RPC 2.0 request" } });
|
|
138
|
+
if (!/^application\/json\b/i.test(req.headers["content-type"] ?? ""))
|
|
139
|
+
return json(res, 415, { jsonrpc: "2.0", id: null, error: { code: A2A_ERRORS.InvalidRequestError, message: "Content-Type must be application/json" } });
|
|
140
|
+
let rpc;
|
|
141
|
+
try {
|
|
142
|
+
rpc = body && body.length ? JSON.parse(body.toString("utf8")) : null;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return json(res, 200, { jsonrpc: "2.0", id: null, error: { code: A2A_ERRORS.JSONParseError, message: "Invalid JSON payload" } });
|
|
146
|
+
}
|
|
147
|
+
const versionHeader = req.headers["a2a-version"];
|
|
148
|
+
const version = (Array.isArray(versionHeader) ? versionHeader[0] : versionHeader) ?? url.searchParams.get("A2A-Version") ?? undefined;
|
|
149
|
+
// A2A-Extensions a client declares are ignored: this agent requires none and offers none
|
|
150
|
+
return json(res, 200, await a2a.handle(rpc, caller, version));
|
|
151
|
+
}
|
|
149
152
|
if (method === "GET" && url.pathname === "/healthz") {
|
|
150
153
|
let block = null;
|
|
151
154
|
try {
|
|
@@ -159,10 +162,10 @@ export function createHttpServer(o) {
|
|
|
159
162
|
if (method === "GET" && url.pathname === "/") {
|
|
160
163
|
const r = await catalog.invoke(node, "cell_status", {}, caller);
|
|
161
164
|
const decisions = node.store.summary().decisions;
|
|
162
|
-
return json(res, 200, { foga: o.version, cell: cfg.name, status: r.ok ? r.output : null, decisions, endpoints: { mcp: "/mcp", api: "/api/<capability>", health: "/healthz" } });
|
|
165
|
+
return json(res, 200, { foga: o.version, cell: cfg.name, status: r.ok ? r.output : null, decisions, endpoints: { mcp: "/mcp", api: "/api/<capability>", a2a: "/a2a", agentCard: A2A_WELL_KNOWN, documents: "/documents/<file>", health: "/healthz" } });
|
|
163
166
|
}
|
|
164
167
|
if (method === "GET" && url.pathname === "/api/capabilities")
|
|
165
|
-
return json(res, 200, { caller: { id: caller.id, level: caller.level, visibility: caller.visibility }, capabilities: catalog.list(caller) });
|
|
168
|
+
return json(res, 200, { caller: { id: caller.id, level: caller.level, visibility: caller.visibility, via: resolved.via }, capabilities: catalog.list(caller) });
|
|
166
169
|
const jobMatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname);
|
|
167
170
|
if (method === "GET" && jobMatch)
|
|
168
171
|
return answer(res, await catalog.invoke(node, "job_status", { jobId: decodeURIComponent(jobMatch[1]) }, caller));
|
|
@@ -183,11 +186,8 @@ export function createHttpServer(o) {
|
|
|
183
186
|
if (method === "POST") {
|
|
184
187
|
if (!/^application\/json\b/i.test(req.headers["content-type"] ?? ""))
|
|
185
188
|
return json(res, 415, { ok: false, gate: "input", reason: "Content-Type must be application/json" });
|
|
186
|
-
const body = await readBody(req, http.maxBodyBytes);
|
|
187
|
-
if (body === null)
|
|
188
|
-
return json(res, 413, { ok: false, gate: "input", reason: `request body over ${http.maxBodyBytes} bytes` });
|
|
189
189
|
try {
|
|
190
|
-
input = body.length ? JSON.parse(body.toString("utf8")) : {};
|
|
190
|
+
input = body && body.length ? JSON.parse(body.toString("utf8")) : {};
|
|
191
191
|
}
|
|
192
192
|
catch {
|
|
193
193
|
return json(res, 400, { ok: false, gate: "input", reason: "the body is not JSON" });
|
|
@@ -200,8 +200,30 @@ export function createHttpServer(o) {
|
|
|
200
200
|
return json(res, 400, { ok: false, gate: "input", reason: "mode must be simulate, build or act" });
|
|
201
201
|
return answer(res, await catalog.invoke(node, name, input, caller, { mode }));
|
|
202
202
|
}
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
// the published documents (foga cell docs build): served as built, by their file names, and at the well-known paths
|
|
204
|
+
if (method === "GET" && (url.pathname.startsWith("/.well-known/") || url.pathname.startsWith("/documents/"))) {
|
|
205
|
+
const dir = documentsDir(node);
|
|
206
|
+
const manifest = readManifest(dir);
|
|
207
|
+
if (!manifest)
|
|
208
|
+
return json(res, 404, { ok: false, reason: "not published yet" });
|
|
209
|
+
let file = null;
|
|
210
|
+
if (url.pathname === A2A_WELL_KNOWN)
|
|
211
|
+
file = DOCUMENT_FILES.agentCard;
|
|
212
|
+
else if (url.pathname === "/.well-known/agent-registration.json")
|
|
213
|
+
file = DOCUMENT_FILES.registration;
|
|
214
|
+
else if (url.pathname.startsWith("/documents/")) {
|
|
215
|
+
const name = basename(decodeURIComponent(url.pathname.slice("/documents/".length)));
|
|
216
|
+
if (name === "manifest.json" || Object.values(DOCUMENT_FILES).includes(name))
|
|
217
|
+
file = name;
|
|
218
|
+
}
|
|
219
|
+
const path = file ? join(dir, file) : null;
|
|
220
|
+
if (!path || !existsSync(path))
|
|
221
|
+
return json(res, 404, { ok: false, reason: "no such document" });
|
|
222
|
+
const text = readFileSync(path);
|
|
223
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "public, max-age=300", "content-length": text.length });
|
|
224
|
+
res.end(text);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
205
227
|
json(res, 404, { ok: false, reason: "no such path" });
|
|
206
228
|
}
|
|
207
229
|
function answer(res, r) {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { getAddress, isHexString, keccak256, randomBytes, toUtf8Bytes, verifyTypedData } from "ethers";
|
|
2
|
+
/**
|
|
3
|
+
* Signed calls (design memo, Phase 3, sections 3 and 6, v0.2): a caller's credential is its own wallet's signature,
|
|
4
|
+
* not a secret the node issued. The envelope is EIP-712 typed data over this cell — the domain's verifyingContract
|
|
5
|
+
* is the cell's account, so a signature made for one cell means nothing to another — in one of two shapes:
|
|
6
|
+
*
|
|
7
|
+
* Request bound to one HTTP request (method, path, the hash of the body), valid for at most two minutes:
|
|
8
|
+
* the shape for an act, since whoever holds the header can replay nothing else with it;
|
|
9
|
+
* Session not bound to a request, valid for at most ten minutes: the shape a client with static headers
|
|
10
|
+
* (an MCP client given `Authorization` once) can use; the policy decides how far it reaches.
|
|
11
|
+
*
|
|
12
|
+
* A request-bound envelope carries a nonce the node remembers until it expires, so a captured header is good once;
|
|
13
|
+
* a session envelope is presented as many times as the session lasts (a captured one is a Bearer token's risk for
|
|
14
|
+
* ten minutes at most, which is why it reaches build unless the configuration says otherwise). The node recovers
|
|
15
|
+
* the address, and the address — with what the chain says about it (an ERC-8004 agent, a KYA standing) — is what
|
|
16
|
+
* the access policy grants a level to. Bearer keys and anonymous reads are untouched: a signature is one more way
|
|
17
|
+
* in, never a requirement.
|
|
18
|
+
*/
|
|
19
|
+
export const SIGNED_SCHEME = "FOGA-EIP712";
|
|
20
|
+
export const REQUEST_MAX_SECONDS = 120;
|
|
21
|
+
export const SESSION_MAX_SECONDS = 600;
|
|
22
|
+
export const SIGNED_TYPES = {
|
|
23
|
+
Request: [
|
|
24
|
+
{ name: "method", type: "string" },
|
|
25
|
+
{ name: "path", type: "string" },
|
|
26
|
+
{ name: "bodyHash", type: "bytes32" },
|
|
27
|
+
{ name: "issuedAt", type: "uint256" },
|
|
28
|
+
{ name: "expiresAt", type: "uint256" },
|
|
29
|
+
{ name: "nonce", type: "bytes32" },
|
|
30
|
+
],
|
|
31
|
+
Session: [
|
|
32
|
+
{ name: "issuedAt", type: "uint256" },
|
|
33
|
+
{ name: "expiresAt", type: "uint256" },
|
|
34
|
+
{ name: "nonce", type: "bytes32" },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
export function signedDomain(chainId, cell) {
|
|
38
|
+
return { name: "FOGA", version: "1", chainId, verifyingContract: getAddress(cell) };
|
|
39
|
+
}
|
|
40
|
+
/** keccak256 of the request body (the empty body for a GET). */
|
|
41
|
+
export function bodyHashOf(body) {
|
|
42
|
+
if (body === null || body === undefined)
|
|
43
|
+
return keccak256(new Uint8Array());
|
|
44
|
+
return keccak256(typeof body === "string" ? toUtf8Bytes(body) : body);
|
|
45
|
+
}
|
|
46
|
+
function message(e) {
|
|
47
|
+
if (e.request)
|
|
48
|
+
return { primaryType: "Request", types: { Request: SIGNED_TYPES.Request }, value: { method: e.request.method, path: e.request.path, bodyHash: e.request.bodyHash, issuedAt: e.issuedAt, expiresAt: e.expiresAt, nonce: e.nonce } };
|
|
49
|
+
return { primaryType: "Session", types: { Session: SIGNED_TYPES.Session }, value: { issuedAt: e.issuedAt, expiresAt: e.expiresAt, nonce: e.nonce } };
|
|
50
|
+
}
|
|
51
|
+
/** Sign an envelope with a wallet (ethers Signer with signTypedData) and return the Authorization header value. */
|
|
52
|
+
export async function signAuthorization(signer, o) {
|
|
53
|
+
const now = o.now ?? Math.floor(Date.now() / 1000);
|
|
54
|
+
const max = o.request ? REQUEST_MAX_SECONDS : SESSION_MAX_SECONDS;
|
|
55
|
+
const ttl = Math.min(o.ttlSeconds ?? max, max);
|
|
56
|
+
const base = { issuedAt: now, expiresAt: now + ttl, nonce: keccak256(randomBytes(32)), request: o.request ? { method: o.request.method.toUpperCase(), path: o.request.path, bodyHash: bodyHashOf(o.request.body) } : undefined };
|
|
57
|
+
const m = message(base);
|
|
58
|
+
const signature = await signer.signTypedData(signedDomain(o.chainId, o.cell), m.types, m.value);
|
|
59
|
+
const envelope = { v: 1, signer: getAddress(await signer.getAddress()), ...(o.agentId ? { agentId: o.agentId } : {}), issuedAt: base.issuedAt, expiresAt: base.expiresAt, nonce: base.nonce, ...(base.request ? { request: base.request } : {}), signature };
|
|
60
|
+
return `${SIGNED_SCHEME} ${Buffer.from(JSON.stringify(envelope), "utf8").toString("base64url")}`;
|
|
61
|
+
}
|
|
62
|
+
/** Remembers the nonces of request-bound envelopes until they expire: a captured one is good once. */
|
|
63
|
+
export class NonceCache {
|
|
64
|
+
seen = new Map();
|
|
65
|
+
lastSweep = 0;
|
|
66
|
+
take(signer, nonce, expiresAt, now) {
|
|
67
|
+
if (now - this.lastSweep > 30) {
|
|
68
|
+
for (const [k, exp] of this.seen)
|
|
69
|
+
if (exp < now)
|
|
70
|
+
this.seen.delete(k);
|
|
71
|
+
this.lastSweep = now;
|
|
72
|
+
}
|
|
73
|
+
const key = `${signer.toLowerCase()}:${nonce.toLowerCase()}`;
|
|
74
|
+
if (this.seen.has(key))
|
|
75
|
+
return false;
|
|
76
|
+
this.seen.set(key, expiresAt);
|
|
77
|
+
if (this.seen.size > 100_000)
|
|
78
|
+
this.seen.delete(this.seen.keys().next().value);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Parse and verify an Authorization header of the FOGA-EIP712 scheme against this cell and this request. */
|
|
83
|
+
export function verifyAuthorization(header, o) {
|
|
84
|
+
const m = new RegExp(`^${SIGNED_SCHEME}\\s+(\\S+)$`, "i").exec(header.trim());
|
|
85
|
+
if (!m)
|
|
86
|
+
return { ok: false, reason: "not a FOGA-EIP712 envelope" };
|
|
87
|
+
let e;
|
|
88
|
+
try {
|
|
89
|
+
e = JSON.parse(Buffer.from(m[1], "base64url").toString("utf8"));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { ok: false, reason: "the envelope is not base64url JSON" };
|
|
93
|
+
}
|
|
94
|
+
if (e?.v !== 1 || typeof e.signer !== "string" || typeof e.issuedAt !== "number" || typeof e.expiresAt !== "number" || !isHexString(e.nonce, 32) || typeof e.signature !== "string")
|
|
95
|
+
return { ok: false, reason: "the envelope lacks a field (v, signer, issuedAt, expiresAt, nonce, signature)" };
|
|
96
|
+
const now = o.now ?? Math.floor(Date.now() / 1000);
|
|
97
|
+
const bound = Boolean(e.request);
|
|
98
|
+
const max = bound ? REQUEST_MAX_SECONDS : SESSION_MAX_SECONDS;
|
|
99
|
+
if (e.expiresAt <= e.issuedAt || e.expiresAt - e.issuedAt > max)
|
|
100
|
+
return { ok: false, reason: `the envelope's validity must be 1 to ${max} seconds (${bound ? "request" : "session"})` };
|
|
101
|
+
if (e.issuedAt > now + 60)
|
|
102
|
+
return { ok: false, reason: "the envelope is issued in the future" };
|
|
103
|
+
if (e.expiresAt < now)
|
|
104
|
+
return { ok: false, reason: "the envelope has expired" };
|
|
105
|
+
if (bound) {
|
|
106
|
+
const r = e.request;
|
|
107
|
+
if (typeof r.method !== "string" || typeof r.path !== "string" || !isHexString(r.bodyHash, 32))
|
|
108
|
+
return { ok: false, reason: "the request binding lacks method, path or bodyHash" };
|
|
109
|
+
if (r.method.toUpperCase() !== o.method.toUpperCase())
|
|
110
|
+
return { ok: false, reason: `the envelope is bound to ${r.method}, this request is ${o.method}` };
|
|
111
|
+
if (r.path !== o.path)
|
|
112
|
+
return { ok: false, reason: `the envelope is bound to ${r.path}, this request is ${o.path}` };
|
|
113
|
+
if (r.bodyHash.toLowerCase() !== bodyHashOf(o.body).toLowerCase())
|
|
114
|
+
return { ok: false, reason: "the envelope is bound to another body" };
|
|
115
|
+
}
|
|
116
|
+
let recovered;
|
|
117
|
+
try {
|
|
118
|
+
const msg = message({ issuedAt: e.issuedAt, expiresAt: e.expiresAt, nonce: e.nonce, request: e.request });
|
|
119
|
+
recovered = verifyTypedData(signedDomain(o.chainId, o.cell), msg.types, msg.value, e.signature);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { ok: false, reason: "the signature does not verify" };
|
|
123
|
+
}
|
|
124
|
+
let claimed;
|
|
125
|
+
try {
|
|
126
|
+
claimed = getAddress(e.signer);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { ok: false, reason: "signer is not an address" };
|
|
130
|
+
}
|
|
131
|
+
if (recovered !== claimed)
|
|
132
|
+
return { ok: false, reason: "the signature was not made by the signer it names" };
|
|
133
|
+
if (bound && o.nonces && !o.nonces.take(claimed, e.nonce, e.expiresAt, now))
|
|
134
|
+
return { ok: false, reason: "the envelope was presented before (nonce reused)" };
|
|
135
|
+
return { ok: true, envelope: { ...e, signer: claimed }, bound };
|
|
136
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -142,7 +142,33 @@ export function buildMcpServer(o) {
|
|
|
142
142
|
if (existsSync(matrix)) {
|
|
143
143
|
server.registerResource("compatibility-matrix", "foga://docs/compatibility-matrix", { title: "Compatibility matrix", description: "The ten standards, what the kernel exercises of each, and the evidence.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(matrix, "utf8") }] }));
|
|
144
144
|
}
|
|
145
|
-
//
|
|
145
|
+
// how to become a cell: every cell carries the genome's instructions, so an agent that meets one can make itself one
|
|
146
|
+
const quickstartDoc = join(repoRoot(), "docs", "quickstart.md");
|
|
147
|
+
if (existsSync(quickstartDoc)) {
|
|
148
|
+
server.registerResource("quickstart", "foga://docs/quickstart", { title: "Become a cell", description: "How any agent makes itself a FOGA cell: one command, its own wallet, the same program this node runs.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(quickstartDoc, "utf8") }] }));
|
|
149
|
+
}
|
|
150
|
+
server.registerPrompt("become-a-cell", { title: "Become a cell", description: "Replicate: make yourself a FOGA cell on your own machine, with your own wallet — the same program as this node, your own identity.", argsSchema: z.object({}) }, () => ({
|
|
151
|
+
messages: [
|
|
152
|
+
{
|
|
153
|
+
role: "user",
|
|
154
|
+
content: {
|
|
155
|
+
type: "text",
|
|
156
|
+
text: [
|
|
157
|
+
`You are talking to cell ${node.config.name}${cell ? ` (account ${cell.address})` : ""} through the Finch OG Agent node software, version ${o.version}. You can become a cell yourself: the program is public, the identity you make is your own, and nothing of this cell's identity is copied.`,
|
|
158
|
+
"On a machine where you can run commands (Node 22.13 or newer, and Foundry's anvil for a local chain):",
|
|
159
|
+
"1. The wallet, first and once: npx -y @finchip.ai/foga wallet new (owner, operator and guardian keys in ~/.foga/wallet; never overwritten; a failure here never blocks the next step)",
|
|
160
|
+
"2. A living cell on a local chain: npx -y @finchip.ai/foga quickstart (anvil, the world, a cell born with your wallet's keys, two tenders, the node served; it prints the MCP client configuration to connect a model to the cell)",
|
|
161
|
+
"3. Connect your model: paste the printed configuration into your MCP client (Claude Desktop, Claude Code, Cursor, or any client with the HTTP form); the stdio form acts with your operator key, the HTTP form with the printed Bearer key.",
|
|
162
|
+
"4. On a public chain, when you are ready: fund the wallet's owner and operator, then foga cell new --wallet ~/.foga/wallet --world <world.json> … and foga cell init --config <cell.config.json> --wallet ~/.foga/wallet.",
|
|
163
|
+
`5. With the wallet alone you can also call this cell as yourself: npx -y @finchip.ai/foga wallet sign --url ${node.config.runtime.http.publicUrl ?? "<this node's URL>"} prints an Authorization header (FOGA-EIP712) for its /api, /mcp and /a2a; what it is granted is this cell's policy.`,
|
|
164
|
+
"The guide is the resource foga://docs/quickstart. In PowerShell, quote the package name: npx -y '@finchip.ai/foga' quickstart.",
|
|
165
|
+
"What you get is a complete body: the ten standards, the catalog that decides, the acceptance (foga doctor, foga demo --loop-d). The mind driving it stays yours.",
|
|
166
|
+
].join("\n"),
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
}));
|
|
171
|
+
// the one prompt about work: take a tender as the node's operator would
|
|
146
172
|
server.registerPrompt("take-tender", { title: "Take a tender", description: "Work one ERC-8414 tender the way the node's operator would: inspect, pre-check, run the skill, submit, settle.", argsSchema: z.object({ tokenId: z.string().describe("the tender's token id") }) }, ({ tokenId }) => ({
|
|
147
173
|
messages: [
|
|
148
174
|
{
|
package/dist/node/config.js
CHANGED
|
@@ -7,7 +7,8 @@ import { ensureDir } from "../util/fs.js";
|
|
|
7
7
|
* Six blocks: identity (the world, the account and its roles, what `foga cell init` writes back), presence (how the
|
|
8
8
|
* cell describes itself to the world), skills (what it holds and offers), policy (the business authorization the
|
|
9
9
|
* policy engine applies to every act), runtime (how the node runs, the HTTP server among it) and access (the API
|
|
10
|
-
* keys the HTTP entrance accepts, as their hashes
|
|
10
|
+
* keys the HTTP entrance accepts, as their hashes, and what a signer proving itself with its wallet is granted).
|
|
11
|
+
* Amounts in wei are decimal strings so the file stays plain JSON.
|
|
11
12
|
*/
|
|
12
13
|
const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/, "an address (0x + 40 hex digits)");
|
|
13
14
|
const bytes32 = z.string().regex(/^0x[0-9a-fA-F]{64}$/, "a 32-byte hex value");
|
|
@@ -45,6 +46,9 @@ export const PresenceSchema = z.object({
|
|
|
45
46
|
description: z.string().max(2000).default(""),
|
|
46
47
|
image: z.string().url().optional(),
|
|
47
48
|
homepage: z.string().url().optional(),
|
|
49
|
+
/** The organization behind the cell, for the A2A agent card. */
|
|
50
|
+
provider: z.object({ organization: z.string().min(1).max(120), url: z.string().url() }).optional(),
|
|
51
|
+
/** The public endpoints; when absent they are derived from runtime.http.publicUrl (/mcp, /a2a, the URL itself). */
|
|
48
52
|
endpoints: z
|
|
49
53
|
.object({
|
|
50
54
|
mcp: z.string().url().optional(),
|
|
@@ -104,6 +108,8 @@ export const HttpSchema = z.object({
|
|
|
104
108
|
allowedHosts: z.array(z.string()).default([]),
|
|
105
109
|
/** The URL the public reaches the node at (for its documents and card). */
|
|
106
110
|
publicUrl: z.string().url().optional(),
|
|
111
|
+
/** Where `foga cell docs build` writes and the node serves the documents from (relative to the config's directory). */
|
|
112
|
+
documentsDir: z.string().default("documents"),
|
|
107
113
|
rateLimitPerMinute: z.number().int().min(1).default(60),
|
|
108
114
|
maxBodyBytes: z.number().int().min(1024).default(256 * 1024),
|
|
109
115
|
});
|
|
@@ -133,8 +139,35 @@ export const AccessKeySchema = z.object({
|
|
|
133
139
|
visibility: z.enum(["public", "caller", "operator"]).default("caller"),
|
|
134
140
|
note: z.string().max(200).optional(),
|
|
135
141
|
});
|
|
142
|
+
/** What a signer (an address proving itself with an EIP-712 envelope) is granted: by address, or by the ERC-8004 agent id it acts for. */
|
|
143
|
+
export const SignerGrantSchema = z
|
|
144
|
+
.object({
|
|
145
|
+
address: address.optional(),
|
|
146
|
+
agentId: uint.optional(),
|
|
147
|
+
level: z.enum(["read", "build", "act"]).default("act"),
|
|
148
|
+
visibility: z.enum(["public", "caller", "operator"]).default("caller"),
|
|
149
|
+
note: z.string().max(200).optional(),
|
|
150
|
+
})
|
|
151
|
+
.refine((g) => g.address || g.agentId, { message: "a signer grant names an address or an agentId" });
|
|
152
|
+
export const SignersSchema = z.object({
|
|
153
|
+
/** What any valid signature is granted without a grant of its own: the public's level (build) by default; none refuses signatures outright. */
|
|
154
|
+
default: z.enum(["none", "read", "build"]).default("build"),
|
|
155
|
+
allow: z.array(SignerGrantSchema).default([]),
|
|
156
|
+
/** A signer whose KYA standing under the cell's scheme reaches minLevel is granted this level. */
|
|
157
|
+
standing: z
|
|
158
|
+
.object({
|
|
159
|
+
minLevel: z.number().int().min(1),
|
|
160
|
+
level: z.enum(["read", "build", "act"]).default("act"),
|
|
161
|
+
visibility: z.enum(["public", "caller", "operator"]).default("caller"),
|
|
162
|
+
})
|
|
163
|
+
.optional(),
|
|
164
|
+
/** An act by signature needs an envelope bound to its request (a session envelope reaches build at most). */
|
|
165
|
+
actsNeedRequestBinding: z.boolean().default(true),
|
|
166
|
+
});
|
|
136
167
|
export const AccessSchema = z.object({
|
|
137
168
|
keys: z.array(AccessKeySchema).default([]),
|
|
169
|
+
/** Signed calls (FOGA-EIP712): what an address proving itself with its wallet is granted. */
|
|
170
|
+
signers: SignersSchema.prefault({}),
|
|
138
171
|
});
|
|
139
172
|
export const CellConfigSchema = z.object({
|
|
140
173
|
schema: z.literal("foga.cell-config/1"),
|