From 381cac00a908793af60d91605ec32506f3eff074 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:28:54 -0400 Subject: [PATCH 01/24] chore(deps): allow established packages past trust window --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c035265e..3e55e87d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: minimumReleaseAge: 1440 minimumReleaseAgeStrict: true trustPolicy: no-downgrade +trustPolicyIgnoreAfter: 10080 trustPolicyExclude: - semver@6.3.1 From 6053fbcf95f03eafdce2d0ca66fdefe32baa41a9 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:29:01 -0400 Subject: [PATCH 02/24] refactor(workspaces): centralize tool access metadata --- .../workspaces/ai/ai-thread-runtime.ts | 28 +++---- src/features/workspaces/ai/workspace-tools.ts | 7 +- .../operations/workspace-tool-definitions.ts | 78 +++++++++++-------- 3 files changed, 67 insertions(+), 46 deletions(-) diff --git a/src/features/workspaces/ai/ai-thread-runtime.ts b/src/features/workspaces/ai/ai-thread-runtime.ts index 14876ebc..db40bdc0 100644 --- a/src/features/workspaces/ai/ai-thread-runtime.ts +++ b/src/features/workspaces/ai/ai-thread-runtime.ts @@ -100,7 +100,7 @@ export function createAIThreadTurnToolConfig(input: { interface AIThreadToolEntry { codemode: boolean; - mutating: boolean; + access: "read" | "write"; name: string; tool: ToolSet[string]; } @@ -111,7 +111,7 @@ const AI_THREAD_SANDBOX_TOOL_DESCRIPTORS: AIThreadToolDescriptor[] = [ { name: "sandbox_bash", codemode: false, - mutating: false, + access: "read", }, ]; @@ -119,7 +119,7 @@ const AI_THREAD_CODE_RUN_TOOL_DESCRIPTORS: AIThreadToolDescriptor[] = [ { name: "compute", codemode: true, - mutating: false, + access: "read", }, ]; @@ -127,17 +127,17 @@ const AI_THREAD_WEB_TOOL_DESCRIPTORS: AIThreadToolDescriptor[] = [ { name: "web_search", codemode: true, - mutating: false, + access: "read", }, { name: "web_markdown", codemode: true, - mutating: false, + access: "read", }, { name: "web_links", codemode: true, - mutating: false, + access: "read", }, ]; @@ -145,12 +145,12 @@ const AI_THREAD_RESEARCH_TOOL_DESCRIPTORS: AIThreadToolDescriptor[] = [ { name: "research_discover", codemode: true, - mutating: false, + access: "read", }, { name: "research_deepen", codemode: true, - mutating: false, + access: "read", }, ]; @@ -158,20 +158,20 @@ const AI_THREAD_TIME_TOOL_DESCRIPTORS: AIThreadToolDescriptor[] = [ { name: "time_get_current", codemode: true, - mutating: false, + access: "read", }, { name: "time_calculate_relative", codemode: true, - mutating: false, + access: "read", }, ]; const AI_THREAD_WORKSPACE_TOOL_DESCRIPTORS: AIThreadToolDescriptor[] = [ - ...workspaceToolDefinitions.map(({ name, mutating }) => ({ + ...workspaceToolDefinitions.map(({ access, name }) => ({ name, codemode: true, - mutating, + access, })), ]; @@ -208,7 +208,7 @@ function createAIThreadToolCatalog(input: { tools: createAIThreadToolSet(entries), getActiveToolNames(canMutate: boolean) { const names = entries - .filter((entry) => canMutate || !entry.mutating) + .filter((entry) => canMutate || entry.access === "read") .map((entry) => entry.name); return names.includes("sandbox_bash") @@ -221,7 +221,7 @@ function createAIThreadToolCatalog(input: { }, getCodemodeTools(canMutate: boolean) { return createAIThreadToolSet( - entries.filter((entry) => entry.codemode && (canMutate || !entry.mutating)), + entries.filter((entry) => entry.codemode && (canMutate || entry.access === "read")), ); }, }; diff --git a/src/features/workspaces/ai/workspace-tools.ts b/src/features/workspaces/ai/workspace-tools.ts index cd83fe14..973384df 100644 --- a/src/features/workspaces/ai/workspace-tools.ts +++ b/src/features/workspaces/ai/workspace-tools.ts @@ -4,6 +4,7 @@ import { tool } from "ai"; import type { AIThreadContext } from "#/features/workspaces/ai/ai-thread-metadata"; import { workspaceToolDefinitions, + getWorkspaceToolScopes, type WorkspaceToolDefinition, } from "#/features/workspaces/operations/workspace-tool-definitions"; import { @@ -31,7 +32,11 @@ function createWorkspaceThreadTool(input: WorkspaceThreadToolConfig) { return await definition.execute( args, - createThreadWorkspaceAccessContext(thread, definition.scopes, toolCallId), + createThreadWorkspaceAccessContext( + thread, + getWorkspaceToolScopes(definition.access), + toolCallId, + ), ); }, }); diff --git a/src/features/workspaces/operations/workspace-tool-definitions.ts b/src/features/workspaces/operations/workspace-tool-definitions.ts index 920aa2f8..f0306d85 100644 --- a/src/features/workspaces/operations/workspace-tool-definitions.ts +++ b/src/features/workspaces/operations/workspace-tool-definitions.ts @@ -48,26 +48,36 @@ import { type WorkspaceOperationSummary, } from "#/features/workspaces/operations/workspace-operation-observability"; -const workspaceReadScopes = ["workspace:read"] as const satisfies readonly WorkspaceAccessScope[]; -const workspaceWriteScopes = workspaceAccessScopes; +export type WorkspaceToolAccess = "read" | "write"; + +export interface WorkspaceToolEffects { + destructive: boolean; + idempotent: boolean; +} + +export function getWorkspaceToolScopes( + access: WorkspaceToolAccess, +): readonly WorkspaceAccessScope[] { + return access === "read" ? ["workspace:read"] : workspaceAccessScopes; +} export type WorkspaceToolDefinition< TName extends string = string, TInputSchema extends z.ZodTypeAny = z.ZodTypeAny, TOutputSchema extends z.ZodTypeAny = z.ZodTypeAny, > = { + access: WorkspaceToolAccess; description: string; + effects: WorkspaceToolEffects; execute: ( args: z.output, context: WorkspaceAccessContext, ) => Promise>; inputExamples: Array<{ input: z.input }>; inputSchema: TInputSchema; - mutating: boolean; name: TName; outputSchema: TOutputSchema; summarizeResult: (result: z.output) => WorkspaceOperationSummary; - scopes: readonly WorkspaceAccessScope[]; }; type RegisteredWorkspaceToolDefinition< @@ -75,6 +85,7 @@ type RegisteredWorkspaceToolDefinition< TInputSchema extends z.ZodTypeAny, TOutputSchema extends z.ZodTypeAny, > = WorkspaceToolDefinition & { + executeUnknown: (input: unknown, context: WorkspaceAccessContext) => Promise; summarizeOutput: (output: unknown) => WorkspaceOperationSummary | null; }; @@ -85,19 +96,24 @@ function defineWorkspaceTool< >( definition: WorkspaceToolDefinition, ): RegisteredWorkspaceToolDefinition { + const execute = async ( + args: z.output, + context: WorkspaceAccessContext, + ): Promise> => + observeWorkspaceOperation({ + context, + mutating: definition.access === "write", + operation: definition.name, + run: () => definition.execute(args, context), + summarize: definition.summarizeResult, + }); + return { ...definition, - execute: async ( - args: z.output, - context: WorkspaceAccessContext, - ): Promise> => - observeWorkspaceOperation({ - context, - mutating: definition.mutating, - operation: definition.name, - run: () => definition.execute(args, context), - summarize: definition.summarizeResult, - }), + execute, + executeUnknown: async (input, context) => { + return await execute(definition.inputSchema.parse(input), context); + }, summarizeOutput: (output) => { const parsed = definition.outputSchema.safeParse(output); return parsed.success ? definition.summarizeResult(parsed.data) : null; @@ -108,14 +124,14 @@ function defineWorkspaceTool< export const workspaceToolDefinitions = [ defineWorkspaceTool({ name: "workspace_list_items", + access: "read", description: "List items by absolute workspace path. If nextOffset is present, use it as offset to continue.", inputSchema: workspaceListItemsInputSchema, inputExamples: workspaceListItemsInputExamples, outputSchema: workspaceListItemsOutputSchema, summarizeResult: summarizeWorkspaceCollectionResult, - scopes: workspaceReadScopes, - mutating: false, + effects: { destructive: false, idempotent: true }, execute: async ({ limit, offset, path, recursive }, context) => { return await listWorkspaceItemsOperation(context, { offset, @@ -127,14 +143,14 @@ export const workspaceToolDefinitions = [ }), defineWorkspaceTool({ name: "workspace_read_items", + access: "read", 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. Defaults to page 1; read at most 20 pages per call and check pages.total before continuing.", inputSchema: workspaceReadItemsInputSchema, inputExamples: workspaceReadItemsInputExamples, outputSchema: workspaceReadItemsOutputSchema, summarizeResult: summarizeWorkspaceCollectionResult, - scopes: workspaceReadScopes, - mutating: false, + effects: { destructive: false, idempotent: true }, execute: async ({ pages, paths }, context) => { return await readWorkspaceItemsOperation(context, { pages, @@ -144,14 +160,14 @@ export const workspaceToolDefinitions = [ }), defineWorkspaceTool({ name: "workspace_rename_item", + access: "write", description: "Rename one actual ThinkEx workspace item by absolute path. If the requested final path already exists, the rename fails instead of auto-renaming.", inputSchema: workspaceRenameItemInputSchema, inputExamples: workspaceRenameItemInputExamples, outputSchema: workspaceRenameItemOutputSchema, summarizeResult: summarizeWorkspaceItemResult, - scopes: workspaceWriteScopes, - mutating: true, + effects: { destructive: false, idempotent: true }, execute: async ({ name, path }, context) => { return await renameWorkspaceItemOperation(context, { name, @@ -161,14 +177,14 @@ export const workspaceToolDefinitions = [ }), defineWorkspaceTool({ name: "workspace_move_items", + access: "write", description: "Move one or more actual ThinkEx workspace items into an existing folder or the workspace root. If the destination already has the same name, that move fails instead of auto-renaming.", inputSchema: workspaceMoveItemsInputSchema, inputExamples: workspaceMoveItemsInputExamples, outputSchema: workspaceMoveItemsOutputSchema, summarizeResult: summarizeWorkspaceCollectionResult, - scopes: workspaceWriteScopes, - mutating: true, + effects: { destructive: false, idempotent: true }, execute: async ({ destinationPath, paths }, context) => { return await moveWorkspaceItemsOperation(context, { destinationPath, @@ -178,13 +194,13 @@ export const workspaceToolDefinitions = [ }), defineWorkspaceTool({ name: "workspace_create_items", + access: "write", description: `Create one or more folders or documents at exact absolute paths. If a path already exists, creation fails instead of renaming. ${workspaceDocumentMarkdownMathInstruction}`, inputSchema: workspaceCreateItemsInputSchema, inputExamples: workspaceCreateItemsInputExamples, outputSchema: workspaceCreateItemsOutputSchema, summarizeResult: summarizeWorkspaceCollectionResult, - scopes: workspaceWriteScopes, - mutating: true, + effects: { destructive: false, idempotent: true }, execute: async ({ items }, context) => { return await createWorkspaceItemsOperation(context, { items, @@ -193,13 +209,13 @@ export const workspaceToolDefinitions = [ }), defineWorkspaceTool({ name: "workspace_delete_items", + access: "write", description: "Delete one or more actual ThinkEx workspace items by absolute path.", inputSchema: workspaceDeleteItemsInputSchema, inputExamples: workspaceDeleteItemsInputExamples, outputSchema: workspaceDeleteItemsOutputSchema, summarizeResult: summarizeWorkspaceCollectionResult, - scopes: workspaceWriteScopes, - mutating: true, + effects: { destructive: true, idempotent: true }, execute: async ({ paths }, context) => { return await deleteWorkspaceItemsOperation(context, { paths, @@ -208,13 +224,13 @@ export const workspaceToolDefinitions = [ }), defineWorkspaceTool({ name: "workspace_edit_item", + access: "write", description: `Edit one actual ThinkEx workspace document by absolute path, or add relationships from any workspace item. Read before editing unless the user requested a simple append or prepend. ${workspaceDocumentMarkdownMathInstruction}`, inputSchema: workspaceEditItemInputSchema, inputExamples: workspaceEditItemInputExamples, outputSchema: workspaceEditItemOutputSchema, summarizeResult: summarizeWorkspaceAppliedResult, - scopes: workspaceWriteScopes, - mutating: true, + effects: { destructive: true, idempotent: false }, execute: async ({ path, edits, relations }, context) => { return await editWorkspaceItemOperation(context, { path, @@ -225,14 +241,14 @@ export const workspaceToolDefinitions = [ }), defineWorkspaceTool({ name: "workspace_link_items", + access: "write", description: "Create relationships from one actual ThinkEx workspace item to other workspace items by absolute path.", inputSchema: workspaceLinkItemsInputSchema, inputExamples: workspaceLinkItemsInputExamples, outputSchema: workspaceLinkItemsOutputSchema, summarizeResult: summarizeWorkspaceItemResult, - scopes: workspaceWriteScopes, - mutating: true, + effects: { destructive: false, idempotent: false }, execute: async ({ path, relations }, context) => { return await linkWorkspaceItemsOperation(context, { path, From b8f07e5fe0dda8967d42e0f49cc9bca9d3eb51b3 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:29:09 -0400 Subject: [PATCH 03/24] feat(auth): add OAuth provider foundation --- drizzle/0002_lyrical_lorna_dane.sql | 101 ++ drizzle/meta/0002_snapshot.json | 1480 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 9 +- package.json | 1 + pnpm-lock.yaml | 32 +- src/db/schema.ts | 115 +++ src/lib/auth-client.ts | 3 +- src/lib/auth.server.ts | 79 +- src/routeTree.gen.ts | 21 + src/routes/oauth.consent.tsx | 103 ++ 10 files changed, 1913 insertions(+), 31 deletions(-) create mode 100644 drizzle/0002_lyrical_lorna_dane.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/routes/oauth.consent.tsx diff --git a/drizzle/0002_lyrical_lorna_dane.sql b/drizzle/0002_lyrical_lorna_dane.sql new file mode 100644 index 00000000..d8b93b02 --- /dev/null +++ b/drizzle/0002_lyrical_lorna_dane.sql @@ -0,0 +1,101 @@ +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 `oauth_access_token_client_id_idx` ON `oauth_access_token` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_access_token_session_id_idx` ON `oauth_access_token` (`session_id`);--> statement-breakpoint +CREATE INDEX `oauth_access_token_user_id_idx` ON `oauth_access_token` (`user_id`);--> statement-breakpoint +CREATE INDEX `oauth_access_token_refresh_id_idx` ON `oauth_access_token` (`refresh_id`);--> statement-breakpoint +CREATE TABLE `oauth_client` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `client_secret` text, + `disabled` integer DEFAULT false, + `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 `oauth_client_user_id_idx` ON `oauth_client` (`user_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 `oauth_consent_client_id_idx` ON `oauth_consent` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_consent_user_id_idx` ON `oauth_consent` (`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 `oauth_refresh_token_client_id_idx` ON `oauth_refresh_token` (`client_id`);--> statement-breakpoint +CREATE INDEX `oauth_refresh_token_session_id_idx` ON `oauth_refresh_token` (`session_id`);--> statement-breakpoint +CREATE INDEX `oauth_refresh_token_user_id_idx` ON `oauth_refresh_token` (`user_id`);--> statement-breakpoint +CREATE TABLE `rate_limit` ( + `id` text PRIMARY KEY NOT NULL, + `key` text NOT NULL, + `count` integer NOT NULL, + `last_request` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `rate_limit_key_unique` ON `rate_limit` (`key`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000..ffaa60cc --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1480 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5924d543-b976-4d9d-9198-4a0aa7702c9c", + "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": {} + }, + "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 + }, + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "oauth_access_token_user_id_idx": { + "name": "oauth_access_token_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_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, + "default": 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 + }, + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_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": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_consent_user_id_idx": { + "name": "oauth_consent_user_id_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 + }, + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "oauth_refresh_token_user_id_idx": { + "name": "oauth_refresh_token_user_id_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": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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..46a76c39 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": 1784222530925, + "tag": "0002_lyrical_lorna_dane", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index c1801855..c9682bc7 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dependencies": { "@ai-sdk/react": "^3.0.210", "@base-ui/react": "^1.6.0", + "@better-auth/oauth-provider": "1.6.23", "@cloudflare/codemode": "^0.4.3", "@cloudflare/containers": "^0.3.7", "@cloudflare/sandbox": "0.12.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b38e5c00..fbdc7720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.3.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@better-auth/oauth-provider': + specifier: 1.6.23 + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(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.20260702.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.7(zod@4.4.3)) '@cloudflare/codemode': specifier: ^0.4.3 version: 0.4.3(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@tanstack/ai@0.22.0(@opentelemetry/api@1.9.1))(ai@6.0.208(zod@4.4.3))(zod@4.4.3) @@ -813,6 +816,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.23': resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==} peerDependencies: @@ -5857,10 +5869,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'} @@ -8711,7 +8719,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)': @@ -9103,7 +9111,7 @@ snapshots: dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 better-call: 1.3.7(zod@4.4.3) jose: 6.2.3 @@ -9138,6 +9146,16 @@ snapshots: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.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(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.23(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(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.20260702.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.7(zod@4.4.3))': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + better-auth: 1.6.23(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.27(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(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.20260702.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.7(zod@4.4.3) + jose: 6.2.3 + zod: 4.4.3 + '@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.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.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) @@ -13470,8 +13488,6 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.8: {} - eventsource-parser@3.1.0: {} eventsource@3.0.7: diff --git a/src/db/schema.ts b/src/db/schema.ts index 25b5ff6d..3e39cbf7 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -96,6 +96,121 @@ export const verification = sqliteTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); +export const rateLimit = sqliteTable("rate_limit", { + id: text("id").primaryKey(), + key: text("key").notNull().unique(), + count: integer("count").notNull(), + lastRequest: integer("last_request").notNull(), +}); + +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" }).default(false), + skipConsent: integer("skip_consent", { mode: "boolean" }), + enableEndSession: integer("enable_end_session", { mode: "boolean" }), + subjectType: text("subject_type"), + scopes: text("scopes", { mode: "json" }).$type(), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "timestamp" }), + updatedAt: integer("updated_at", { mode: "timestamp" }), + name: text("name"), + uri: text("uri"), + icon: text("icon"), + contacts: text("contacts", { mode: "json" }).$type(), + tos: text("tos"), + policy: text("policy"), + softwareId: text("software_id"), + softwareVersion: text("software_version"), + softwareStatement: text("software_statement"), + redirectUris: text("redirect_uris", { mode: "json" }).$type().notNull(), + postLogoutRedirectUris: text("post_logout_redirect_uris", { mode: "json" }).$type(), + tokenEndpointAuthMethod: text("token_endpoint_auth_method"), + grantTypes: text("grant_types", { mode: "json" }).$type(), + responseTypes: text("response_types", { mode: "json" }).$type(), + public: integer("public", { mode: "boolean" }), + type: text("type"), + requirePKCE: integer("require_pkce", { mode: "boolean" }), + referenceId: text("reference_id"), + metadata: text("metadata", { mode: "json" }).$type>(), + }, + (table) => [index("oauth_client_user_id_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" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + revoked: integer("revoked", { mode: "timestamp" }), + authTime: integer("auth_time", { mode: "timestamp" }), + scopes: text("scopes", { mode: "json" }).$type().notNull(), + }, + (table) => [ + index("oauth_refresh_token_client_id_idx").on(table.clientId), + index("oauth_refresh_token_session_id_idx").on(table.sessionId), + index("oauth_refresh_token_user_id_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" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + scopes: text("scopes", { mode: "json" }).$type().notNull(), + }, + (table) => [ + index("oauth_access_token_client_id_idx").on(table.clientId), + index("oauth_access_token_session_id_idx").on(table.sessionId), + index("oauth_access_token_user_id_idx").on(table.userId), + index("oauth_access_token_refresh_id_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" }).$type().notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), + }, + (table) => [ + index("oauth_consent_client_id_idx").on(table.clientId), + index("oauth_consent_user_id_idx").on(table.userId), + ], +); + export const workspaces = sqliteTable( "workspaces", { diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index fe924cf6..5f3f0eac 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: [oauthProviderClient(), ...(import.meta.env.DEV ? [anonymousClient()] : [])], sessionOptions: { refetchInterval: 0, refetchOnWindowFocus: false, diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index 04f10885..a9e231b3 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -1,8 +1,9 @@ import { env as workerEnv } from "cloudflare:workers"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { betterAuth } from "better-auth/minimal"; -import { anonymous } from "better-auth/plugins"; +import { anonymous, jwt } from "better-auth/plugins"; import { tanstackStartCookies } from "better-auth/tanstack-start"; +import { oauthProvider } from "@better-auth/oauth-provider"; import { sql } from "drizzle-orm"; import { @@ -44,6 +45,24 @@ function getAuthSecret(env: AuthRuntimeEnv) { return secret; } +function getGoogleSocialProviders(env: AuthRuntimeEnv) { + if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { + return { + google: { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + prompt: "select_account" as const, + }, + }; + } + + if (isProduction) { + throw new Error("Google OAuth credentials are required in production."); + } + + return {}; +} + async function transferAnonymousUserData( database: Db, input: { anonymousUserId: string; newUserId: string }, @@ -154,6 +173,9 @@ async function transferAnonymousUserData( function createAuth(database: Db, env: AuthRuntimeEnv) { const baseURL = getAuthBaseURL(); + const appOrigin = + typeof baseURL === "string" ? baseURL : (baseURL.fallback ?? "http://localhost:3000"); + const mcpResource = `${appOrigin}/mcp`; return betterAuth({ database: drizzleAdapter(database, { @@ -162,6 +184,7 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { }), secret: getAuthSecret(env), baseURL, + disabledPaths: ["/token"], trustedOrigins: getTrustedAppOrigins(typeof baseURL === "string" ? baseURL : baseURL.fallback), session: { expiresIn: 60 * 60 * 24 * 90, @@ -173,33 +196,47 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { }, rateLimit: { enabled: isProduction, - storage: "memory", + storage: "database", }, advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip"], }, }, - socialProviders: { - google: { - clientId: env.GOOGLE_CLIENT_ID || "", - clientSecret: env.GOOGLE_CLIENT_SECRET || "", - prompt: "select_account", - }, - }, + socialProviders: getGoogleSocialProviders(env), plugins: [ - anonymous({ - emailDomainName: "anonymous.thinkex.app", - generateName: () => "Guest", - onLinkAccount: async ({ anonymousUser, newUser }) => { - const transferInput = { - anonymousUserId: anonymousUser.user.id, - newUserId: newUser.user.id, - }; - - await transferAnonymousUserData(database, transferInput); - await transferLinkedAccountResources(transferInput); - }, + ...(import.meta.env.DEV + ? [ + anonymous({ + emailDomainName: "anonymous.thinkex.app", + generateName: () => "Guest", + onLinkAccount: async ({ anonymousUser, newUser }) => { + const transferInput = { + anonymousUserId: anonymousUser.user.id, + newUserId: newUser.user.id, + }; + + await transferAnonymousUserData(database, transferInput); + await transferLinkedAccountResources(transferInput); + }, + }), + ] + : []), + jwt({ disableSettingJwtHeader: true }), + oauthProvider({ + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + clientRegistrationDefaultScopes: [ + "openid", + "offline_access", + "workspaces:read", + "workspaces:write", + ], + consentPage: "/oauth/consent", + grantTypes: ["authorization_code", "refresh_token"], + loginPage: "/login", + scopes: ["openid", "offline_access", "workspaces:read", "workspaces:write"], + validAudiences: [mcpResource], }), tanstackStartCookies(), ], diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 77eba439..d8ff641c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -20,6 +20,7 @@ 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 BlogIndexRouteImport } from './routes/blog.index' +import { Route as OauthConsentRouteImport } from './routes/oauth.consent' import { Route as InviteTokenRouteImport } from './routes/invite.$token' import { Route as BlogRssRouteImport } from './routes/blog.rss' import { Route as BlogSlugRouteImport } from './routes/blog.$slug' @@ -89,6 +90,11 @@ const BlogIndexRoute = BlogIndexRouteImport.update({ path: '/', getParentRoute: () => BlogRoute, } 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', @@ -185,6 +191,7 @@ export interface FileRoutesByFullPath { '/blog/$slug': typeof BlogSlugRoute '/blog/rss': typeof BlogRssRoute '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute '/blog/': typeof BlogIndexRoute '/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -210,6 +217,7 @@ export interface FileRoutesByTo { '/blog/$slug': typeof BlogSlugRoute '/blog/rss': typeof BlogRssRoute '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute '/blog': typeof BlogIndexRoute '/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -238,6 +246,7 @@ export interface FileRoutesById { '/blog/$slug': typeof BlogSlugRoute '/blog/rss': typeof BlogRssRoute '/invite/$token': typeof InviteTokenRoute + '/oauth/consent': typeof OauthConsentRoute '/blog/': typeof BlogIndexRoute '/_protected/workspaces/$workspaceId': typeof ProtectedWorkspacesWorkspaceIdRoute '/api/auth/$': typeof ApiAuthSplatRoute @@ -266,6 +275,7 @@ export interface FileRouteTypes { | '/blog/$slug' | '/blog/rss' | '/invite/$token' + | '/oauth/consent' | '/blog/' | '/workspaces/$workspaceId' | '/api/auth/$' @@ -291,6 +301,7 @@ export interface FileRouteTypes { | '/blog/$slug' | '/blog/rss' | '/invite/$token' + | '/oauth/consent' | '/blog' | '/workspaces/$workspaceId' | '/api/auth/$' @@ -318,6 +329,7 @@ export interface FileRouteTypes { | '/blog/$slug' | '/blog/rss' | '/invite/$token' + | '/oauth/consent' | '/blog/' | '/_protected/workspaces/$workspaceId' | '/api/auth/$' @@ -342,6 +354,7 @@ export interface RootRouteChildren { SitemapDotxmlRoute: typeof SitemapDotxmlRoute TermsRoute: typeof TermsRoute InviteTokenRoute: typeof InviteTokenRoute + OauthConsentRoute: typeof OauthConsentRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiPosthogSurveyFeedbackRoute: typeof ApiPosthogSurveyFeedbackRoute ApiV1WorkspacesRoute: typeof ApiV1WorkspacesRouteWithChildren @@ -426,6 +439,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BlogIndexRouteImport parentRoute: typeof BlogRoute } + '/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' @@ -606,6 +626,7 @@ const rootRouteChildren: RootRouteChildren = { SitemapDotxmlRoute: SitemapDotxmlRoute, TermsRoute: TermsRoute, InviteTokenRoute: InviteTokenRoute, + OauthConsentRoute: OauthConsentRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiPosthogSurveyFeedbackRoute: ApiPosthogSurveyFeedbackRoute, ApiV1WorkspacesRoute: ApiV1WorkspacesRouteWithChildren, diff --git a/src/routes/oauth.consent.tsx b/src/routes/oauth.consent.tsx new file mode 100644 index 00000000..5ceeb198 --- /dev/null +++ b/src/routes/oauth.consent.tsx @@ -0,0 +1,103 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Loader2 } from "lucide-react"; +import { useState } from "react"; +import { z } from "zod"; + +import { Button } from "#/components/ui/button"; +import { authClient } from "#/lib/auth-client"; + +const scopeLabels: Record = { + openid: "Identify your ThinkEx account", + offline_access: "Stay connected until you revoke access", + "workspaces:read": "View your workspaces and their contents", + "workspaces:write": "Create, edit, move, link, and delete workspace content", +}; + +export const Route = createFileRoute("/oauth/consent")({ + validateSearch: z.object({ + client_id: z.string(), + scope: z.string().optional(), + }), + component: OAuthConsentPage, +}); + +function OAuthConsentPage() { + const { client_id: clientId, scope } = Route.useSearch(); + const [pendingDecision, setPendingDecision] = useState<"allow" | "deny" | null>(null); + const [error, setError] = useState(null); + const scopes = scope?.split(" ").filter(Boolean) ?? []; + + async function decide(accept: boolean) { + setPendingDecision(accept ? "allow" : "deny"); + setError(null); + + const result = await authClient.oauth2.consent({ + accept, + scope, + }); + + if (result.error) { + setError(result.error.message ?? "Unable to complete authorization."); + setPendingDecision(null); + return; + } + + if (result.data && "url" in result.data && typeof result.data.url === "string") { + window.location.assign(result.data.url); + return; + } + + setError("The authorization server did not return a redirect."); + setPendingDecision(null); + } + + return ( +
+
+
+

Connect an MCP client

+

Allow access to ThinkEx?

+

+ Client {clientId} is requesting: +

+
+ +
    + {scopes.map((requestedScope) => ( +
  • + + {scopeLabels[requestedScope] ?? requestedScope} +
  • + ))} +
+ +

+ Your current role is checked again for every workspace operation. This connection cannot + grant access to a workspace you cannot already use. +

+ + {error ?

{error}

: null} + +
+ + +
+
+
+ ); +} From 2677a1eff2c0f7c44766176cb2a6cbd8baa36312 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:29:17 -0400 Subject: [PATCH 04/24] feat(mcp): expose workspace operations through code mode --- src/features/mcp/mcp-openapi.ts | 71 +++++++ src/features/mcp/mcp-operation-catalog.ts | 123 +++++++++++ .../mcp/mcp-operation-catalog.worker.test.ts | 45 ++++ src/features/mcp/mcp-route.ts | 194 ++++++++++++++++++ src/server.ts | 9 +- 5 files changed, 441 insertions(+), 1 deletion(-) create mode 100644 src/features/mcp/mcp-openapi.ts create mode 100644 src/features/mcp/mcp-operation-catalog.ts create mode 100644 src/features/mcp/mcp-operation-catalog.worker.test.ts create mode 100644 src/features/mcp/mcp-route.ts diff --git a/src/features/mcp/mcp-openapi.ts b/src/features/mcp/mcp-openapi.ts new file mode 100644 index 00000000..adbd7907 --- /dev/null +++ b/src/features/mcp/mcp-openapi.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +import { mcpOperations } from "#/features/mcp/mcp-operation-catalog"; + +function toOpenApiSchema(schema: z.ZodType) { + const { $schema: _schemaDialect, ...jsonSchema } = z.toJSONSchema(schema); + return jsonSchema; +} + +function buildOperationPath(operation: (typeof mcpOperations)[number]) { + return { + post: { + operationId: operation.name, + summary: operation.description, + security: [{ oauth2: [operation.requiredScope] }], + "x-thinkex-access": operation.access, + "x-thinkex-effects": operation.effects, + requestBody: { + required: true, + content: { + "application/json": { + schema: toOpenApiSchema(operation.inputSchema), + }, + }, + }, + responses: { + "200": { + description: "Operation result", + content: { + "application/json": { + schema: toOpenApiSchema(operation.outputSchema), + }, + }, + }, + }, + }, + }; +} + +export const mcpOpenApiSpec: Record = { + openapi: "3.1.0", + info: { + title: "ThinkEx workspace operations", + version: "1.0.0", + description: + "Discover and execute ThinkEx workspace operations. Start with workspace_list to find the user's workspaces and membership roles.", + }, + paths: Object.fromEntries( + mcpOperations.map((operation) => [ + `/operations/${operation.name}`, + buildOperationPath(operation), + ]), + ), + components: { + securitySchemes: { + oauth2: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: "/api/auth/oauth2/authorize", + tokenUrl: "/api/auth/oauth2/token", + scopes: { + "workspaces:read": "Read workspaces the user belongs to.", + "workspaces:write": "Modify workspaces when the user's role permits it.", + }, + }, + }, + }, + }, + }, +}; diff --git a/src/features/mcp/mcp-operation-catalog.ts b/src/features/mcp/mcp-operation-catalog.ts new file mode 100644 index 00000000..313f6e0c --- /dev/null +++ b/src/features/mcp/mcp-operation-catalog.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { workspaceSummarySchema } from "#/features/workspaces/contracts"; +import { createAccountAccessContext } from "#/features/workspaces/operations/account-access-context"; +import { listAccountWorkspacesOperation } from "#/features/workspaces/operations/list-workspaces"; +import { createWorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; +import { + getWorkspaceToolScopes, + type WorkspaceToolAccess, + type WorkspaceToolEffects, + workspaceToolDefinitions, +} from "#/features/workspaces/operations/workspace-tool-definitions"; + +export const mcpScopes = ["workspaces:read", "workspaces:write"] as const; + +export type McpScope = (typeof mcpScopes)[number]; + +export interface McpPrincipal { + scopes: ReadonlySet; + userId: string; +} + +export interface McpOperation { + access: WorkspaceToolAccess; + description: string; + effects: WorkspaceToolEffects; + execute: (input: unknown, principal: McpPrincipal, operationId: string) => Promise; + inputSchema: z.ZodType; + name: string; + outputSchema: z.ZodType; + requiredScope: McpScope; +} + +const listWorkspacesOutputSchema = z.object({ + workspaces: z.array(workspaceSummarySchema), +}); + +const listWorkspacesOperation: McpOperation = { + name: "workspace_list", + access: "read", + description: + "List every ThinkEx workspace the authenticated user belongs to, including their role in each workspace.", + effects: { destructive: false, idempotent: true }, + inputSchema: z.object({}), + outputSchema: listWorkspacesOutputSchema, + requiredScope: "workspaces:read", + execute: async (_input, principal) => { + return await listAccountWorkspacesOperation( + createAccountAccessContext({ + scopes: ["workspaces:read"], + userId: principal.userId, + }), + ); + }, +}; + +function adaptWorkspaceOperation( + definition: (typeof workspaceToolDefinitions)[number], +): McpOperation { + const inputSchema = z.object({ + workspaceId: z.string().min(1).describe("The workspace ID returned by workspace_list."), + args: definition.inputSchema, + }); + const envelopeSchema = z.object({ + workspaceId: z.string().min(1), + args: z.unknown(), + }); + + return { + name: definition.name, + access: definition.access, + description: definition.description, + effects: definition.effects, + inputSchema, + outputSchema: definition.outputSchema, + requiredScope: definition.access === "read" ? "workspaces:read" : "workspaces:write", + execute: async (input, principal, operationId) => { + const parsed = envelopeSchema.parse(input); + + return await definition.executeUnknown( + parsed.args, + createWorkspaceAccessContext({ + operationId, + scopes: getWorkspaceToolScopes(definition.access), + userId: principal.userId, + workspaceId: parsed.workspaceId, + }), + ); + }, + }; +} + +export const mcpOperations: readonly McpOperation[] = [ + listWorkspacesOperation, + ...workspaceToolDefinitions.map(adaptWorkspaceOperation), +]; + +const mcpOperationsByName = new Map( + mcpOperations.map((operation) => [operation.name, operation] as const), +); + +export function getMcpOperation(name: string) { + return mcpOperationsByName.get(name) ?? null; +} + +export async function executeMcpOperation(input: { + name: string; + body: unknown; + operationId: string; + principal: McpPrincipal; +}) { + const operation = getMcpOperation(input.name); + + if (!operation) { + throw new Error("Unknown MCP operation."); + } + + if (!input.principal.scopes.has(operation.requiredScope)) { + throw new Error(`Missing OAuth scope: ${operation.requiredScope}`); + } + + return await operation.execute(input.body, input.principal, input.operationId); +} diff --git a/src/features/mcp/mcp-operation-catalog.worker.test.ts b/src/features/mcp/mcp-operation-catalog.worker.test.ts new file mode 100644 index 00000000..2bd1de8f --- /dev/null +++ b/src/features/mcp/mcp-operation-catalog.worker.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("#/db/server", () => ({ + createDbContext: vi.fn(), +})); + +vi.mock("#/features/workspaces/server/permissions", () => ({ + assertCanMutateWorkspace: vi.fn(), + assertCanReadWorkspace: vi.fn(), +})); + +vi.mock("#/integrations/observability/operational-events", () => ({ + recordOperationalOutcome: vi.fn(), +})); + +import { getMcpOperation, mcpOperations } from "#/features/mcp/mcp-operation-catalog"; +import { mcpOpenApiSpec } from "#/features/mcp/mcp-openapi"; +import { workspaceToolDefinitions } from "#/features/workspaces/operations/workspace-tool-definitions"; + +describe("MCP operation catalog", () => { + it("publishes every workspace operation from the canonical registry", () => { + expect(mcpOperations.map(({ name }) => name)).toEqual([ + "workspace_list", + ...workspaceToolDefinitions.map(({ name }) => name), + ]); + }); + + it("derives OAuth scopes from read and write access", () => { + for (const operation of mcpOperations) { + expect(operation.requiredScope).toBe( + operation.access === "read" ? "workspaces:read" : "workspaces:write", + ); + } + }); + + it("marks deletion as destructive", () => { + expect(getMcpOperation("workspace_delete_items")?.effects.destructive).toBe(true); + }); + + it("generates one allowlisted OpenAPI path per operation", () => { + const paths = mcpOpenApiSpec.paths as Record; + + expect(Object.keys(paths)).toEqual(mcpOperations.map(({ name }) => `/operations/${name}`)); + }); +}); diff --git a/src/features/mcp/mcp-route.ts b/src/features/mcp/mcp-route.ts new file mode 100644 index 00000000..826972f3 --- /dev/null +++ b/src/features/mcp/mcp-route.ts @@ -0,0 +1,194 @@ +import { DynamicWorkerExecutor } from "@cloudflare/codemode"; +import { openApiMcpServer } from "@cloudflare/codemode/mcp"; +import { + mcpHandler, + oauthProviderAuthServerMetadata, + oauthProviderOpenIdConfigMetadata, +} from "@better-auth/oauth-provider"; +import { createMcpHandler } from "agents/mcp"; + +import { + executeMcpOperation, + getMcpOperation, + mcpScopes, + type McpPrincipal, +} from "#/features/mcp/mcp-operation-catalog"; +import { mcpOpenApiSpec } from "#/features/mcp/mcp-openapi"; +import { getAppOrigin } from "#/lib/app-origin"; +import { withAuth } from "#/lib/auth.server"; + +const mcpPath = "/mcp"; +const operationPathPrefix = "/operations/"; + +function getOAuthUrls() { + const origin = getAppOrigin(); + return { + issuer: `${origin}/api/auth`, + resource: `${origin}${mcpPath}`, + }; +} + +function getPrincipal(payload: { scope?: unknown; sub?: string }): McpPrincipal { + if (!payload.sub) { + throw new Error("OAuth access token is missing its subject."); + } + + return { + scopes: new Set( + typeof payload.scope === "string" ? payload.scope.split(" ").filter(Boolean) : [], + ), + userId: payload.sub, + }; +} + +async function confirmDelete( + server: ReturnType, + requestId: string | number, +) { + if (!server.server.getClientCapabilities()?.elicitation) { + throw new Error("This MCP client must support elicitation before deleting workspace items."); + } + + const result = await server.server.elicitInput( + { + mode: "form", + message: "Confirm deletion of the requested ThinkEx workspace items.", + requestedSchema: { + type: "object", + properties: { + confirm: { + type: "boolean", + title: "Delete workspace items", + description: "This action cannot be undone.", + }, + }, + required: ["confirm"], + }, + }, + { relatedRequestId: requestId }, + ); + + if (result.action !== "accept" || result.content?.confirm !== true) { + throw new Error("Workspace deletion was not confirmed."); + } +} + +function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { + const holder: { server?: ReturnType } = {}; + const server = openApiMcpServer({ + name: "ThinkEx", + version: "1.0.0", + description: + "Search and execute operations across the authenticated user's ThinkEx workspaces.", + spec: mcpOpenApiSpec, + executor: new DynamicWorkerExecutor({ + loader: env.LOADER, + globalOutbound: null, + }), + request: async (options, context) => { + if (options.method !== "POST" || !options.path.startsWith(operationPathPrefix)) { + throw new Error("Unsupported MCP operation request."); + } + + const operationName = options.path.slice(operationPathPrefix.length); + const operation = getMcpOperation(operationName); + + if (!operation) { + throw new Error("Unknown MCP operation."); + } + + if (operation.name === "workspace_delete_items") { + const activeServer = holder.server; + + if (!activeServer) { + throw new Error("MCP server is not ready."); + } + + await confirmDelete(activeServer, context.requestId); + } + + return await executeMcpOperation({ + name: operationName, + body: options.body, + operationId: `mcp:${String(context.requestId)}:${crypto.randomUUID()}`, + principal, + }); + }, + }); + + holder.server = server; + return server; +} + +function protectedResourceMetadata() { + const { issuer, resource } = getOAuthUrls(); + + return Response.json({ + resource, + authorization_servers: [issuer], + bearer_methods_supported: ["header"], + scopes_supported: mcpScopes, + }); +} + +async function handleAuthenticatedMcpRequest( + request: Request, + env: Cloudflare.Env, + ctx: ExecutionContext, +) { + const { issuer, resource } = getOAuthUrls(); + const authenticate = mcpHandler( + { + jwksUrl: `${issuer}/jwks`, + verifyOptions: { + audience: resource, + issuer, + }, + }, + async (authenticatedRequest, payload) => { + const server = createThinkExMcpServer(env, getPrincipal(payload)); + return await createMcpHandler(server, { route: mcpPath })(authenticatedRequest, env, ctx); + }, + ); + + return await authenticate(request); +} + +export async function routeMcpRequest( + request: Request, + env: Cloudflare.Env, + ctx: ExecutionContext, +): Promise { + const pathname = new URL(request.url).pathname; + + if ( + pathname === "/.well-known/oauth-protected-resource" || + pathname === `/.well-known/oauth-protected-resource${mcpPath}` + ) { + return protectedResourceMetadata(); + } + + if ( + pathname === "/.well-known/oauth-authorization-server" || + pathname === "/.well-known/oauth-authorization-server/api/auth" + ) { + return await withAuth(async (auth) => { + return await oauthProviderAuthServerMetadata(auth)(request); + }); + } + + if ( + pathname === "/.well-known/openid-configuration" || + pathname === "/.well-known/openid-configuration/api/auth" + ) { + return await withAuth(async (auth) => { + return await oauthProviderOpenIdConfigMetadata(auth)(request); + }); + } + + if (pathname !== mcpPath) { + return null; + } + + return await handleAuthenticatedMcpRequest(request, env, ctx); +} diff --git a/src/server.ts b/src/server.ts index dbc061b4..f2ecb2d2 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/mcp/mcp-route"; import { recordOperationalFailure } from "#/integrations/observability/operational-events"; import { posthogHost, posthogHostOrigin, posthogProjectToken } from "#/integrations/posthog/config"; import { getTelemetryRequestDetails } from "#/integrations/posthog/server-context"; @@ -73,8 +74,14 @@ function withSecurityHeaders(response: Response, env: Cloudflare.Env, request: R } 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 1bdb19523e8dc33a91a4b7a62591f9686da83aae Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:36:46 -0400 Subject: [PATCH 05/24] fix(ci): isolate staging Worker deployments --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c9682bc7..76dac5fc 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test": "vp test --run", "test:workers": "vp test --run --passWithNoTests --config vitest.cloudflare.config.ts", "deploy:worker": "CLOUDFLARE_ENV=production wrangler deploy", - "deploy:worker:staging": "CLOUDFLARE_ENV=staging wrangler deploy", + "deploy:worker:staging": "CLOUDFLARE_ENV=staging wrangler deploy --containers-rollout=none", "deploy": "vp run build:production && vp run deploy:worker", "deploy:staging": "vp run build:staging && vp run deploy:worker:staging", "db:generate": "drizzle-kit generate", From bf9e0c272e9e8838eeea395809f23184f93d6cd3 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:56:24 -0400 Subject: [PATCH 06/24] fix(auth): persist OAuth signing keys --- drizzle/0003_aberrant_nitro.sql | 7 + drizzle/meta/0003_snapshot.json | 1525 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema.ts | 8 + 4 files changed, 1547 insertions(+) create mode 100644 drizzle/0003_aberrant_nitro.sql create mode 100644 drizzle/meta/0003_snapshot.json diff --git a/drizzle/0003_aberrant_nitro.sql b/drizzle/0003_aberrant_nitro.sql new file mode 100644 index 00000000..f9e30d42 --- /dev/null +++ b/drizzle/0003_aberrant_nitro.sql @@ -0,0 +1,7 @@ +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 +); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..83249524 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1525 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "15338d4e-67b6-46a8-8377-fd6c21c4e1de", + "prevId": "5924d543-b976-4d9d-9198-4a0aa7702c9c", + "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 + }, + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "oauth_access_token_user_id_idx": { + "name": "oauth_access_token_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_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, + "default": 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 + }, + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_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": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_consent_user_id_idx": { + "name": "oauth_consent_user_id_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 + }, + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "oauth_refresh_token_user_id_idx": { + "name": "oauth_refresh_token_user_id_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": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rate_limit_key_unique": { + "name": "rate_limit_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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 46a76c39..1af096dc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1784222530925, "tag": "0002_lyrical_lorna_dane", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1784224556940, + "tag": "0003_aberrant_nitro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 3e39cbf7..790de4eb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -103,6 +103,14 @@ export const rateLimit = sqliteTable("rate_limit", { lastRequest: integer("last_request").notNull(), }); +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" }).notNull(), + expiresAt: integer("expires_at", { mode: "timestamp" }), +}); + export const oauthClient = sqliteTable( "oauth_client", { From a25f6b3d562287abb1ba5d2a65e81a603add525e Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:03:49 -0400 Subject: [PATCH 07/24] fix(mcp): allow browser OAuth preflights --- src/features/mcp/mcp-cors.test.ts | 34 +++++++++++++++++++++++++++++++ src/features/mcp/mcp-cors.ts | 30 +++++++++++++++++++++++++++ src/features/mcp/mcp-route.ts | 18 ++++++++++++---- src/routes/api/auth/$.ts | 14 +++++++++++-- 4 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 src/features/mcp/mcp-cors.test.ts create mode 100644 src/features/mcp/mcp-cors.ts diff --git a/src/features/mcp/mcp-cors.test.ts b/src/features/mcp/mcp-cors.test.ts new file mode 100644 index 00000000..f7e047bf --- /dev/null +++ b/src/features/mcp/mcp-cors.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { isMcpOAuthPath, mcpCorsPreflightResponse, withMcpCors } from "#/features/mcp/mcp-cors"; + +describe("MCP protocol CORS", () => { + it("only identifies the public MCP OAuth endpoints", () => { + expect(isMcpOAuthPath("/api/auth/oauth2/token")).toBe(true); + expect(isMcpOAuthPath("/api/auth/jwks")).toBe(true); + expect(isMcpOAuthPath("/api/auth/session")).toBe(false); + }); + + it("returns a reusable preflight response", () => { + const response = mcpCorsPreflightResponse(); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-methods")).toContain("POST"); + expect(response.headers.get("access-control-allow-headers")).toContain("authorization"); + }); + + it("preserves the response while exposing MCP headers", async () => { + const response = withMcpCors( + new Response("missing authorization header", { + status: 401, + headers: { "WWW-Authenticate": "Bearer" }, + }), + ); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("missing authorization header"); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-expose-headers")).toContain("www-authenticate"); + }); +}); diff --git a/src/features/mcp/mcp-cors.ts b/src/features/mcp/mcp-cors.ts new file mode 100644 index 00000000..1fe067de --- /dev/null +++ b/src/features/mcp/mcp-cors.ts @@ -0,0 +1,30 @@ +const mcpCorsRequestHeaders = "authorization, content-type, mcp-protocol-version"; +const mcpCorsResponseHeaders = "mcp-session-id, www-authenticate"; + +export function isMcpOAuthPath(pathname: string) { + return pathname === "/api/auth/jwks" || pathname.startsWith("/api/auth/oauth2/"); +} + +export function withMcpCors(response: Response) { + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", "*"); + headers.set("Access-Control-Expose-Headers", mcpCorsResponseHeaders); + + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +export function mcpCorsPreflightResponse() { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Headers": mcpCorsRequestHeaders, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Origin": "*", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/src/features/mcp/mcp-route.ts b/src/features/mcp/mcp-route.ts index 826972f3..0e9e7a6c 100644 --- a/src/features/mcp/mcp-route.ts +++ b/src/features/mcp/mcp-route.ts @@ -13,6 +13,7 @@ import { mcpScopes, type McpPrincipal, } from "#/features/mcp/mcp-operation-catalog"; +import { mcpCorsPreflightResponse, withMcpCors } from "#/features/mcp/mcp-cors"; import { mcpOpenApiSpec } from "#/features/mcp/mcp-openapi"; import { getAppOrigin } from "#/lib/app-origin"; import { withAuth } from "#/lib/auth.server"; @@ -160,12 +161,21 @@ export async function routeMcpRequest( ctx: ExecutionContext, ): Promise { const pathname = new URL(request.url).pathname; + const isMcpProtocolPath = + pathname === mcpPath || + pathname.startsWith("/.well-known/oauth-protected-resource") || + pathname.startsWith("/.well-known/oauth-authorization-server") || + pathname.startsWith("/.well-known/openid-configuration"); + + if (request.method === "OPTIONS" && isMcpProtocolPath) { + return mcpCorsPreflightResponse(); + } if ( pathname === "/.well-known/oauth-protected-resource" || pathname === `/.well-known/oauth-protected-resource${mcpPath}` ) { - return protectedResourceMetadata(); + return withMcpCors(protectedResourceMetadata()); } if ( @@ -173,7 +183,7 @@ export async function routeMcpRequest( pathname === "/.well-known/oauth-authorization-server/api/auth" ) { return await withAuth(async (auth) => { - return await oauthProviderAuthServerMetadata(auth)(request); + return withMcpCors(await oauthProviderAuthServerMetadata(auth)(request)); }); } @@ -182,7 +192,7 @@ export async function routeMcpRequest( pathname === "/.well-known/openid-configuration/api/auth" ) { return await withAuth(async (auth) => { - return await oauthProviderOpenIdConfigMetadata(auth)(request); + return withMcpCors(await oauthProviderOpenIdConfigMetadata(auth)(request)); }); } @@ -190,5 +200,5 @@ export async function routeMcpRequest( return null; } - return await handleAuthenticatedMcpRequest(request, env, ctx); + return withMcpCors(await handleAuthenticatedMcpRequest(request, env, ctx)); } diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts index 9c743286..ae9df50b 100644 --- a/src/routes/api/auth/$.ts +++ b/src/routes/api/auth/$.ts @@ -1,11 +1,21 @@ import { createFileRoute } from "@tanstack/react-router"; +import { isMcpOAuthPath, mcpCorsPreflightResponse, withMcpCors } from "#/features/mcp/mcp-cors"; import { withAuth } from "#/lib/auth.server"; +async function handleAuthRequest(request: Request) { + const response = await withAuth((auth) => auth.handler(request)); + return isMcpOAuthPath(new URL(request.url).pathname) ? withMcpCors(response) : response; +} + export const Route = createFileRoute("/api/auth/$")({ server: { handlers: { - GET: ({ request }) => withAuth((auth) => auth.handler(request)), - POST: ({ request }) => withAuth((auth) => auth.handler(request)), + GET: ({ request }) => handleAuthRequest(request), + OPTIONS: ({ request }) => + isMcpOAuthPath(new URL(request.url).pathname) + ? mcpCorsPreflightResponse() + : new Response(null, { status: 404 }), + POST: ({ request }) => handleAuthRequest(request), }, }, }); From 8b4239c380d5ba7fcd78ca9244cc68b4e69dc33d Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:10:43 -0400 Subject: [PATCH 08/24] fix(mcp): verify tokens with local signing keys --- src/features/mcp/mcp-route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/mcp/mcp-route.ts b/src/features/mcp/mcp-route.ts index 0e9e7a6c..829f5515 100644 --- a/src/features/mcp/mcp-route.ts +++ b/src/features/mcp/mcp-route.ts @@ -138,9 +138,13 @@ async function handleAuthenticatedMcpRequest( ctx: ExecutionContext, ) { const { issuer, resource } = getOAuthUrls(); + const getLocalJwks = () => withAuth((auth) => auth.api.getJwks()); const authenticate = mcpHandler( { - jwksUrl: `${issuer}/jwks`, + // Better Auth supports a JWKS callback at runtime, but its public type only + // exposes URL strings. A local callback avoids a Worker self-fetch through + // the public hostname while retaining Better Auth's token verifier. + jwksUrl: getLocalJwks as unknown as string, verifyOptions: { audience: resource, issuer, From bf387e2a898d19b9be1031deadb8da18e172a306 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:15:54 -0400 Subject: [PATCH 09/24] fix(workspaces): isolate batch mutation ids --- src/features/workspaces/operations/create-items.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index 75e459dc..f68754d0 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -154,7 +154,7 @@ export async function createWorkspaceItemsOperation( onNameConflict: "error", initialContent: initialContent.content, actorUserId: accessContext.actor.userId, - clientMutationId: accessContext.operationId, + clientMutationId: `${accessContext.operationId}:${index}`, }); } catch (error) { if (error instanceof WorkspaceKernelNameConflictError) { From 8bccd02e16293efbd6359f0ac2335d4e893e262c Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:22:58 -0400 Subject: [PATCH 10/24] fix(workspaces): recognize conflicts across RPC --- .../kernel/workspace-kernel-errors.ts | 27 +++++++++++++++++++ .../kernel/workspace-kernel-store.test.ts | 24 +++++++++++++++++ .../kernel/workspace-kernel-store.ts | 11 +------- .../workspaces/operations/create-items.ts | 4 +-- .../workspaces/operations/move-items.ts | 2 +- .../workspaces/operations/rename-item.ts | 4 +-- 6 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 src/features/workspaces/kernel/workspace-kernel-errors.ts create mode 100644 src/features/workspaces/kernel/workspace-kernel-store.test.ts diff --git a/src/features/workspaces/kernel/workspace-kernel-errors.ts b/src/features/workspaces/kernel/workspace-kernel-errors.ts new file mode 100644 index 00000000..3007b0d3 --- /dev/null +++ b/src/features/workspaces/kernel/workspace-kernel-errors.ts @@ -0,0 +1,27 @@ +export class WorkspaceKernelNameConflictError extends Error { + constructor( + readonly itemId?: string, + readonly requestedName?: string, + ) { + super("Workspace item name already exists."); + this.name = "WorkspaceKernelNameConflictError"; + } +} + +export function isWorkspaceKernelNameConflictError( + error: unknown, +): error is WorkspaceKernelNameConflictError { + if (error instanceof WorkspaceKernelNameConflictError) { + return true; + } + + if (!(error instanceof Error)) { + return false; + } + + return ( + error.name === "WorkspaceKernelNameConflictError" || + error.message === "Workspace item name already exists." || + error.message === "WorkspaceKernelNameConflictError: Workspace item name already exists." + ); +} diff --git a/src/features/workspaces/kernel/workspace-kernel-store.test.ts b/src/features/workspaces/kernel/workspace-kernel-store.test.ts new file mode 100644 index 00000000..ed5f368b --- /dev/null +++ b/src/features/workspaces/kernel/workspace-kernel-store.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { + isWorkspaceKernelNameConflictError, + WorkspaceKernelNameConflictError, +} from "#/features/workspaces/kernel/workspace-kernel-errors"; + +describe("isWorkspaceKernelNameConflictError", () => { + it("recognizes local name conflict errors", () => { + expect(isWorkspaceKernelNameConflictError(new WorkspaceKernelNameConflictError())).toBe(true); + }); + + it("recognizes name conflict errors serialized across Durable Object RPC", () => { + expect( + isWorkspaceKernelNameConflictError( + new Error("WorkspaceKernelNameConflictError: Workspace item name already exists."), + ), + ).toBe(true); + }); + + it("rejects unrelated errors", () => { + expect(isWorkspaceKernelNameConflictError(new Error("Forbidden"))).toBe(false); + }); +}); diff --git a/src/features/workspaces/kernel/workspace-kernel-store.ts b/src/features/workspaces/kernel/workspace-kernel-store.ts index 3414de5f..b53769c7 100644 --- a/src/features/workspaces/kernel/workspace-kernel-store.ts +++ b/src/features/workspaces/kernel/workspace-kernel-store.ts @@ -18,18 +18,9 @@ import { } from "#/features/workspaces/kernel/workspace-kernel-schema"; import { parseWorkspaceMetadataJson } from "#/features/workspaces/kernel/workspace-kernel-metadata"; import type { WorkspaceKernelNameConflictPolicy } from "#/features/workspaces/kernel/workspace-kernel-types"; +import { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; import { getMetadataNumber } from "#/features/workspaces/model/workspace-file"; -export class WorkspaceKernelNameConflictError extends Error { - constructor( - readonly itemId?: string, - readonly requestedName?: string, - ) { - super("Workspace item name already exists."); - this.name = "WorkspaceKernelNameConflictError"; - } -} - export class WorkspaceKernelStore { private readonly sql: WorkspaceKernelSql; private readonly workspaceId: () => string; diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index f68754d0..dd64cddd 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -20,7 +20,7 @@ import { WorkspaceKernelPathError, type WorkspaceKernelTree, } from "#/features/workspaces/kernel/workspace-kernel-paths"; -import { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-store"; +import { isWorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; export interface CreateWorkspaceItemOperationInput { type: "document" | "folder"; @@ -157,7 +157,7 @@ export async function createWorkspaceItemsOperation( clientMutationId: `${accessContext.operationId}:${index}`, }); } catch (error) { - if (error instanceof WorkspaceKernelNameConflictError) { + if (isWorkspaceKernelNameConflictError(error)) { failed.push({ code: "path_already_exists", index, diff --git a/src/features/workspaces/operations/move-items.ts b/src/features/workspaces/operations/move-items.ts index b2104a8c..0dee7e09 100644 --- a/src/features/workspaces/operations/move-items.ts +++ b/src/features/workspaces/operations/move-items.ts @@ -10,7 +10,7 @@ import { joinWorkspaceItemPath, type WorkspaceKernelTree, } from "#/features/workspaces/kernel/workspace-kernel-paths"; -import { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-store"; +import { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; export interface MoveWorkspaceItemsOperationInput { destinationPath: string; diff --git a/src/features/workspaces/operations/rename-item.ts b/src/features/workspaces/operations/rename-item.ts index 50af96cf..7127aef0 100644 --- a/src/features/workspaces/operations/rename-item.ts +++ b/src/features/workspaces/operations/rename-item.ts @@ -8,7 +8,7 @@ import { getParentWorkspacePath, joinWorkspaceItemPath, } from "#/features/workspaces/kernel/workspace-kernel-paths"; -import { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-store"; +import { isWorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; export interface RenameWorkspaceItemOperationInput { name: string; @@ -81,7 +81,7 @@ export async function renameWorkspaceItemOperation( }, }; } catch (error) { - if (error instanceof WorkspaceKernelNameConflictError) { + if (isWorkspaceKernelNameConflictError(error)) { return { failed: [ { From c69c4df31217b94ce89e64441963994efdaf3d50 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:23:01 -0400 Subject: [PATCH 11/24] chore(auth): silence verified metadata warning --- src/lib/auth.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index a9e231b3..5ca813f8 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -236,6 +236,7 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { grantTypes: ["authorization_code", "refresh_token"], loginPage: "/login", scopes: ["openid", "offline_access", "workspaces:read", "workspaces:write"], + silenceWarnings: { oauthAuthServerConfig: true }, validAudiences: [mcpResource], }), tanstackStartCookies(), From 078491b820cf27050d2f22189efc211046a82026 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:27:07 -0400 Subject: [PATCH 12/24] fix(workspaces): match serialized conflict errors --- .../workspaces/kernel/workspace-kernel-errors.ts | 14 ++++++-------- .../kernel/workspace-kernel-store.test.ts | 6 +++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/features/workspaces/kernel/workspace-kernel-errors.ts b/src/features/workspaces/kernel/workspace-kernel-errors.ts index 3007b0d3..1df398b6 100644 --- a/src/features/workspaces/kernel/workspace-kernel-errors.ts +++ b/src/features/workspaces/kernel/workspace-kernel-errors.ts @@ -15,13 +15,11 @@ export function isWorkspaceKernelNameConflictError( return true; } - if (!(error instanceof Error)) { - return false; - } + const serialized = String(error); - return ( - error.name === "WorkspaceKernelNameConflictError" || - error.message === "Workspace item name already exists." || - error.message === "WorkspaceKernelNameConflictError: Workspace item name already exists." - ); + return [ + "WorkspaceKernelNameConflictError: Workspace item name already exists.", + "Error: WorkspaceKernelNameConflictError: Workspace item name already exists.", + "Error: Workspace item name already exists.", + ].includes(serialized); } diff --git a/src/features/workspaces/kernel/workspace-kernel-store.test.ts b/src/features/workspaces/kernel/workspace-kernel-store.test.ts index ed5f368b..5591c444 100644 --- a/src/features/workspaces/kernel/workspace-kernel-store.test.ts +++ b/src/features/workspaces/kernel/workspace-kernel-store.test.ts @@ -12,9 +12,9 @@ describe("isWorkspaceKernelNameConflictError", () => { it("recognizes name conflict errors serialized across Durable Object RPC", () => { expect( - isWorkspaceKernelNameConflictError( - new Error("WorkspaceKernelNameConflictError: Workspace item name already exists."), - ), + isWorkspaceKernelNameConflictError({ + toString: () => "WorkspaceKernelNameConflictError: Workspace item name already exists.", + }), ).toBe(true); }); From 7c432bd52425cfd2419ab9d2fb536917bd143142 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:37:17 -0400 Subject: [PATCH 13/24] refactor(mcp): trust workspace permissions for deletes --- src/features/mcp/mcp-route.ts | 44 ----------------------------------- 1 file changed, 44 deletions(-) diff --git a/src/features/mcp/mcp-route.ts b/src/features/mcp/mcp-route.ts index 829f5515..c463293c 100644 --- a/src/features/mcp/mcp-route.ts +++ b/src/features/mcp/mcp-route.ts @@ -42,40 +42,7 @@ function getPrincipal(payload: { scope?: unknown; sub?: string }): McpPrincipal }; } -async function confirmDelete( - server: ReturnType, - requestId: string | number, -) { - if (!server.server.getClientCapabilities()?.elicitation) { - throw new Error("This MCP client must support elicitation before deleting workspace items."); - } - - const result = await server.server.elicitInput( - { - mode: "form", - message: "Confirm deletion of the requested ThinkEx workspace items.", - requestedSchema: { - type: "object", - properties: { - confirm: { - type: "boolean", - title: "Delete workspace items", - description: "This action cannot be undone.", - }, - }, - required: ["confirm"], - }, - }, - { relatedRequestId: requestId }, - ); - - if (result.action !== "accept" || result.content?.confirm !== true) { - throw new Error("Workspace deletion was not confirmed."); - } -} - function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { - const holder: { server?: ReturnType } = {}; const server = openApiMcpServer({ name: "ThinkEx", version: "1.0.0", @@ -98,16 +65,6 @@ function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { throw new Error("Unknown MCP operation."); } - if (operation.name === "workspace_delete_items") { - const activeServer = holder.server; - - if (!activeServer) { - throw new Error("MCP server is not ready."); - } - - await confirmDelete(activeServer, context.requestId); - } - return await executeMcpOperation({ name: operationName, body: options.body, @@ -117,7 +74,6 @@ function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { }, }); - holder.server = server; return server; } From 38380ebb8b16faf65a096e24fd5a0ce60368a5f1 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:09:07 -0400 Subject: [PATCH 14/24] refactor(workspaces): return typed mutation conflicts Represent expected name conflicts as serializable kernel outcomes so Cloudflare RPC does not discard conflict metadata. Create initial relations in the same kernel command to avoid partial workspace mutations. --- .../kernel/workspace-kernel-access.ts | 110 +++++++++++------- .../kernel/workspace-kernel-errors.ts | 25 ---- .../kernel/workspace-kernel-file-commands.ts | 6 +- .../kernel/workspace-kernel-item-commands.ts | 88 ++++++++++---- .../kernel/workspace-kernel-store.test.ts | 24 ---- .../kernel/workspace-kernel-store.ts | 39 +++++-- .../kernel/workspace-kernel-types.ts | 30 +++++ .../workspaces/kernel/workspace-kernel.ts | 7 +- .../workspaces/operations/create-items.ts | 47 +++----- .../workspaces/operations/move-items.ts | 84 ++++++------- .../workspaces/operations/rename-item.ts | 54 +++++---- .../v1/workspaces.$workspaceId.file-upload.ts | 23 ++-- 12 files changed, 294 insertions(+), 243 deletions(-) delete mode 100644 src/features/workspaces/kernel/workspace-kernel-errors.ts delete mode 100644 src/features/workspaces/kernel/workspace-kernel-store.test.ts diff --git a/src/features/workspaces/kernel/workspace-kernel-access.ts b/src/features/workspaces/kernel/workspace-kernel-access.ts index fbefb5fb..739497d3 100644 --- a/src/features/workspaces/kernel/workspace-kernel-access.ts +++ b/src/features/workspaces/kernel/workspace-kernel-access.ts @@ -1,5 +1,3 @@ -import { getAgentByName } from "agents"; - import { createDbContext } from "#/db/server"; import { workspaceKernelAgentName } from "#/features/workspaces/agent-routes"; import type { ResourcePurgeResult } from "#/features/workspaces/resource-purge-result"; @@ -14,19 +12,21 @@ import type { WorkspaceItemSummary, WorkspacePage, } from "#/features/workspaces/contracts"; -import type { - CreateWorkspaceKernelFileFromUploadArgs, - CreateWorkspaceKernelRelationArgs, - DeleteWorkspaceKernelItemsResult, - ListWorkspaceKernelItemRelationsArgs, - MoveWorkspaceKernelItemsResult, - ReadWorkspaceKernelFilePreviewResult, - ReadWorkspaceKernelFileProjectionArgs, - ReadWorkspaceKernelFileProjectionResult, - UpsertWorkspaceKernelFileProjectionArgs, - WorkspaceKernelFileSource, - WorkspaceKernelItemRelation, - WorkspaceKernelNameConflictPolicy, +import { + requireAppliedWorkspaceKernelMutation, + type CreateWorkspaceKernelFileFromUploadArgs, + type CreateWorkspaceKernelRelationArgs, + type DeleteWorkspaceKernelItemsResult, + type ListWorkspaceKernelItemRelationsArgs, + type MoveWorkspaceKernelItemsResult, + type ReadWorkspaceKernelFilePreviewResult, + type ReadWorkspaceKernelFileProjectionArgs, + type ReadWorkspaceKernelFileProjectionResult, + type UpsertWorkspaceKernelFileProjectionArgs, + type WorkspaceKernelFileSource, + type WorkspaceKernelItemRelation, + type WorkspaceKernelNameConflictPolicy, + type WorkspaceKernelMutationOutcome, } from "#/features/workspaces/kernel/workspace-kernel-types"; import type { WorkspaceFileAssetKind } from "#/features/workspaces/model/workspace-file"; import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages"; @@ -61,9 +61,10 @@ export interface WorkspaceKernelClient { color?: CreateWorkspaceItemInput["color"]; metadataJson?: Record; initialContent?: string; + initialRelations?: CreateWorkspaceKernelRelationArgs[]; actorUserId?: string | null; clientMutationId?: string | null; - }): Promise>; + }): Promise>; createFileFromUpload( input: CreateWorkspaceKernelFileFromUploadArgs, ): Promise>; @@ -73,7 +74,7 @@ export interface WorkspaceKernelClient { onNameConflict?: WorkspaceKernelNameConflictPolicy; actorUserId?: string | null; clientMutationId?: string | null; - }): Promise>; + }): Promise>; moveItems(input: { items: Array<{ itemId: string; @@ -83,7 +84,7 @@ export interface WorkspaceKernelClient { onNameConflict?: WorkspaceKernelNameConflictPolicy; actorUserId?: string | null; clientMutationId?: string | null; - }): Promise>; + }): Promise>; updateItemColor(input: { itemId: string; color: UpdateWorkspaceItemColorInput["color"]; @@ -179,16 +180,18 @@ export async function createWorkspaceKernelItem( await assertCanMutateWorkspace(dbContext.db, input); const kernel = await getWorkspaceKernel(input.workspaceId); - return await kernel.createItem({ - id: input.id, - parentId: input.parentId ?? null, - type: input.type, - name: input.name, - color: input.color, - initialContent: input.initialContent, - actorUserId: input.userId, - clientMutationId: input.clientMutationId ?? null, - }); + return requireAppliedWorkspaceKernelMutation( + await kernel.createItem({ + id: input.id, + parentId: input.parentId ?? null, + type: input.type, + name: input.name, + color: input.color, + initialContent: input.initialContent, + actorUserId: input.userId, + clientMutationId: input.clientMutationId ?? null, + }), + ); } finally { await dbContext.dispose(); } @@ -241,12 +244,14 @@ export async function renameWorkspaceKernelItem( await assertCanMutateWorkspace(dbContext.db, input); const kernel = await getWorkspaceKernel(input.workspaceId); - return await kernel.renameItem({ - itemId: input.itemId, - name: input.name, - actorUserId: input.userId, - clientMutationId: input.clientMutationId ?? null, - }); + return requireAppliedWorkspaceKernelMutation( + await kernel.renameItem({ + itemId: input.itemId, + name: input.name, + actorUserId: input.userId, + clientMutationId: input.clientMutationId ?? null, + }), + ); } finally { await dbContext.dispose(); } @@ -261,12 +266,14 @@ export async function moveWorkspaceKernelItems( await assertCanMutateWorkspace(dbContext.db, input); const kernel = await getWorkspaceKernel(input.workspaceId); - return await kernel.moveItems({ - items: input.items, - parentId: input.parentId ?? null, - actorUserId: input.userId, - clientMutationId: input.clientMutationId ?? null, - }); + return requireAppliedWorkspaceKernelMutation( + await kernel.moveItems({ + items: input.items, + parentId: input.parentId ?? null, + actorUserId: input.userId, + clientMutationId: input.clientMutationId ?? null, + }), + ); } finally { await dbContext.dispose(); } @@ -328,8 +335,23 @@ export async function getWorkspaceKernelFromEnv( env: Cloudflare.Env, workspaceId: string, ): Promise { - return getAgentByName( - env[workspaceKernelAgentName], - workspaceId, - ) as unknown as WorkspaceKernelClient; + // The generated recursive Agent stub exceeds TypeScript's instantiation depth. + // Keep that SDK limitation at this binding boundary instead of leaking casts to callers. + const namespace: unknown = Reflect.get(env as object, workspaceKernelAgentName); + if (!isWorkspaceKernelNamespace(namespace)) { + throw new Error("Workspace kernel binding is unavailable."); + } + + return namespace.getByName(workspaceId); +} + +function isWorkspaceKernelNamespace( + value: unknown, +): value is { getByName(name: string): WorkspaceKernelClient } { + return ( + typeof value === "object" && + value !== null && + "getByName" in value && + typeof value.getByName === "function" + ); } diff --git a/src/features/workspaces/kernel/workspace-kernel-errors.ts b/src/features/workspaces/kernel/workspace-kernel-errors.ts deleted file mode 100644 index 1df398b6..00000000 --- a/src/features/workspaces/kernel/workspace-kernel-errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -export class WorkspaceKernelNameConflictError extends Error { - constructor( - readonly itemId?: string, - readonly requestedName?: string, - ) { - super("Workspace item name already exists."); - this.name = "WorkspaceKernelNameConflictError"; - } -} - -export function isWorkspaceKernelNameConflictError( - error: unknown, -): error is WorkspaceKernelNameConflictError { - if (error instanceof WorkspaceKernelNameConflictError) { - return true; - } - - const serialized = String(error); - - return [ - "WorkspaceKernelNameConflictError: Workspace item name already exists.", - "Error: WorkspaceKernelNameConflictError: Workspace item name already exists.", - "Error: Workspace item name already exists.", - ].includes(serialized); -} diff --git a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts index ae5e35d0..87b03a01 100644 --- a/src/features/workspaces/kernel/workspace-kernel-file-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-file-commands.ts @@ -118,12 +118,16 @@ export class WorkspaceKernelFileCommands { const now = Date.now(); const itemId = input.id; const requestedName = normalizeWorkspaceUploadFileName(input.fileName, descriptor); - const name = this.store.resolveItemName({ + const nameResolution = this.store.resolveItemName({ itemId, type: "file", parentId, requestedName, }); + if (nameResolution.status === "conflict") { + throw new Error("Automatic file naming unexpectedly produced a conflict."); + } + const name = nameResolution.name; const shellPath = getWorkspaceKernelFileShellPath({ itemId, extension: getWorkspaceFileShellExtension({ diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index 96e39e18..add5b236 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -30,6 +30,7 @@ import type { RenameWorkspaceKernelItemArgs, UpdateWorkspaceKernelItemColorArgs, WriteWorkspaceKernelItemArgs, + WorkspaceKernelMutationOutcome, } from "#/features/workspaces/kernel/workspace-kernel-types"; import { resolveWorkspaceItemColorForCreate, @@ -63,7 +64,7 @@ export class WorkspaceKernelItemCommands { async createItem( input: CreateWorkspaceKernelItemArgs, - ): Promise> { + ): Promise> { const type = workspaceItemTypeSchema.parse(input.type); if (type === "file") { @@ -86,7 +87,7 @@ export class WorkspaceKernelItemCommands { const priorResult = getPriorResult(); if (priorResult) { - return priorResult; + return { command: priorResult, status: "applied" }; } const color = resolveWorkspaceItemColorForCreate({ @@ -100,13 +101,28 @@ export class WorkspaceKernelItemCommands { } this.store.assertParentIsValid(parentId); - const name = this.store.resolveItemName({ + const nameResolution = this.store.resolveItemName({ itemId: id, type, parentId, requestedName: input.name, onNameConflict: input.onNameConflict, }); + + if (nameResolution.status === "conflict") { + return nameResolution; + } + + const name = nameResolution.name; + const initialRelations = input.initialRelations ?? []; + + for (const relation of initialRelations) { + if (relation.fromItemId !== id) { + throw new Error("Initial workspace relations must originate from the created item."); + } + + this.store.assertActiveItem(relation.toItemId); + } const shellPath = getWorkspaceKernelShellPath({ id, type }); const { initialContent, metadataJson } = buildWorkspaceItemCreateBootstrap({ type, @@ -124,7 +140,7 @@ export class WorkspaceKernelItemCommands { const concurrentResult = getPriorResult(); if (concurrentResult) { - return concurrentResult; + return { command: concurrentResult, status: "applied" }; } this.sql` @@ -157,6 +173,7 @@ export class WorkspaceKernelItemCommands { `; const item = this.store.requireItem(id); + this.relations.createRelations(initialRelations); const event = this.events.commit({ type: "workspace.item.created", actorUserId: input.actorUserId ?? null, @@ -164,19 +181,19 @@ export class WorkspaceKernelItemCommands { payload: { item }, }); - return { result: item, event }; + return { command: { result: item, event }, status: "applied" }; } async renameItem( input: RenameWorkspaceKernelItemArgs, - ): Promise> { + ): Promise> { if (!input.name.trim()) { throw new Error("Item name is required."); } const existingItem = this.store.assertActiveItem(input.itemId); const type = workspaceItemTypeSchema.parse(existingItem.type); - const name = this.store.resolveItemName({ + const nameResolution = this.store.resolveItemName({ itemId: existingItem.id, type, parentId: existingItem.parent_id, @@ -185,23 +202,30 @@ export class WorkspaceKernelItemCommands { onNameConflict: input.onNameConflict, }); + if (nameResolution.status === "conflict") { + return nameResolution; + } + this.sql` UPDATE kernel_items - SET name = ${name}, updated_at = ${Date.now()} + SET name = ${nameResolution.name}, updated_at = ${Date.now()} WHERE id = ${input.itemId} AND deleted_at IS NULL `; - return this.commitItemEvent({ - type: "workspace.item.renamed", - itemId: input.itemId, - actorUserId: input.actorUserId, - clientMutationId: input.clientMutationId, - }); + return { + command: this.commitItemEvent({ + type: "workspace.item.renamed", + itemId: input.itemId, + actorUserId: input.actorUserId, + clientMutationId: input.clientMutationId, + }), + status: "applied", + }; } async moveItems( input: MoveWorkspaceKernelItemsArgs, - ): Promise> { + ): Promise> { const parentId = input.parentId ?? null; const movesByItemId = new Map(input.items.map((item) => [item.itemId, item])); const roots = this.getUniqueRootRows(input.items.map((item) => item.itemId)); @@ -220,7 +244,11 @@ export class WorkspaceKernelItemCommands { rows: roots, }); - for (const plannedMove of plannedMoves) { + if (plannedMoves.status === "conflict") { + return plannedMoves; + } + + for (const plannedMove of plannedMoves.rows) { movedItems.push( this.moveItemRow({ name: plannedMove.name, @@ -238,7 +266,7 @@ export class WorkspaceKernelItemCommands { payload: { items: movedItems }, }); - return { result: movedItems, event }; + return { command: { result: movedItems, event }, status: "applied" }; } async updateItemColor( @@ -422,9 +450,15 @@ export class WorkspaceKernelItemCommands { }) { const reservedNames: string[] = []; - return input.rows.map((row) => { + const rows: Array<{ + name: string; + row: KernelItemRow; + sortOrder?: number; + }> = []; + + for (const row of input.rows) { const type = workspaceItemTypeSchema.parse(row.type); - const name = this.store.resolveItemName({ + const nameResolution = this.store.resolveItemName({ itemId: row.id, type, parentId: input.parentId, @@ -434,14 +468,20 @@ export class WorkspaceKernelItemCommands { reservedNames, }); - reservedNames.push(name); + if (nameResolution.status === "conflict") { + return nameResolution; + } - return { - name, + reservedNames.push(nameResolution.name); + + rows.push({ + name: nameResolution.name, row, sortOrder: input.movesByItemId.get(row.id)?.sortOrder, - }; - }); + }); + } + + return { rows, status: "resolved" as const }; } private getUniqueRootRows(itemIds: string[]) { diff --git a/src/features/workspaces/kernel/workspace-kernel-store.test.ts b/src/features/workspaces/kernel/workspace-kernel-store.test.ts deleted file mode 100644 index 5591c444..00000000 --- a/src/features/workspaces/kernel/workspace-kernel-store.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - isWorkspaceKernelNameConflictError, - WorkspaceKernelNameConflictError, -} from "#/features/workspaces/kernel/workspace-kernel-errors"; - -describe("isWorkspaceKernelNameConflictError", () => { - it("recognizes local name conflict errors", () => { - expect(isWorkspaceKernelNameConflictError(new WorkspaceKernelNameConflictError())).toBe(true); - }); - - it("recognizes name conflict errors serialized across Durable Object RPC", () => { - expect( - isWorkspaceKernelNameConflictError({ - toString: () => "WorkspaceKernelNameConflictError: Workspace item name already exists.", - }), - ).toBe(true); - }); - - it("rejects unrelated errors", () => { - expect(isWorkspaceKernelNameConflictError(new Error("Forbidden"))).toBe(false); - }); -}); diff --git a/src/features/workspaces/kernel/workspace-kernel-store.ts b/src/features/workspaces/kernel/workspace-kernel-store.ts index b53769c7..690f1a02 100644 --- a/src/features/workspaces/kernel/workspace-kernel-store.ts +++ b/src/features/workspaces/kernel/workspace-kernel-store.ts @@ -18,7 +18,7 @@ import { } from "#/features/workspaces/kernel/workspace-kernel-schema"; import { parseWorkspaceMetadataJson } from "#/features/workspaces/kernel/workspace-kernel-metadata"; import type { WorkspaceKernelNameConflictPolicy } from "#/features/workspaces/kernel/workspace-kernel-types"; -import { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; +import type { WorkspaceKernelNameConflict } from "#/features/workspaces/kernel/workspace-kernel-types"; import { getMetadataNumber } from "#/features/workspaces/model/workspace-file"; export class WorkspaceKernelStore { @@ -205,7 +205,9 @@ export class WorkspaceKernelStore { excludeItemId?: string; onNameConflict?: WorkspaceKernelNameConflictPolicy; reservedNames?: string[]; - }) { + }): + | { name: string; status: "resolved" } + | { conflict: WorkspaceKernelNameConflict; status: "conflict" } { const existingNames = [ ...this.getActiveSiblingNames(input.parentId, input.excludeItemId), ...(input.reservedNames ?? []), @@ -216,21 +218,38 @@ export class WorkspaceKernelStore { if (input.onNameConflict === "error") { if (!requestedName) { - throw new WorkspaceKernelNameConflictError(input.itemId, input.requestedName); + return { + conflict: { + code: "name_conflict", + itemId: input.itemId ?? null, + requestedName: input.requestedName?.trim() || null, + }, + status: "conflict", + }; } if (existingNames.includes(requestedName)) { - throw new WorkspaceKernelNameConflictError(input.itemId, requestedName); + return { + conflict: { + code: "name_conflict", + itemId: input.itemId ?? null, + requestedName, + }, + status: "conflict", + }; } - return requestedName; + return { name: requestedName, status: "resolved" }; } - return getAvailableWorkspaceItemName({ - type: input.type, - requestedName: input.requestedName, - existingNames, - }); + return { + name: getAvailableWorkspaceItemName({ + type: input.type, + requestedName: input.requestedName, + existingNames, + }), + status: "resolved", + }; } requireItem(itemId: string) { diff --git a/src/features/workspaces/kernel/workspace-kernel-types.ts b/src/features/workspaces/kernel/workspace-kernel-types.ts index 14f3be2d..a860a33b 100644 --- a/src/features/workspaces/kernel/workspace-kernel-types.ts +++ b/src/features/workspaces/kernel/workspace-kernel-types.ts @@ -10,6 +10,7 @@ import type { WorkspaceFileAssetKind, WorkspaceUploadConversion, } from "#/features/workspaces/model/workspace-file"; +import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages"; export interface WorkspaceKernelPage { workspaceId: string; @@ -45,6 +46,34 @@ export interface ListWorkspaceKernelItemsArgs { export type WorkspaceKernelNameConflictPolicy = "rename" | "error"; +export interface WorkspaceKernelNameConflict { + code: "name_conflict"; + itemId: string | null; + requestedName: string | null; +} + +export type WorkspaceKernelMutationOutcome = + | { + command: WorkspaceCommandResult; + status: "applied"; + } + | { + conflict: WorkspaceKernelNameConflict; + status: "conflict"; + }; + +export function requireAppliedWorkspaceKernelMutation( + outcome: WorkspaceKernelMutationOutcome, +): WorkspaceCommandResult { + if (outcome.status === "conflict") { + throw new Error("Workspace kernel unexpectedly returned a name conflict.", { + cause: outcome.conflict, + }); + } + + return outcome.command; +} + export interface CreateWorkspaceKernelItemArgs { id?: string; parentId?: string | null; @@ -54,6 +83,7 @@ export interface CreateWorkspaceKernelItemArgs { color?: WorkspaceItemColor; metadataJson?: Record; initialContent?: string; + initialRelations?: CreateWorkspaceKernelRelationArgs[]; actorUserId?: string | null; clientMutationId?: string | null; } diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index 9c02b1da..93d69998 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -36,6 +36,7 @@ import type { UpdateWorkspaceKernelItemColorArgs, UpsertWorkspaceKernelFileProjectionArgs, WorkspaceKernelPage, + WorkspaceKernelMutationOutcome, WriteWorkspaceKernelItemArgs, } from "#/features/workspaces/kernel/workspace-kernel-types"; import { getChatAttachmentWorkspacePrefix } from "#/features/workspaces/ai/chat-attachment-storage"; @@ -142,7 +143,7 @@ export class WorkspaceKernel extends Agent { async createItem( input: CreateWorkspaceKernelItemArgs, - ): Promise> { + ): Promise> { return this.runMutation("create_item", input, 1, () => this.itemCommands.createItem(input)); } @@ -170,13 +171,13 @@ export class WorkspaceKernel extends Agent { async renameItem( input: RenameWorkspaceKernelItemArgs, - ): Promise> { + ): Promise> { return this.runMutation("rename_item", input, 1, () => this.itemCommands.renameItem(input)); } async moveItems( input: MoveWorkspaceKernelItemsArgs, - ): Promise> { + ): Promise> { return this.runMutation("move_items", input, input.items.length, () => this.itemCommands.moveItems(input), ); diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index dd64cddd..4ff312d8 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -11,7 +11,6 @@ import type { WorkspaceAccessContext } from "#/features/workspaces/operations/wo import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; import { parseMarkdownToTiptapDocumentProjection } from "#/features/workspaces/documents/document-markdown"; import { stringifyTiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; -import type { WorkspaceKernelClient } from "#/features/workspaces/kernel/workspace-kernel-access"; import { getParentWorkspacePath, getWorkspacePathName, @@ -20,7 +19,6 @@ import { WorkspaceKernelPathError, type WorkspaceKernelTree, } from "#/features/workspaces/kernel/workspace-kernel-paths"; -import { isWorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; export interface CreateWorkspaceItemOperationInput { type: "document" | "folder"; @@ -143,35 +141,28 @@ export async function createWorkspaceItemsOperation( continue; } - let command: Awaited>; - - try { - command = await workspaceContext.kernel.createItem({ - id, - parentId: parent.parentId, - type: itemInput.type, - name: path.name, - onNameConflict: "error", - initialContent: initialContent.content, - actorUserId: accessContext.actor.userId, - clientMutationId: `${accessContext.operationId}:${index}`, + const outcome = await workspaceContext.kernel.createItem({ + id, + parentId: parent.parentId, + type: itemInput.type, + name: path.name, + onNameConflict: "error", + initialContent: initialContent.content, + initialRelations: relations.relations, + actorUserId: accessContext.actor.userId, + clientMutationId: `${accessContext.operationId}:${index}`, + }); + + if (outcome.status === "conflict") { + failed.push({ + code: "path_already_exists", + index, + path: path.path, }); - } catch (error) { - if (isWorkspaceKernelNameConflictError(error)) { - failed.push({ - code: "path_already_exists", - index, - path: path.path, - }); - continue; - } - - throw error; + continue; } - if (relations.relations.length > 0) { - await workspaceContext.kernel.createRelations({ relations: relations.relations }); - } + const command = outcome.command; const createdPath = joinWorkspaceItemPath(parent.path, command.result.name); diff --git a/src/features/workspaces/operations/move-items.ts b/src/features/workspaces/operations/move-items.ts index 0dee7e09..fd2a28a1 100644 --- a/src/features/workspaces/operations/move-items.ts +++ b/src/features/workspaces/operations/move-items.ts @@ -10,7 +10,6 @@ import { joinWorkspaceItemPath, type WorkspaceKernelTree, } from "#/features/workspaces/kernel/workspace-kernel-paths"; -import { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; export interface MoveWorkspaceItemsOperationInput { destinationPath: string; @@ -141,58 +140,51 @@ export async function moveWorkspaceItemsOperation( const pendingItems = [...resolvedItems]; while (pendingItems.length > 0) { - try { - const command = await workspaceContext.kernel.moveItems({ - items: pendingItems.map((resolved) => ({ itemId: resolved.item.id })), - parentId: destination.parentId, - onNameConflict: "error", - actorUserId: accessContext.actor.userId, - clientMutationId: accessContext.operationId, - }); - const pendingItemsById = new Map(); - - for (const pendingItem of pendingItems) { - if (!pendingItemsById.has(pendingItem.item.id)) { - pendingItemsById.set(pendingItem.item.id, pendingItem); - } - } + const outcome = await workspaceContext.kernel.moveItems({ + items: pendingItems.map((resolved) => ({ itemId: resolved.item.id })), + parentId: destination.parentId, + onNameConflict: "error", + actorUserId: accessContext.actor.userId, + clientMutationId: accessContext.operationId, + }); - items.push( - ...command.result.map((item) => { - const resolved = pendingItemsById.get(item.id); + if (outcome.status === "conflict") { + const conflictIndex = pendingItems.findIndex( + (resolved) => resolved.item.id === outcome.conflict.itemId, + ); - if (!resolved) { - throw new Error(`Moved workspace item was not resolved: ${item.id}`); - } + if (conflictIndex < 0) { + throw new Error("Workspace move conflict did not identify a pending item."); + } - return { - path: joinWorkspaceItemPath(destination.path, item.name), - previousPath: resolved.path, - type: item.type, - }; - }), - ); - break; - } catch (error) { - if (error instanceof WorkspaceKernelNameConflictError && error.itemId) { - const conflictIndex = pendingItems.findIndex( - (resolved) => resolved.item.id === error.itemId, - ); + const [conflictedItem] = pendingItems.splice(conflictIndex, 1); + failed.push({ + code: "path_already_exists", + index: conflictedItem.index, + path: conflictedItem.path, + }); + continue; + } - if (conflictIndex >= 0) { - const [conflictedItem] = pendingItems.splice(conflictIndex, 1); + const pendingItemsById = new Map( + pendingItems.map((pendingItem) => [pendingItem.item.id, pendingItem] as const), + ); + items.push( + ...outcome.command.result.map((item) => { + const resolved = pendingItemsById.get(item.id); - failed.push({ - code: "path_already_exists", - index: conflictedItem.index, - path: conflictedItem.path, - }); - continue; + if (!resolved) { + throw new Error(`Moved workspace item was not resolved: ${item.id}`); } - } - throw error; - } + return { + path: joinWorkspaceItemPath(destination.path, item.name), + previousPath: resolved.path, + type: item.type, + }; + }), + ); + break; } return { diff --git a/src/features/workspaces/operations/rename-item.ts b/src/features/workspaces/operations/rename-item.ts index 7127aef0..40e4d0e8 100644 --- a/src/features/workspaces/operations/rename-item.ts +++ b/src/features/workspaces/operations/rename-item.ts @@ -8,7 +8,6 @@ import { getParentWorkspacePath, joinWorkspaceItemPath, } from "#/features/workspaces/kernel/workspace-kernel-paths"; -import { isWorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-errors"; export interface RenameWorkspaceItemOperationInput { name: string; @@ -63,35 +62,34 @@ export async function renameWorkspaceItemOperation( }; } - try { - const command = await workspaceContext.kernel.renameItem({ - itemId: resolution.item.id, - name: input.name, - onNameConflict: "error", - actorUserId: accessContext.actor.userId, - clientMutationId: accessContext.operationId, - }); + const outcome = await workspaceContext.kernel.renameItem({ + itemId: resolution.item.id, + name: input.name, + onNameConflict: "error", + actorUserId: accessContext.actor.userId, + clientMutationId: accessContext.operationId, + }); + if (outcome.status === "conflict") { return { - failed: [], - item: { - path: joinWorkspaceItemPath(getParentWorkspacePath(resolution.path), command.result.name), - previousPath: resolution.path, - type: command.result.type, - }, + failed: [ + { + code: "path_already_exists", + path: resolution.path, + }, + ], }; - } catch (error) { - if (isWorkspaceKernelNameConflictError(error)) { - return { - failed: [ - { - code: "path_already_exists", - path: resolution.path, - }, - ], - }; - } - - throw error; } + + return { + failed: [], + item: { + path: joinWorkspaceItemPath( + getParentWorkspacePath(resolution.path), + outcome.command.result.name, + ), + previousPath: resolution.path, + type: outcome.command.result.type, + }, + }; } diff --git a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts index 0dd2ed1b..70fb488f 100644 --- a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts +++ b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts @@ -13,6 +13,7 @@ import { createWorkspaceFileFromUpload, getWorkspaceKernel, } from "#/features/workspaces/kernel/workspace-kernel-access"; +import { requireAppliedWorkspaceKernelMutation } from "#/features/workspaces/kernel/workspace-kernel-types"; import { resolveWorkspaceFileAiReadStrategy, WorkspaceFileUploadError, @@ -268,16 +269,18 @@ async function createWorkspaceDocumentFromUpload(input: { getWorkspaceKernel(input.claims.workspaceId), ]); - return kernel.createItem({ - id: input.claims.itemId, - actorUserId: input.claims.userId, - clientMutationId: input.claims.clientMutationId, - initialContent: documentContent.initialContent, - metadataJson: documentContent.metadataJson, - name: documentContent.name, - parentId: input.claims.parentId, - type: "document", - }); + return requireAppliedWorkspaceKernelMutation( + await kernel.createItem({ + id: input.claims.itemId, + actorUserId: input.claims.userId, + clientMutationId: input.claims.clientMutationId, + initialContent: documentContent.initialContent, + metadataJson: documentContent.metadataJson, + name: documentContent.name, + parentId: input.claims.parentId, + type: "document", + }), + ); } async function authorizeWorkspaceUpload(request: Request, workspaceId: string) { From c8055ff166c08984261c7029f3f210e25603d741 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:09:16 -0400 Subject: [PATCH 15/24] refactor(mcp): isolate auth and protocol configuration Verify access tokens through the typed Better Auth API, preserve JWKS infrastructure failures, and centralize MCP paths, scopes, and audience configuration. Reject consent requests that do not name any permissions. --- src/features/mcp/mcp-auth.ts | 71 +++++++++++++++ src/features/mcp/mcp-config.ts | 24 ++++++ src/features/mcp/mcp-cors.ts | 3 +- src/features/mcp/mcp-openapi.ts | 16 ++-- src/features/mcp/mcp-operation-catalog.ts | 5 +- src/features/mcp/mcp-route.ts | 100 ++++++++++------------ src/lib/app-origin.ts | 4 +- src/lib/auth.server.ts | 12 +-- src/routes/oauth.consent.tsx | 18 +++- 9 files changed, 171 insertions(+), 82 deletions(-) create mode 100644 src/features/mcp/mcp-auth.ts create mode 100644 src/features/mcp/mcp-config.ts diff --git a/src/features/mcp/mcp-auth.ts b/src/features/mcp/mcp-auth.ts new file mode 100644 index 00000000..58d6d397 --- /dev/null +++ b/src/features/mcp/mcp-auth.ts @@ -0,0 +1,71 @@ +import { verifyJwsAccessToken } from "better-auth/oauth2"; + +type VerifyAccessTokenOptions = Parameters[1]; +type JwksProvider = Exclude; +export type McpAccessTokenPayload = Awaited>; + +const jwksCacheKey = {}; + +export async function authenticateMcpRequest(input: { + getJwks: JwksProvider; + handle: (request: Request, payload: McpAccessTokenPayload) => Promise; + issuer: string; + request: Request; + resource: string; +}) { + const token = readBearerToken(input.request.headers.get("Authorization")); + if (!token) { + return unauthorized(input.resource, "Missing bearer token."); + } + + let payload: McpAccessTokenPayload; + let jwksError: unknown; + try { + payload = await verifyJwsAccessToken(token, { + jwksCacheKey, + jwksFetch: async () => { + try { + return await input.getJwks(); + } catch (error) { + jwksError = error; + throw error; + } + }, + verifyOptions: { + audience: input.resource, + issuer: input.issuer, + }, + }); + } catch { + if (jwksError) { + throw new Error("Unable to load the MCP token verification keys.", { + cause: jwksError, + }); + } + + return unauthorized(input.resource, "Invalid bearer token."); + } + + return input.handle(input.request, payload); +} + +function readBearerToken(authorization: string | null) { + const match = authorization?.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || null; +} + +function unauthorized(resource: string, message: string) { + const resourceUrl = new URL(resource); + const metadataUrl = new URL( + `/.well-known/oauth-protected-resource${resourceUrl.pathname}`, + resourceUrl, + ); + + return new Response(message, { + status: 401, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"`, + }, + }); +} diff --git a/src/features/mcp/mcp-config.ts b/src/features/mcp/mcp-config.ts new file mode 100644 index 00000000..4626afb0 --- /dev/null +++ b/src/features/mcp/mcp-config.ts @@ -0,0 +1,24 @@ +export const mcpPath = "/mcp"; +export const mcpAuthPath = "/api/auth"; +export const mcpOperationPathPrefix = "/operations/"; + +export const mcpScopes = ["workspaces:read", "workspaces:write"] as const; +export type McpScope = (typeof mcpScopes)[number]; + +export const mcpScopeDescriptions: Record = { + "workspaces:read": "View your workspaces and their contents.", + "workspaces:write": "Create, edit, move, link, and delete workspace content.", +}; + +export const mcpOAuthScopes = ["openid", "offline_access", ...mcpScopes] as const; + +export function getMcpResource(origin: string) { + return `${origin}${mcpPath}`; +} + +export function getMcpUrls(origin: string) { + return { + issuer: `${origin}${mcpAuthPath}`, + resource: getMcpResource(origin), + }; +} diff --git a/src/features/mcp/mcp-cors.ts b/src/features/mcp/mcp-cors.ts index 1fe067de..81735648 100644 --- a/src/features/mcp/mcp-cors.ts +++ b/src/features/mcp/mcp-cors.ts @@ -2,7 +2,7 @@ const mcpCorsRequestHeaders = "authorization, content-type, mcp-protocol-version const mcpCorsResponseHeaders = "mcp-session-id, www-authenticate"; export function isMcpOAuthPath(pathname: string) { - return pathname === "/api/auth/jwks" || pathname.startsWith("/api/auth/oauth2/"); + return pathname === `${mcpAuthPath}/jwks` || pathname.startsWith(`${mcpAuthPath}/oauth2/`); } export function withMcpCors(response: Response) { @@ -28,3 +28,4 @@ export function mcpCorsPreflightResponse() { }, }); } +import { mcpAuthPath } from "#/features/mcp/mcp-config"; diff --git a/src/features/mcp/mcp-openapi.ts b/src/features/mcp/mcp-openapi.ts index adbd7907..66b382c2 100644 --- a/src/features/mcp/mcp-openapi.ts +++ b/src/features/mcp/mcp-openapi.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +import { + mcpAuthPath, + mcpOperationPathPrefix, + mcpScopeDescriptions, +} from "#/features/mcp/mcp-config"; import { mcpOperations } from "#/features/mcp/mcp-operation-catalog"; function toOpenApiSchema(schema: z.ZodType) { @@ -47,7 +52,7 @@ export const mcpOpenApiSpec: Record = { }, paths: Object.fromEntries( mcpOperations.map((operation) => [ - `/operations/${operation.name}`, + `${mcpOperationPathPrefix}${operation.name}`, buildOperationPath(operation), ]), ), @@ -57,12 +62,9 @@ export const mcpOpenApiSpec: Record = { type: "oauth2", flows: { authorizationCode: { - authorizationUrl: "/api/auth/oauth2/authorize", - tokenUrl: "/api/auth/oauth2/token", - scopes: { - "workspaces:read": "Read workspaces the user belongs to.", - "workspaces:write": "Modify workspaces when the user's role permits it.", - }, + authorizationUrl: `${mcpAuthPath}/oauth2/authorize`, + tokenUrl: `${mcpAuthPath}/oauth2/token`, + scopes: mcpScopeDescriptions, }, }, }, diff --git a/src/features/mcp/mcp-operation-catalog.ts b/src/features/mcp/mcp-operation-catalog.ts index 313f6e0c..5b204fe2 100644 --- a/src/features/mcp/mcp-operation-catalog.ts +++ b/src/features/mcp/mcp-operation-catalog.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { McpScope } from "#/features/mcp/mcp-config"; import { workspaceSummarySchema } from "#/features/workspaces/contracts"; import { createAccountAccessContext } from "#/features/workspaces/operations/account-access-context"; import { listAccountWorkspacesOperation } from "#/features/workspaces/operations/list-workspaces"; @@ -11,10 +12,6 @@ import { workspaceToolDefinitions, } from "#/features/workspaces/operations/workspace-tool-definitions"; -export const mcpScopes = ["workspaces:read", "workspaces:write"] as const; - -export type McpScope = (typeof mcpScopes)[number]; - export interface McpPrincipal { scopes: ReadonlySet; userId: string; diff --git a/src/features/mcp/mcp-route.ts b/src/features/mcp/mcp-route.ts index c463293c..91d0797b 100644 --- a/src/features/mcp/mcp-route.ts +++ b/src/features/mcp/mcp-route.ts @@ -1,33 +1,37 @@ import { DynamicWorkerExecutor } from "@cloudflare/codemode"; import { openApiMcpServer } from "@cloudflare/codemode/mcp"; import { - mcpHandler, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, } from "@better-auth/oauth-provider"; import { createMcpHandler } from "agents/mcp"; +import { authenticateMcpRequest } from "#/features/mcp/mcp-auth"; import { - executeMcpOperation, - getMcpOperation, + getMcpUrls, + mcpAuthPath, + mcpOperationPathPrefix, + mcpPath, mcpScopes, - type McpPrincipal, -} from "#/features/mcp/mcp-operation-catalog"; +} from "#/features/mcp/mcp-config"; +import { executeMcpOperation, type McpPrincipal } from "#/features/mcp/mcp-operation-catalog"; import { mcpCorsPreflightResponse, withMcpCors } from "#/features/mcp/mcp-cors"; import { mcpOpenApiSpec } from "#/features/mcp/mcp-openapi"; import { getAppOrigin } from "#/lib/app-origin"; import { withAuth } from "#/lib/auth.server"; -const mcpPath = "/mcp"; -const operationPathPrefix = "/operations/"; - -function getOAuthUrls() { - const origin = getAppOrigin(); - return { - issuer: `${origin}/api/auth`, - resource: `${origin}${mcpPath}`, - }; -} +const protectedResourceMetadataPaths = new Set([ + "/.well-known/oauth-protected-resource", + `/.well-known/oauth-protected-resource${mcpPath}`, +]); +const authorizationServerMetadataPaths = new Set([ + "/.well-known/oauth-authorization-server", + `/.well-known/oauth-authorization-server${mcpAuthPath}`, +]); +const openIdMetadataPaths = new Set([ + "/.well-known/openid-configuration", + `/.well-known/openid-configuration${mcpAuthPath}`, +]); function getPrincipal(payload: { scope?: unknown; sub?: string }): McpPrincipal { if (!payload.sub) { @@ -54,16 +58,11 @@ function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { globalOutbound: null, }), request: async (options, context) => { - if (options.method !== "POST" || !options.path.startsWith(operationPathPrefix)) { + if (options.method !== "POST" || !options.path.startsWith(mcpOperationPathPrefix)) { throw new Error("Unsupported MCP operation request."); } - const operationName = options.path.slice(operationPathPrefix.length); - const operation = getMcpOperation(operationName); - - if (!operation) { - throw new Error("Unknown MCP operation."); - } + const operationName = options.path.slice(mcpOperationPathPrefix.length); return await executeMcpOperation({ name: operationName, @@ -78,7 +77,7 @@ function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { } function protectedResourceMetadata() { - const { issuer, resource } = getOAuthUrls(); + const { issuer, resource } = getMcpUrls(getAppOrigin()); return Response.json({ resource, @@ -93,26 +92,27 @@ async function handleAuthenticatedMcpRequest( env: Cloudflare.Env, ctx: ExecutionContext, ) { - const { issuer, resource } = getOAuthUrls(); - const getLocalJwks = () => withAuth((auth) => auth.api.getJwks()); - const authenticate = mcpHandler( - { - // Better Auth supports a JWKS callback at runtime, but its public type only - // exposes URL strings. A local callback avoids a Worker self-fetch through - // the public hostname while retaining Better Auth's token verifier. - jwksUrl: getLocalJwks as unknown as string, - verifyOptions: { - audience: resource, - issuer, - }, - }, - async (authenticatedRequest, payload) => { + const { issuer, resource } = getMcpUrls(getAppOrigin()); + + return await authenticateMcpRequest({ + getJwks: () => withAuth((auth) => auth.api.getJwks()), + handle: async (authenticatedRequest, payload) => { const server = createThinkExMcpServer(env, getPrincipal(payload)); return await createMcpHandler(server, { route: mcpPath })(authenticatedRequest, env, ctx); }, - ); + issuer, + request, + resource, + }); +} - return await authenticate(request); +function isMcpProtocolPath(pathname: string) { + return ( + pathname === mcpPath || + protectedResourceMetadataPaths.has(pathname) || + authorizationServerMetadataPaths.has(pathname) || + openIdMetadataPaths.has(pathname) + ); } export async function routeMcpRequest( @@ -121,36 +121,22 @@ export async function routeMcpRequest( ctx: ExecutionContext, ): Promise { const pathname = new URL(request.url).pathname; - const isMcpProtocolPath = - pathname === mcpPath || - pathname.startsWith("/.well-known/oauth-protected-resource") || - pathname.startsWith("/.well-known/oauth-authorization-server") || - pathname.startsWith("/.well-known/openid-configuration"); - if (request.method === "OPTIONS" && isMcpProtocolPath) { + if (request.method === "OPTIONS" && isMcpProtocolPath(pathname)) { return mcpCorsPreflightResponse(); } - if ( - pathname === "/.well-known/oauth-protected-resource" || - pathname === `/.well-known/oauth-protected-resource${mcpPath}` - ) { + if (protectedResourceMetadataPaths.has(pathname)) { return withMcpCors(protectedResourceMetadata()); } - if ( - pathname === "/.well-known/oauth-authorization-server" || - pathname === "/.well-known/oauth-authorization-server/api/auth" - ) { + if (authorizationServerMetadataPaths.has(pathname)) { return await withAuth(async (auth) => { return withMcpCors(await oauthProviderAuthServerMetadata(auth)(request)); }); } - if ( - pathname === "/.well-known/openid-configuration" || - pathname === "/.well-known/openid-configuration/api/auth" - ) { + if (openIdMetadataPaths.has(pathname)) { return await withAuth(async (auth) => { return withMcpCors(await oauthProviderOpenIdConfigMetadata(auth)(request)); }); diff --git a/src/lib/app-origin.ts b/src/lib/app-origin.ts index 18cdfb4b..8720296a 100644 --- a/src/lib/app-origin.ts +++ b/src/lib/app-origin.ts @@ -51,8 +51,8 @@ export function getAppOrigin() { } function getOptionalEnvString(name: string) { - const env = workerEnv as unknown as Record; - return env[name]?.trim() || undefined; + const value = Reflect.get(workerEnv as object, name); + return typeof value === "string" ? value.trim() || undefined : undefined; } function parseCommaList(value: string | undefined) { diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index 5ca813f8..de96d35c 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -6,6 +6,7 @@ import { tanstackStartCookies } from "better-auth/tanstack-start"; import { oauthProvider } from "@better-auth/oauth-provider"; import { sql } from "drizzle-orm"; +import { getMcpResource, mcpOAuthScopes } from "#/features/mcp/mcp-config"; import { purgeUserAccountResources, transferLinkedAccountResources, @@ -175,7 +176,7 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { const baseURL = getAuthBaseURL(); const appOrigin = typeof baseURL === "string" ? baseURL : (baseURL.fallback ?? "http://localhost:3000"); - const mcpResource = `${appOrigin}/mcp`; + const mcpResource = getMcpResource(appOrigin); return betterAuth({ database: drizzleAdapter(database, { @@ -226,16 +227,11 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { oauthProvider({ allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, - clientRegistrationDefaultScopes: [ - "openid", - "offline_access", - "workspaces:read", - "workspaces:write", - ], + clientRegistrationDefaultScopes: [...mcpOAuthScopes], consentPage: "/oauth/consent", grantTypes: ["authorization_code", "refresh_token"], loginPage: "/login", - scopes: ["openid", "offline_access", "workspaces:read", "workspaces:write"], + scopes: [...mcpOAuthScopes], silenceWarnings: { oauthAuthServerConfig: true }, validAudiences: [mcpResource], }), diff --git a/src/routes/oauth.consent.tsx b/src/routes/oauth.consent.tsx index 5ceeb198..fa3354fc 100644 --- a/src/routes/oauth.consent.tsx +++ b/src/routes/oauth.consent.tsx @@ -4,13 +4,13 @@ import { useState } from "react"; import { z } from "zod"; import { Button } from "#/components/ui/button"; +import { mcpScopeDescriptions } from "#/features/mcp/mcp-config"; import { authClient } from "#/lib/auth-client"; const scopeLabels: Record = { openid: "Identify your ThinkEx account", offline_access: "Stay connected until you revoke access", - "workspaces:read": "View your workspaces and their contents", - "workspaces:write": "Create, edit, move, link, and delete workspace content", + ...mcpScopeDescriptions, }; export const Route = createFileRoute("/oauth/consent")({ @@ -26,11 +26,18 @@ function OAuthConsentPage() { const [pendingDecision, setPendingDecision] = useState<"allow" | "deny" | null>(null); const [error, setError] = useState(null); const scopes = scope?.split(" ").filter(Boolean) ?? []; + const hasRequestedScopes = scopes.length > 0; async function decide(accept: boolean) { setPendingDecision(accept ? "allow" : "deny"); setError(null); + if (accept && !hasRequestedScopes) { + setError("This client did not specify any permissions."); + setPendingDecision(null); + return; + } + const result = await authClient.oauth2.consent({ accept, scope, @@ -70,6 +77,11 @@ function OAuthConsentPage() { ))} + {!hasRequestedScopes ? ( +

+ This client did not specify any permissions. Deny this request and reconnect. +

+ ) : null}

Your current role is checked again for every workspace operation. This connection cannot @@ -90,7 +102,7 @@ function OAuthConsentPage() { ) : null} - {groups.map((group) => ( + {MODEL_GROUPS.map((group) => (

diff --git a/src/features/workspaces/components/use-auto-hide-overlay.ts b/src/features/workspaces/components/use-auto-hide-overlay.ts index 0dcab05d..3943590f 100644 --- a/src/features/workspaces/components/use-auto-hide-overlay.ts +++ b/src/features/workspaces/components/use-auto-hide-overlay.ts @@ -1,4 +1,4 @@ -import { type FocusEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type FocusEvent, useEffect, useRef, useState } from "react"; export type AutoHideControls = { show: () => void; @@ -21,16 +21,16 @@ export function useAutoHideControls(delayMs: number): { const isPinnedRef = useRef(false); const hideTimeoutRef = useRef | null>(null); - const show = useCallback(() => { + const show = () => { setIsVisible(true); if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); hideTimeoutRef.current = null; } - }, []); + }; - const scheduleHide = useCallback(() => { + const scheduleHide = () => { if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); } @@ -40,27 +40,24 @@ export function useAutoHideControls(delayMs: number): { setIsVisible(false); } }, delayMs); - }, [delayMs]); + }; - const pin = useCallback(() => { + const pin = () => { isPinnedRef.current = true; show(); - }, [show]); + }; - const unpin = useCallback(() => { + const unpin = () => { isPinnedRef.current = false; scheduleHide(); - }, [scheduleHide]); + }; - const controls = useMemo( - (): AutoHideControls => ({ - show, - scheduleHide, - pin, - unpin, - }), - [pin, scheduleHide, show, unpin], - ); + const controls: AutoHideControls = { + show, + scheduleHide, + pin, + unpin, + }; useEffect( () => () => { diff --git a/src/features/workspaces/contracts.ts b/src/features/workspaces/contracts.ts index bcb403bf..90195530 100644 --- a/src/features/workspaces/contracts.ts +++ b/src/features/workspaces/contracts.ts @@ -332,7 +332,7 @@ export type WorkspaceMemberSummary = z.infer !post.draft) .sort((a, b) => b.date.localeCompare(a.date)); @@ -15,10 +22,5 @@ export function getBlogPostUrl(slug: string) { } export function formatBlogDate(date: string) { - return new Intl.DateTimeFormat("en", { - month: "long", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }).format(new Date(`${date}T00:00:00.000Z`)); + return blogDateFormatter.format(new Date(`${date}T00:00:00.000Z`)); } diff --git a/src/lib/use-native-file-drop-target.ts b/src/lib/use-native-file-drop-target.ts index d7f5c6ec..1efb41cf 100644 --- a/src/lib/use-native-file-drop-target.ts +++ b/src/lib/use-native-file-drop-target.ts @@ -1,4 +1,4 @@ -import { type RefObject, useEffect } from "react"; +import { type RefObject, useEffect, useEffectEvent } from "react"; import { hasNativeFiles } from "#/lib/native-file-drag"; @@ -26,6 +26,12 @@ export function useNativeFileDropTarget({ shouldHandle?: (event: DragEvent) => boolean; targetRef: RefObject; }) { + const setActive = useEffectEvent((isActive: boolean) => { + onActiveChange?.(isActive); + }); + const dropFiles = useEffectEvent(onDrop); + const canHandleEvent = useEffectEvent(shouldHandle); + useEffect(() => { if (!enabled) { return; @@ -36,12 +42,8 @@ export function useNativeFileDropTarget({ return; } - const setActive = (isActive: boolean) => { - onActiveChange?.(isActive); - }; - const canHandle = (event: DragEvent) => { - if (!shouldHandle(event)) { + if (!canHandleEvent(event)) { setActive(false); return false; } @@ -90,7 +92,7 @@ export function useNativeFileDropTarget({ setActive(false); if (dataTransfer.files.length > 0) { - onDrop(dataTransfer.files); + dropFiles(dataTransfer.files); } }; @@ -106,5 +108,5 @@ export function useNativeFileDropTarget({ target.removeEventListener("drop", handleDrop); setActive(false); }; - }, [enabled, onActiveChange, onDrop, shouldHandle, targetRef]); + }, [enabled, targetRef]); } From 99e2e7f73a5cc54acb08f9dfca4596ece8dac20b Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:08:01 -0400 Subject: [PATCH 19/24] fix(security): pin sanitizer and MCP audience Force the patched DOMPurify release across transitive consumers. Document the single-audience constraint mitigating Better Auth 1.6 resource widening. --- pnpm-lock.yaml | 11 ++++++----- pnpm-workspace.yaml | 1 + src/lib/auth.server.ts | 2 ++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbdc7720..57cb79fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + dompurify: 3.4.11 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 vitest: 4.1.9 @@ -5557,8 +5558,8 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dompurify@3.4.3: - resolution: {integrity: sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A==} + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} dot-prop@10.1.0: resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} @@ -13192,7 +13193,7 @@ snapshots: dom-accessibility-api@0.5.16: {} - dompurify@3.4.3: + dompurify@3.4.11: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -14626,7 +14627,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.21 - dompurify: 3.4.3 + dompurify: 3.4.11 es-toolkit: 1.46.1 katex: 0.16.47 khroma: 2.1.0 @@ -15492,7 +15493,7 @@ snapshots: '@posthog/core': 1.39.6 '@posthog/types': 1.392.1 core-js: 3.49.0 - dompurify: 3.4.3 + dompurify: 3.4.11 fflate: 0.4.8 preact: 10.29.2 query-selector-shadow-dom: 1.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3e55e87d..aa2d0b8d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ trustPolicyExclude: - semver@6.3.1 overrides: + dompurify: 3.4.11 vite: npm:@voidzero-dev/vite-plus-core@0.2.2 vitest: 4.1.9 diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts index de96d35c..b51a6272 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -233,6 +233,8 @@ function createAuth(database: Db, env: AuthRuntimeEnv) { loginPage: "/login", scopes: [...mcpOAuthScopes], silenceWarnings: { oauthAuthServerConfig: true }, + // Keep this to one audience while Better Auth 1.6.x is in use. Its + // resource indicator is not bound to the original authorization grant. validAudiences: [mcpResource], }), tanstackStartCookies(), From d64bb457a17509eb11422b6d98b79c1beb179b12 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:41:08 -0400 Subject: [PATCH 20/24] fix(mcp): harden OAuth protocol recovery --- src/features/mcp/mcp-cors.test.ts | 1 + src/features/mcp/mcp-cors.ts | 2 +- src/features/mcp/mcp-route.ts | 2 +- src/routes/oauth.consent.tsx | 26 ++++++++++++-------------- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/features/mcp/mcp-cors.test.ts b/src/features/mcp/mcp-cors.test.ts index f7e047bf..f4a6f16f 100644 --- a/src/features/mcp/mcp-cors.test.ts +++ b/src/features/mcp/mcp-cors.test.ts @@ -16,6 +16,7 @@ describe("MCP protocol CORS", () => { expect(response.headers.get("access-control-allow-origin")).toBe("*"); expect(response.headers.get("access-control-allow-methods")).toContain("POST"); expect(response.headers.get("access-control-allow-headers")).toContain("authorization"); + expect(response.headers.get("access-control-allow-headers")).toContain("mcp-session-id"); }); it("preserves the response while exposing MCP headers", async () => { diff --git a/src/features/mcp/mcp-cors.ts b/src/features/mcp/mcp-cors.ts index 81735648..e9866412 100644 --- a/src/features/mcp/mcp-cors.ts +++ b/src/features/mcp/mcp-cors.ts @@ -1,4 +1,4 @@ -const mcpCorsRequestHeaders = "authorization, content-type, mcp-protocol-version"; +const mcpCorsRequestHeaders = "authorization, content-type, mcp-protocol-version, mcp-session-id"; const mcpCorsResponseHeaders = "mcp-session-id, www-authenticate"; export function isMcpOAuthPath(pathname: string) { diff --git a/src/features/mcp/mcp-route.ts b/src/features/mcp/mcp-route.ts index 91d0797b..cbfa8fa8 100644 --- a/src/features/mcp/mcp-route.ts +++ b/src/features/mcp/mcp-route.ts @@ -30,7 +30,7 @@ const authorizationServerMetadataPaths = new Set([ ]); const openIdMetadataPaths = new Set([ "/.well-known/openid-configuration", - `/.well-known/openid-configuration${mcpAuthPath}`, + `${mcpAuthPath}/.well-known/openid-configuration`, ]); function getPrincipal(payload: { scope?: unknown; sub?: string }): McpPrincipal { diff --git a/src/routes/oauth.consent.tsx b/src/routes/oauth.consent.tsx index fa3354fc..5d04cefa 100644 --- a/src/routes/oauth.consent.tsx +++ b/src/routes/oauth.consent.tsx @@ -38,23 +38,21 @@ function OAuthConsentPage() { return; } - const result = await authClient.oauth2.consent({ - accept, - scope, - }); + try { + const result = await authClient.oauth2.consent({ accept, scope }); - if (result.error) { - setError(result.error.message ?? "Unable to complete authorization."); - setPendingDecision(null); - return; - } - - if (result.data && "url" in result.data && typeof result.data.url === "string") { - window.location.assign(result.data.url); - return; + if (result.error) { + setError(result.error.message ?? "Unable to complete authorization."); + } else if (result.data && "url" in result.data && typeof result.data.url === "string") { + window.location.assign(result.data.url); + return; + } else { + setError("The authorization server did not return a redirect."); + } + } catch { + setError("Unable to complete authorization."); } - setError("The authorization server did not return a redirect."); setPendingDecision(null); } From 210ed66741e044b7d6b27a54644d63658a506ab5 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:41:26 -0400 Subject: [PATCH 21/24] fix(workspaces): make item creation metadata atomic --- .../kernel/workspace-kernel-item-commands.ts | 79 ++++++++++--------- .../workspaces/kernel/workspace-kernel.ts | 1 + 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index add5b236..e060e6b5 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -43,6 +43,7 @@ export class WorkspaceKernelItemCommands { private readonly relations: WorkspaceKernelRelations; private readonly sql: WorkspaceKernelSql; private readonly store: WorkspaceKernelStore; + private readonly transactionSync: (closure: () => T) => T; private readonly workspace: ShellWorkspace; private readonly workspaceId: () => string; @@ -51,6 +52,7 @@ export class WorkspaceKernelItemCommands { relations: WorkspaceKernelRelations; sql: WorkspaceKernelSql; store: WorkspaceKernelStore; + transactionSync: (closure: () => T) => T; workspace: ShellWorkspace; workspaceId: () => string; }) { @@ -58,6 +60,7 @@ export class WorkspaceKernelItemCommands { this.relations = input.relations; this.sql = input.sql; this.store = input.store; + this.transactionSync = input.transactionSync; this.workspace = input.workspace; this.workspaceId = input.workspaceId; } @@ -143,45 +146,49 @@ export class WorkspaceKernelItemCommands { return { command: concurrentResult, status: "applied" }; } - this.sql` - INSERT INTO kernel_items ( - id, - parent_id, - type, - name, - color, - metadata_json, - sort_order, - shell_path, - created_at, - updated_at, - deleted_at - ) - VALUES ( - ${id}, - ${parentId}, - ${type}, - ${name}, - ${color}, - ${JSON.stringify(metadataJson)}, - ${this.store.getNextSortOrder(parentId)}, - ${shellPath}, - ${now}, - ${now}, - NULL - ) - `; + const command = this.transactionSync(() => { + this.sql` + INSERT INTO kernel_items ( + id, + parent_id, + type, + name, + color, + metadata_json, + sort_order, + shell_path, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${id}, + ${parentId}, + ${type}, + ${name}, + ${color}, + ${JSON.stringify(metadataJson)}, + ${this.store.getNextSortOrder(parentId)}, + ${shellPath}, + ${now}, + ${now}, + NULL + ) + `; + + const item = this.store.requireItem(id); + this.relations.createRelations(initialRelations); + const event = this.events.commit({ + type: "workspace.item.created", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { item }, + }); - const item = this.store.requireItem(id); - this.relations.createRelations(initialRelations); - const event = this.events.commit({ - type: "workspace.item.created", - actorUserId: input.actorUserId ?? null, - clientMutationId: input.clientMutationId ?? null, - payload: { item }, + return { result: item, event }; }); - return { command: { result: item, event }, status: "applied" }; + return { command, status: "applied" }; } async renameItem( diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index 5c9a52da..9ce3290b 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -82,6 +82,7 @@ export class WorkspaceKernel extends Agent { relations: this.relations, sql: this.kernelSql, store: this.store, + transactionSync: (closure) => this.ctx.storage.transactionSync(closure), workspace: this.workspace, workspaceId: () => this.name, }); From e48c33d3a2048573365d25db1c21ba646b421124 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:51:12 -0400 Subject: [PATCH 22/24] fix(workspaces): keep deletion state and event atomic --- .../kernel/workspace-kernel-item-commands.ts | 111 ++++++++++-------- .../workspaces/kernel/workspace-kernel.ts | 1 - 2 files changed, 59 insertions(+), 53 deletions(-) diff --git a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts index e060e6b5..5d531c0b 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -37,13 +37,13 @@ import { workspaceItemSupportsCustomColor, } from "#/features/workspaces/model/workspace-item-colors"; import type { WorkspaceCommandResult } from "#/features/workspaces/realtime/messages"; +import { recordOperationalFailure } from "#/integrations/observability/operational-events"; export class WorkspaceKernelItemCommands { private readonly events: WorkspaceKernelEventBus; private readonly relations: WorkspaceKernelRelations; private readonly sql: WorkspaceKernelSql; private readonly store: WorkspaceKernelStore; - private readonly transactionSync: (closure: () => T) => T; private readonly workspace: ShellWorkspace; private readonly workspaceId: () => string; @@ -52,7 +52,6 @@ export class WorkspaceKernelItemCommands { relations: WorkspaceKernelRelations; sql: WorkspaceKernelSql; store: WorkspaceKernelStore; - transactionSync: (closure: () => T) => T; workspace: ShellWorkspace; workspaceId: () => string; }) { @@ -60,7 +59,6 @@ export class WorkspaceKernelItemCommands { this.relations = input.relations; this.sql = input.sql; this.store = input.store; - this.transactionSync = input.transactionSync; this.workspace = input.workspace; this.workspaceId = input.workspaceId; } @@ -146,49 +144,47 @@ export class WorkspaceKernelItemCommands { return { command: concurrentResult, status: "applied" }; } - const command = this.transactionSync(() => { - this.sql` - INSERT INTO kernel_items ( - id, - parent_id, - type, - name, - color, - metadata_json, - sort_order, - shell_path, - created_at, - updated_at, - deleted_at - ) - VALUES ( - ${id}, - ${parentId}, - ${type}, - ${name}, - ${color}, - ${JSON.stringify(metadataJson)}, - ${this.store.getNextSortOrder(parentId)}, - ${shellPath}, - ${now}, - ${now}, - NULL - ) - `; - - const item = this.store.requireItem(id); - this.relations.createRelations(initialRelations); - const event = this.events.commit({ - type: "workspace.item.created", - actorUserId: input.actorUserId ?? null, - clientMutationId: input.clientMutationId ?? null, - payload: { item }, - }); + // Keep these writes synchronous. SQLite-backed Durable Objects coalesce + // writes without an intervening await into one atomic implicit transaction. + this.sql` + INSERT INTO kernel_items ( + id, + parent_id, + type, + name, + color, + metadata_json, + sort_order, + shell_path, + created_at, + updated_at, + deleted_at + ) + VALUES ( + ${id}, + ${parentId}, + ${type}, + ${name}, + ${color}, + ${JSON.stringify(metadataJson)}, + ${this.store.getNextSortOrder(parentId)}, + ${shellPath}, + ${now}, + ${now}, + NULL + ) + `; - return { result: item, event }; + const item = this.store.requireItem(id); + this.relations.createRelations(initialRelations); + const event = this.events.commit({ + type: "workspace.item.created", + actorUserId: input.actorUserId ?? null, + clientMutationId: input.clientMutationId ?? null, + payload: { item }, }); - return { command, status: "applied" }; + return { command: { result: item, event }, status: "applied" }; } async renameItem( @@ -312,15 +308,6 @@ export class WorkspaceKernelItemCommands { this.store.softDeleteItems(deleteIds, Date.now()); this.relations.deleteRelationsForItems(deleteIds); - await Promise.all( - rowsToRemove.map((row) => - this.workspace.rm(row.shell_path, { - recursive: true, - force: true, - }), - ), - ); - const result = { itemIds: rootIds, deletedItemIds: deleteIds }; const event = this.events.commit({ type: "workspace.item.deleted", @@ -329,6 +316,26 @@ export class WorkspaceKernelItemCommands { payload: { itemIds: rootIds, deletedItemIds: deleteIds }, }); + try { + await Promise.all( + rowsToRemove.map((row) => + this.workspace.rm(row.shell_path, { + recursive: true, + force: true, + }), + ), + ); + } catch (error) { + recordOperationalFailure({ + error, + event: "workspace_shell_cleanup", + fields: { + item_count: rowsToRemove.length, + workspace_id: this.workspaceId(), + }, + }); + } + return { result, event }; } diff --git a/src/features/workspaces/kernel/workspace-kernel.ts b/src/features/workspaces/kernel/workspace-kernel.ts index 9ce3290b..5c9a52da 100644 --- a/src/features/workspaces/kernel/workspace-kernel.ts +++ b/src/features/workspaces/kernel/workspace-kernel.ts @@ -82,7 +82,6 @@ export class WorkspaceKernel extends Agent { relations: this.relations, sql: this.kernelSql, store: this.store, - transactionSync: (closure) => this.ctx.storage.transactionSync(closure), workspace: this.workspace, workspaceId: () => this.name, }); From fb9c0500e16727b91a356a6875b80d3496e2e5fc Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:17:58 -0400 Subject: [PATCH 23/24] feat(mcp): polish OAuth consent and metadata --- server.json | 25 +++++++ src/components/AuthPageLayout.tsx | 30 ++++++++ src/components/AuthScreen.tsx | 20 ++---- src/features/mcp/mcp-consent.functions.ts | 35 ++++++++++ src/features/mcp/mcp-route.ts | 7 +- src/routes/invite.$token.tsx | 24 ++----- src/routes/oauth.consent.tsx | 84 +++++++++++++++-------- 7 files changed, 161 insertions(+), 64 deletions(-) create mode 100644 server.json create mode 100644 src/components/AuthPageLayout.tsx create mode 100644 src/features/mcp/mcp-consent.functions.ts diff --git a/server.json b/server.json new file mode 100644 index 00000000..39b8f0d6 --- /dev/null +++ b/server.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "app.thinkex/thinkex", + "title": "ThinkEx", + "description": "Search and update the workspaces you can access in ThinkEx.", + "version": "1.0.0", + "websiteUrl": "https://thinkex.app", + "icons": [ + { + "src": "https://thinkex.app/apple-touch-icon.png", + "mimeType": "image/png", + "sizes": ["180x180"] + } + ], + "repository": { + "url": "https://github.com/ThinkEx-OSS/thinkex", + "source": "github" + }, + "remotes": [ + { + "type": "streamable-http", + "url": "https://thinkex.app/mcp" + } + ] +} diff --git a/src/components/AuthPageLayout.tsx b/src/components/AuthPageLayout.tsx new file mode 100644 index 00000000..42bbe4f8 --- /dev/null +++ b/src/components/AuthPageLayout.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +import ThinkExLogo from "#/components/ThinkExLogo"; +import { cn } from "#/lib/utils"; + +interface AuthPageLayoutProps { + children: ReactNode; + footer?: ReactNode; +} + +export default function AuthPageLayout({ children, footer }: AuthPageLayoutProps) { + return ( +
+
+
+ + {children} +
+
+ {footer ? ( +
{footer}
+ ) : null} +
+ ); +} diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index f5fca268..1b22d963 100644 --- a/src/components/AuthScreen.tsx +++ b/src/components/AuthScreen.tsx @@ -1,5 +1,5 @@ import AuthPanel, { AuthLegalNotice } from "#/components/AuthPanel"; -import ThinkExLogo from "#/components/ThinkExLogo"; +import AuthPageLayout from "#/components/AuthPageLayout"; interface AuthScreenProps { callbackURL: string; @@ -7,19 +7,11 @@ interface AuthScreenProps { export default function AuthScreen({ callbackURL }: AuthScreenProps) { return ( -
-
-
- -

Continue to ThinkEx

-
- -
-
-
-
- + }> +

Continue to ThinkEx

+
+
-
+ ); } diff --git a/src/features/mcp/mcp-consent.functions.ts b/src/features/mcp/mcp-consent.functions.ts new file mode 100644 index 00000000..6aeda8e9 --- /dev/null +++ b/src/features/mcp/mcp-consent.functions.ts @@ -0,0 +1,35 @@ +import { createServerFn } from "@tanstack/react-start"; +import { getRequestHeaders } from "@tanstack/react-start/server"; +import { z } from "zod"; + +import { withAuth } from "#/lib/auth.server"; + +const consentContextInputSchema = z.object({ + clientId: z.string().min(1), +}); + +function getClientDisplayName(clientName: string | undefined) { + const normalizedName = clientName?.trim().replace(/\s+/g, " "); + return normalizedName ? normalizedName.slice(0, 80) : "MCP client"; +} + +export const getMcpConsentContextFn = createServerFn({ method: "GET" }) + .validator(consentContextInputSchema) + .handler(async ({ data }) => { + const headers = getRequestHeaders(); + + return await withAuth(async (auth) => { + const [client, session] = await Promise.all([ + auth.api.getOAuthClientPublic({ + headers, + query: { client_id: data.clientId }, + }), + auth.api.getSession({ headers }), + ]); + + return { + clientName: getClientDisplayName(client.client_name), + userEmail: session?.user.email ?? null, + }; + }); + }); diff --git a/src/features/mcp/mcp-route.ts b/src/features/mcp/mcp-route.ts index cbfa8fa8..001b42fb 100644 --- a/src/features/mcp/mcp-route.ts +++ b/src/features/mcp/mcp-route.ts @@ -77,10 +77,15 @@ function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { } function protectedResourceMetadata() { - const { issuer, resource } = getMcpUrls(getAppOrigin()); + const origin = getAppOrigin(); + const { issuer, resource } = getMcpUrls(origin); return Response.json({ resource, + resource_name: "ThinkEx", + resource_documentation: `${origin}/`, + resource_policy_uri: `${origin}/privacy`, + resource_tos_uri: `${origin}/terms`, authorization_servers: [issuer], bearer_methods_supported: ["header"], scopes_supported: mcpScopes, diff --git a/src/routes/invite.$token.tsx b/src/routes/invite.$token.tsx index 77868e09..a6524b5d 100644 --- a/src/routes/invite.$token.tsx +++ b/src/routes/invite.$token.tsx @@ -1,8 +1,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import type { ReactNode } from "react"; import AuthPanel from "#/components/AuthPanel"; -import ThinkExLogo from "#/components/ThinkExLogo"; +import AuthPageLayout from "#/components/AuthPageLayout"; import { workspaceRoleLabels } from "#/features/workspaces/contracts"; import { acceptWorkspaceInviteFn, @@ -55,19 +54,6 @@ export const Route = createFileRoute("/invite/$token")({ component: InviteRoutePage, }); -function InviteScreen({ children }: { children: ReactNode }) { - return ( -
-
-
- - {children} -
-
-
- ); -} - function InviteRoutePage() { const { token } = Route.useParams(); const result = Route.useLoaderData(); @@ -79,7 +65,7 @@ function InviteRoutePage() { const callbackURL = buildInvitePath(token); return ( - +

Join {preview.workspaceName}

@@ -90,13 +76,13 @@ function InviteRoutePage() {

- + ); } function InviteUnavailablePage() { return ( - +

Invite unavailable

@@ -106,6 +92,6 @@ function InviteUnavailablePage() {

- + ); } diff --git a/src/routes/oauth.consent.tsx b/src/routes/oauth.consent.tsx index 5d04cefa..abaf9817 100644 --- a/src/routes/oauth.consent.tsx +++ b/src/routes/oauth.consent.tsx @@ -1,11 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Loader2 } from "lucide-react"; +import { Check, Loader2 } from "lucide-react"; import { useState } from "react"; import { z } from "zod"; +import AuthPageLayout from "#/components/AuthPageLayout"; import { Button } from "#/components/ui/button"; +import { getMcpConsentContextFn } from "#/features/mcp/mcp-consent.functions"; import { mcpScopeDescriptions } from "#/features/mcp/mcp-config"; import { authClient } from "#/lib/auth-client"; +import { buildPublicMeta } from "#/lib/seo"; const scopeLabels: Record = { openid: "Identify your ThinkEx account", @@ -18,11 +21,20 @@ export const Route = createFileRoute("/oauth/consent")({ client_id: z.string(), scope: z.string().optional(), }), + loaderDeps: ({ search }) => ({ clientId: search.client_id }), + loader: ({ deps }) => getMcpConsentContextFn({ data: deps }), + head: () => ({ + meta: buildPublicMeta({ + title: "Connect to ThinkEx", + description: "Review access before connecting an MCP client to your ThinkEx account.", + }), + }), component: OAuthConsentPage, }); function OAuthConsentPage() { - const { client_id: clientId, scope } = Route.useSearch(); + const { scope } = Route.useSearch(); + const { clientName, userEmail } = Route.useLoaderData(); const [pendingDecision, setPendingDecision] = useState<"allow" | "deny" | null>(null); const [error, setError] = useState(null); const scopes = scope?.split(" ").filter(Boolean) ?? []; @@ -57,57 +69,69 @@ function OAuthConsentPage() { } return ( -
-
+ +

Connect an MCP client

-

Allow access to ThinkEx?

-

- Client {clientId} is requesting: -

+

Connect {clientName} to ThinkEx?

+ {userEmail ? ( +

+ Signed in as{" "} + {userEmail} +

+ ) : null}
-
    - {scopes.map((requestedScope) => ( -
  • - - {scopeLabels[requestedScope] ?? requestedScope} -
  • - ))} -
+
+

This will allow {clientName} to:

+
    + {scopes.map((requestedScope) => ( +
  • +
  • + ))} +
+
{!hasRequestedScopes ? (

- This client did not specify any permissions. Deny this request and reconnect. + This client did not specify any permissions. Cancel this request and reconnect.

) : null} -

+

Your current role is checked again for every workspace operation. This connection cannot grant access to a workspace you cannot already use.

- {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} -
+
-
+ ); } From e088e4f30f82c45ffd2a6719b2d0ce9ba8f6c7fc Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:24:34 -0400 Subject: [PATCH 24/24] fix(command): stabilize item registration --- src/components/ui/command.tsx | 64 +++++++++++++++++------------------ 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 93c3bec2..310eb314 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -12,13 +12,36 @@ import { InputGroup, InputGroupAddon } from "#/components/ui/input-group.tsx"; import { cn } from "#/lib/utils.ts"; type CommandContextValue = { - registerItem: (id: string, visible: boolean) => void; + dispatchItem: React.Dispatch; search: string; setSearch: (value: string) => void; - unregisterItem: (id: string) => void; visibleCount: number; }; +type CommandItemAction = + | { id: string; type: "unregister" } + | { id: string; type: "register"; visible: boolean }; + +function commandItemsReducer(current: Map, action: CommandItemAction) { + if (action.type === "unregister") { + if (!current.has(action.id)) { + return current; + } + + const next = new Map(current); + next.delete(action.id); + return next; + } + + if (current.get(action.id) === action.visible) { + return current; + } + + const next = new Map(current); + next.set(action.id, action.visible); + return next; +} + const CommandContext = React.createContext(null); function useCommandContext() { @@ -31,37 +54,12 @@ function useCommandContext() { function Command({ className, children, ...props }: React.ComponentProps<"div">) { const [search, setSearch] = React.useState(""); - const [items, setItems] = React.useState(() => new Map()); - - const registerItem = (id: string, visible: boolean) => { - setItems((current) => { - if (current.get(id) === visible) { - return current; - } - - const next = new Map(current); - next.set(id, visible); - return next; - }); - }; - - const unregisterItem = (id: string) => { - setItems((current) => { - if (!current.has(id)) { - return current; - } - - const next = new Map(current); - next.delete(id); - return next; - }); - }; + const [items, dispatchItem] = React.useReducer(commandItemsReducer, new Map()); const contextValue = { - registerItem, + dispatchItem, search, setSearch, - unregisterItem, visibleCount: Array.from(items.values()).filter(Boolean).length, }; @@ -220,14 +218,14 @@ function CommandItem({ value?: string; }) { const id = React.useId(); - const { registerItem, search, unregisterItem } = useCommandContext(); + const { dispatchItem, search } = useCommandContext(); const itemValue = value ?? (typeof children === "string" ? children : ""); const visible = !search || itemValue.toLowerCase().includes(search.trim().toLowerCase()); React.useEffect(() => { - registerItem(id, visible); - return () => unregisterItem(id); - }, [id, registerItem, unregisterItem, visible]); + dispatchItem({ id, type: "register", visible }); + return () => dispatchItem({ id, type: "unregister" }); + }, [dispatchItem, id, visible]); if (!visible) { return null;