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.
261 lines • 12.9 kB
JavaScript
/**
* `credentials` namespace — TWO surfaces that must not be confused.
*
* r.credentials.<verb> the GATEWAY's project credentials
* r.credentials.projectKeys.<verb> the LOCAL key cache on this machine
*
* A **project credential** (`r402_…`) is a ROW on the gateway: named, listable,
* expiring, individually revocable, and several may be live per kind at once —
* that overlap IS zero-downtime rotation. It is the replacement for the legacy
* `anon_key` / `service_key`, which are DERIVED from the platform signing key,
* never expire, and cannot be revoked one at a time. The signing key behind the
* legacy pair is being retired.
*
* These methods existed on the gateway and in the OpenAPI document with no
* client at all, so an agent that wanted off the legacy keys had to hand-roll a
* SIWX-signed request — which is exactly the "hidden manual step" the project
* exists to remove. `r.credentials.status()` is the one to poll; it answers
* "am I still on the retiring key" without any privileged role.
*
* The secret is returned EXACTLY ONCE, from `issue()` / `rotate()` /
* `mintToken()`. There is no read that returns it. Never write one of these
* responses to a result cache, tmp file, or expansion handle.
*/
import { gateSecret } from "../secret-gate.js";
import { LocalError } from "../errors.js";
import { requireProjectCredentials } from "../project-credentials.js";
export class Credentials {
client;
/** The LOCAL key cache on this machine. Not project inventory. */
projectKeys;
constructor(client) {
this.client = client;
this.projectKeys = new ProjectKeysCache(client);
}
/**
* Issue a named project credential
* (`POST /projects/v1/:project_id/credentials`).
*
* Requires owner membership on the project's owning org PLUS a fresh
* step-up, and a grant key can never satisfy it — a scoped agent credential
* must not be able to escalate itself into a permanent root. If you are
* running unattended and hold only a grant key, use {@link mintToken}.
*
* The returned `secret` is shown ONCE. Persist it before doing anything
* else; there is no read that returns it, only `rotate()` for a new one.
*/
async issue(projectId, input) {
gateSecret(this.client, "credentials.issue", projectId, input);
if (!projectId) {
throw new LocalError("credentials.issue requires a projectId", "issuing project credential");
}
if (input?.kind !== "anon" && input?.kind !== "service") {
throw new LocalError('credentials.issue requires { kind: "anon" | "service" }', "issuing project credential", { code: "BAD_USAGE", details: { project_id: projectId } });
}
if (typeof input.name !== "string" || input.name.trim().length === 0) {
throw new LocalError("credentials.issue requires a non-empty { name } — it is how you identify and rotate this credential later", "issuing project credential", { code: "BAD_USAGE", details: { project_id: projectId } });
}
const body = { kind: input.kind, name: input.name.trim() };
if (input.expiresAt !== undefined)
body.expires_at = input.expiresAt;
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/credentials`, { method: "POST", body, context: "issuing project credential" });
}
/**
* List a project's credentials (`GET /projects/v1/:project_id/credentials`).
* Metadata only — never a secret or a secret hash. `project.read` is enough.
*/
async list(projectId, opts = {}) {
if (!projectId) {
throw new LocalError("credentials.list requires a projectId", "listing project credentials");
}
const qs = opts.includeRevoked ? "?include_revoked=true" : "";
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/credentials${qs}`, { method: "GET", context: "listing project credentials" });
}
/**
* Rotation posture (`GET /projects/v1/:project_id/credential-status`) — the
* surface to poll deliberately, rather than waiting to notice the
* `X-Run402-Key-Rotation` advisory header.
*
* `state: "legacy"` means the project still depends on the derived
* anon/service keys signed by the retiring platform key. Only `project.read`
* is required: knowing you should rotate is not a privileged act, and gating
* it behind the owner role would hide the warning from the automation that
* most needs it.
*/
async status(projectId) {
if (!projectId) {
throw new LocalError("credentials.status requires a projectId", "reading credential status");
}
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/credential-status`, { method: "GET", context: "reading credential status" });
}
/**
* Rotate a credential
* (`POST /projects/v1/:project_id/credentials/:credential_id/rotate`) — mint
* a replacement and revoke the old one in a single transaction, keeping the
* name and recording `replacement_of`.
*
* For a rotation with NO downtime window, prefer `issue()` a second live
* credential, deploy it, then `revoke()` the first: several credentials may
* be live per kind at once, and that overlap is the whole point. `rotate()`
* is the right call when the old secret is already compromised.
*/
async rotate(projectId, credentialId) {
gateSecret(this.client, "credentials.rotate", projectId, credentialId);
if (!projectId) {
throw new LocalError("credentials.rotate requires a projectId", "rotating project credential");
}
if (!credentialId) {
throw new LocalError("credentials.rotate requires a credentialId", "rotating project credential");
}
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/credentials/${encodeURIComponent(credentialId)}/rotate`, { method: "POST", context: "rotating project credential" });
}
/**
* Revoke a credential immediately
* (`DELETE /projects/v1/:project_id/credentials/:credential_id`). Frees the
* name for reuse. Owner + step-up, same as `issue()`.
*/
async revoke(projectId, credentialId, opts = {}) {
if (!projectId) {
throw new LocalError("credentials.revoke requires a projectId", "revoking project credential");
}
if (!credentialId) {
throw new LocalError("credentials.revoke requires a credentialId", "revoking project credential");
}
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/credentials/${encodeURIComponent(credentialId)}`, {
method: "DELETE",
...(opts.reason ? { body: { reason: opts.reason } } : {}),
context: "revoking project credential",
});
}
/**
* Mint a SHORT-LIVED project token (`POST /projects/v1/:project_id/tokens`).
*
* This is the cold-restart recovery path, and the one credential call a
* grant key CAN make with no human present: an agent that lost its local
* state but still holds a grant key gets back to work unattended. There is no
* step-up, because there is nobody to prompt; what it hands back expires, so
* it cannot become a durable root.
*
* Defaults to `service` — the kind an agent needs to deploy.
*/
async mintToken(projectId, opts = {}) {
gateSecret(this.client, "credentials.mintToken", projectId, opts);
if (!projectId) {
throw new LocalError("credentials.mintToken requires a projectId", "minting project token");
}
return this.client.request(`/projects/v1/${encodeURIComponent(projectId)}/tokens`, {
method: "POST",
body: { kind: opts.kind ?? "service" },
context: "minting project token",
});
}
}
export class ProjectKeysCache {
client;
constructor(client) {
this.client = client;
}
async list() {
const listProjectCredentials = this.client.credentials.listProjectCredentials;
if (!listProjectCredentials) {
throw unsupported("listing local project-key cache", "listProjectCredentials");
}
const entries = await listProjectCredentials.call(this.client.credentials);
const projects = await Promise.all(Object.entries(entries)
.sort(([a], [b]) => a.localeCompare(b))
.map(([projectId, entry]) => redactedEntry(projectId, entry, provenance(this.client))));
return { projects, ...provenance(this.client) };
}
async status(projectId) {
const entry = await this.client.getProjectCredentials(projectId);
return redactedEntry(projectId, entry, provenance(this.client));
}
async import(projectId, opts) {
gateSecret(this.client, "credentials.projectKeys.import", projectId, opts);
if (!this.client.credentials.saveProject) {
throw unsupported("importing local project keys", "saveProject");
}
if (!opts || typeof opts !== "object" || typeof opts.serviceKey !== "string" || opts.serviceKey.length === 0) {
throw new LocalError("credentials.projectKeys.import requires a non-empty serviceKey", "importing local project keys", {
code: "BAD_USAGE",
details: { project_id: projectId },
});
}
const existing = await this.client.getProjectCredentials(projectId);
await this.client.credentials.saveProject(projectId, {
anon_key: opts.anonKey ?? existing?.anon_key ?? "",
service_key: opts.serviceKey,
...(opts.siteUrl ?? existing?.site_url ? { site_url: opts.siteUrl ?? existing?.site_url } : {}),
cached_at: new Date().toISOString(),
});
const entry = await this.client.getProjectCredentials(projectId);
return { imported: true, ...(await redactedEntry(projectId, entry, provenance(this.client))) };
}
async export(projectId, opts = {}) {
gateSecret(this.client, "credentials.projectKeys.export", projectId, opts);
if (opts.reveal !== true) {
throw new LocalError("Exporting full project keys requires { reveal: true }", "exporting local project keys", {
code: "REVEAL_REQUIRED",
details: { project_id: projectId, ...provenance(this.client) },
});
}
const keys = await requireProjectCredentials(this.client, projectId, "exporting local project keys");
return {
project_id: projectId,
...keys,
...provenance(this.client),
revealed: true,
};
}
async delete(projectId) {
if (!this.client.credentials.removeProject) {
throw unsupported("removing local project keys", "removeProject");
}
const existed = await this.client.getProjectCredentials(projectId);
await this.client.credentials.removeProject(projectId);
return { removed: Boolean(existed), ...(await redactedEntry(projectId, null, provenance(this.client))) };
}
}
function provenance(client) {
return {
source: "local_cache",
...(client.credentials.getProjectCredentialCacheInfo?.() ?? {}),
};
}
async function redactedEntry(projectId, entry, info) {
return {
project_id: projectId,
configured: Boolean(entry),
has_anon_key: Boolean(entry?.anon_key),
has_service_key: Boolean(entry?.service_key),
anon_key_prefix: prefix(entry?.anon_key),
service_key_prefix: prefix(entry?.service_key),
anon_key_fingerprint: await fingerprint(entry?.anon_key),
service_key_fingerprint: await fingerprint(entry?.service_key),
site_url: entry?.site_url ?? null,
cached_at: entry?.cached_at ?? null,
...info,
};
}
function prefix(value) {
if (!value)
return null;
return `${value.slice(0, 8)}...`;
}
async function fingerprint(value) {
if (!value || !globalThis.crypto?.subtle)
return null;
const bytes = new TextEncoder().encode(value);
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
.slice(0, 16);
}
function unsupported(context, method) {
return new LocalError(`The configured credential provider does not support ${method}(). Use @run402/sdk/node for local project-key cache operations.`, context, {
code: "LOCAL_CREDENTIAL_CACHE_UNSUPPORTED",
details: { method, source: "local_cache" },
});
}
//# sourceMappingURL=credentials.js.map