Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/selfhost-connected-clients.md
Original file line number Diff line number Diff line change
@@ -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.
122 changes: 122 additions & 0 deletions apps/host-selfhost/src/access/api.ts
Original file line number Diff line number Diff line change
@@ -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>()(
"AccessError",
{ message: Schema.String },
{ httpApiStatus: 500 },
) {}

export class AccessUnauthorized extends Schema.TaggedErrorClass<AccessUnauthorized>()(
"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>()(
"AccessForbidden",
{ message: Schema.String },
{ httpApiStatus: 403 },
) {}

export class AccessNotFound extends Schema.TaggedErrorClass<AccessNotFound>()(
"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);
Loading
Loading