UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

485 lines • 23.8 kB
/** * Named wallets (profiles) and wallet selection, owned by the Node SDK. * * Each named wallet is a self-contained profile directory under * `{config_dir}/profiles/<name>/` with its own key, project keystore, and * non-secret `meta.json`; the reserved `default` wallet lives at the * config-dir root. This module is the one implementation of: * * - the SELECTION precedence chain (`resolveWalletSelection`), highest first: * 1. an explicit flag value (`--wallet <name>` / `--profile <name>`) * 2. `RUN402_WALLET` / `RUN402_PROFILE` * 3. the nearest `.run402.local.json` / `.run402.json` binding, walking up * 4. the global default recorded by `wallets use` (base `config.json`) * 5. `default` * An env value that disagrees with a directory binding is a hard error * (`WALLET_SELECTION_CONFLICT`) unless the caller is the wallet-management * surface itself, which must keep working while selection is ambiguous. * - the MANAGEMENT verbs on `r.wallets` (`list`, `current`, `create`, `use`, * `rename`, `bind`, `unbind`, `import`, `remove`), which operate on explicit * named targets and are independent of the active selection. * * The CLI (`run402 wallets …`, the `--wallet` pre-resolution in `cli.mjs`) and * the MCP server consume these; neither adds a precedence rule of its own. * * A rejected name is a value we know nothing about — a private key pasted * into `RUN402_WALLET` is a demonstrated case — so every refusal routes the * value through `describeRejectedValue()`: a short typo shows in full, anything * secret-shaped is redacted. */ import { join } from "node:path"; import { DEFAULT_PROFILE, getWalletPath, isValidProfileName, } from "../../core-dist/config.js"; import { ensureProfileDir, getDefaultWallet, listProfileNames, profileDir, profileExists, readMeta, removeProfile, renameProfile, setDefaultWallet, writeMeta, } from "../../core-dist/profiles.js"; import { readWallet, saveWallet } from "../../core-dist/wallet.js"; import { describeRejectedValue } from "../../core-dist/redact.js"; import { findBindingKey, readBindingFile, updateBindingFile } from "../../core-dist/binding-file.js"; import { LocalError } from "../errors.js"; import { Wallets } from "../namespaces/wallets.js"; import { gateSecret } from "../secret-gate.js"; import { initializeWalletAction } from "./local-actions.js"; /** * The active profile's wallet, or null when there is none. A file that exists * but cannot be read as a wallet is `BAD_WALLET_FILE`, naming the file and * the way to recreate it, never the parser's own failure. */ export function readLocalWallet() { try { return readWallet(); } catch (err) { throw new LocalError(err?.message ?? "wallet.json is malformed", "reading the local wallet", { code: "BAD_WALLET_FILE", hint: "Back up ~/.config/run402/wallet.json and run 'run402 init' to recreate it.", details: { path: getWalletPath() }, next_actions: [initializeWalletAction()], }); } } const DEFAULT = DEFAULT_PROFILE; const PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/; const CONTEXT = "managing local wallets"; /** * A wallet selection refused: bad name, env-vs-binding conflict, or a named * wallet that does not exist locally. A {@link LocalError} carrying `code`, * `hint`, and `details`, never exiting the process, so a caller mid-protocol * (the git remote helper) can report it on its own channel. */ export class WalletSelectionError extends LocalError { constructor({ code, message, hint, details }) { super(message, "selecting a wallet", { code, ...(hint !== undefined ? { hint } : {}), ...(details !== undefined ? { details } : {}) }); this.name = "WalletSelectionError"; } } function envName(env) { const raw = env.RUN402_WALLET ?? env.RUN402_PROFILE; return typeof raw === "string" && raw.trim() ? raw.trim() : null; } function assertValidSelectionName(name, origin) { if (name === DEFAULT || isValidProfileName(name)) return; throw new WalletSelectionError({ code: "BAD_WALLET_NAME", message: `Invalid wallet name ${JSON.stringify(describeRejectedValue(name))} (from ${origin}).`, hint: "Wallet names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-'). If a private key or other secret ended up here, it does not belong in a NAME field — see `run402 wallets import` — and should be treated as compromised.", details: { name: describeRejectedValue(name), origin }, }); } /** Nearest wallet binding walking up from `startDir` to the filesystem root. */ export function findWalletBinding(startDir) { const hit = findBindingKey(startDir, "wallet"); return hit ? { wallet: hit.value, file: hit.file } : null; } /** * The one precedence chain. Pure: reads the environment it is handed, the * binding files above `cwd`, and the global default; never prompts, never * exits. Throws {@link WalletSelectionError}. */ export function resolveWalletSelection({ walletFlag, env = process.env, cwd = process.cwd(), allowConflict = false, } = {}) { if (walletFlag) { if (walletFlag.value === undefined || walletFlag.value === "") { throw new WalletSelectionError({ code: "BAD_FLAG", message: `${walletFlag.flag} requires a value`, details: { flag: walletFlag.flag } }); } assertValidSelectionName(walletFlag.value, walletFlag.flag); return { name: walletFlag.value, source: "flag", sourceDetail: walletFlag.flag }; } const fromEnv = envName(env); const binding = findWalletBinding(cwd); if (fromEnv && binding && fromEnv !== binding.wallet && !allowConflict) { // Runs BEFORE the name check, so an unvalidated (possibly secret-shaped) // env value reaches here whenever a binding exists: redact it the same // way. The binding's value comes from a committed file whose writer // validated it. throw new WalletSelectionError({ code: "WALLET_SELECTION_CONFLICT", message: `Ambiguous wallet: RUN402_WALLET=${describeRejectedValue(fromEnv)} but ${binding.file} selects '${binding.wallet}'.`, hint: "Resolve with one of: pass --wallet <name>, unset RUN402_WALLET, or run402 wallets unbind.", details: { env_wallet: describeRejectedValue(fromEnv), binding_wallet: binding.wallet, binding_file: binding.file }, }); } if (fromEnv) { assertValidSelectionName(fromEnv, "RUN402_WALLET"); return { name: fromEnv, source: "env", sourceDetail: "RUN402_WALLET" }; } if (binding) { assertValidSelectionName(binding.wallet, binding.file); return { name: binding.wallet, source: "binding", sourceDetail: binding.file }; } const globalDefault = getDefaultWallet(); if (globalDefault && globalDefault !== DEFAULT) return { name: globalDefault, source: "config", sourceDetail: "wallets use" }; return { name: DEFAULT, source: "default", sourceDetail: null }; } function looksLikeAddress(s) { return /^0x[a-fA-F0-9]{40}$/.test(s); } /** * Fail closed when a non-default selection names a wallet that does not exist * locally. Throws {@link WalletSelectionError} `WALLET_NOT_FOUND`. */ export function assertWalletExists({ name, source }) { if (name === DEFAULT) return; if (profileExists(name)) return; // A bare 64-hex private key satisfies the name charset, so this path can // still see key material: echo an address (public) or a short plain name, // redact anything else. // An address is public, so it is echoed whole. const shown = looksLikeAddress(name) ? name : describeRejectedValue(name); const hint = looksLikeAddress(name) ? `'${name}' looks like a wallet address, not a local wallet name. --wallet selects a local wallet; commands that take an address spell it --address (run402 billing link-wallet --address ${name}, run402 orgs payout-wallet --address ${name}, run402 orgs members add --address ${name}).` : shown === name ? `Run 'run402 wallets list' to see wallets, or 'run402 wallets new ${name}' to create it.` : "Run 'run402 wallets list' to see wallets. This value was not shown because it looks like a secret rather than a wallet name — if a private key or other credential landed here, treat it as compromised."; throw new WalletSelectionError({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${shown}'.`, hint, details: { wallet: shown, source }, }); } /** The environment variable carrying the published selection's provenance. */ export const ACTIVE_WALLET_CONTEXT_ENV = "RUN402_ACTIVE_WALLET_JSON"; /** * Resolve, fail closed, and PUBLISH the selection to `env` so every core path * function resolves under it: `RUN402_WALLET` names the wallet and * `RUN402_ACTIVE_WALLET_JSON` carries how it was chosen (read back by * `r.wallets.current()`, since the env var alone no longer can say). Call * once per process, before any client reads a path. */ export function selectWallet(opts = {}) { const env = (opts.env ?? process.env); const cwd = opts.cwd ?? process.cwd(); const fromEnv = envName(env); const binding = findWalletBinding(cwd); const resolved = resolveWalletSelection({ ...opts, env, cwd }); if (!opts.allowMissing) assertWalletExists(resolved); env.RUN402_WALLET = resolved.name; const context = { name: resolved.name, source: resolved.source, sourceDetail: resolved.sourceDetail, binding: binding ? { wallet: binding.wallet, file: binding.file } : null, envName: fromEnv, diverged: !!(fromEnv && binding && fromEnv !== binding.wallet), }; env[ACTIVE_WALLET_CONTEXT_ENV] = JSON.stringify(context); return resolved; } /** The published selection context, or null when no surface published one. */ export function readActiveWalletContext(env = process.env) { try { const parsed = JSON.parse(env[ACTIVE_WALLET_CONTEXT_ENV] || ""); return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } function shortAddr(a) { return typeof a === "string" && a.length >= 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a ?? null; } function badUsage(message, hint, details) { return new LocalError(message, CONTEXT, { code: "BAD_USAGE", ...(hint !== undefined ? { hint } : {}), ...(details !== undefined ? { details } : {}) }); } function requireName(name, what = "wallet name") { if (!name) throw badUsage(`Missing ${what}.`, "run402 wallets --help"); if (name === DEFAULT) return name; if (!isValidProfileName(name)) { throw new LocalError(`Invalid ${what} ${JSON.stringify(describeRejectedValue(name))}.`, CONTEXT, { code: "BAD_WALLET_NAME", hint: "Names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-').", details: { name: describeRejectedValue(name) }, }); } return name; } function walletNotFound(name) { return new LocalError(`No local wallet named '${describeRejectedValue(name)}'.`, CONTEXT, { code: "WALLET_NOT_FOUND", hint: "run402 wallets list", details: { name: describeRejectedValue(name) }, }); } function profileWalletPath(name) { return join(profileDir(name), "wallet.json"); } function walletInfo(name, active) { const meta = readMeta(name); let address = meta?.address ?? null; let rail = meta?.rail ?? null; const label = meta?.label ?? null; if (!address) { try { const w = readWallet(profileWalletPath(name)); address = w?.address ?? null; rail = rail ?? w?.rail ?? null; } catch { /* best-effort */ } } return { local_label: name, server_label: label, address, address_short: shortAddr(address), rail, active: name === active }; } /** * `r.wallets` on the Node entry: the isomorphic local-wallet and label verbs * plus the named-wallet (profile) management verbs. */ export class NodeWallets extends Wallets { #client; #clientFor; constructor(client, opts = {}) { super(client); this.#client = client; this.#clientFor = opts.clientFor; } /** The selection in force: the one a surface published, else resolved fresh from the environment and cwd. */ #selection() { const context = readActiveWalletContext(); if (context) return { name: context.name, context, resolved: null }; const resolved = resolveWalletSelection({ allowConflict: true }); return { name: resolved.name, context: null, resolved }; } /** Every local wallet, without loading any private key. */ async list() { const active = this.#selection().name; return listProfileNames().map((name) => walletInfo(name, active)); } /** The active wallet: its name, how it was selected, and the local wallet file's facts. */ async current() { const { name, context, resolved } = this.#selection(); const info = walletInfo(name, name); const warnings = []; const binding = context ? context.binding : findWalletBinding(process.cwd()); const fromEnv = context ? context.envName : envName(process.env); const diverged = context ? context.diverged : !!(fromEnv && binding && fromEnv !== binding.wallet); if (diverged && binding) { warnings.push({ code: "WALLET_SELECTION_CONFLICT", message: `RUN402_WALLET=${fromEnv} but ${binding.file} selects '${binding.wallet}'.`, hint: "Resolve with: --wallet <name>, unset RUN402_WALLET, or run402 wallets unbind.", }); } if (info.server_label && info.server_label !== name) { warnings.push({ code: "WALLET_LABEL_DRIFT", message: `Local label '${name}' differs from the server label '${info.server_label}'.`, hint: "Run 'run402 wallets rename' to reconcile.", }); } // `faucet_used` tracks faucet invocation, not pay-readiness. const local = await this.status(); return { local_label: name, source: context?.source ?? resolved?.source ?? "unknown", source_detail: context ? context.sourceDetail ?? null : resolved?.sourceDetail ?? null, address: local?.configured ? local.address : info.address, server_label: info.server_label, configured: !!local?.configured, created: local?.created ?? null, rail: local?.configured ? local.rail ?? "x402" : info.rail, faucet_used: !!local?.faucet_used, path: local?.path ?? getWalletPath(), ...(local?.configured ? {} : { next_actions: [initializeWalletAction()] }), warnings, }; } async create(name, opts = {}) { if (name === undefined) return super.create(); gateSecret(this.#client, "wallets.create", name, opts); const label = requireName(name); if (profileExists(label)) { throw new LocalError(`A wallet named '${label}' already exists.`, CONTEXT, { code: "WALLET_EXISTS", hint: "run402 wallets list", details: { name: label } }); } const requested = opts.rail ?? "x402"; if (!["x402", "mpp", "lightning"].includes(requested)) { throw badUsage("--rail must be x402, mpp, or lightning", undefined, { rail: requested }); } const rail = requested; const { generatePrivateKey, privateKeyToAccount } = await import("viem/accounts"); const privateKey = generatePrivateKey(); const address = privateKeyToAccount(privateKey).address; const created = new Date().toISOString(); ensureProfileDir(label); saveWallet({ address, privateKey, created, funded: false, rail }, profileWalletPath(label)); // The reserved `default` wallet carries no server label; a named wallet mirrors its name. if (label === DEFAULT) { writeMeta(label, { name: label, address, rail: rail, created }); } else { writeMeta(label, { name: label, address, label, rail: rail, created }); await this.#pushLabel(label, label, address); } // requireName constrains names to shell-safe lowercase identifiers. const command = rail === "lightning" ? `run402 --wallet ${label} init lightning` : label === DEFAULT ? "run402 wallets fund" : `run402 wallets use ${label}`; const why = rail === "lightning" ? "Initialize this Lightning wallet." : label === DEFAULT ? "Fund the wallet you just created from the testnet faucet." : "Select the wallet you just created."; return { local_label: label, address, rail, created: true, next: command, next_actions: [{ type: "run_command", command, why }] }; } /** Set the global default wallet (`wallets use`). */ async use(name) { const label = requireName(name); if (label !== DEFAULT && !profileExists(label)) throw walletNotFound(label); setDefaultWallet(label); return { local_label: label, active: true }; } /** Rename a wallet; renaming `default` moves it under `profiles/`. */ async rename(oldName, newName) { const from = requireName(oldName, "old wallet name"); const to = requireName(newName, "new wallet name"); if (to === DEFAULT) { throw new LocalError("Cannot rename a wallet to the reserved name 'default'.", CONTEXT, { code: "BAD_WALLET_NAME", details: { name: to } }); } if (!profileExists(from)) throw walletNotFound(from); try { renameProfile(from, to); } catch (e) { throw new LocalError(e?.message ?? "rename failed", CONTEXT, { code: "WALLET_RENAME_FAILED", details: { from, to } }); } if (getDefaultWallet() === from) setDefaultWallet(to); const meta = readMeta(to) ?? { name: to }; let address = meta.address ?? null; if (!address) { try { address = readWallet(profileWalletPath(to))?.address ?? null; } catch { /* best-effort */ } } writeMeta(to, { ...meta, name: to, label: to, ...(address ? { address } : {}) }); await this.#pushLabel(to, to, address); return { from, to, renamed: true }; } /** Bind a directory (default cwd) to a wallet by writing its name into `./.run402.json`. */ async bind(name, opts = {}) { const label = name ? requireName(name) : this.#selection().name; // MERGE, never clobber: the same file carries `org`/`room` from `orgs bind`. const { contents } = updateBindingFile(opts.cwd ?? process.cwd(), { wallet: label }); const result = { wallet: label, file: ".run402.json", bound: true, safe_to_commit: true, note: "Safe to commit — contains no secrets, only the wallet name.", binding: contents, }; if (label !== DEFAULT && !profileExists(label)) { result.warning = `No local wallet named '${label}' yet — create it with 'run402 wallets new ${label}'.`; } return result; } /** Remove only the `wallet` key from `./.run402.json`; the file goes when nothing else is left in it. */ async unbind(opts = {}) { const dir = opts.cwd ?? process.cwd(); const had = readBindingFile(dir).wallet !== undefined; const { contents, removed } = updateBindingFile(dir, { wallet: null }); return { file: ".run402.json", unbound: had, removed, binding: contents }; } /** * Adopt an existing private key as a new named wallet. `privateKey` may be a * function, called only after the name checks pass, so a caller reading the * key from a file or stdin reads nothing for a refused name. Refused on the * sandbox surface: its input is a private key. */ async import(name, privateKey) { gateSecret(this.#client, "wallets.import", name); const label = requireName(name); if (label === DEFAULT) { throw new LocalError("'default' is reserved.", CONTEXT, { code: "BAD_WALLET_NAME", details: { name: label } }); } if (profileExists(label)) { throw new LocalError(`A wallet named '${label}' already exists.`, CONTEXT, { code: "WALLET_EXISTS", details: { name: label } }); } const key = (typeof privateKey === "function" ? privateKey() : privateKey).trim(); if (!PRIVATE_KEY_RE.test(key)) { throw new LocalError("Key must be a 0x-prefixed 64-hex secp256k1 private key.", CONTEXT, { code: "BAD_PRIVATE_KEY", details: { name: label } }); } const { privateKeyToAccount } = await import("viem/accounts"); let address; try { address = privateKeyToAccount(key).address; } catch (e) { throw new LocalError(`Invalid private key: ${e?.message}`, CONTEXT, { code: "BAD_PRIVATE_KEY", details: { name: label } }); } const created = new Date().toISOString(); ensureProfileDir(label); saveWallet({ address, privateKey: key, created, funded: false, rail: "x402" }, profileWalletPath(label)); writeMeta(label, { name: label, address, label, rail: "x402", created }); await this.#pushLabel(label, label, address); return { local_label: label, address, imported: true }; } /** Delete a named wallet and its keys. Requires `{ confirm: true }`; `default` is protected. */ async remove(name, opts = {}) { const label = requireName(name); if (label === DEFAULT) { throw new LocalError("Refusing to remove the reserved 'default' wallet.", CONTEXT, { code: "WALLET_PROTECTED", details: { name: label } }); } if (!profileExists(label)) throw walletNotFound(label); if (opts.confirm !== true) { throw new LocalError(`Removing wallet '${label}' deletes its private key and project keystore. This cannot be undone.`, CONTEXT, { code: "CONFIRMATION_REQUIRED", hint: `Re-run with --yes to confirm: run402 wallets rm ${label} --yes`, details: { name: label }, }); } removeProfile(label); if (getDefaultWallet() === label) setDefaultWallet(DEFAULT); return { local_label: label, removed: true }; } /** * Best-effort server-side label push, signed with the TARGET wallet's key * so a just-created or renamed wallet can set its own label. On by default; * `RUN402_WALLET_LABEL_SYNC=0` disables it. Never throws: the local folder * name is the source of truth and the server label only mirrors it. */ async #pushLabel(name, label, address) { if (process.env.RUN402_WALLET_LABEL_SYNC === "0") return; if (!address || !this.#clientFor) return; try { const sibling = this.#clientFor({ walletPath: profileWalletPath(name), keystorePath: join(profileDir(name), "projects.json") }); await sibling.wallet(address).setLabel(label); } catch { /* best-effort — never block the local operation */ } } } //# sourceMappingURL=wallets.js.map