@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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { buildAgentCard, endpointsOf } from "../a2a/card.js";
|
|
4
|
+
import { AID } from "../core/aid.js";
|
|
5
|
+
import { agentDescriptor, descriptorHash } from "../core/ard.js";
|
|
6
|
+
import { accountRef, dataUri, dataUriJcs, keccakJcs } from "../core/canonical.js";
|
|
7
|
+
import { cellUnitDefinition } from "../core/units.js";
|
|
8
|
+
import { repoRoot } from "../util/fs.js";
|
|
9
|
+
import { configPath } from "./config.js";
|
|
10
|
+
/**
|
|
11
|
+
* One configuration, several documents (design memo, Phase 3, section 7): everything the cell says about itself to
|
|
12
|
+
* the outside is generated from `cell.config.json` and the chain — the ERC-8004 registration file, the ERC-8434
|
|
13
|
+
* AID document, the ERC-8444 agent descriptor, the ERC-8454 unit definition, the A2A agent card and the MCP
|
|
14
|
+
* registry manifest — each in its own file with only the fields its standard prescribes, the shared metadata
|
|
15
|
+
* (version, content hash, built time) in `manifest.json` beside them. `foga cell docs build` writes them under the
|
|
16
|
+
* configuration's documents directory; the node serves them (`/documents/<file>`, `/.well-known/agent-card.json`,
|
|
17
|
+
* `/.well-known/agent-registration.json`); `foga cell docs publish` commits what changed on chain and reads it back.
|
|
18
|
+
*/
|
|
19
|
+
export const DOCUMENTS_SCHEMA = "foga.documents/1";
|
|
20
|
+
export const DOCUMENT_FILES = {
|
|
21
|
+
registration: "registration.json",
|
|
22
|
+
aidDocument: "aid-document.json",
|
|
23
|
+
ardAgent: "ard-agent.json",
|
|
24
|
+
aufUnit: "auf-unit.json",
|
|
25
|
+
agentCard: "agent-card.json",
|
|
26
|
+
serverJson: "server.json",
|
|
27
|
+
};
|
|
28
|
+
/** The directory the documents live in: runtime.http.documentsDir, relative to the configuration's directory. */
|
|
29
|
+
export function documentsDir(node) {
|
|
30
|
+
return configPath(node.loaded, node.config.runtime.http.documentsDir);
|
|
31
|
+
}
|
|
32
|
+
/** The URI a document is published under: the public URL when the node has one, the document itself (data:) otherwise. */
|
|
33
|
+
export function documentUri(node, file, doc) {
|
|
34
|
+
const base = node.config.runtime.http.publicUrl?.replace(/\/+$/, "");
|
|
35
|
+
if (!base)
|
|
36
|
+
return dataUri(doc);
|
|
37
|
+
if (file === DOCUMENT_FILES.registration)
|
|
38
|
+
return `${base}/.well-known/agent-registration.json`;
|
|
39
|
+
if (file === DOCUMENT_FILES.agentCard)
|
|
40
|
+
return `${base}/.well-known/agent-card.json`;
|
|
41
|
+
return `${base}/documents/${file}`;
|
|
42
|
+
}
|
|
43
|
+
/** The ERC-8004 registration file (registration-v1) of the cell, from its configuration. */
|
|
44
|
+
export function buildRegistration(node) {
|
|
45
|
+
const cfg = node.config;
|
|
46
|
+
const chainId = node.chainId;
|
|
47
|
+
const endpoints = endpointsOf(node);
|
|
48
|
+
const account = cfg.identity.account;
|
|
49
|
+
const services = [
|
|
50
|
+
...(endpoints.mcp ? [{ name: "MCP", endpoint: endpoints.mcp, version: "2026-07-28" }] : []),
|
|
51
|
+
...(endpoints.a2a ? [{ name: "A2A", endpoint: endpoints.a2a, version: "1.0" }] : []),
|
|
52
|
+
...(endpoints.web ? [{ name: "web", endpoint: endpoints.web }] : []),
|
|
53
|
+
];
|
|
54
|
+
return {
|
|
55
|
+
type: "https://eips.ethereum.org/EIPS/eip-8004#registration-v1",
|
|
56
|
+
name: cfg.presence.name,
|
|
57
|
+
description: cfg.presence.description,
|
|
58
|
+
...(cfg.presence.image ? { image: cfg.presence.image } : {}),
|
|
59
|
+
services,
|
|
60
|
+
...(cfg.identity.agentId ? { registrations: [{ agentId: Number(cfg.identity.agentId), agentRegistry: `eip155:${chainId}:${node.kernel.identity.address.toLowerCase()}` }] } : {}),
|
|
61
|
+
x402Support: false,
|
|
62
|
+
active: true,
|
|
63
|
+
supportedTrust: ["reputation"],
|
|
64
|
+
"x-foga": { cell: cfg.name, anchor: accountRef(chainId, account), ...(cfg.identity.bindingId ? { binding: cfg.identity.bindingId } : {}), ...(cfg.identity.safety.specId ? { specification: cfg.identity.safety.specId } : {}) },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The AID document: the identity facet over the registration, the standing facet when attested, the skills facet, the endpoints facet. */
|
|
68
|
+
export async function buildAidDocument(node, registration) {
|
|
69
|
+
const cfg = node.config;
|
|
70
|
+
const k = node.kernel;
|
|
71
|
+
const chainId = node.chainId;
|
|
72
|
+
const account = cfg.identity.account;
|
|
73
|
+
const self = accountRef(chainId, account);
|
|
74
|
+
const agentId = cfg.identity.agentId ? Number(cfg.identity.agentId) : undefined;
|
|
75
|
+
const facets = [{ facetType: "aid:core/identity/v1", provenance: "SELF", issuer: self, validUntil: 0, digest: keccakJcs(registration), access: { mode: "PUBLIC" }, resolver: { kind: "erc8004-identity", chainId, registry: k.identity.address, ...(agentId !== undefined ? { agentId } : {}) } }];
|
|
76
|
+
if (cfg.identity.standing) {
|
|
77
|
+
try {
|
|
78
|
+
const subject = (await import("../core/kya.js")).accountSubject(chainId, account);
|
|
79
|
+
const r = await k.kya.resolve(subject, cfg.identity.standing.schemeId, cfg.identity.standing.issuers);
|
|
80
|
+
if (r.level > 0)
|
|
81
|
+
facets.push({ facetType: "aid:core/kya/v1", provenance: "ATTESTED", issuer: accountRef(chainId, cfg.identity.standing.issuers[0]), validUntil: Number(r.expiresAt), digest: r.assertionId, access: { mode: "PUBLIC" }, resolver: { kind: "erc8419-assertion", chainId, registry: k.kya.registryAddress, schemeId: cfg.identity.standing.schemeId, issuers: cfg.identity.standing.issuers, assertionId: r.assertionId, level: r.level } });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* no standing readable: no facet */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const held = Object.entries(cfg.skills.held);
|
|
88
|
+
if (held.length) {
|
|
89
|
+
const skills = Object.fromEntries(held.map(([name, h]) => [name, { tokenId: h.tokenId, packageHash: h.packageHash ?? null, offered: cfg.skills.offered.includes(name) }]));
|
|
90
|
+
facets.push({ facetType: "aid:skills/erc8338/v1", provenance: "SELF", issuer: self, validUntil: 0, digest: keccakJcs(skills), access: { mode: "PUBLIC" }, resolver: { kind: "erc8338-skill", chainId, registry: k.skills.address, tokenIds: held.map(([, h]) => h.tokenId) } });
|
|
91
|
+
}
|
|
92
|
+
const endpoints = endpointsOf(node);
|
|
93
|
+
if (endpoints.mcp || endpoints.a2a || endpoints.web) {
|
|
94
|
+
const ep = { mcp: endpoints.mcp, a2a: endpoints.a2a, web: endpoints.web };
|
|
95
|
+
facets.push({ facetType: "aid:endpoints/v1", provenance: "SELF", issuer: self, validUntil: 0, digest: keccakJcs(ep), access: { mode: "PUBLIC" }, resolver: { kind: "foga-endpoints", ...ep } });
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
version: "aid-document/v1",
|
|
99
|
+
aid: self,
|
|
100
|
+
did: `did:aid:${self}`,
|
|
101
|
+
...(agentId !== undefined ? { binding: { registry: k.identity.address, agentId } } : {}),
|
|
102
|
+
livenessWindow: cfg.identity.livenessWindow ?? Number(node.world.file.params.aid.defaultLivenessWindow),
|
|
103
|
+
facets,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The MCP registry manifest for this cell: the package's server.json with this cell's remote. */
|
|
107
|
+
export function buildServerJson(node, version) {
|
|
108
|
+
const template = JSON.parse(readFileSync(join(repoRoot(), "server.json"), "utf8"));
|
|
109
|
+
const endpoints = endpointsOf(node);
|
|
110
|
+
const remotes = endpoints.mcp ? [{ type: "streamable-http", url: endpoints.mcp, headers: [{ name: "Authorization", description: "Bearer <key> for acts (foga node key new), or a FOGA-EIP712 envelope signed by your wallet; reads and builds need none", isRequired: false, isSecret: true }] }] : [];
|
|
111
|
+
return { ...template, description: `${node.config.presence.name}: a FOGA cell on ${node.world.file.network} (chain ${node.chainId}); reads, dry runs and builds for the public, acts by key or signature`.slice(0, 100), version, remotes };
|
|
112
|
+
}
|
|
113
|
+
/** Build every document from the configuration and the chain and write them, with the manifest, under the documents directory. */
|
|
114
|
+
export async function buildDocuments(node, o) {
|
|
115
|
+
const cfg = node.config;
|
|
116
|
+
if (!cfg.identity.account)
|
|
117
|
+
throw new Error("the cell is not born: no account in the configuration (foga cell init first)");
|
|
118
|
+
const dir = resolve(o.dir ?? documentsDir(node));
|
|
119
|
+
mkdirSync(dir, { recursive: true });
|
|
120
|
+
const registration = buildRegistration(node);
|
|
121
|
+
const aidDocument = await buildAidDocument(node, registration);
|
|
122
|
+
const chainId = node.chainId;
|
|
123
|
+
const endpoints = endpointsOf(node);
|
|
124
|
+
const offered = cfg.skills.offered.map((s) => `erc8338:${s}`);
|
|
125
|
+
const ardAgent = agentDescriptor({ chainId, anchor: cfg.identity.account, slug: `cell-${cfg.name.toLowerCase()}`, name: cfg.presence.name, description: cfg.presence.description, url: endpoints.web ?? undefined, capabilities: ["erc8414:fulfiller", "erc8338:holder", ...offered], registration: cfg.identity.agentId ? { registry: `eip155:${chainId}:${node.kernel.identity.address.toLowerCase()}`, agentId: cfg.identity.agentId } : undefined, domain: cfg.runtime.http.publicUrl ? new URL(cfg.runtime.http.publicUrl).hostname : undefined });
|
|
126
|
+
const aufUnit = cellUnitDefinition({ chainId, cell: cfg.identity.account, name: cfg.presence.name, summary: cfg.presence.description || undefined });
|
|
127
|
+
const agentCard = await buildAgentCard(node, { version: o.version });
|
|
128
|
+
const serverJson = buildServerJson(node, o.version);
|
|
129
|
+
const previous = readManifest(dir);
|
|
130
|
+
const docs = { [DOCUMENT_FILES.registration]: registration, [DOCUMENT_FILES.aidDocument]: aidDocument, [DOCUMENT_FILES.ardAgent]: ardAgent, [DOCUMENT_FILES.aufUnit]: aufUnit, [DOCUMENT_FILES.agentCard]: agentCard, [DOCUMENT_FILES.serverJson]: serverJson };
|
|
131
|
+
const standards = { [DOCUMENT_FILES.registration]: "ERC-8004 registration-v1", [DOCUMENT_FILES.aidDocument]: "ERC-8434 aid-document/v1", [DOCUMENT_FILES.ardAgent]: "ERC-8444 agent descriptor", [DOCUMENT_FILES.aufUnit]: "ERC-8454 unit definition", [DOCUMENT_FILES.agentCard]: "A2A 1.0 AgentCard", [DOCUMENT_FILES.serverJson]: "MCP registry server.json (2025-12-11)" };
|
|
132
|
+
const entries = {};
|
|
133
|
+
let changed = !previous;
|
|
134
|
+
for (const [file, doc] of Object.entries(docs)) {
|
|
135
|
+
const text = JSON.stringify(doc, null, 2) + "\n";
|
|
136
|
+
writeFileSync(join(dir, file), text);
|
|
137
|
+
const contentHash = keccakJcs(doc);
|
|
138
|
+
entries[file] = { file, contentHash, bytes: Buffer.byteLength(text), standard: standards[file] };
|
|
139
|
+
if (previous && previous.documents[file]?.contentHash !== contentHash)
|
|
140
|
+
changed = true;
|
|
141
|
+
}
|
|
142
|
+
const manifest = {
|
|
143
|
+
schema: DOCUMENTS_SCHEMA,
|
|
144
|
+
cell: cfg.name,
|
|
145
|
+
account: cfg.identity.account,
|
|
146
|
+
agentId: cfg.identity.agentId ?? null,
|
|
147
|
+
chainId,
|
|
148
|
+
version: previous ? (changed ? previous.version + 1 : previous.version) : 1,
|
|
149
|
+
builtAt: new Date().toISOString(),
|
|
150
|
+
publicUrl: cfg.runtime.http.publicUrl ?? null,
|
|
151
|
+
documents: entries,
|
|
152
|
+
};
|
|
153
|
+
writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
154
|
+
return { dir, manifest, registration, aidDocument, ardAgent, aufUnit, agentCard, serverJson };
|
|
155
|
+
}
|
|
156
|
+
export function readManifest(dir) {
|
|
157
|
+
try {
|
|
158
|
+
const m = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
159
|
+
return m.schema === DOCUMENTS_SCHEMA ? m : null;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** The documents as built, read back from their directory (for the node to serve and for a publish). */
|
|
166
|
+
export function readBuilt(dir) {
|
|
167
|
+
const manifest = readManifest(dir);
|
|
168
|
+
if (!manifest)
|
|
169
|
+
return null;
|
|
170
|
+
const read = (file) => JSON.parse(readFileSync(join(dir, file), "utf8"));
|
|
171
|
+
return { dir, manifest, registration: read(DOCUMENT_FILES.registration), aidDocument: read(DOCUMENT_FILES.aidDocument), ardAgent: read(DOCUMENT_FILES.ardAgent), aufUnit: read(DOCUMENT_FILES.aufUnit), agentCard: read(DOCUMENT_FILES.agentCard), serverJson: read(DOCUMENT_FILES.serverJson) };
|
|
172
|
+
}
|
|
173
|
+
/** What the chain holds now for the four commitments. */
|
|
174
|
+
export async function onChainState(node) {
|
|
175
|
+
const cfg = node.config;
|
|
176
|
+
const k = node.kernel;
|
|
177
|
+
const account = cfg.identity.account;
|
|
178
|
+
const agentURI = cfg.identity.agentId ? await k.identity.contract().tokenURI(BigInt(cfg.identity.agentId)).catch(() => null) : null;
|
|
179
|
+
const aid = await k.aid.documentURI(account).catch(() => null);
|
|
180
|
+
const ard = cfg.identity.recordId ? await k.ard.recordOf(cfg.identity.recordId).then((r) => ({ descriptorHash: r.descriptorHash, descriptorURI: r.descriptorURI })).catch(() => null) : null;
|
|
181
|
+
let unit = null;
|
|
182
|
+
if (cfg.identity.unitId) {
|
|
183
|
+
try {
|
|
184
|
+
const id = BigInt(cfg.identity.unitId);
|
|
185
|
+
const version = await k.units.latestVersion(id);
|
|
186
|
+
const v = await k.units.versionOf(id, version);
|
|
187
|
+
unit = { version, boundaryHash: v.boundaryHash };
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
unit = null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { agentURI, aid, ard, unit };
|
|
194
|
+
}
|
|
195
|
+
/** The on-chain steps a publish needs: each commitment that differs from what the chain holds; nothing for one that matches. */
|
|
196
|
+
export async function publishSteps(node, built) {
|
|
197
|
+
const cfg = node.config;
|
|
198
|
+
const k = node.kernel;
|
|
199
|
+
const state = await onChainState(node);
|
|
200
|
+
const steps = [];
|
|
201
|
+
const unchanged = [];
|
|
202
|
+
if (cfg.identity.agentId) {
|
|
203
|
+
const uri = documentUri(node, DOCUMENT_FILES.registration, built.registration);
|
|
204
|
+
if (state.agentURI === uri)
|
|
205
|
+
unchanged.push("identity-uri");
|
|
206
|
+
else
|
|
207
|
+
steps.push({ name: "identity-uri", target: k.identity.address, data: k.identity.contract().interface.encodeFunctionData("setAgentURI", [BigInt(cfg.identity.agentId), uri]), label: `setAgentURI(${cfg.identity.agentId}, ${uri.slice(0, 60)}${uri.length > 60 ? "…" : ""})`, expect: { uri } });
|
|
208
|
+
}
|
|
209
|
+
{
|
|
210
|
+
const c = AID.documentCommitment(built.aidDocument);
|
|
211
|
+
const uri = cfg.runtime.http.publicUrl ? documentUri(node, DOCUMENT_FILES.aidDocument, built.aidDocument) : c.uri;
|
|
212
|
+
if (state.aid && state.aid.digest.toLowerCase() === c.digest.toLowerCase() && state.aid.uri === uri)
|
|
213
|
+
unchanged.push("aid-document");
|
|
214
|
+
else
|
|
215
|
+
steps.push({ name: "aid-document", target: k.aid.address, data: k.aid.encodeSetDocumentURI(uri, c.digest), label: `setDocumentURI(${uri.slice(0, 60)}${uri.length > 60 ? "…" : ""}, ${c.digest.slice(0, 10)}…)`, expect: { uri, digest: c.digest } });
|
|
216
|
+
}
|
|
217
|
+
if (cfg.identity.recordId) {
|
|
218
|
+
const p = descriptorHash(built.ardAgent);
|
|
219
|
+
if (state.ard && state.ard.descriptorHash.toLowerCase() === p.hex.toLowerCase())
|
|
220
|
+
unchanged.push("ard-descriptor");
|
|
221
|
+
else
|
|
222
|
+
steps.push({ name: "ard-descriptor", target: k.ard.address, data: k.ard.encodeUpdateDescriptor(cfg.identity.recordId, built.ardAgent), label: `updateDescriptor(${cfg.identity.recordId.slice(0, 10)}…, ${p.hex.slice(0, 10)}…)`, expect: { hash: p.hex } });
|
|
223
|
+
}
|
|
224
|
+
if (cfg.identity.unitId) {
|
|
225
|
+
const hash = k.units.boundaryHash(built.aufUnit);
|
|
226
|
+
if (state.unit && state.unit.boundaryHash.toLowerCase() === hash.toLowerCase())
|
|
227
|
+
unchanged.push("unit-version");
|
|
228
|
+
else
|
|
229
|
+
steps.push({ name: "unit-version", target: k.units.registryAddress, data: k.units.registry().interface.encodeFunctionData("publishVersion", [BigInt(cfg.identity.unitId), hash, dataUriJcs(built.aufUnit), []]), label: `publishVersion(${cfg.identity.unitId}, ${hash.slice(0, 10)}…)`, expect: { hash } });
|
|
230
|
+
}
|
|
231
|
+
return { steps, unchanged };
|
|
232
|
+
}
|
|
233
|
+
/** After a publish: what the chain holds against what was built; a URL is fetched when it can be. */
|
|
234
|
+
export async function readBack(node, built, o = {}) {
|
|
235
|
+
const cfg = node.config;
|
|
236
|
+
const k = node.kernel;
|
|
237
|
+
const rows = [];
|
|
238
|
+
const state = await onChainState(node);
|
|
239
|
+
let lastFetchProblem = "";
|
|
240
|
+
const fetchJson = async (url) => {
|
|
241
|
+
if (!url.startsWith("http"))
|
|
242
|
+
return null;
|
|
243
|
+
if (o.fetchUrls === false) {
|
|
244
|
+
lastFetchProblem = "not fetched";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(o.timeoutMs ?? 5000) });
|
|
249
|
+
if (!r.ok) {
|
|
250
|
+
lastFetchProblem = `HTTP ${r.status}`;
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
return await r.json();
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
lastFetchProblem = e.message;
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
if (cfg.identity.agentId) {
|
|
261
|
+
const expected = documentUri(node, DOCUMENT_FILES.registration, built.registration);
|
|
262
|
+
if (!state.agentURI)
|
|
263
|
+
rows.push({ commitment: "ERC-8004 agentURI", ok: false, detail: "no tokenURI readable" });
|
|
264
|
+
else if (state.agentURI !== expected)
|
|
265
|
+
rows.push({ commitment: "ERC-8004 agentURI", ok: false, detail: `the chain names ${state.agentURI.slice(0, 80)}; built ${expected.slice(0, 80)}` });
|
|
266
|
+
else {
|
|
267
|
+
const identityFacet = built.aidDocument.facets.find((f) => f.facetType === "aid:core/identity/v1");
|
|
268
|
+
const content = state.agentURI.startsWith("data:") ? built.registration : await fetchJson(state.agentURI);
|
|
269
|
+
if (content === null)
|
|
270
|
+
rows.push({ commitment: "ERC-8004 agentURI", ok: null, detail: `${state.agentURI} is the committed URI; not read from here (${lastFetchProblem}; the node must serve it)` });
|
|
271
|
+
else {
|
|
272
|
+
const digest = keccakJcs(content);
|
|
273
|
+
rows.push({ commitment: "ERC-8004 agentURI", ok: digest === identityFacet?.digest, detail: digest === identityFacet?.digest ? `the registration read from ${state.agentURI.startsWith("data:") ? "the data URI" : state.agentURI} hashes to the AID identity facet's digest ${digest.slice(0, 10)}…` : `the registration read back hashes to ${digest.slice(0, 10)}…, the AID identity facet says ${identityFacet?.digest.slice(0, 10)}…` });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
{
|
|
278
|
+
const c = AID.documentCommitment(built.aidDocument);
|
|
279
|
+
const ok = Boolean(state.aid && state.aid.digest.toLowerCase() === c.digest.toLowerCase());
|
|
280
|
+
rows.push({ commitment: "ERC-8434 documentURI", ok, detail: ok ? `digest ${c.digest.slice(0, 10)}… at ${state.aid.uri.slice(0, 60)}${state.aid.uri.length > 60 ? "…" : ""}` : `the chain holds ${state.aid?.digest.slice(0, 10) ?? "nothing"}, built ${c.digest.slice(0, 10)}…` });
|
|
281
|
+
if (ok && state.aid.uri.startsWith("http")) {
|
|
282
|
+
const content = await fetchJson(state.aid.uri);
|
|
283
|
+
if (content !== null)
|
|
284
|
+
rows.push({ commitment: "ERC-8434 document content", ok: AID.documentCommitment(content).digest.toLowerCase() === c.digest.toLowerCase(), detail: `fetched from ${state.aid.uri}` });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (cfg.identity.recordId) {
|
|
288
|
+
const p = descriptorHash(built.ardAgent);
|
|
289
|
+
const ok = Boolean(state.ard && state.ard.descriptorHash.toLowerCase() === p.hex.toLowerCase());
|
|
290
|
+
rows.push({ commitment: "ERC-8444 descriptor", ok, detail: ok ? `descriptorHash ${p.hex.slice(0, 10)}… on record ${cfg.identity.recordId.slice(0, 10)}…` : `the record holds ${state.ard?.descriptorHash.slice(0, 10) ?? "nothing"}, built ${p.hex.slice(0, 10)}…` });
|
|
291
|
+
}
|
|
292
|
+
if (cfg.identity.unitId) {
|
|
293
|
+
const hash = k.units.boundaryHash(built.aufUnit);
|
|
294
|
+
const ok = Boolean(state.unit && state.unit.boundaryHash.toLowerCase() === hash.toLowerCase());
|
|
295
|
+
rows.push({ commitment: "ERC-8454 unit version", ok, detail: ok ? `version ${state.unit.version} boundaryHash ${hash.slice(0, 10)}…` : `the latest version holds ${state.unit?.boundaryHash.slice(0, 10) ?? "nothing"}, built ${hash.slice(0, 10)}…` });
|
|
296
|
+
}
|
|
297
|
+
return rows;
|
|
298
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { formatEther, getAddress } from "ethers";
|
|
4
|
+
import { accountSubject } from "../core/kya.js";
|
|
5
|
+
import { operatorScope } from "../genesis/operator-scope.js";
|
|
6
|
+
import { runExtractIsolated, runProbeIsolated } from "../genesis/skill-runner.js";
|
|
7
|
+
import { documentsDir, readBack, readBuilt } from "./documents.js";
|
|
8
|
+
import { NODE_SCOPE_GROUPS } from "./init.js";
|
|
9
|
+
import { KeyIsolationError, loadOperatorKey } from "./keys.js";
|
|
10
|
+
export async function preflight(node, o = {}) {
|
|
11
|
+
const rows = [];
|
|
12
|
+
const row = (area, check, verdict, detail) => rows.push({ area, check, verdict, detail });
|
|
13
|
+
const cfg = node.config;
|
|
14
|
+
const k = node.kernel;
|
|
15
|
+
const env = o.env ?? process.env;
|
|
16
|
+
// --- the world and the chain
|
|
17
|
+
try {
|
|
18
|
+
const net = await k.world.provider.getNetwork();
|
|
19
|
+
const same = Number(net.chainId) === node.world.file.chainId;
|
|
20
|
+
row("world", "the RPC serves the world's chain", same ? "Pass" : "Fail", `RPC chain ${net.chainId}, world ${node.world.file.worldId} on chain ${node.world.file.chainId}`);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
row("world", "the RPC serves the world's chain", "Fail", `the RPC did not answer: ${e.message}`);
|
|
24
|
+
}
|
|
25
|
+
const storeWorld = node.store.getMeta("worldId");
|
|
26
|
+
row("world", "the store belongs to this world and this account", storeWorld && storeWorld !== node.world.file.worldId ? "Fail" : "Pass", `store ${node.storePath}: world ${storeWorld ?? "(new)"}, account ${node.store.getMeta("cellAccount") ?? "(none yet)"}`);
|
|
27
|
+
// --- the account and its permissions
|
|
28
|
+
if (!cfg.identity.account || !node.cell) {
|
|
29
|
+
row("account", "the cell is born", "Fail", "the configuration names no account (foga cell init)");
|
|
30
|
+
return finish(node, rows);
|
|
31
|
+
}
|
|
32
|
+
const cell = node.body;
|
|
33
|
+
let version = 1;
|
|
34
|
+
try {
|
|
35
|
+
version = Number(await cell.contract().ACCOUNT_VERSION());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
version = 1;
|
|
39
|
+
}
|
|
40
|
+
row("account", "a revision-2 account (operator allow-list)", version >= 2 ? "Pass" : "Fail", `${cell.address} is revision ${version}${version < 2 ? ": no allow-list; foga cell migrate moves the cell to a revision-2 account" : ""}`);
|
|
41
|
+
const [owner, operator] = await Promise.all([cell.owner(), cell.operator()]);
|
|
42
|
+
const rolesOk = getAddress(owner) === getAddress(cfg.identity.owner) && getAddress(operator) === getAddress(cfg.identity.operator);
|
|
43
|
+
row("account", "the account names the configuration's owner and operator", rolesOk ? "Pass" : "Fail", `owner ${owner}, operator ${operator}`);
|
|
44
|
+
try {
|
|
45
|
+
const [anchor, status] = await Promise.all([k.aid.stateName(cell.address), cell.status()]);
|
|
46
|
+
row("account", "anchor ACTIVE and binding Satisfied", anchor === "ACTIVE" && status.verdictName === "Satisfied" ? "Pass" : anchor === "STALE" ? "Warn" : "Fail", `anchor ${anchor}, binding ${status.verdictName}${status.reasonNames.length ? ` [${status.reasonNames.join(", ")}]` : ""}${anchor === "STALE" ? " (the next heartbeat restores it)" : ""}`);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
row("account", "anchor ACTIVE and binding Satisfied", "Fail", e.message);
|
|
50
|
+
}
|
|
51
|
+
if (version >= 2) {
|
|
52
|
+
try {
|
|
53
|
+
const scope = operatorScope(k, NODE_SCOPE_GROUPS);
|
|
54
|
+
const missing = [];
|
|
55
|
+
for (let i = 0; i < scope.targets.length; i++)
|
|
56
|
+
if (!(await cell.contract().operatorAllowed(scope.targets[i], scope.selectors[i])))
|
|
57
|
+
missing.push(scope.names[i]);
|
|
58
|
+
row("account", "the operator allow-list covers the node's capabilities", missing.length ? "Fail" : "Pass", missing.length ? `${missing.length} of ${scope.names.length} missing: ${missing.join(", ")} (the owner adds them with setOperatorAllowedBatch; a cell born before they were part of the scope)` : `${scope.names.length} (target, selector) pairs allowed`);
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
row("account", "the operator allow-list covers the node's capabilities", "NotTested", e.message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const [valueCap, balance] = await Promise.all([cell.contract().operatorDailyValueCap?.().catch(() => null), k.balance(cell.address)]);
|
|
66
|
+
const budget = BigInt(cfg.policy.budget.dailyValueWei);
|
|
67
|
+
const perOp = BigInt(cfg.policy.budget.perOperationValueWei);
|
|
68
|
+
row("budget", "the policy's caps are consistent", perOp <= budget && BigInt(cfg.policy.budget.dailyGasWei) > 0n ? "Pass" : "Fail", `per operation ${formatEther(perOp)} ETH <= daily ${formatEther(budget)} ETH; gas ${formatEther(BigInt(cfg.policy.budget.dailyGasWei))} ETH a day`);
|
|
69
|
+
row("budget", "the account holds working capital", balance > 0n ? "Pass" : "Warn", `${formatEther(balance)} ETH on the account${valueCap !== null && valueCap !== undefined ? `; the account's own daily value cap ${formatEther(BigInt(valueCap))} ETH` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
row("budget", "the policy's caps are consistent", "NotTested", e.message);
|
|
73
|
+
}
|
|
74
|
+
// --- the operator key: this environment's, alone; the address the account names; gas for its acts
|
|
75
|
+
try {
|
|
76
|
+
const key = loadOperatorKey(env);
|
|
77
|
+
const match = getAddress(key.address) === getAddress(cfg.identity.operator);
|
|
78
|
+
row("keys", "the operator key is here, alone, and is the account's operator", match ? "Pass" : "Fail", `${key.address} from ${key.source.kind === "file" ? key.source.path : key.source.name}${match ? "" : `; the account names ${cfg.identity.operator}`}`);
|
|
79
|
+
const gas = await k.balance(key.address);
|
|
80
|
+
const floor = BigInt(cfg.policy.budget.dailyGasWei) / 4n;
|
|
81
|
+
row("keys", "the operator holds gas (a quarter of the daily gas budget at least)", gas >= floor ? "Pass" : "Fail", `${formatEther(gas)} ETH (floor ${formatEther(floor)} ETH); the catalog refuses commitments below the floor`);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
row("keys", "the operator key is here, alone, and is the account's operator", e instanceof KeyIsolationError ? "Fail" : "NotTested", e.message);
|
|
85
|
+
}
|
|
86
|
+
for (const role of ["owner", "guardian"]) {
|
|
87
|
+
const names = { inline: `FOGA_${role.toUpperCase()}_KEY`, file: `FOGA_${role.toUpperCase()}_KEY_FILE` };
|
|
88
|
+
if (env[names.inline] || env[names.file])
|
|
89
|
+
row("keys", `the ${role} key stays out of the node's environment`, "Fail", `${env[names.inline] ? names.inline : names.file} is set here; a node holds the operator key alone`);
|
|
90
|
+
}
|
|
91
|
+
if (env.FOGA_MNEMONIC || env.FOGA_MNEMONIC_FILE)
|
|
92
|
+
row("keys", "the phrase stays out of the node's environment", "Fail", "FOGA_MNEMONIC[_FILE] is set here");
|
|
93
|
+
// --- standing
|
|
94
|
+
if (cfg.identity.standing) {
|
|
95
|
+
try {
|
|
96
|
+
const r = await k.kya.resolve(accountSubject(node.chainId, cell.address), cfg.identity.standing.schemeId, cfg.identity.standing.issuers);
|
|
97
|
+
const now = await node.now();
|
|
98
|
+
const live = r.level > 0 && (Number(r.expiresAt) === 0 || Number(r.expiresAt) > now);
|
|
99
|
+
row("standing", "the cell's KYA standing is attested and current", live ? "Pass" : "Warn", live ? `level ${r.level}${Number(r.expiresAt) ? `, until ${new Date(Number(r.expiresAt) * 1000).toISOString()}` : ""}` : `level ${r.level}${Number(r.expiresAt) && Number(r.expiresAt) <= now ? ", expired" : ""}; an issuer attests (foga cell attest is theirs, not the cell's)`);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
row("standing", "the cell's KYA standing is attested and current", "NotTested", e.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
else
|
|
106
|
+
row("standing", "the cell's KYA standing is attested and current", "Warn", "no identity.standing in the configuration: counterparties cannot be required to a level, and the cell has none to show");
|
|
107
|
+
// --- skills: held on chain, committed to the package that is here, complete, offered, and running
|
|
108
|
+
let held = [];
|
|
109
|
+
try {
|
|
110
|
+
held = await node.heldSkills();
|
|
111
|
+
row("skills", "every held skill's package is here and matches its on-chain commitment", "Pass", held.length ? held.map((h) => `${h.name} (token ${h.tokenId}, ${Object.keys(h.pkg.objects).length} objects, ${h.packageHash.slice(0, 10)}…)`).join("; ") : "no skills held");
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
row("skills", "every held skill's package is here and matches its on-chain commitment", "Fail", e.message);
|
|
115
|
+
}
|
|
116
|
+
const notHeld = cfg.skills.offered.filter((s) => !(s in cfg.skills.held));
|
|
117
|
+
row("skills", "every offered skill is held", notHeld.length ? "Fail" : "Pass", notHeld.length ? `offered but not held: ${notHeld.join(", ")}` : `${cfg.skills.offered.length} offered of ${Object.keys(cfg.skills.held).length} held`);
|
|
118
|
+
const executors = ["foga-probe", "finch-extract-verify"];
|
|
119
|
+
const noExecutor = cfg.skills.offered.filter((s) => !executors.includes(s));
|
|
120
|
+
if (noExecutor.length)
|
|
121
|
+
row("skills", "every offered skill has an executor in this node", "Fail", `no executor for ${noExecutor.join(", ")} (this node runs ${executors.join(" and ")})`);
|
|
122
|
+
if (o.executors !== false) {
|
|
123
|
+
if (cfg.skills.offered.includes("foga-probe") && held.some((h) => h.name === "foga-probe")) {
|
|
124
|
+
try {
|
|
125
|
+
const started = Date.now();
|
|
126
|
+
const block = await k.blockNumber();
|
|
127
|
+
const run = await runProbeIsolated({ worldPath: node.worldPath, rpc: node.rpc, bundlePath: node.bundlePath, input: { subject: cell.address, block, lookback: 9000, kya: cfg.identity.standing ? { schemeId: cfg.identity.standing.schemeId, issuers: cfg.identity.standing.issuers } : undefined }, timeoutMs: 60_000 });
|
|
128
|
+
row("skills", "foga-probe runs in the worker", run.answer ? "Pass" : "Fail", `probed ${cell.address} at block ${block} in ${Date.now() - started} ms`);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
row("skills", "foga-probe runs in the worker", "Fail", e.message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (cfg.skills.offered.includes("finch-extract-verify") && held.some((h) => h.name === "finch-extract-verify")) {
|
|
135
|
+
const fixture = existsSync(node.inputDir) ? readdirSync(node.inputDir).find((f) => f.endsWith(".txt")) : undefined;
|
|
136
|
+
if (!fixture)
|
|
137
|
+
row("skills", "finch-extract-verify runs in the worker", "NotTested", `no .txt document in the input directory ${node.inputDir}`);
|
|
138
|
+
else {
|
|
139
|
+
try {
|
|
140
|
+
const started = Date.now();
|
|
141
|
+
const pkg = held.find((h) => h.name === "finch-extract-verify");
|
|
142
|
+
const run = await runExtractIsolated({ documentPath: join(node.inputDir, fixture), fieldsPath: join(node.skillsDir, "finch-extract-verify", "fields.json"), packageHash: pkg.packageHash, inputDir: node.inputDir, skillsDir: node.skillsDir, timeoutMs: 60_000 });
|
|
143
|
+
row("skills", "finch-extract-verify runs in the worker", run.resultHash ? "Pass" : "Fail", `extracted ${fixture} in ${Date.now() - started} ms`);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
row("skills", "finch-extract-verify runs in the worker", "Fail", e.message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
row("skills", "the input directory exists", existsSync(node.inputDir) && statSync(node.inputDir).isDirectory() ? "Pass" : "Fail", node.inputDir);
|
|
152
|
+
// --- the documents: built, and what the chain holds agrees with them
|
|
153
|
+
const built = readBuilt(documentsDir(node));
|
|
154
|
+
if (!built)
|
|
155
|
+
row("documents", "the documents are built", "Warn", `nothing under ${documentsDir(node)} (foga cell docs build; foga cell docs publish commits them)`);
|
|
156
|
+
else {
|
|
157
|
+
row("documents", "the documents are built", "Pass", `v${built.manifest.version} built ${built.manifest.builtAt}, ${Object.keys(built.manifest.documents).length} files`);
|
|
158
|
+
for (const r of await readBack(node, built, { fetchUrls: o.network !== false }))
|
|
159
|
+
row("documents", r.commitment, r.ok === true ? "Pass" : r.ok === false ? "Fail" : "Warn", r.detail);
|
|
160
|
+
}
|
|
161
|
+
// --- the public face
|
|
162
|
+
const publicUrl = cfg.runtime.http.publicUrl;
|
|
163
|
+
if (!publicUrl)
|
|
164
|
+
row("public", "a public URL is configured", "Warn", "runtime.http.publicUrl is not set: the documents commit as data: URIs and the card declares no interface");
|
|
165
|
+
else if (o.network === false)
|
|
166
|
+
row("public", "the public URL answers", "NotTested", publicUrl);
|
|
167
|
+
else {
|
|
168
|
+
try {
|
|
169
|
+
const r = await fetch(`${publicUrl.replace(/\/+$/, "")}/healthz`, { signal: AbortSignal.timeout(5000) });
|
|
170
|
+
const body = (await r.json());
|
|
171
|
+
const same = body.account?.toLowerCase() === cell.address.toLowerCase();
|
|
172
|
+
row("public", "the public URL answers for this cell", r.ok && same ? "Pass" : "Fail", `${publicUrl}/healthz: HTTP ${r.status}, account ${body.account ?? "none"}`);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
row("public", "the public URL answers for this cell", "Warn", `${publicUrl}/healthz not reachable from here: ${e.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (cfg.runtime.http.allowedHosts.length && publicUrl) {
|
|
179
|
+
const host = new URL(publicUrl).hostname;
|
|
180
|
+
row("public", "the public host is among the allowed hosts", cfg.runtime.http.allowedHosts.includes(host) ? "Pass" : "Fail", `${host} ${cfg.runtime.http.allowedHosts.includes(host) ? "is" : "is not"} in runtime.http.allowedHosts (${cfg.runtime.http.allowedHosts.join(", ")})`);
|
|
181
|
+
}
|
|
182
|
+
// --- the model, when the node is to run alive
|
|
183
|
+
if (cfg.runtime.model.provider === "anthropic") {
|
|
184
|
+
row("model", "the anthropic adapter has its key and model name", env.ANTHROPIC_API_KEY && cfg.runtime.model.model ? "Pass" : "Fail", `${env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY set" : "ANTHROPIC_API_KEY not set"}; model ${cfg.runtime.model.model ?? "(none)"}; ${cfg.runtime.model.dailyCalls} calls a day`);
|
|
185
|
+
}
|
|
186
|
+
else
|
|
187
|
+
row("model", "the model", "Pass", `provider ${cfg.runtime.model.provider} (host mode serves without one; foga node run --model anthropic needs the key and a model name)`);
|
|
188
|
+
return finish(node, rows);
|
|
189
|
+
}
|
|
190
|
+
function finish(node, rows) {
|
|
191
|
+
const summary = { Pass: 0, Warn: 0, Fail: 0, NotTested: 0 };
|
|
192
|
+
for (const r of rows)
|
|
193
|
+
summary[r.verdict]++;
|
|
194
|
+
return { schema: "foga.preflight/1", cell: node.config.name, account: node.config.identity.account ?? null, chainId: node.chainId, at: new Date().toISOString(), rows, summary };
|
|
195
|
+
}
|
|
196
|
+
export function printPreflight(report, print) {
|
|
197
|
+
print(`preflight: cell ${report.cell}${report.account ? ` (${report.account})` : ""} on chain ${report.chainId}, ${report.at}`);
|
|
198
|
+
let area = "";
|
|
199
|
+
for (const r of report.rows) {
|
|
200
|
+
if (r.area !== area) {
|
|
201
|
+
area = r.area;
|
|
202
|
+
print(` ${area}`);
|
|
203
|
+
}
|
|
204
|
+
print(` ${r.verdict.padEnd(9)} ${r.check}`);
|
|
205
|
+
print(` ${r.detail}`);
|
|
206
|
+
}
|
|
207
|
+
print(` ${report.summary.Pass} pass, ${report.summary.Warn} warn, ${report.summary.Fail} fail, ${report.summary.NotTested} not tested`);
|
|
208
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { Wallet, isHexString } from "ethers";
|
|
5
|
+
/**
|
|
6
|
+
* The wallet: an agent's own keys, the first thing it makes (design memo, Phase 3, section 3, "wallet first").
|
|
7
|
+
* One directory holds one key per role in its own 0600 file — owner, operator and guardian for an agent that will
|
|
8
|
+
* be a cell, or a single signer for one that only calls other cells — and a `wallet.json` naming the addresses.
|
|
9
|
+
* Making one touches no network and no chain; it is pure entropy on the agent's own machine, so it does not fail,
|
|
10
|
+
* and it is never made twice: an existing wallet is reported, never overwritten. The node's key rules
|
|
11
|
+
* (`src/node/keys.ts`) read the files as they are; `FOGA_OPERATOR_KEY_FILE=<wallet>/operator.key` is a node's
|
|
12
|
+
* environment, `--wallet <dir>` the CLI's shorthand for the same.
|
|
13
|
+
*/
|
|
14
|
+
export const WALLET_SCHEMA = "foga.wallet/1";
|
|
15
|
+
export const CELL_ROLES = ["owner", "operator", "guardian"];
|
|
16
|
+
/** The default wallet: ~/.foga/wallet. A named wallet lives beside it under ~/.foga/wallets/<name>. */
|
|
17
|
+
export function walletDir(name) {
|
|
18
|
+
const home = process.env.FOGA_HOME ?? join(homedir(), ".foga");
|
|
19
|
+
return name ? join(home, "wallets", name) : join(home, "wallet");
|
|
20
|
+
}
|
|
21
|
+
function readKeyFile(path) {
|
|
22
|
+
const key = readFileSync(path, "utf8").trim();
|
|
23
|
+
if (!isHexString(key, 32))
|
|
24
|
+
throw new Error(`${path} does not hold a private key (expected 0x followed by 64 hex digits)`);
|
|
25
|
+
return key;
|
|
26
|
+
}
|
|
27
|
+
/** The wallet at `dir`, read from its files (the addresses are derived from the keys, never trusted from wallet.json); null when there is none. */
|
|
28
|
+
export function loadWallet(dir) {
|
|
29
|
+
const abs = resolve(dir);
|
|
30
|
+
const manifest = join(abs, "wallet.json");
|
|
31
|
+
if (!existsSync(manifest))
|
|
32
|
+
return null;
|
|
33
|
+
const file = JSON.parse(readFileSync(manifest, "utf8"));
|
|
34
|
+
if (file.schema !== WALLET_SCHEMA)
|
|
35
|
+
throw new Error(`${manifest} is not a ${WALLET_SCHEMA} file (schema ${String(file.schema)})`);
|
|
36
|
+
const roles = Object.keys(file.roles);
|
|
37
|
+
const addresses = {};
|
|
38
|
+
const files = {};
|
|
39
|
+
for (const role of roles) {
|
|
40
|
+
const path = join(abs, file.roles[role].file);
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
throw new Error(`wallet ${abs}: the ${role} key file ${file.roles[role].file} is missing`);
|
|
43
|
+
addresses[role] = new Wallet(readKeyFile(path)).address;
|
|
44
|
+
files[role] = path;
|
|
45
|
+
}
|
|
46
|
+
return { name: file.name, dir: abs, createdAt: file.createdAt, roles, addresses, files };
|
|
47
|
+
}
|
|
48
|
+
/** The private key of one role of a wallet. */
|
|
49
|
+
export function walletKey(wallet, role) {
|
|
50
|
+
const path = wallet.files[role];
|
|
51
|
+
if (!path)
|
|
52
|
+
throw new Error(`wallet ${wallet.dir} has no ${role} key (roles: ${wallet.roles.join(", ")})`);
|
|
53
|
+
return readKeyFile(path);
|
|
54
|
+
}
|
|
55
|
+
/** Make a wallet at `dir`. Refuses a directory that already holds one (or anything else): a wallet is never overwritten. */
|
|
56
|
+
export function createWallet(dir, o = {}) {
|
|
57
|
+
const abs = resolve(dir);
|
|
58
|
+
if (existsSync(join(abs, "wallet.json")))
|
|
59
|
+
throw new Error(`${abs} already holds a wallet; foga wallet show prints it (a wallet is never overwritten)`);
|
|
60
|
+
if (existsSync(abs) && readdirSync(abs).length)
|
|
61
|
+
throw new Error(`${abs} exists and is not empty; choose another directory`);
|
|
62
|
+
const roles = o.roles ?? [...CELL_ROLES];
|
|
63
|
+
mkdirSync(abs, { recursive: true, mode: 0o700 });
|
|
64
|
+
const file = { schema: WALLET_SCHEMA, name: o.name ?? "default", createdAt: new Date().toISOString(), roles: {} };
|
|
65
|
+
for (const role of roles) {
|
|
66
|
+
const w = Wallet.createRandom();
|
|
67
|
+
const name = `${role}.key`;
|
|
68
|
+
writeFileSync(join(abs, name), w.privateKey + "\n", { mode: 0o600 });
|
|
69
|
+
file.roles[role] = { address: w.address, file: name };
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(join(abs, "wallet.json"), JSON.stringify(file, null, 2) + "\n");
|
|
72
|
+
return loadWallet(abs);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The wallet to use, made on the spot when there is none (the first step, done by default). A disk that refuses the
|
|
76
|
+
* write does not stop anything: keys are made in memory, the caller is told they are not kept, and the work goes on.
|
|
77
|
+
*/
|
|
78
|
+
export function ensureWallet(dir, o = {}) {
|
|
79
|
+
const existing = loadWallet(dir);
|
|
80
|
+
if (existing)
|
|
81
|
+
return { wallet: existing, created: false, ephemeral: false };
|
|
82
|
+
try {
|
|
83
|
+
return { wallet: createWallet(dir, o), created: true, ephemeral: false };
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
const roles = o.roles ?? [...CELL_ROLES];
|
|
87
|
+
const addresses = {};
|
|
88
|
+
const files = {};
|
|
89
|
+
for (const role of roles) {
|
|
90
|
+
const w = Wallet.createRandom();
|
|
91
|
+
addresses[role] = w.address;
|
|
92
|
+
files[role] = `memory:${w.privateKey}`;
|
|
93
|
+
}
|
|
94
|
+
return { wallet: { name: o.name ?? "ephemeral", dir: resolve(dir), createdAt: new Date().toISOString(), roles, addresses, files }, created: true, ephemeral: true, reason: e.message };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** The private key of a role of an ensured wallet, whether it lives in a file or in memory. */
|
|
98
|
+
export function ensuredKey(w, role) {
|
|
99
|
+
const path = w.wallet.files[role];
|
|
100
|
+
if (!path)
|
|
101
|
+
throw new Error(`wallet ${w.wallet.dir} has no ${role} key`);
|
|
102
|
+
return path.startsWith("memory:") ? path.slice("memory:".length) : readKeyFile(path);
|
|
103
|
+
}
|
|
104
|
+
/** Whether a wallet's key files are private to their owner (POSIX); a note per file that is not. */
|
|
105
|
+
export function walletFileProblems(wallet) {
|
|
106
|
+
if (process.platform === "win32")
|
|
107
|
+
return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const role of wallet.roles) {
|
|
110
|
+
const path = wallet.files[role];
|
|
111
|
+
if (path.startsWith("memory:"))
|
|
112
|
+
continue;
|
|
113
|
+
const st = statSync(path);
|
|
114
|
+
if (st.mode & 0o077)
|
|
115
|
+
out.push(`${path} is readable by other users (mode ${(st.mode & 0o777).toString(8)}); chmod 600`);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|