From 91bc0b345a9333b61242cdb501f648a8e287bec2 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 00:40:27 -0400 Subject: [PATCH 01/29] feat(oauth): add Better Auth OAuth provider for MCP access Co-authored-by: Cursor --- .gitignore | 1 + drizzle/0002_mcp_oauth.sql | 101 ++ drizzle/meta/0002_snapshot.json | 1478 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- package.json | 1 + pnpm-lock.yaml | 156 +- pnpm-workspace.yaml | 2 + src/db/schema.ts | 178 +- src/lib/auth.server.ts | 21 +- src/routeTree.gen.ts | 44 + .../oauth-authorization-server.ts | 11 + src/routes/oauth/consent.tsx | 44 + 12 files changed, 2012 insertions(+), 34 deletions(-) create mode 100644 drizzle/0002_mcp_oauth.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/routes/[.]well-known/oauth-authorization-server.ts create mode 100644 src/routes/oauth/consent.tsx 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/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 6025b14d..9f86660b 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.19", "@cf-wasm/photon": "^0.3.6", "@cloudflare/codemode": "^0.4.2", "@cloudflare/containers": "^0.3.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3475cbd..f6fecda1 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.19 + version: 1.6.23(c27d70b3b99c8f34eeec41b5ea3935de) '@cf-wasm/photon': specifier: ^0.3.6 version: 0.3.6 @@ -215,10 +218,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.19(@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)) + version: 1.6.19(@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 +233,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 +309,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(@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))) + version: 0.16.18(@cloudflare/workers-types@4.20260627.1)(@vitest/runner@4.1.9)(@vitest/snapshot@4.1.9)(vitest@4.1.9) '@cloudflare/workers-types': specifier: ^4.20260627.1 version: 4.20260627.1 @@ -792,6 +795,15 @@ packages: mongodb: optional: true + '@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.19': resolution: {integrity: sha512-pXZBhR7/bzJb48IUHlGMyz9SM9h1OCO5GIIuHEllJYt8MKgrjtsnXfUkwZh6pAUEIp3WxBEYMMK96bfkqHiWEg==} peerDependencies: @@ -3344,6 +3356,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} @@ -5027,9 +5048,24 @@ packages: zod: optional: true + 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==} @@ -6050,6 +6086,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'} @@ -9178,38 +9217,65 @@ 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/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.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.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/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.7(zod@4.4.3) + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 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) + '@cloudflare/workers-types': 4.20260627.1 + '@opentelemetry/api': 1.9.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/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.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.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.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)(@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.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.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.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.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.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.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/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.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.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.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.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(c27d70b3b99c8f34eeec41b5ea3935de)': 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.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.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.19(@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/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/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.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.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.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.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.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 @@ -9323,7 +9389,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(@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/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)': dependencies: '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -11078,6 +11144,9 @@ snapshots: '@preact/signals-core@1.14.2': {} + '@prisma/client@5.22.0': + optional: true + '@rolldown/binding-android-arm64@1.1.2': optional: true @@ -12586,14 +12655,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.19(@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.19(@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 @@ -12634,15 +12703,15 @@ 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.19(@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/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.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.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.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.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.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.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.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.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.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.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 @@ -12654,9 +12723,11 @@ snapshots: 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) @@ -12677,11 +12748,31 @@ snapshots: optionalDependencies: 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 + rou3: 0.7.12 + set-cookie-parser: 3.1.1 + 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 @@ -13309,11 +13400,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 @@ -13744,6 +13837,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/lib/auth.server.ts b/src/lib/auth.server.ts index c9f5080c..2b9815b2 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -1,8 +1,9 @@ +import { oauthProvider } from "@better-auth/oauth-provider"; import { env as workerEnv } from "cloudflare:workers"; import { APIError } from "better-auth/api"; 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"; @@ -13,7 +14,7 @@ import { import { sendDeleteAccountVerificationEmail } from "#/features/account/account-deletion-email"; import * as schema from "#/db/schema"; import { createDbContext } from "#/db/server"; -import { getAuthBaseURL, getTrustedAppOrigins } from "#/lib/app-origin"; +import { getAuthBaseURL, getAppOrigin, getTrustedAppOrigins } from "#/lib/app-origin"; const isProduction = import.meta.env.PROD; @@ -157,6 +158,7 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { const baseURL = getAuthBaseURL(); return betterAuth({ + disabledPaths: ["/token"], database: drizzleAdapter(database, { provider: "sqlite", schema, @@ -202,6 +204,21 @@ 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: ["workspace:read"], + clientRegistrationAllowedScopes: ["workspace:read"], + clientRegistrationDefaultScopes: ["workspace:read"], + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + }), tanstackStartCookies(), ], user: { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 849df741..2d511d39 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -17,9 +17,11 @@ 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 ApiV1WorkspacesRouteImport } from './routes/api/v1/workspaces' import { Route as ApiPosthogSurveyFeedbackRouteImport } from './routes/api/posthog/survey-feedback' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -68,6 +70,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 +90,12 @@ 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 ApiV1WorkspacesRoute = ApiV1WorkspacesRouteImport.update({ id: '/api/v1/workspaces', path: '/api/v1/workspaces', @@ -138,9 +151,11 @@ 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 '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute '/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute @@ -158,9 +173,11 @@ 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 '/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute @@ -180,9 +197,11 @@ 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 '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute '/_protected/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute @@ -202,9 +221,11 @@ export interface FileRouteTypes { | '/privacy' | '/signup' | '/terms' + | '/.well-known/oauth-authorization-server' | '/home' | '/settings' | '/invite/$token' + | '/oauth/consent' | '/workspaces/$workspaceId' | '/api/auth/$' | '/api/posthog/survey-feedback' @@ -222,9 +243,11 @@ export interface FileRouteTypes { | '/privacy' | '/signup' | '/terms' + | '/.well-known/oauth-authorization-server' | '/home' | '/settings' | '/invite/$token' + | '/oauth/consent' | '/workspaces/$workspaceId' | '/api/auth/$' | '/api/posthog/survey-feedback' @@ -243,9 +266,11 @@ export interface FileRouteTypes { | '/privacy' | '/signup' | '/terms' + | '/.well-known/oauth-authorization-server' | '/_protected/home' | '/_protected/settings' | '/invite/$token' + | '/oauth/consent' | '/_protected/workspaces/$workspaceId' | '/api/auth/$' | '/api/posthog/survey-feedback' @@ -265,7 +290,9 @@ export interface RootRouteChildren { PrivacyRoute: typeof PrivacyRoute SignupRoute: typeof SignupRoute TermsRoute: typeof TermsRoute + DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute InviteTokenRoute: typeof InviteTokenRoute + OauthConsentRoute: typeof OauthConsentRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiPosthogSurveyFeedbackRoute: typeof ApiPosthogSurveyFeedbackRoute ApiV1WorkspacesRoute: typeof ApiV1WorkspacesRouteWithChildren @@ -329,6 +356,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 +384,13 @@ 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 + } '/api/v1/workspaces': { id: '/api/v1/workspaces' path: '/api/v1/workspaces' @@ -456,7 +497,10 @@ const rootRouteChildren: RootRouteChildren = { PrivacyRoute: PrivacyRoute, SignupRoute: SignupRoute, TermsRoute: TermsRoute, + DotwellKnownOauthAuthorizationServerRoute: + DotwellKnownOauthAuthorizationServerRoute, InviteTokenRoute: InviteTokenRoute, + OauthConsentRoute: OauthConsentRoute, 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..13dc2d4a --- /dev/null +++ b/src/routes/[.]well-known/oauth-authorization-server.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { withAuth } from "#/lib/auth.server"; + +export const Route = createFileRoute("/.well-known/oauth-authorization-server")({ + server: { + handlers: { + GET: ({ request }) => withAuth((auth) => auth.handler(request)), + }, + }, +}); diff --git a/src/routes/oauth/consent.tsx b/src/routes/oauth/consent.tsx new file mode 100644 index 00000000..65765cdc --- /dev/null +++ b/src/routes/oauth/consent.tsx @@ -0,0 +1,44 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +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(); + + return ( +
+
+

Authorize application

+

+ OAuth consent UI is not implemented yet. This stub route exists so the OAuth provider can + redirect here during development. +

+
+
+				{JSON.stringify(search, null, 2)}
+			
+
+ ); +} From f939ff453d72e13395bacd9373095479d7c98fe2 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 01:06:42 -0400 Subject: [PATCH 02/29] refactor(workspaces): implemented MCP token verification --- src/features/workspaces/mcp/mcp-auth.ts | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/features/workspaces/mcp/mcp-auth.ts diff --git a/src/features/workspaces/mcp/mcp-auth.ts b/src/features/workspaces/mcp/mcp-auth.ts new file mode 100644 index 00000000..5df7564b --- /dev/null +++ b/src/features/workspaces/mcp/mcp-auth.ts @@ -0,0 +1,139 @@ +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 { getAuthBaseURL, getAppOrigin } 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"); + + if (!authHeader?.startsWith("Bearer ")) { + throw new McpAuthError(401, "missing_token"); + } + + const token = authHeader.slice("Bearer ".length).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`, + verifyOptions: { + issuer, + audience: getAppOrigin(), + }, + }); + } catch { + 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("workspace:read")) { + 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("workspace:read")) { + scopes.push("workspace:read"); + } + + return createWorkspaceAccessContext({ + userId: actor.userId, + workspaceId, + scopes, + }); +} From c601e20755c88580e574808e5c4a90a9ec258775 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 01:22:18 -0400 Subject: [PATCH 03/29] feat(mcp): integrate MCP request handling and update dependencies --- package.json | 1 + pnpm-lock.yaml | 11 +++--- src/features/workspaces/agent-routes.ts | 5 +++ src/features/workspaces/mcp/mcp-route.ts | 45 ++++++++++++++++++++++++ src/server.ts | 9 ++++- 5 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 src/features/workspaces/mcp/mcp-route.ts diff --git a/package.json b/package.json index 9f86660b..78e76e07 100644 --- a/package.json +++ b/package.json @@ -69,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6fecda1..0b7ad432 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,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) @@ -5973,10 +5976,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'} @@ -8763,7 +8762,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)': @@ -13677,8 +13676,6 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.8: {} - eventsource-parser@3.1.0: {} eventsource@3.0.7: 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-route.ts b/src/features/workspaces/mcp/mcp-route.ts new file mode 100644 index 00000000..1d2b9637 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-route.ts @@ -0,0 +1,45 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { createMcpHandler } from "agents/mcp"; + +import { isMcpRequestPath } from "#/features/workspaces/agent-routes"; +import { type McpActor, McpAuthError, verifyMcpBearerToken } from "./mcp-auth"; + +function buildMcpServer(): McpServer { + const server = new McpServer({ name: "thinkex", version: "1.0.0" }); + // Tool registrations added in Subproblem 4 + 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); + } catch (error) { + if (error instanceof McpAuthError) { + return Response.json({ error: error.code }, { status: error.status }); + } + throw error; + } + + const handler = createMcpHandler(buildMcpServer(), { + authContext: { + props: { + userId: actor.userId, + clientId: actor.clientId, + scopes: [...actor.grantedScopes], + }, + }, + }); + return handler(request, env, ctx); +} 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) { From 196867095452cc1ccef8fe336e5286b360e56724 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 01:35:42 -0400 Subject: [PATCH 04/29] feat(mcp): enhance MCP server setup with actor integration and tool reg --- src/features/workspaces/mcp/mcp-route.ts | 7 +- src/features/workspaces/mcp/mcp-tools.ts | 103 +++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 src/features/workspaces/mcp/mcp-tools.ts diff --git a/src/features/workspaces/mcp/mcp-route.ts b/src/features/workspaces/mcp/mcp-route.ts index 1d2b9637..cf227cd6 100644 --- a/src/features/workspaces/mcp/mcp-route.ts +++ b/src/features/workspaces/mcp/mcp-route.ts @@ -3,10 +3,11 @@ import { createMcpHandler } from "agents/mcp"; import { isMcpRequestPath } from "#/features/workspaces/agent-routes"; import { type McpActor, McpAuthError, verifyMcpBearerToken } from "./mcp-auth"; +import { registerMcpTools } from "./mcp-tools"; -function buildMcpServer(): McpServer { +function buildMcpServer(actor: McpActor): McpServer { const server = new McpServer({ name: "thinkex", version: "1.0.0" }); - // Tool registrations added in Subproblem 4 + registerMcpTools(server, actor); return server; } @@ -32,7 +33,7 @@ export async function routeMcpRequest( throw error; } - const handler = createMcpHandler(buildMcpServer(), { + const handler = createMcpHandler(buildMcpServer(actor), { authContext: { props: { userId: actor.userId, diff --git a/src/features/workspaces/mcp/mcp-tools.ts b/src/features/workspaces/mcp/mcp-tools.ts new file mode 100644 index 00000000..84bcb3f4 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-tools.ts @@ -0,0 +1,103 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +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 { workspacePageRangeSchema } from "#/features/workspaces/operations/workspace-tool-schemas"; +import { buildMcpAccountContext, buildMcpWorkspaceContext, type McpActor } from "./mcp-auth"; + +function mcpTextResult(result: unknown) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(result), + }, + ], + }; +} + +export function registerMcpTools(server: McpServer, actor: McpActor): void { + server.registerTool( + "thinkex_list_workspaces", + { + description: "List workspaces accessible to the authenticated user.", + }, + async () => { + const context = buildMcpAccountContext(actor); + const { workspaces } = await listAccountWorkspacesOperation(context); + + return mcpTextResult({ + 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: { + 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."), + }, + }, + async ({ workspaceId, limit, path, recursive }) => { + const context = buildMcpWorkspaceContext(actor, workspaceId); + const result = await listWorkspaceItemsOperation(context, { + path, + recursive, + limit, + }); + + return mcpTextResult(result); + }, + ); + + server.registerTool( + "thinkex_workspace_read_items", + { + description: + "Read ThinkEx documents and files by absolute path. Use pages for continuation: PDF pages for PDFs, 1000-line Markdown pages for documents and extracted files.", + inputSchema: { + 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."), + }, + }, + async ({ workspaceId, pages, paths }) => { + const context = buildMcpWorkspaceContext(actor, workspaceId); + const result = await readWorkspaceItemsOperation(context, { + pages, + paths, + }); + + return mcpTextResult(result); + }, + ); +} From 282e1eac4b326c239b96b62da7fb91de06100be4 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 02:05:29 -0400 Subject: [PATCH 05/29] feat(ui): add MCP connections settings UI and OAuth consent screen --- .../account/components/SettingsNav.tsx | 30 ++ .../account/components/SettingsPage.tsx | 3 + .../connections/ConnectionsSettingsPage.tsx | 367 ++++++++++++++++++ src/features/account/connections/mcp-setup.ts | 53 +++ src/features/account/connections/oauth-api.ts | 135 +++++++ .../account/connections/oauth-scope-labels.ts | 15 + src/lib/auth-client.ts | 3 +- src/routeTree.gen.ts | 62 ++- src/routes/_protected/settings.tsx | 17 +- .../_protected/settings/connections.tsx | 14 + src/routes/_protected/settings/index.tsx | 14 + src/routes/oauth/consent.tsx | 145 ++++++- 12 files changed, 834 insertions(+), 24 deletions(-) create mode 100644 src/features/account/components/SettingsNav.tsx create mode 100644 src/features/account/connections/ConnectionsSettingsPage.tsx create mode 100644 src/features/account/connections/mcp-setup.ts create mode 100644 src/features/account/connections/oauth-api.ts create mode 100644 src/features/account/connections/oauth-scope-labels.ts create mode 100644 src/routes/_protected/settings/connections.tsx create mode 100644 src/routes/_protected/settings/index.tsx 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..41e9eeff --- /dev/null +++ b/src/features/account/connections/ConnectionsSettingsPage.tsx @@ -0,0 +1,367 @@ +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 { + deleteOAuthConsent, + 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() { + const mcpServerUrl = getMcpServerUrl(); + const { copied, copy } = useCopyToClipboard({ + onError: () => toast.error("Could not copy MCP server URL"), + }); + + return ( + + MCP server URL + + + + + + + + ); +} + +function EditorSetupInstructions() { + const mcpServerUrl = getMcpServerUrl(); + 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 [revokingConsentId, setRevokingConsentId] = useState(null); + + 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: deleteOAuthConsent, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: oauthConsentsQueryKey }); + toast.success("Access revoked"); + }, + onError: (mutationError) => { + toast.error(getErrorMessage(mutationError, "Unable to revoke access right now.")); + }, + onSettled: () => { + setRevokingConsentId(null); + }, + }); + + const handleRevoke = (consentId: string) => { + setRevokingConsentId(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() { + 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.ts b/src/features/account/connections/mcp-setup.ts new file mode 100644 index 00000000..4578a873 --- /dev/null +++ b/src/features/account/connections/mcp-setup.ts @@ -0,0 +1,53 @@ +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(): string { + if (typeof window === "undefined") { + return "https://app.thinkex.app/mcp"; + } + + return `${window.location.origin}/mcp`; +} diff --git a/src/features/account/connections/oauth-api.ts b/src/features/account/connections/oauth-api.ts new file mode 100644 index 00000000..5ae4ba68 --- /dev/null +++ b/src/features/account/connections/oauth-api.ts @@ -0,0 +1,135 @@ +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 payload = (await response.json()) as T & { message?: string }; + + if (!response.ok) { + throw new Error( + typeof payload === "object" && payload && "message" in payload && payload.message + ? payload.message + : "Request failed", + ); + } + + return payload; +} + +async function authGet(path: string, query?: Record): Promise { + const url = new URL(path, window.location.origin); + + if (query) { + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + } + + const response = await fetch(url, { + credentials: "include", + }); + + return readAuthJson(response); +} + +async function authPost(path: string, body: Record): Promise { + const response = await fetch(new URL(path, window.location.origin), { + 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 deleteOAuthConsent(consentId: string): Promise { + await authPost("/api/auth/oauth2/delete-consent", { + id: consentId, + }); +} + +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-scope-labels.ts b/src/features/account/connections/oauth-scope-labels.ts new file mode 100644 index 00000000..24707233 --- /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 scope.split(/\s+/).filter(Boolean); +} 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/routeTree.gen.ts b/src/routeTree.gen.ts index 2d511d39..4bb2bc86 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -22,10 +22,12 @@ 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 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' @@ -96,6 +98,11 @@ const DotwellKnownOauthAuthorizationServerRoute = 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', @@ -118,6 +125,12 @@ const ProtectedWorkspacesWorkspaceIdRoute = path: '/workspaces/$workspaceId', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedSettingsConnectionsRoute = + ProtectedSettingsConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => ProtectedSettingsRoute, + } as any) const ApiV1WorkspacesWorkspaceIdFileUploadRoute = ApiV1WorkspacesWorkspaceIdFileUploadRouteImport.update({ id: '/$workspaceId/file-upload', @@ -153,13 +166,15 @@ export interface FileRoutesByFullPath { '/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 + '/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 @@ -175,13 +190,14 @@ export interface FileRoutesByTo { '/terms': typeof TermsRoute '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute '/home': typeof ProtectedHomeRoute - '/settings': typeof ProtectedSettingsRoute '/invite/$token': typeof InviteTokenRoute '/oauth/consent': typeof OauthConsentRoute + '/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 @@ -199,13 +215,15 @@ export interface FileRoutesById { '/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 + '/_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 @@ -226,10 +244,12 @@ export interface FileRouteTypes { | '/settings' | '/invite/$token' | '/oauth/consent' + | '/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' @@ -245,13 +265,14 @@ export interface FileRouteTypes { | '/terms' | '/.well-known/oauth-authorization-server' | '/home' - | '/settings' | '/invite/$token' | '/oauth/consent' + | '/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' @@ -271,10 +292,12 @@ export interface FileRouteTypes { | '/_protected/settings' | '/invite/$token' | '/oauth/consent' + | '/_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' @@ -391,6 +414,13 @@ declare module '@tanstack/react-router' { 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' @@ -419,6 +449,13 @@ 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 + } '/api/v1/workspaces/$workspaceId/file-upload': { id: '/api/v1/workspaces/$workspaceId/file-upload' path: '/$workspaceId/file-upload' @@ -450,15 +487,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, } 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..90bfb673 --- /dev/null +++ b/src/routes/_protected/settings/connections.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ConnectionsSettingsPage } from "#/features/account/connections/ConnectionsSettingsPage"; + +export const Route = createFileRoute("/_protected/settings/connections")({ + head: () => ({ + meta: [ + { + title: "ThinkEx | Connections", + }, + ], + }), + component: ConnectionsSettingsPage, +}); 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 index 65765cdc..8b8e3ad2 100644 --- a/src/routes/oauth/consent.tsx +++ b/src/routes/oauth/consent.tsx @@ -1,6 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; import { createFileRoute } 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({ @@ -26,19 +39,139 @@ export const Route = createFileRoute("/oauth/consent")({ function OAuthConsentPage() { const search = Route.useSearch(); + const [pendingAction, setPendingAction] = 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) { + window.location.assign(result.url); + return; + } + + 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

-

- OAuth consent UI is not implemented yet. This stub route exists so the OAuth provider can - redirect here during development. +

+ Review the access requested by this application before continuing.

-
-				{JSON.stringify(search, null, 2)}
-			
+ + {!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} +
+ +
+

+ Requested access +

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

No scopes were requested.

+ )} +
+
+ +

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

+ +
+ + +
); } From e73a7517f9dc072533e6db4443c17991b08ee4b3 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 02:28:23 -0400 Subject: [PATCH 06/29] refactor(mcp): streamline input schemas for reading workspace items --- src/features/workspaces/mcp/mcp-auth.test.ts | 167 ++++++++++++++++++ src/features/workspaces/mcp/mcp-schemas.ts | 29 +++ src/features/workspaces/mcp/mcp-tools.test.ts | 148 ++++++++++++++++ src/features/workspaces/mcp/mcp-tools.ts | 35 +--- .../operations/workspace-page-range-schema.ts | 16 ++ .../operations/workspace-tool-schemas.ts | 19 +- 6 files changed, 370 insertions(+), 44 deletions(-) create mode 100644 src/features/workspaces/mcp/mcp-auth.test.ts create mode 100644 src/features/workspaces/mcp/mcp-schemas.ts create mode 100644 src/features/workspaces/mcp/mcp-tools.test.ts create mode 100644 src/features/workspaces/operations/workspace-page-range-schema.ts 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..05a1531e --- /dev/null +++ b/src/features/workspaces/mcp/mcp-auth.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("better-auth/oauth2", () => ({ + verifyAccessToken: vi.fn(), +})); + +vi.mock("#/lib/app-origin", () => ({ + getAppOrigin: () => "https://app.example.com", + getAuthBaseURL: () => "https://app.example.com", +})); + +import { verifyAccessToken } from "better-auth/oauth2"; +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("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 empty grantedScopes when scope claim is absent", async () => { + vi.mocked(verifyAccessToken).mockResolvedValueOnce({ sub: "user-3" } as never); + + const actor = await verifyMcpBearerToken(makeRequest("Bearer valid.no-scope.token")); + + 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-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-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 index 84bcb3f4..d617d777 100644 --- a/src/features/workspaces/mcp/mcp-tools.ts +++ b/src/features/workspaces/mcp/mcp-tools.ts @@ -1,11 +1,12 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; 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 { workspacePageRangeSchema } from "#/features/workspaces/operations/workspace-tool-schemas"; import { buildMcpAccountContext, buildMcpWorkspaceContext, type McpActor } from "./mcp-auth"; +import { mcpListItemsInputSchema, mcpReadItemsInputSchema } from "./mcp-schemas"; + +export { mcpListItemsInputSchema, mcpReadItemsInputSchema } from "./mcp-schemas"; function mcpTextResult(result: unknown) { return { @@ -43,25 +44,7 @@ export function registerMcpTools(server: McpServer, actor: McpActor): void { "thinkex_workspace_list_items", { description: "List items in a ThinkEx workspace by absolute path.", - inputSchema: { - 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."), - }, + inputSchema: mcpListItemsInputSchema, }, async ({ workspaceId, limit, path, recursive }) => { const context = buildMcpWorkspaceContext(actor, workspaceId); @@ -80,15 +63,7 @@ export function registerMcpTools(server: McpServer, actor: McpActor): void { { description: "Read ThinkEx documents and files by absolute path. Use pages for continuation: PDF pages for PDFs, 1000-line Markdown pages for documents and extracted files.", - inputSchema: { - 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."), - }, + inputSchema: mcpReadItemsInputSchema, }, async ({ workspaceId, pages, paths }) => { const context = buildMcpWorkspaceContext(actor, workspaceId); 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 bd623a0f..963d3022 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -57,20 +57,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."), -}); - -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.", - ); +import { + workspacePageRangeSchema, + workspaceReadPagesSchema, +} from "#/features/workspaces/operations/workspace-page-range-schema"; +export { workspacePageRangeSchema, workspaceReadPagesSchema }; export const workspaceListItemsInputSchema = z.object({ limit: z From c36e7eb107187b57be5af7d27af4967f50e0c342 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 09:35:43 -0400 Subject: [PATCH 07/29] feat(mcp): add OAuth protected-resource metadata and valid audiences --- src/lib/app-origin.ts | 8 ++++++ src/lib/auth.server.ts | 8 +++++- src/routeTree.gen.ts | 23 +++++++++++++++ .../oauth-protected-resource/mcp.ts | 28 +++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/routes/[.]well-known/oauth-protected-resource/mcp.ts 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.server.ts b/src/lib/auth.server.ts index 2b9815b2..dbdce63d 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -14,7 +14,12 @@ import { import { sendDeleteAccountVerificationEmail } from "#/features/account/account-deletion-email"; import * as schema from "#/db/schema"; import { createDbContext } from "#/db/server"; -import { getAuthBaseURL, getAppOrigin, getTrustedAppOrigins } from "#/lib/app-origin"; +import { + getAuthBaseURL, + getAppOrigin, + getMcpResourceUrl, + getTrustedAppOrigins, +} from "#/lib/app-origin"; const isProduction = import.meta.env.PROD; @@ -218,6 +223,7 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { clientRegistrationDefaultScopes: ["workspace:read"], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, + validAudiences: [getAppOrigin(), getMcpResourceUrl()], }), tanstackStartCookies(), ], diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4bb2bc86..b2a94e51 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -28,6 +28,7 @@ import { Route as ApiPosthogSurveyFeedbackRouteImport } from './routes/api/posth 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' @@ -131,6 +132,12 @@ const ProtectedSettingsConnectionsRoute = 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', @@ -169,6 +176,7 @@ export interface FileRoutesByFullPath { '/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 @@ -192,6 +200,7 @@ export interface FileRoutesByTo { '/home': typeof ProtectedHomeRoute '/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 @@ -218,6 +227,7 @@ export interface FileRoutesById { '/_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 @@ -244,6 +254,7 @@ export interface FileRouteTypes { | '/settings' | '/invite/$token' | '/oauth/consent' + | '/.well-known/oauth-protected-resource/mcp' | '/settings/connections' | '/workspaces/$workspaceId' | '/api/auth/$' @@ -267,6 +278,7 @@ export interface FileRouteTypes { | '/home' | '/invite/$token' | '/oauth/consent' + | '/.well-known/oauth-protected-resource/mcp' | '/settings/connections' | '/workspaces/$workspaceId' | '/api/auth/$' @@ -292,6 +304,7 @@ export interface FileRouteTypes { | '/_protected/settings' | '/invite/$token' | '/oauth/consent' + | '/.well-known/oauth-protected-resource/mcp' | '/_protected/settings/connections' | '/_protected/workspaces/$workspaceId' | '/api/auth/$' @@ -316,6 +329,7 @@ export interface RootRouteChildren { DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute InviteTokenRoute: typeof InviteTokenRoute OauthConsentRoute: typeof OauthConsentRoute + DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiPosthogSurveyFeedbackRoute: typeof ApiPosthogSurveyFeedbackRoute ApiV1WorkspacesRoute: typeof ApiV1WorkspacesRouteWithChildren @@ -456,6 +470,13 @@ declare module '@tanstack/react-router' { 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' @@ -551,6 +572,8 @@ const rootRouteChildren: RootRouteChildren = { DotwellKnownOauthAuthorizationServerRoute, InviteTokenRoute: InviteTokenRoute, OauthConsentRoute: OauthConsentRoute, + DotwellKnownOauthProtectedResourceMcpRoute: + DotwellKnownOauthProtectedResourceMcpRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiPosthogSurveyFeedbackRoute: ApiPosthogSurveyFeedbackRoute, ApiV1WorkspacesRoute: ApiV1WorkspacesRouteWithChildren, 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..3b3700cc --- /dev/null +++ b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts @@ -0,0 +1,28 @@ +import { createFileRoute } from "@tanstack/react-router"; + +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: ["workspace:read"], + bearer_methods_supported: ["header"], + }, + { + headers: { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + }, + }, + ); + }, + }, + }, +}); From 7666146cb9051fdc2472b6cc4641805eba206287 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 09:35:51 -0400 Subject: [PATCH 08/29] feat(mcp): verify tokens against /mcp audience and workspace:read --- src/features/workspaces/mcp/mcp-auth.test.ts | 58 ++++++++++++++++++-- src/features/workspaces/mcp/mcp-auth.ts | 12 +++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/features/workspaces/mcp/mcp-auth.test.ts b/src/features/workspaces/mcp/mcp-auth.test.ts index 05a1531e..bbdc49d0 100644 --- a/src/features/workspaces/mcp/mcp-auth.test.ts +++ b/src/features/workspaces/mcp/mcp-auth.test.ts @@ -4,12 +4,26 @@ 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, @@ -76,6 +90,35 @@ describe("verifyMcpBearerToken", () => { }); }); + 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", @@ -87,12 +130,17 @@ describe("verifyMcpBearerToken", () => { expect(actor.clientId).toBeNull(); }); - it("returns empty grantedScopes when scope claim is absent", async () => { - vi.mocked(verifyAccessToken).mockResolvedValueOnce({ sub: "user-3" } as never); + it("throws 401 invalid_token when scope claim is absent from a rejected token", async () => { + vi.mocked(verifyAccessToken).mockRejectedValueOnce( + new APIError("FORBIDDEN", { message: "invalid scope workspace:read" }), + ); - const actor = await verifyMcpBearerToken(makeRequest("Bearer valid.no-scope.token")); - - expect(actor.grantedScopes).toEqual(new Set()); + await expect( + verifyMcpBearerToken(makeRequest("Bearer valid.no-scope.token")), + ).rejects.toMatchObject({ + status: 403, + code: "insufficient_scope", + }); }); it("errors are McpAuthError instances", async () => { diff --git a/src/features/workspaces/mcp/mcp-auth.ts b/src/features/workspaces/mcp/mcp-auth.ts index 5df7564b..71e63cf8 100644 --- a/src/features/workspaces/mcp/mcp-auth.ts +++ b/src/features/workspaces/mcp/mcp-auth.ts @@ -1,3 +1,4 @@ +import { APIError } from "better-auth/api"; import { verifyAccessToken } from "better-auth/oauth2"; import { @@ -10,7 +11,7 @@ import { type WorkspaceAccessContext, type WorkspaceAccessScope, } from "#/features/workspaces/operations/workspace-access-context"; -import { getAuthBaseURL, getAppOrigin } from "#/lib/app-origin"; +import { getAuthBaseURL, getAppOrigin, getMcpResourceUrl } from "#/lib/app-origin"; export interface McpActor { userId: string; @@ -88,12 +89,17 @@ export async function verifyMcpBearerToken(request: Request): Promise try { payload = await verifyAccessToken(token, { jwksUrl: `${jwksBaseUrl}/api/auth/jwks`, + scopes: ["workspace:read"], verifyOptions: { issuer, - audience: getAppOrigin(), + audience: getMcpResourceUrl(), }, }); - } catch { + } catch (error) { + if (error instanceof APIError && error.status === "FORBIDDEN") { + throw new McpAuthError(403, "insufficient_scope"); + } + throw new McpAuthError(401, "invalid_token"); } From f061200ab9ae037029e33b2e3ecb86b0f42c5448 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 09:35:56 -0400 Subject: [PATCH 09/29] feat(mcp): emit WWW-Authenticate header on auth failures --- src/features/workspaces/mcp/mcp-route.test.ts | 36 +++++++++++++++++++ src/features/workspaces/mcp/mcp-route.ts | 17 ++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/features/workspaces/mcp/mcp-route.test.ts 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..8023941c --- /dev/null +++ b/src/features/workspaces/mcp/mcp-route.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("agents/mcp", () => ({ + createMcpHandler: vi.fn(), +})); + +vi.mock("#/features/workspaces/mcp/mcp-tools", () => ({ + registerMcpTools: vi.fn(), +})); + +vi.mock("#/lib/app-origin", () => ({ + getMcpProtectedResourceMetadataUrl: () => + "https://app.example.com/.well-known/oauth-protected-resource/mcp", +})); + +import { routeMcpRequest } from "#/features/workspaces/mcp/mcp-route"; + +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 () => { + 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" }); + }); +}); diff --git a/src/features/workspaces/mcp/mcp-route.ts b/src/features/workspaces/mcp/mcp-route.ts index cf227cd6..7906bf1b 100644 --- a/src/features/workspaces/mcp/mcp-route.ts +++ b/src/features/workspaces/mcp/mcp-route.ts @@ -2,9 +2,16 @@ 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 { 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); @@ -28,7 +35,15 @@ export async function routeMcpRequest( actor = await verifyMcpBearerToken(request); } catch (error) { if (error instanceof McpAuthError) { - return Response.json({ error: error.code }, { status: error.status }); + return Response.json( + { error: error.code }, + { + status: error.status, + headers: { + "WWW-Authenticate": buildMcpWwwAuthenticateHeader(error.code), + }, + }, + ); } throw error; } From b6ceff35b57a6ea9732e801814923fad1bfacc31 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 09:36:05 -0400 Subject: [PATCH 10/29] feat(mcp): harden tools with access denials and audit hook --- src/features/workspaces/mcp/mcp-audit.ts | 28 ++++ .../workspaces/mcp/mcp-tool-access.ts | 41 ++++++ src/features/workspaces/mcp/mcp-tools.ts | 126 +++++++++++++----- 3 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 src/features/workspaces/mcp/mcp-audit.ts create mode 100644 src/features/workspaces/mcp/mcp-tool-access.ts 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-tool-access.ts b/src/features/workspaces/mcp/mcp-tool-access.ts new file mode 100644 index 00000000..473051f9 --- /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() { + return { + workspaces: [], + failed: [{ code: "insufficient_scope" as const }], + }; +} + +export function mcpListItemsAccessDeniedResult(path = "/") { + return { + path, + more: false, + items: [], + failed: [{ code: "workspace_forbidden" as const }], + }; +} + +export function mcpReadItemsAccessDeniedResult() { + return { + items: [], + failed: [{ code: "workspace_forbidden" as const }], + }; +} diff --git a/src/features/workspaces/mcp/mcp-tools.ts b/src/features/workspaces/mcp/mcp-tools.ts index d617d777..b81aaeb9 100644 --- a/src/features/workspaces/mcp/mcp-tools.ts +++ b/src/features/workspaces/mcp/mcp-tools.ts @@ -1,10 +1,19 @@ 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 type { WorkspaceReadItemsResult } from "#/features/workspaces/operations/read-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 { mcpListItemsInputSchema, mcpReadItemsInputSchema } from "./mcp-schemas"; +import { + isMcpToolAccessError, + mcpListItemsAccessDeniedResult, + mcpListWorkspacesAccessDeniedResult, + mcpReadItemsAccessDeniedResult, +} from "./mcp-tool-access"; export { mcpListItemsInputSchema, mcpReadItemsInputSchema } from "./mcp-schemas"; @@ -19,25 +28,65 @@ function mcpTextResult(result: unknown) { }; } +async function runMcpTool(input: { + actor: McpActor; + deniedResult: 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.deniedResult); + } + + 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 () => { - const context = buildMcpAccountContext(actor); - const { workspaces } = await listAccountWorkspacesOperation(context); + async () => + runMcpTool({ + actor, + deniedResult: mcpListWorkspacesAccessDeniedResult(), + toolName: "thinkex_list_workspaces", + run: async () => { + const context = buildMcpAccountContext(actor); + const { workspaces } = await listAccountWorkspacesOperation(context); - return mcpTextResult({ - workspaces: workspaces.map(({ id, name, description, membershipRole }) => ({ - id, - name, - description, - role: membershipRole, - })), - }); - }, + return { + workspaces: workspaces.map(({ id, name, description, membershipRole }) => ({ + id, + name, + description, + role: membershipRole, + })), + }; + }, + }), ); server.registerTool( @@ -46,33 +95,44 @@ export function registerMcpTools(server: McpServer, actor: McpActor): void { description: "List items in a ThinkEx workspace by absolute path.", inputSchema: mcpListItemsInputSchema, }, - async ({ workspaceId, limit, path, recursive }) => { - const context = buildMcpWorkspaceContext(actor, workspaceId); - const result = await listWorkspaceItemsOperation(context, { - path, - recursive, - limit, - }); - - return mcpTextResult(result); - }, + async ({ workspaceId, limit, path, recursive }) => + runMcpTool({ + actor, + deniedResult: mcpListItemsAccessDeniedResult( + path, + ) 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 ThinkEx documents and files by absolute path. Use pages for continuation: PDF pages for PDFs, 1000-line Markdown pages for documents and extracted files.", + description: "Read documents and files in a ThinkEx workspace by absolute path.", inputSchema: mcpReadItemsInputSchema, }, - async ({ workspaceId, pages, paths }) => { - const context = buildMcpWorkspaceContext(actor, workspaceId); - const result = await readWorkspaceItemsOperation(context, { - pages, - paths, - }); - - return mcpTextResult(result); - }, + async ({ workspaceId, pages, paths }) => + runMcpTool({ + actor, + deniedResult: mcpReadItemsAccessDeniedResult() as unknown as WorkspaceReadItemsResult, + toolName: "thinkex_workspace_read_items", + workspaceId, + run: async () => { + const context = buildMcpWorkspaceContext(actor, workspaceId); + return readWorkspaceItemsOperation(context, { + pages, + paths, + }); + }, + }), ); } From 249d578f8f3452818976a8d8cfd18931cde22928 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 09:36:10 -0400 Subject: [PATCH 11/29] docs(mcp): add developer setup and v1 limitations --- docs/MCP.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/MCP.md diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 00000000..1ec22647 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,53 @@ +# 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. +- **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. From 3f86addfcf4239b762722064cbfbfb1aafa572b3 Mon Sep 17 00:00:00 2001 From: syednahm Date: Fri, 3 Jul 2026 11:09:20 -0400 Subject: [PATCH 12/29] feat(mcp): refactor MCP server URL handling in ConnectionsSettingsPage --- .../connections/ConnectionsSettingsPage.tsx | 14 +++++++------- .../account/connections/mcp-setup.functions.ts | 7 +++++++ src/features/account/connections/mcp-setup.ts | 12 ++++++++---- src/routes/_protected/settings/connections.tsx | 12 +++++++++++- 4 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 src/features/account/connections/mcp-setup.functions.ts diff --git a/src/features/account/connections/ConnectionsSettingsPage.tsx b/src/features/account/connections/ConnectionsSettingsPage.tsx index 41e9eeff..ed28c792 100644 --- a/src/features/account/connections/ConnectionsSettingsPage.tsx +++ b/src/features/account/connections/ConnectionsSettingsPage.tsx @@ -65,8 +65,8 @@ function formatAuthorizedDate(value: Date | string): string { }); } -function McpServerUrlField() { - const mcpServerUrl = getMcpServerUrl(); +function McpServerUrlField({ serverMcpServerUrl }: { serverMcpServerUrl: string }) { + const mcpServerUrl = getMcpServerUrl(serverMcpServerUrl); const { copied, copy } = useCopyToClipboard({ onError: () => toast.error("Could not copy MCP server URL"), }); @@ -101,8 +101,8 @@ function McpServerUrlField() { ); } -function EditorSetupInstructions() { - const mcpServerUrl = getMcpServerUrl(); +function EditorSetupInstructions({ serverMcpServerUrl }: { serverMcpServerUrl: string }) { + const mcpServerUrl = getMcpServerUrl(serverMcpServerUrl); const configJson = useMemo(() => buildMcpServerConfig(mcpServerUrl), [mcpServerUrl]); return ( @@ -315,7 +315,7 @@ function AuthorizedConnectionsSection() { ); } -export function ConnectionsSettingsPage() { +export function ConnectionsSettingsPage({ mcpServerUrl }: { mcpServerUrl: string }) { const navigate = useNavigate(); const router = useRouter(); const canGoBack = useCanGoBack(); @@ -358,8 +358,8 @@ export function ConnectionsSettingsPage() {

- - + + 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 index 4578a873..0864633c 100644 --- a/src/features/account/connections/mcp-setup.ts +++ b/src/features/account/connections/mcp-setup.ts @@ -1,3 +1,5 @@ +import { getClientOrigin } from "#/lib/client-url"; + interface EditorSetupGuide { id: string; label: string; @@ -44,10 +46,12 @@ export const EDITOR_SETUP_GUIDES: readonly EditorSetupGuide[] = [ }, ] as const; -export function getMcpServerUrl(): string { - if (typeof window === "undefined") { - return "https://app.thinkex.app/mcp"; +export function getMcpServerUrl(serverMcpServerUrl: string): string { + const origin = getClientOrigin(); + + if (origin) { + return `${origin}/mcp`; } - return `${window.location.origin}/mcp`; + return serverMcpServerUrl; } diff --git a/src/routes/_protected/settings/connections.tsx b/src/routes/_protected/settings/connections.tsx index 90bfb673..f705d43f 100644 --- a/src/routes/_protected/settings/connections.tsx +++ b/src/routes/_protected/settings/connections.tsx @@ -1,8 +1,12 @@ 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: [ { @@ -10,5 +14,11 @@ export const Route = createFileRoute("/_protected/settings/connections")({ }, ], }), - component: ConnectionsSettingsPage, + component: ConnectionsRoutePage, }); + +function ConnectionsRoutePage() { + const { mcpServerUrl } = Route.useLoaderData(); + + return ; +} From 166f6813c0ce87976d392b6c90cdb9f1696e39f1 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 14:05:09 -0400 Subject: [PATCH 13/29] feat(mcp): enhance access denial handling with dynamic error codes --- docs/MCP.md | 1 + .../mcp/mcp-read-content-cap.test.ts | 173 ++++++++++++++++++ .../workspaces/mcp/mcp-read-content-cap.ts | 63 +++++++ .../workspaces/mcp/mcp-tool-access.test.ts | 53 ++++++ .../workspaces/mcp/mcp-tool-access.ts | 12 +- src/features/workspaces/mcp/mcp-tools.ts | 27 ++- 6 files changed, 313 insertions(+), 16 deletions(-) create mode 100644 src/features/workspaces/mcp/mcp-read-content-cap.test.ts create mode 100644 src/features/workspaces/mcp/mcp-read-content-cap.ts create mode 100644 src/features/workspaces/mcp/mcp-tool-access.test.ts diff --git a/docs/MCP.md b/docs/MCP.md index 1ec22647..a92cb877 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -49,5 +49,6 @@ Google creds are optional; guest sign-in is enough to authorize a client. - **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/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..5e746d10 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-read-content-cap.test.ts @@ -0,0 +1,173 @@ +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("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..c1c368f0 --- /dev/null +++ b/src/features/workspaces/mcp/mcp-read-content-cap.ts @@ -0,0 +1,63 @@ +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; +} + +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, 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-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 index 473051f9..05625c52 100644 --- a/src/features/workspaces/mcp/mcp-tool-access.ts +++ b/src/features/workspaces/mcp/mcp-tool-access.ts @@ -17,25 +17,25 @@ export function resolveMcpToolAccessFailureCode( return error instanceof WorkspaceForbiddenError ? "workspace_forbidden" : "insufficient_scope"; } -export function mcpListWorkspacesAccessDeniedResult() { +export function mcpListWorkspacesAccessDeniedResult(code: McpToolAccessFailureCode) { return { workspaces: [], - failed: [{ code: "insufficient_scope" as const }], + failed: [{ code }], }; } -export function mcpListItemsAccessDeniedResult(path = "/") { +export function mcpListItemsAccessDeniedResult(path: string, code: McpToolAccessFailureCode) { return { path, more: false, items: [], - failed: [{ code: "workspace_forbidden" as const }], + failed: [{ code }], }; } -export function mcpReadItemsAccessDeniedResult() { +export function mcpReadItemsAccessDeniedResult(code: McpToolAccessFailureCode) { return { items: [], - failed: [{ code: "workspace_forbidden" as const }], + failed: [{ code }], }; } diff --git a/src/features/workspaces/mcp/mcp-tools.ts b/src/features/workspaces/mcp/mcp-tools.ts index b81aaeb9..c6e820d5 100644 --- a/src/features/workspaces/mcp/mcp-tools.ts +++ b/src/features/workspaces/mcp/mcp-tools.ts @@ -3,16 +3,18 @@ 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 type { WorkspaceReadItemsResult } from "#/features/workspaces/operations/read-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"; @@ -30,7 +32,7 @@ function mcpTextResult(result: unknown) { async function runMcpTool(input: { actor: McpActor; - deniedResult: TResult; + deniedResultForCode: (code: McpToolAccessFailureCode) => TResult; run: () => Promise; toolName: string; workspaceId?: string; @@ -50,7 +52,7 @@ async function runMcpTool(input: { toolName: input.toolName, workspaceId: input.workspaceId, }); - return mcpTextResult(input.deniedResult); + return mcpTextResult(input.deniedResultForCode(resolveMcpToolAccessFailureCode(error))); } recordMcpToolCallFromActor(input.actor, { @@ -71,7 +73,7 @@ export function registerMcpTools(server: McpServer, actor: McpActor): void { async () => runMcpTool({ actor, - deniedResult: mcpListWorkspacesAccessDeniedResult(), + deniedResultForCode: mcpListWorkspacesAccessDeniedResult, toolName: "thinkex_list_workspaces", run: async () => { const context = buildMcpAccountContext(actor); @@ -98,9 +100,11 @@ export function registerMcpTools(server: McpServer, actor: McpActor): void { async ({ workspaceId, limit, path, recursive }) => runMcpTool({ actor, - deniedResult: mcpListItemsAccessDeniedResult( - path, - ) as unknown as ListWorkspaceKernelItemsResult, + deniedResultForCode: (code) => + mcpListItemsAccessDeniedResult( + path ?? "/", + code, + ) as unknown as ListWorkspaceKernelItemsResult, toolName: "thinkex_workspace_list_items", workspaceId, run: async () => { @@ -121,17 +125,20 @@ export function registerMcpTools(server: McpServer, actor: McpActor): void { inputSchema: mcpReadItemsInputSchema, }, async ({ workspaceId, pages, paths }) => - runMcpTool({ + runMcpTool({ actor, - deniedResult: mcpReadItemsAccessDeniedResult() as unknown as WorkspaceReadItemsResult, + deniedResultForCode: (code) => + mcpReadItemsAccessDeniedResult(code) as unknown as McpReadItemsResult, toolName: "thinkex_workspace_read_items", workspaceId, run: async () => { const context = buildMcpWorkspaceContext(actor, workspaceId); - return readWorkspaceItemsOperation(context, { + const result = await readWorkspaceItemsOperation(context, { pages, paths, }); + + return capMcpReadItemsContent(result); }, }), ); From 127223ea64b1d617923d989edc598c1aab390117 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 15:01:28 -0400 Subject: [PATCH 14/29] test(mcp): update test to handle tokens w/o scope claims --- src/features/workspaces/mcp/mcp-auth.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/features/workspaces/mcp/mcp-auth.test.ts b/src/features/workspaces/mcp/mcp-auth.test.ts index bbdc49d0..c9d02300 100644 --- a/src/features/workspaces/mcp/mcp-auth.test.ts +++ b/src/features/workspaces/mcp/mcp-auth.test.ts @@ -130,17 +130,15 @@ describe("verifyMcpBearerToken", () => { expect(actor.clientId).toBeNull(); }); - it("throws 401 invalid_token when scope claim is absent from a rejected token", async () => { - vi.mocked(verifyAccessToken).mockRejectedValueOnce( - new APIError("FORBIDDEN", { message: "invalid scope workspace:read" }), - ); + 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); - await expect( - verifyMcpBearerToken(makeRequest("Bearer valid.no-scope.token")), - ).rejects.toMatchObject({ - status: 403, - code: "insufficient_scope", - }); + 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 () => { From 408b9e692bd81086e225fe2f6b9f36a19de68c23 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 15:02:24 -0400 Subject: [PATCH 15/29] fix(account): ensure unique OAuth scope labels by removing duplicates --- src/features/account/connections/oauth-scope-labels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/account/connections/oauth-scope-labels.ts b/src/features/account/connections/oauth-scope-labels.ts index 24707233..957ef25c 100644 --- a/src/features/account/connections/oauth-scope-labels.ts +++ b/src/features/account/connections/oauth-scope-labels.ts @@ -11,5 +11,5 @@ export function parseOAuthScopeParam(scope: string | undefined): string[] { return []; } - return scope.split(/\s+/).filter(Boolean); + return [...new Set(scope.split(/\s+/).filter(Boolean))]; } From 7563c32bfbfd2ed213eb2f28532f8ae2e11f7ab7 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 15:13:13 -0400 Subject: [PATCH 16/29] refactor(mcp): replace hardcoded scopes with MCP_SUPPORTED_SCOPES --- src/features/workspaces/mcp/mcp-auth.ts | 10 +++++++--- src/features/workspaces/mcp/mcp-scopes.ts | 3 +++ src/lib/auth.server.ts | 7 ++++--- .../[.]well-known/oauth-protected-resource/mcp.ts | 3 ++- 4 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 src/features/workspaces/mcp/mcp-scopes.ts diff --git a/src/features/workspaces/mcp/mcp-auth.ts b/src/features/workspaces/mcp/mcp-auth.ts index 71e63cf8..b03706b6 100644 --- a/src/features/workspaces/mcp/mcp-auth.ts +++ b/src/features/workspaces/mcp/mcp-auth.ts @@ -11,6 +11,10 @@ import { 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 { @@ -89,7 +93,7 @@ export async function verifyMcpBearerToken(request: Request): Promise try { payload = await verifyAccessToken(token, { jwksUrl: `${jwksBaseUrl}/api/auth/jwks`, - scopes: ["workspace:read"], + scopes: [...MCP_SUPPORTED_SCOPES], verifyOptions: { issuer, audience: getMcpResourceUrl(), @@ -117,7 +121,7 @@ export async function verifyMcpBearerToken(request: Request): Promise export function buildMcpAccountContext(actor: McpActor): AccountAccessContext { const scopes: AccountAccessScope[] = []; - if (actor.grantedScopes.has("workspace:read")) { + if (actor.grantedScopes.has(MCP_WORKSPACE_READ_SCOPE)) { scopes.push("workspaces:read"); } @@ -133,7 +137,7 @@ export function buildMcpWorkspaceContext( ): WorkspaceAccessContext { const scopes: WorkspaceAccessScope[] = []; - if (actor.grantedScopes.has("workspace:read")) { + if (actor.grantedScopes.has(MCP_WORKSPACE_READ_SCOPE)) { scopes.push("workspace:read"); } 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/lib/auth.server.ts b/src/lib/auth.server.ts index dbdce63d..2e10bfd2 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -11,6 +11,7 @@ import { purgeUserAccountResources, transferLinkedAccountResources, } from "#/features/workspaces/durable-object-lifecycle"; +import { MCP_SUPPORTED_SCOPES } from "#/features/workspaces/mcp/mcp-scopes"; import { sendDeleteAccountVerificationEmail } from "#/features/account/account-deletion-email"; import * as schema from "#/db/schema"; import { createDbContext } from "#/db/server"; @@ -218,9 +219,9 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { oauthProvider({ loginPage: "/login", consentPage: "/oauth/consent", - scopes: ["workspace:read"], - clientRegistrationAllowedScopes: ["workspace:read"], - clientRegistrationDefaultScopes: ["workspace:read"], + scopes: [...MCP_SUPPORTED_SCOPES], + clientRegistrationAllowedScopes: [...MCP_SUPPORTED_SCOPES], + clientRegistrationDefaultScopes: [...MCP_SUPPORTED_SCOPES], allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, validAudiences: [getAppOrigin(), getMcpResourceUrl()], diff --git a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts index 3b3700cc..918ae2b1 100644 --- a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts +++ b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts @@ -1,5 +1,6 @@ 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")({ @@ -12,7 +13,7 @@ export const Route = createFileRoute("/.well-known/oauth-protected-resource/mcp" { resource: getMcpResourceUrl(appOrigin), authorization_servers: [appOrigin], - scopes_supported: ["workspace:read"], + scopes_supported: [...MCP_SUPPORTED_SCOPES], bearer_methods_supported: ["header"], }, { From c73b44d12c89659cc914854d02acf910a4027de0 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 15:17:45 -0400 Subject: [PATCH 17/29] fix(connections): revoke consents per-id to avoid state races --- .../connections/ConnectionsSettingsPage.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/features/account/connections/ConnectionsSettingsPage.tsx b/src/features/account/connections/ConnectionsSettingsPage.tsx index ed28c792..80121224 100644 --- a/src/features/account/connections/ConnectionsSettingsPage.tsx +++ b/src/features/account/connections/ConnectionsSettingsPage.tsx @@ -216,7 +216,7 @@ function AuthorizedConnectionRow({ function AuthorizedConnectionsSection() { const queryClient = useQueryClient(); - const [revokingConsentId, setRevokingConsentId] = useState(null); + const [revokingConsentIds, setRevokingConsentIds] = useState>(new Set()); const { data: consents, @@ -244,13 +244,17 @@ function AuthorizedConnectionsSection() { onError: (mutationError) => { toast.error(getErrorMessage(mutationError, "Unable to revoke access right now.")); }, - onSettled: () => { - setRevokingConsentId(null); + onSettled: (_data, _error, consentId) => { + setRevokingConsentIds((current) => { + const next = new Set(current); + next.delete(consentId); + return next; + }); }, }); const handleRevoke = (consentId: string) => { - setRevokingConsentId(consentId); + setRevokingConsentIds((current) => new Set(current).add(consentId)); revokeMutation.mutate(consentId); }; @@ -304,7 +308,7 @@ function AuthorizedConnectionsSection() { clientName={client?.client_name} scopes={Array.isArray(consent.scopes) ? consent.scopes : []} authorizedAt={consent.createdAt} - isRevoking={revokingConsentId === consent.id} + isRevoking={revokingConsentIds.has(consent.id)} onRevoke={() => handleRevoke(consent.id)} /> ); From 99879dc8e6aebc60a910bfb5c9f8860b80efe08a Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 15:19:10 -0400 Subject: [PATCH 18/29] feat(mcp): support case-insensitive bearer token scheme in authorization --- src/features/workspaces/mcp/mcp-auth.test.ts | 12 ++++++++++++ src/features/workspaces/mcp/mcp-auth.ts | 7 +++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/features/workspaces/mcp/mcp-auth.test.ts b/src/features/workspaces/mcp/mcp-auth.test.ts index c9d02300..b6c852db 100644 --- a/src/features/workspaces/mcp/mcp-auth.test.ts +++ b/src/features/workspaces/mcp/mcp-auth.test.ts @@ -90,6 +90,18 @@ describe("verifyMcpBearerToken", () => { }); }); + 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", diff --git a/src/features/workspaces/mcp/mcp-auth.ts b/src/features/workspaces/mcp/mcp-auth.ts index b03706b6..227f2361 100644 --- a/src/features/workspaces/mcp/mcp-auth.ts +++ b/src/features/workspaces/mcp/mcp-auth.ts @@ -54,11 +54,14 @@ function resolveJwksBaseUrl(): string { function parseBearerToken(request: Request): string { const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { + // 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 = authHeader.slice("Bearer ".length).trim(); + const token = match[1].trim(); if (!token) { throw new McpAuthError(401, "missing_token"); From 0f2e5a518a160bba9d1736be4f1e39c69c85e5d9 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 15:21:19 -0400 Subject: [PATCH 19/29] refactor(oauth-api): simplify request path building for auth endpoints --- src/features/account/connections/oauth-api.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/features/account/connections/oauth-api.ts b/src/features/account/connections/oauth-api.ts index 5ae4ba68..5b2e484c 100644 --- a/src/features/account/connections/oauth-api.ts +++ b/src/features/account/connections/oauth-api.ts @@ -69,16 +69,22 @@ async function readAuthJson(response: Response): Promise { return payload; } -async function authGet(path: string, query?: Record): Promise { - const url = new URL(path, window.location.origin); - - if (query) { - for (const [key, value] of Object.entries(query)) { - url.searchParams.set(key, value); - } +// 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 response = await fetch(url, { + 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", }); @@ -86,7 +92,7 @@ async function authGet(path: string, query?: Record): Promise } async function authPost(path: string, body: Record): Promise { - const response = await fetch(new URL(path, window.location.origin), { + const response = await fetch(path, { method: "POST", credentials: "include", headers: { From f6bad794d665995bcd4273d3fdb5d522bd764061 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 15:22:44 -0400 Subject: [PATCH 20/29] fix(oauth-api): improve error handling in auth response parsing --- src/features/account/connections/oauth-api.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/features/account/connections/oauth-api.ts b/src/features/account/connections/oauth-api.ts index 5b2e484c..8bfceb51 100644 --- a/src/features/account/connections/oauth-api.ts +++ b/src/features/account/connections/oauth-api.ts @@ -56,17 +56,27 @@ function buildSignedOAuthQuery(search: string): string | undefined { } async function readAuthJson(response: Response): Promise { - const payload = (await response.json()) as T & { message?: string }; + 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( - typeof payload === "object" && payload && "message" in payload && payload.message + payload && typeof payload === "object" && "message" in payload && payload.message ? payload.message : "Request failed", ); } - return payload; + return payload as T; } // These auth endpoints are same-origin and cookie-scoped, so we issue relative From 86fedb6970acef6dd9f9630f1a6f97993b2995f9 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 16:01:17 -0400 Subject: [PATCH 21/29] chore(dependencies): update betterauth packages to version 1.6.23 --- package.json | 4 +- pnpm-lock.yaml | 146 +++++++++++++++++++------------------------------ 2 files changed, 59 insertions(+), 91 deletions(-) diff --git a/package.json b/package.json index 15593f49..a286e192 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "dependencies": { "@ai-sdk/react": "^3.0.210", "@base-ui/react": "^1.6.0", - "@better-auth/oauth-provider": "^1.6.19", + "@better-auth/oauth-provider": "^1.6.23", "@cf-wasm/photon": "^0.3.6", "@cloudflare/codemode": "^0.4.2", "@cloudflare/containers": "^0.3.7", @@ -108,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 03cd2ab3..517498fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: 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.19 - version: 1.6.23(c27d70b3b99c8f34eeec41b5ea3935de) + specifier: ^1.6.23 + version: 1.6.23(d02ba86cb84b2e685bd73caaf1c127e5) '@cf-wasm/photon': specifier: ^0.3.6 version: 0.3.6 @@ -221,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)(@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) + 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)(@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)) + 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 @@ -312,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 @@ -745,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 @@ -762,36 +762,36 @@ 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: @@ -807,10 +807,10 @@ packages: better-auth: ^1.6.23 better-call: 1.3.7 - '@better-auth/prisma-adapter@1.6.19': - resolution: {integrity: sha512-pXZBhR7/bzJb48IUHlGMyz9SM9h1OCO5GIIuHEllJYt8MKgrjtsnXfUkwZh6pAUEIp3WxBEYMMK96bfkqHiWEg==} + '@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 @@ -820,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 @@ -4869,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 @@ -4931,14 +4931,6 @@ packages: vue: optional: true - better-call@1.3.6: - resolution: {integrity: sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA==} - peerDependencies: - zod: ^4.0.0 - peerDependenciesMeta: - zod: - optional: true - better-call@1.3.7: resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} peerDependencies: @@ -9087,22 +9079,7 @@ 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)': - 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) - jose: 6.2.3 - kysely: 0.28.17 - nanostores: 1.3.0 - zod: 4.4.3 - optionalDependencies: - '@cloudflare/workers-types': 4.20260627.1 - '@opentelemetry/api': 1.9.1 - - '@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.7(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 @@ -9117,50 +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.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/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.7(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)(@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.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/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.7(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.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/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.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.7(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.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.7(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/oauth-provider@1.6.23(c27d70b3b99c8f34eeec41b5ea3935de)': + '@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.7(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.19(@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-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.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/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.7(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.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/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.7(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 @@ -9274,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 @@ -12472,13 +12449,13 @@ 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)(@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): + 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)(@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-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 @@ -12520,20 +12497,20 @@ snapshots: baseline-browser-mapping@2.10.40: {} - better-auth@1.6.19(@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-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.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.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.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.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.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.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.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.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.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.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/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 @@ -12556,15 +12533,6 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.6(zod@4.4.3): - dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - rou3: 0.7.12 - set-cookie-parser: 3.1.1 - optionalDependencies: - zod: 4.4.3 - better-call@1.3.7(zod@4.4.3): dependencies: '@better-auth/utils': 0.4.2 From ac8e0beb8660f4d5de15a96bb1250186cd0b3818 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 16:09:24 -0400 Subject: [PATCH 22/29] feat(mcp): implement safe truncation for UTF-16 pairs in capping --- .../mcp/mcp-read-content-cap.test.ts | 23 +++++++++++++++++++ .../workspaces/mcp/mcp-read-content-cap.ts | 16 ++++++++++++- .../oauth-authorization-server.ts | 15 +++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/features/workspaces/mcp/mcp-read-content-cap.test.ts b/src/features/workspaces/mcp/mcp-read-content-cap.test.ts index 5e746d10..508fe9cd 100644 --- a/src/features/workspaces/mcp/mcp-read-content-cap.test.ts +++ b/src/features/workspaces/mcp/mcp-read-content-cap.test.ts @@ -110,6 +110,29 @@ describe("capMcpReadItemsContent", () => { }); }); + 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([ { diff --git a/src/features/workspaces/mcp/mcp-read-content-cap.ts b/src/features/workspaces/mcp/mcp-read-content-cap.ts index c1c368f0..b6cb3efa 100644 --- a/src/features/workspaces/mcp/mcp-read-content-cap.ts +++ b/src/features/workspaces/mcp/mcp-read-content-cap.ts @@ -17,6 +17,19 @@ export interface McpReadItemsResult extends WorkspaceReadItemsResult { 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, @@ -45,7 +58,8 @@ export function capMcpReadItemsContent( responseTruncated = true; const truncatedContent = - item.content.slice(0, remainingBudget) + MCP_READ_CONTENT_TRUNCATION_NOTICE; + item.content.slice(0, safeTruncationLength(item.content, remainingBudget)) + + MCP_READ_CONTENT_TRUNCATION_NOTICE; remainingBudget = 0; return { diff --git a/src/routes/[.]well-known/oauth-authorization-server.ts b/src/routes/[.]well-known/oauth-authorization-server.ts index 13dc2d4a..6dbe05c7 100644 --- a/src/routes/[.]well-known/oauth-authorization-server.ts +++ b/src/routes/[.]well-known/oauth-authorization-server.ts @@ -5,7 +5,20 @@ import { withAuth } from "#/lib/auth.server"; export const Route = createFileRoute("/.well-known/oauth-authorization-server")({ server: { handlers: { - GET: ({ request }) => withAuth((auth) => auth.handler(request)), + 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, + }); + }, }, }, }); From 1435221c947ff95c7d822dd51d17d2e3b5d84ec5 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 16:11:17 -0400 Subject: [PATCH 23/29] feat(oauth): add nav button for missing client id in consent page --- src/routes/oauth/consent.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/routes/oauth/consent.tsx b/src/routes/oauth/consent.tsx index 8b8e3ad2..c92c6f00 100644 --- a/src/routes/oauth/consent.tsx +++ b/src/routes/oauth/consent.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Loader2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -39,6 +39,7 @@ export const Route = createFileRoute("/oauth/consent")({ function OAuthConsentPage() { const search = Route.useSearch(); + const navigate = useNavigate(); const [pendingAction, setPendingAction] = useState<"allow" | "deny" | null>(null); const requestedScopes = parseOAuthScopeParam(search.scope); const scopeLabels = formatOAuthScopes(requestedScopes); @@ -93,9 +94,20 @@ function OAuthConsentPage() { {!search.client_id ? ( -

- This authorization request is missing a client identifier. -

+
+

+ This authorization request is missing a client identifier. +

+ +
) : null}
From c615b7926eac8692738323f0d2be8a0aa6f59ac4 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 16:16:15 -0400 Subject: [PATCH 24/29] fix(connections): add padding to settings page layout --- src/features/account/connections/ConnectionsSettingsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/account/connections/ConnectionsSettingsPage.tsx b/src/features/account/connections/ConnectionsSettingsPage.tsx index 80121224..cf0e3a63 100644 --- a/src/features/account/connections/ConnectionsSettingsPage.tsx +++ b/src/features/account/connections/ConnectionsSettingsPage.tsx @@ -342,7 +342,7 @@ export function ConnectionsSettingsPage({ mcpServerUrl }: { mcpServerUrl: string } > -
+
-
-

- Requested access -

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

No scopes were requested.

- )} -
+ {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.

+ )} +
+ )}
-

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

- -
- - -
+ {completedAction ? null : ( + <> +

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

+ +
+ + +
+ + )} ); } From 7078b1bf4b0a4f0afacca63be6b1dfca13d9a8e8 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 17:39:58 -0400 Subject: [PATCH 27/29] refactor(auth): remove unused import for account deletion email --- src/lib/auth.server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index cd95f0dd..f70bb3a2 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -11,7 +11,6 @@ import { transferLinkedAccountResources, } from "#/features/workspaces/durable-object-lifecycle"; import { MCP_SUPPORTED_SCOPES } from "#/features/workspaces/mcp/mcp-scopes"; -import { sendDeleteAccountVerificationEmail } from "#/features/account/account-deletion-email"; import * as schema from "#/db/schema"; import { createDbContext } from "#/db/server"; import { From 9e673866a9e4cdd1d7b0bf56dd25f02ecf1f0d57 Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 17:46:18 -0400 Subject: [PATCH 28/29] fix(oauth): improve error handling during redirect after consent --- src/routes/oauth/consent.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/routes/oauth/consent.tsx b/src/routes/oauth/consent.tsx index 4a015e37..95504d1a 100644 --- a/src/routes/oauth/consent.tsx +++ b/src/routes/oauth/consent.tsx @@ -66,8 +66,15 @@ function OAuthConsentPage() { if (result.url) { setCompletedAction(accept ? "allow" : "deny"); - window.location.assign(result.url); - return; + 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."); From 53f34ec8986adb37dc26a0873b92acf75d4a971a Mon Sep 17 00:00:00 2001 From: syednahm Date: Sat, 4 Jul 2026 18:34:21 -0400 Subject: [PATCH 29/29] feat(mcp): implement MCP connection authorization and related tests --- .../connections/oauth-connections.server.ts | 23 ++--- .../workspaces/mcp/mcp-authorization.test.ts | 92 +++++++++++++++++++ .../workspaces/mcp/mcp-authorization.ts | 27 ++++++ src/features/workspaces/mcp/mcp-route.test.ts | 67 +++++++++++++- src/features/workspaces/mcp/mcp-route.ts | 2 + src/lib/auth.server.ts | 4 - 6 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 src/features/workspaces/mcp/mcp-authorization.test.ts create mode 100644 src/features/workspaces/mcp/mcp-authorization.ts diff --git a/src/features/account/connections/oauth-connections.server.ts b/src/features/account/connections/oauth-connections.server.ts index 64ba08ba..f3d66176 100644 --- a/src/features/account/connections/oauth-connections.server.ts +++ b/src/features/account/connections/oauth-connections.server.ts @@ -1,6 +1,6 @@ import { and, eq } from "drizzle-orm"; -import { oauthAccessToken, oauthConsent, oauthRefreshToken } from "#/db/schema"; +import { oauthConsent, oauthRefreshToken } from "#/db/schema"; import type { createDbContext } from "#/db/server"; type Db = Awaited>["db"]; @@ -22,12 +22,9 @@ export class OAuthConsentForbiddenError extends Error { /** * Revoke an authorized OAuth connection for the current user. * - * Deleting the consent row alone does not stop access: Better Auth leaves the - * client's issued access/refresh tokens in place, and the client silently mints - * new access tokens from its refresh token. We also purge the client's tokens - * for this user so the refresh loop is cut immediately. MCP bearer verification - * is offline (JWKS only), so an already-issued access token still works until it - * expires (bounded by `accessTokenExpiresIn`), but it can no longer be renewed. + * 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, @@ -47,17 +44,9 @@ export async function revokeOAuthConnection( throw new OAuthConsentForbiddenError(); } - // Purge tokens and consent atomically so a mid-operation failure can never - // leave the connection half-revoked (e.g. consent gone but tokens still live). + // 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(oauthAccessToken) - .where( - and( - eq(oauthAccessToken.clientId, consent.clientId), - eq(oauthAccessToken.userId, input.userId), - ), - ), db .delete(oauthRefreshToken) .where( 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-route.test.ts b/src/features/workspaces/mcp/mcp-route.test.ts index 8023941c..5f9effe8 100644 --- a/src/features/workspaces/mcp/mcp-route.test.ts +++ b/src/features/workspaces/mcp/mcp-route.test.ts @@ -1,20 +1,49 @@ 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.fn(), + 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"); @@ -24,6 +53,8 @@ describe("routeMcpRequest", () => { }); 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); @@ -33,4 +64,38 @@ describe("routeMcpRequest", () => { ); 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 index 7906bf1b..a00167de 100644 --- a/src/features/workspaces/mcp/mcp-route.ts +++ b/src/features/workspaces/mcp/mcp-route.ts @@ -3,6 +3,7 @@ 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"; @@ -33,6 +34,7 @@ export async function routeMcpRequest( try { actor = await verifyMcpBearerToken(request); + await assertMcpConnectionAuthorized(actor); } catch (error) { if (error instanceof McpAuthError) { return Response.json( diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index f70bb3a2..7a865303 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -218,10 +218,6 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { oauthProvider({ loginPage: "/login", consentPage: "/oauth/consent", - // Bounds how long a revoked connection can keep working: MCP bearer - // verification is offline, so a live access token stays valid until it - // expires. Revoke purges refresh tokens, so this window is the ceiling. - accessTokenExpiresIn: 1200, scopes: [...MCP_SUPPORTED_SCOPES], clientRegistrationAllowedScopes: [...MCP_SUPPORTED_SCOPES], clientRegistrationDefaultScopes: [...MCP_SUPPORTED_SCOPES],