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.

292 lines • 11.5 kB
import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { existsSync, renameSync, mkdirSync, chmodSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { describeRejectedValue } from "./redact.js"; export const DEFAULT_API_BASE = "https://api.run402.com"; /** * Validate a user-supplied API base URL. Throws a clear error message that * names the env var when the URL is malformed or uses a scheme other than * http(s). Empty string is treated as "set but empty" (almost always a * templating mishap) and emits a stderr warning before falling back to * `fallback`. * * Returns the validated URL string (unchanged) or `null` if the env var was * unset. */ function validateApiBase(envVar, raw, fallback) { if (raw == null) return null; if (raw === "") { process.stderr.write(`warning: ${envVar} is set but empty - using default. Unset the env var to suppress this warning.\n`); return fallback; } let u; try { u = new URL(raw); } catch { throw new Error(`${envVar} is not a valid URL: ${JSON.stringify(describeRejectedValue(raw))}. Expected an http(s) URL like https://api.run402.com.`); } if (u.protocol !== "https:" && u.protocol !== "http:") { throw new Error(`${envVar} must use http(s):, got ${u.protocol} (full value: ${JSON.stringify(describeRejectedValue(raw))}).`); } return raw; } export function getApiBase() { const validated = validateApiBase("RUN402_API_BASE", process.env.RUN402_API_BASE, DEFAULT_API_BASE); return validated ?? getConfiguredApiBase() ?? DEFAULT_API_BASE; } export function getApiBaseSource() { if (process.env.RUN402_API_BASE !== undefined) return "env"; return getConfiguredApiBase() ? "profile" : "default"; } /** * API base for the deploy-v2 routes. Defaults to the same value as * `getApiBase()`. Set `RUN402_DEPLOY_API_BASE` to point only deploy traffic * elsewhere — useful when running deploy-v2 against a staging gateway while * the rest of the SDK still talks to production. In normal use callers * should not need this override. */ export function getDeployApiBase() { const fallback = getApiBase(); const validated = validateApiBase("RUN402_DEPLOY_API_BASE", process.env.RUN402_DEPLOY_API_BASE, fallback); return validated ?? fallback; } /** * The base credential directory — the root under which the `default` wallet * lives directly and named wallets live under `profiles/<name>/`. This is the * value `RUN402_CONFIG_DIR` overrides; profiles nest *within* it. */ export function getConfigBaseDir() { return process.env.RUN402_CONFIG_DIR || join(homedir(), ".config", "run402"); } export const DEFAULT_PROFILE = "default"; // Filesystem-safe wallet/profile name. Lowercase only (avoids collisions on // case-insensitive filesystems like macOS), starts alphanumeric, then // alphanumeric/underscore/hyphen, max 64 chars. The CLI edge enforces this for // nice UX; core re-checks it as a defense-in-depth guard so a hostile // `RUN402_WALLET`/`RUN402_PROFILE` cannot traverse outside the profiles dir. const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; export function isValidProfileName(name) { return PROFILE_NAME_RE.test(name); } /** * Guard against path traversal from the profile env vars. The reserved * `default` profile is always allowed (it maps to the base dir). Any other * name must pass the filesystem-safe pattern; otherwise we throw rather than * silently resolve a surprising path. */ function assertSafeProfileName(name) { if (name === DEFAULT_PROFILE) return; if (!isValidProfileName(name)) { // A value that failed this check is a value we know nothing about — the // most likely mistake is a typo, but a Base-mainnet private key pasted // into RUN402_WALLET (a NAME field) is a demonstrated case, and echoing // the raw value propagates it into terminals, logs, and session // transcripts. // describeRejectedValue() shows short/plain values in full (useful for // an actual typo) and redacts anything long or hex-shaped enough to be // a secret instead of a name. throw new Error(`Invalid wallet/profile name ${JSON.stringify(describeRejectedValue(name))}. ` + "Names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-'). " + "Check the RUN402_WALLET / RUN402_PROFILE env var."); } } /** * The active wallet/profile name from the environment. `RUN402_WALLET` is * canonical; `RUN402_PROFILE` is accepted as an alias. Empty/unset → the * reserved `default`. The CLI edge resolves the `--wallet` flag and any * per-directory `.run402.json` binding into `RUN402_WALLET` *before* dispatch, * so core stays env-only and never reads argv or cwd. */ export function getActiveProfile() { const raw = process.env.RUN402_WALLET ?? process.env.RUN402_PROFILE; const name = raw == null ? "" : raw.trim(); if (!name) return DEFAULT_PROFILE; assertSafeProfileName(name); return name; } /** Directory holding all named wallets: `{base}/profiles`. */ export function getProfilesDir() { return join(getConfigBaseDir(), "profiles"); } /** * The effective config directory for the *active* wallet. The `default` wallet * resolves to the base dir (zero migration for existing single-wallet * installs); any named wallet resolves to `{base}/profiles/<name>`. Because * keystore, wallet, and meta paths all derive from this, switching the * profile env var moves the whole wallet bundle atomically — and the SDK/MCP * inherit profile selection for free. */ export function getConfigDir() { const base = getConfigBaseDir(); const profile = getActiveProfile(); return profile === DEFAULT_PROFILE ? base : join(base, "profiles", profile); } export function getKeystorePath() { return getProjectCredentialsPath(); } export function getLegacyProjectsPath() { return join(getConfigDir(), "projects.json"); } export function getProjectCredentialsPath() { return join(getConfigDir(), "credentials", "project-keys.v1.json"); } export function getProfileStatePath() { return join(getConfigDir(), "state.json"); } export function getApiTargetConfigPath() { return join(getConfigDir(), "target.json"); } export function readApiTargetConfig(path) { const p = path ?? getApiTargetConfigPath(); try { const parsed = JSON.parse(readFileSync(p, "utf-8")); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; const cfg = parsed; if (cfg.api_base !== undefined && typeof cfg.api_base !== "string") return null; if (cfg.target_kind !== undefined && cfg.target_kind !== "cloud" && cfg.target_kind !== "core" && cfg.target_kind !== "unknown") { return null; } return cfg; } catch { return null; } } function atomicWrite(path, content, mode) { const dir = dirname(path); mkdirSync(dir, { recursive: true }); const tmp = join(dir, `.target.${randomBytes(4).toString("hex")}.tmp`); writeFileSync(tmp, content, { mode }); renameSync(tmp, path); try { chmodSync(path, mode); } catch { /* best-effort on non-POSIX */ } } export function saveApiTargetConfig(config, path) { const p = path ?? getApiTargetConfigPath(); atomicWrite(p, JSON.stringify(config, null, 2), 0o600); } export function configureApiBase(apiBase, options = {}) { if (apiBase === "") { throw new Error("api_base must be a non-empty http(s) URL."); } const validated = validateApiBase("api_base", apiBase, DEFAULT_API_BASE); if (!validated) { throw new Error("api_base must be a non-empty http(s) URL."); } const config = { api_base: validated.replace(/\/+$/, ""), target_kind: options.target_kind ?? "unknown", updated_at: options.updated_at ?? new Date().toISOString(), ...(options.health_status ? { health_status: options.health_status } : {}), ...(options.health_error ? { health_error: options.health_error } : {}), }; saveApiTargetConfig(config); return config; } export function getConfiguredApiBase() { const cfg = readApiTargetConfig(); if (!cfg?.api_base) return null; const validated = validateApiBase("api_base", cfg.api_base, DEFAULT_API_BASE); return validated ? validated.replace(/\/+$/, "") : null; } export function getApiTargetKind() { const cfg = readApiTargetConfig(); return cfg?.target_kind ?? "unknown"; } function stripTrailingSlashes(value) { return value.replace(/\/+$/, ""); } function isExplicitHttpCoreTarget(apiBase) { if (stripTrailingSlashes(apiBase) === stripTrailingSlashes(DEFAULT_API_BASE)) return false; try { return new URL(apiBase).protocol === "http:"; } catch { return false; } } export function isCoreApiTarget() { const apiBase = getApiBase(); const cfg = readApiTargetConfig(); if (cfg?.target_kind === "core" && cfg.api_base) { return stripTrailingSlashes(apiBase) === stripTrailingSlashes(cfg.api_base); } if (process.env.RUN402_API_BASE !== undefined && isExplicitHttpCoreTarget(apiBase)) return true; return false; } /** * The file that holds a wallet's key, in the base config dir (the `default` * wallet) and in each `profiles/<name>/` directory. */ export const WALLET_FILE_NAME = "wallet.json"; /** The name the wallet key file had before it was `wallet.json`. */ const PREVIOUS_WALLET_FILE_NAME = "allowance.json"; const walletFilesMovedFor = new Set(); /** * One-time local move of the wallet key file to its current name: in the base * config dir and every `profiles/<name>/` directory, `allowance.json` is * renamed to `wallet.json` (an atomic same-directory rename) when * `wallet.json` is absent, then tightened to 0600. A directory that already * holds `wallet.json` is left untouched. Runs once per base dir per process; * nothing reads the previous name afterwards. */ export function moveWalletFiles() { const base = getConfigBaseDir(); if (walletFilesMovedFor.has(base)) return; walletFilesMovedFor.add(base); const dirs = [base]; try { for (const entry of readdirSync(join(base, "profiles"), { withFileTypes: true })) { if (entry.isDirectory()) dirs.push(join(base, "profiles", entry.name)); } } catch { // No profiles directory yet. } for (const dir of dirs) { const from = join(dir, PREVIOUS_WALLET_FILE_NAME); const to = join(dir, WALLET_FILE_NAME); if (existsSync(to) || !existsSync(from)) continue; try { renameSync(from, to); } catch { continue; } try { chmodSync(to, 0o600); } catch { // Best-effort (e.g. Windows / exotic filesystems). Read-time self-heal // in readWallet() is the backstop. } } } export function getWalletPath() { if (process.env.RUN402_WALLET_PATH) return process.env.RUN402_WALLET_PATH; moveWalletFiles(); return join(getConfigDir(), WALLET_FILE_NAME); } //# sourceMappingURL=config.js.map