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