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.

365 lines • 16.8 kB
/** * `session` — a person's **sign-in session** (wire token class * `control_plane_session`). One session, bound to the principal, graded by how * it was minted (`grade` on every mint response and on `GET /agent/v1/whoami`): * * - `browser` — the console: email magic link, passkey, Google, or GitHub. * - `loopback` — `run402 login`: the loopback-PKCE browser ceremony * ({@link Session.buildCliAuthorizeUrl} + {@link Session.exchangeCliToken}). * Full and step-up-able. * - `device` — `run402 login --device`: RFC 8628 device authorization * ({@link Session.deviceStart} + {@link Session.devicePoll}). Read-only: the * gateway refuses every mutation with `403 SESSION_READ_ONLY`. * * Reached as `r.session.*`. Isomorphic — no Node APIs; the loopback server and * PKCE generation live in the Node CLI. The public *mint* methods send no auth * (the body, the magic-link token, or the code + verifier is the credential); * the *session-bound* methods take `opts.token` to send the bearer explicitly, * and fall back to the credential provider's default auth (e.g. * {@link controlPlaneSessionCredentials} or a SIWX wallet) when omitted. * * WebAuthn option/assertion payloads are opaque passthroughs (`unknown`) — the * browser runs the actual ceremony; a headless client cannot. * * High-stakes writes (invite, membership, transfer, delete) require a **fresh * passkey** — a magic-link/OAuth session does NOT satisfy step-up, so the * gateway returns {@link StepUpRequiredError}; `stepUpOptions`/`stepUpVerify` * are how a long-lived session re-establishes that freshness. A write approval * ({@link WriteApproval}) never satisfies step-up. */ import { gateSecret } from "../secret-gate.js"; import { ApiError, NetworkError } from "../errors.js"; const POLL_ERROR_CODES = new Set([ "authorization_pending", "slow_down", "access_denied", "expired_token", ]); export class Session { client; constructor(client) { this.client = client; } // ── command-line login (public — no auth) ── /** * Build the loopback-PKCE authorize URL `run402 login` opens in the browser * (`GET /agent/v1/control-plane/cli/authorize`). Pure — no network. The * caller generates `codeChallenge`/`state`/`nonce` and runs the redirect * server on `127.0.0.1`. */ buildCliAuthorizeUrl(params) { const q = new URLSearchParams({ redirect_uri: params.redirectUri, code_challenge: params.codeChallenge, code_challenge_method: "S256", state: params.state, nonce: params.nonce, }); return `${this.client.apiBase}/agent/v1/control-plane/cli/authorize?${q.toString()}`; } /** * Exchange the loopback authorization code (+ PKCE verifier) for a * `loopback`-grade sign-in session (`POST …/cli/token`). Unauthenticated — * the code + verifier are the credential. */ async exchangeCliToken(params) { gateSecret(this.client, "session.exchangeCliToken", params); return this.client.request("/agent/v1/control-plane/cli/token", { method: "POST", body: { code: params.code, code_verifier: params.codeVerifier, redirect_uri: params.redirectUri, state: params.state, }, withAuth: false, context: "exchanging CLI authorization code", }); } /** * Begin RFC 8628 device authorization (`POST …/cli/device`) for * `run402 login --device`. Unauthenticated. Returns the codes the CLI prints * (`user_code` + `verification_uri`) plus the poll `interval` and * `expires_in`. The person approves in the console, which needs a recent * sign-in (`403 RECENT_SIGN_IN_REQUIRED` otherwise). */ async deviceStart() { return this.client.request("/agent/v1/control-plane/cli/device", { method: "POST", body: {}, withAuth: false, context: "starting device authorization", }); } /** * Poll once for approval (`POST …/cli/device/token`). Bypasses the kernel's * error mapping on purpose: the RFC 8628 error codes (`authorization_pending`, * `slow_down`, ...) are normal polling states returned as data, not * exceptions. Only an unexpected response shape throws. The approved session * is `device` grade (read-only). */ async devicePoll(deviceCode) { gateSecret(this.client, "session.devicePoll", deviceCode); const url = `${this.client.apiBase}/agent/v1/control-plane/cli/device/token`; let res; try { res = await this.client.fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_code: deviceCode }), }); } catch (err) { throw new NetworkError(`Network error while polling device token: ${err.message}`, err, "polling device token"); } const body = (await res.json().catch(() => null)); if (res.ok && body && typeof body.control_plane_session_token === "string") { return { kind: "approved", session: body }; } const error = body && typeof body.error === "string" ? body.error : null; if (error && POLL_ERROR_CODES.has(error)) { return { kind: error }; } throw new ApiError(`Unexpected device-token response (HTTP ${res.status})`, res.status, body, "polling device token"); } // ── login / mint (public — no auth; the body or link token is the credential) ── /** * Send a control-plane sign-in magic link to `email` * (`POST /agent/v1/control-plane/session/email`). Non-enumerating: an * identical response whether or not the email can sign in. Rate-limited. */ async email(input) { return this.client.request("/agent/v1/control-plane/session/email", { method: "POST", body: { email: input.email }, withAuth: false, context: "sending control-plane magic link", }); } /** * Exchange a magic-link token for a sign-in session * (`POST …/session/email/verify`). Verifies the email, resolves/creates the * principal, **auto-claims any pending invites**, and mints the session * (`amr: ["email"]`). */ async verifyEmail(input) { gateSecret(this.client, "session.verifyEmail", input); return this.client.request("/agent/v1/control-plane/session/email/verify", { method: "POST", body: { token: input.token }, withAuth: false, context: "verifying control-plane magic link", }); } /** * Get WebAuthn login options for an email's passkeys * (`POST …/session/passkey/options`). Opaque — pass `options` to the browser's * `navigator.credentials.get`. */ async passkeyOptions(input) { return this.client.request("/agent/v1/control-plane/session/passkey/options", { method: "POST", body: { email: input.email }, withAuth: false, context: "requesting control-plane passkey login options", }); } /** * Verify a WebAuthn assertion and mint a session (`amr: ["passkey"]`) * (`POST …/session/passkey/verify`). `response` is the opaque assertion from * the browser. */ async passkeyVerify(input) { gateSecret(this.client, "session.passkeyVerify", input); return this.client.request("/agent/v1/control-plane/session/passkey/verify", { method: "POST", body: { email: input.email, response: input.response }, withAuth: false, context: "verifying control-plane passkey login", }); } /** * Build the browser OAuth start URL for `provider` * (`GET …/oauth/:provider/start`). Pure — no network. Open it in a browser; * the gateway 302s to the provider, then the callback lands on the console * with the session token in the URL fragment. * * Note: the live bridge can return `503` until the gateway provisions the * provider's `CONTROL_PLANE_{GOOGLE,GITHUB}_*` client credentials. */ oauthUrl(provider) { return `${this.client.apiBase}/agent/v1/control-plane/oauth/${encodeURIComponent(provider)}/start`; } /** * Run the recovery-code ceremony (`POST …/recovery/consume`). Mints a session * with `amr: ["recovery_code"]` which **cannot** do high-stakes ops * (`must_enroll_passkey: true`) — enrol a passkey to restore full access. */ async consumeRecoveryCode(input) { gateSecret(this.client, "session.consumeRecoveryCode", input); return this.client.request("/agent/v1/control-plane/recovery/consume", { method: "POST", body: { code: input.code }, withAuth: false, context: "consuming control-plane recovery code", }); } // ── session lifecycle (bearer; falls back to credential provider) ── /** * Resolve the session's principal, memberships, and `session` — * `{ grade, amr, amr_times }` (`GET /agent/v1/whoami`). The `memberships` * reflect any invites auto-claimed at sign-in. With no `token` the request * uses the credential provider, and `session` is `null` for a wallet caller. */ async whoami(opts = {}) { gateSecret(this.client, "session.whoami", opts); return this.client.request("/agent/v1/whoami", { ...authFor(opts), context: "resolving sign-in session", }); } /** Rotate the access token (`POST …/session/refresh`). */ async refresh(opts = {}) { gateSecret(this.client, "session.refresh", opts); return this.client.request("/agent/v1/control-plane/session/refresh", { method: "POST", ...authFor(opts), context: "refreshing sign-in session", }); } /** Sign out — revoke the session server-side (`POST …/session/revoke`). Idempotent. */ async revoke(opts = {}) { gateSecret(this.client, "session.revoke", opts); return this.client.request("/agent/v1/control-plane/session/revoke", { method: "POST", ...authFor(opts), context: "revoking sign-in session", }); } // ── passkey enrollment (bearer + step-up, enforced by the gateway) ── /** WebAuthn registration options for a new passkey (`POST …/passkey/enroll/options`). */ async enrollPasskeyOptions(opts = {}) { gateSecret(this.client, "session.enrollPasskeyOptions", opts); return this.client.request("/agent/v1/control-plane/passkey/enroll/options", { method: "POST", ...authFor(opts), context: "requesting control-plane passkey enrollment options", }); } /** Verify a passkey registration (`POST …/passkey/enroll/verify`). `label` names the authenticator. */ async enrollPasskeyVerify(input) { gateSecret(this.client, "session.enrollPasskeyVerify", input); const { token, response, label } = input; return this.client.request("/agent/v1/control-plane/passkey/enroll/verify", { method: "POST", body: { response, ...(label !== undefined ? { label } : {}) }, ...authFor({ token }), context: "verifying control-plane passkey enrollment", }); } // ── step-up ceremony (bearer) ── /** * WebAuthn step-up options for a high-stakes op (`POST …/step-up/options`). * `opClass` binds the elevation, e.g. `"org.invite"` / `"org.membership"` / * `"project.transfer"` (see {@link StepUpRequiredError.requiredAmr}). */ async stepUpOptions(input = {}) { gateSecret(this.client, "session.stepUpOptions", input); const { token, opClass } = input; return this.client.request("/agent/v1/control-plane/step-up/options", { method: "POST", body: opClass ? { op_class: opClass } : {}, ...authFor({ token }), context: "requesting control-plane step-up options", }); } /** * Verify a step-up assertion (`POST …/step-up/verify`) → refreshes session * passkey-freshness and records an action-bound elevation when `opClass` (and * optionally `objectKind`/`objectId`) are given. Retry the gated write after. */ async stepUpVerify(input) { gateSecret(this.client, "session.stepUpVerify", input); const { token, response, opClass, objectKind, objectId } = input; return this.client.request("/agent/v1/control-plane/step-up/verify", { method: "POST", body: { response, ...(opClass !== undefined ? { op_class: opClass } : {}), ...(objectKind !== undefined ? { object_kind: objectKind } : {}), ...(objectId !== undefined ? { object_id: objectId } : {}), }, ...authFor({ token }), context: "verifying control-plane step-up", }); } // ── recovery codes (bearer + step-up) ── /** (Re)issue recovery codes — shown ONCE (`POST …/recovery/issue`). */ async issueRecoveryCodes(opts = {}) { gateSecret(this.client, "session.issueRecoveryCodes", opts); return this.client.request("/agent/v1/control-plane/recovery/issue", { method: "POST", ...authFor(opts), context: "issuing control-plane recovery codes", }); } // ── authenticator management (bearer) ── /** List my active authenticators — no secret material (`GET …/authenticators`). */ async listAuthenticators(opts = {}) { gateSecret(this.client, "session.listAuthenticators", opts); const res = await this.client.request("/agent/v1/control-plane/authenticators", { ...authFor(opts), context: "listing control-plane authenticators" }); return res.authenticators ?? []; } /** * Revoke an authenticator (`DELETE …/authenticators/:id`). Step-up enforced; * the gateway refuses to remove the last passkey of a sole org owner * (`OWNER_NEEDS_PASSKEY`). */ async revokeAuthenticator(input) { gateSecret(this.client, "session.revokeAuthenticator", input); const { token, id } = input; return this.client.request(`/agent/v1/control-plane/authenticators/${encodeURIComponent(id)}`, { method: "DELETE", ...authFor({ token }), context: "revoking control-plane authenticator" }); } // ── source-access custody (vault-recovery-custody; bearer) ── /** * My own source-access key + wrapper set, ciphertext included * (`GET /agent/v1/source-access/wrappers`) — the states/scheme read behind * `run402 repos access`. Principal-scoped structurally: only the * caller's own wrappers ever come back. Enrollment/activation/revocation * are console ceremonies (WebAuthn); this SDK surface is read-only. */ async sourceAccessWrappers(opts = {}) { gateSecret(this.client, "session.sourceAccessWrappers", opts); return this.client.request("/agent/v1/source-access/wrappers", { ...authFor(opts), context: "reading source-access wrappers", }); } /** * Export my versioned member recovery bundle * (`GET /agent/v1/source-access/recovery-bundle`, format * `r402s-member-recovery-bundle/v1`) — key identity + every ACTIVE wrapper * ciphertext. A server-side wrapper row alone is NOT offline backup; this * bundle kept in the member's own storage (separately from the source * recovery code) is what `r402s-recover` opens with no run402 server. The * gateway stamps the export as recovery-posture evidence. */ async sourceAccessRecoveryBundle(opts = {}) { gateSecret(this.client, "session.sourceAccessRecoveryBundle", opts); return this.client.request("/agent/v1/source-access/recovery-bundle", { ...authFor(opts), context: "exporting source-access recovery bundle", }); } } /** * Build the auth half of a request: explicit `Authorization: Bearer <token>` * (and `withAuth: false`) when a token is passed, else fall through to the * credential provider (`withAuth` defaults true). */ function authFor(opts) { return opts.token ? { headers: { Authorization: `Bearer ${opts.token}` }, withAuth: false } : {}; } //# sourceMappingURL=session.js.map