diff --git a/.changeset/selfhost-connected-clients.md b/.changeset/selfhost-connected-clients.md new file mode 100644 index 0000000000..53a7514564 --- /dev/null +++ b/.changeset/selfhost-connected-clients.md @@ -0,0 +1,9 @@ +--- +"executor": minor +--- + +**Self-host: a Connected clients page — everything that can act as you, and a way to cut each one off** + +The console had no view of the MCP clients that connected over OAuth, and no way to disconnect one short of editing the database. The new **Connected clients** page lists, for the signed-in user only: MCP clients (Claude Code, Cursor, Codex, …) with their last sign-in, personal API keys, and browser sessions. Each can be revoked after a confirmation. Revoking an MCP client deletes its tokens and its consent, so it is signed out on its next request — including a session it already has open, since MCP authenticates every request — and must be approved again before it can call a tool. + +The plane (`/api/access/*`) answers the signed-in browser only: a request carrying `Authorization` or `x-api-key` is refused, so an agent cannot list or revoke the credentials of the person it acts for. Mutations also require a same-origin `Origin`, a user only ever sees and revokes their own credentials (anyone else's read as not found), and no token, key hash or client secret is ever served. diff --git a/apps/host-selfhost/src/access/api.ts b/apps/host-selfhost/src/access/api.ts new file mode 100644 index 0000000000..49c58df547 --- /dev/null +++ b/apps/host-selfhost/src/access/api.ts @@ -0,0 +1,122 @@ +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +// --------------------------------------------------------------------------- +// Connected clients API — what is signed in as you, and how to cut it off +// (app-local, self-host only). +// +// Two credential kinds live here because nothing else serves them: the MCP +// clients that connected over OAuth (Claude Code, Cursor, Codex, …) and the +// browser sessions you are signed in with. Personal API keys already have the +// shared /account/api-keys surface, which the console page reuses rather than +// duplicating. +// +// Everything is scoped to the caller's OWN credentials, and every route is +// refused unless the request is the signed-in browser itself (see handlers.ts): +// an agent holding an API key or an OAuth token must not be able to list, let +// alone revoke, the credentials of the person it acts for. +// +// Browser-safe: schemas + the HttpApi value only (no server imports), so the +// web client can build a typed AtomHttpApi from it. +// --------------------------------------------------------------------------- + +export class AccessError extends Schema.TaggedErrorClass()( + "AccessError", + { message: Schema.String }, + { httpApiStatus: 500 }, +) {} + +export class AccessUnauthorized extends Schema.TaggedErrorClass()( + "AccessUnauthorized", + {}, + { httpApiStatus: 401 }, +) {} + +/** Refused: not the signed-in browser, a cross-origin request, or an attempt + * to revoke the session making the request. */ +export class AccessForbidden extends Schema.TaggedErrorClass()( + "AccessForbidden", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} + +export class AccessNotFound extends Schema.TaggedErrorClass()( + "AccessNotFound", + {}, + { httpApiStatus: 404 }, +) {} + +/** An MCP client that connected to this instance as you, over OAuth. */ +export const OAuthClientEntry = Schema.Struct({ + clientId: Schema.String, + /** The name the client registered ("Claude Code"). Client-chosen text, + * cleaned and bounded before it is served. */ + name: Schema.NullOr(Schema.String), + /** Epoch ms. When the client registered itself. */ + registeredAt: Schema.NullOr(Schema.Number), + /** Epoch ms. The latest token issued to it for you — its last sign-in or + * refresh. */ + lastAuthorizedAt: Schema.NullOr(Schema.Number), + /** Tokens that can still be used or refreshed. Zero means the client has + * to sign in again before it can call anything. */ + activeTokens: Schema.Number, +}); + +/** A browser (or other cookie) session you are signed in with. */ +export const SessionEntry = Schema.Struct({ + id: Schema.String, + /** Epoch ms. */ + createdAt: Schema.Number, + /** Epoch ms. The session's last refresh — roughly its last use. */ + lastActiveAt: Schema.Number, + /** Epoch ms. */ + expiresAt: Schema.Number, + userAgent: Schema.NullOr(Schema.String), + ipAddress: Schema.NullOr(Schema.String), + /** The session making this request — it cannot revoke itself here. */ + current: Schema.Boolean, +}); + +export const ConnectedClientsResponse = Schema.Struct({ + oauthClients: Schema.Array(OAuthClientEntry), + sessions: Schema.Array(SessionEntry), +}); + +export const RevokeResponse = Schema.Struct({ + /** How many credentials were removed. */ + revoked: Schema.Number, +}); + +const accessErrors = [AccessError, AccessUnauthorized, AccessForbidden, AccessNotFound]; + +// Paths are `/access/*` (no `/api`): the server mounts this on the same +// `/api`-prefixed router as the core API, and the client prepends the `/api` +// base — symmetric with the admin API. +export const AccessApi = HttpApiGroup.make("access") + .add( + HttpApiEndpoint.get("listConnectedClients", "/access/clients", { + success: ConnectedClientsResponse, + error: accessErrors, + }), + ) + .add( + HttpApiEndpoint.delete("revokeOAuthClient", "/access/oauth-clients/:clientId", { + params: { clientId: Schema.String.check(Schema.isMaxLength(256)) }, + success: RevokeResponse, + error: accessErrors, + }), + ) + .add( + HttpApiEndpoint.delete("revokeSession", "/access/sessions/:sessionId", { + params: { sessionId: Schema.String.check(Schema.isMaxLength(256)) }, + success: RevokeResponse, + error: accessErrors, + }), + ); + +/** + * Standalone HttpApi wrapping the access group — used to build the self-host + * `AccessApiClient` atoms in the web app, and mounted server-side as an + * extension route layer. + */ +export const AccessHttpApi = HttpApi.make("executor-self-host-access").add(AccessApi); diff --git a/apps/host-selfhost/src/access/connected-clients.test.ts b/apps/host-selfhost/src/access/connected-clients.test.ts new file mode 100644 index 0000000000..6dde1aabb5 --- /dev/null +++ b/apps/host-selfhost/src/access/connected-clients.test.ts @@ -0,0 +1,293 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// --------------------------------------------------------------------------- +// The connected-clients plane lists and revokes the credentials that act as a +// user — so its refusals matter more than its answers. Boots the real app: +// real Better Auth, real MCP OAuth tokens, real sessions. +// --------------------------------------------------------------------------- + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-access-")); +process.env.BETTER_AUTH_SECRET = "access-secret-0123456789-abcdefghij-klmnopq"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "owner@access.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "owner-pass-123456"; + +let handler!: (request: Request) => Promise; +let dispose: () => Promise = async () => {}; + +beforeAll(async () => { + const { makeSelfHostApiHandler } = await import("../app"); + const app = await makeSelfHostApiHandler(); + handler = app.handler; + dispose = app.dispose; +}); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; +const REDIRECT = "http://localhost:9999/callback"; + +interface Login { + readonly cookie: string; + readonly token: string; +} + +const firstCookie = (res: Response): string => + (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; + +const signIn = async (email: string, password: string): Promise => { + const res = await handler( + new Request(`${BASE}/api/auth/sign-in/email`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE }, + body: JSON.stringify({ email, password }), + }), + ); + expect(res.status).toBe(200); + return { cookie: firstCookie(res), token: res.headers.get("set-auth-token") ?? "" }; +}; + +const signUpMember = async (email: string): Promise => { + const inviteCode = await mintInviteCode(handler, "member"); + const res = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json", origin: BASE }, + body: JSON.stringify({ email, password: "member-pass-123456", name: email, inviteCode }), + }), + ); + expect(res.status).toBe(200); + return { cookie: firstCookie(res), token: res.headers.get("set-auth-token") ?? "" }; +}; + +const b64url = (buf: Uint8Array): string => + btoa(String.fromCharCode(...buf)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); + +/** Register an MCP client and walk the real OAuth + consent flow as `cookie`. */ +const connectOAuthClient = async ( + cookie: string, + clientName: string, +): Promise<{ + readonly clientId: string; + readonly accessToken: string; + readonly refreshToken: string; +}> => { + const reg = await handler( + new Request(`${BASE}/api/auth/mcp/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: clientName, + redirect_uris: [REDIRECT], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }), + }), + ); + expect([200, 201]).toContain(reg.status); + const clientId = String(((await reg.json()) as { client_id: string }).client_id); + + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const challenge = b64url( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))), + ); + const authorizeUrl = new URL(`${BASE}/api/auth/mcp/authorize`); + authorizeUrl.search = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + code_challenge: challenge, + code_challenge_method: "S256", + scope: "openid offline_access", + }).toString(); + const authorize = await handler( + new Request(authorizeUrl, { headers: { cookie }, redirect: "manual" }), + ); + expect(authorize.status).toBe(302); + const consentCode = + new URL(authorize.headers.get("location") ?? "", BASE).searchParams.get("consent_code") ?? ""; + const consent = await handler( + new Request(`${BASE}/api/auth/oauth2/consent`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ accept: true, consent_code: consentCode }), + }), + ); + expect(consent.status).toBe(200); + const redirectURI = String(((await consent.json()) as { redirectURI: string }).redirectURI); + const code = new URL(redirectURI).searchParams.get("code") ?? ""; + + const token = await handler( + new Request(`${BASE}/api/auth/mcp/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + client_id: clientId, + code_verifier: verifier, + }).toString(), + }), + ); + expect(token.status).toBe(200); + const tokens = (await token.json()) as { access_token: string; refresh_token?: string }; + return { clientId, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? "" }; +}; + +/** An MCP `initialize` with the token — 200 while it is valid, 401 once revoked. */ +const mcpInitialize = (accessToken: string) => + handler( + new Request(`${BASE}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "t", version: "1" }, + }, + }), + }), + ); + +const listClients = (headers: Record) => + handler(new Request(`${BASE}/api/access/clients`, { headers })); + +const revoke = (path: string, headers: Record) => + handler(new Request(`${BASE}/api/access/${path}`, { method: "DELETE", headers })); + +interface Listing { + readonly oauthClients: readonly { + readonly clientId: string; + readonly name: string | null; + readonly activeTokens: number; + }[]; + readonly sessions: readonly { readonly id: string; readonly current: boolean }[]; +} + +const OWNER = () => signIn("owner@access.test", "owner-pass-123456"); + +test("lists the signed-in user's MCP clients and sessions — never a secret", async () => { + const owner = await OWNER(); + const client = await connectOAuthClient(owner.cookie, "Claude Code"); + + const res = await listClients({ cookie: owner.cookie }); + expect(res.status).toBe(200); + const text = await res.clone().text(); + const listing = (await res.json()) as Listing; + + const listed = listing.oauthClients.find((entry) => entry.clientId === client.clientId); + expect(listed?.name).toBe("Claude Code"); + expect(listed?.activeTokens).toBeGreaterThan(0); + expect(listing.sessions.some((session) => session.current)).toBe(true); + + // No bearer material of any kind reaches the browser: not the client's + // tokens, not the session token, not the cookie value. + const secrets = [ + client.accessToken, + client.refreshToken, + owner.token, + owner.cookie.split("=")[1] ?? "", + ].filter((secret) => secret.length > 0); + expect(secrets.length).toBeGreaterThanOrEqual(3); + for (const secret of secrets) expect(text).not.toContain(secret); +}); + +test("answers only the browser session — never an agent credential", async () => { + const owner = await OWNER(); + const client = await connectOAuthClient(owner.cookie, "Cursor"); + + // No credential at all. + expect((await listClients({})).status).toBe(401); + // A bearer session (the CLI's shape) and an MCP OAuth token: both refused, + // even though each authenticates the rest of the API. + expect((await listClients({ authorization: `Bearer ${owner.token}` })).status).toBe(403); + expect((await listClients({ authorization: `Bearer ${client.accessToken}` })).status).toBe(403); + // An API key header, even alongside a valid cookie. + expect((await listClients({ cookie: owner.cookie, "x-api-key": "anything" })).status).toBe(403); +}); + +test("revoking an MCP client cuts off its token at once, open session included", async () => { + const owner = await OWNER(); + const client = await connectOAuthClient(owner.cookie, "Codex"); + expect((await mcpInitialize(client.accessToken)).status).toBe(200); + + const res = await revoke(`oauth-clients/${encodeURIComponent(client.clientId)}`, { + cookie: owner.cookie, + origin: BASE, + }); + expect(res.status).toBe(200); + expect(((await res.json()) as { revoked: number }).revoked).toBeGreaterThan(0); + + // The same token no longer opens anything. + expect((await mcpInitialize(client.accessToken)).status).toBe(401); + const listing = (await (await listClients({ cookie: owner.cookie })).json()) as Listing; + expect(listing.oauthClients.some((entry) => entry.clientId === client.clientId)).toBe(false); +}); + +test("refuses a revoke from another origin, or with none", async () => { + const owner = await OWNER(); + const client = await connectOAuthClient(owner.cookie, "Cross-origin target"); + const path = `oauth-clients/${encodeURIComponent(client.clientId)}`; + + expect( + (await revoke(path, { cookie: owner.cookie, origin: "https://evil.example" })).status, + ).toBe(403); + expect((await revoke(path, { cookie: owner.cookie })).status).toBe(403); + // Still connected. + expect((await mcpInitialize(client.accessToken)).status).toBe(200); +}); + +test("revokes another session, but not the one making the request", async () => { + const first = await OWNER(); + const second = await OWNER(); + const listing = (await (await listClients({ cookie: first.cookie })).json()) as Listing; + const current = listing.sessions.find((session) => session.current); + const other = listing.sessions.find((session) => !session.current); + expect(current).toBeDefined(); + expect(other).toBeDefined(); + + expect( + (await revoke(`sessions/${current?.id}`, { cookie: first.cookie, origin: BASE })).status, + ).toBe(403); + + // Revoke every OTHER session; the second browser is among them. + for (const session of listing.sessions.filter((s) => !s.current)) { + const res = await revoke(`sessions/${session.id}`, { cookie: first.cookie, origin: BASE }); + expect(res.status).toBe(200); + } + expect((await listClients({ cookie: second.cookie })).status).toBe(401); + expect((await listClients({ cookie: first.cookie })).status).toBe(200); +}); + +test("another user's client reads as not found — and stays connected", async () => { + const bob = await signUpMember("bob@access.test"); + const bobClient = await connectOAuthClient(bob.cookie, "Bob's agent"); + const owner = await OWNER(); + + const res = await revoke(`oauth-clients/${encodeURIComponent(bobClient.clientId)}`, { + cookie: owner.cookie, + origin: BASE, + }); + expect(res.status).toBe(404); + expect((await mcpInitialize(bobClient.accessToken)).status).toBe(200); + + const listing = (await (await listClients({ cookie: owner.cookie })).json()) as Listing; + expect(listing.oauthClients.some((entry) => entry.clientId === bobClient.clientId)).toBe(false); +}); diff --git a/apps/host-selfhost/src/access/handlers.ts b/apps/host-selfhost/src/access/handlers.ts new file mode 100644 index 0000000000..0c7d78c9cf --- /dev/null +++ b/apps/host-selfhost/src/access/handlers.ts @@ -0,0 +1,373 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; +import { Effect, Layer, Option, Predicate, Schema } from "effect"; + +import { + AccessError, + AccessForbidden, + AccessHttpApi, + AccessNotFound, + AccessUnauthorized, + type OAuthClientEntry, + type SessionEntry, +} from "./api"; +import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; + +// --------------------------------------------------------------------------- +// Handlers for the connected-clients API. Two rules carry the security: +// +// 1. ONLY THE SIGNED-IN BROWSER. A request carrying `Authorization` or +// `x-api-key` is refused before any lookup, even though Better Auth would +// happily resolve it: the credentials this plane lists and revokes are +// exactly the ones an agent holds, and an agent must not be able to see or +// cut off the person it acts for (or quietly re-authorize itself). +// 2. ONLY YOUR OWN. Every read and delete is filtered on the caller's user +// id; a client id or session id that belongs to someone else reads as +// not found, never as forbidden, so ids cannot be probed. +// +// Mutations additionally require a same-origin `Origin` — a browser always +// sends one on a DELETE — so a page elsewhere cannot drive them with the +// user's cookie. Tokens, key hashes and client secrets never leave the server. +// --------------------------------------------------------------------------- + +/** Longest client name served. A client names itself at registration. */ +const NAME_LIMIT = 80; +/** Upper bound on credential rows read per list — a user with more OAuth + * tokens than this has a problem this page is not the tool for. */ +const ROW_LIMIT = 5000; + +const requestHeaders = Effect.map( + HttpServerRequest.HttpServerRequest.asEffect(), + (request): Headers => new Headers({ ...request.headers }), +); + +interface Caller { + readonly userId: string; + readonly sessionId: string; +} + +/** Rule 1 + 2: a cookie session and nothing else, resolved to its user. */ +const requireBrowserSession = (headers: Headers) => + Effect.gen(function* () { + if (headers.has("authorization") || headers.has("x-api-key")) { + return yield* new AccessForbidden({ + message: "Connected clients can only be managed from the signed-in web console.", + }); + } + const { auth } = yield* BetterAuth; + const resolved = yield* Effect.tryPromise({ + try: () => auth.api.getSession({ headers }), + catch: () => new AccessError({ message: "Session lookup failed" }), + }); + if (!resolved) return yield* new AccessUnauthorized(); + return { userId: resolved.user.id, sessionId: resolved.session.id } satisfies Caller; + }); + +/** Mutations only from this instance's own pages. */ +const requireSameOrigin = (headers: Headers, allowedOrigins: ReadonlySet) => { + const origin = headers.get("origin"); + return origin !== null && allowedOrigins.has(origin) + ? Effect.void + : Effect.fail(new AccessForbidden({ message: "Cross-origin request refused." })); +}; + +// What the adapter hands back is untyped, so each row is decoded at this +// boundary; extra columns (tokens, secrets) are dropped by construction. +// +// Every field that may be absent is `Absent(...)`, not `NullishOr(...)`: in +// Effect v4 `NullishOr` accepts a null or undefined VALUE but still requires +// the KEY, and Better Auth omits empty columns (a session with no recorded IP +// has no `ipAddress` key at all) — which failed the whole list in production. +// +// Timestamps come back as a Date, an ISO string or epoch ms depending on the +// driver and on which Better Auth version wrote the row; `toMs` reads all of +// them, so the schema does not pretend to know which. +const Absent = (schema: S) => Schema.optional(Schema.NullOr(schema)); +const Timestamp = Schema.Unknown; +export const TokenRow = Schema.Struct({ + clientId: Schema.String, + createdAt: Absent(Timestamp), + accessTokenExpiresAt: Absent(Timestamp), + refreshTokenExpiresAt: Absent(Timestamp), +}); +export const ConsentRow = Schema.Struct({ clientId: Schema.String }); +export const ApplicationRow = Schema.Struct({ + clientId: Schema.String, + name: Absent(Schema.String), + createdAt: Absent(Timestamp), +}); +export const SessionRow = Schema.Struct({ + id: Schema.String, + token: Schema.String, + createdAt: Absent(Timestamp), + updatedAt: Absent(Timestamp), + expiresAt: Absent(Timestamp), + userAgent: Absent(Schema.String), + ipAddress: Absent(Schema.String), +}); + +/** + * A row's field TYPES, never its values — what a skipped row is logged as. + * These rows hold session tokens and OAuth tokens, so a diagnostic that + * printed a value would be a leak; one that prints `{ token: "string" }` is + * enough to see why a row did not decode. + */ +const shapeOf = (row: unknown): unknown => { + if (row === null || typeof row !== "object") return row === null ? "null" : typeof row; + if (Array.isArray(row)) return `array(${row.length})`; + return Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + value === null + ? "null" + : value instanceof Date + ? Number.isNaN(value.getTime()) + ? "invalid-date" + : "date" + : Array.isArray(value) + ? "array" + : typeof value, + ]), + ); +}; + +/** + * Decode a list row by row. One row this code cannot read must not blank the + * whole page — the credential it describes is still worth listing the rest + * around — so it is skipped and logged by shape instead of failing the read. + * Only a result that is not a list at all fails. + */ +const decodeRows = + (decodeRow: (row: unknown) => Option.Option, op: string) => + (raw: unknown): Effect.Effect => + Effect.gen(function* () { + if (!Array.isArray(raw)) { + yield* Effect.logWarning("connected clients: expected a list").pipe( + Effect.annotateLogs({ op, got: shapeOf(raw) }), + ); + return yield* new AccessError({ message: `Unreadable rows (${op})` }); + } + const rows: A[] = []; + for (const row of raw) { + const decoded = decodeRow(row); + if (Option.isSome(decoded)) { + rows.push(decoded.value); + } else { + yield* Effect.logWarning("connected clients: skipped an unreadable row").pipe( + Effect.annotateLogs({ op, shape: shapeOf(row) }), + ); + } + } + return rows; + }); + +const decodeTokens = decodeRows(Schema.decodeUnknownOption(TokenRow), "oauthAccessToken"); +const decodeConsents = decodeRows(Schema.decodeUnknownOption(ConsentRow), "oauthConsent"); +const decodeApplications = decodeRows( + Schema.decodeUnknownOption(ApplicationRow), + "oauthApplication", +); +const decodeSessions = decodeRows(Schema.decodeUnknownOption(SessionRow), "session"); + +const toMs = (value: unknown): number | null => { + const ms = + value instanceof Date + ? value.getTime() + : typeof value === "string" || typeof value === "number" + ? new Date(value).getTime() + : Number.NaN; + return Number.isNaN(ms) ? null : ms; +}; + +// oxlint-disable-next-line no-control-regex -- matching control characters is the point +const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f]/g; + +/** Collapse whitespace, drop control characters, bound the length. */ +const cleanName = (value: string | null | undefined): string | null => { + if (value == null) return null; + const cleaned = value.replace(CONTROL_CHARACTERS, " ").replace(/\s+/g, " ").trim(); + if (cleaned.length === 0) return null; + return cleaned.length > NAME_LIMIT ? `${cleaned.slice(0, NAME_LIMIT)}…` : cleaned; +}; + +type Adapter = Awaited["adapter"]; + +const adapterOf = Effect.gen(function* () { + const { auth } = yield* BetterAuth; + const { adapter } = yield* Effect.promise(() => auth.$context); + return adapter; +}); + +const read = (op: string, run: () => Promise) => + Effect.tryPromise({ try: run, catch: () => new AccessError({ message: `Failed to ${op}` }) }); + +/** The MCP OAuth clients holding a token or a consent for this user. */ +const listOAuthClients = (adapter: Adapter, userId: string, now: number) => + Effect.gen(function* () { + const byUser = [{ field: "userId", value: userId }]; + const tokens = yield* read("list OAuth tokens", () => + adapter.findMany({ model: "oauthAccessToken", where: byUser, limit: ROW_LIMIT }), + ).pipe(Effect.flatMap(decodeTokens)); + const consents = yield* read("list OAuth consents", () => + adapter.findMany({ model: "oauthConsent", where: byUser, limit: ROW_LIMIT }), + ).pipe(Effect.flatMap(decodeConsents)); + + const clientIds = [...new Set([...tokens, ...consents].map((row) => row.clientId))]; + if (clientIds.length === 0) return []; + const applications = yield* read("list OAuth clients", () => + adapter.findMany({ + model: "oauthApplication", + where: [{ field: "clientId", operator: "in", value: clientIds }], + limit: clientIds.length, + }), + ).pipe(Effect.flatMap(decodeApplications)); + const appById = new Map(applications.map((app) => [app.clientId, app])); + + const entries = clientIds.map((clientId): typeof OAuthClientEntry.Type => { + const own = tokens.filter((token) => token.clientId === clientId); + const issued = own.map((token) => toMs(token.createdAt)).filter(Predicate.isNotNull); + const usable = own.filter((token) => { + const access = toMs(token.accessTokenExpiresAt); + const refresh = toMs(token.refreshTokenExpiresAt); + return (access !== null && access > now) || (refresh !== null && refresh > now); + }); + const app = appById.get(clientId); + return { + clientId, + name: cleanName(app?.name), + registeredAt: toMs(app?.createdAt), + lastAuthorizedAt: issued.length > 0 ? Math.max(...issued) : null, + activeTokens: usable.length, + }; + }); + // Most recently used first; never-authorized registrations last. + return entries.sort((a, b) => (b.lastAuthorizedAt ?? 0) - (a.lastAuthorizedAt ?? 0)); + }); + +/** The caller's session rows, straight from the adapter — the same read path + * as the OAuth rows, so the two lists agree on what a row looks like. */ +const sessionRowsOf = (adapter: Adapter, userId: string) => + read("list sessions", () => + adapter.findMany({ + model: "session", + where: [{ field: "userId", value: userId }], + limit: ROW_LIMIT, + }), + ).pipe(Effect.flatMap(decodeSessions)); + +const listSessions = (adapter: Adapter, caller: Caller, now: number) => + Effect.gen(function* () { + const sessions = yield* sessionRowsOf(adapter, caller.userId); + return sessions + .map((session): typeof SessionEntry.Type => ({ + id: session.id, + createdAt: toMs(session.createdAt) ?? 0, + lastActiveAt: toMs(session.updatedAt) ?? 0, + expiresAt: toMs(session.expiresAt) ?? 0, + userAgent: session.userAgent ?? null, + ipAddress: session.ipAddress ?? null, + current: session.id === caller.sessionId, + })) + .filter((session) => session.expiresAt > now) + .sort((a, b) => b.lastActiveAt - a.lastActiveAt); + }); + +const makeAccessHandlers = (allowedOrigins: ReadonlySet) => + HttpApiBuilder.group(AccessHttpApi, "access", (handlers) => + handlers + .handle("listConnectedClients", () => + Effect.gen(function* () { + const headers = yield* requestHeaders; + const caller = yield* requireBrowserSession(headers); + const adapter = yield* adapterOf; + const now = Date.now(); + return { + oauthClients: yield* listOAuthClients(adapter, caller.userId, now), + sessions: yield* listSessions(adapter, caller, now), + }; + }), + ) + .handle("revokeOAuthClient", ({ params }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + yield* requireSameOrigin(headers, allowedOrigins); + const caller = yield* requireBrowserSession(headers); + const adapter = yield* adapterOf; + // The caller's rows for this client only — another user's tokens for + // the same client are theirs to revoke. + const where = [ + { field: "clientId", value: params.clientId }, + { field: "userId", value: caller.userId }, + ]; + // Tokens AND consent: without the consent row the client cannot + // silently mint a new token — it must bring the user back through + // the approval screen. + const tokens = yield* read("revoke OAuth tokens", () => + adapter.deleteMany({ model: "oauthAccessToken", where }), + ); + const consents = yield* read("revoke OAuth consent", () => + adapter.deleteMany({ model: "oauthConsent", where }), + ); + if (tokens + consents === 0) return yield* new AccessNotFound(); + return { revoked: tokens }; + }), + ) + .handle("revokeSession", ({ params }) => + Effect.gen(function* () { + const headers = yield* requestHeaders; + yield* requireSameOrigin(headers, allowedOrigins); + const caller = yield* requireBrowserSession(headers); + if (params.sessionId === caller.sessionId) { + return yield* new AccessForbidden({ + message: "This is the session you are using — sign out instead.", + }); + } + const { auth } = yield* BetterAuth; + const adapter = yield* adapterOf; + // Better Auth revokes by token and checks ownership itself; the id → + // token lookup happens here, server-side, over the caller's own rows. + const sessions = yield* sessionRowsOf(adapter, caller.userId); + const target = sessions.find((session) => session.id === params.sessionId); + if (!target) return yield* new AccessNotFound(); + yield* read("revoke session", () => + auth.api.revokeSession({ headers, body: { token: target.token } }), + ); + return { revoked: 1 }; + }), + ), + ); + +export interface SelfHostAccessApiDeps { + readonly betterAuth: BetterAuthHandle; + readonly mountPrefix: `/${string}`; + /** The instance's own origins (`webBaseUrl` + configured aliases); a + * mutation from any other origin is refused. */ + readonly trustedOrigins: readonly string[]; +} + +/** + * The mountable extension route layer: registers the access routes on the + * `mountPrefix`-prefixed view of the ambient router (so `/access/*` is served + * at `/api/access/*`), with the Better Auth handle provided per request — the + * same construction as the admin API layer. + */ +export const makeSelfHostAccessApiLayer = ({ + betterAuth, + mountPrefix, + trustedOrigins, +}: SelfHostAccessApiDeps) => { + const allowedOrigins = new Set( + trustedOrigins.flatMap((value) => { + const origin = URL.canParse(value) ? new URL(value).origin : null; + return origin === null ? [] : [origin]; + }), + ); + const prefixedRouter = Layer.effect(HttpRouter.HttpRouter)( + Effect.map(HttpRouter.HttpRouter.asEffect(), (router) => router.prefixed(mountPrefix)), + ); + return HttpApiBuilder.layer(AccessHttpApi).pipe( + Layer.provide(makeAccessHandlers(allowedOrigins)), + Layer.provide(prefixedRouter), + HttpRouter.provideRequest(Layer.succeed(BetterAuth)(betterAuth)), + ); +}; diff --git a/apps/host-selfhost/src/access/row-decoding.test.ts b/apps/host-selfhost/src/access/row-decoding.test.ts new file mode 100644 index 0000000000..960d6a0846 --- /dev/null +++ b/apps/host-selfhost/src/access/row-decoding.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Option, Schema } from "effect"; + +import { ApplicationRow, SessionRow, TokenRow } from "./handlers"; + +// Production regression: Better Auth omits empty columns, so a row can arrive +// WITHOUT a key the schema called nullable. Under Effect v4 `NullishOr` that +// failed the whole Connected clients list ("Unreadable rows (session)") on a +// live instance whose sessions had no recorded IP. Absent keys must decode. + +const decodeSession = Schema.decodeUnknownOption(SessionRow); +const decodeToken = Schema.decodeUnknownOption(TokenRow); +const decodeApplication = Schema.decodeUnknownOption(ApplicationRow); + +describe("connected-clients row decoding", () => { + it("accepts a session with no ipAddress or userAgent key", () => { + expect(Option.isSome(decodeSession({ id: "s1", token: "t1", createdAt: new Date() }))).toBe( + true, + ); + }); + + it("accepts timestamps as Date, ISO string or epoch ms", () => { + for (const at of [new Date(), "2026-09-13T02:01:29.143Z", 1789819423837]) { + const row = { id: "s", token: "t", createdAt: at, updatedAt: at, expiresAt: at }; + expect(Option.isSome(decodeSession(row))).toBe(true); + } + }); + + it("accepts an OAuth token and application with their optional fields absent", () => { + expect(Option.isSome(decodeToken({ clientId: "c1" }))).toBe(true); + expect(Option.isSome(decodeApplication({ clientId: "c1" }))).toBe(true); + }); + + it("still refuses a row without its identity", () => { + expect(Option.isNone(decodeSession({ token: "t" }))).toBe(true); + expect(Option.isNone(decodeToken({ createdAt: new Date() }))).toBe(true); + }); +}); diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 18bcdf9fc2..28ea6386f0 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -13,6 +13,7 @@ import { runSqliteDataMigrations } from "@executor-js/sdk"; import { resolveAuthProviders } from "./auth"; import { selfHostDataMigrations } from "./db/data-migrations"; +import { makeSelfHostAccessApiLayer } from "./access/handlers"; import { makeSelfHostAdminApiLayer } from "./admin/handlers"; import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api"; import { makeSelfHostSystemApiLayer } from "./system/handlers"; @@ -128,6 +129,13 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)), // App-local admin (invite-code) API, served under /api/admin/*. makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + // Connected clients (/api/access/*): the signed-in user's own MCP OAuth + // clients and browser sessions, with revoke. Browser-session only. + makeSelfHostAccessApiLayer({ + betterAuth, + mountPrefix: "/api", + trustedOrigins: [config.webBaseUrl, ...config.trustedOrigins], + }), // Tenant-wide admin users API (/api/admin/users*): the owner's view of // who uses this instance and what they've connected. Owner/admin-gated, // same as the invite routes above. diff --git a/apps/host-selfhost/web/access-atoms.tsx b/apps/host-selfhost/web/access-atoms.tsx new file mode 100644 index 0000000000..e402572e91 --- /dev/null +++ b/apps/host-selfhost/web/access-atoms.tsx @@ -0,0 +1,19 @@ +import { AccessApiClient } from "./access-client"; + +// --------------------------------------------------------------------------- +// Self-host connected-clients atoms — the signed-in user's MCP OAuth clients +// and browser sessions. API keys reuse the shared account atoms. +// --------------------------------------------------------------------------- + +// Local reactivity key: this list only matters within this client. +const CONNECTED_CLIENTS_KEY = "self-host:connected-clients"; + +export const connectedClientsAtom = AccessApiClient.query("access", "listConnectedClients", { + reactivityKeys: [CONNECTED_CLIENTS_KEY], +}); + +export const revokeOAuthClient = AccessApiClient.mutation("access", "revokeOAuthClient"); +export const revokeSession = AccessApiClient.mutation("access", "revokeSession"); + +/** Mutations that change the list pass these at the call site. */ +export const connectedClientsWriteKeys = [CONNECTED_CLIENTS_KEY] as const; diff --git a/apps/host-selfhost/web/access-client.tsx b/apps/host-selfhost/web/access-client.tsx new file mode 100644 index 0000000000..3364f9306b --- /dev/null +++ b/apps/host-selfhost/web/access-client.tsx @@ -0,0 +1,31 @@ +import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import * as Effect from "effect/Effect"; + +import { reportApiClientInfrastructureCause } from "@executor-js/react/api/client"; +import { getExecutorApiBaseUrl } from "@executor-js/react/api/server-connection"; + +import { AccessHttpApi } from "../src/access/api"; + +// --------------------------------------------------------------------------- +// Self-host connected-clients atom client (/api/access/*). +// +// Same construction as the admin client, with one deliberate difference: it +// never attaches an `Authorization` header. The server refuses any request +// carrying one (the plane answers the signed-in browser only), so this client +// rides on the same-origin session cookie alone. +// --------------------------------------------------------------------------- + +const AccessApiClient = AtomHttpApi.Service<"SelfHostAccessApiClient">()( + "SelfHostAccessApiClient", + { + api: AccessHttpApi, + httpClient: FetchHttpClient.layer, + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl()), + ), + transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause), + }, +); + +export { AccessApiClient }; diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 4d09b63152..b05d6d62fc 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport } from './../../../packages/react/src/routes/policies' +import { Route as ConnectedClientsRouteImport } from './routes/app/connected-clients' import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport } from './../../../packages/react/src/routes/artifacts' import { Route as ApiKeysRouteImport } from './routes/app/api-keys' import { Route as AdminRouteImport } from './routes/app/admin' @@ -64,6 +65,11 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute = path: '/{-$orgSlug}/policies', getParentRoute: () => rootRouteImport, } as any) +const ConnectedClientsRoute = ConnectedClientsRouteImport.update({ + id: '/{-$orgSlug}/connected-clients', + path: '/{-$orgSlug}/connected-clients', + getParentRoute: () => rootRouteImport, +} as any) const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute = DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteImport.update({ id: '/{-$orgSlug}/artifacts', @@ -157,6 +163,7 @@ export interface FileRoutesByFullPath { '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/connected-clients': typeof ConnectedClientsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -177,6 +184,7 @@ export interface FileRoutesByTo { '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/connected-clients': typeof ConnectedClientsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -198,6 +206,7 @@ export interface FileRoutesById { '/{-$orgSlug}/admin': typeof AdminRoute '/{-$orgSlug}/api-keys': typeof ApiKeysRoute '/{-$orgSlug}/artifacts': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + '/{-$orgSlug}/connected-clients': typeof ConnectedClientsRoute '/{-$orgSlug}/policies': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -220,6 +229,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/connected-clients' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -240,6 +250,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/connected-clients' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -260,6 +271,7 @@ export interface FileRouteTypes { | '/{-$orgSlug}/admin' | '/{-$orgSlug}/api-keys' | '/{-$orgSlug}/artifacts' + | '/{-$orgSlug}/connected-clients' | '/{-$orgSlug}/policies' | '/{-$orgSlug}/secrets' | '/{-$orgSlug}/toolkits' @@ -281,6 +293,7 @@ export interface RootRouteChildren { AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren + ConnectedClientsRoute: typeof ConnectedClientsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren @@ -339,6 +352,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRouteImport parentRoute: typeof rootRouteImport } + '/{-$orgSlug}/connected-clients': { + id: '/{-$orgSlug}/connected-clients' + path: '/{-$orgSlug}/connected-clients' + fullPath: '/{-$orgSlug}/connected-clients' + preLoaderRoute: typeof ConnectedClientsRouteImport + parentRoute: typeof rootRouteImport + } '/{-$orgSlug}/artifacts': { id: '/{-$orgSlug}/artifacts' path: '/{-$orgSlug}/artifacts' @@ -462,6 +482,7 @@ const rootRouteChildren: RootRouteChildren = { ApiKeysRoute: ApiKeysRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesArtifactsRouteWithChildren, + ConnectedClientsRoute: ConnectedClientsRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute: DotDotDotDotDotDotDotDotPackagesReactSrcRoutesPoliciesRoute, DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index 75b1a55343..72d6b689d9 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -46,10 +46,15 @@ export const Route = createRootRoute({ component: RootComponent, }); -// Self-host adds account API keys for every member. The instance administration +// Self-host adds account API keys and connected clients for every member (both +// show only the member's own credentials). The instance administration // surfaces are appended separately after the active member is confirmed as an // owner/admin, so plain members are not offered links that only refuse them. -const selfHostNavItems = [...defaultShellNavItems, { to: "/api-keys", label: "API keys" }]; +const selfHostNavItems = [ + ...defaultShellNavItems, + { to: "/api-keys", label: "API keys" }, + { to: "/connected-clients", label: "Connected clients" }, +]; const selfHostAdminNavItems = [ { to: "/admin", label: "Admin" }, diff --git a/apps/host-selfhost/web/routes/app/connected-clients.tsx b/apps/host-selfhost/web/routes/app/connected-clients.tsx new file mode 100644 index 0000000000..662d3ddddb --- /dev/null +++ b/apps/host-selfhost/web/routes/app/connected-clients.tsx @@ -0,0 +1,433 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState, type ReactNode } from "react"; +import { Exit } from "effect"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { toast } from "@executor-js/react/components/sonner"; + +import { apiKeysAtom, revokeApiKey } from "@executor-js/react/api/account-atoms"; +import { apiKeyWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { Badge } from "@executor-js/react/components/badge"; +import { Button } from "@executor-js/react/components/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { ErrorState } from "@executor-js/react/components/error-state"; +import { PageContainer, PageHeader } from "@executor-js/react/components/page"; +import { useExecutorDocumentTitle } from "@executor-js/react/lib/document-title"; + +import { + connectedClientsAtom, + connectedClientsWriteKeys, + revokeOAuthClient, + revokeSession, +} from "../../access-atoms"; + +export const Route = createFileRoute("/{-$orgSlug}/connected-clients")({ + component: ConnectedClientsPage, +}); + +// --------------------------------------------------------------------------- +// Connected clients — everything that can act as you on this instance, in one +// place, each with a way to cut it off: +// +// - MCP clients that connected over OAuth (Claude Code, Cursor, Codex, …) +// - personal API keys (the shared /account surface; created on API keys) +// - browser sessions +// +// Every revoke asks first: each one signs something out immediately, and an +// MCP client or a script only finds out on its next call. +// --------------------------------------------------------------------------- + +const formatWhen = (epochMs: number | null): string => { + if (epochMs === null || epochMs <= 0) return "Never"; + const seconds = Math.round((Date.now() - epochMs) / 1000); + if (seconds < 0) { + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(epochMs); + } + if (seconds < 60) return "Just now"; + if (seconds < 3600) return `${Math.round(seconds / 60)} min ago`; + if (seconds < 86_400) return `${Math.round(seconds / 3600)} h ago`; + if (seconds < 30 * 86_400) return `${Math.round(seconds / 86_400)} days ago`; + return new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }).format(epochMs); +}; + +const fromIso = (value: string | null): number | null => { + if (value === null) return null; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms; +}; + +/** Enough of a user agent to recognise the device — not a fingerprint. */ +const describeUserAgent = (userAgent: string | null): string => { + if (!userAgent) return "Unknown device"; + const browser = /Edg\//.test(userAgent) + ? "Edge" + : /Firefox\//.test(userAgent) + ? "Firefox" + : /Chrome\//.test(userAgent) + ? "Chrome" + : /Safari\//.test(userAgent) + ? "Safari" + : "Browser"; + const os = /iPhone|iPad/.test(userAgent) + ? "iOS" + : /Android/.test(userAgent) + ? "Android" + : /Mac OS X/.test(userAgent) + ? "macOS" + : /Windows/.test(userAgent) + ? "Windows" + : /Linux/.test(userAgent) + ? "Linux" + : null; + return os ? `${browser} on ${os}` : browser; +}; + +interface PendingRevoke { + readonly title: string; + readonly description: string; + readonly action: string; + readonly run: () => Promise; +} + +function ConnectedClientsPage() { + useExecutorDocumentTitle("Connected clients"); + const [pending, setPending] = useState(null); + + return ( + + +
+ + + +
+ setPending(null)} /> +
+ ); +} + +function Section(props: { + readonly title: string; + readonly description: ReactNode; + readonly children: ReactNode; +}) { + return ( +
+

{props.title}

+

{props.description}

+ {props.children} +
+ ); +} + +function Notice(props: { readonly children: ReactNode }) { + return ( +
+ {props.children} +
+ ); +} + +const ROW = + "grid grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 last:border-b-0 md:grid-cols-[1.4fr_1fr_1fr_auto]"; +const HEAD = + "grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wider text-muted-foreground md:grid-cols-[1.4fr_1fr_1fr_auto]"; + +function Rows(props: { readonly columns: readonly string[]; readonly children: ReactNode }) { + return ( +
+
+ {props.columns[0]} + {props.columns[1]} + {props.columns[2]} + Actions +
+ {props.children} +
+ ); +} + +function RevokeButton(props: { readonly label: string; readonly onClick: () => void }) { + return ( + + ); +} + +// ── MCP clients ───────────────────────────────────────────────────────────── + +function OAuthClientsSection(props: { readonly onRevoke: (pending: PendingRevoke) => void }) { + const result = useAtomValue(connectedClientsAtom); + const refresh = useAtomRefresh(connectedClientsAtom); + const doRevoke = useAtomSet(revokeOAuthClient, { mode: "promiseExit" }); + + return ( +
+ {AsyncResult.match(result, { + onInitial: () => Loading clients…, + onFailure: () => , + onSuccess: ({ value }) => + value.oauthClients.length === 0 ? ( + No MCP client has connected with your account yet. + ) : ( + + {value.oauthClients.map((client) => { + const name = client.name ?? "Unnamed client"; + return ( +
+
+

{name}

+

+ {client.clientId} +

+
+

+ {formatWhen(client.lastAuthorizedAt)} +

+
+ {client.activeTokens > 0 ? ( + Connected + ) : ( + Signed out + )} +
+ + props.onRevoke({ + title: "Revoke MCP client", + description: `Revoke ${name}? It is signed out immediately, including any session it has open, and must be approved again before it can call a tool.`, + action: "Revoke client", + run: async () => { + const exit = await doRevoke({ + params: { clientId: client.clientId }, + reactivityKeys: connectedClientsWriteKeys, + }); + return Exit.isSuccess(exit); + }, + }) + } + /> +
+ ); + })} +
+ ), + })} +
+ ); +} + +// ── API keys ──────────────────────────────────────────────────────────────── + +function ApiKeysSection(props: { readonly onRevoke: (pending: PendingRevoke) => void }) { + const result = useAtomValue(apiKeysAtom); + const refresh = useAtomRefresh(apiKeysAtom); + const doRevoke = useAtomSet(revokeApiKey, { mode: "promiseExit" }); + + return ( +
+ Personal keys scripts and agents use as a bearer token. Create new ones on the{" "} + + API keys + {" "} + page. + + } + > + {AsyncResult.match(result, { + onInitial: () => Loading API keys…, + onFailure: () => , + onSuccess: ({ value }) => + value.apiKeys.length === 0 ? ( + You have no API keys. + ) : ( + + {value.apiKeys.map((key) => ( +
+
+

{key.name}

+

+ {key.obfuscatedValue} +

+
+

+ {formatWhen(fromIso(key.createdAt))} +

+

+ {formatWhen(fromIso(key.lastUsedAt))} +

+ + props.onRevoke({ + title: "Revoke API key", + description: `Revoke ${key.name}? Any script or agent authenticating with it loses access immediately. This cannot be undone.`, + action: "Revoke key", + run: async () => { + const exit = await doRevoke({ + params: { apiKeyId: key.id }, + reactivityKeys: apiKeyWriteKeys, + }); + return Exit.isSuccess(exit); + }, + }) + } + /> +
+ ))} +
+ ), + })} +
+ ); +} + +// ── Browser sessions ──────────────────────────────────────────────────────── + +function SessionsSection(props: { readonly onRevoke: (pending: PendingRevoke) => void }) { + const result = useAtomValue(connectedClientsAtom); + const refresh = useAtomRefresh(connectedClientsAtom); + const doRevoke = useAtomSet(revokeSession, { mode: "promiseExit" }); + + return ( +
+ {AsyncResult.match(result, { + onInitial: () => Loading sessions…, + onFailure: () => , + onSuccess: ({ value }) => ( + + {value.sessions.map((session) => { + const device = describeUserAgent(session.userAgent); + return ( +
+
+

+ {device} + {session.current ? This browser : null} +

+ {session.ipAddress ? ( +

+ {session.ipAddress} +

+ ) : null} +
+

+ {formatWhen(session.lastActiveAt)} +

+

+ {new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(session.expiresAt)} +

+ {session.current ? ( + + ) : ( + + props.onRevoke({ + title: "Sign out session", + description: `Sign out ${device}? That browser has to sign in again.`, + action: "Sign out", + run: async () => { + const exit = await doRevoke({ + params: { sessionId: session.id }, + reactivityKeys: connectedClientsWriteKeys, + }); + return Exit.isSuccess(exit); + }, + }) + } + /> + )} +
+ ); + })} +
+ ), + })} +
+ ); +} + +// ── Confirmation ──────────────────────────────────────────────────────────── + +function ConfirmRevokeDialog(props: { + readonly pending: PendingRevoke | null; + readonly onClose: () => void; +}) { + const [busy, setBusy] = useState(false); + const { pending } = props; + return ( + { + if (!open && !busy) props.onClose(); + }} + > + + + {pending?.title ?? ""} + + {pending?.description ?? ""} + + + + + + + + + + + ); +}