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
JavaScript
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