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/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/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/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 f393077f..1af096dc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1782668884562, "tag": "0001_last_mulholland_black", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "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/package.json b/package.json index c1801855..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", @@ -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..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 @@ -23,6 +24,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 +817,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: @@ -5545,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==} @@ -5857,10 +5870,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 +8720,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 +9112,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 +9147,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) @@ -13174,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 @@ -13470,8 +13489,6 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.8: {} - eventsource-parser@3.1.0: {} eventsource@3.0.7: @@ -14610,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 @@ -15476,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 c035265e..aa2d0b8d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,10 +4,12 @@ packages: minimumReleaseAge: 1440 minimumReleaseAgeStrict: true trustPolicy: no-downgrade +trustPolicyIgnoreAfter: 10080 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/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/components/landing/gravity.tsx b/src/components/landing/gravity.tsx index f61e0172..068e8aef 100644 --- a/src/components/landing/gravity.tsx +++ b/src/components/landing/gravity.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react"; import type Matter from "matter-js"; import { cn } from "#/lib/utils"; @@ -52,7 +52,7 @@ export function MatterBody({ angle = 0, }: MatterBodyProps) { const elementRef = useRef(null); - const idRef = useRef(crypto.randomUUID()); + const id = useId(); const context = useContext(GravityContext); useEffect(() => { @@ -60,8 +60,6 @@ export function MatterBody({ return; } - const id = idRef.current; - context.registerElement(id, elementRef.current, { isDraggable, matterBodyOptions, @@ -71,7 +69,7 @@ export function MatterBody({ }); return () => context.unregisterElement(id); - }, [angle, context, isDraggable, matterBodyOptions, x, y]); + }, [angle, context, id, isDraggable, matterBodyOptions, x, y]); return (
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,42 +54,14 @@ function useCommandContext() { function Command({ className, children, ...props }: React.ComponentProps<"div">) { const [search, setSearch] = React.useState(""); - const [items, setItems] = React.useState(() => new Map()); - - const registerItem = React.useCallback((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 = React.useCallback((id: string) => { - setItems((current) => { - if (!current.has(id)) { - return current; - } + const [items, dispatchItem] = React.useReducer(commandItemsReducer, new Map()); - const next = new Map(current); - next.delete(id); - return next; - }); - }, []); - - const contextValue = React.useMemo( - () => ({ - registerItem, - search, - setSearch, - unregisterItem, - visibleCount: Array.from(items.values()).filter(Boolean).length, - }), - [items, registerItem, search, unregisterItem], - ); + const contextValue = { + dispatchItem, + search, + setSearch, + visibleCount: Array.from(items.values()).filter(Boolean).length, + }; return ( { - 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; diff --git a/src/db/schema.ts b/src/db/schema.ts index 25b5ff6d..790de4eb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -96,6 +96,129 @@ 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 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", + { + 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/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-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-cors.test.ts b/src/features/mcp/mcp-cors.test.ts new file mode 100644 index 00000000..f4a6f16f --- /dev/null +++ b/src/features/mcp/mcp-cors.test.ts @@ -0,0 +1,35 @@ +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"); + expect(response.headers.get("access-control-allow-headers")).toContain("mcp-session-id"); + }); + + 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..e9866412 --- /dev/null +++ b/src/features/mcp/mcp-cors.ts @@ -0,0 +1,31 @@ +const mcpCorsRequestHeaders = "authorization, content-type, mcp-protocol-version, mcp-session-id"; +const mcpCorsResponseHeaders = "mcp-session-id, www-authenticate"; + +export function isMcpOAuthPath(pathname: string) { + return pathname === `${mcpAuthPath}/jwks` || pathname.startsWith(`${mcpAuthPath}/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", + }, + }); +} +import { mcpAuthPath } from "#/features/mcp/mcp-config"; diff --git a/src/features/mcp/mcp-openapi.ts b/src/features/mcp/mcp-openapi.ts new file mode 100644 index 00000000..66b382c2 --- /dev/null +++ b/src/features/mcp/mcp-openapi.ts @@ -0,0 +1,73 @@ +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) { + 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) => [ + `${mcpOperationPathPrefix}${operation.name}`, + buildOperationPath(operation), + ]), + ), + components: { + securitySchemes: { + oauth2: { + type: "oauth2", + flows: { + authorizationCode: { + 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 new file mode 100644 index 00000000..5b204fe2 --- /dev/null +++ b/src/features/mcp/mcp-operation-catalog.ts @@ -0,0 +1,120 @@ +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"; +import { createWorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; +import { + getWorkspaceToolScopes, + type WorkspaceToolAccess, + type WorkspaceToolEffects, + workspaceToolDefinitions, +} from "#/features/workspaces/operations/workspace-tool-definitions"; + +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..001b42fb --- /dev/null +++ b/src/features/mcp/mcp-route.ts @@ -0,0 +1,155 @@ +import { DynamicWorkerExecutor } from "@cloudflare/codemode"; +import { openApiMcpServer } from "@cloudflare/codemode/mcp"; +import { + oauthProviderAuthServerMetadata, + oauthProviderOpenIdConfigMetadata, +} from "@better-auth/oauth-provider"; +import { createMcpHandler } from "agents/mcp"; + +import { authenticateMcpRequest } from "#/features/mcp/mcp-auth"; +import { + getMcpUrls, + mcpAuthPath, + mcpOperationPathPrefix, + mcpPath, + mcpScopes, +} 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 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", + `${mcpAuthPath}/.well-known/openid-configuration`, +]); + +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, + }; +} + +function createThinkExMcpServer(env: Cloudflare.Env, principal: McpPrincipal) { + 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(mcpOperationPathPrefix)) { + throw new Error("Unsupported MCP operation request."); + } + + const operationName = options.path.slice(mcpOperationPathPrefix.length); + + return await executeMcpOperation({ + name: operationName, + body: options.body, + operationId: `mcp:${String(context.requestId)}:${crypto.randomUUID()}`, + principal, + }); + }, + }); + + return server; +} + +function protectedResourceMetadata() { + 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, + }); +} + +async function handleAuthenticatedMcpRequest( + request: Request, + env: Cloudflare.Env, + ctx: ExecutionContext, +) { + 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, + }); +} + +function isMcpProtocolPath(pathname: string) { + return ( + pathname === mcpPath || + protectedResourceMetadataPaths.has(pathname) || + authorizationServerMetadataPaths.has(pathname) || + openIdMetadataPaths.has(pathname) + ); +} + +export async function routeMcpRequest( + request: Request, + env: Cloudflare.Env, + ctx: ExecutionContext, +): Promise { + const pathname = new URL(request.url).pathname; + + if (request.method === "OPTIONS" && isMcpProtocolPath(pathname)) { + return mcpCorsPreflightResponse(); + } + + if (protectedResourceMetadataPaths.has(pathname)) { + return withMcpCors(protectedResourceMetadata()); + } + + if (authorizationServerMetadataPaths.has(pathname)) { + return await withAuth(async (auth) => { + return withMcpCors(await oauthProviderAuthServerMetadata(auth)(request)); + }); + } + + if (openIdMetadataPaths.has(pathname)) { + return await withAuth(async (auth) => { + return withMcpCors(await oauthProviderOpenIdConfigMetadata(auth)(request)); + }); + } + + if (pathname !== mcpPath) { + return null; + } + + return withMcpCors(await handleAuthenticatedMcpRequest(request, env, ctx)); +} diff --git a/src/features/workspaces/ai/ai-thread-prompt-scope.ts b/src/features/workspaces/ai/ai-thread-prompt-scope.ts index 6c1e5739..24a101a1 100644 --- a/src/features/workspaces/ai/ai-thread-prompt-scope.ts +++ b/src/features/workspaces/ai/ai-thread-prompt-scope.ts @@ -3,6 +3,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { workspaceMembers, workspaces } from "#/db/schema"; import { createDbContext } from "#/db/server"; import type { AIThreadPromptScope } from "#/features/workspaces/ai/ai-thread-metadata"; +import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; import { getWorkspaceMemberCapabilities } from "#/features/workspaces/workspace-member-capabilities"; export async function getWorkspacePromptScope({ @@ -32,7 +33,7 @@ export async function getWorkspacePromptScope({ .limit(1); if (!membership) { - throw new Error("Forbidden"); + throw new WorkspaceForbiddenError(); } return { diff --git a/src/features/workspaces/ai/ai-thread-runtime.ts b/src/features/workspaces/ai/ai-thread-runtime.ts index 14876ebc..752f71d9 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")), ); }, }; @@ -389,13 +389,15 @@ export function getWorkspaceAiGatewayProviderOptions(input?: { ...getWorkspaceAiGatewayTransportOptions(), ...getWorkspaceAiGatewayRoutingOptions(modelId), tags, - user: input?.thread?.userId, + ...(input?.thread ? { user: input.thread.userId } : {}), }, ...getWorkspaceAiReasoningOptions(modelId), - } as unknown as WorkspaceAiProviderOptions; + } satisfies WorkspaceAiProviderOptions; } -function getWorkspaceAiReasoningOptions(modelId: ReturnType) { +function getWorkspaceAiReasoningOptions( + modelId: ReturnType, +): WorkspaceAiProviderOptions { switch (modelId) { case "claude-sonnet": return { diff --git a/src/features/workspaces/ai/ai-thread.ts b/src/features/workspaces/ai/ai-thread.ts index 4c9283ab..ee544c9e 100644 --- a/src/features/workspaces/ai/ai-thread.ts +++ b/src/features/workspaces/ai/ai-thread.ts @@ -337,9 +337,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { viewed, }); } - - this.activeRunStartedAt = undefined; - this.activeUsageContext = undefined; } catch (error) { const thread = this.activeUsageContext?.thread; recordOperationalFailure({ @@ -354,6 +351,8 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { }, }); } finally { + this.activeRunStartedAt = undefined; + this.activeUsageContext = undefined; await this._refreshSessionPromptIfNeeded(); } } @@ -498,9 +497,29 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { latencySeconds?: number; prompt?: string; }) { - const thread = await this._getThreadContext().catch(() => null); + let thread: AIThreadContext | null = null; + try { + thread = await this._getThreadContext(); + } catch (error) { + recordOperationalFailure({ + error, + event: "ai_auxiliary_context", + fields: { + feature: input.feature, + thread_id: this.name, + }, + }); + } if (!thread) { + recordOperationalFailure({ + error: input.error, + event: "ai_auxiliary", + fields: { + feature: input.feature, + thread_id: this.name, + }, + }); return; } diff --git a/src/features/workspaces/ai/user-ai-agents.ts b/src/features/workspaces/ai/user-ai-agents.ts index e392e901..d9a1e769 100644 --- a/src/features/workspaces/ai/user-ai-agents.ts +++ b/src/features/workspaces/ai/user-ai-agents.ts @@ -10,6 +10,7 @@ import { } from "#/features/workspaces/ai/ai-inspector"; import { createAIThreadClass } from "#/features/workspaces/ai/ai-thread"; import type { ResourcePurgeResult } from "#/features/workspaces/resource-purge-result"; +import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; import { copyChatAttachmentsForThread, deleteChatAttachmentsForThread, @@ -57,12 +58,14 @@ export type { class AIThreadNotFoundError extends Error { constructor() { super("Chat thread not found"); + this.name = "AIThreadNotFoundError"; } } class AIThreadForbiddenError extends Error { constructor() { super("Forbidden"); + this.name = "AIThreadForbiddenError"; } } @@ -140,7 +143,11 @@ export class UserAIStore extends Agent { if (error instanceof AIThreadForbiddenError) { return new Response("Forbidden", { status: 403 }); } - return new Response("Chat thread not found", { status: 404 }); + if (error instanceof AIThreadNotFoundError) { + return new Response("Chat thread not found", { status: 404 }); + } + + throw error; } } @@ -156,10 +163,7 @@ export class UserAIStore extends Agent { throw new Error("workspaceId is required"); } - await getWorkspacePromptScope({ - userId: this.name, - workspaceId, - }); + await this._getWorkspacePromptScope(workspaceId); return this._createThreadRecord(workspaceId); } @@ -186,12 +190,7 @@ export class UserAIStore extends Agent { return null; } - await getWorkspacePromptScope({ - userId: this.name, - workspaceId, - }).catch(() => { - throw new AIThreadForbiddenError(); - }); + await this._getWorkspacePromptScope(workspaceId); const existing = this._getThreadSummary(threadId); if (existing) { @@ -282,7 +281,17 @@ export class UserAIStore extends Agent { userId: this.name, workspaceId: thread.workspace_id, }).catch((error) => { - console.error("Failed to delete chat attachments for deleted thread", error); + recordOperationalFailure({ + distinctId: this.name, + error, + event: "chat_attachment_cleanup", + fields: { + reason: "thread_deleted", + thread_id: threadId, + user_id: this.name, + workspace_id: thread.workspace_id, + }, + }); }), ); } @@ -305,8 +314,18 @@ export class UserAIStore extends Agent { } deleteThreadMeta(this, thread.id); - } catch { + } catch (error) { failed += 1; + recordOperationalFailure({ + distinctId: this.name, + error, + event: "ai_thread_purge", + fields: { + thread_id: thread.id, + user_id: this.name, + workspace_id: thread.workspace_id, + }, + }); } } @@ -390,10 +409,7 @@ export class UserAIStore extends Agent { async getThreadContext(threadId: string): Promise { try { const thread = await this._requireThreadMetaOrEnsureDefault(threadId); - const promptScope = await getWorkspacePromptScope({ - workspaceId: thread.workspace_id, - userId: this.name, - }); + const promptScope = await this._getWorkspacePromptScope(thread.workspace_id); return { id: thread.id, @@ -401,8 +417,12 @@ export class UserAIStore extends Agent { promptScope, userId: this.name, }; - } catch { - return null; + } catch (error) { + if (error instanceof AIThreadNotFoundError || error instanceof AIThreadForbiddenError) { + return null; + } + + throw error; } } @@ -600,7 +620,17 @@ export class UserAIStore extends Agent { userId: input.sourceUserId, workspaceId: snapshot.meta.workspace_id, }).catch((error) => { - console.error("Failed to delete source chat attachments after account linking", error); + recordOperationalFailure({ + distinctId: this.name, + error, + event: "chat_attachment_cleanup", + fields: { + reason: "account_linked", + thread_id: snapshot.meta.id, + user_id: input.sourceUserId, + workspace_id: snapshot.meta.workspace_id, + }, + }); }); this._refreshState(); @@ -665,23 +695,31 @@ export class UserAIStore extends Agent { throw new AIThreadNotFoundError(); } + await this._getWorkspacePromptScope(thread.workspace_id); + + return thread; + } + + private async _getWorkspacePromptScope(workspaceId: string) { try { - await getWorkspacePromptScope({ + return await getWorkspacePromptScope({ userId: this.name, - workspaceId: thread.workspace_id, + workspaceId, }); - } catch { - throw new AIThreadForbiddenError(); - } + } catch (error) { + if (error instanceof WorkspaceForbiddenError) { + throw new AIThreadForbiddenError(); + } - return thread; + throw error; + } } private async _requireThreadMetaOrEnsureDefault(threadId: string): Promise { try { return await this._requireThreadMeta(threadId); } catch (error) { - if (error instanceof AIThreadForbiddenError) { + if (!(error instanceof AIThreadNotFoundError)) { throw error; } diff --git a/src/features/workspaces/ai/web-tools.ts b/src/features/workspaces/ai/web-tools.ts index b6df09e3..8af461ce 100644 --- a/src/features/workspaces/ai/web-tools.ts +++ b/src/features/workspaces/ai/web-tools.ts @@ -48,7 +48,7 @@ const browserPageInputExamples = [ ]; export function createAIThreadWebTools(env: Cloudflare.Env): ToolSet { - const browser = env.BROWSER as unknown as QuickActionBinding; + const browser: QuickActionBinding = env.BROWSER; return { web_search: tool({ 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/components/WorkspaceFileIntakeProvider.tsx b/src/features/workspaces/components/WorkspaceFileIntakeProvider.tsx index ed152d86..bfde1ae1 100644 --- a/src/features/workspaces/components/WorkspaceFileIntakeProvider.tsx +++ b/src/features/workspaces/components/WorkspaceFileIntakeProvider.tsx @@ -1,4 +1,4 @@ -import { createContext, type ReactNode, use, useCallback, useState } from "react"; +import { createContext, type ReactNode, use, useState } from "react"; import { WorkspaceFileIntakeReviewDialog } from "#/features/workspaces/components/WorkspaceFileIntakeReviewDialog"; import { useWorkspaceFileUpload } from "#/features/workspaces/components/WorkspaceFileUploadProvider"; @@ -20,33 +20,27 @@ export function WorkspaceFileIntakeProvider({ children }: { children: ReactNode const { requestFileSelection, uploadFiles: uploadRawFiles } = useWorkspaceFileUpload(); const [rejectedFiles, setRejectedFiles] = useState([]); - const handleWorkspaceFiles = useCallback( - (files: Iterable, parentId: string | null) => { - const review = classifyIncomingWorkspaceFiles(Array.from(files), { - canUploadToWorkspace: capabilities.canMutateContent, - }); + const handleWorkspaceFiles = (files: Iterable, parentId: string | null) => { + const review = classifyIncomingWorkspaceFiles(Array.from(files), { + canUploadToWorkspace: capabilities.canMutateContent, + }); - if (review.accepted.length > 0) { - uploadRawFiles(review.accepted, parentId); - } + if (review.accepted.length > 0) { + uploadRawFiles(review.accepted, parentId); + } - setRejectedFiles(review.rejected); - }, - [capabilities.canMutateContent, uploadRawFiles], - ); + setRejectedFiles(review.rejected); + }; - const requestFileUpload = useCallback( - (parentId: string | null) => { - if (!capabilities.canMutateContent) { - return; - } + const requestFileUpload = (parentId: string | null) => { + if (!capabilities.canMutateContent) { + return; + } - requestFileSelection((files) => { - handleWorkspaceFiles(files, parentId); - }); - }, - [capabilities.canMutateContent, handleWorkspaceFiles, requestFileSelection], - ); + requestFileSelection((files) => { + handleWorkspaceFiles(files, parentId); + }); + }; return ( (null); const onSelectFilesRef = useRef<((files: File[]) => void) | null>(null); - const uploadFiles = useCallback( - (files: Iterable, parentId: string | null) => { - if (!capabilities.canMutateContent) { - return; - } - - const fileList = Array.from(files); - - if (fileList.length === 0) { - return; - } - - void runWorkspaceFileUploadBatch({ - workspaceId, - parentId, - files: fileList, - onSuccess: (command) => { - applyWorkspaceEventToCache(queryClient, command.event); - }, - }).catch(() => undefined); - }, - [capabilities.canMutateContent, queryClient, workspaceId], - ); + const uploadFiles = (files: Iterable, parentId: string | null) => { + if (!capabilities.canMutateContent) { + return; + } + + const fileList = Array.from(files); + + if (fileList.length === 0) { + return; + } + + void runWorkspaceFileUploadBatch({ + workspaceId, + parentId, + files: fileList, + onSuccess: (command) => { + applyWorkspaceEventToCache(queryClient, command.event); + }, + }).catch(() => undefined); + }; const requestFileSelection = (onSelectFiles: (files: File[]) => void) => { onSelectFilesRef.current = onSelectFiles; diff --git a/src/features/workspaces/components/WorkspaceShareEmailInviteField.tsx b/src/features/workspaces/components/WorkspaceShareEmailInviteField.tsx index 997ed07f..e01bf397 100644 --- a/src/features/workspaces/components/WorkspaceShareEmailInviteField.tsx +++ b/src/features/workspaces/components/WorkspaceShareEmailInviteField.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Send, X } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "#/components/ui/badge"; @@ -26,7 +26,7 @@ export function WorkspaceShareEmailInviteField({ }) { const queryClient = useQueryClient(); const inputRef = useRef(null); - const grantableRoles = useMemo(() => getGrantableInviteRoles(membershipRole), [membershipRole]); + const grantableRoles = getGrantableInviteRoles(membershipRole); const [draftEmails, setDraftEmails] = useState([]); const [inputValue, setInputValue] = useState(""); const [inviteRoleSelection, setInviteRoleSelection] = useState(() => diff --git a/src/features/workspaces/components/ai-chat/AiChatModelPicker.tsx b/src/features/workspaces/components/ai-chat/AiChatModelPicker.tsx index e41f8235..a099dde1 100644 --- a/src/features/workspaces/components/ai-chat/AiChatModelPicker.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatModelPicker.tsx @@ -1,5 +1,5 @@ import { Check, ChevronUp, Waypoints } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "#/components/ui/popover"; import { @@ -21,6 +21,12 @@ interface AiChatModelPickerProps { onModelChange?: (modelId: WorkspaceAiChatModelId) => void; } +const AUTO_MODEL = WORKSPACE_AI_CHAT_MODELS.find((model) => model.provider === "auto"); +const MODEL_GROUPS = WORKSPACE_AI_CHAT_PROVIDERS.flatMap((provider) => { + const models = WORKSPACE_AI_CHAT_MODELS.filter((model) => model.provider === provider.id); + return models.length > 0 ? [{ ...provider, models }] : []; +}); + export default function AiChatModelPicker({ modelId, onModelChange }: AiChatModelPickerProps) { const [open, setOpen] = useState(false); // The model whose details are shown in the side panel. Falls back to the @@ -34,16 +40,6 @@ export default function AiChatModelPicker({ modelId, onModelChange }: AiChatMode // The "Auto" option lives outside the provider groups — it's ThinkEx's own // choice, not a provider's model. - const autoModel = WORKSPACE_AI_CHAT_MODELS.find((model) => model.provider === "auto"); - const groups = useMemo( - () => - WORKSPACE_AI_CHAT_PROVIDERS.map((provider) => ({ - ...provider, - models: WORKSPACE_AI_CHAT_MODELS.filter((model) => model.provider === provider.id), - })).filter((group) => group.models.length > 0), - [], - ); - const handleSelect = (nextId: WorkspaceAiChatModelId) => { onModelChange?.(nextId); setOpen(false); @@ -71,7 +67,7 @@ export default function AiChatModelPicker({ modelId, onModelChange }: AiChatMode > {/* Left: grouped, scrollable model list */}
- {autoModel ? ( + {AUTO_MODEL ? (
@@ -79,24 +75,24 @@ export default function AiChatModelPicker({ modelId, onModelChange }: AiChatMode
) : 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; @@ -37,7 +40,8 @@ async function purgeUserAIStore(userId: string) { try { const store = getUserAIStoreLifecycleAgent(env, userId); return await store.purgeForDeletion(); - } catch { + } catch (error) { + recordPurgeAgentFailure("user", userId, error); return { attempted: 1, failed: 1 }; } } @@ -68,11 +72,24 @@ async function purgeWorkspaceResourcesResult(workspaceId: string): Promise undefined); - await deleteR2Prefix(input.bucket, prefix).catch(() => undefined); + const cleanupErrors: unknown[] = []; + try { + await flushPageWrites(writes); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + + try { + await deleteR2Prefix(input.bucket, prefix); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + + if (cleanupErrors.length > 0) { + throw new AggregateError( + [error, ...cleanupErrors], + "Workspace page projection failed and cleanup did not complete.", + { cause: error }, + ); + } + throw error; } } diff --git a/src/features/workspaces/invites/workspace-invite-email.ts b/src/features/workspaces/invites/workspace-invite-email.ts index 08517548..4c7fbdf6 100644 --- a/src/features/workspaces/invites/workspace-invite-email.ts +++ b/src/features/workspaces/invites/workspace-invite-email.ts @@ -8,6 +8,7 @@ import { getWorkspaceInviteFromEmail, TRANSACTIONAL_FROM_NAME, } from "#/lib/transactional-email"; +import { recordOperationalFailure } from "#/integrations/observability/operational-events"; export type WorkspaceInviteEmailDeliveryFailureReason = "missing_binding" | "send_failed"; @@ -87,7 +88,11 @@ export async function sendWorkspaceInviteEmails(input: { text: content.text, }); return null; - } catch { + } catch (error) { + recordOperationalFailure({ + error, + event: "workspace_invite_email_send", + }); return { email: invite.email, reason: "send_failed", diff --git a/src/features/workspaces/invites/workspace-invite-functions.ts b/src/features/workspaces/invites/workspace-invite-functions.ts index 3be34e83..b6ee5dc7 100644 --- a/src/features/workspaces/invites/workspace-invite-functions.ts +++ b/src/features/workspaces/invites/workspace-invite-functions.ts @@ -14,6 +14,7 @@ import { acceptWorkspaceInvite, getOrCreateWorkspaceInviteLink, getWorkspaceInvitePreview, + WorkspaceInviteError, } from "#/features/workspaces/invites/workspace-invites.server"; import { withDb, withWorkspaceDb } from "#/features/workspaces/server/workspace-db"; @@ -37,18 +38,38 @@ const cancelWorkspaceEmailInviteInputSchema = z.object({ inviteId: z.string().min(1), }); +type WorkspaceInviteResolution = { status: "ready"; value: T } | { status: "unavailable" }; + +async function resolveWorkspaceInvite( + operation: () => Promise, +): Promise> { + try { + return { status: "ready", value: await operation() }; + } catch (error) { + if (error instanceof WorkspaceInviteError) { + return { status: "unavailable" }; + } + + throw error; + } +} + export const getWorkspaceInvitePreviewFn = createServerFn({ method: "GET" }) .validator(workspaceInviteTokenSchema) - .handler(async ({ data }) => withDb((db) => getWorkspaceInvitePreview(db, data.token))); + .handler(async ({ data }) => + resolveWorkspaceInvite(() => withDb((db) => getWorkspaceInvitePreview(db, data.token))), + ); export const acceptWorkspaceInviteFn = createServerFn({ method: "POST" }) .validator(workspaceInviteTokenSchema) .handler(async ({ data }) => - withWorkspaceDb(({ db, userId }) => - acceptWorkspaceInvite(db, { - token: data.token, - userId, - }), + resolveWorkspaceInvite(() => + withWorkspaceDb(({ db, userId }) => + acceptWorkspaceInvite(db, { + token: data.token, + userId, + }), + ), ), ); 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-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..5d531c0b 100644 --- a/src/features/workspaces/kernel/workspace-kernel-item-commands.ts +++ b/src/features/workspaces/kernel/workspace-kernel-item-commands.ts @@ -30,12 +30,14 @@ import type { RenameWorkspaceKernelItemArgs, UpdateWorkspaceKernelItemColorArgs, WriteWorkspaceKernelItemArgs, + WorkspaceKernelMutationOutcome, } from "#/features/workspaces/kernel/workspace-kernel-types"; import { resolveWorkspaceItemColorForCreate, 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; @@ -63,7 +65,7 @@ export class WorkspaceKernelItemCommands { async createItem( input: CreateWorkspaceKernelItemArgs, - ): Promise> { + ): Promise> { const type = workspaceItemTypeSchema.parse(input.type); if (type === "file") { @@ -86,7 +88,7 @@ export class WorkspaceKernelItemCommands { const priorResult = getPriorResult(); if (priorResult) { - return priorResult; + return { command: priorResult, status: "applied" }; } const color = resolveWorkspaceItemColorForCreate({ @@ -100,13 +102,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,9 +141,11 @@ export class WorkspaceKernelItemCommands { const concurrentResult = getPriorResult(); if (concurrentResult) { - return concurrentResult; + return { command: concurrentResult, status: "applied" }; } + // 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, @@ -157,6 +176,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 +184,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 +205,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 +247,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 +269,7 @@ export class WorkspaceKernelItemCommands { payload: { items: movedItems }, }); - return { result: movedItems, event }; + return { command: { result: movedItems, event }, status: "applied" }; } async updateItemColor( @@ -277,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", @@ -294,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 }; } @@ -422,9 +464,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 +482,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.ts b/src/features/workspaces/kernel/workspace-kernel-store.ts index 3414de5f..690f1a02 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 type { WorkspaceKernelNameConflict } from "#/features/workspaces/kernel/workspace-kernel-types"; 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; @@ -214,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 ?? []), @@ -225,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..5c9a52da 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"; @@ -45,7 +46,10 @@ import type { WorkspaceRealtimeEvent, WorkspaceRealtimeServerMessage, } from "#/features/workspaces/realtime/messages"; -import { recordOperationalOutcome } from "#/integrations/observability/operational-events"; +import { + recordOperationalFailure, + recordOperationalOutcome, +} from "#/integrations/observability/operational-events"; import { deleteR2Prefix } from "#/lib/r2"; const workspaceKernelInlineThresholdBytes = 1_500_000; @@ -142,7 +146,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 +174,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), ); @@ -259,8 +263,16 @@ export class WorkspaceKernel extends Agent { workspaceId, itemId, }).purgeForDeletion(); - } catch { + } catch (error) { failed += 1; + recordOperationalFailure({ + error, + event: "workspace_document_purge", + fields: { + item_id: itemId, + workspace_id: workspaceId, + }, + }); } } diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index 75e459dc..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 { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-store"; 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, + 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 (error instanceof WorkspaceKernelNameConflictError) { - 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 b2104a8c..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-store"; 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 50af96cf..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 { WorkspaceKernelNameConflictError } from "#/features/workspaces/kernel/workspace-kernel-store"; 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 (error instanceof WorkspaceKernelNameConflictError) { - 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/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, diff --git a/src/features/workspaces/read-page-selection.ts b/src/features/workspaces/read-page-selection.ts index 7ab492a0..2fba1ca2 100644 --- a/src/features/workspaces/read-page-selection.ts +++ b/src/features/workspaces/read-page-selection.ts @@ -9,6 +9,7 @@ export const maxWorkspacePageReadCount = 20; export class WorkspacePageSelectionError extends Error { constructor(readonly code: "page_range_out_of_range" | "page_selection_too_large") { super(code); + this.name = "WorkspacePageSelectionError"; } } diff --git a/src/features/workspaces/realtime/use-workspace-presence.ts b/src/features/workspaces/realtime/use-workspace-presence.ts index 20d7f60b..da1a2d67 100644 --- a/src/features/workspaces/realtime/use-workspace-presence.ts +++ b/src/features/workspaces/realtime/use-workspace-presence.ts @@ -69,7 +69,7 @@ export function useWorkspaceRealtime({ onEventRef.current = onEvent; onReconnectRef.current = onReconnect; onRevisionGapRef.current = onRevisionGap; - }, [onEvent, onReconnect, onRevisionGap]); + }); let currentPresence = presence; if (presence.workspaceId !== workspaceId) { 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-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..b51a6272 100644 --- a/src/lib/auth.server.ts +++ b/src/lib/auth.server.ts @@ -1,10 +1,12 @@ 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 { getMcpResource, mcpOAuthScopes } from "#/features/mcp/mcp-config"; import { purgeUserAccountResources, transferLinkedAccountResources, @@ -44,6 +46,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 +174,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 = getMcpResource(appOrigin); return betterAuth({ database: drizzleAdapter(database, { @@ -162,6 +185,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 +197,45 @@ 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: [...mcpOAuthScopes], + consentPage: "/oauth/consent", + grantTypes: ["authorization_code", "refresh_token"], + 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(), ], diff --git a/src/lib/blog.ts b/src/lib/blog.ts index 34c6d53c..72e6997b 100644 --- a/src/lib/blog.ts +++ b/src/lib/blog.ts @@ -2,6 +2,13 @@ import { allBlogPosts } from "content-collections"; export const blogBasePath = "/blog"; +const blogDateFormatter = new Intl.DateTimeFormat("en", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); + export const publishedBlogPosts = allBlogPosts .filter((post) => !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]); } 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/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), }, }, }); diff --git a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts index 0dd2ed1b..ba517091 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, @@ -42,6 +43,7 @@ import { } from "#/features/workspaces/upload/workspace-upload-intake"; import { apiError, apiJson, getRequestId } from "#/lib/api/http"; import { getSessionFromRequest } from "#/lib/auth-queries.server"; +import { recordOperationalFailure } from "#/integrations/observability/operational-events"; const uploadIntentSchema = z.object({ clientMutationId: z.string().min(1), @@ -217,7 +219,13 @@ async function finalizeWorkspaceFileUpload( } if (claims.target === "staging") { - await env.WORKSPACE_KERNEL_FILES.delete(uploadedObjectKey).catch(() => undefined); + await deleteUploadObjectBestEffort({ + cleanup: "staging_upload", + key: uploadedObjectKey, + requestId, + userId: observation.userId, + workspaceId, + }); } uploadCompleted = true; @@ -227,11 +235,40 @@ async function finalizeWorkspaceFileUpload( return workspaceUploadErrorResponse(requestId, error); } finally { if (completionClaimKey && !uploadCompleted) { - await env.WORKSPACE_KERNEL_FILES.delete(completionClaimKey).catch(() => undefined); + await deleteUploadObjectBestEffort({ + cleanup: "completion_claim", + key: completionClaimKey, + requestId, + userId: observation.userId, + workspaceId, + }); } } } +async function deleteUploadObjectBestEffort(input: { + cleanup: "completion_claim" | "staging_upload"; + key: string; + requestId: string; + userId?: string; + workspaceId: string; +}) { + try { + await env.WORKSPACE_KERNEL_FILES.delete(input.key); + } catch (error) { + recordOperationalFailure({ + distinctId: input.userId, + error, + event: "workspace_file_upload_cleanup", + fields: { + cleanup: input.cleanup, + request_id: input.requestId, + workspace_id: input.workspaceId, + }, + }); + } +} + async function queueWorkspaceFileExtraction( upload: Awaited>, input: { itemId: string; requestId: string; userId: string; workspaceId: string }, @@ -268,16 +305,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) { @@ -357,6 +396,7 @@ class WorkspaceUploadRequestError extends Error { message: string, ) { super(message); + this.name = "WorkspaceUploadRequestError"; } } diff --git a/src/routes/invite.$token.tsx b/src/routes/invite.$token.tsx index 91c0d77f..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, @@ -21,10 +20,13 @@ export const Route = createFileRoute("/invite/$token")({ const result = await acceptWorkspaceInviteFn({ data: { token: params.token }, }); + if (result.status === "unavailable") { + return result; + } throw redirect({ to: "/workspaces/$workspaceId", - params: { workspaceId: result.workspaceId }, + params: { workspaceId: result.value.workspaceId }, search: { tab: undefined, view: undefined, @@ -32,50 +34,38 @@ export const Route = createFileRoute("/invite/$token")({ }); } - try { - return await getWorkspaceInvitePreviewFn({ - data: { token: params.token }, - }); - } catch { - throw new Error("INVITE_UNAVAILABLE"); - } + return await getWorkspaceInvitePreviewFn({ + data: { token: params.token }, + }); }, head: ({ loaderData }) => ({ - meta: loaderData - ? buildPublicMeta({ - title: `Join ${loaderData.workspaceName}`, - description: `${loaderData.inviterName} invited you to join ${loaderData.workspaceName} on ThinkEx.`, - openGraphImageAlt: `Join ${loaderData.workspaceName} on ThinkEx`, - }) - : buildPublicMeta({ - title: "Invite unavailable", - description: "This ThinkEx invite link is invalid, expired, or has been revoked.", - }), + meta: + loaderData?.status === "ready" + ? buildPublicMeta({ + title: `Join ${loaderData.value.workspaceName}`, + description: `${loaderData.value.inviterName} invited you to join ${loaderData.value.workspaceName} on ThinkEx.`, + openGraphImageAlt: `Join ${loaderData.value.workspaceName} on ThinkEx`, + }) + : buildPublicMeta({ + title: "Invite unavailable", + description: "This ThinkEx invite link is invalid, expired, or has been revoked.", + }), }), - component: InviteLandingPage, - errorComponent: InviteUnavailablePage, + component: InviteRoutePage, }); -function InviteScreen({ children }: { children: ReactNode }) { - return ( -
-
-
- - {children} -
-
-
- ); -} - -function InviteLandingPage() { +function InviteRoutePage() { const { token } = Route.useParams(); - const preview = Route.useLoaderData(); + const result = Route.useLoaderData(); + if (result.status === "unavailable") { + return ; + } + + const preview = result.value; const callbackURL = buildInvitePath(token); return ( - +

Join {preview.workspaceName}

@@ -86,13 +76,13 @@ function InviteLandingPage() {

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

Invite unavailable

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

- + ); } diff --git a/src/routes/oauth.consent.tsx b/src/routes/oauth.consent.tsx new file mode 100644 index 00000000..abaf9817 --- /dev/null +++ b/src/routes/oauth.consent.tsx @@ -0,0 +1,137 @@ +import { createFileRoute } from "@tanstack/react-router"; +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", + offline_access: "Stay connected until you revoke access", + ...mcpScopeDescriptions, +}; + +export const Route = createFileRoute("/oauth/consent")({ + validateSearch: z.object({ + 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 { 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) ?? []; + 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; + } + + try { + const result = await authClient.oauth2.consent({ accept, scope }); + + 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."); + } + + setPendingDecision(null); + } + + return ( + +
+
+

Connect an MCP client

+

Connect {clientName} to ThinkEx?

+ {userEmail ? ( +

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

+ ) : null} +
+ +
+

This will allow {clientName} to:

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

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