diff --git a/.gitignore b/.gitignore index 34830075..66186d49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.pnpm-store/ .DS_Store dist dist-ssr diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 00000000..a92cb877 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,54 @@ +# ThinkEx MCP server (v1) + +Remote, read-only Model Context Protocol endpoint over ThinkEx workspaces. + +## Endpoint + +- **URL:** `https:///mcp` (locally `http://localhost:3000/mcp`) +- **Transport:** Streamable HTTP (stateless) +- **Auth:** OAuth 2.1 bearer token with `workspace:read` scope + +Clients discover auth via `GET /.well-known/oauth-protected-resource/mcp` (RFC 9728); unauthenticated `/mcp` requests return `401` with a `WWW-Authenticate` header pointing there. Tokens must use the RFC 8707 `resource` parameter `https:///mcp`; only JWTs with that audience are accepted (opaque tokens are rejected). + +## Adding to a client + +Dynamic client registration and consent are enabled, so clients only need the URL. + +- **Cursor** (`~/.cursor/mcp.json`) / **Windsurf** (`mcp_config.json`): `{ "mcpServers": { "thinkex": { "url": "https:///mcp" } } }` (Windsurf uses `serverUrl`). +- **VS Code** (`.vscode/mcp.json`): `{ "servers": { "thinkex": { "type": "http", "url": "https:///mcp" } } }`. +- **Claude Desktop:** add as a custom connector under Settings → Connectors. +- **stdio-only clients:** bridge with `npx -y mcp-remote https:///mcp`. + +## Tools + +Read-only, called in sequence: + +1. `thinkex_list_workspaces` — workspaces for the user (id, name, description, role). Get a workspace `id`. +2. `thinkex_workspace_list_items` — items under a `path` (`recursive`, `limit`; paginated). Start at `/`. +3. `thinkex_workspace_read_items` — read specific `paths` with an optional `pages` range (PDF pages for files, 1000-line pages for documents). + +Domain failures (path not found, folder read, unsupported type, out-of-range pages, workspace access denied) return as structured `failed` entries, not protocol errors. + +## Local development + +1. Copy `.dev.vars.example` to `.dev.vars` (or `pnpm dev` with Infisical). +2. `pnpm db:migrate:local` (first run only). +3. `CLOUDFLARE_VITE_FORCE_LOCAL=true pnpm serve:dev` +4. `npx @modelcontextprotocol/inspector`, point it at `http://localhost:3000/mcp` (Streamable HTTP). Sign in (guest or Google) and complete consent when prompted. + +Required env vars (in `.dev.vars`, see `docs/ENVIRONMENT.md`): + +- `BETTER_AUTH_SECRET` — random 32+ char string; signs sessions and tokens. +- `BETTER_AUTH_URL` — app origin (e.g. `http://localhost:3000`); the OAuth issuer, JWKS host, and `/mcp` token audience. + +Google creds are optional; guest sign-in is enough to authorize a client. + +## v1 limitations + +- **Read-only** — three read tools only; no write tools. +- **No images** — text/Markdown projections only; unsupported types return `status: "unsupported"`. +- **Async PDFs** — `status: "pending"` means extraction isn't ready yet (retry later); `status: "failed"` means extraction failed. +- **No resources or subscriptions** — tools only; no `thinkex://` URIs or change notifications. +- **Read cap** — 100k chars per read response (`truncated: true` if exceeded). +- **No rate limiting** on tool calls yet; scope and membership are enforced on every call at the token and operation layers. +- **Audit logging** is structured (`recordMcpToolCall`) but not wired to telemetry yet; actor fields (`userId`, `clientId`, `scopes`) are recorded at each tool invocation for a future issue. diff --git a/drizzle/0002_mcp_oauth.sql b/drizzle/0002_mcp_oauth.sql new file mode 100644 index 00000000..975a0c7f --- /dev/null +++ b/drizzle/0002_mcp_oauth.sql @@ -0,0 +1,101 @@ +CREATE TABLE `jwks` ( + `id` text PRIMARY KEY NOT NULL, + `public_key` text NOT NULL, + `private_key` text NOT NULL, + `created_at` integer NOT NULL, + `expires_at` integer +); +--> statement-breakpoint +CREATE TABLE `oauth_client` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `client_secret` text, + `disabled` integer, + `skip_consent` integer, + `enable_end_session` integer, + `subject_type` text, + `scopes` text, + `user_id` text, + `created_at` integer, + `updated_at` integer, + `name` text, + `uri` text, + `icon` text, + `contacts` text, + `tos` text, + `policy` text, + `software_id` text, + `software_version` text, + `software_statement` text, + `redirect_uris` text NOT NULL, + `post_logout_redirect_uris` text, + `token_endpoint_auth_method` text, + `grant_types` text, + `response_types` text, + `public` integer, + `type` text, + `require_pkce` integer, + `reference_id` text, + `metadata` text, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauthClient_userId_idx` ON `oauth_client` (`user_id`);--> statement-breakpoint +CREATE TABLE `oauth_refresh_token` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `client_id` text NOT NULL, + `session_id` text, + `user_id` text NOT NULL, + `reference_id` text, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `revoked` integer, + `auth_time` integer, + `scopes` text NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_refresh_token_token_unique` ON `oauth_refresh_token` (`token`);--> statement-breakpoint +CREATE INDEX `oauthRefreshToken_clientId_idx` ON `oauth_refresh_token` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauthRefreshToken_sessionId_idx` ON `oauth_refresh_token` (`session_id`);--> statement-breakpoint +CREATE INDEX `oauthRefreshToken_userId_idx` ON `oauth_refresh_token` (`user_id`);--> statement-breakpoint +CREATE TABLE `oauth_access_token` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `client_id` text NOT NULL, + `session_id` text, + `user_id` text, + `reference_id` text, + `refresh_id` text, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `scopes` text NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`refresh_id`) REFERENCES `oauth_refresh_token`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_access_token_token_unique` ON `oauth_access_token` (`token`);--> statement-breakpoint +CREATE INDEX `oauthAccessToken_clientId_idx` ON `oauth_access_token` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauthAccessToken_sessionId_idx` ON `oauth_access_token` (`session_id`);--> statement-breakpoint +CREATE INDEX `oauthAccessToken_userId_idx` ON `oauth_access_token` (`user_id`);--> statement-breakpoint +CREATE INDEX `oauthAccessToken_refreshId_idx` ON `oauth_access_token` (`refresh_id`);--> statement-breakpoint +CREATE TABLE `oauth_consent` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `user_id` text, + `reference_id` text, + `scopes` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `oauthConsent_clientId_idx` ON `oauth_consent` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauthConsent_userId_idx` ON `oauth_consent` (`user_id`); diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..884cf009 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1478 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cc56ddf6-89d8-4cc6-b3a9-f866788aa25d", + "prevId": "7ae74374-70d8-4c2b-893d-225bfdfcb406", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jwks": { + "name": "jwks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_token": { + "name": "oauth_access_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "oauthAccessToken_clientId_idx": { + "name": "oauthAccessToken_clientId_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauthAccessToken_sessionId_idx": { + "name": "oauthAccessToken_sessionId_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "oauthAccessToken_userId_idx": { + "name": "oauthAccessToken_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauthAccessToken_refreshId_idx": { + "name": "oauthAccessToken_refreshId_idx", + "columns": [ + "refresh_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contacts": { + "name": "contacts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_types": { + "name": "grant_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_types": { + "name": "response_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "oauthClient_userId_idx": { + "name": "oauthClient_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauthConsent_clientId_idx": { + "name": "oauthConsent_clientId_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauthConsent_userId_idx": { + "name": "oauthConsent_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token": { + "name": "oauth_refresh_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_time": { + "name": "auth_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "oauthRefreshToken_clientId_idx": { + "name": "oauthRefreshToken_clientId_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauthRefreshToken_sessionId_idx": { + "name": "oauthRefreshToken_sessionId_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_invites": { + "name": "workspace_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "workspace_invites_pending_link_per_role": { + "name": "workspace_invites_pending_link_per_role", + "columns": [ + "workspace_id", + "role" + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'link' and \"workspace_invites\".\"status\" = 'pending'" + }, + "workspace_invites_pending_email_per_workspace": { + "name": "workspace_invites_pending_email_per_workspace", + "columns": [ + "workspace_id", + "email" + ], + "isUnique": true, + "where": "\"workspace_invites\".\"type\" = 'email' and \"workspace_invites\".\"status\" = 'pending'" + }, + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "workspace_invites_created_by_user_id_idx": { + "name": "workspace_invites_created_by_user_id_idx", + "columns": [ + "created_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_created_by_user_id_user_id_fk": { + "name": "workspace_invites_created_by_user_id_user_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "workspace_invites_role_check": { + "name": "workspace_invites_role_check", + "value": "\"workspace_invites\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + }, + "workspace_invites_type_check": { + "name": "workspace_invites_type_check", + "value": "\"workspace_invites\".\"type\" in ('email', 'link')" + }, + "workspace_invites_status_check": { + "name": "workspace_invites_status_check", + "value": "\"workspace_invites\".\"status\" in ('pending', 'accepted', 'revoked', 'expired')" + } + } + }, + "workspace_members": { + "name": "workspace_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'viewer'" + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_members_workspace_user_unique": { + "name": "workspace_members_workspace_user_unique", + "columns": [ + "workspace_id", + "user_id" + ], + "isUnique": true + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "workspace_members_user_last_opened_at_idx": { + "name": "workspace_members_user_last_opened_at_idx", + "columns": [ + "user_id", + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "workspace_members_role_check": { + "name": "workspace_members_role_check", + "value": "\"workspace_members\".\"role\" in ('owner', 'admin', 'editor', 'viewer')" + } + } + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_owner_id_idx": { + "name": "workspaces_owner_id_idx", + "columns": [ + "owner_id" + ], + "isUnique": false + }, + "workspaces_archived_at_idx": { + "name": "workspaces_archived_at_idx", + "columns": [ + "archived_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_owner_id_user_id_fk": { + "name": "workspaces_owner_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f393077f..b9230048 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1782668884562, "tag": "0001_last_mulholland_black", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1783052325052, + "tag": "0002_mcp_oauth", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index bb38a489..a286e192 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dependencies": { "@ai-sdk/react": "^3.0.210", "@base-ui/react": "^1.6.0", + "@better-auth/oauth-provider": "^1.6.23", "@cf-wasm/photon": "^0.3.6", "@cloudflare/codemode": "^0.4.2", "@cloudflare/containers": "^0.3.7", @@ -68,6 +69,7 @@ "@embedpdf/plugin-viewport": "^2.14.4", "@embedpdf/plugin-zoom": "^2.14.4", "@fontsource-variable/geist": "5.2.9", + "@modelcontextprotocol/sdk": "^1.29.0", "@posthog/ai": "^8.2.2", "@posthog/react": "^1.10.3", "@posthog/rollup-plugin": "^1.4.7", @@ -106,7 +108,7 @@ "agents": "^0.17.3", "ai": "^6.0.208", "autumn-js": "^1.2.34", - "better-auth": "^1.6.19", + "better-auth": "^1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-kit": "^0.31.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c083ced..517498fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.3.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@better-auth/oauth-provider': + specifier: ^1.6.23 + version: 1.6.23(d02ba86cb84b2e685bd73caaf1c127e5) '@cf-wasm/photon': specifier: ^0.3.6 version: 0.3.6 @@ -102,6 +105,9 @@ importers: '@fontsource-variable/geist': specifier: 5.2.9 version: 5.2.9 + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) '@posthog/ai': specifier: ^8.2.2 version: 8.2.2(@ai-sdk/provider@3.0.13)(@opentelemetry/api@1.9.1)(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(posthog-node@5.38.4) @@ -215,10 +221,10 @@ importers: version: 6.0.208(zod@4.4.3) autumn-js: specifier: ^1.2.34 - version: 1.2.34(better-auth@1.6.19(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)))(better-call@1.3.6(zod@4.4.3))(express@5.2.1)(hono@4.12.27)(react@19.2.7) + version: 1.2.34(better-auth@1.6.23(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)))(better-call@1.3.7(zod@4.4.3))(express@5.2.1)(hono@4.12.27)(react@19.2.7) better-auth: - specifier: ^1.6.19 - version: 1.6.19(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)) + specifier: ^1.6.23 + version: 1.6.23(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -230,7 +236,7 @@ importers: version: 0.31.10 drizzle-orm: specifier: ^0.45.1 - version: 0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1) + version: 0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1) katex: specifier: ^0.17.0 version: 0.17.0 @@ -306,7 +312,7 @@ importers: version: 8.0.2(@babel/core@7.29.7) '@cloudflare/vitest-pool-workers': specifier: ^0.16.17 - version: 0.16.18(@cloudflare/workers-types@4.20260627.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9) + version: 0.16.18(@cloudflare/workers-types@4.20260627.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.0.1)(typescript@6.0.3))) '@cloudflare/workers-types': specifier: ^4.20260627.1 version: 4.20260627.1 @@ -739,14 +745,14 @@ packages: '@types/react': optional: true - '@better-auth/core@1.6.19': - resolution: {integrity: sha512-ddE3Y9MoQ8t32QSO5Y8mV7pmnDAv5LdJjX1SPWUH6JUUmuOc7YEy3B5JfXZIJbRaXFdnAitN7pHPVa5u/dYAZA==} + '@better-auth/core@1.6.23': + resolution: {integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==} peerDependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@cloudflare/workers-types': '>=4' '@opentelemetry/api': ^1.9.0 - better-call: 1.3.6 + better-call: 1.3.7 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 @@ -756,46 +762,55 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.19': - resolution: {integrity: sha512-57C9ePorPmIEez6dHuQMz3hCTkYim0lfVRIoRtX7PiVfiRFB2bjXseQwrCJfQmkgMFlkp1s/c9nKgAjc2EvAIg==} + '@better-auth/drizzle-adapter@1.6.23': + resolution: {integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 drizzle-orm: ^0.45.2 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.6.19': - resolution: {integrity: sha512-DlmvllEd0nv8JL+plX3JB3WTmqDFnGFOmjmIiUDHo8R3PTAvC0ZaJq3Jk+LQLN5PyVQSUzXZKtvTQYaqRHzBaw==} + '@better-auth/kysely-adapter@1.6.23': + resolution: {integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.19': - resolution: {integrity: sha512-cZ8iLRG/T8Oi/CqE9FTHj3z8pIOqRsINi50trWxPNwyY/Eyb7YCljrBi0PuqgIdyVs7BWfrrtEYTpO4ddfuwEw==} + '@better-auth/memory-adapter@1.6.23': + resolution: {integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.19': - resolution: {integrity: sha512-8AReXqhMGiGQIPEpGbmAhh+R4g70TsAVvzwdd6Aj4q+LTSwd3tqC89TFJ4eX8KSplxm9PFBZ6g6gsRmDd7urQg==} + '@better-auth/mongo-adapter@1.6.23': + resolution: {integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.6.19': - resolution: {integrity: sha512-pXZBhR7/bzJb48IUHlGMyz9SM9h1OCO5GIIuHEllJYt8MKgrjtsnXfUkwZh6pAUEIp3WxBEYMMK96bfkqHiWEg==} + '@better-auth/oauth-provider@1.6.23': + resolution: {integrity: sha512-1sDN+N4Sztmpk8ziCU3MXicxOTfvYoHvHvhJMQ7PSfr+pLXnYN+dJFI9S3zBRwstmTeJx/OhRIZWrwFJ0TgBnA==} + peerDependencies: + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + better-auth: ^1.6.23 + better-call: 1.3.7 + + '@better-auth/prisma-adapter@1.6.23': + resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -805,10 +820,10 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.6.19': - resolution: {integrity: sha512-bBaB6SMIsrD3WutDdm5YQ1bQyinANTimHD8RtpLWhNh/jIXvzgwVVCrDFqA256vcGC/ZRCydmtW8ZrUqNMW9Og==} + '@better-auth/telemetry@1.6.23': + resolution: {integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -3236,6 +3251,15 @@ packages: '@preact/signals-core@1.14.2': resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==} + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + '@rolldown/binding-android-arm64@1.1.2': resolution: {integrity: sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4845,8 +4869,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - better-auth@1.6.19: - resolution: {integrity: sha512-68eXWKj0sxa0xW4+n4tENd6Co94UCynPKe1fncmO6kIB3XhSXWgwDEpiUouJV2dmLBrHM1FPkoI6Q5597zCGpQ==} + better-auth@1.6.23: + resolution: {integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -4907,17 +4931,24 @@ packages: vue: optional: true - better-call@1.3.6: - resolution: {integrity: sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA==} + better-call@1.3.7: + resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: zod: optional: true + better-sqlite3@12.11.1: + resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -5825,10 +5856,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.8: - resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -5938,6 +5965,9 @@ packages: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -8610,7 +8640,7 @@ snapshots: dependencies: '@ai-sdk/provider': 3.0.10 '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.8 + eventsource-parser: 3.1.0 zod: 4.4.3 '@ai-sdk/provider-utils@4.0.35(zod@4.4.3)': @@ -9049,13 +9079,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': + '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.41.1 '@standard-schema/spec': 1.1.0 - better-call: 1.3.6(zod@4.4.3) + better-call: 1.3.7(zod@4.4.3) jose: 6.2.3 kysely: 0.28.17 nanostores: 1.3.0 @@ -9064,38 +9094,50 @@ snapshots: '@cloudflare/workers-types': 4.20260627.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))': + '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1) - '@better-auth/kysely-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.28.17)': + '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.28.17)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.28.17 - '@better-auth/memory-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/oauth-provider@1.6.23(d02ba86cb84b2e685bd73caaf1c127e5)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + better-auth: 1.6.23(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)) + better-call: 1.3.7(zod@4.4.3) + jose: 6.2.3 + zod: 4.4.3 - '@better-auth/prisma-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 + optionalDependencies: + '@prisma/client': 5.22.0 - '@better-auth/telemetry@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -9209,7 +9251,7 @@ snapshots: - utf-8-validate - workerd - '@cloudflare/vitest-pool-workers@0.16.18(@cloudflare/workers-types@4.20260627.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9)': + '@cloudflare/vitest-pool-workers@0.16.18(@cloudflare/workers-types@4.20260627.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@26.0.1)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.0.1)(typescript@6.0.3)))': dependencies: '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -10899,6 +10941,9 @@ snapshots: '@preact/signals-core@1.14.2': {} + '@prisma/client@5.22.0': + optional: true + '@rolldown/binding-android-arm64@1.1.2': optional: true @@ -12404,14 +12449,14 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - autumn-js@1.2.34(better-auth@1.6.19(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)))(better-call@1.3.6(zod@4.4.3))(express@5.2.1)(hono@4.12.27)(react@19.2.7): + autumn-js@1.2.34(better-auth@1.6.23(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)))(better-call@1.3.7(zod@4.4.3))(express@5.2.1)(hono@4.12.27)(react@19.2.7): dependencies: query-string: 9.4.1 rou3: 0.6.3 zod: 4.4.3 optionalDependencies: - better-auth: 1.6.19(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)) - better-call: 1.3.6(zod@4.4.3) + better-auth: 1.6.23(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)) + better-call: 1.3.7(zod@4.4.3) express: 5.2.1 hono: 4.12.27 react: 19.2.7 @@ -12452,29 +12497,31 @@ snapshots: baseline-browser-mapping@2.10.40: {} - better-auth@1.6.19(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)): + better-auth@1.6.23(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@tanstack/react-start@1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.12)(svelte@5.56.3(@typescript-eslint/types@8.61.1))(vitest@4.1.9)(vue@3.5.38(typescript@6.0.3)): dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1)) - '@better-auth/kysely-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1)) + '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0) + '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 '@noble/hashes': 2.2.0 - better-call: 1.3.6(zod@4.4.3) + better-call: 1.3.7(zod@4.4.3) defu: 6.1.7 jose: 6.2.3 kysely: 0.28.17 nanostores: 1.3.0 zod: 4.4.3 optionalDependencies: + '@prisma/client': 5.22.0 '@tanstack/react-start': 1.168.26(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.0.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + better-sqlite3: 12.11.1 drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1) + drizzle-orm: 0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1) pg: 8.22.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -12486,7 +12533,7 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.6(zod@4.4.3): + better-call@1.3.7(zod@4.4.3): dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -12495,11 +12542,22 @@ snapshots: optionalDependencies: zod: 4.4.3 + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + optional: true + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 optional: true + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + optional: true + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -13124,11 +13182,13 @@ snapshots: esbuild: 0.25.12 tsx: 4.22.4 - drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1): + drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260627.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(kysely@0.28.17)(pg@8.22.0)(sql.js@1.14.1): optionalDependencies: '@cloudflare/workers-types': 4.20260627.1 '@opentelemetry/api': 1.9.1 + '@prisma/client': 5.22.0 '@types/pg': 8.20.0 + better-sqlite3: 12.11.1 kysely: 0.28.17 pg: 8.22.0 sql.js: 1.14.1 @@ -13399,8 +13459,6 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.8: {} - eventsource-parser@3.1.0: {} eventsource@3.0.7: @@ -13559,6 +13617,9 @@ snapshots: transitivePeerDependencies: - supports-color + file-uri-to-path@1.0.0: + optional: true + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 05599839..94c8be73 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,6 +35,8 @@ peerDependencyRules: allowBuilds: "@mongodb-js/zstd": true "@posthog/cli": true + "@prisma/client": false + better-sqlite3: false core-js: true core-js-pure: true esbuild: true diff --git a/src/db/schema.ts b/src/db/schema.ts index 25b5ff6d..19ffc6e1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -96,6 +96,120 @@ export const verification = sqliteTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); +export const jwks = sqliteTable("jwks", { + id: text("id").primaryKey(), + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }), +}); + +export const oauthClient = sqliteTable( + "oauth_client", + { + id: text("id").primaryKey(), + clientId: text("client_id").notNull().unique(), + clientSecret: text("client_secret"), + disabled: integer("disabled", { mode: "boolean" }), + skipConsent: integer("skip_consent", { mode: "boolean" }), + enableEndSession: integer("enable_end_session", { mode: "boolean" }), + subjectType: text("subject_type"), + scopes: text("scopes", { mode: "json" }), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }), + name: text("name"), + uri: text("uri"), + icon: text("icon"), + contacts: text("contacts", { mode: "json" }), + tos: text("tos"), + policy: text("policy"), + softwareId: text("software_id"), + softwareVersion: text("software_version"), + softwareStatement: text("software_statement"), + redirectUris: text("redirect_uris", { mode: "json" }).notNull(), + postLogoutRedirectUris: text("post_logout_redirect_uris", { mode: "json" }), + tokenEndpointAuthMethod: text("token_endpoint_auth_method"), + grantTypes: text("grant_types", { mode: "json" }), + responseTypes: text("response_types", { mode: "json" }), + public: integer("public", { mode: "boolean" }), + type: text("type"), + requirePKCE: integer("require_pkce", { mode: "boolean" }), + referenceId: text("reference_id"), + metadata: text("metadata", { mode: "json" }), + }, + (table) => [index("oauthClient_userId_idx").on(table.userId)], +); + +export const oauthRefreshToken = sqliteTable( + "oauth_refresh_token", + { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + sessionId: text("session_id").references(() => session.id, { onDelete: "set null" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + referenceId: text("reference_id"), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + revoked: integer("revoked", { mode: "timestamp_ms" }), + authTime: integer("auth_time", { mode: "timestamp_ms" }), + scopes: text("scopes", { mode: "json" }).notNull(), + }, + (table) => [ + index("oauthRefreshToken_clientId_idx").on(table.clientId), + index("oauthRefreshToken_sessionId_idx").on(table.sessionId), + index("oauthRefreshToken_userId_idx").on(table.userId), + ], +); + +export const oauthAccessToken = sqliteTable( + "oauth_access_token", + { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + sessionId: text("session_id").references(() => session.id, { onDelete: "set null" }), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + referenceId: text("reference_id"), + refreshId: text("refresh_id").references(() => oauthRefreshToken.id, { onDelete: "cascade" }), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + scopes: text("scopes", { mode: "json" }).notNull(), + }, + (table) => [ + index("oauthAccessToken_clientId_idx").on(table.clientId), + index("oauthAccessToken_sessionId_idx").on(table.sessionId), + index("oauthAccessToken_userId_idx").on(table.userId), + index("oauthAccessToken_refreshId_idx").on(table.refreshId), + ], +); + +export const oauthConsent = sqliteTable( + "oauth_consent", + { + id: text("id").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + referenceId: text("reference_id"), + scopes: text("scopes", { mode: "json" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + }, + (table) => [ + index("oauthConsent_clientId_idx").on(table.clientId), + index("oauthConsent_userId_idx").on(table.userId), + ], +); + export const workspaces = sqliteTable( "workspaces", { @@ -205,15 +319,21 @@ export const workspaceInvites = sqliteTable( export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), + oauthClients: many(oauthClient), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), + oauthConsents: many(oauthConsent), ownedWorkspaces: many(workspaces), workspaceMemberships: many(workspaceMembers), })); -export const sessionRelations = relations(session, ({ one }) => ({ +export const sessionRelations = relations(session, ({ one, many }) => ({ user: one(user, { fields: [session.userId], references: [user.id], }), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), })); export const accountRelations = relations(account, ({ one }) => ({ @@ -253,3 +373,59 @@ export const workspaceInviteRelations = relations(workspaceInvites, ({ one }) => references: [user.id], }), })); + +export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({ + user: one(user, { + fields: [oauthClient.userId], + references: [user.id], + }), + refreshTokens: many(oauthRefreshToken), + accessTokens: many(oauthAccessToken), + consents: many(oauthConsent), +})); + +export const oauthRefreshTokenRelations = relations(oauthRefreshToken, ({ one, many }) => ({ + client: one(oauthClient, { + fields: [oauthRefreshToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthRefreshToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthRefreshToken.userId], + references: [user.id], + }), + accessTokens: many(oauthAccessToken), +})); + +export const oauthAccessTokenRelations = relations(oauthAccessToken, ({ one }) => ({ + client: one(oauthClient, { + fields: [oauthAccessToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthAccessToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthAccessToken.userId], + references: [user.id], + }), + refreshToken: one(oauthRefreshToken, { + fields: [oauthAccessToken.refreshId], + references: [oauthRefreshToken.id], + }), +})); + +export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({ + client: one(oauthClient, { + fields: [oauthConsent.clientId], + references: [oauthClient.clientId], + }), + user: one(user, { + fields: [oauthConsent.userId], + references: [user.id], + }), +})); diff --git a/src/features/account/components/SettingsNav.tsx b/src/features/account/components/SettingsNav.tsx new file mode 100644 index 00000000..8d475667 --- /dev/null +++ b/src/features/account/components/SettingsNav.tsx @@ -0,0 +1,30 @@ +import { Link } from "@tanstack/react-router"; + +import { cn } from "#/lib/utils"; + +const navLinkClassName = + "rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"; + +const navLinkActiveClassName = "bg-muted text-foreground"; + +export function SettingsNav() { + return ( + + ); +} diff --git a/src/features/account/components/SettingsPage.tsx b/src/features/account/components/SettingsPage.tsx index c8e948f2..a7b0199c 100644 --- a/src/features/account/components/SettingsPage.tsx +++ b/src/features/account/components/SettingsPage.tsx @@ -9,6 +9,7 @@ import { Button } from "#/components/ui/button"; import { Field, FieldGroup, FieldLabel } from "#/components/ui/field"; import { Input } from "#/components/ui/input"; import { DeleteAccountSection } from "#/features/account/components/DeleteAccountSection"; +import { SettingsNav } from "#/features/account/components/SettingsNav"; import { signOutCurrentUser } from "#/lib/auth-sign-out"; import { getErrorMessage } from "#/lib/error-message"; import { getAuthSessionQueryOptions } from "#/lib/session-query"; @@ -64,6 +65,8 @@ export function SettingsPage() { Back + +
diff --git a/src/features/account/connections/ConnectionsSettingsPage.tsx b/src/features/account/connections/ConnectionsSettingsPage.tsx new file mode 100644 index 00000000..38873c17 --- /dev/null +++ b/src/features/account/connections/ConnectionsSettingsPage.tsx @@ -0,0 +1,368 @@ +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router"; +import { ArrowLeft, CheckIcon, CopyIcon, Plug, Settings } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; + +import AppShell from "#/components/AppShell"; +import { + CodeBlockActions, + CodeBlockCopyButton, + CodeBlockHeader, + CodeBlockLabel, +} from "#/components/code-block/code-block-chrome"; +import { AnimatedIconSwap } from "#/components/ui/animated-icon-swap"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "#/components/ui/alert-dialog"; +import { Button } from "#/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "#/components/ui/empty"; +import { Field, FieldLabel } from "#/components/ui/field"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "#/components/ui/input-group"; +import { Skeleton } from "#/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "#/components/ui/tabs"; +import { SettingsNav } from "#/features/account/components/SettingsNav"; +import { + buildMcpServerConfig, + EDITOR_SETUP_GUIDES, + getMcpServerUrl, +} from "#/features/account/connections/mcp-setup"; +import { formatOAuthScopes } from "#/features/account/connections/oauth-scope-labels"; +import { revokeOAuthConnectionFn } from "#/features/account/connections/oauth-connections.functions"; +import { getOAuthClient, getOAuthConsents } from "#/features/account/connections/oauth-api"; +import { useCopyToClipboard } from "#/hooks/use-copy-to-clipboard"; +import { getErrorMessage } from "#/lib/error-message"; + +const oauthConsentsQueryKey = ["oauth-consents"] as const; + +function oauthClientQueryKey(clientId: string) { + return ["oauth-client", clientId] as const; +} + +function formatAuthorizedDate(value: Date | string): string { + const date = value instanceof Date ? value : new Date(value); + + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function McpServerUrlField({ serverMcpServerUrl }: { serverMcpServerUrl: string }) { + const mcpServerUrl = getMcpServerUrl(serverMcpServerUrl); + const { copied, copy } = useCopyToClipboard({ + onError: () => toast.error("Could not copy MCP server URL"), + }); + + return ( + + MCP server URL + + + + + + + + ); +} + +function EditorSetupInstructions({ serverMcpServerUrl }: { serverMcpServerUrl: string }) { + const mcpServerUrl = getMcpServerUrl(serverMcpServerUrl); + const configJson = useMemo(() => buildMcpServerConfig(mcpServerUrl), [mcpServerUrl]); + + return ( +
+
+

Connect to your editor

+

+ Add ThinkEx as a remote MCP server in your editor. When prompted, sign in and approve + access. +

+
+ + + + {EDITOR_SETUP_GUIDES.map((guide) => ( + + {guide.label} + + ))} + + + {EDITOR_SETUP_GUIDES.map((guide) => ( + +

+ Add this configuration to{" "} + {guide.configPath} + {guide.notes ? `. ${guide.notes}` : "."} +

+ +
+ + {guide.label} MCP config + + toast.error("Could not copy MCP config")} + /> + + +
+								{configJson}
+							
+
+
+ ))} +
+
+ ); +} + +interface AuthorizedConnectionRowProps { + consentId: string; + clientId: string; + clientName: string | undefined; + scopes: readonly string[]; + authorizedAt: Date | string; + isRevoking: boolean; + onRevoke: () => void; +} + +function AuthorizedConnectionRow({ + clientName, + scopes, + authorizedAt, + isRevoking, + onRevoke, +}: AuthorizedConnectionRowProps) { + const [open, setOpen] = useState(false); + const scopeLabels = formatOAuthScopes(scopes); + + return ( +
+
+

{clientName ?? "Unknown application"}

+

{scopeLabels.join(" · ")}

+

+ Authorized {formatAuthorizedDate(authorizedAt)} +

+
+ + + + + + Revoke access? + + {clientName ?? "This application"} will no longer be able to access your ThinkEx + workspaces. You can reconnect later from your editor. + + + + Cancel + { + event.preventDefault(); + onRevoke(); + }} + > + Revoke + + + + +
+ ); +} + +function AuthorizedConnectionsSection() { + const queryClient = useQueryClient(); + const [revokingConsentIds, setRevokingConsentIds] = useState>(new Set()); + + const { + data: consents, + isLoading, + isError, + error, + } = useQuery({ + queryKey: oauthConsentsQueryKey, + queryFn: getOAuthConsents, + }); + + const clientQueries = useQueries({ + queries: (consents ?? []).map((consent) => ({ + queryKey: oauthClientQueryKey(consent.clientId), + queryFn: () => getOAuthClient(consent.clientId), + })), + }); + + const revokeMutation = useMutation({ + mutationFn: (consentId: string) => revokeOAuthConnectionFn({ data: { consentId } }), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: oauthConsentsQueryKey }); + toast.success("Access revoked"); + }, + onError: (mutationError) => { + toast.error(getErrorMessage(mutationError, "Unable to revoke access right now.")); + }, + onSettled: (_data, _error, consentId) => { + setRevokingConsentIds((current) => { + const next = new Set(current); + next.delete(consentId); + return next; + }); + }, + }); + + const handleRevoke = (consentId: string) => { + setRevokingConsentIds((current) => new Set(current).add(consentId)); + revokeMutation.mutate(consentId); + }; + + return ( +
+
+

Authorized applications

+

+ Applications that can access your ThinkEx workspaces on your behalf. +

+
+ + {isLoading ? ( +
+ + +
+ ) : null} + + {isError ? ( +

+ {getErrorMessage(error, "Unable to load authorized applications.")} +

+ ) : null} + + {!isLoading && !isError && (consents?.length ?? 0) === 0 ? ( + + + + + No applications connected yet + + When you connect an editor or agent, it will appear here so you can review or revoke + access. + + + + ) : null} + + {!isLoading && !isError && consents && consents.length > 0 ? ( +
+ {consents.map((consent, index) => { + const client = clientQueries[index]?.data; + + return ( + handleRevoke(consent.id)} + /> + ); + })} +
+ ) : null} +
+ ); +} + +export function ConnectionsSettingsPage({ mcpServerUrl }: { mcpServerUrl: string }) { + const navigate = useNavigate(); + const router = useRouter(); + const canGoBack = useCanGoBack(); + + const handleBack = () => { + if (canGoBack) { + router.history.back(); + return; + } + + void navigate({ to: "/home" }); + }; + + return ( + + + ); +} diff --git a/src/features/account/connections/mcp-setup.functions.ts b/src/features/account/connections/mcp-setup.functions.ts new file mode 100644 index 00000000..f840a36e --- /dev/null +++ b/src/features/account/connections/mcp-setup.functions.ts @@ -0,0 +1,7 @@ +import { createServerFn } from "@tanstack/react-start"; + +import { getMcpResourceUrl } from "#/lib/app-origin"; + +export const getMcpServerUrlFn = createServerFn({ method: "GET" }).handler(async () => + getMcpResourceUrl(), +); diff --git a/src/features/account/connections/mcp-setup.ts b/src/features/account/connections/mcp-setup.ts new file mode 100644 index 00000000..0864633c --- /dev/null +++ b/src/features/account/connections/mcp-setup.ts @@ -0,0 +1,57 @@ +import { getClientOrigin } from "#/lib/client-url"; + +interface EditorSetupGuide { + id: string; + label: string; + configPath: string; + notes?: string; +} + +export function buildMcpServerConfig(mcpServerUrl: string): string { + return JSON.stringify( + { + mcpServers: { + thinkex: { + url: mcpServerUrl, + }, + }, + }, + null, + 2, + ); +} + +export const EDITOR_SETUP_GUIDES: readonly EditorSetupGuide[] = [ + { + id: "cursor", + label: "Cursor", + configPath: "~/.cursor/mcp.json", + }, + { + id: "claude-desktop", + label: "Claude Desktop", + configPath: + "~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows)", + }, + { + id: "vscode", + label: "VS Code", + configPath: ".vscode/mcp.json in your workspace root", + notes: "Requires an MCP-capable extension such as Cline, Continue, or GitHub Copilot.", + }, + { + id: "windsurf", + label: "Windsurf", + configPath: "~/.codeium/windsurf/mcp_config.json", + }, +] as const; + +export function getMcpServerUrl(serverMcpServerUrl: string): string { + const origin = getClientOrigin(); + + if (origin) { + return `${origin}/mcp`; + } + + return serverMcpServerUrl; +} diff --git a/src/features/account/connections/oauth-api.ts b/src/features/account/connections/oauth-api.ts new file mode 100644 index 00000000..d6166c37 --- /dev/null +++ b/src/features/account/connections/oauth-api.ts @@ -0,0 +1,145 @@ +export interface OAuthConsentRecord { + id: string; + clientId: string; + userId: string; + scopes: string[]; + createdAt: string; + updatedAt: string; +} + +export interface OAuthClientRecord { + client_id: string; + client_name?: string; + logo_uri?: string; + client_uri?: string; +} + +interface OAuthConsentResponse { + redirect: boolean; + url: string; +} + +const signedQueryParameterNameParam = "ba_param"; + +function getSignedOAuthQueryParameterNames(params: URLSearchParams): Set | undefined { + const signedParameterNames = params.getAll(signedQueryParameterNameParam); + + if (signedParameterNames.length === 0) { + return undefined; + } + + return new Set(signedParameterNames); +} + +function buildSignedOAuthQuery(search: string): string | undefined { + const params = new URLSearchParams(search); + + if (!params.has("sig")) { + return undefined; + } + + const signedParameterNames = getSignedOAuthQueryParameterNames(params); + + if (!signedParameterNames) { + return undefined; + } + + const signedParams = new URLSearchParams(); + + for (const [key, value] of params.entries()) { + if (key === "sig" || key === signedQueryParameterNameParam || signedParameterNames.has(key)) { + signedParams.append(key, value); + } + } + + return signedParams.toString(); +} + +async function readAuthJson(response: Response): Promise { + const rawBody = await response.text(); + + let payload: (T & { message?: string }) | undefined; + + if (rawBody) { + try { + payload = JSON.parse(rawBody) as T & { message?: string }; + } catch { + payload = undefined; + } + } + + if (!response.ok) { + throw new Error( + payload && typeof payload === "object" && "message" in payload && payload.message + ? payload.message + : "Request failed", + ); + } + + return payload as T; +} + +// These auth endpoints are same-origin and cookie-scoped, so we issue relative +// requests. `fetch` resolves them against the document base in the browser, +// avoiding a hard dependency on `window` (which keeps the helpers testable). +function buildAuthRequestPath(path: string, query?: Record): string { + if (!query) { + return path; + } + + const searchParams = new URLSearchParams(query); + const queryString = searchParams.toString(); + + return queryString ? `${path}?${queryString}` : path; +} + +async function authGet(path: string, query?: Record): Promise { + const response = await fetch(buildAuthRequestPath(path, query), { + credentials: "include", + }); + + return readAuthJson(response); +} + +async function authPost(path: string, body: Record): Promise { + const response = await fetch(path, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + return readAuthJson(response); +} + +export async function getOAuthConsents(): Promise { + return authGet("/api/auth/oauth2/get-consents"); +} + +export async function getOAuthClient(clientId: string): Promise { + return authGet("/api/auth/oauth2/get-client", { + client_id: clientId, + }); +} + +export async function getOAuthClientPublic(clientId: string): Promise { + return authGet("/api/auth/oauth2/public-client", { + client_id: clientId, + }); +} + +export async function submitOAuthConsent(input: { + accept: boolean; + scope?: string; +}): Promise { + const oauthQuery = + typeof window !== "undefined" ? buildSignedOAuthQuery(window.location.search) : undefined; + + return authPost("/api/auth/oauth2/consent", { + accept: input.accept, + scope: input.scope, + ...(oauthQuery ? { oauth_query: oauthQuery } : {}), + }); +} diff --git a/src/features/account/connections/oauth-connections.functions.ts b/src/features/account/connections/oauth-connections.functions.ts new file mode 100644 index 00000000..36462f8e --- /dev/null +++ b/src/features/account/connections/oauth-connections.functions.ts @@ -0,0 +1,20 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + +import { revokeOAuthConnection } from "#/features/account/connections/oauth-connections.server"; +import { withWorkspaceDb } from "#/features/workspaces/server/workspace-db"; + +const revokeOAuthConnectionInputSchema = z.object({ + consentId: z.string().min(1), +}); + +export const revokeOAuthConnectionFn = createServerFn({ method: "POST" }) + .validator(revokeOAuthConnectionInputSchema) + .handler(async ({ data }) => + withWorkspaceDb(({ db, userId }) => + revokeOAuthConnection(db, { + consentId: data.consentId, + userId, + }), + ), + ); diff --git a/src/features/account/connections/oauth-connections.server.ts b/src/features/account/connections/oauth-connections.server.ts new file mode 100644 index 00000000..f3d66176 --- /dev/null +++ b/src/features/account/connections/oauth-connections.server.ts @@ -0,0 +1,60 @@ +import { and, eq } from "drizzle-orm"; + +import { oauthConsent, oauthRefreshToken } from "#/db/schema"; +import type { createDbContext } from "#/db/server"; + +type Db = Awaited>["db"]; + +export class OAuthConsentNotFoundError extends Error { + constructor() { + super("Authorized connection not found"); + this.name = "OAuthConsentNotFoundError"; + } +} + +export class OAuthConsentForbiddenError extends Error { + constructor() { + super("Forbidden"); + this.name = "OAuthConsentForbiddenError"; + } +} + +/** + * Revoke an authorized OAuth connection for the current user. + * + * Deleting the consent row is what blocks MCP access: `assertMcpConnectionAuthorized` + * rejects tool calls on the next request. We also purge refresh tokens so the client + * cannot silently mint new access tokens without going through consent again. + */ +export async function revokeOAuthConnection( + db: Db, + input: { consentId: string; userId: string }, +): Promise { + const [consent] = await db + .select({ clientId: oauthConsent.clientId, userId: oauthConsent.userId }) + .from(oauthConsent) + .where(eq(oauthConsent.id, input.consentId)) + .limit(1); + + if (!consent) { + throw new OAuthConsentNotFoundError(); + } + + if (consent.userId !== input.userId) { + throw new OAuthConsentForbiddenError(); + } + + // Purge consent and refresh tokens atomically so a mid-operation failure can never + // leave the connection half-revoked (e.g. consent gone but refresh tokens still live). + await db.batch([ + db + .delete(oauthRefreshToken) + .where( + and( + eq(oauthRefreshToken.clientId, consent.clientId), + eq(oauthRefreshToken.userId, input.userId), + ), + ), + db.delete(oauthConsent).where(eq(oauthConsent.id, input.consentId)), + ]); +} diff --git a/src/features/account/connections/oauth-scope-labels.ts b/src/features/account/connections/oauth-scope-labels.ts new file mode 100644 index 00000000..957ef25c --- /dev/null +++ b/src/features/account/connections/oauth-scope-labels.ts @@ -0,0 +1,15 @@ +export const OAUTH_SCOPE_LABELS: Record = { + "workspace:read": "Read your ThinkEx workspaces, files, and documents", +}; + +export function formatOAuthScopes(scopes: readonly string[]): string[] { + return scopes.map((scope) => OAUTH_SCOPE_LABELS[scope] ?? scope); +} + +export function parseOAuthScopeParam(scope: string | undefined): string[] { + if (!scope) { + return []; + } + + return [...new Set(scope.split(/\s+/).filter(Boolean))]; +} diff --git a/src/features/workspaces/agent-routes.ts b/src/features/workspaces/agent-routes.ts index eaea0b8c..ba64762c 100644 --- a/src/features/workspaces/agent-routes.ts +++ b/src/features/workspaces/agent-routes.ts @@ -10,6 +10,7 @@ export const workspaceKernelPathPrefix = "/workspace-kernel"; export const workspaceKernelBasePath = "workspace-kernel"; export const workspaceKernelRealtimeSegment = "realtime"; export const documentSessionPathPrefix = "/document-session"; +export const mcpPathPrefix = "/mcp"; export interface DocumentSessionRouteParams { workspaceId: string; @@ -28,6 +29,10 @@ export function isDocumentSessionRequestPath(pathname: string) { return pathname.startsWith(`${documentSessionPathPrefix}/`); } +export function isMcpRequestPath(pathname: string) { + return matchesPathPrefix(pathname, mcpPathPrefix); +} + export function getWorkspaceKernelRouteWorkspaceId(pathname: string) { if (!isWorkspaceKernelRequestPath(pathname)) { return null; diff --git a/src/features/workspaces/mcp/mcp-audit.ts b/src/features/workspaces/mcp/mcp-audit.ts new file mode 100644 index 00000000..10ef0fa7 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-audit.ts @@ -0,0 +1,28 @@ +import type { McpActor } from "./mcp-auth"; + +export type McpToolCallResultStatus = "denied" | "failed" | "ok"; + +export interface McpToolCallRecord { + clientId: string | null; + resultStatus: McpToolCallResultStatus; + scopes: readonly string[]; + toolName: string; + userId: string; + workspaceId?: string; +} + +export function recordMcpToolCall(_record: McpToolCallRecord): void { + // Reserved for future audit/telemetry (e.g. PostHog server events before write tools). +} + +export function recordMcpToolCallFromActor( + actor: McpActor, + input: Omit, +): void { + recordMcpToolCall({ + clientId: actor.clientId, + scopes: [...actor.grantedScopes], + userId: actor.userId, + ...input, + }); +} diff --git a/src/features/workspaces/mcp/mcp-auth.test.ts b/src/features/workspaces/mcp/mcp-auth.test.ts new file mode 100644 index 00000000..b6c852db --- /dev/null +++ b/src/features/workspaces/mcp/mcp-auth.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("better-auth/oauth2", () => ({ + verifyAccessToken: vi.fn(), +})); + +vi.mock("better-auth/api", () => ({ + APIError: class APIError extends Error { + constructor( + readonly status: string, + readonly body?: { message?: string }, + ) { + super(body?.message ?? status); + this.name = "APIError"; + } + }, +})); + +vi.mock("#/lib/app-origin", () => ({ + getAppOrigin: () => "https://app.example.com", + getAuthBaseURL: () => "https://app.example.com", + getMcpResourceUrl: () => "https://app.example.com/mcp", +})); + +import { verifyAccessToken } from "better-auth/oauth2"; +import { APIError } from "better-auth/api"; +import { + buildMcpAccountContext, + buildMcpWorkspaceContext, + McpAuthError, + verifyMcpBearerToken, +} from "#/features/workspaces/mcp/mcp-auth"; + +function makeRequest(authHeader?: string): Request { + const headers: Record = authHeader ? { Authorization: authHeader } : {}; + return new Request("https://app.example.com/mcp", { headers }); +} + +describe("verifyMcpBearerToken", () => { + it("throws 401 missing_token when Authorization header is absent", async () => { + await expect(verifyMcpBearerToken(makeRequest())).rejects.toMatchObject({ + status: 401, + code: "missing_token", + }); + }); + + it("throws 401 missing_token when Authorization header is not Bearer", async () => { + await expect(verifyMcpBearerToken(makeRequest("Basic dXNlcjpwYXNz"))).rejects.toMatchObject({ + status: 401, + code: "missing_token", + }); + }); + + it("throws 401 missing_token when Bearer value is blank", async () => { + await expect(verifyMcpBearerToken(makeRequest("Bearer "))).rejects.toMatchObject({ + status: 401, + code: "missing_token", + }); + }); + + it("throws 401 invalid_token when verifyAccessToken rejects (expired token)", async () => { + vi.mocked(verifyAccessToken).mockRejectedValueOnce(new Error("Token expired")); + await expect(verifyMcpBearerToken(makeRequest("Bearer expired.token"))).rejects.toMatchObject({ + status: 401, + code: "invalid_token", + }); + }); + + it("throws 401 invalid_token when payload has no sub claim", async () => { + vi.mocked(verifyAccessToken).mockResolvedValueOnce({ scope: "workspace:read" } as never); + await expect(verifyMcpBearerToken(makeRequest("Bearer no-sub.token"))).rejects.toMatchObject({ + status: 401, + code: "invalid_token", + }); + }); + + it("returns McpActor for a valid token with scopes and client_id", async () => { + vi.mocked(verifyAccessToken).mockResolvedValueOnce({ + sub: "user-1", + client_id: "client-a", + scope: "workspace:read openid", + } as never); + + const actor = await verifyMcpBearerToken(makeRequest("Bearer valid.token")); + + expect(actor).toEqual({ + userId: "user-1", + clientId: "client-a", + grantedScopes: new Set(["workspace:read", "openid"]), + }); + }); + + it("accepts a case-insensitive bearer scheme per RFC 7235", async () => { + vi.mocked(verifyAccessToken).mockResolvedValueOnce({ + sub: "user-1", + scope: "workspace:read", + } as never); + + const actor = await verifyMcpBearerToken(makeRequest("bearer valid.token")); + + expect(actor.userId).toBe("user-1"); + expect(verifyAccessToken).toHaveBeenCalledWith("valid.token", expect.anything()); + }); + + it("verifies access tokens against the MCP resource audience", async () => { + vi.mocked(verifyAccessToken).mockResolvedValueOnce({ + sub: "user-1", + scope: "workspace:read", + } as never); + + await verifyMcpBearerToken(makeRequest("Bearer valid.token")); + + expect(verifyAccessToken).toHaveBeenCalledWith("valid.token", { + jwksUrl: "https://app.example.com/api/auth/jwks", + scopes: ["workspace:read"], + verifyOptions: { + issuer: "https://app.example.com", + audience: "https://app.example.com/mcp", + }, + }); + }); + + it("throws 403 insufficient_scope when verifyAccessToken rejects missing scope", async () => { + vi.mocked(verifyAccessToken).mockRejectedValueOnce( + new APIError("FORBIDDEN", { message: "invalid scope workspace:read" }), + ); + + await expect(verifyMcpBearerToken(makeRequest("Bearer scoped.token"))).rejects.toMatchObject({ + status: 403, + code: "insufficient_scope", + }); + }); + + it("returns null clientId when client_id is absent from payload", async () => { + vi.mocked(verifyAccessToken).mockResolvedValueOnce({ + sub: "user-2", + scope: "workspace:read", + } as never); + + const actor = await verifyMcpBearerToken(makeRequest("Bearer valid.no-client.token")); + + expect(actor.clientId).toBeNull(); + }); + + it("returns an actor with empty grantedScopes when the resolved token has no scope claim", async () => { + vi.mocked(verifyAccessToken).mockResolvedValueOnce({ + sub: "user-3", + } as never); + + const actor = await verifyMcpBearerToken(makeRequest("Bearer valid.no-scope.token")); + + expect(actor.userId).toBe("user-3"); + expect(actor.grantedScopes).toEqual(new Set()); + }); + + it("errors are McpAuthError instances", async () => { + await expect(verifyMcpBearerToken(makeRequest())).rejects.toBeInstanceOf(McpAuthError); + }); +}); + +describe("buildMcpAccountContext scope mapping", () => { + it("includes workspaces:read when actor has workspace:read grant", () => { + const actor = { + userId: "u1", + clientId: null, + grantedScopes: new Set(["workspace:read"]), + }; + const ctx = buildMcpAccountContext(actor); + expect(ctx.actor.scopes.has("workspaces:read")).toBe(true); + }); + + it("excludes workspaces:read when actor has no grants", () => { + const actor = { userId: "u1", clientId: null, grantedScopes: new Set() }; + const ctx = buildMcpAccountContext(actor); + expect(ctx.actor.scopes.has("workspaces:read")).toBe(false); + }); + + it("excludes workspaces:read when actor has unrelated scopes only", () => { + const actor = { + userId: "u1", + clientId: null, + grantedScopes: new Set(["openid", "profile"]), + }; + const ctx = buildMcpAccountContext(actor); + expect(ctx.actor.scopes.has("workspaces:read")).toBe(false); + }); +}); + +describe("buildMcpWorkspaceContext scope mapping", () => { + it("includes workspace:read when actor has workspace:read grant", () => { + const actor = { + userId: "u1", + clientId: null, + grantedScopes: new Set(["workspace:read"]), + }; + const ctx = buildMcpWorkspaceContext(actor, "ws-1"); + expect(ctx.actor.scopes.has("workspace:read")).toBe(true); + }); + + it("excludes workspace:read when actor has no grants", () => { + const actor = { userId: "u1", clientId: null, grantedScopes: new Set() }; + const ctx = buildMcpWorkspaceContext(actor, "ws-1"); + expect(ctx.actor.scopes.has("workspace:read")).toBe(false); + }); + + it("never includes workspace:write even when actor carries workspace:write in granted scopes", () => { + const actor = { + userId: "u1", + clientId: null, + grantedScopes: new Set(["workspace:read", "workspace:write"]), + }; + const ctx = buildMcpWorkspaceContext(actor, "ws-1"); + expect(ctx.actor.scopes.has("workspace:write")).toBe(false); + }); + + it("carries the workspaceId into the context", () => { + const actor = { + userId: "u1", + clientId: null, + grantedScopes: new Set(["workspace:read"]), + }; + const ctx = buildMcpWorkspaceContext(actor, "ws-42"); + expect(ctx.workspaceId).toBe("ws-42"); + }); +}); diff --git a/src/features/workspaces/mcp/mcp-auth.ts b/src/features/workspaces/mcp/mcp-auth.ts new file mode 100644 index 00000000..227f2361 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-auth.ts @@ -0,0 +1,152 @@ +import { APIError } from "better-auth/api"; +import { verifyAccessToken } from "better-auth/oauth2"; + +import { + type AccountAccessContext, + type AccountAccessScope, + createAccountAccessContext, +} from "#/features/workspaces/operations/account-access-context"; +import { + createWorkspaceAccessContext, + type WorkspaceAccessContext, + type WorkspaceAccessScope, +} from "#/features/workspaces/operations/workspace-access-context"; +import { + MCP_SUPPORTED_SCOPES, + MCP_WORKSPACE_READ_SCOPE, +} from "#/features/workspaces/mcp/mcp-scopes"; +import { getAuthBaseURL, getAppOrigin, getMcpResourceUrl } from "#/lib/app-origin"; + +export interface McpActor { + userId: string; + clientId: string | null; + grantedScopes: ReadonlySet; +} + +export class McpAuthError extends Error { + constructor( + readonly status: 401 | 403, + readonly code: string, + ) { + super(code); + this.name = "McpAuthError"; + } +} + +type AccessTokenPayload = Awaited>; + +function resolveAuthIssuer(): string { + const baseURL = getAuthBaseURL(); + + return typeof baseURL === "string" ? baseURL : getAppOrigin(); +} + +function resolveJwksBaseUrl(): string { + const baseURL = getAuthBaseURL(); + + if (typeof baseURL === "string") { + return baseURL; + } + + return baseURL.fallback ?? getAppOrigin(); +} + +function parseBearerToken(request: Request): string { + const authHeader = request.headers.get("Authorization"); + + // RFC 7235: the auth-scheme is case-insensitive, so accept "bearer"/"BEARER" too. + const match = authHeader?.match(/^Bearer[ \t]+(.*)$/i); + + if (!match) { + throw new McpAuthError(401, "missing_token"); + } + + const token = match[1].trim(); + + if (!token) { + throw new McpAuthError(401, "missing_token"); + } + + return token; +} + +function parseGrantedScopes(payload: AccessTokenPayload): ReadonlySet { + const scopeClaim = payload.scope; + + if (typeof scopeClaim !== "string" || scopeClaim.length === 0) { + return new Set(); + } + + return new Set(scopeClaim.split(" ").filter(Boolean)); +} + +function parseClientId(payload: AccessTokenPayload): string | null { + const clientId = payload.client_id; + + return typeof clientId === "string" ? clientId : null; +} + +export async function verifyMcpBearerToken(request: Request): Promise { + const token = parseBearerToken(request); + const issuer = resolveAuthIssuer(); + const jwksBaseUrl = resolveJwksBaseUrl(); + + let payload: AccessTokenPayload; + + try { + payload = await verifyAccessToken(token, { + jwksUrl: `${jwksBaseUrl}/api/auth/jwks`, + scopes: [...MCP_SUPPORTED_SCOPES], + verifyOptions: { + issuer, + audience: getMcpResourceUrl(), + }, + }); + } catch (error) { + if (error instanceof APIError && error.status === "FORBIDDEN") { + throw new McpAuthError(403, "insufficient_scope"); + } + + throw new McpAuthError(401, "invalid_token"); + } + + if (!payload.sub) { + throw new McpAuthError(401, "invalid_token"); + } + + return { + userId: payload.sub, + clientId: parseClientId(payload), + grantedScopes: parseGrantedScopes(payload), + }; +} + +export function buildMcpAccountContext(actor: McpActor): AccountAccessContext { + const scopes: AccountAccessScope[] = []; + + if (actor.grantedScopes.has(MCP_WORKSPACE_READ_SCOPE)) { + scopes.push("workspaces:read"); + } + + return createAccountAccessContext({ + userId: actor.userId, + scopes, + }); +} + +export function buildMcpWorkspaceContext( + actor: McpActor, + workspaceId: string, +): WorkspaceAccessContext { + const scopes: WorkspaceAccessScope[] = []; + + if (actor.grantedScopes.has(MCP_WORKSPACE_READ_SCOPE)) { + scopes.push("workspace:read"); + } + + return createWorkspaceAccessContext({ + userId: actor.userId, + workspaceId, + scopes, + }); +} diff --git a/src/features/workspaces/mcp/mcp-authorization.test.ts b/src/features/workspaces/mcp/mcp-authorization.test.ts new file mode 100644 index 00000000..d7ea0bb3 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-authorization.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("#/db/server", () => ({ + createDbContext: vi.fn(), +})); + +vi.mock("better-auth/oauth2", () => ({ + verifyAccessToken: vi.fn(), +})); + +vi.mock("#/lib/app-origin", () => ({ + getAppOrigin: () => "https://app.example.com", + getAuthBaseURL: () => "https://app.example.com", + getMcpResourceUrl: () => "https://app.example.com/mcp", +})); + +import { createDbContext } from "#/db/server"; +import { assertMcpConnectionAuthorized } from "#/features/workspaces/mcp/mcp-authorization"; +import { McpAuthError } from "#/features/workspaces/mcp/mcp-auth"; + +function mockDbContext(consentRows: unknown[]) { + const limit = vi.fn().mockResolvedValue(consentRows); + const where = vi.fn().mockReturnValue({ limit }); + const from = vi.fn().mockReturnValue({ where }); + const select = vi.fn().mockReturnValue({ from }); + const dispose = vi.fn().mockResolvedValue(undefined); + + vi.mocked(createDbContext).mockResolvedValue({ + db: { select }, + dispose, + } as never); + + return { select, from, where, limit, dispose }; +} + +describe("assertMcpConnectionAuthorized", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws 401 invalid_token when clientId is null", async () => { + await expect( + assertMcpConnectionAuthorized({ + userId: "user-1", + clientId: null, + grantedScopes: new Set(["workspace:read"]), + }), + ).rejects.toMatchObject({ + status: 401, + code: "invalid_token", + }); + + expect(createDbContext).not.toHaveBeenCalled(); + }); + + it("throws 401 invalid_token when consent row is missing", async () => { + mockDbContext([]); + + await expect( + assertMcpConnectionAuthorized({ + userId: "user-1", + clientId: "client-a", + grantedScopes: new Set(["workspace:read"]), + }), + ).rejects.toMatchObject({ + status: 401, + code: "invalid_token", + }); + }); + + it("passes when consent row exists", async () => { + mockDbContext([{ id: "consent-1" }]); + + await expect( + assertMcpConnectionAuthorized({ + userId: "user-1", + clientId: "client-a", + grantedScopes: new Set(["workspace:read"]), + }), + ).resolves.toBeUndefined(); + }); + + it("errors are McpAuthError instances", async () => { + await expect( + assertMcpConnectionAuthorized({ + userId: "user-1", + clientId: null, + grantedScopes: new Set(), + }), + ).rejects.toBeInstanceOf(McpAuthError); + }); +}); diff --git a/src/features/workspaces/mcp/mcp-authorization.ts b/src/features/workspaces/mcp/mcp-authorization.ts new file mode 100644 index 00000000..935dccff --- /dev/null +++ b/src/features/workspaces/mcp/mcp-authorization.ts @@ -0,0 +1,27 @@ +import { and, eq } from "drizzle-orm"; + +import { oauthConsent } from "#/db/schema"; +import { createDbContext } from "#/db/server"; +import { type McpActor, McpAuthError } from "./mcp-auth"; + +export async function assertMcpConnectionAuthorized(actor: McpActor): Promise { + if (!actor.clientId) { + throw new McpAuthError(401, "invalid_token"); + } + + const dbContext = await createDbContext(); + + try { + const [consent] = await dbContext.db + .select({ id: oauthConsent.id }) + .from(oauthConsent) + .where(and(eq(oauthConsent.clientId, actor.clientId), eq(oauthConsent.userId, actor.userId))) + .limit(1); + + if (!consent) { + throw new McpAuthError(401, "invalid_token"); + } + } finally { + await dbContext.dispose(); + } +} diff --git a/src/features/workspaces/mcp/mcp-read-content-cap.test.ts b/src/features/workspaces/mcp/mcp-read-content-cap.test.ts new file mode 100644 index 00000000..508fe9cd --- /dev/null +++ b/src/features/workspaces/mcp/mcp-read-content-cap.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { + capMcpReadItemsContent, + MCP_READ_CONTENT_TRUNCATION_NOTICE, + MCP_READ_MAX_TOTAL_CONTENT_CHARS, +} from "#/features/workspaces/mcp/mcp-read-content-cap"; +import type { WorkspaceReadItemsResult } from "#/features/workspaces/operations/read-items"; + +function createReadResult( + items: WorkspaceReadItemsResult["items"], + failed: WorkspaceReadItemsResult["failed"] = [], +): WorkspaceReadItemsResult { + return { items, failed }; +} + +describe("capMcpReadItemsContent", () => { + it("returns items unchanged when total content is within budget", () => { + const result = createReadResult([ + { + path: "/doc-a", + status: "ready", + type: "document", + content: "hello", + }, + { + path: "/doc-b", + status: "ready", + type: "document", + content: "world", + }, + ]); + + expect(capMcpReadItemsContent(result, 100)).toEqual({ + items: [ + { + path: "/doc-a", + status: "ready", + type: "document", + content: "hello", + }, + { + path: "/doc-b", + status: "ready", + type: "document", + content: "world", + }, + ], + failed: [], + }); + }); + + it("truncates a single oversized item and sets truncated flags", () => { + const content = "x".repeat(50); + const result = createReadResult([ + { + path: "/large.pdf", + status: "ready", + type: "file", + content, + }, + ]); + + const capped = capMcpReadItemsContent(result, 20); + + expect(capped.truncated).toBe(true); + expect(capped.items[0]?.truncated).toBe(true); + expect(capped.items[0]?.content).toBe(`${"x".repeat(20)}${MCP_READ_CONTENT_TRUNCATION_NOTICE}`); + }); + + it("carries budget across items and empties later items when exhausted", () => { + const result = createReadResult([ + { + path: "/first", + status: "ready", + type: "document", + content: "aaaa", + }, + { + path: "/second", + status: "ready", + type: "document", + content: "bbbb", + }, + { + path: "/third", + status: "ready", + type: "document", + content: "cccc", + }, + ]); + + const capped = capMcpReadItemsContent(result, 6); + + expect(capped.truncated).toBe(true); + expect(capped.items[0]).toMatchObject({ + path: "/first", + content: "aaaa", + }); + expect(capped.items[0]?.truncated).toBeUndefined(); + expect(capped.items[1]).toMatchObject({ + path: "/second", + truncated: true, + }); + expect(capped.items[1]?.content).toBe(`bb${MCP_READ_CONTENT_TRUNCATION_NOTICE}`); + expect(capped.items[2]).toMatchObject({ + path: "/third", + content: "", + truncated: true, + }); + }); + + it("does not split a surrogate pair at the truncation boundary", () => { + // "ab" then a rocket emoji (astral char = one surrogate pair). Budget of 3 + // would cut between the high and low surrogate without the guard. + const content = `ab${"\u{1F680}"}cd`; + const result = createReadResult([ + { + path: "/emoji", + status: "ready", + type: "document", + content, + }, + ]); + + const capped = capMcpReadItemsContent(result, 3); + const cappedContent = capped.items[0]?.content; + + expect(capped.items[0]?.truncated).toBe(true); + expect(cappedContent).toBe(`ab${MCP_READ_CONTENT_TRUNCATION_NOTICE}`); + // No lone surrogate should remain in the kept text. + const keptText = cappedContent!.slice(0, -MCP_READ_CONTENT_TRUNCATION_NOTICE.length); + expect(keptText).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/); + }); + + it("leaves items without content untouched", () => { + const result = createReadResult([ + { + path: "/pending.png", + status: "pending", + type: "file", + }, + { + path: "/failed.png", + status: "failed", + type: "file", + }, + ]); + + const capped = capMcpReadItemsContent(result, 10); + + expect(capped.truncated).toBeUndefined(); + expect(capped.items).toEqual(result.items); + }); + + it("preserves failed entries and ordering", () => { + const result = createReadResult( + [ + { + path: "/doc", + status: "ready", + type: "document", + content: "ok", + }, + ], + [{ code: "path_not_found", index: 1, path: "/missing" }], + ); + + const capped = capMcpReadItemsContent(result, 100); + + expect(capped.failed).toEqual(result.failed); + expect(capped.items[0]?.path).toBe("/doc"); + }); + + it("uses MCP_READ_MAX_TOTAL_CONTENT_CHARS by default", () => { + const content = "a".repeat(MCP_READ_MAX_TOTAL_CONTENT_CHARS + 1); + const result = createReadResult([ + { + path: "/doc", + status: "ready", + type: "document", + content, + }, + ]); + + const capped = capMcpReadItemsContent(result); + const cappedContent = capped.items[0]?.content; + + expect(capped.truncated).toBe(true); + expect(cappedContent).toBeDefined(); + expect(cappedContent!.length).toBeGreaterThan(MCP_READ_MAX_TOTAL_CONTENT_CHARS); + expect(cappedContent!.length).toBeLessThanOrEqual( + MCP_READ_MAX_TOTAL_CONTENT_CHARS + MCP_READ_CONTENT_TRUNCATION_NOTICE.length, + ); + }); +}); diff --git a/src/features/workspaces/mcp/mcp-read-content-cap.ts b/src/features/workspaces/mcp/mcp-read-content-cap.ts new file mode 100644 index 00000000..b6cb3efa --- /dev/null +++ b/src/features/workspaces/mcp/mcp-read-content-cap.ts @@ -0,0 +1,77 @@ +import type { + WorkspaceReadItem, + WorkspaceReadItemsResult, +} from "#/features/workspaces/operations/read-items"; + +export const MCP_READ_MAX_TOTAL_CONTENT_CHARS = 100_000; + +export const MCP_READ_CONTENT_TRUNCATION_NOTICE = + "\n\n... (content truncated to fit MCP read budget)"; + +export interface McpReadItem extends WorkspaceReadItem { + truncated?: boolean; +} + +export interface McpReadItemsResult extends WorkspaceReadItemsResult { + items: McpReadItem[]; + truncated?: boolean; +} + +// Avoid slicing through a UTF-16 surrogate pair: if the last kept unit is a +// high surrogate, its low half sits just past the budget, so drop it to prevent +// emitting a lone (malformed) surrogate. +function safeTruncationLength(content: string, budget: number): number { + const lastUnit = content.charCodeAt(budget - 1); + + if (lastUnit >= 0xd800 && lastUnit <= 0xdbff) { + return budget - 1; + } + + return budget; +} + +export function capMcpReadItemsContent( + result: WorkspaceReadItemsResult, + maxTotalChars = MCP_READ_MAX_TOTAL_CONTENT_CHARS, +): McpReadItemsResult { + let remainingBudget = maxTotalChars; + let responseTruncated = false; + + const items = result.items.map((item) => { + if (item.content === undefined) { + return item; + } + + if (remainingBudget <= 0) { + responseTruncated = true; + return { + ...item, + content: "", + truncated: true, + }; + } + + if (item.content.length <= remainingBudget) { + remainingBudget -= item.content.length; + return item; + } + + responseTruncated = true; + const truncatedContent = + item.content.slice(0, safeTruncationLength(item.content, remainingBudget)) + + MCP_READ_CONTENT_TRUNCATION_NOTICE; + remainingBudget = 0; + + return { + ...item, + content: truncatedContent, + truncated: true, + }; + }); + + return { + ...result, + items, + ...(responseTruncated ? { truncated: true } : {}), + }; +} diff --git a/src/features/workspaces/mcp/mcp-route.test.ts b/src/features/workspaces/mcp/mcp-route.test.ts new file mode 100644 index 00000000..5f9effe8 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-route.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; + +const { createMcpHandler } = vi.hoisted(() => ({ + createMcpHandler: vi.fn(() => vi.fn().mockResolvedValue(new Response("ok"))), +})); + +vi.mock("agents/mcp", () => ({ + createMcpHandler, +})); + +vi.mock("#/features/workspaces/mcp/mcp-tools", () => ({ + registerMcpTools: vi.fn(), +})); + +vi.mock("#/features/workspaces/mcp/mcp-auth", () => ({ + McpAuthError: class McpAuthError extends Error { + constructor( + readonly status: 401 | 403, + readonly code: string, + ) { + super(code); + this.name = "McpAuthError"; + } + }, + verifyMcpBearerToken: vi.fn(), +})); + +vi.mock("#/features/workspaces/mcp/mcp-authorization", () => ({ + assertMcpConnectionAuthorized: vi.fn(), +})); + +vi.mock("#/lib/app-origin", () => ({ + getMcpProtectedResourceMetadataUrl: () => + "https://app.example.com/.well-known/oauth-protected-resource/mcp", +})); + +import { McpAuthError, verifyMcpBearerToken } from "#/features/workspaces/mcp/mcp-auth"; +import { assertMcpConnectionAuthorized } from "#/features/workspaces/mcp/mcp-authorization"; +import { routeMcpRequest } from "#/features/workspaces/mcp/mcp-route"; + +const actor = { + userId: "user-1", + clientId: "client-a", + grantedScopes: new Set(["workspace:read"]), +}; + +describe("routeMcpRequest", () => { + it("returns null for non-MCP paths", async () => { + const request = new Request("https://app.example.com/api/health"); + const response = await routeMcpRequest(request, {} as Env, {} as ExecutionContext); + + expect(response).toBeNull(); + }); + + it("returns 401 with WWW-Authenticate when Authorization header is missing", async () => { + vi.mocked(verifyMcpBearerToken).mockRejectedValueOnce(new McpAuthError(401, "missing_token")); + + const request = new Request("https://app.example.com/mcp"); + const response = await routeMcpRequest(request, {} as Env, {} as ExecutionContext); + + expect(response?.status).toBe(401); + expect(response?.headers.get("WWW-Authenticate")).toBe( + 'Bearer error="missing_token", resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/mcp"', + ); + expect(await response?.json()).toEqual({ error: "missing_token" }); + }); + + it("returns 401 with WWW-Authenticate when the connection is revoked", async () => { + vi.mocked(verifyMcpBearerToken).mockResolvedValueOnce(actor); + vi.mocked(assertMcpConnectionAuthorized).mockRejectedValueOnce( + new McpAuthError(401, "invalid_token"), + ); + + const request = new Request("https://app.example.com/mcp", { + headers: { Authorization: "Bearer revoked.token" }, + }); + const response = await routeMcpRequest(request, {} as Env, {} as ExecutionContext); + + expect(assertMcpConnectionAuthorized).toHaveBeenCalledWith(actor); + expect(response?.status).toBe(401); + expect(response?.headers.get("WWW-Authenticate")).toBe( + 'Bearer error="invalid_token", resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/mcp"', + ); + expect(await response?.json()).toEqual({ error: "invalid_token" }); + expect(createMcpHandler).not.toHaveBeenCalled(); + }); + + it("delegates to createMcpHandler when the connection is authorized", async () => { + vi.mocked(verifyMcpBearerToken).mockResolvedValueOnce(actor); + vi.mocked(assertMcpConnectionAuthorized).mockResolvedValueOnce(undefined); + + const request = new Request("https://app.example.com/mcp", { + headers: { Authorization: "Bearer valid.token" }, + }); + const response = await routeMcpRequest(request, {} as Env, {} as ExecutionContext); + + expect(assertMcpConnectionAuthorized).toHaveBeenCalledWith(actor); + expect(createMcpHandler).toHaveBeenCalledOnce(); + expect(response?.status).toBe(200); + }); +}); diff --git a/src/features/workspaces/mcp/mcp-route.ts b/src/features/workspaces/mcp/mcp-route.ts new file mode 100644 index 00000000..a00167de --- /dev/null +++ b/src/features/workspaces/mcp/mcp-route.ts @@ -0,0 +1,63 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpHandler } from "agents/mcp"; + +import { isMcpRequestPath } from "#/features/workspaces/agent-routes"; +import { getMcpProtectedResourceMetadataUrl } from "#/lib/app-origin"; +import { assertMcpConnectionAuthorized } from "./mcp-authorization"; +import { type McpActor, McpAuthError, verifyMcpBearerToken } from "./mcp-auth"; +import { registerMcpTools } from "./mcp-tools"; + +function buildMcpWwwAuthenticateHeader(errorCode: string): string { + const resourceMetadataUrl = getMcpProtectedResourceMetadataUrl(); + + return `Bearer error="${errorCode}", resource_metadata="${resourceMetadataUrl}"`; +} + +function buildMcpServer(actor: McpActor): McpServer { + const server = new McpServer({ name: "thinkex", version: "1.0.0" }); + registerMcpTools(server, actor); + return server; +} + +export async function routeMcpRequest( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + const { pathname } = new URL(request.url); + + if (!isMcpRequestPath(pathname)) { + return null; + } + + let actor: McpActor; + + try { + actor = await verifyMcpBearerToken(request); + await assertMcpConnectionAuthorized(actor); + } catch (error) { + if (error instanceof McpAuthError) { + return Response.json( + { error: error.code }, + { + status: error.status, + headers: { + "WWW-Authenticate": buildMcpWwwAuthenticateHeader(error.code), + }, + }, + ); + } + throw error; + } + + const handler = createMcpHandler(buildMcpServer(actor), { + authContext: { + props: { + userId: actor.userId, + clientId: actor.clientId, + scopes: [...actor.grantedScopes], + }, + }, + }); + return handler(request, env, ctx); +} diff --git a/src/features/workspaces/mcp/mcp-schemas.ts b/src/features/workspaces/mcp/mcp-schemas.ts new file mode 100644 index 00000000..39bd11ed --- /dev/null +++ b/src/features/workspaces/mcp/mcp-schemas.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { workspacePageRangeSchema } from "#/features/workspaces/operations/workspace-page-range-schema"; + +export const mcpListItemsInputSchema = { + workspaceId: z.string().min(1).describe("Workspace id to list items from."), + limit: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe("Maximum number of workspace items to return. Defaults to 100."), + path: z.string().min(1).optional().describe("Absolute path in the workspace. Defaults to /."), + recursive: z + .boolean() + .optional() + .describe("Include nested descendants. Defaults to false for immediate children only."), +} as const; + +export const mcpReadItemsInputSchema = { + workspaceId: z.string().min(1).describe("Workspace id to read items from."), + pages: workspacePageRangeSchema.optional(), + paths: z + .array(z.string().min(1)) + .min(1) + .max(20) + .describe("Absolute paths in the workspace to read."), +} as const; diff --git a/src/features/workspaces/mcp/mcp-scopes.ts b/src/features/workspaces/mcp/mcp-scopes.ts new file mode 100644 index 00000000..dade6424 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-scopes.ts @@ -0,0 +1,3 @@ +export const MCP_WORKSPACE_READ_SCOPE = "workspace:read"; + +export const MCP_SUPPORTED_SCOPES = [MCP_WORKSPACE_READ_SCOPE] as const; diff --git a/src/features/workspaces/mcp/mcp-tool-access.test.ts b/src/features/workspaces/mcp/mcp-tool-access.test.ts new file mode 100644 index 00000000..2acfc318 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-tool-access.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("#/lib/auth-queries.server", () => ({ + getSessionFromHeaders: vi.fn(), +})); + +import { AccessScopeError } from "#/features/workspaces/operations/access-context"; +import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; +import { + mcpListItemsAccessDeniedResult, + mcpListWorkspacesAccessDeniedResult, + mcpReadItemsAccessDeniedResult, + resolveMcpToolAccessFailureCode, +} from "#/features/workspaces/mcp/mcp-tool-access"; + +describe("resolveMcpToolAccessFailureCode", () => { + it("maps WorkspaceForbiddenError to workspace_forbidden", () => { + expect(resolveMcpToolAccessFailureCode(new WorkspaceForbiddenError())).toBe( + "workspace_forbidden", + ); + }); + + it("maps AccessScopeError to insufficient_scope", () => { + expect( + resolveMcpToolAccessFailureCode(new AccessScopeError("workspace", "workspace:read")), + ).toBe("insufficient_scope"); + }); +}); + +describe("mcp access denied result builders", () => { + it("builds list workspaces denial with the provided code", () => { + expect(mcpListWorkspacesAccessDeniedResult("insufficient_scope")).toEqual({ + workspaces: [], + failed: [{ code: "insufficient_scope" }], + }); + }); + + it("builds list items denial with path and code", () => { + expect(mcpListItemsAccessDeniedResult("/Course Notes", "workspace_forbidden")).toEqual({ + path: "/Course Notes", + more: false, + items: [], + failed: [{ code: "workspace_forbidden" }], + }); + }); + + it("builds read items denial with the provided code", () => { + expect(mcpReadItemsAccessDeniedResult("workspace_forbidden")).toEqual({ + items: [], + failed: [{ code: "workspace_forbidden" }], + }); + }); +}); diff --git a/src/features/workspaces/mcp/mcp-tool-access.ts b/src/features/workspaces/mcp/mcp-tool-access.ts new file mode 100644 index 00000000..05625c52 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-tool-access.ts @@ -0,0 +1,41 @@ +import { AccessScopeError } from "#/features/workspaces/operations/access-context"; +import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; + +export const mcpToolAccessFailureCodes = ["insufficient_scope", "workspace_forbidden"] as const; + +export type McpToolAccessFailureCode = (typeof mcpToolAccessFailureCodes)[number]; + +export function isMcpToolAccessError( + error: unknown, +): error is WorkspaceForbiddenError | AccessScopeError { + return error instanceof WorkspaceForbiddenError || error instanceof AccessScopeError; +} + +export function resolveMcpToolAccessFailureCode( + error: WorkspaceForbiddenError | AccessScopeError, +): McpToolAccessFailureCode { + return error instanceof WorkspaceForbiddenError ? "workspace_forbidden" : "insufficient_scope"; +} + +export function mcpListWorkspacesAccessDeniedResult(code: McpToolAccessFailureCode) { + return { + workspaces: [], + failed: [{ code }], + }; +} + +export function mcpListItemsAccessDeniedResult(path: string, code: McpToolAccessFailureCode) { + return { + path, + more: false, + items: [], + failed: [{ code }], + }; +} + +export function mcpReadItemsAccessDeniedResult(code: McpToolAccessFailureCode) { + return { + items: [], + failed: [{ code }], + }; +} diff --git a/src/features/workspaces/mcp/mcp-tools.test.ts b/src/features/workspaces/mcp/mcp-tools.test.ts new file mode 100644 index 00000000..c7f1bd43 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-tools.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + mcpListItemsInputSchema, + mcpReadItemsInputSchema, +} from "#/features/workspaces/mcp/mcp-schemas"; + +const listItemsSchema = z.object(mcpListItemsInputSchema); +const readItemsSchema = z.object(mcpReadItemsInputSchema); + +describe("thinkex_list_workspaces", () => { + it("accepts no input (no inputSchema registered)", () => { + // thinkex_list_workspaces has no inputSchema — no validation surface to test. + // It is exercised end-to-end via MCP Inspector integration tests. + expect(true).toBe(true); + }); +}); + +describe("thinkex_workspace_list_items input schema", () => { + it("rejects missing workspaceId", () => { + expect(() => listItemsSchema.parse({ path: "/" })).toThrow(); + }); + + it("rejects empty workspaceId", () => { + expect(() => listItemsSchema.parse({ workspaceId: "" })).toThrow(); + }); + + it("accepts valid minimal input", () => { + expect(listItemsSchema.parse({ workspaceId: "ws-1" })).toMatchObject({ + workspaceId: "ws-1", + }); + }); + + it("accepts all optional fields when valid", () => { + const result = listItemsSchema.parse({ + workspaceId: "ws-1", + path: "/Course Notes", + recursive: true, + limit: 50, + }); + expect(result).toMatchObject({ + workspaceId: "ws-1", + path: "/Course Notes", + recursive: true, + limit: 50, + }); + }); + + it("rejects limit below minimum (0)", () => { + expect(() => listItemsSchema.parse({ workspaceId: "ws-1", limit: 0 })).toThrow(); + }); + + it("rejects limit above maximum (201)", () => { + expect(() => listItemsSchema.parse({ workspaceId: "ws-1", limit: 201 })).toThrow(); + }); + + it("rejects non-integer limit", () => { + expect(() => listItemsSchema.parse({ workspaceId: "ws-1", limit: 1.5 })).toThrow(); + }); + + it("rejects empty path string", () => { + expect(() => listItemsSchema.parse({ workspaceId: "ws-1", path: "" })).toThrow(); + }); +}); + +describe("thinkex_workspace_read_items input schema", () => { + it("rejects missing workspaceId", () => { + expect(() => readItemsSchema.parse({ paths: ["/doc.md"] })).toThrow(); + }); + + it("rejects empty workspaceId", () => { + expect(() => readItemsSchema.parse({ workspaceId: "", paths: ["/doc.md"] })).toThrow(); + }); + + it("rejects empty paths array", () => { + expect(() => readItemsSchema.parse({ workspaceId: "ws-1", paths: [] })).toThrow(); + }); + + it("rejects paths array containing an empty string", () => { + expect(() => readItemsSchema.parse({ workspaceId: "ws-1", paths: [""] })).toThrow(); + }); + + it("rejects paths array exceeding max length of 20", () => { + const paths = Array.from({ length: 21 }, (_, i) => `/file${i}.md`); + expect(() => readItemsSchema.parse({ workspaceId: "ws-1", paths })).toThrow(); + }); + + it("accepts valid minimal input", () => { + expect(readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/doc.md"] })).toMatchObject({ + workspaceId: "ws-1", + paths: ["/doc.md"], + }); + }); + + it("accepts paths array at the max boundary (20 paths)", () => { + const paths = Array.from({ length: 20 }, (_, i) => `/file${i}.md`); + expect(() => readItemsSchema.parse({ workspaceId: "ws-1", paths })).not.toThrow(); + }); + + describe("pages field", () => { + it("accepts a single page number", () => { + expect(() => + readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"], pages: "1" }), + ).not.toThrow(); + }); + + it("accepts a page range", () => { + expect(() => + readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"], pages: "1-3" }), + ).not.toThrow(); + }); + + it("accepts a comma-separated list of pages and ranges", () => { + expect(() => + readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"], pages: "1,3,5-7" }), + ).not.toThrow(); + }); + + it("rejects an empty pages string", () => { + expect(() => + readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"], pages: "" }), + ).toThrow(); + }); + + it("rejects a non-numeric pages string", () => { + expect(() => + readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"], pages: "abc" }), + ).toThrow(); + }); + + it("rejects an open-ended range (e.g. '1-')", () => { + expect(() => + readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"], pages: "1-" }), + ).toThrow(); + }); + + it("rejects consecutive commas (e.g. '1,,3')", () => { + expect(() => + readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"], pages: "1,,3" }), + ).toThrow(); + }); + + it("omitting pages is valid (defaults to page 1 at read time)", () => { + expect(() => readItemsSchema.parse({ workspaceId: "ws-1", paths: ["/f.md"] })).not.toThrow(); + }); + }); +}); diff --git a/src/features/workspaces/mcp/mcp-tools.ts b/src/features/workspaces/mcp/mcp-tools.ts new file mode 100644 index 00000000..c6e820d5 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-tools.ts @@ -0,0 +1,145 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import type { ListWorkspaceKernelItemsResult } from "#/features/workspaces/kernel/workspace-kernel-list"; +import { listAccountWorkspacesOperation } from "#/features/workspaces/operations/list-workspaces"; +import { listWorkspaceItemsOperation } from "#/features/workspaces/operations/list-items"; +import { readWorkspaceItemsOperation } from "#/features/workspaces/operations/read-items"; +import { recordMcpToolCallFromActor } from "#/features/workspaces/mcp/mcp-audit"; +import { buildMcpAccountContext, buildMcpWorkspaceContext, type McpActor } from "./mcp-auth"; +import { capMcpReadItemsContent, type McpReadItemsResult } from "./mcp-read-content-cap"; +import { mcpListItemsInputSchema, mcpReadItemsInputSchema } from "./mcp-schemas"; +import { + isMcpToolAccessError, + mcpListItemsAccessDeniedResult, + mcpListWorkspacesAccessDeniedResult, + mcpReadItemsAccessDeniedResult, + resolveMcpToolAccessFailureCode, + type McpToolAccessFailureCode, +} from "./mcp-tool-access"; + +export { mcpListItemsInputSchema, mcpReadItemsInputSchema } from "./mcp-schemas"; + +function mcpTextResult(result: unknown) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result), + }, + ], + }; +} + +async function runMcpTool(input: { + actor: McpActor; + deniedResultForCode: (code: McpToolAccessFailureCode) => TResult; + run: () => Promise; + toolName: string; + workspaceId?: string; +}) { + try { + const result = await input.run(); + recordMcpToolCallFromActor(input.actor, { + resultStatus: "ok", + toolName: input.toolName, + workspaceId: input.workspaceId, + }); + return mcpTextResult(result); + } catch (error) { + if (isMcpToolAccessError(error)) { + recordMcpToolCallFromActor(input.actor, { + resultStatus: "denied", + toolName: input.toolName, + workspaceId: input.workspaceId, + }); + return mcpTextResult(input.deniedResultForCode(resolveMcpToolAccessFailureCode(error))); + } + + recordMcpToolCallFromActor(input.actor, { + resultStatus: "failed", + toolName: input.toolName, + workspaceId: input.workspaceId, + }); + throw error; + } +} + +export function registerMcpTools(server: McpServer, actor: McpActor): void { + server.registerTool( + "thinkex_list_workspaces", + { + description: "List workspaces accessible to the authenticated user.", + }, + async () => + runMcpTool({ + actor, + deniedResultForCode: mcpListWorkspacesAccessDeniedResult, + toolName: "thinkex_list_workspaces", + run: async () => { + const context = buildMcpAccountContext(actor); + const { workspaces } = await listAccountWorkspacesOperation(context); + + return { + workspaces: workspaces.map(({ id, name, description, membershipRole }) => ({ + id, + name, + description, + role: membershipRole, + })), + }; + }, + }), + ); + + server.registerTool( + "thinkex_workspace_list_items", + { + description: "List items in a ThinkEx workspace by absolute path.", + inputSchema: mcpListItemsInputSchema, + }, + async ({ workspaceId, limit, path, recursive }) => + runMcpTool({ + actor, + deniedResultForCode: (code) => + mcpListItemsAccessDeniedResult( + path ?? "/", + code, + ) as unknown as ListWorkspaceKernelItemsResult, + toolName: "thinkex_workspace_list_items", + workspaceId, + run: async () => { + const context = buildMcpWorkspaceContext(actor, workspaceId); + return listWorkspaceItemsOperation(context, { + path, + recursive, + limit, + }); + }, + }), + ); + + server.registerTool( + "thinkex_workspace_read_items", + { + description: "Read documents and files in a ThinkEx workspace by absolute path.", + inputSchema: mcpReadItemsInputSchema, + }, + async ({ workspaceId, pages, paths }) => + runMcpTool({ + actor, + deniedResultForCode: (code) => + mcpReadItemsAccessDeniedResult(code) as unknown as McpReadItemsResult, + toolName: "thinkex_workspace_read_items", + workspaceId, + run: async () => { + const context = buildMcpWorkspaceContext(actor, workspaceId); + const result = await readWorkspaceItemsOperation(context, { + pages, + paths, + }); + + return capMcpReadItemsContent(result); + }, + }), + ); +} diff --git a/src/features/workspaces/operations/workspace-page-range-schema.ts b/src/features/workspaces/operations/workspace-page-range-schema.ts new file mode 100644 index 00000000..82d16849 --- /dev/null +++ b/src/features/workspaces/operations/workspace-page-range-schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const workspaceReadPagesSchema = z.object({ + requested: z.string().describe("Requested page range."), + returned: z.array(z.number().int().min(1)).describe("Page numbers included in content."), + total: z.number().int().min(1).describe("Total pages available."), +}); + +export const workspacePageRangeSchema = z + .string() + .trim() + .min(1) + .regex(/^\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*$/) + .describe( + "1-based pages to read, like 1, 3, 5-7, or 1,4-6. For PDFs, pages are PDF pages. For Markdown-backed items, each page is 1000 Markdown lines. Defaults to 1.", + ); diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts index 3b5b93b3..676ac353 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -62,11 +62,11 @@ export const accountListWorkspacesOutputSchema = z.object({ workspaces: z.array(workspaceSummarySchema), }); -export const workspaceReadPagesSchema = z.object({ - requested: z.string().describe("Requested page range."), - returned: z.array(z.number().int().min(1)).describe("Page numbers included in content."), - total: z.number().int().min(1).describe("Total pages available."), -}); +import { + workspacePageRangeSchema, + workspaceReadPagesSchema, +} from "#/features/workspaces/operations/workspace-page-range-schema"; +export { workspacePageRangeSchema, workspaceReadPagesSchema }; const workspaceRelationInputSchema = z.object({ kind: workspaceRelationKindSchema.describe( @@ -81,15 +81,6 @@ const workspaceRelationInputSchema = z.object({ path: z.string().min(1).describe("Absolute path of the related ThinkEx workspace item."), }); -export const workspacePageRangeSchema = z - .string() - .trim() - .min(1) - .regex(/^\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*$/) - .describe( - "1-based pages to read, like 1, 3, 5-7, or 1,4-6. For PDFs, pages are PDF pages. For Markdown-backed items, each page is 1000 Markdown lines. Defaults to 1.", - ); - export const workspaceListItemsInputSchema = z.object({ limit: z .number() diff --git a/src/lib/app-origin.ts b/src/lib/app-origin.ts index 18cdfb4b..f72e94d6 100644 --- a/src/lib/app-origin.ts +++ b/src/lib/app-origin.ts @@ -91,3 +91,11 @@ export function getTrustedAppOrigins(appOrigin?: string) { export function buildInviteUrl(token: string, appOrigin = getAppOrigin()) { return `${appOrigin}${buildInvitePath(token)}`; } + +export function getMcpResourceUrl(appOrigin = getAppOrigin()) { + return `${appOrigin}/mcp`; +} + +export function getMcpProtectedResourceMetadataUrl(appOrigin = getAppOrigin()) { + return `${appOrigin}/.well-known/oauth-protected-resource/mcp`; +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index fe924cf6..9d39fe3d 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,8 +1,9 @@ import { createAuthClient } from "better-auth/react"; import { anonymousClient } from "better-auth/client/plugins"; +import { oauthProviderClient } from "@better-auth/oauth-provider/client"; export const authClient = createAuthClient({ - plugins: [anonymousClient()], + plugins: [anonymousClient(), oauthProviderClient()], sessionOptions: { refetchInterval: 0, refetchOnWindowFocus: false, diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index c40a77ca..7a865303 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -1,7 +1,8 @@ +import { oauthProvider } from "@better-auth/oauth-provider"; import { env as workerEnv } from "cloudflare:workers"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { betterAuth } from "better-auth/minimal"; -import { anonymous } from "better-auth/plugins"; +import { anonymous, jwt } from "better-auth/plugins"; import { tanstackStartCookies } from "better-auth/tanstack-start"; import { sql } from "drizzle-orm"; @@ -9,10 +10,16 @@ import { purgeUserAccountResources, transferLinkedAccountResources, } from "#/features/workspaces/durable-object-lifecycle"; +import { MCP_SUPPORTED_SCOPES } from "#/features/workspaces/mcp/mcp-scopes"; import * as schema from "#/db/schema"; import { createDbContext } from "#/db/server"; +import { + getAuthBaseURL, + getAppOrigin, + getMcpResourceUrl, + getTrustedAppOrigins, +} from "#/lib/app-origin"; import { capturePostHogServerEvent } from "#/integrations/posthog/server"; -import { getAuthBaseURL, getTrustedAppOrigins } from "#/lib/app-origin"; const isProduction = import.meta.env.PROD; @@ -156,6 +163,7 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { const baseURL = getAuthBaseURL(); return betterAuth({ + disabledPaths: ["/token"], database: drizzleAdapter(database, { provider: "sqlite", schema, @@ -201,6 +209,22 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { await transferLinkedAccountResources(transferInput); }, }), + jwt({ + disableSettingJwtHeader: true, + jwt: { + issuer: typeof baseURL === "string" ? baseURL : getAppOrigin(), + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/oauth/consent", + scopes: [...MCP_SUPPORTED_SCOPES], + clientRegistrationAllowedScopes: [...MCP_SUPPORTED_SCOPES], + clientRegistrationDefaultScopes: [...MCP_SUPPORTED_SCOPES], + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + validAudiences: [getAppOrigin(), getMcpResourceUrl()], + }), tanstackStartCookies(), ], databaseHooks: { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 849df741..b2a94e51 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,13 +17,18 @@ import { Route as CookiesRouteImport } from './routes/cookies' import { Route as AccountDeletedRouteImport } from './routes/account-deleted' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as IndexRouteImport } from './routes/index' +import { Route as OauthConsentRouteImport } from './routes/oauth/consent' import { Route as InviteTokenRouteImport } from './routes/invite.$token' import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings' import { Route as ProtectedHomeRouteImport } from './routes/_protected/home' +import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server' +import { Route as ProtectedSettingsIndexRouteImport } from './routes/_protected/settings/index' import { Route as ApiV1WorkspacesRouteImport } from './routes/api/v1/workspaces' import { Route as ApiPosthogSurveyFeedbackRouteImport } from './routes/api/posthog/survey-feedback' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ProtectedWorkspacesWorkspaceIdRouteImport } from './routes/_protected/workspaces.$workspaceId' +import { Route as ProtectedSettingsConnectionsRouteImport } from './routes/_protected/settings/connections' +import { Route as DotwellKnownOauthProtectedResourceMcpRouteImport } from './routes/[.]well-known/oauth-protected-resource/mcp' import { Route as ApiV1WorkspacesWorkspaceIdFileUploadRouteImport } from './routes/api/v1/workspaces.$workspaceId.file-upload' import { Route as ApiV1WorkspacesWorkspaceIdChatAttachmentNormalizationRouteImport } from './routes/api/v1/workspaces.$workspaceId.chat-attachment-normalization' import { Route as ApiV1WorkspacesWorkspaceIdFilesItemIdPreviewRouteImport } from './routes/api/v1/workspaces.$workspaceId.files.$itemId.preview' @@ -68,6 +73,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const OauthConsentRoute = OauthConsentRouteImport.update({ + id: '/oauth/consent', + path: '/oauth/consent', + getParentRoute: () => rootRouteImport, +} as any) const InviteTokenRoute = InviteTokenRouteImport.update({ id: '/invite/$token', path: '/invite/$token', @@ -83,6 +93,17 @@ const ProtectedHomeRoute = ProtectedHomeRouteImport.update({ path: '/home', getParentRoute: () => ProtectedRoute, } as any) +const DotwellKnownOauthAuthorizationServerRoute = + DotwellKnownOauthAuthorizationServerRouteImport.update({ + id: '/.well-known/oauth-authorization-server', + path: '/.well-known/oauth-authorization-server', + getParentRoute: () => rootRouteImport, + } as any) +const ProtectedSettingsIndexRoute = ProtectedSettingsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ProtectedSettingsRoute, +} as any) const ApiV1WorkspacesRoute = ApiV1WorkspacesRouteImport.update({ id: '/api/v1/workspaces', path: '/api/v1/workspaces', @@ -105,6 +126,18 @@ const ProtectedWorkspacesWorkspaceIdRoute = path: '/workspaces/$workspaceId', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedSettingsConnectionsRoute = + ProtectedSettingsConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => ProtectedSettingsRoute, + } as any) +const DotwellKnownOauthProtectedResourceMcpRoute = + DotwellKnownOauthProtectedResourceMcpRouteImport.update({ + id: '/.well-known/oauth-protected-resource/mcp', + path: '/.well-known/oauth-protected-resource/mcp', + getParentRoute: () => rootRouteImport, + } as any) const ApiV1WorkspacesWorkspaceIdFileUploadRoute = ApiV1WorkspacesWorkspaceIdFileUploadRouteImport.update({ id: '/$workspaceId/file-upload', @@ -138,13 +171,18 @@ export interface FileRoutesByFullPath { '/privacy': typeof PrivacyRoute '/signup': typeof SignupRoute '/terms': typeof TermsRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute '/home': typeof ProtectedHomeRoute - '/settings': typeof ProtectedSettingsRoute + '/settings': typeof ProtectedSettingsRouteWithChildren '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute + '/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute + '/settings/connections': typeof ProtectedSettingsConnectionsRoute '/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/settings/': typeof ProtectedSettingsIndexRoute '/api/v1/workspaces/$workspaceId/chat-attachment-normalization': typeof ApiV1WorkspacesWorkspaceIdChatAttachmentNormalizationRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -158,13 +196,17 @@ export interface FileRoutesByTo { '/privacy': typeof PrivacyRoute '/signup': typeof SignupRoute '/terms': typeof TermsRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute '/home': typeof ProtectedHomeRoute - '/settings': typeof ProtectedSettingsRoute '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute + '/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute + '/settings/connections': typeof ProtectedSettingsConnectionsRoute '/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/settings': typeof ProtectedSettingsIndexRoute '/api/v1/workspaces/$workspaceId/chat-attachment-normalization': typeof ApiV1WorkspacesWorkspaceIdChatAttachmentNormalizationRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -180,13 +222,18 @@ export interface FileRoutesById { '/privacy': typeof PrivacyRoute '/signup': typeof SignupRoute '/terms': typeof TermsRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute '/_protected/home': typeof ProtectedHomeRoute - '/_protected/settings': typeof ProtectedSettingsRoute + '/_protected/settings': typeof ProtectedSettingsRouteWithChildren '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute + '/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute + '/_protected/settings/connections': typeof ProtectedSettingsConnectionsRoute '/_protected/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/_protected/settings/': typeof ProtectedSettingsIndexRoute '/api/v1/workspaces/$workspaceId/chat-attachment-normalization': typeof ApiV1WorkspacesWorkspaceIdChatAttachmentNormalizationRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -202,13 +249,18 @@ export interface FileRouteTypes { | '/privacy' | '/signup' | '/terms' + | '/.well-known/oauth-authorization-server' | '/home' | '/settings' | '/invite/$token' + | '/oauth/consent' + | '/.well-known/oauth-protected-resource/mcp' + | '/settings/connections' | '/workspaces/$workspaceId' | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/settings/' | '/api/v1/workspaces/$workspaceId/chat-attachment-normalization' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -222,13 +274,17 @@ export interface FileRouteTypes { | '/privacy' | '/signup' | '/terms' + | '/.well-known/oauth-authorization-server' | '/home' - | '/settings' | '/invite/$token' + | '/oauth/consent' + | '/.well-known/oauth-protected-resource/mcp' + | '/settings/connections' | '/workspaces/$workspaceId' | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/settings' | '/api/v1/workspaces/$workspaceId/chat-attachment-normalization' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -243,13 +299,18 @@ export interface FileRouteTypes { | '/privacy' | '/signup' | '/terms' + | '/.well-known/oauth-authorization-server' | '/_protected/home' | '/_protected/settings' | '/invite/$token' + | '/oauth/consent' + | '/.well-known/oauth-protected-resource/mcp' + | '/_protected/settings/connections' | '/_protected/workspaces/$workspaceId' | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/_protected/settings/' | '/api/v1/workspaces/$workspaceId/chat-attachment-normalization' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -265,7 +326,10 @@ export interface RootRouteChildren { PrivacyRoute: typeof PrivacyRoute SignupRoute: typeof SignupRoute TermsRoute: typeof TermsRoute + DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute InviteTokenRoute: typeof InviteTokenRoute + OauthConsentRoute: typeof OauthConsentRoute + DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiPosthogSurveyFeedbackRoute: typeof ApiPosthogSurveyFeedbackRoute ApiV1WorkspacesRoute: typeof ApiV1WorkspacesRouteWithChildren @@ -329,6 +393,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/oauth/consent': { + id: '/oauth/consent' + path: '/oauth/consent' + fullPath: '/oauth/consent' + preLoaderRoute: typeof OauthConsentRouteImport + parentRoute: typeof rootRouteImport + } '/invite/$token': { id: '/invite/$token' path: '/invite/$token' @@ -350,6 +421,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedHomeRouteImport parentRoute: typeof ProtectedRoute } + '/.well-known/oauth-authorization-server': { + id: '/.well-known/oauth-authorization-server' + path: '/.well-known/oauth-authorization-server' + fullPath: '/.well-known/oauth-authorization-server' + preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport + parentRoute: typeof rootRouteImport + } + '/_protected/settings/': { + id: '/_protected/settings/' + path: '/' + fullPath: '/settings/' + preLoaderRoute: typeof ProtectedSettingsIndexRouteImport + parentRoute: typeof ProtectedSettingsRoute + } '/api/v1/workspaces': { id: '/api/v1/workspaces' path: '/api/v1/workspaces' @@ -378,6 +463,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedWorkspacesWorkspaceIdRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/settings/connections': { + id: '/_protected/settings/connections' + path: '/connections' + fullPath: '/settings/connections' + preLoaderRoute: typeof ProtectedSettingsConnectionsRouteImport + parentRoute: typeof ProtectedSettingsRoute + } + '/.well-known/oauth-protected-resource/mcp': { + id: '/.well-known/oauth-protected-resource/mcp' + path: '/.well-known/oauth-protected-resource/mcp' + fullPath: '/.well-known/oauth-protected-resource/mcp' + preLoaderRoute: typeof DotwellKnownOauthProtectedResourceMcpRouteImport + parentRoute: typeof rootRouteImport + } '/api/v1/workspaces/$workspaceId/file-upload': { id: '/api/v1/workspaces/$workspaceId/file-upload' path: '/$workspaceId/file-upload' @@ -409,15 +508,28 @@ declare module '@tanstack/react-router' { } } +interface ProtectedSettingsRouteChildren { + ProtectedSettingsConnectionsRoute: typeof ProtectedSettingsConnectionsRoute + ProtectedSettingsIndexRoute: typeof ProtectedSettingsIndexRoute +} + +const ProtectedSettingsRouteChildren: ProtectedSettingsRouteChildren = { + ProtectedSettingsConnectionsRoute: ProtectedSettingsConnectionsRoute, + ProtectedSettingsIndexRoute: ProtectedSettingsIndexRoute, +} + +const ProtectedSettingsRouteWithChildren = + ProtectedSettingsRoute._addFileChildren(ProtectedSettingsRouteChildren) + interface ProtectedRouteChildren { ProtectedHomeRoute: typeof ProtectedHomeRoute - ProtectedSettingsRoute: typeof ProtectedSettingsRoute + ProtectedSettingsRoute: typeof ProtectedSettingsRouteWithChildren ProtectedWorkspacesWorkspaceIdRoute: typeof ProtectedWorkspacesWorkspaceIdRoute } const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedHomeRoute: ProtectedHomeRoute, - ProtectedSettingsRoute: ProtectedSettingsRoute, + ProtectedSettingsRoute: ProtectedSettingsRouteWithChildren, ProtectedWorkspacesWorkspaceIdRoute: ProtectedWorkspacesWorkspaceIdRoute, } @@ -456,7 +568,12 @@ const rootRouteChildren: RootRouteChildren = { PrivacyRoute: PrivacyRoute, SignupRoute: SignupRoute, TermsRoute: TermsRoute, + DotwellKnownOauthAuthorizationServerRoute: + DotwellKnownOauthAuthorizationServerRoute, InviteTokenRoute: InviteTokenRoute, + OauthConsentRoute: OauthConsentRoute, + DotwellKnownOauthProtectedResourceMcpRoute: + DotwellKnownOauthProtectedResourceMcpRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiPosthogSurveyFeedbackRoute: ApiPosthogSurveyFeedbackRoute, ApiV1WorkspacesRoute: ApiV1WorkspacesRouteWithChildren, diff --git a/src/routes/[.]well-known/oauth-authorization-server.ts b/src/routes/[.]well-known/oauth-authorization-server.ts new file mode 100644 index 00000000..6dbe05c7 --- /dev/null +++ b/src/routes/[.]well-known/oauth-authorization-server.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { withAuth } from "#/lib/auth.server"; + +export const Route = createFileRoute("/.well-known/oauth-authorization-server")({ + server: { + handlers: { + GET: async ({ request }) => { + const response = await withAuth((auth) => auth.handler(request)); + + // Match the protected-resource metadata route so browser-based + // discovery clients can read this cross-origin. + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", "*"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }, + }, + }, +}); diff --git a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts new file mode 100644 index 00000000..918ae2b1 --- /dev/null +++ b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts @@ -0,0 +1,29 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { MCP_SUPPORTED_SCOPES } from "#/features/workspaces/mcp/mcp-scopes"; +import { getAppOrigin, getMcpResourceUrl } from "#/lib/app-origin"; + +export const Route = createFileRoute("/.well-known/oauth-protected-resource/mcp")({ + server: { + handlers: { + GET: () => { + const appOrigin = getAppOrigin(); + + return Response.json( + { + resource: getMcpResourceUrl(appOrigin), + authorization_servers: [appOrigin], + scopes_supported: [...MCP_SUPPORTED_SCOPES], + bearer_methods_supported: ["header"], + }, + { + headers: { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + }, + }, + ); + }, + }, + }, +}); diff --git a/src/routes/_protected/settings.tsx b/src/routes/_protected/settings.tsx index 8677aa56..a71dd0c1 100644 --- a/src/routes/_protected/settings.tsx +++ b/src/routes/_protected/settings.tsx @@ -1,14 +1,9 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { SettingsPage } from "#/features/account/components/SettingsPage"; +import { createFileRoute, Outlet } from "@tanstack/react-router"; export const Route = createFileRoute("/_protected/settings")({ - head: () => ({ - meta: [ - { - title: "ThinkEx | Settings", - }, - ], - }), - component: SettingsPage, + component: SettingsLayout, }); + +function SettingsLayout() { + return ; +} diff --git a/src/routes/_protected/settings/connections.tsx b/src/routes/_protected/settings/connections.tsx new file mode 100644 index 00000000..f705d43f --- /dev/null +++ b/src/routes/_protected/settings/connections.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConnectionsSettingsPage } from "#/features/account/connections/ConnectionsSettingsPage"; +import { getMcpServerUrlFn } from "#/features/account/connections/mcp-setup.functions"; + +export const Route = createFileRoute("/_protected/settings/connections")({ + loader: async () => ({ + mcpServerUrl: await getMcpServerUrlFn(), + }), + head: () => ({ + meta: [ + { + title: "ThinkEx | Connections", + }, + ], + }), + component: ConnectionsRoutePage, +}); + +function ConnectionsRoutePage() { + const { mcpServerUrl } = Route.useLoaderData(); + + return ; +} diff --git a/src/routes/_protected/settings/index.tsx b/src/routes/_protected/settings/index.tsx new file mode 100644 index 00000000..f27f5062 --- /dev/null +++ b/src/routes/_protected/settings/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SettingsPage } from "#/features/account/components/SettingsPage"; + +export const Route = createFileRoute("/_protected/settings/")({ + head: () => ({ + meta: [ + { + title: "ThinkEx | Settings", + }, + ], + }), + component: SettingsPage, +}); diff --git a/src/routes/oauth/consent.tsx b/src/routes/oauth/consent.tsx new file mode 100644 index 00000000..95504d1a --- /dev/null +++ b/src/routes/oauth/consent.tsx @@ -0,0 +1,221 @@ +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; + +import ThinkExLogo from "#/components/ThinkExLogo"; +import { Button } from "#/components/ui/button"; +import { Skeleton } from "#/components/ui/skeleton"; +import { + formatOAuthScopes, + parseOAuthScopeParam, +} from "#/features/account/connections/oauth-scope-labels"; +import { getOAuthClientPublic, submitOAuthConsent } from "#/features/account/connections/oauth-api"; +import { getErrorMessage } from "#/lib/error-message"; +import { buildPublicMeta } from "#/lib/seo"; + +const oauthConsentSearchSchema = z.object({ + client_id: z.string().optional(), + scope: z.string().optional(), + redirect_uri: z.string().optional(), + state: z.string().optional(), + response_type: z.string().optional(), + code_challenge: z.string().optional(), + code_challenge_method: z.string().optional(), +}); + +export const Route = createFileRoute("/oauth/consent")({ + validateSearch: oauthConsentSearchSchema, + head: () => ({ + meta: buildPublicMeta({ + title: "Authorize Application", + description: "Review and approve access for a connected application.", + }), + }), + component: OAuthConsentPage, +}); + +function OAuthConsentPage() { + const search = Route.useSearch(); + const navigate = useNavigate(); + const [pendingAction, setPendingAction] = useState<"allow" | "deny" | null>(null); + const [completedAction, setCompletedAction] = useState<"allow" | "deny" | null>(null); + const requestedScopes = parseOAuthScopeParam(search.scope); + const scopeLabels = formatOAuthScopes(requestedScopes); + + const { + data: client, + isLoading: isClientLoading, + isError: isClientError, + } = useQuery({ + queryKey: ["oauth-client-public", search.client_id], + enabled: Boolean(search.client_id), + queryFn: () => getOAuthClientPublic(search.client_id!), + }); + + const handleConsent = async (accept: boolean) => { + setPendingAction(accept ? "allow" : "deny"); + + try { + const result = await submitOAuthConsent({ + accept, + scope: search.scope, + }); + + if (result.url) { + setCompletedAction(accept ? "allow" : "deny"); + try { + window.location.assign(result.url); + return; + } catch (error) { + setCompletedAction(null); + toast.error( + getErrorMessage(error, "Unable to redirect after authorization. Please try again."), + ); + } + } + + toast.error("Authorization completed, but no redirect was returned."); + } catch (error) { + toast.error(getErrorMessage(error, "Unable to complete authorization right now.")); + } finally { + setPendingAction(null); + } + }; + + const clientName = client?.client_name ?? "Unknown application"; + const isPending = pendingAction !== null; + + return ( +
+
+ + ThinkEx +
+ +
+

Authorize application

+

+ Review the access requested by this application before continuing. +

+
+ + {!search.client_id ? ( +
+

+ This authorization request is missing a client identifier. +

+ +
+ ) : null} + +
+
+

+ Application +

+ {isClientLoading ? : null} + {isClientError ? ( +

Unable to load application details.

+ ) : null} + {!isClientLoading && !isClientError ? ( +
+ {client?.logo_uri ? ( + + ) : null} +
+

{clientName}

+ {client?.client_uri ? ( +

{client.client_uri}

+ ) : null} +
+
+ ) : null} +
+ + {completedAction ? ( +
+

+ {completedAction === "allow" + ? `Authorization complete. Return to ${clientName} to continue. You can close this tab.` + : "Access denied. You can close this tab."} +

+ +
+ ) : ( +
+

+ Requested access +

+ {scopeLabels.length > 0 ? ( +
    + {scopeLabels.map((label) => ( +
  • + {label} +
  • + ))} +
+ ) : ( +

No scopes were requested.

+ )} +
+ )} +
+ + {completedAction ? null : ( + <> +

+ Allowing access lets this application read your ThinkEx workspaces on your behalf. You + can revoke access anytime from Settings → Connections. +

+ +
+ + +
+ + )} +
+ ); +} diff --git a/src/server.ts b/src/server.ts index 844b1dd5..ccdabdde 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,6 +3,7 @@ import handler from "@tanstack/react-start/server-entry"; import { routeUserAIRequest } from "#/features/workspaces/ai/auth"; import { routeDocumentSessionRequest } from "#/features/workspaces/documents/document-session-auth"; import { routeWorkspaceKernelRequest } from "#/features/workspaces/kernel/workspace-kernel-auth"; +import { routeMcpRequest } from "#/features/workspaces/mcp/mcp-route"; import { posthogHost, posthogHostOrigin, posthogProjectToken } from "#/integrations/posthog/config"; import { capturePostHogServerException } from "#/integrations/posthog/server"; @@ -108,8 +109,14 @@ function withSecurityHeaders(response: Response) { } export default { - async fetch(request, env) { + async fetch(request, env, ctx) { try { + const mcpResponse = await routeMcpRequest(request, env, ctx); + + if (mcpResponse) { + return mcpResponse; + } + const chatResponse = await routeUserAIRequest(request, env); if (chatResponse) {