From 090126ee52d6949907bd46b88a6266b8ba99e2fc Mon Sep 17 00:00:00 2001 From: Michiel de Gooijer <61051030+midego1@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:46:37 +0700 Subject: [PATCH 1/2] Add a Connected clients page to self-host Nothing in the console showed which MCP clients had connected over OAuth, and disconnecting one meant editing the database. The page lists the signed-in user's MCP clients, API keys (the existing /account surface) and browser sessions, each revocable after a confirmation. Revoking an MCP client deletes its access tokens and its consent: it is refused on its next request, open session included (MCP authenticates every request), and must be approved again. The /api/access plane answers the signed-in browser only, refusing any Authorization or x-api-key header, so an agent cannot list or revoke its own user's credentials; mutations also require a same-origin Origin. Everything is scoped to the caller's own rows, another user's ids read as not found, and no token or secret is served. Co-Authored-By: Claude Opus 5 --- .changeset/selfhost-connected-clients.md | 9 + apps/host-selfhost/src/access/api.ts | 122 +++++ .../src/access/connected-clients.test.ts | 293 ++++++++++++ apps/host-selfhost/src/access/handlers.ts | 297 ++++++++++++ apps/host-selfhost/src/app.ts | 8 + apps/host-selfhost/web/access-atoms.tsx | 19 + apps/host-selfhost/web/access-client.tsx | 31 ++ apps/host-selfhost/web/routeTree.gen.ts | 21 + apps/host-selfhost/web/routes/__root.tsx | 9 +- .../web/routes/app/connected-clients.tsx | 433 ++++++++++++++++++ 10 files changed, 1240 insertions(+), 2 deletions(-) create mode 100644 .changeset/selfhost-connected-clients.md create mode 100644 apps/host-selfhost/src/access/api.ts create mode 100644 apps/host-selfhost/src/access/connected-clients.test.ts create mode 100644 apps/host-selfhost/src/access/handlers.ts create mode 100644 apps/host-selfhost/web/access-atoms.tsx create mode 100644 apps/host-selfhost/web/access-client.tsx create mode 100644 apps/host-selfhost/web/routes/app/connected-clients.tsx 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..88f02375de --- /dev/null +++ b/apps/host-selfhost/src/access/handlers.ts @@ -0,0 +1,297 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; +import { Effect, Layer, 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. +const Timestamp = Schema.Union([Schema.Date, Schema.String, Schema.Number]); +const TokenRow = Schema.Struct({ + clientId: Schema.String, + createdAt: Schema.NullishOr(Timestamp), + accessTokenExpiresAt: Schema.NullishOr(Timestamp), + refreshTokenExpiresAt: Schema.NullishOr(Timestamp), +}); +const ConsentRow = Schema.Struct({ clientId: Schema.String }); +const ApplicationRow = Schema.Struct({ + clientId: Schema.String, + name: Schema.NullishOr(Schema.String), + createdAt: Schema.NullishOr(Timestamp), +}); +const SessionRow = Schema.Struct({ + id: Schema.String, + token: Schema.String, + createdAt: Timestamp, + updatedAt: Timestamp, + expiresAt: Timestamp, + userAgent: Schema.NullishOr(Schema.String), + ipAddress: Schema.NullishOr(Schema.String), +}); + +const decodeTokens = Schema.decodeUnknownEffect(Schema.Array(TokenRow)); +const decodeConsents = Schema.decodeUnknownEffect(Schema.Array(ConsentRow)); +const decodeApplications = Schema.decodeUnknownEffect(Schema.Array(ApplicationRow)); +const decodeSessions = Schema.decodeUnknownEffect(Schema.Array(SessionRow)); + +const toMs = (value: Date | string | number | null | undefined): number | null => { + if (value == null) return null; + const ms = value instanceof Date ? value.getTime() : new Date(value).getTime(); + 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}` }) }); + +const undecodable = (op: string) => () => new AccessError({ message: `Unreadable rows (${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), Effect.mapError(undecodable("oauthAccessToken"))); + const consents = yield* read("list OAuth consents", () => + adapter.findMany({ model: "oauthConsent", where: byUser, limit: ROW_LIMIT }), + ).pipe(Effect.flatMap(decodeConsents), Effect.mapError(undecodable("oauthConsent"))); + + 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), Effect.mapError(undecodable("oauthApplication"))); + 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)); + }); + +const listSessions = (headers: Headers, caller: Caller, now: number) => + Effect.gen(function* () { + const { auth } = yield* BetterAuth; + const sessions = yield* read("list sessions", () => auth.api.listSessions({ headers })).pipe( + Effect.flatMap(decodeSessions), + Effect.mapError(undecodable("session")), + ); + 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(headers, 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; + // Better Auth revokes by token and checks ownership itself; the id → + // token lookup happens here, server-side, over the caller's own list. + const sessions = yield* read("list sessions", () => + auth.api.listSessions({ headers }), + ).pipe(Effect.flatMap(decodeSessions), Effect.mapError(undecodable("session"))); + 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/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 ?? ""} + + + + + + + + + + + ); +} From bf977fd37978c2a46c0b6591f341990ada898b17 Mon Sep 17 00:00:00 2001 From: Michiel de Gooijer <61051030+midego1@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:10:57 +0700 Subject: [PATCH 2/2] Read connected-clients rows through the adapter, tolerating absent keys In production the Connected clients page failed with "Unreadable rows (session)": Better Auth omits empty columns, so a session with no recorded IP arrives without an ipAddress key, and Effect v4's NullishOr accepts a null/undefined value but still requires the key. Optional fields are now optional(NullOr(...)), timestamps accept Date, ISO string or epoch ms, and sessions are read through the adapter like the OAuth rows. A row that still fails to decode is skipped and logged by field TYPES only (never values: these rows hold tokens) instead of blanking the whole list. Co-Authored-By: Claude Opus 5 --- apps/host-selfhost/src/access/handlers.ts | 154 +++++++++++++----- .../src/access/row-decoding.test.ts | 38 +++++ 2 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 apps/host-selfhost/src/access/row-decoding.test.ts diff --git a/apps/host-selfhost/src/access/handlers.ts b/apps/host-selfhost/src/access/handlers.ts index 88f02375de..0c7d78c9cf 100644 --- a/apps/host-selfhost/src/access/handlers.ts +++ b/apps/host-selfhost/src/access/handlers.ts @@ -1,6 +1,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; -import { Effect, Layer, Predicate, Schema } from "effect"; +import { Effect, Layer, Option, Predicate, Schema } from "effect"; import { AccessError, @@ -73,37 +73,109 @@ const requireSameOrigin = (headers: Headers, allowedOrigins: ReadonlySet // What the adapter hands back is untyped, so each row is decoded at this // boundary; extra columns (tokens, secrets) are dropped by construction. -const Timestamp = Schema.Union([Schema.Date, Schema.String, Schema.Number]); -const TokenRow = Schema.Struct({ +// +// 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: Schema.NullishOr(Timestamp), - accessTokenExpiresAt: Schema.NullishOr(Timestamp), - refreshTokenExpiresAt: Schema.NullishOr(Timestamp), + createdAt: Absent(Timestamp), + accessTokenExpiresAt: Absent(Timestamp), + refreshTokenExpiresAt: Absent(Timestamp), }); -const ConsentRow = Schema.Struct({ clientId: Schema.String }); -const ApplicationRow = Schema.Struct({ +export const ConsentRow = Schema.Struct({ clientId: Schema.String }); +export const ApplicationRow = Schema.Struct({ clientId: Schema.String, - name: Schema.NullishOr(Schema.String), - createdAt: Schema.NullishOr(Timestamp), + name: Absent(Schema.String), + createdAt: Absent(Timestamp), }); -const SessionRow = Schema.Struct({ +export const SessionRow = Schema.Struct({ id: Schema.String, token: Schema.String, - createdAt: Timestamp, - updatedAt: Timestamp, - expiresAt: Timestamp, - userAgent: Schema.NullishOr(Schema.String), - ipAddress: Schema.NullishOr(Schema.String), + createdAt: Absent(Timestamp), + updatedAt: Absent(Timestamp), + expiresAt: Absent(Timestamp), + userAgent: Absent(Schema.String), + ipAddress: Absent(Schema.String), }); -const decodeTokens = Schema.decodeUnknownEffect(Schema.Array(TokenRow)); -const decodeConsents = Schema.decodeUnknownEffect(Schema.Array(ConsentRow)); -const decodeApplications = Schema.decodeUnknownEffect(Schema.Array(ApplicationRow)); -const decodeSessions = Schema.decodeUnknownEffect(Schema.Array(SessionRow)); +/** + * 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, + ]), + ); +}; -const toMs = (value: Date | string | number | null | undefined): number | null => { - if (value == null) return null; - const ms = value instanceof Date ? value.getTime() : new Date(value).getTime(); +/** + * 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; }; @@ -129,18 +201,16 @@ const adapterOf = Effect.gen(function* () { const read = (op: string, run: () => Promise) => Effect.tryPromise({ try: run, catch: () => new AccessError({ message: `Failed to ${op}` }) }); -const undecodable = (op: string) => () => new AccessError({ message: `Unreadable rows (${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), Effect.mapError(undecodable("oauthAccessToken"))); + ).pipe(Effect.flatMap(decodeTokens)); const consents = yield* read("list OAuth consents", () => adapter.findMany({ model: "oauthConsent", where: byUser, limit: ROW_LIMIT }), - ).pipe(Effect.flatMap(decodeConsents), Effect.mapError(undecodable("oauthConsent"))); + ).pipe(Effect.flatMap(decodeConsents)); const clientIds = [...new Set([...tokens, ...consents].map((row) => row.clientId))]; if (clientIds.length === 0) return []; @@ -150,7 +220,7 @@ const listOAuthClients = (adapter: Adapter, userId: string, now: number) => where: [{ field: "clientId", operator: "in", value: clientIds }], limit: clientIds.length, }), - ).pipe(Effect.flatMap(decodeApplications), Effect.mapError(undecodable("oauthApplication"))); + ).pipe(Effect.flatMap(decodeApplications)); const appById = new Map(applications.map((app) => [app.clientId, app])); const entries = clientIds.map((clientId): typeof OAuthClientEntry.Type => { @@ -174,13 +244,20 @@ const listOAuthClients = (adapter: Adapter, userId: string, now: number) => return entries.sort((a, b) => (b.lastAuthorizedAt ?? 0) - (a.lastAuthorizedAt ?? 0)); }); -const listSessions = (headers: Headers, caller: Caller, now: number) => +/** 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 { auth } = yield* BetterAuth; - const sessions = yield* read("list sessions", () => auth.api.listSessions({ headers })).pipe( - Effect.flatMap(decodeSessions), - Effect.mapError(undecodable("session")), - ); + const sessions = yield* sessionRowsOf(adapter, caller.userId); return sessions .map((session): typeof SessionEntry.Type => ({ id: session.id, @@ -206,7 +283,7 @@ const makeAccessHandlers = (allowedOrigins: ReadonlySet) => const now = Date.now(); return { oauthClients: yield* listOAuthClients(adapter, caller.userId, now), - sessions: yield* listSessions(headers, caller, now), + sessions: yield* listSessions(adapter, caller, now), }; }), ) @@ -246,11 +323,10 @@ const makeAccessHandlers = (allowedOrigins: ReadonlySet) => }); } 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 list. - const sessions = yield* read("list sessions", () => - auth.api.listSessions({ headers }), - ).pipe(Effect.flatMap(decodeSessions), Effect.mapError(undecodable("session"))); + // 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", () => 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); + }); +});