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 (
-
- );
-}
-
-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) => (
+ -
+
+ {scopeLabels[requestedScope] ?? 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) {