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.

228 lines • 9.62 kB
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, rmdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; import { getLegacyProjectsPath, getProjectCredentialsPath } from "./config.js"; import { clearActiveProjectId as clearProfileActiveProjectId, getActiveProjectId as getProfileActiveProjectId, recordMigration, setActiveProjectId as setProfileActiveProjectId, } from "./profile-state.js"; import { readWallet } from "./wallet.js"; function withFileLock(path, fn, { retries = 200, delayMs = 20 } = {}) { const lockDir = path + ".lock"; mkdirSync(dirname(path), { recursive: true }); for (let i = 0; i < retries; i++) { try { mkdirSync(lockDir, { mode: 0o700 }); } catch (e) { const code = e.code; if (code !== "EEXIST") throw e; const until = Date.now() + delayMs; while (Date.now() < until) { /* spin */ } continue; } try { return fn(); } finally { try { rmdirSync(lockDir); } catch { /* best-effort */ } } } throw new Error(`Could not acquire keystore lock after ${retries} retries: ${lockDir}`); } function normalizeParsedKeyStore(parsed) { if (Array.isArray(parsed)) { const projects = {}; for (const item of parsed) { if (item.project_id) { projects[item.project_id] = { anon_key: item.anon_key, service_key: item.service_key, ...(item.site_url && { site_url: item.site_url }), ...(item.deployed_at && { deployed_at: item.deployed_at }), cached_at: new Date().toISOString(), source: "legacy_projects_json", }; } } return { version: 1, source: "local_cache", projects }; } if (parsed && typeof parsed === "object" && "projects" in parsed) { const obj = parsed; const rawProjects = obj.projects && typeof obj.projects === "object" && !Array.isArray(obj.projects) ? obj.projects : {}; const projects = {}; for (const [id, proj] of Object.entries(rawProjects)) { const rec = { ...proj }; delete rec.tier; delete rec.lease_expires_at; delete rec.expires_at; projects[id] = rec; } return { version: 1, source: "local_cache", ...(typeof obj.active_project_id === "string" && { active_project_id: obj.active_project_id }), ...(typeof obj.previous_active_project_id === "string" && { previous_active_project_id: obj.previous_active_project_id }), projects, ...(typeof obj.migrated_from === "string" && { migrated_from: obj.migrated_from }), ...(typeof obj.migrated_at === "string" && { migrated_at: obj.migrated_at }), }; } return { version: 1, source: "local_cache", projects: {} }; } function loadParsedKeyStore(path) { try { return normalizeParsedKeyStore(JSON.parse(readFileSync(path, "utf-8"))); } catch { return { version: 1, source: "local_cache", projects: {} }; } } function migrateLegacyProjectsJson(targetPath) { const legacyPath = getLegacyProjectsPath(); if (existsSync(targetPath) || !existsSync(legacyPath)) return; const legacy = loadParsedKeyStore(legacyPath); const migratedAt = new Date().toISOString(); const cache = { version: 1, source: "local_cache", projects: legacy.projects, migrated_from: legacyPath, migrated_at: migratedAt, }; saveKeyStore(cache, targetPath); if (legacy.active_project_id) { // Scoped the same way every other write in this module now is (see // `currentPrincipal`'s doc comment) — an unscoped write here is exactly // what poisons the "unknown"-principal bucket for good on a machine that // migrates before its wallet exists yet. setProfileActiveProjectId(legacy.active_project_id, undefined, { principal: currentPrincipal() }); } recordMigration("projects_json_import", { legacy_path: legacyPath, cache_path: targetPath, project_count: Object.keys(legacy.projects).length, imported_at: migratedAt, }); } /** * Load the project-key credential cache from disk. * Auto-imports legacy `projects.json` formats into the new cache path when using * the default location: * - Array format (CLI legacy): [{project_id, ...}] → {projects: {id: {...}}} * - Object format: {active_project_id, projects} → credentials cache + state.json * - Old metadata fields: tier/expires_at/lease_expires_at are stripped */ export function loadKeyStore(path) { const p = path ?? getProjectCredentialsPath(); if (!path) migrateLegacyProjectsJson(p); return loadParsedKeyStore(p); } export function saveKeyStore(store, path) { const p = path ?? getProjectCredentialsPath(); const dir = dirname(p); mkdirSync(dir, { recursive: true }); const cache = { version: 1, source: "local_cache", ...(store.migrated_from ? { migrated_from: store.migrated_from } : {}), ...(store.migrated_at ? { migrated_at: store.migrated_at } : {}), projects: store.projects, }; const tmp = join(dir, `.project-keys.${randomBytes(4).toString("hex")}.tmp`); writeFileSync(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 }); renameSync(tmp, p); chmodSync(p, 0o600); } export function getProject(projectId, path) { const store = loadKeyStore(path); return store.projects[projectId]; } export function saveProject(projectId, project, path) { const p = path ?? getProjectCredentialsPath(); withFileLock(p, () => { const store = loadKeyStore(p); store.projects[projectId] = { ...project, cached_at: project.cached_at ?? new Date().toISOString() }; saveKeyStore(store, p); }); } export function updateProject(projectId, update, path) { const p = path ?? getProjectCredentialsPath(); withFileLock(p, () => { const store = loadKeyStore(p); const existing = store.projects[projectId]; if (existing) { store.projects[projectId] = { ...existing, ...update, cached_at: existing.cached_at ?? new Date().toISOString() }; saveKeyStore(store, p); } }); } export function removeProject(projectId, path) { const p = path ?? getProjectCredentialsPath(); withFileLock(p, () => { const store = loadKeyStore(p); delete store.projects[projectId]; saveKeyStore(store, p); }); if (!path) clearProfileActiveProjectId(projectId, undefined, { principal: currentPrincipal() }); } /** * The active-project scope's `principal`, derived from the CURRENT wallet's * wallet — matching exactly what `NodeCredentialsProvider.setActiveProject` * (sdk/src/node/credentials.ts) writes after `projects.provision` / * `projects.use`. Without this, every reader here used the profile-state * module's own default (empty) scope, which resolves to a FIXED * "unknown"-principal bucket — the same bucket for every wallet in this * profile. * * That mismatch is silent for a brand-new profile (the flat fallback in * `profile-state.ts#getActiveProjectId` happens to agree), but once the * "unknown" bucket is EVER populated by any principal-less write — the * one-time legacy `projects.json` migration below, or any provision/`use` * call made before this machine had a wallet — it never gets * updated again (real wallet operations write to the principal-keyed * bucket, not "unknown") and PERMANENTLY shadows every later wallet-scoped * activation for every caller that reads through this module: `resolveProjectId` * / `resolveProject` (cli/lib/config.mjs), `projects current`, and every * vault-verb target resolution. The failure it causes: `repos create` * calls `projects.provision`, whose `creds.setActiveProject` correctly * persists the new project under the real wallet's scoped bucket AND the * flat fallback, yet every CLI read of "the active project" resolves the * stale "unknown"-bucket entry first and never falls through to the * freshly-updated flat value. * * Best-effort: an unreadable/malformed wallet degrades to `null` (the * "unknown" bucket callers tolerate), never a * throw — this is a read-scoping concern, not a wallet-validity one. */ function currentPrincipal() { try { return readWallet()?.address ?? null; } catch { return null; } } export function getActiveProjectId(path) { return getProfileActiveProjectId(path, { principal: currentPrincipal() }); } export function setActiveProjectId(projectId, path) { setProfileActiveProjectId(projectId, path, { principal: currentPrincipal() }); } /** * Scoped the same way as the getter/setter above — a caller reaching for * `profile-state.ts`'s `clearActiveProjectId` directly (unscoped) clears only * the "unknown"-principal bucket, leaving a real wallet's own scoped entry * (set through `setActiveProjectId` above, or `NodeCredentialsProvider`) * untouched and still resolvable. */ export function clearActiveProjectId(projectId, path) { clearProfileActiveProjectId(projectId, path, { principal: currentPrincipal() }); } //# sourceMappingURL=keystore.js.map