From 1fb785ed39f41c0111f1cb7feb10c4aae4c053b2 Mon Sep 17 00:00:00 2001 From: Rabithua <1289770378@qq.com> Date: Thu, 2 Jul 2026 01:04:26 +0800 Subject: [PATCH 1/2] Add OAuth HTTP MCP support --- server/drizzle.config.ts | 2 +- .../migrations/0017_orange_black_tom.sql | 91 + .../migrations/meta/0017_snapshot.json | 3233 +++++++++++++++++ server/drizzle/migrations/meta/_journal.json | 7 + server/drizzle/oauthMcpSchema.ts | 209 ++ server/json/main.json | 1 + server/mcp/articles.ts | 65 + server/mcp/attachmentFinalize.ts | 242 ++ server/mcp/attachmentPresign.ts | 131 + server/mcp/attachmentSupport.ts | 45 + server/mcp/attachments.ts | 48 + server/mcp/data.ts | 22 + server/mcp/errorCodes.json | 52 + server/mcp/notes.ts | 165 + server/mcp/profile.ts | 13 + server/mcp/reactions.ts | 26 + server/mcp/registry.ts | 22 + server/mcp/serverInfo.json | 7 + server/mcp/settings.ts | 12 + server/mcp/shared.ts | 123 + server/mcp/toolDefinitions.json | 653 ++++ server/mcp/tools.ts | 33 + server/mcp/types.ts | 17 + server/middleware/mcpOAuth.ts | 70 + server/oauth/errors.ts | 68 + server/oauth/flow.ts | 98 + server/oauth/messages.json | 54 + server/oauth/scopes.ts | 62 + server/oauth/tokens.ts | 72 + server/oauth/utils.ts | 113 + server/route/oauthMetadata.ts | 43 + server/route/v2/index.ts | 4 + server/route/v2/mcp.ts | 162 + server/route/v2/mcpOAuth.ts | 299 ++ server/scripts/runMigrations.ts | 5 +- server/server.ts | 2 + server/tests/oauth-mcp.test.ts | 50 + server/tests/oauthMcp/common.test.ts | 149 + server/tests/oauthMcp/flow.test.ts | 164 + .../oauthMcp/metadataAuthorization.test.ts | 92 + server/tests/oauthMcp/protocolTools.test.ts | 140 + server/tests/oauthMcp/tokenCases.test.ts | 160 + server/types/hono.ts | 7 + server/utils/dbMethods/index.ts | 4 + .../utils/dbMethods/oauthMcpAuthorization.ts | 124 + server/utils/dbMethods/oauthMcpClients.ts | 45 + server/utils/dbMethods/oauthMcpGrants.ts | 32 + .../utils/dbMethods/oauthMcpRefreshTokens.ts | 112 + server/utils/drizzle.ts | 5 +- web/src/locales/en.json | 48 + web/src/locales/ja.json | 48 + web/src/locales/zh.json | 48 + web/src/pages/login/index.tsx | 15 +- web/src/pages/oauth/authorize.tsx | 244 ++ web/src/route/main.tsx | 23 +- web/src/utils/oauthMcpApi.ts | 29 + 56 files changed, 7802 insertions(+), 8 deletions(-) create mode 100644 server/drizzle/migrations/0017_orange_black_tom.sql create mode 100644 server/drizzle/migrations/meta/0017_snapshot.json create mode 100644 server/drizzle/oauthMcpSchema.ts create mode 100644 server/mcp/articles.ts create mode 100644 server/mcp/attachmentFinalize.ts create mode 100644 server/mcp/attachmentPresign.ts create mode 100644 server/mcp/attachmentSupport.ts create mode 100644 server/mcp/attachments.ts create mode 100644 server/mcp/data.ts create mode 100644 server/mcp/errorCodes.json create mode 100644 server/mcp/notes.ts create mode 100644 server/mcp/profile.ts create mode 100644 server/mcp/reactions.ts create mode 100644 server/mcp/registry.ts create mode 100644 server/mcp/serverInfo.json create mode 100644 server/mcp/settings.ts create mode 100644 server/mcp/shared.ts create mode 100644 server/mcp/toolDefinitions.json create mode 100644 server/mcp/tools.ts create mode 100644 server/mcp/types.ts create mode 100644 server/middleware/mcpOAuth.ts create mode 100644 server/oauth/errors.ts create mode 100644 server/oauth/flow.ts create mode 100644 server/oauth/messages.json create mode 100644 server/oauth/scopes.ts create mode 100644 server/oauth/tokens.ts create mode 100644 server/oauth/utils.ts create mode 100644 server/route/oauthMetadata.ts create mode 100644 server/route/v2/mcp.ts create mode 100644 server/route/v2/mcpOAuth.ts create mode 100644 server/tests/oauth-mcp.test.ts create mode 100644 server/tests/oauthMcp/common.test.ts create mode 100644 server/tests/oauthMcp/flow.test.ts create mode 100644 server/tests/oauthMcp/metadataAuthorization.test.ts create mode 100644 server/tests/oauthMcp/protocolTools.test.ts create mode 100644 server/tests/oauthMcp/tokenCases.test.ts create mode 100644 server/utils/dbMethods/oauthMcpAuthorization.ts create mode 100644 server/utils/dbMethods/oauthMcpClients.ts create mode 100644 server/utils/dbMethods/oauthMcpGrants.ts create mode 100644 server/utils/dbMethods/oauthMcpRefreshTokens.ts create mode 100644 web/src/pages/oauth/authorize.tsx create mode 100644 web/src/utils/oauthMcpApi.ts diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 5f794954..d179ed87 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'drizzle-kit'; export default defineConfig({ - schema: './drizzle/schema.ts', + schema: ['./drizzle/schema.ts', './drizzle/oauthMcpSchema.ts'], out: './drizzle/migrations', dialect: 'postgresql', dbCredentials: { diff --git a/server/drizzle/migrations/0017_orange_black_tom.sql b/server/drizzle/migrations/0017_orange_black_tom.sql new file mode 100644 index 00000000..b1985ae7 --- /dev/null +++ b/server/drizzle/migrations/0017_orange_black_tom.sql @@ -0,0 +1,91 @@ +CREATE TABLE "oauth_authorization_codes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "codeHash" text NOT NULL, + "requestId" uuid NOT NULL, + "clientId" varchar(255) NOT NULL, + "userid" uuid NOT NULL, + "redirectUri" text NOT NULL, + "scopes" text[] DEFAULT '{}' NOT NULL, + "resource" text NOT NULL, + "codeChallenge" text NOT NULL, + "codeChallengeMethod" varchar(20) NOT NULL, + "consumedAt" timestamp (6) with time zone, + "expiresAt" timestamp (6) with time zone NOT NULL, + "createdAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "oauth_authorization_codes_codeHash_unique" UNIQUE("codeHash") +); +--> statement-breakpoint +CREATE TABLE "oauth_authorization_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clientId" varchar(255) NOT NULL, + "redirectUri" text NOT NULL, + "scopes" text[] DEFAULT '{}' NOT NULL, + "state" text, + "resource" text NOT NULL, + "codeChallenge" text NOT NULL, + "codeChallengeMethod" varchar(20) NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL, + "userid" uuid, + "expiresAt" timestamp (6) with time zone NOT NULL, + "createdAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp (6) with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "oauth_clients" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "clientId" varchar(255) NOT NULL, + "clientName" varchar(255) NOT NULL, + "clientUri" text, + "logoUri" text, + "redirectUris" text[] NOT NULL, + "scopes" text[] DEFAULT '{}' NOT NULL, + "grantTypes" text[] DEFAULT '{"authorization_code","refresh_token"}' NOT NULL, + "responseTypes" text[] DEFAULT '{"code"}' NOT NULL, + "createdAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "oauth_clients_clientId_unique" UNIQUE("clientId") +); +--> statement-breakpoint +CREATE TABLE "oauth_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userid" uuid NOT NULL, + "clientId" varchar(255) NOT NULL, + "scopes" text[] DEFAULT '{}' NOT NULL, + "resource" text NOT NULL, + "createdAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "unique_oauth_grant_user_client_resource" UNIQUE("userid","clientId","resource") +); +--> statement-breakpoint +CREATE TABLE "oauth_refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "tokenHash" text NOT NULL, + "clientId" varchar(255) NOT NULL, + "userid" uuid NOT NULL, + "scopes" text[] DEFAULT '{}' NOT NULL, + "resource" text NOT NULL, + "revokedAt" timestamp (6) with time zone, + "replacedByTokenId" uuid, + "expiresAt" timestamp (6) with time zone NOT NULL, + "createdAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + "updatedAt" timestamp (6) with time zone DEFAULT now() NOT NULL, + CONSTRAINT "oauth_refresh_tokens_tokenHash_unique" UNIQUE("tokenHash") +); +--> statement-breakpoint +ALTER TABLE "oauth_authorization_codes" ADD CONSTRAINT "oauth_authorization_codes_clientId_oauth_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."oauth_clients"("clientId") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_authorization_codes" ADD CONSTRAINT "oauth_authorization_codes_requestId_oauth_authorization_requests_id_fk" FOREIGN KEY ("requestId") REFERENCES "public"."oauth_authorization_requests"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_authorization_codes" ADD CONSTRAINT "oauth_authorization_codes_userid_users_id_fk" FOREIGN KEY ("userid") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_authorization_requests" ADD CONSTRAINT "oauth_authorization_requests_clientId_oauth_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."oauth_clients"("clientId") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_authorization_requests" ADD CONSTRAINT "oauth_authorization_requests_userid_users_id_fk" FOREIGN KEY ("userid") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_grants" ADD CONSTRAINT "oauth_grants_clientId_oauth_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."oauth_clients"("clientId") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_grants" ADD CONSTRAINT "oauth_grants_userid_users_id_fk" FOREIGN KEY ("userid") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_refresh_tokens" ADD CONSTRAINT "oauth_refresh_tokens_clientId_oauth_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."oauth_clients"("clientId") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "oauth_refresh_tokens" ADD CONSTRAINT "oauth_refresh_tokens_userid_users_id_fk" FOREIGN KEY ("userid") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "oauth_authorization_codes_codeHash_idx" ON "oauth_authorization_codes" USING btree ("codeHash");--> statement-breakpoint +CREATE INDEX "oauth_authorization_codes_requestId_idx" ON "oauth_authorization_codes" USING btree ("requestId");--> statement-breakpoint +CREATE INDEX "oauth_authorization_requests_clientId_idx" ON "oauth_authorization_requests" USING btree ("clientId");--> statement-breakpoint +CREATE INDEX "oauth_authorization_requests_expiresAt_idx" ON "oauth_authorization_requests" USING btree ("expiresAt");--> statement-breakpoint +CREATE INDEX "oauth_clients_clientId_idx" ON "oauth_clients" USING btree ("clientId");--> statement-breakpoint +CREATE INDEX "oauth_grants_userid_idx" ON "oauth_grants" USING btree ("userid");--> statement-breakpoint +CREATE INDEX "oauth_refresh_tokens_tokenHash_idx" ON "oauth_refresh_tokens" USING btree ("tokenHash");--> statement-breakpoint +CREATE INDEX "oauth_refresh_tokens_client_user_idx" ON "oauth_refresh_tokens" USING btree ("clientId","userid"); \ No newline at end of file diff --git a/server/drizzle/migrations/meta/0017_snapshot.json b/server/drizzle/migrations/meta/0017_snapshot.json new file mode 100644 index 00000000..4586a658 --- /dev/null +++ b/server/drizzle/migrations/meta/0017_snapshot.json @@ -0,0 +1,3233 @@ +{ + "id": "ab7014bc-c5bb-4e57-9cdb-634eef48ec07", + "prevId": "6aaf7d9d-2e81-4506-b9ae-8eb4b013bbda", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_token_usage_logs": { + "name": "ai_token_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "promptTokens": { + "name": "promptTokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completionTokens": { + "name": "completionTokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "totalTokens": { + "name": "totalTokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_token_usage_logs_userid_idx": { + "name": "ai_token_usage_logs_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_token_usage_logs_createdAt_idx": { + "name": "ai_token_usage_logs_createdAt_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_token_usage_logs_userid_users_id_fk": { + "name": "ai_token_usage_logs_userid_users_id_fk", + "tableFrom": "ai_token_usage_logs", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.articles": { + "name": "articles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorId": { + "name": "authorId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "articles_authorId_idx": { + "name": "articles_authorId_idx", + "columns": [ + { + "expression": "authorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "articles_authorId_users_id_fk": { + "name": "articles_authorId_users_id_fk", + "tableFrom": "articles", + "tableTo": "users", + "columnsFrom": [ + "authorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compressUrl": { + "name": "compressUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "posterUrl": { + "name": "posterUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "roteid": { + "name": "roteid", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "storage": { + "name": "storage", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sortIndex": { + "name": "sortIndex", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + } + }, + "indexes": { + "attachments_userid_idx": { + "name": "attachments_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_roteid_idx": { + "name": "attachments_roteid_idx", + "columns": [ + { + "expression": "roteid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_roteid_sortIndex_idx": { + "name": "attachments_roteid_sortIndex_idx", + "columns": [ + { + "expression": "roteid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sortIndex", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "attachments_userid_users_id_fk": { + "name": "attachments_userid_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "attachments_roteid_rotes_id_fk": { + "name": "attachments_roteid_rotes_id_fk", + "tableFrom": "attachments", + "tableTo": "rotes", + "columnsFrom": [ + "roteid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_embeddings": { + "name": "document_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sourceType": { + "name": "sourceType", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "sourceId": { + "name": "sourceId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chunkIndex": { + "name": "chunkIndex", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contentHash": { + "name": "contentHash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "embeddingModel": { + "name": "embeddingModel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embeddingDimensions": { + "name": "embeddingDimensions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_embeddings_ownerId_idx": { + "name": "document_embeddings_ownerId_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_embeddings_source_idx": { + "name": "document_embeddings_source_idx", + "columns": [ + { + "expression": "sourceType", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sourceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_embeddings_owner_source_idx": { + "name": "document_embeddings_owner_source_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sourceType", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_embeddings_model_dimensions_idx": { + "name": "document_embeddings_model_dimensions_idx", + "columns": [ + { + "expression": "embeddingModel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embeddingDimensions", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_embeddings_ownerId_users_id_fk": { + "name": "document_embeddings_ownerId_users_id_fk", + "tableFrom": "document_embeddings", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "document_embeddings_source_chunk_unique": { + "name": "document_embeddings_source_chunk_unique", + "nullsNotDistinct": false, + "columns": [ + "sourceType", + "sourceId", + "chunkIndex" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding_jobs": { + "name": "embedding_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ownerId": { + "name": "ownerId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sourceType": { + "name": "sourceType", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "sourceId": { + "name": "sourceId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'upsert'" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lockedAt": { + "name": "lockedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "embedding_jobs_status_idx": { + "name": "embedding_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_jobs_source_idx": { + "name": "embedding_jobs_source_idx", + "columns": [ + { + "expression": "sourceType", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sourceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_jobs_ownerId_idx": { + "name": "embedding_jobs_ownerId_idx", + "columns": [ + { + "expression": "ownerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "embedding_jobs_ownerId_users_id_fk": { + "name": "embedding_jobs_ownerId_users_id_fk", + "tableFrom": "embedding_jobs", + "tableTo": "users", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_authorization_codes": { + "name": "oauth_authorization_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requestId": { + "name": "requestId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "redirectUri": { + "name": "redirectUri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallengeMethod": { + "name": "codeChallengeMethod", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_authorization_codes_codeHash_idx": { + "name": "oauth_authorization_codes_codeHash_idx", + "columns": [ + { + "expression": "codeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_codes_requestId_idx": { + "name": "oauth_authorization_codes_requestId_idx", + "columns": [ + { + "expression": "requestId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_authorization_codes_clientId_oauth_clients_clientId_fk": { + "name": "oauth_authorization_codes_clientId_oauth_clients_clientId_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "clientId" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "oauth_authorization_codes_requestId_oauth_authorization_requests_id_fk": { + "name": "oauth_authorization_codes_requestId_oauth_authorization_requests_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "oauth_authorization_requests", + "columnsFrom": [ + "requestId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "oauth_authorization_codes_userid_users_id_fk": { + "name": "oauth_authorization_codes_userid_users_id_fk", + "tableFrom": "oauth_authorization_codes", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_authorization_codes_codeHash_unique": { + "name": "oauth_authorization_codes_codeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "codeHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_authorization_requests": { + "name": "oauth_authorization_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clientId": { + "name": "clientId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "redirectUri": { + "name": "redirectUri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallengeMethod": { + "name": "codeChallengeMethod", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_authorization_requests_clientId_idx": { + "name": "oauth_authorization_requests_clientId_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_authorization_requests_expiresAt_idx": { + "name": "oauth_authorization_requests_expiresAt_idx", + "columns": [ + { + "expression": "expiresAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_authorization_requests_clientId_oauth_clients_clientId_fk": { + "name": "oauth_authorization_requests_clientId_oauth_clients_clientId_fk", + "tableFrom": "oauth_authorization_requests", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "clientId" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "oauth_authorization_requests_userid_users_id_fk": { + "name": "oauth_authorization_requests_userid_users_id_fk", + "tableFrom": "oauth_authorization_requests", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_clients": { + "name": "oauth_clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clientId": { + "name": "clientId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "clientName": { + "name": "clientName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "clientUri": { + "name": "clientUri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logoUri": { + "name": "logoUri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirectUris": { + "name": "redirectUris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "grantTypes": { + "name": "grantTypes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"authorization_code\",\"refresh_token\"}'" + }, + "responseTypes": { + "name": "responseTypes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"code\"}'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_clients_clientId_idx": { + "name": "oauth_clients_clientId_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_clients_clientId_unique": { + "name": "oauth_clients_clientId_unique", + "nullsNotDistinct": false, + "columns": [ + "clientId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_grants": { + "name": "oauth_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_grants_userid_idx": { + "name": "oauth_grants_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_grants_clientId_oauth_clients_clientId_fk": { + "name": "oauth_grants_clientId_oauth_clients_clientId_fk", + "tableFrom": "oauth_grants", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "clientId" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "oauth_grants_userid_users_id_fk": { + "name": "oauth_grants_userid_users_id_fk", + "tableFrom": "oauth_grants", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_oauth_grant_user_client_resource": { + "name": "unique_oauth_grant_user_client_resource", + "nullsNotDistinct": false, + "columns": [ + "userid", + "clientId", + "resource" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientId": { + "name": "clientId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "replacedByTokenId": { + "name": "replacedByTokenId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_refresh_tokens_tokenHash_idx": { + "name": "oauth_refresh_tokens_tokenHash_idx", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_tokens_client_user_idx": { + "name": "oauth_refresh_tokens_client_user_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_tokens_clientId_oauth_clients_clientId_fk": { + "name": "oauth_refresh_tokens_clientId_oauth_clients_clientId_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "oauth_clients", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "clientId" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "oauth_refresh_tokens_userid_users_id_fk": { + "name": "oauth_refresh_tokens_userid_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_tokens_tokenHash_unique": { + "name": "oauth_refresh_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tokenHash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.open_key_usage_logs": { + "name": "open_key_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "openKeyId": { + "name": "openKeyId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "clientIp": { + "name": "clientIp", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "statusCode": { + "name": "statusCode", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "responseTime": { + "name": "responseTime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "open_key_usage_logs_openKeyId_idx": { + "name": "open_key_usage_logs_openKeyId_idx", + "columns": [ + { + "expression": "openKeyId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "open_key_usage_logs_createdAt_idx": { + "name": "open_key_usage_logs_createdAt_idx", + "columns": [ + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "open_key_usage_logs_openKeyId_user_open_keys_id_fk": { + "name": "open_key_usage_logs_openKeyId_user_open_keys_id_fk", + "tableFrom": "open_key_usage_logs", + "tableTo": "user_open_keys", + "columnsFrom": [ + "openKeyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "visitorId": { + "name": "visitorId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "visitorInfo": { + "name": "visitorInfo", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "roteid": { + "name": "roteid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reactions_roteid_type_idx": { + "name": "reactions_roteid_type_idx", + "columns": [ + { + "expression": "roteid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_userid_idx": { + "name": "reactions_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_visitorId_idx": { + "name": "reactions_visitorId_idx", + "columns": [ + { + "expression": "visitorId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_roteid_rotes_id_fk": { + "name": "reactions_roteid_rotes_id_fk", + "tableFrom": "reactions", + "tableTo": "rotes", + "columnsFrom": [ + "roteid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "reactions_userid_users_id_fk": { + "name": "reactions_userid_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_reaction": { + "name": "unique_reaction", + "nullsNotDistinct": false, + "columns": [ + "userid", + "visitorId", + "roteid", + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permission_policies": { + "name": "role_permission_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_permission_policies_role_idx": { + "name": "role_permission_policies_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_role_permission": { + "name": "unique_role_permission", + "nullsNotDistinct": false, + "columns": [ + "role", + "permission" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rote_changes": { + "name": "rote_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "originid": { + "name": "originid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "roteid": { + "name": "roteid", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'CREATE'" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rote_changes_originid_createdAt_idx": { + "name": "rote_changes_originid_createdAt_idx", + "columns": [ + { + "expression": "originid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rote_changes_originid_action_idx": { + "name": "rote_changes_originid_action_idx", + "columns": [ + { + "expression": "originid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rote_changes_roteid_createdAt_idx": { + "name": "rote_changes_roteid_createdAt_idx", + "columns": [ + { + "expression": "roteid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rote_changes_userid_idx": { + "name": "rote_changes_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rote_changes_roteid_action_idx": { + "name": "rote_changes_roteid_action_idx", + "columns": [ + { + "expression": "roteid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rote_changes_roteid_rotes_id_fk": { + "name": "rote_changes_roteid_rotes_id_fk", + "tableFrom": "rote_changes", + "tableTo": "rotes", + "columnsFrom": [ + "roteid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rote_link_previews": { + "name": "rote_link_previews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "roteid": { + "name": "roteid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "siteName": { + "name": "siteName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contentExcerpt": { + "name": "contentExcerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rote_link_previews_roteid_idx": { + "name": "rote_link_previews_roteid_idx", + "columns": [ + { + "expression": "roteid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rote_link_previews_roteid_rotes_id_fk": { + "name": "rote_link_previews_roteid_rotes_id_fk", + "tableFrom": "rote_link_previews", + "tableTo": "rotes", + "columnsFrom": [ + "roteid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rote_link_previews_roteid_url_unique": { + "name": "rote_link_previews_roteid_url_unique", + "nullsNotDistinct": false, + "columns": [ + "roteid", + "url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rotes": { + "name": "rotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "default": "'Rote'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "authorid": { + "name": "authorid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "articleId": { + "name": "articleId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pin": { + "name": "pin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "editor": { + "name": "editor", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false, + "default": "'normal'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rotes_authorid_state_idx": { + "name": "rotes_authorid_state_idx", + "columns": [ + { + "expression": "authorid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rotes_authorid_archived_idx": { + "name": "rotes_authorid_archived_idx", + "columns": [ + { + "expression": "authorid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rotes_authorid_created_at_idx": { + "name": "rotes_authorid_created_at_idx", + "columns": [ + { + "expression": "authorid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "createdAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rotes_tags_idx": { + "name": "rotes_tags_idx", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "rotes_articleId_idx": { + "name": "rotes_articleId_idx", + "columns": [ + { + "expression": "articleId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rotes_authorid_users_id_fk": { + "name": "rotes_authorid_users_id_fk", + "tableFrom": "rotes", + "tableTo": "users", + "columnsFrom": [ + "authorid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "rotes_articleId_articles_id_fk": { + "name": "rotes_articleId_articles_id_fk", + "tableFrom": "rotes", + "tableTo": "articles", + "columnsFrom": [ + "articleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group": { + "name": "group", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "isRequired": { + "name": "isRequired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isInitialized": { + "name": "isInitialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isSystem": { + "name": "isSystem", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "settings_isRequired_idx": { + "name": "settings_isRequired_idx", + "columns": [ + { + "expression": "isRequired", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settings_isInitialized_idx": { + "name": "settings_isInitialized_idx", + "columns": [ + { + "expression": "isInitialized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "settings_isSystem_idx": { + "name": "settings_isSystem_idx", + "columns": [ + { + "expression": "isSystem", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_group_unique": { + "name": "settings_group_unique", + "nullsNotDistinct": false, + "columns": [ + "group" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_oauth_bindings": { + "name": "user_oauth_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerUsername": { + "name": "providerUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_oauth_bindings_userid_idx": { + "name": "user_oauth_bindings_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_oauth_bindings_provider_idx": { + "name": "user_oauth_bindings_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_oauth_bindings_providerId_idx": { + "name": "user_oauth_bindings_providerId_idx", + "columns": [ + { + "expression": "providerId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_oauth_bindings_userid_users_id_fk": { + "name": "user_oauth_bindings_userid_users_id_fk", + "tableFrom": "user_oauth_bindings", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_provider": { + "name": "unique_user_provider", + "nullsNotDistinct": false, + "columns": [ + "userid", + "provider" + ] + }, + "unique_provider_id": { + "name": "unique_provider_id", + "nullsNotDistinct": false, + "columns": [ + "provider", + "providerId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_open_keys": { + "name": "user_open_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"SENDROTE\"}'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_open_keys_userid_idx": { + "name": "user_open_keys_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_open_keys_userid_users_id_fk": { + "name": "user_open_keys_userid_users_id_fk", + "tableFrom": "user_open_keys", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkeys": { + "name": "user_passkeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credentialId": { + "name": "credentialId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "publicKey": { + "name": "publicKey", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deviceName": { + "name": "deviceName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "deviceType": { + "name": "deviceType", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_passkeys_userid_idx": { + "name": "user_passkeys_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_passkeys_credentialId_idx": { + "name": "user_passkeys_credentialId_idx", + "columns": [ + { + "expression": "credentialId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkeys_userid_users_id_fk": { + "name": "user_passkeys_userid_users_id_fk", + "tableFrom": "user_passkeys", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_passkeys_credentialId_unique": { + "name": "user_passkeys_credentialId_unique", + "nullsNotDistinct": false, + "columns": [ + "credentialId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_permission_overrides": { + "name": "user_permission_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updatedBy": { + "name": "updatedBy", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_permission_overrides_userid_idx": { + "name": "user_permission_overrides_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_permission_overrides_userid_users_id_fk": { + "name": "user_permission_overrides_userid_users_id_fk", + "tableFrom": "user_permission_overrides", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_permission_overrides_updatedBy_users_id_fk": { + "name": "user_permission_overrides_updatedBy_users_id_fk", + "tableFrom": "user_permission_overrides", + "tableTo": "users", + "columnsFrom": [ + "updatedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_permission": { + "name": "unique_user_permission", + "nullsNotDistinct": false, + "columns": [ + "userid", + "permission" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "darkmode": { + "name": "darkmode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allowExplore": { + "name": "allowExplore", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_settings_userid_idx": { + "name": "user_settings_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_settings_userid_users_id_fk": { + "name": "user_settings_userid_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_settings_userid_unique": { + "name": "user_settings_userid_unique", + "nullsNotDistinct": false, + "columns": [ + "userid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_sw_subscriptions": { + "name": "user_sw_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userid": { + "name": "userid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expirationTime": { + "name": "expirationTime", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "keys": { + "name": "keys", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_sw_subscriptions_userid_idx": { + "name": "user_sw_subscriptions_userid_idx", + "columns": [ + { + "expression": "userid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_sw_subscriptions_endpoint_idx": { + "name": "user_sw_subscriptions_endpoint_idx", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_sw_subscriptions_userid_users_id_fk": { + "name": "user_sw_subscriptions_userid_users_id_fk", + "tableFrom": "user_sw_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "userid" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_sw_subscriptions_endpoint_unique": { + "name": "user_sw_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "passwordhash": { + "name": "passwordhash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "nickname": { + "name": "nickname", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover": { + "name": "cover", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp (6) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'user'" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/migrations/meta/_journal.json b/server/drizzle/migrations/meta/_journal.json index fde1558d..a3777557 100644 --- a/server/drizzle/migrations/meta/_journal.json +++ b/server/drizzle/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1781071229459, "tag": "0016_organic_sunset_bain", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1782903517329, + "tag": "0017_orange_black_tom", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/drizzle/oauthMcpSchema.ts b/server/drizzle/oauthMcpSchema.ts new file mode 100644 index 00000000..abc218d9 --- /dev/null +++ b/server/drizzle/oauthMcpSchema.ts @@ -0,0 +1,209 @@ +import { relations } from 'drizzle-orm'; +import { + foreignKey, + index, + pgTable, + text, + timestamp, + unique, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; +import { users } from './schema'; + +export const oauthClients = pgTable( + 'oauth_clients', + { + id: uuid('id').primaryKey().defaultRandom(), + clientId: varchar('clientId', { length: 255 }).notNull().unique(), + clientName: varchar('clientName', { length: 255 }).notNull(), + clientUri: text('clientUri'), + logoUri: text('logoUri'), + redirectUris: text('redirectUris').array().notNull(), + scopes: text('scopes').array().notNull().default([]), + grantTypes: text('grantTypes') + .array() + .notNull() + .default(['authorization_code', 'refresh_token']), + responseTypes: text('responseTypes').array().notNull().default(['code']), + createdAt: timestamp('createdAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + updatedAt: timestamp('updatedAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + }, + (table) => ({ clientIdIdx: index('oauth_clients_clientId_idx').on(table.clientId) }) +); + +export const oauthAuthorizationRequests = pgTable( + 'oauth_authorization_requests', + { + id: uuid('id').primaryKey().defaultRandom(), + clientId: varchar('clientId', { length: 255 }).notNull(), + redirectUri: text('redirectUri').notNull(), + scopes: text('scopes').array().notNull().default([]), + state: text('state'), + resource: text('resource').notNull(), + codeChallenge: text('codeChallenge').notNull(), + codeChallengeMethod: varchar('codeChallengeMethod', { length: 20 }).notNull(), + status: varchar('status', { length: 20 }).notNull().default('pending'), + userid: uuid('userid'), + expiresAt: timestamp('expiresAt', { withTimezone: true, precision: 6 }).notNull(), + createdAt: timestamp('createdAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + updatedAt: timestamp('updatedAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + }, + (table) => ({ + clientIdIdx: index('oauth_authorization_requests_clientId_idx').on(table.clientId), + expiresAtIdx: index('oauth_authorization_requests_expiresAt_idx').on(table.expiresAt), + clientFk: foreignKey({ columns: [table.clientId], foreignColumns: [oauthClients.clientId] }) + .onDelete('cascade') + .onUpdate('cascade'), + useridFk: foreignKey({ columns: [table.userid], foreignColumns: [users.id] }) + .onDelete('set null') + .onUpdate('cascade'), + }) +); + +export const oauthAuthorizationCodes = pgTable( + 'oauth_authorization_codes', + { + id: uuid('id').primaryKey().defaultRandom(), + codeHash: text('codeHash').notNull().unique(), + requestId: uuid('requestId').notNull(), + clientId: varchar('clientId', { length: 255 }).notNull(), + userid: uuid('userid').notNull(), + redirectUri: text('redirectUri').notNull(), + scopes: text('scopes').array().notNull().default([]), + resource: text('resource').notNull(), + codeChallenge: text('codeChallenge').notNull(), + codeChallengeMethod: varchar('codeChallengeMethod', { length: 20 }).notNull(), + consumedAt: timestamp('consumedAt', { withTimezone: true, precision: 6 }), + expiresAt: timestamp('expiresAt', { withTimezone: true, precision: 6 }).notNull(), + createdAt: timestamp('createdAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + }, + (table) => ({ + codeHashIdx: index('oauth_authorization_codes_codeHash_idx').on(table.codeHash), + requestIdIdx: index('oauth_authorization_codes_requestId_idx').on(table.requestId), + clientFk: foreignKey({ columns: [table.clientId], foreignColumns: [oauthClients.clientId] }) + .onDelete('cascade') + .onUpdate('cascade'), + requestFk: foreignKey({ + columns: [table.requestId], + foreignColumns: [oauthAuthorizationRequests.id], + }) + .onDelete('cascade') + .onUpdate('cascade'), + useridFk: foreignKey({ columns: [table.userid], foreignColumns: [users.id] }) + .onDelete('cascade') + .onUpdate('cascade'), + }) +); + +export const oauthRefreshTokens = pgTable( + 'oauth_refresh_tokens', + { + id: uuid('id').primaryKey().defaultRandom(), + tokenHash: text('tokenHash').notNull().unique(), + clientId: varchar('clientId', { length: 255 }).notNull(), + userid: uuid('userid').notNull(), + scopes: text('scopes').array().notNull().default([]), + resource: text('resource').notNull(), + revokedAt: timestamp('revokedAt', { withTimezone: true, precision: 6 }), + replacedByTokenId: uuid('replacedByTokenId'), + expiresAt: timestamp('expiresAt', { withTimezone: true, precision: 6 }).notNull(), + createdAt: timestamp('createdAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + updatedAt: timestamp('updatedAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + }, + (table) => ({ + tokenHashIdx: index('oauth_refresh_tokens_tokenHash_idx').on(table.tokenHash), + clientUserIdx: index('oauth_refresh_tokens_client_user_idx').on(table.clientId, table.userid), + clientFk: foreignKey({ columns: [table.clientId], foreignColumns: [oauthClients.clientId] }) + .onDelete('cascade') + .onUpdate('cascade'), + useridFk: foreignKey({ columns: [table.userid], foreignColumns: [users.id] }) + .onDelete('cascade') + .onUpdate('cascade'), + }) +); + +export const oauthGrants = pgTable( + 'oauth_grants', + { + id: uuid('id').primaryKey().defaultRandom(), + userid: uuid('userid').notNull(), + clientId: varchar('clientId', { length: 255 }).notNull(), + scopes: text('scopes').array().notNull().default([]), + resource: text('resource').notNull(), + createdAt: timestamp('createdAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + updatedAt: timestamp('updatedAt', { withTimezone: true, precision: 6 }).notNull().defaultNow(), + }, + (table) => ({ + uniqueUserClientResource: unique('unique_oauth_grant_user_client_resource').on( + table.userid, + table.clientId, + table.resource + ), + useridIdx: index('oauth_grants_userid_idx').on(table.userid), + clientFk: foreignKey({ columns: [table.clientId], foreignColumns: [oauthClients.clientId] }) + .onDelete('cascade') + .onUpdate('cascade'), + useridFk: foreignKey({ columns: [table.userid], foreignColumns: [users.id] }) + .onDelete('cascade') + .onUpdate('cascade'), + }) +); + +export const oauthClientsRelations = relations(oauthClients, ({ many }) => ({ + authorizationRequests: many(oauthAuthorizationRequests), + authorizationCodes: many(oauthAuthorizationCodes), + refreshTokens: many(oauthRefreshTokens), + grants: many(oauthGrants), +})); + +export const oauthAuthorizationRequestsRelations = relations( + oauthAuthorizationRequests, + ({ one, many }) => ({ + client: one(oauthClients, { + fields: [oauthAuthorizationRequests.clientId], + references: [oauthClients.clientId], + }), + user: one(users, { fields: [oauthAuthorizationRequests.userid], references: [users.id] }), + codes: many(oauthAuthorizationCodes), + }) +); + +export const oauthAuthorizationCodesRelations = relations(oauthAuthorizationCodes, ({ one }) => ({ + client: one(oauthClients, { + fields: [oauthAuthorizationCodes.clientId], + references: [oauthClients.clientId], + }), + request: one(oauthAuthorizationRequests, { + fields: [oauthAuthorizationCodes.requestId], + references: [oauthAuthorizationRequests.id], + }), + user: one(users, { fields: [oauthAuthorizationCodes.userid], references: [users.id] }), +})); + +export const oauthRefreshTokensRelations = relations(oauthRefreshTokens, ({ one }) => ({ + client: one(oauthClients, { + fields: [oauthRefreshTokens.clientId], + references: [oauthClients.clientId], + }), + user: one(users, { fields: [oauthRefreshTokens.userid], references: [users.id] }), +})); + +export const oauthGrantsRelations = relations(oauthGrants, ({ one }) => ({ + client: one(oauthClients, { + fields: [oauthGrants.clientId], + references: [oauthClients.clientId], + }), + user: one(users, { fields: [oauthGrants.userid], references: [users.id] }), +})); + +export type OAuthClient = typeof oauthClients.$inferSelect; +export type NewOAuthClient = typeof oauthClients.$inferInsert; +export type OAuthAuthorizationRequest = typeof oauthAuthorizationRequests.$inferSelect; +export type NewOAuthAuthorizationRequest = typeof oauthAuthorizationRequests.$inferInsert; +export type OAuthAuthorizationCode = typeof oauthAuthorizationCodes.$inferSelect; +export type NewOAuthAuthorizationCode = typeof oauthAuthorizationCodes.$inferInsert; +export type OAuthRefreshToken = typeof oauthRefreshTokens.$inferSelect; +export type NewOAuthRefreshToken = typeof oauthRefreshTokens.$inferInsert; +export type OAuthGrant = typeof oauthGrants.$inferSelect; +export type NewOAuthGrant = typeof oauthGrants.$inferInsert; diff --git a/server/json/main.json b/server/json/main.json index 2fec07be..1bcc67d9 100644 --- a/server/json/main.json +++ b/server/json/main.json @@ -51,6 +51,7 @@ "experiment", "admin", "app", + "oauth", "unknown", "doc", "article" diff --git a/server/mcp/articles.ts b/server/mcp/articles.ts new file mode 100644 index 00000000..766d8324 --- /dev/null +++ b/server/mcp/articles.ts @@ -0,0 +1,65 @@ +import { + createArticle, + deleteArticle, + findArticleById, + findRoteById, + getNoteArticleCard, + getNoteByArticleId, + listMyArticles, + updateArticle, +} from '../utils/dbMethods'; +import { ArticleCreateZod, ArticleUpdateZod } from '../utils/zod'; +import mcpErrors from './errorCodes.json'; +import { defineMcpTool } from './registry'; +import { assertUuid, parseOptionalInteger, parseOptionalLimit, requireAuth } from './shared'; +import type { McpTool } from './types'; + +export const articleTools: McpTool[] = [ + defineMcpTool('articles_create', async (c, args) => { + const auth = requireAuth(c); + ArticleCreateZod.parse(args); + return await createArticle({ content: args.content, authorId: auth.userId }); + }), + defineMcpTool('articles_list', async (c, args) => { + const auth = requireAuth(c); + return await listMyArticles(auth.userId, { + skip: parseOptionalInteger(args.skip, 'skip'), + limit: parseOptionalLimit(args.limit), + keyword: typeof args.keyword === 'string' ? args.keyword : undefined, + }); + }), + defineMcpTool('articles_get', async (c, args) => { + const auth = requireAuth(c); + const id = assertUuid(args.id, 'article_id'); + const article = await findArticleById(id); + if (!article) throw new Error(mcpErrors.articleNotFound); + const note = await getNoteByArticleId(id); + if (article.authorId === auth.userId || note?.state === 'public') return { ...article, note }; + throw new Error(mcpErrors.articleAccessDenied); + }), + defineMcpTool('articles_get_by_note', async (c, args) => { + const auth = requireAuth(c); + const noteId = assertUuid(args.noteId, 'note_id'); + const note = await findRoteById(noteId); + if (!note) throw new Error(mcpErrors.noteNotFound); + if (note.state !== 'public' && note.authorid !== auth.userId) { + throw new Error(mcpErrors.notePrivate); + } + return await getNoteArticleCard(noteId); + }), + defineMcpTool('articles_update', async (c, args) => { + const auth = requireAuth(c); + const id = assertUuid(args.id, 'article_id'); + ArticleUpdateZod.parse(args); + const updated = await updateArticle({ id, authorId: auth.userId, content: args.content }); + if (!updated) throw new Error(mcpErrors.articleNotFoundOrDenied); + return updated; + }), + defineMcpTool('articles_delete', async (c, args) => { + const auth = requireAuth(c); + const id = assertUuid(args.id, 'article_id'); + const removed = await deleteArticle({ id, authorId: auth.userId }); + if (!removed) throw new Error(mcpErrors.articleNotFoundOrDenied); + return removed; + }), +]; diff --git a/server/mcp/attachmentFinalize.ts b/server/mcp/attachmentFinalize.ts new file mode 100644 index 00000000..ff489be7 --- /dev/null +++ b/server/mcp/attachmentFinalize.ts @@ -0,0 +1,242 @@ +import { getAttachmentUploadPolicy } from '../attachments/uploadPolicy'; +import type { HonoContext } from '../types/hono'; +import type { UploadResult } from '../types/main'; +import { + extractCompressedUuid, + extractOriginalUploadUuid, + extractPairedVideoUuid, + extractPosterUuid, +} from '../attachments/uploadKeys'; +import { getAttachmentDetailsByRoteId, upsertAttachmentsByOriginalKey } from '../utils/dbMethods'; +import { + MAX_BATCH_SIZE, + inferAttachmentMediaKind, + isImageContentType, + isVideoContentType, + mergeUniqueRoteAttachmentDetails, + validateContentType, + validateFileSize, + validateRoteAttachmentDetails, +} from '../utils/fileValidation'; +import { checkObjectExists } from '../utils/r2'; +import type { FinalizeAttachmentInput } from './attachmentSupport'; +import mcpErrors from './errorCodes.json'; +import { requireStorageAvailable } from './attachmentSupport'; +import { requireAuth } from './shared'; + +function assertFinalizeInput( + attachments: FinalizeAttachmentInput[] | undefined +): asserts attachments is FinalizeAttachmentInput[] { + if (!attachments || !Array.isArray(attachments) || attachments.length === 0) { + throw new Error(mcpErrors.attachmentsRequired); + } + if (attachments.length > MAX_BATCH_SIZE) { + throw new Error(mcpErrors.attachmentBatchLimitExceeded); + } +} + +function validateAttachmentPayload(item: FinalizeAttachmentInput, maxVideoUploadSizeMB: number) { + if (item.mimetype) { + validateContentType(item.mimetype); + validateFileSize(item.size, item.mimetype, maxVideoUploadSizeMB); + } + if (item.mediaKind === 'livePhoto' || item.pairedVideoKey) { + if (!isImageContentType(item.mimetype)) throw new Error(mcpErrors.livePhotoOriginalNotImage); + validateContentType(item.pairedVideoMimetype); + if (!isVideoContentType(item.pairedVideoMimetype)) + throw new Error(mcpErrors.livePhotoPairedVideoNotVideo); + validateFileSize(item.pairedVideoSize, item.pairedVideoMimetype, maxVideoUploadSizeMB); + } +} + +async function collectValidAttachments(attachments: FinalizeAttachmentInput[]) { + const validationErrors: string[] = []; + const validAttachments: FinalizeAttachmentInput[] = []; + for (const item of attachments) { + const mediaKind = inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + key: item.originalKey, + }); + const normalizedAttachment = { ...item }; + const originalExists = await checkObjectExists(item.originalKey); + if (!originalExists) { + validationErrors.push( + mcpErrors.originalFileNotFoundPrefix + item.originalKey + ':' + item.uuid + ); + continue; + } + + const originalUuid = extractOriginalUploadUuid(item.originalKey); + if (!originalUuid || originalUuid !== item.uuid) { + validationErrors.push(mcpErrors.uuidMismatchPrefix + item.originalKey); + continue; + } + if (mediaKind === 'video' && normalizedAttachment.compressedKey) { + validationErrors.push(mcpErrors.videoCompressedKeyForbiddenPrefix + item.originalKey); + continue; + } + if ((mediaKind === 'image' || mediaKind === 'livePhoto') && normalizedAttachment.posterKey) { + normalizedAttachment.posterKey = undefined; + } + if ( + (mediaKind === 'image' || mediaKind === 'livePhoto') && + normalizedAttachment.compressedKey + ) { + const compressedExists = await checkObjectExists(normalizedAttachment.compressedKey); + const compressedUuid = extractCompressedUuid(normalizedAttachment.compressedKey); + if (!compressedExists || !compressedUuid || originalUuid !== compressedUuid) { + validationErrors.push( + mcpErrors.compressedFileInvalidPrefix + normalizedAttachment.compressedKey + ); + normalizedAttachment.compressedKey = undefined; + } + } + if (mediaKind === 'livePhoto') { + if (!normalizedAttachment.pairedVideoKey) { + validationErrors.push(mcpErrors.livePhotoPairedVideoMissingPrefix + item.originalKey); + continue; + } + const pairedVideoExists = await checkObjectExists(normalizedAttachment.pairedVideoKey); + const pairedVideoUuid = extractPairedVideoUuid(normalizedAttachment.pairedVideoKey); + if (!pairedVideoExists || !pairedVideoUuid || originalUuid !== pairedVideoUuid) { + validationErrors.push( + mcpErrors.livePhotoPairedVideoInvalidPrefix + normalizedAttachment.pairedVideoKey + ); + continue; + } + } else if (normalizedAttachment.pairedVideoKey) { + validationErrors.push(mcpErrors.pairedVideoLivePhotoOnlyPrefix + item.originalKey); + continue; + } + if (mediaKind === 'video' && normalizedAttachment.posterKey) { + const posterExists = await checkObjectExists(normalizedAttachment.posterKey); + const posterUuid = extractPosterUuid(normalizedAttachment.posterKey); + if (!posterExists || !posterUuid || originalUuid !== posterUuid) { + validationErrors.push(mcpErrors.posterFileInvalidPrefix + normalizedAttachment.posterKey); + normalizedAttachment.posterKey = undefined; + } + } + if (!mediaKind) { + validationErrors.push(mcpErrors.attachmentMediaUnsupportedPrefix + item.originalKey); + continue; + } + validAttachments.push(normalizedAttachment); + } + + if (validAttachments.length === 0) { + const message = + validationErrors.length === 1 + ? validationErrors[0] + : mcpErrors.attachmentValidationErrorsPrefix + validationErrors.join(';'); + throw new Error(message || mcpErrors.attachmentValidationFailed); + } + return validAttachments; +} + +function toUploadResult(urlPrefix: string, item: FinalizeAttachmentInput): UploadResult { + const mediaKind = inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype || null, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + }); + const pairedVideoUrl = + mediaKind === 'livePhoto' && item.pairedVideoKey ? urlPrefix + '/' + item.pairedVideoKey : null; + const details: any = { + size: item.size || 0, + mimetype: item.mimetype || null, + mediaKind, + mtime: new Date().toISOString(), + key: item.originalKey, + }; + if (item.compressedKey) details.compressKey = item.compressedKey; + if (item.posterKey) details.posterKey = item.posterKey; + if (pairedVideoUrl && item.pairedVideoKey) { + details.pairedVideoKey = item.pairedVideoKey; + details.pairedVideoUrl = pairedVideoUrl; + details.pairedVideoMimetype = item.pairedVideoMimetype || null; + details.pairedVideoSize = item.pairedVideoSize || 0; + if (item.pairedVideoFilename) details.pairedVideoFilename = item.pairedVideoFilename; + } + if (item.hash) details.hash = item.hash; + + return { + url: urlPrefix + '/' + item.originalKey, + compressUrl: + (mediaKind === 'image' || mediaKind === 'livePhoto') && item.compressedKey + ? urlPrefix + '/' + item.compressedKey + : null, + posterUrl: mediaKind === 'video' && item.posterKey ? urlPrefix + '/' + item.posterKey : null, + details, + }; +} + +export async function finalizeAttachments(c: HonoContext, args: Record) { + const auth = requireAuth(c); + const storageConfig = requireStorageAvailable(); + const uploadPolicy = await getAttachmentUploadPolicy(auth.userId); + if (!uploadPolicy.canUploadAttachments) throw new Error(mcpErrors.capabilityAttachmentUpload); + + const attachments = args.attachments as FinalizeAttachmentInput[] | undefined; + const noteId = typeof args.noteId === 'string' ? args.noteId : undefined; + assertFinalizeInput(attachments); + const prefix = 'users/' + auth.userId + '/'; + const invalid = attachments.find( + (item) => + !item.originalKey?.startsWith(prefix) || + (item.compressedKey !== undefined && !item.compressedKey.startsWith(prefix)) || + (item.posterKey !== undefined && !item.posterKey.startsWith(prefix)) || + (item.pairedVideoKey !== undefined && !item.pairedVideoKey.startsWith(prefix)) + ); + if (invalid) throw new Error(mcpErrors.objectKeyInvalid); + attachments.forEach((item) => validateAttachmentPayload(item, uploadPolicy.maxVideoUploadSizeMB)); + + const hasVideo = attachments.some( + (item) => + inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + key: item.originalKey, + }) === 'video' || + item.mediaKind === 'livePhoto' || + !!item.pairedVideoKey + ); + if (hasVideo && !auth.scopes.includes('video:upload')) + throw new Error(mcpErrors.insufficientVideoUpload); + if (hasVideo && !uploadPolicy.canUploadVideo) throw new Error(mcpErrors.capabilityVideoUpload); + + const validAttachments = await collectValidAttachments(attachments); + if (noteId) { + const currentAttachments = await getAttachmentDetailsByRoteId(noteId); + const pendingAttachments = validAttachments.map((item) => ({ + details: { + key: item.originalKey, + mimetype: item.mimetype || null, + mediaKind: inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype || null, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + }), + compressKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + }, + })); + validateRoteAttachmentDetails( + mergeUniqueRoteAttachmentDetails(currentAttachments, pendingAttachments) + ); + } + + const uploads = validAttachments.map((item) => toUploadResult(storageConfig.urlPrefix, item)); + return await upsertAttachmentsByOriginalKey(auth.userId, noteId, uploads); +} diff --git a/server/mcp/attachmentPresign.ts b/server/mcp/attachmentPresign.ts new file mode 100644 index 00000000..d9414e65 --- /dev/null +++ b/server/mcp/attachmentPresign.ts @@ -0,0 +1,131 @@ +import { randomUUID } from 'crypto'; +import { getUploadExtension } from '../attachments/uploadKeys'; +import { getAttachmentUploadPolicy } from '../attachments/uploadPolicy'; +import type { HonoContext } from '../types/hono'; +import { + MAX_FILES, + getMediaKindFromContentType, + isImageContentType, + isVideoContentType, + validateContentType, + validateFileSize, +} from '../utils/fileValidation'; +import { presignPutUrl } from '../utils/r2'; +import { AttachmentPresignZod } from '../utils/zod'; +import type { PresignFileInput } from './attachmentSupport'; +import mcpErrors from './errorCodes.json'; +import { requireStorageAvailable } from './attachmentSupport'; +import { requireAuth } from './shared'; + +function validatePresignFile(file: PresignFileInput, maxVideoUploadSizeMB: number) { + validateContentType(file.contentType); + if (file.mediaKind !== 'livePhoto') { + validateFileSize(file.size, file.contentType, maxVideoUploadSizeMB); + return; + } + + if (!isImageContentType(file.contentType)) { + throw new Error(mcpErrors.livePhotoOriginalNotImage); + } + if (!file.pairedVideo) { + throw new Error(mcpErrors.livePhotoPairedVideoRequired); + } + validateContentType(file.pairedVideo.contentType); + if (!isVideoContentType(file.pairedVideo.contentType)) { + throw new Error(mcpErrors.livePhotoPairedVideoNotVideo); + } + validateFileSize(file.size, file.contentType, maxVideoUploadSizeMB); + validateFileSize(file.pairedVideo.size, file.pairedVideo.contentType, maxVideoUploadSizeMB); +} + +export async function presignAttachments(c: HonoContext, args: Record) { + const auth = requireAuth(c); + requireStorageAvailable(); + AttachmentPresignZod.parse(args); + const files = args.files as PresignFileInput[]; + const uploadPolicy = await getAttachmentUploadPolicy(auth.userId); + if (!uploadPolicy.canUploadAttachments) { + throw new Error(mcpErrors.capabilityAttachmentUpload); + } + if (files.length > MAX_FILES) { + throw new Error(mcpErrors.fileCountExceeded); + } + + const hasVideo = files.some( + (file) => isVideoContentType(file.contentType) || file.mediaKind === 'livePhoto' + ); + if (hasVideo && !auth.scopes.includes('video:upload')) { + throw new Error(mcpErrors.insufficientVideoUpload); + } + if (hasVideo && !uploadPolicy.canUploadVideo) { + throw new Error(mcpErrors.capabilityVideoUpload); + } + + files.forEach((file) => validatePresignFile(file, uploadPolicy.maxVideoUploadSizeMB)); + + const items = await Promise.all( + files.map(async (file) => { + const uuid = randomUUID(); + const ext = getUploadExtension(file.filename, file.contentType); + const originalKey = 'users/' + auth.userId + '/uploads/' + uuid + ext; + const mediaKind = + file.mediaKind === 'livePhoto' + ? 'livePhoto' + : getMediaKindFromContentType(file.contentType); + const original = await presignPutUrl(originalKey, file.contentType || undefined, 15 * 60); + const result: Record = { + uuid, + original: { + key: originalKey, + putUrl: original.putUrl, + url: original.url, + contentType: file.contentType, + }, + }; + + if (mediaKind === 'image' || mediaKind === 'livePhoto') { + const compressedKey = 'users/' + auth.userId + '/compressed/' + uuid + '.webp'; + const compressed = await presignPutUrl(compressedKey, 'image/webp', 15 * 60); + result.compressed = { + key: compressedKey, + putUrl: compressed.putUrl, + url: compressed.url, + contentType: 'image/webp', + }; + } + + if (mediaKind === 'livePhoto') { + const pairedVideo = file.pairedVideo; + if (!pairedVideo) throw new Error(mcpErrors.livePhotoPairedVideoRequired); + const pairedVideoExt = getUploadExtension(pairedVideo.filename, pairedVideo.contentType); + const pairedVideoKey = 'users/' + auth.userId + '/paired-videos/' + uuid + pairedVideoExt; + const pairedVideoUpload = await presignPutUrl( + pairedVideoKey, + pairedVideo.contentType || undefined, + 15 * 60 + ); + result.pairedVideo = { + key: pairedVideoKey, + putUrl: pairedVideoUpload.putUrl, + url: pairedVideoUpload.url, + contentType: pairedVideo.contentType, + }; + } + + if (mediaKind === 'video') { + const posterKey = 'users/' + auth.userId + '/posters/' + uuid + '.jpg'; + const poster = await presignPutUrl(posterKey, 'image/jpeg', 15 * 60); + result.poster = { + key: posterKey, + putUrl: poster.putUrl, + url: poster.url, + contentType: 'image/jpeg', + }; + } + + return result; + }) + ); + + return { items }; +} diff --git a/server/mcp/attachmentSupport.ts b/server/mcp/attachmentSupport.ts new file mode 100644 index 00000000..704998cd --- /dev/null +++ b/server/mcp/attachmentSupport.ts @@ -0,0 +1,45 @@ +import type { StorageConfig } from '../types/config'; +import { getGlobalConfig } from '../utils/config'; +import mcpErrors from './errorCodes.json'; + +export type PresignFileInput = { + filename?: string; + contentType?: string; + size?: number; + mediaKind?: 'image' | 'video' | 'livePhoto'; + pairedVideo?: { + filename?: string; + contentType?: string; + size?: number; + }; +}; + +export type FinalizeAttachmentInput = { + uuid: string; + originalKey: string; + compressedKey?: string; + posterKey?: string; + pairedVideoKey?: string; + pairedVideoSize?: number; + pairedVideoMimetype?: string; + pairedVideoFilename?: string; + size?: number; + mimetype?: string; + mediaKind?: 'image' | 'video' | 'livePhoto'; + hash?: string; + noteId?: string; +}; + +export function requireStorageAvailable() { + const storageConfig = getGlobalConfig('storage'); + if ( + !storageConfig || + !storageConfig.endpoint || + !storageConfig.accessKeyId || + !storageConfig.secretAccessKey || + !storageConfig.bucket + ) { + throw new Error(mcpErrors.storageNotConfigured); + } + return storageConfig; +} diff --git a/server/mcp/attachments.ts b/server/mcp/attachments.ts new file mode 100644 index 00000000..c577279f --- /dev/null +++ b/server/mcp/attachments.ts @@ -0,0 +1,48 @@ +import { + deleteAttachment, + deleteAttachments, + findRoteById, + updateAttachmentsSortOrder, +} from '../utils/dbMethods'; +import { MAX_BATCH_SIZE } from '../utils/fileValidation'; +import { finalizeAttachments } from './attachmentFinalize'; +import mcpErrors from './errorCodes.json'; +import { presignAttachments } from './attachmentPresign'; +import { defineMcpTool } from './registry'; +import { assertUuid, requireAuth } from './shared'; +import type { McpTool } from './types'; + +export const attachmentTools: McpTool[] = [ + defineMcpTool('attachments_presign_upload', presignAttachments), + defineMcpTool('attachments_finalize_upload', finalizeAttachments), + defineMcpTool('attachments_sort', async (c, args) => { + const auth = requireAuth(c); + const noteId = assertUuid(args.noteId, 'note_id'); + const attachmentIds = args.attachmentIds as string[]; + if (!Array.isArray(attachmentIds) || attachmentIds.length === 0) + throw new Error(mcpErrors.attachmentIdsRequired); + if (attachmentIds.length > MAX_BATCH_SIZE) + throw new Error(mcpErrors.attachmentBatchLimitExceeded); + attachmentIds.forEach((id) => assertUuid(id, 'attachment_id')); + const note = await findRoteById(noteId); + if (!note) throw new Error(mcpErrors.noteNotFound); + if (note.authorid !== auth.userId) throw new Error(mcpErrors.noteOwnershipRequired); + return await updateAttachmentsSortOrder(auth.userId, noteId, attachmentIds); + }), + defineMcpTool( + 'attachments_delete_one', + async (c, args) => + await deleteAttachment(assertUuid(args.id, 'attachment_id'), requireAuth(c).userId) + ), + defineMcpTool('attachments_delete_many', async (c, args) => { + const auth = requireAuth(c); + const ids = args.ids as string[]; + if (!Array.isArray(ids) || ids.length === 0) throw new Error(mcpErrors.idsRequired); + if (ids.length > MAX_BATCH_SIZE) throw new Error(mcpErrors.batchLimitExceeded); + ids.forEach((id) => assertUuid(id, 'attachment_id')); + return await deleteAttachments( + ids.map((id) => ({ id })), + auth.userId + ); + }), +]; diff --git a/server/mcp/data.ts b/server/mcp/data.ts new file mode 100644 index 00000000..e7fcebc5 --- /dev/null +++ b/server/mcp/data.ts @@ -0,0 +1,22 @@ +import { getHeatMap, getMyTags, statistics } from '../utils/dbMethods'; +import { defineMcpTool, getToolDefinitionsForScopes } from './registry'; +import { requireAuth, validateDateRange } from './shared'; +import type { McpTool } from './types'; + +export const dataTools: McpTool[] = [ + defineMcpTool('permissions_get', async (c) => { + const auth = requireAuth(c); + return { + clientId: auth.clientId, + userId: auth.userId, + scopes: auth.scopes, + tools: getToolDefinitionsForScopes(auth.scopes).map((tool) => tool.name), + }; + }), + defineMcpTool('tags_get', async (c) => await getMyTags(requireAuth(c).userId)), + defineMcpTool('heatmap_get', async (c, args) => { + validateDateRange(args.startDate, args.endDate); + return await getHeatMap(requireAuth(c).userId, args.startDate, args.endDate); + }), + defineMcpTool('statistics_get', async (c) => await statistics(requireAuth(c).userId)), +]; diff --git a/server/mcp/errorCodes.json b/server/mcp/errorCodes.json new file mode 100644 index 00000000..2e0f02fc --- /dev/null +++ b/server/mcp/errorCodes.json @@ -0,0 +1,52 @@ +{ + "articleAccessDenied": "article_access_denied", + "articleNotFound": "article_not_found", + "articleNotFoundOrDenied": "article_not_found_or_denied", + "attachmentBatchLimitExceeded": "attachment_batch_limit_exceeded", + "attachmentIdsRequired": "attachment_ids_required", + "attachmentMediaUnsupportedPrefix": "attachment_media_unsupported:", + "attachmentValidationFailed": "attachment_validation_failed", + "attachmentValidationErrorsPrefix": "attachment_validation_errors:", + "attachmentsRequired": "attachments_required", + "archivedBooleanRequired": "archived_boolean_required", + "authRequired": "mcp_auth_required", + "batchLimitExceeded": "batch_limit_exceeded", + "capabilityAttachmentUpload": "capability_required:attachment.upload", + "capabilityVideoUpload": "capability_required:attachment.video.upload", + "compressedFileInvalidPrefix": "compressed_file_invalid:", + "dateRangeRequired": "date_range_required", + "fileCountExceeded": "file_count_exceeded", + "idsRequired": "ids_required", + "insufficientScope": "insufficient_scope", + "insufficientVideoUpload": "insufficient_scope:video:upload", + "invalidCalendarDatePrefix": "invalid_calendar_date:", + "invalidDateFormat": "invalid_date_format", + "invalidOrigin": "invalid_origin", + "invalidRequest": "invalid_request", + "invalidUuidPrefix": "invalid_uuid:", + "livePhotoOriginalNotImage": "live_photo_original_not_image", + "livePhotoPairedVideoInvalidPrefix": "live_photo_paired_video_invalid:", + "livePhotoPairedVideoMissingPrefix": "live_photo_paired_video_missing:", + "livePhotoPairedVideoNotVideo": "live_photo_paired_video_not_video", + "livePhotoPairedVideoRequired": "live_photo_paired_video_required", + "mcpToolFailed": "mcp_tool_failed", + "methodNotFoundPrefix": "method_not_found:", + "nonNegativeIntegerRequiredPrefix": "non_negative_integer_required:", + "noteNotFound": "note_not_found", + "noteOwnershipRequired": "note_ownership_required", + "notePrivate": "note_private", + "objectKeyInvalid": "object_key_invalid", + "originalFileNotFoundPrefix": "original_file_not_found:", + "pairedVideoLivePhotoOnlyPrefix": "paired_video_live_photo_only:", + "positiveLimitRequired": "positive_limit_required", + "posterFileInvalidPrefix": "poster_file_invalid:", + "roteNotFound": "rote_not_found", + "storageNotConfigured": "storage_not_configured", + "tagCountExceeded": "tag_count_exceeded", + "tagLengthExceeded": "tag_length_exceeded", + "toolDefinitionMissingPrefix": "mcp_tool_definition_missing:", + "toolNameRequired": "tool_name_required", + "unknownToolPrefix": "unknown_tool:", + "uuidMismatchPrefix": "attachment_uuid_mismatch:", + "videoCompressedKeyForbiddenPrefix": "video_compressed_key_forbidden:" +} diff --git a/server/mcp/notes.ts b/server/mcp/notes.ts new file mode 100644 index 00000000..289817be --- /dev/null +++ b/server/mcp/notes.ts @@ -0,0 +1,165 @@ +import type { HonoContext } from '../types/hono'; +import { + createRote, + deleteEmbeddingsForSource, + deleteRote, + deleteRoteAttachmentsByRoteId, + deleteRoteLinkPreviewsByRoteId, + editRote, + enqueueEmbeddingJob, + findMyRote, + findRoteById, + findRotesByIds, + searchMyRotes, + setNoteArticleId, +} from '../utils/dbMethods'; +import { MAX_BATCH_SIZE } from '../utils/fileValidation'; +import { extractUrlsFromContent, parseAndStoreRoteLinkPreviews } from '../utils/linkPreview'; +import { NoteCreateZod, NoteUpdateZod, SearchKeywordZod } from '../utils/zod'; +import mcpErrors from './errorCodes.json'; +import { defineMcpTool } from './registry'; +import { + assertUuid, + buildNoteFilter, + parseArchived, + parseOptionalInteger, + parseOptionalLimit, + processTags, + requireAuth, +} from './shared'; +import type { McpTool } from './types'; + +async function createNote(c: HonoContext, args: Record) { + const auth = requireAuth(c); + NoteCreateZod.parse(args); + const result = await createRote({ + content: args.content, + title: args.title || '', + state: args.state || 'private', + type: args.type || 'rote', + tags: processTags(args.tags), + pin: !!args.pin, + archived: !!args.archived, + editor: args.editor, + authorid: auth.userId, + }); + + void enqueueEmbeddingJob('rote', result.id, auth.userId).catch((error) => { + console.error('mcp_rote_embedding_enqueue_failed', error); + }); + + const articleIdToSet = + typeof args.articleId === 'string' + ? args.articleId + : Array.isArray(args.articleIds) && args.articleIds.length > 0 + ? args.articleIds[0] + : null; + if (articleIdToSet) { + await setNoteArticleId(result.id, articleIdToSet, auth.userId); + return result; + } + + void parseAndStoreRoteLinkPreviews(result.id, result.content).catch((error) => { + console.error('mcp_link_preview_create_failed', error); + }); + + return result; +} + +async function updateNote(c: HonoContext, args: Record) { + const auth = requireAuth(c); + const id = assertUuid(args.id, 'note_id'); + NoteUpdateZod.parse(args); + await editRote({ ...args, id, authorid: auth.userId }); + + void enqueueEmbeddingJob('rote', id, auth.userId).catch((error) => { + console.error('mcp_rote_embedding_enqueue_failed', error); + }); + + let articleIdToSet: string | null | undefined; + if ('articleId' in args) { + articleIdToSet = typeof args.articleId === 'string' ? args.articleId : (args.articleId ?? null); + } else if (Array.isArray(args.articleIds)) { + articleIdToSet = args.articleIds.length > 0 ? args.articleIds[0] : null; + } + + if (articleIdToSet !== undefined) { + await setNoteArticleId(id, articleIdToSet, auth.userId); + } + + const data = await findRoteById(id); + const hasArticle = Boolean(data?.articleId || data?.article); + const contentProvided = Object.prototype.hasOwnProperty.call(args, 'content'); + const contentForPreview = contentProvided ? args.content : data?.content; + + if (hasArticle && articleIdToSet !== undefined) { + await deleteRoteLinkPreviewsByRoteId(id); + } else if ( + (contentProvided || articleIdToSet !== undefined) && + typeof contentForPreview === 'string' + ) { + const urls = extractUrlsFromContent(contentForPreview); + await deleteRoteLinkPreviewsByRoteId(id); + if (urls.length > 0 && !hasArticle) { + void parseAndStoreRoteLinkPreviews(id, contentForPreview).catch((error) => { + console.error('mcp_link_preview_update_failed', error); + }); + } + } + + return data; +} + +export const noteTools: McpTool[] = [ + defineMcpTool('notes_create', createNote), + defineMcpTool('notes_list', async (c, args) => { + const auth = requireAuth(c); + return await findMyRote( + auth.userId, + parseOptionalInteger(args.skip, 'skip'), + parseOptionalLimit(args.limit), + buildNoteFilter(args), + parseArchived(args.archived) + ); + }), + defineMcpTool('notes_search', async (c, args) => { + const auth = requireAuth(c); + SearchKeywordZod.parse({ keyword: args.keyword }); + return await searchMyRotes( + auth.userId, + args.keyword, + parseOptionalInteger(args.skip, 'skip'), + parseOptionalLimit(args.limit), + buildNoteFilter(args), + parseArchived(args.archived) + ); + }), + defineMcpTool('notes_get', async (c, args) => { + const auth = requireAuth(c); + const id = assertUuid(args.id, 'note_id'); + const note = await findRoteById(id); + if (!note) throw new Error(mcpErrors.noteNotFound); + if (note.state === 'public' || note.authorid === auth.userId) return note; + throw new Error(mcpErrors.notePrivate); + }), + defineMcpTool('notes_batch_get', async (c, args) => { + const auth = requireAuth(c); + const ids = args.ids as string[]; + if (!Array.isArray(ids) || ids.length === 0) throw new Error(mcpErrors.idsRequired); + if (ids.length > MAX_BATCH_SIZE) throw new Error(mcpErrors.batchLimitExceeded); + ids.forEach((id) => assertUuid(id, 'note_id')); + const notes = await findRotesByIds(ids); + return notes.filter((note) => note.state === 'public' || note.authorid === auth.userId); + }), + defineMcpTool('notes_update', updateNote), + defineMcpTool('notes_delete', async (c, args) => { + const auth = requireAuth(c); + const id = assertUuid(args.id, 'note_id'); + const data = await deleteRote({ id, authorid: auth.userId }); + await deleteRoteAttachmentsByRoteId(id, auth.userId); + void deleteEmbeddingsForSource('rote', id).catch((error) => { + console.error('mcp_rote_embedding_delete_failed', error); + }); + return data; + }), +]; diff --git a/server/mcp/profile.ts b/server/mcp/profile.ts new file mode 100644 index 00000000..81750564 --- /dev/null +++ b/server/mcp/profile.ts @@ -0,0 +1,13 @@ +import { editMyProfile, getMyProfile } from '../utils/dbMethods'; +import { UsernameUpdateZod } from '../utils/zod'; +import { defineMcpTool } from './registry'; +import { requireAuth } from './shared'; +import type { McpTool } from './types'; + +export const profileTools: McpTool[] = [ + defineMcpTool('profile_get', async (c) => await getMyProfile(requireAuth(c).userId)), + defineMcpTool('profile_update', async (c, args) => { + if (args.username !== undefined) UsernameUpdateZod.parse({ username: args.username }); + return await editMyProfile(requireAuth(c).userId, args); + }), +]; diff --git a/server/mcp/reactions.ts b/server/mcp/reactions.ts new file mode 100644 index 00000000..6fbe821e --- /dev/null +++ b/server/mcp/reactions.ts @@ -0,0 +1,26 @@ +import { addReaction, findRoteById, removeReaction } from '../utils/dbMethods'; +import { ReactionCreateZod } from '../utils/zod'; +import mcpErrors from './errorCodes.json'; +import { defineMcpTool } from './registry'; +import { assertUuid, requireAuth } from './shared'; +import type { McpTool } from './types'; + +export const reactionTools: McpTool[] = [ + defineMcpTool('reactions_add', async (c, args) => { + const auth = requireAuth(c); + ReactionCreateZod.parse({ type: args.type, roteid: args.roteid, metadata: args.metadata }); + const note = await findRoteById(args.roteid); + if (!note) throw new Error(mcpErrors.roteNotFound); + return await addReaction({ + type: args.type, + roteid: args.roteid, + userid: auth.userId, + metadata: args.metadata, + }); + }), + defineMcpTool('reactions_remove', async (c, args) => { + const auth = requireAuth(c); + assertUuid(args.roteid, 'note_id'); + return await removeReaction({ type: args.type, roteid: args.roteid, userid: auth.userId }); + }), +]; diff --git a/server/mcp/registry.ts b/server/mcp/registry.ts new file mode 100644 index 00000000..0b2d9b3f --- /dev/null +++ b/server/mcp/registry.ts @@ -0,0 +1,22 @@ +import mcpErrors from './errorCodes.json'; +import rawDefinitions from './toolDefinitions.json'; +import type { McpTool, McpToolDefinition } from './types'; + +const toolDefinitions = rawDefinitions as McpToolDefinition[]; +const definitionByName = new Map( + toolDefinitions.map((definition) => [definition.name, definition]) +); + +export function defineMcpTool(name: string, handler: McpTool['handler']): McpTool { + const definition = definitionByName.get(name); + if (!definition) { + throw new Error(mcpErrors.toolDefinitionMissingPrefix + name); + } + return { ...definition, handler }; +} + +export function getToolDefinitionsForScopes(scopes: string[]): McpToolDefinition[] { + return toolDefinitions.filter((tool) => + tool.requiredScopes.every((scope) => scopes.includes(scope)) + ); +} diff --git a/server/mcp/serverInfo.json b/server/mcp/serverInfo.json new file mode 100644 index 00000000..7c80b1e1 --- /dev/null +++ b/server/mcp/serverInfo.json @@ -0,0 +1,7 @@ +{ + "name": "Rote MCP", + "version": "1.0.0", + "instructions": "Use Rote MCP tools to manage notes, articles, reactions, profile, statistics, settings, and attachments.", + "sseUnsupported": "SSE streams are not supported by this MCP endpoint", + "statelessDeleteUnsupported": "MCP sessions are stateless and cannot be deleted" +} diff --git a/server/mcp/settings.ts b/server/mcp/settings.ts new file mode 100644 index 00000000..f817f4e3 --- /dev/null +++ b/server/mcp/settings.ts @@ -0,0 +1,12 @@ +import { getMySettings, updateMySettings } from '../utils/dbMethods'; +import { defineMcpTool } from './registry'; +import { requireAuth } from './shared'; +import type { McpTool } from './types'; + +export const settingTools: McpTool[] = [ + defineMcpTool('settings_get', async (c) => await getMySettings(requireAuth(c).userId)), + defineMcpTool( + 'settings_update', + async (c, args) => await updateMySettings(requireAuth(c).userId, args) + ), +]; diff --git a/server/mcp/shared.ts b/server/mcp/shared.ts new file mode 100644 index 00000000..9de6e5f0 --- /dev/null +++ b/server/mcp/shared.ts @@ -0,0 +1,123 @@ +import type { HonoContext } from '../types/hono'; +import { createResponse, isValidUUID } from '../utils/main'; +import mcpErrors from './errorCodes.json'; +import type { McpToolResult } from './types'; + +export function requireAuth(c: HonoContext) { + const auth = c.get('mcpAuth'); + if (!auth) { + throw new Error(mcpErrors.authRequired); + } + return auth; +} + +export function assertUuid(id: unknown, label: string): string { + if (typeof id !== 'string' || !isValidUUID(id)) { + throw new Error(mcpErrors.invalidUuidPrefix + label); + } + return id; +} + +export function parseOptionalInteger(value: unknown, label: string): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(mcpErrors.nonNegativeIntegerRequiredPrefix + label); + } + return parsed; +} + +export function parseOptionalLimit(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined; + } + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(mcpErrors.positiveLimitRequired); + } + return parsed; +} + +export function processTags(tags: unknown): string[] { + if (Array.isArray(tags)) { + const processed = tags + .filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0) + .map((tag) => tag.trim()); + if (processed.length > 20) { + throw new Error(mcpErrors.tagCountExceeded); + } + for (const tag of processed) { + if (tag.length > 50) { + throw new Error(mcpErrors.tagLengthExceeded); + } + } + return processed; + } + + if (typeof tags === 'string' && tags.trim().length > 0) { + const tag = tags.trim(); + if (tag.length > 50) { + throw new Error(mcpErrors.tagLengthExceeded); + } + return [tag]; + } + + return []; +} + +export function buildNoteFilter(args: Record) { + const filter: any = {}; + const tags = processTags(args.tags ?? args.tag); + if (tags.length > 0) { + filter.tags = { hasEvery: tags }; + } + + for (const key of ['state', 'type', 'pin']) { + if (args[key] !== undefined) { + filter[key] = args[key]; + } + } + + return filter; +} + +export function parseArchived(value: unknown): boolean | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === 'boolean') { + return value; + } + if (value === 'true') return true; + if (value === 'false') return false; + throw new Error(mcpErrors.archivedBooleanRequired); +} + +export function validateDateRange(startDate: unknown, endDate: unknown) { + if (typeof startDate !== 'string' || typeof endDate !== 'string') { + throw new Error(mcpErrors.dateRangeRequired); + } + + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(startDate) || !dateRegex.test(endDate)) { + throw new Error(mcpErrors.invalidDateFormat); + } + + for (const [label, value] of [ + ['start_date', startDate], + ['end_date', endDate], + ] as const) { + const parsed = new Date(value + 'T00:00:00Z'); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) { + throw new Error(mcpErrors.invalidCalendarDatePrefix + label); + } + } +} + +export function formatToolOutput(data: any): McpToolResult { + return { + content: [{ type: 'text', text: JSON.stringify(createResponse(data), null, 2) }], + }; +} diff --git a/server/mcp/toolDefinitions.json b/server/mcp/toolDefinitions.json new file mode 100644 index 00000000..359b2cc3 --- /dev/null +++ b/server/mcp/toolDefinitions.json @@ -0,0 +1,653 @@ +[ + { + "name": "notes_create", + "description": "Create a Rote note for the current user.", + "requiredScopes": ["notes:write"], + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "title": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["private", "public"] + }, + "type": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "pin": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "editor": { + "type": "string" + }, + "articleId": { + "type": "string", + "format": "uuid" + }, + "articleIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 1 + } + }, + "required": ["content"], + "additionalProperties": false + } + }, + { + "name": "notes_list", + "description": "List notes owned by the current user.", + "requiredScopes": ["notes:read"], + "inputSchema": { + "type": "object", + "properties": { + "skip": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1 + }, + "archived": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "state": { + "type": "string" + }, + "type": { + "type": "string" + }, + "pin": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "notes_search", + "description": "Search notes owned by the current user.", + "requiredScopes": ["notes:read"], + "inputSchema": { + "type": "object", + "properties": { + "keyword": { + "type": "string" + }, + "skip": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1 + }, + "archived": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "state": { + "type": "string" + }, + "type": { + "type": "string" + }, + "pin": { + "type": "boolean" + } + }, + "required": ["keyword"], + "additionalProperties": false + } + }, + { + "name": "notes_get", + "description": "Get a note by ID if it is public or owned by the current user.", + "requiredScopes": ["notes:read"], + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + { + "name": "notes_batch_get", + "description": "Batch get public or owned notes by IDs.", + "requiredScopes": ["notes:read"], + "inputSchema": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1 + } + }, + "required": ["ids"], + "additionalProperties": false + } + }, + { + "name": "notes_update", + "description": "Update a note owned by the current user.", + "requiredScopes": ["notes:write"], + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "content": { + "type": "string" + }, + "type": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "state": { + "type": "string" + }, + "archived": { + "type": "boolean" + }, + "pin": { + "type": "boolean" + }, + "editor": { + "type": "string" + }, + "attachmentIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "articleId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "articleIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "maxItems": 1 + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + { + "name": "notes_delete", + "description": "Delete a note owned by the current user.", + "requiredScopes": ["notes:delete"], + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + { + "name": "articles_create", + "description": "Create an article for the current user.", + "requiredScopes": ["articles:write"], + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": ["content"], + "additionalProperties": false + } + }, + { + "name": "articles_list", + "description": "List current user articles.", + "requiredScopes": ["articles:read"], + "inputSchema": { + "type": "object", + "properties": { + "skip": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1 + }, + "keyword": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "articles_get", + "description": "Get an article by ID if owned or referenced by a public note.", + "requiredScopes": ["articles:read"], + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + { + "name": "articles_get_by_note", + "description": "Get the article card bound to a note.", + "requiredScopes": ["articles:read"], + "inputSchema": { + "type": "object", + "properties": { + "noteId": { + "type": "string", + "format": "uuid" + } + }, + "required": ["noteId"], + "additionalProperties": false + } + }, + { + "name": "articles_update", + "description": "Update an article owned by the current user.", + "requiredScopes": ["articles:write"], + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "content": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + { + "name": "articles_delete", + "description": "Delete an article owned by the current user.", + "requiredScopes": ["articles:delete"], + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + { + "name": "reactions_add", + "description": "Add a reaction to a note as the current user.", + "requiredScopes": ["reactions:write"], + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "roteid": { + "type": "string", + "format": "uuid" + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "roteid"], + "additionalProperties": false + } + }, + { + "name": "reactions_remove", + "description": "Remove the current user reaction from a note.", + "requiredScopes": ["reactions:delete"], + "inputSchema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "roteid": { + "type": "string", + "format": "uuid" + } + }, + "required": ["type", "roteid"], + "additionalProperties": false + } + }, + { + "name": "profile_get", + "description": "Get current user profile.", + "requiredScopes": ["profile:read"], + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "profile_update", + "description": "Update current user profile.", + "requiredScopes": ["profile:write"], + "inputSchema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "description": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "cover": { + "type": "string" + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "permissions_get", + "description": "Get OAuth scopes and currently available MCP tools.", + "requiredScopes": [], + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "tags_get", + "description": "Get current user tag statistics.", + "requiredScopes": ["stats:read"], + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "heatmap_get", + "description": "Get current user activity heatmap between two dates.", + "requiredScopes": ["stats:read"], + "inputSchema": { + "type": "object", + "properties": { + "startDate": { + "type": "string" + }, + "endDate": { + "type": "string" + } + }, + "required": ["startDate", "endDate"], + "additionalProperties": false + } + }, + { + "name": "statistics_get", + "description": "Get current user note, article, and attachment statistics.", + "requiredScopes": ["stats:read"], + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "settings_get", + "description": "Get current user settings.", + "requiredScopes": ["settings:read"], + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "settings_update", + "description": "Update current user settings.", + "requiredScopes": ["settings:write"], + "inputSchema": { + "type": "object", + "properties": { + "allowExplore": { + "type": "boolean" + } + }, + "required": [], + "additionalProperties": false + } + }, + { + "name": "attachments_presign_upload", + "description": "Create presigned upload URLs for image, video, or Live Photo attachments.", + "requiredScopes": ["attachments:write"], + "inputSchema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "minItems": 1, + "maxItems": 9, + "items": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 1 + }, + "mediaKind": { + "type": "string", + "enum": ["image", "video", "livePhoto"] + }, + "pairedVideo": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "size": { + "type": "integer", + "minimum": 1 + } + }, + "required": ["contentType", "size"], + "additionalProperties": false + } + }, + "required": ["contentType", "size"], + "additionalProperties": false + } + } + }, + "required": ["files"], + "additionalProperties": false + } + }, + { + "name": "attachments_finalize_upload", + "description": "Finalize uploaded attachment objects and create attachment records.", + "requiredScopes": ["attachments:write"], + "inputSchema": { + "type": "object", + "properties": { + "noteId": { + "type": "string", + "format": "uuid" + }, + "attachments": { + "type": "array", + "items": { + "type": "object" + }, + "minItems": 1 + } + }, + "required": ["attachments"], + "additionalProperties": false + } + }, + { + "name": "attachments_sort", + "description": "Update attachment order for a note owned by the current user.", + "requiredScopes": ["attachments:write"], + "inputSchema": { + "type": "object", + "properties": { + "noteId": { + "type": "string", + "format": "uuid" + }, + "attachmentIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1 + } + }, + "required": ["noteId", "attachmentIds"], + "additionalProperties": false + } + }, + { + "name": "attachments_delete_one", + "description": "Delete one attachment owned by the current user.", + "requiredScopes": ["attachments:delete"], + "inputSchema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + { + "name": "attachments_delete_many", + "description": "Delete multiple attachments owned by the current user.", + "requiredScopes": ["attachments:delete"], + "inputSchema": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "minItems": 1 + } + }, + "required": ["ids"], + "additionalProperties": false + } + } +] diff --git a/server/mcp/tools.ts b/server/mcp/tools.ts new file mode 100644 index 00000000..d68a2d54 --- /dev/null +++ b/server/mcp/tools.ts @@ -0,0 +1,33 @@ +import { articleTools } from './articles'; +import { attachmentTools } from './attachments'; +import { dataTools } from './data'; +import { noteTools } from './notes'; +import { profileTools } from './profile'; +import { reactionTools } from './reactions'; +import { settingTools } from './settings'; +import { formatToolOutput } from './shared'; +import type { McpTool, McpToolResult } from './types'; + +export type { McpTool, McpToolResult } from './types'; + +export const mcpTools: McpTool[] = [ + ...noteTools, + ...articleTools, + ...reactionTools, + ...profileTools, + ...dataTools, + ...settingTools, + ...attachmentTools, +]; + +export function getToolsForScopes(scopes: string[]): McpTool[] { + return mcpTools.filter((tool) => tool.requiredScopes.every((scope) => scopes.includes(scope))); +} + +export function getToolByName(name: string): McpTool | undefined { + return mcpTools.find((tool) => tool.name === name); +} + +export function toMcpToolResult(data: any): McpToolResult { + return formatToolOutput(data); +} diff --git a/server/mcp/types.ts b/server/mcp/types.ts new file mode 100644 index 00000000..7dccd650 --- /dev/null +++ b/server/mcp/types.ts @@ -0,0 +1,17 @@ +import type { HonoContext } from '../types/hono'; + +export type McpToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +export type McpToolDefinition = { + name: string; + description: string; + requiredScopes: string[]; + inputSchema: Record; +}; + +export type McpTool = McpToolDefinition & { + handler: (c: HonoContext, args: Record) => Promise; +}; diff --git a/server/middleware/mcpOAuth.ts b/server/middleware/mcpOAuth.ts new file mode 100644 index 00000000..0b6b2ce5 --- /dev/null +++ b/server/middleware/mcpOAuth.ts @@ -0,0 +1,70 @@ +import messages from '../oauth/messages.json'; +import type { HonoContext } from '../types/hono'; +import { getSafeUser } from '../utils/dbMethods'; +import { verifyMcpAccessToken } from '../oauth/tokens'; +import { getMcpResource, getOAuthIssuer } from '../oauth/utils'; + +function setAuthChallenge(c: HonoContext, error?: string, scope?: string) { + const resourceMetadata = `${getOAuthIssuer(c)}/.well-known/oauth-protected-resource`; + const parts = [`resource_metadata="${resourceMetadata}"`]; + if (error) { + parts.push(`error="${error}"`); + } + if (scope) { + parts.push(`scope="${scope}"`); + } + c.header('WWW-Authenticate', `Bearer ${parts.join(', ')}`); +} + +export async function authenticateMcpOAuth(c: HonoContext, next: () => Promise) { + const authHeader = c.req.header('authorization') || ''; + const [scheme, token] = authHeader.split(/\s+/, 2); + + if (scheme?.toLowerCase() !== 'bearer' || !token) { + setAuthChallenge(c); + return c.json({ error: messages.errors.authorizationRequired }, 401); + } + + try { + const payload = await verifyMcpAccessToken(token, getOAuthIssuer(c), getMcpResource(c)); + const user = await getSafeUser(payload.userId); + if (!user) { + setAuthChallenge(c, messages.errors.invalidToken); + return c.json({ error: messages.errors.invalidToken }, 401); + } + + c.set('user', user); + c.set('mcpAuth', { + token, + userId: payload.userId, + clientId: payload.clientId, + scopes: payload.scope.split(/\s+/).filter(Boolean), + resource: payload.resource, + }); + await next(); + } catch (_error) { + setAuthChallenge(c, messages.errors.invalidToken); + return c.json({ error: messages.errors.invalidToken }, 401); + } +} + +export function requireMcpScopes(requiredScopes: string[]) { + return async (c: HonoContext, next: () => Promise) => { + const auth = c.get('mcpAuth'); + if (!auth) { + setAuthChallenge(c); + return c.json({ error: messages.errors.authorizationRequired }, 401); + } + + const missing = requiredScopes.filter((scope) => !auth.scopes.includes(scope)); + if (missing.length > 0) { + setAuthChallenge(c, messages.errors.insufficientScope, requiredScopes.join(' ')); + return c.json( + { error: messages.errors.insufficientScope, scope: requiredScopes.join(' ') }, + 403 + ); + } + + await next(); + }; +} diff --git a/server/oauth/errors.ts b/server/oauth/errors.ts new file mode 100644 index 00000000..91594005 --- /dev/null +++ b/server/oauth/errors.ts @@ -0,0 +1,68 @@ +import type { HonoContext } from '../types/hono'; +import messages from './messages.json'; + +export class OAuthProtocolError extends Error { + constructor( + public oauthError: string, + message: string, + public status = 400 + ) { + super(message); + this.name = 'OAuthProtocolError'; + } +} + +export function oauthError(error: string, description: string, status = 400): never { + throw new OAuthProtocolError(error, description, status); +} + +function toOAuthError(error: unknown): OAuthProtocolError { + if (error instanceof OAuthProtocolError) { + return error; + } + + const message = error instanceof Error ? error.message : messages.requestFailed; + if ( + message.includes(messages.markers.oauthScopeInvalid) || + message.includes(messages.markers.scopeNotAllowed) + ) { + return new OAuthProtocolError(messages.errors.invalidScope, message, 400); + } + if (message.includes(messages.markers.redirectUri)) { + return new OAuthProtocolError(messages.errors.invalidRequest, message, 400); + } + if (message.includes(messages.markers.resource)) { + return new OAuthProtocolError(messages.errors.invalidTarget, message, 400); + } + if (message.includes(messages.markers.client)) { + return new OAuthProtocolError(messages.errors.invalidClient, message, 400); + } + if ( + message.includes(messages.markers.required) || + message.includes(messages.markers.missing) || + message.includes(messages.markers.invalid) + ) { + return new OAuthProtocolError(messages.errors.invalidRequest, message, 400); + } + + return new OAuthProtocolError(messages.errors.invalidRequest, message, 400); +} + +export async function withOAuthErrors(c: HonoContext, action: () => Promise | Response) { + try { + return await action(); + } catch (error: any) { + if (error?.name === 'DatabaseError') { + throw error; + } + + const protocolError = toOAuthError(error); + return c.json( + { + error: protocolError.oauthError, + error_description: protocolError.message, + }, + protocolError.status as any + ); + } +} diff --git a/server/oauth/flow.ts b/server/oauth/flow.ts new file mode 100644 index 00000000..9c69fc76 --- /dev/null +++ b/server/oauth/flow.ts @@ -0,0 +1,98 @@ +import { + createOAuthRefreshToken, + findOAuthAuthorizationRequest, + findOAuthClient, +} from '../utils/dbMethods'; +import type { HonoContext } from '../types/hono'; +import { generateMcpAccessToken } from './tokens'; +import messages from './messages.json'; +import { + DEFAULT_OAUTH_MCP_SCOPES, + OAUTH_MCP_SCOPES, + formatScope, + parseScope, + validateOAuthScopes, +} from './scopes'; +import { + MCP_ACCESS_TOKEN_TTL_SECONDS, + OAUTH_REFRESH_TOKEN_TTL_SECONDS, + expiresIn, + getOAuthIssuer, + randomOAuthToken, + sha256Hex, +} from './utils'; + +export function getFrontendAuthorizeUrl(c: HonoContext, requestId: string) { + const frontendUrl = (c.get('dynamicFrontendUrl') || 'http://localhost:3001').replace(/\/$/, ''); + return frontendUrl + '/oauth/authorize?requestId=' + encodeURIComponent(requestId); +} + +export function normalizeClientScopes(rawScope: unknown): string[] { + const scopes = parseScope(rawScope, [...OAUTH_MCP_SCOPES]); + return validateOAuthScopes(scopes.length > 0 ? scopes : [...OAUTH_MCP_SCOPES]); +} + +export function normalizeRequestedScopes(rawScope: unknown, clientScopes: string[]): string[] { + const scopes = parseScope(rawScope, DEFAULT_OAUTH_MCP_SCOPES); + const normalized = validateOAuthScopes(scopes.length > 0 ? scopes : DEFAULT_OAUTH_MCP_SCOPES); + const unsupported = normalized.filter((scope) => !clientScopes.includes(scope)); + if (unsupported.length > 0) { + throw new Error(messages.codes.scopeNotAllowedPrefix + unsupported.join(',')); + } + return normalized; +} + +export async function getPendingRequestWithClient(requestId: string) { + const request = await findOAuthAuthorizationRequest(requestId); + if (!request) { + throw new Error(messages.codes.requestIdRequired); + } + if (request.expiresAt.getTime() <= Date.now()) { + throw new Error(messages.markers.invalid); + } + if (request.status !== 'pending') { + throw new Error(messages.markers.invalid); + } + + const client = await findOAuthClient(request.clientId); + if (!client) { + throw new Error(messages.errors.invalidClient); + } + return { request, client }; +} + +export async function issueTokenPair(input: { + c: HonoContext; + userId: string; + clientId: string; + scopes: string[]; + resource: string; +}) { + const refreshToken = randomOAuthToken(48); + const refreshRecord = await createOAuthRefreshToken({ + tokenHash: sha256Hex(refreshToken), + clientId: input.clientId, + userId: input.userId, + scopes: input.scopes, + resource: input.resource, + expiresAt: expiresIn(OAUTH_REFRESH_TOKEN_TTL_SECONDS), + }); + + const accessToken = await generateMcpAccessToken({ + issuer: getOAuthIssuer(input.c), + resource: input.resource, + userId: input.userId, + clientId: input.clientId, + scopes: input.scopes, + }); + + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: MCP_ACCESS_TOKEN_TTL_SECONDS, + refresh_token: refreshToken, + scope: formatScope(input.scopes), + resource: input.resource, + refresh_token_id: refreshRecord.id, + }; +} diff --git a/server/oauth/messages.json b/server/oauth/messages.json new file mode 100644 index 00000000..290c5fe5 --- /dev/null +++ b/server/oauth/messages.json @@ -0,0 +1,54 @@ +{ + "requestFailed": "OAuth request failed", + "redirectUrisRequired": "redirect_uris is required", + "redirectUrisMustContainStrings": "redirect_uris must contain strings", + "defaultClientName": "Rote MCP Client", + "onlyCodeResponseType": "Only response_type=code is supported", + "missingAuthorizationParameter": "Missing OAuth authorization parameter", + "onlyPkceS256": "Only PKCE S256 is supported", + "clientNotFound": "OAuth client not found", + "redirectUriMismatch": "redirect_uri does not match registered OAuth client", + "invalidResource": "Invalid OAuth resource", + "missingCodeExchangeParameter": "code, redirect_uri, code_verifier and resource are required", + "clientIdRequired": "client_id is required", + "refreshTokenRequired": "refresh_token is required", + "tokenRequired": "token is required", + "errors": { + "accessDenied": "access_denied", + "authorizationRequired": "authorization_required", + "invalidClient": "invalid_client", + "invalidClientMetadata": "invalid_client_metadata", + "invalidGrant": "invalid_grant", + "invalidRequest": "invalid_request", + "invalidScope": "invalid_scope", + "invalidTarget": "invalid_target", + "invalidToken": "invalid_token", + "insufficientScope": "insufficient_scope", + "unsupportedGrantType": "unsupported_grant_type", + "unsupportedResponseType": "unsupported_response_type" + }, + "markers": { + "client": "client", + "invalid": "invalid", + "missing": "missing", + "oauthScopeInvalid": "oauth_scope_invalid", + "redirectUri": "redirect_uri", + "required": "required", + "resource": "resource", + "scopeNotAllowed": "scope_not_allowed" + }, + "codes": { + "decisionRequired": "oauth_decision_required", + "jwtNotConfigured": "jwt_not_configured", + "requestIdRequired": "request_id_required", + "redirectUriFragmentForbidden": "redirect_uri_fragment_forbidden", + "redirectUriInvalid": "redirect_uri_invalid", + "redirectUriSchemeForbidden": "redirect_uri_scheme_forbidden", + "scopeInvalidPrefix": "oauth_scope_invalid:", + "scopeNotAllowedPrefix": "scope_not_allowed:", + "tokenPayloadInvalid": "token_payload_invalid", + "tokenResourceInvalid": "token_resource_invalid", + "tokenTypeInvalid": "token_type_invalid" + }, + "revoked": "revoked" +} diff --git a/server/oauth/scopes.ts b/server/oauth/scopes.ts new file mode 100644 index 00000000..2d27b653 --- /dev/null +++ b/server/oauth/scopes.ts @@ -0,0 +1,62 @@ +import messages from './messages.json'; + +export const OAUTH_MCP_SCOPES = [ + 'notes:read', + 'notes:write', + 'notes:delete', + 'articles:read', + 'articles:write', + 'articles:delete', + 'reactions:write', + 'reactions:delete', + 'profile:read', + 'profile:write', + 'stats:read', + 'settings:read', + 'settings:write', + 'attachments:write', + 'attachments:delete', + 'video:upload', +] as const; + +export type OAuthMcpScope = (typeof OAUTH_MCP_SCOPES)[number]; + +export const DEFAULT_OAUTH_MCP_SCOPES: OAuthMcpScope[] = [ + 'notes:read', + 'notes:write', + 'articles:read', + 'profile:read', +]; + +const SCOPE_SET = new Set(OAUTH_MCP_SCOPES); + +export function parseScope(scope: unknown, fallback: string[] = []): string[] { + if (typeof scope !== 'string') { + return fallback; + } + + return Array.from( + new Set( + scope + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean) + ) + ); +} + +export function validateOAuthScopes(scopes: string[]): string[] { + const invalid = scopes.filter((scope) => !SCOPE_SET.has(scope)); + if (invalid.length > 0) { + throw new Error(messages.codes.scopeInvalidPrefix + invalid.join(',')); + } + return scopes; +} + +export function formatScope(scopes: string[]): string { + return Array.from(new Set(scopes)).join(' '); +} + +export function hasRequiredScopes(granted: string[], required: string[]): boolean { + return required.every((scope) => granted.includes(scope)); +} diff --git a/server/oauth/tokens.ts b/server/oauth/tokens.ts new file mode 100644 index 00000000..fb081461 --- /dev/null +++ b/server/oauth/tokens.ts @@ -0,0 +1,72 @@ +import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; +import type { SecurityConfig } from '../types/config'; +import { getGlobalConfig } from '../utils/config'; +import messages from './messages.json'; +import { MCP_ACCESS_TOKEN_TTL_SECONDS } from './utils'; + +export interface McpAccessTokenPayload extends JWTPayload { + typ: 'mcp_access'; + userId: string; + clientId: string; + scope: string; + resource: string; +} + +function getJwtSecret(): Uint8Array { + const config = getGlobalConfig('security'); + if (!config?.jwtSecret) { + throw new Error(messages.codes.jwtNotConfigured); + } + return new TextEncoder().encode(config.jwtSecret); +} + +export async function generateMcpAccessToken(input: { + issuer: string; + resource: string; + userId: string; + clientId: string; + scopes: string[]; +}): Promise { + return await new SignJWT({ + typ: 'mcp_access', + userId: input.userId, + clientId: input.clientId, + scope: input.scopes.join(' '), + resource: input.resource, + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setIssuer(input.issuer) + .setAudience(input.resource) + .setExpirationTime(`${MCP_ACCESS_TOKEN_TTL_SECONDS}s`) + .sign(getJwtSecret()); +} + +export async function verifyMcpAccessToken( + token: string, + issuer: string, + resource: string +): Promise { + const { payload } = await jwtVerify(token, getJwtSecret(), { + issuer, + audience: resource, + }); + + if (payload.typ !== 'mcp_access') { + throw new Error(messages.codes.tokenTypeInvalid); + } + + if (payload.resource !== resource) { + throw new Error(messages.codes.tokenResourceInvalid); + } + + if ( + typeof payload.userId !== 'string' || + typeof payload.clientId !== 'string' || + typeof payload.scope !== 'string' + ) { + throw new Error(messages.codes.tokenPayloadInvalid); + } + + return payload as McpAccessTokenPayload; +} diff --git a/server/oauth/utils.ts b/server/oauth/utils.ts new file mode 100644 index 00000000..e941f293 --- /dev/null +++ b/server/oauth/utils.ts @@ -0,0 +1,113 @@ +import { createHash, randomBytes } from 'crypto'; +import type { HonoContext } from '../types/hono'; +import { getApiUrl } from '../utils/main'; +import messages from './messages.json'; + +export const MCP_ACCESS_TOKEN_TTL_SECONDS = 15 * 60; +export const OAUTH_AUTHORIZATION_REQUEST_TTL_SECONDS = 10 * 60; +export const OAUTH_AUTHORIZATION_CODE_TTL_SECONDS = 5 * 60; +export const OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; + +export function randomOAuthToken(byteLength = 32): string { + return randomBytes(byteLength).toString('base64url'); +} + +export function sha256Base64Url(value: string): string { + return createHash('sha256').update(value).digest('base64url'); +} + +export function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function pkceS256(verifier: string): string { + return sha256Base64Url(verifier); +} + +export function getMcpResource(c: HonoContext): string { + return `${getApiUrl(c)}/v2/api/mcp`; +} + +export function getOAuthIssuer(c: HonoContext): string { + return getApiUrl(c); +} + +export function getOAuthAuthorizeEndpoint(c: HonoContext): string { + return `${getApiUrl(c)}/v2/api/oauth/authorize`; +} + +export function getOAuthTokenEndpoint(c: HonoContext): string { + return `${getApiUrl(c)}/v2/api/oauth/token`; +} + +export function getOAuthRegisterEndpoint(c: HonoContext): string { + return `${getApiUrl(c)}/v2/api/oauth/register`; +} + +export function getOAuthRevokeEndpoint(c: HonoContext): string { + return `${getApiUrl(c)}/v2/api/oauth/revoke`; +} + +export function expiresIn(seconds: number): Date { + return new Date(Date.now() + seconds * 1000); +} + +export function assertValidRedirectUri(value: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(messages.codes.redirectUriInvalid); + } + + if (parsed.hash) { + throw new Error(messages.codes.redirectUriFragmentForbidden); + } + + const hostname = parsed.hostname.toLowerCase(); + const isLocalhost = + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1'; + + if (parsed.protocol === 'https:') { + return; + } + + if (parsed.protocol === 'http:' && isLocalhost) { + return; + } + + throw new Error(messages.codes.redirectUriSchemeForbidden); +} + +export async function parseFormBody(c: HonoContext): Promise> { + const contentType = c.req.header('content-type') || ''; + if (contentType.includes('application/x-www-form-urlencoded')) { + const text = await c.req.text(); + return Object.fromEntries(new URLSearchParams(text)); + } + + const body = await c.req.json().catch(() => ({})); + const out: Record = {}; + Object.entries(body as Record).forEach(([key, value]) => { + if (typeof value === 'string') { + out[key] = value; + } + }); + return out; +} + +export function appendOAuthRedirectParams( + redirectUri: string, + params: Record +): string { + const url = new URL(redirectUri); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value.length > 0) { + url.searchParams.set(key, value); + } + }); + return url.toString(); +} diff --git a/server/route/oauthMetadata.ts b/server/route/oauthMetadata.ts new file mode 100644 index 00000000..61eaa43a --- /dev/null +++ b/server/route/oauthMetadata.ts @@ -0,0 +1,43 @@ +import { Hono } from 'hono'; +import serverInfo from '../mcp/serverInfo.json'; +import type { HonoContext, HonoVariables } from '../types/hono'; +import { OAUTH_MCP_SCOPES } from '../oauth/scopes'; +import { + getMcpResource, + getOAuthAuthorizeEndpoint, + getOAuthIssuer, + getOAuthRegisterEndpoint, + getOAuthRevokeEndpoint, + getOAuthTokenEndpoint, +} from '../oauth/utils'; + +const oauthMetadataRouter = new Hono<{ Variables: HonoVariables }>(); + +oauthMetadataRouter.get('/oauth-protected-resource', (c: HonoContext) => { + const resource = getMcpResource(c); + return c.json({ + resource, + authorization_servers: [getOAuthIssuer(c)], + scopes_supported: OAUTH_MCP_SCOPES, + bearer_methods_supported: ['header'], + resource_name: serverInfo.name, + }); +}); + +oauthMetadataRouter.get('/oauth-authorization-server', (c: HonoContext) => + c.json({ + issuer: getOAuthIssuer(c), + authorization_endpoint: getOAuthAuthorizeEndpoint(c), + token_endpoint: getOAuthTokenEndpoint(c), + registration_endpoint: getOAuthRegisterEndpoint(c), + revocation_endpoint: getOAuthRevokeEndpoint(c), + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: OAUTH_MCP_SCOPES, + resource_documentation: getMcpResource(c), + }) +); + +export default oauthMetadataRouter; diff --git a/server/route/v2/index.ts b/server/route/v2/index.ts index f59d2e19..668e4816 100644 --- a/server/route/v2/index.ts +++ b/server/route/v2/index.ts @@ -14,6 +14,8 @@ import changeRouter from './change'; import notesRouter from './note'; import notificationsRouter from './notification'; import oauthRouter from './oauth'; +import mcpRouter from './mcp'; +import mcpOAuthRouter from './mcpOAuth'; import openKeyRouter from './openKeyRouter'; import passkeyRouter from './passkey'; import permissionsRouter from './permissions'; @@ -127,6 +129,8 @@ router.route('/api-keys', apiKeysRouter); router.route('/attachments', attachmentsRouter); router.route('/site', siteRouter); router.route('/openkey', openKeyRouter); +router.route('/oauth', mcpOAuthRouter); +router.route('/mcp', mcpRouter); router.route('/admin', adminRouter); router.route('/admin/permissions', adminPermissionsRouter); router.route('/permissions', permissionsRouter); diff --git a/server/route/v2/mcp.ts b/server/route/v2/mcp.ts new file mode 100644 index 00000000..411de102 --- /dev/null +++ b/server/route/v2/mcp.ts @@ -0,0 +1,162 @@ +import { Hono } from 'hono'; +import { authenticateMcpOAuth } from '../../middleware/mcpOAuth'; +import type { SiteConfig } from '../../types/config'; +import type { HonoContext, HonoVariables } from '../../types/hono'; +import { getGlobalConfig } from '../../utils/config'; +import { getToolByName, getToolsForScopes, toMcpToolResult } from '../../mcp/tools'; +import mcpErrors from '../../mcp/errorCodes.json'; +import serverInfo from '../../mcp/serverInfo.json'; + +const mcpRouter = new Hono<{ Variables: HonoVariables }>(); +const SERVER_INFO = { name: serverInfo.name, version: serverInfo.version }; + +type JsonRpcRequest = { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: any; +}; + +function jsonRpcResult(id: JsonRpcRequest['id'], result: any) { + return { jsonrpc: '2.0', id: id ?? null, result }; +} + +function jsonRpcError(id: JsonRpcRequest['id'], code: number, message: string, data?: any) { + return { + jsonrpc: '2.0', + id: id ?? null, + error: data === undefined ? { code, message } : { code, message, data }, + }; +} + +function isLocalOrigin(origin: string): boolean { + try { + const url = new URL(origin); + return ( + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '[::1]' || + url.hostname === '::1' + ); + } catch { + return false; + } +} + +function validateOrigin(c: HonoContext): Response | null { + const origin = c.req.header('origin'); + if (!origin) { + return null; + } + + const siteConfig = getGlobalConfig('site'); + const allowed = new Set([ + ...(siteConfig?.allowedOrigins || []), + ...(siteConfig?.frontendUrl ? [siteConfig.frontendUrl] : []), + c.get('dynamicFrontendUrl') || '', + ]); + + if (allowed.has(origin) || isLocalOrigin(origin)) { + return null; + } + + return c.json({ error: mcpErrors.invalidOrigin }, 403); +} + +mcpRouter.get('/', authenticateMcpOAuth, (c: HonoContext) => { + const originError = validateOrigin(c); + if (originError) return originError; + return c.text(serverInfo.sseUnsupported, 405); +}); + +mcpRouter.delete('/', authenticateMcpOAuth, (c: HonoContext) => { + const originError = validateOrigin(c); + if (originError) return originError; + return c.text(serverInfo.statelessDeleteUnsupported, 405); +}); + +mcpRouter.post('/', authenticateMcpOAuth, async (c: HonoContext) => { + const originError = validateOrigin(c); + if (originError) return originError; + + const body = (await c.req.json().catch(() => null)) as JsonRpcRequest | null; + if (!body || body.jsonrpc !== '2.0' || typeof body.method !== 'string') { + return c.json(jsonRpcError(null, -32600, mcpErrors.invalidRequest), 400); + } + + if (body.id === undefined || body.id === null) { + if (body.method === 'notifications/initialized' || body.method.startsWith('notifications/')) { + return c.body(null, 202); + } + return c.body(null, 202); + } + + try { + switch (body.method) { + case 'initialize': { + return c.json( + jsonRpcResult(body.id, { + protocolVersion: '2025-06-18', + capabilities: { + tools: { + listChanged: false, + }, + }, + serverInfo: SERVER_INFO, + instructions: serverInfo.instructions, + }) + ); + } + case 'ping': + return c.json(jsonRpcResult(body.id, {})); + case 'tools/list': { + const auth = c.get('mcpAuth'); + const scopes = auth?.scopes || []; + return c.json( + jsonRpcResult(body.id, { + tools: getToolsForScopes(scopes).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }) + ); + } + case 'tools/call': { + const name = body.params?.name; + if (typeof name !== 'string') { + return c.json(jsonRpcError(body.id, -32602, mcpErrors.toolNameRequired), 400); + } + const tool = getToolByName(name); + if (!tool) { + return c.json(jsonRpcError(body.id, -32601, mcpErrors.unknownToolPrefix + name), 404); + } + + const auth = c.get('mcpAuth'); + const missing = tool.requiredScopes.filter((scope) => !auth?.scopes.includes(scope)); + if (missing.length > 0) { + c.header( + 'WWW-Authenticate', + `Bearer error="insufficient_scope", scope="${tool.requiredScopes.join(' ')}"` + ); + return c.json( + jsonRpcError(body.id, -32001, mcpErrors.insufficientScope, { missing }), + 403 + ); + } + + const result = await tool.handler(c, body.params?.arguments || {}); + return c.json(jsonRpcResult(body.id, toMcpToolResult(result))); + } + default: + return c.json( + jsonRpcError(body.id, -32601, mcpErrors.methodNotFoundPrefix + body.method), + 404 + ); + } + } catch (error: any) { + return c.json(jsonRpcError(body.id, -32000, error?.message || mcpErrors.mcpToolFailed), 500); + } +}); + +export default mcpRouter; diff --git a/server/route/v2/mcpOAuth.ts b/server/route/v2/mcpOAuth.ts new file mode 100644 index 00000000..768c63c0 --- /dev/null +++ b/server/route/v2/mcpOAuth.ts @@ -0,0 +1,299 @@ +import { randomUUID } from 'crypto'; +import { Hono } from 'hono'; +import mainJson from '../../json/main.json'; +import messages from '../../oauth/messages.json'; +import { + consumeOAuthAuthorizationCode, + createOAuthAuthorizationCode, + createOAuthAuthorizationRequest, + createOAuthClient, + findOAuthClient, + findOAuthRefreshToken, + revokeOAuthRefreshToken, + rotateOAuthRefreshToken, + updateOAuthAuthorizationRequestStatus, + upsertOAuthGrant, +} from '../../utils/dbMethods'; +import { createResponse } from '../../utils/main'; +import { authenticateJWT } from '../../middleware/jwtAuth'; +import { generateMcpAccessToken } from '../../oauth/tokens'; +import { formatScope } from '../../oauth/scopes'; +import { + getFrontendAuthorizeUrl, + getPendingRequestWithClient, + issueTokenPair, + normalizeClientScopes, + normalizeRequestedScopes, +} from '../../oauth/flow'; +import { oauthError, withOAuthErrors } from '../../oauth/errors'; +import { + appendOAuthRedirectParams, + assertValidRedirectUri, + expiresIn, + getMcpResource, + getOAuthIssuer, + MCP_ACCESS_TOKEN_TTL_SECONDS, + OAUTH_AUTHORIZATION_CODE_TTL_SECONDS, + OAUTH_AUTHORIZATION_REQUEST_TTL_SECONDS, + OAUTH_REFRESH_TOKEN_TTL_SECONDS, + parseFormBody, + pkceS256, + randomOAuthToken, + sha256Hex, +} from '../../oauth/utils'; +import type { HonoContext, HonoVariables, SafeUser } from '../../types/hono'; + +const oauthRouter = new Hono<{ Variables: HonoVariables }>(); + +oauthRouter.post('/register', (c: HonoContext) => + withOAuthErrors(c, async () => { + const body = await c.req.json().catch(() => ({})); + const redirectUris = (body as any).redirect_uris; + if (!Array.isArray(redirectUris) || redirectUris.length === 0) { + oauthError(messages.errors.invalidClientMetadata, messages.redirectUrisRequired); + } + + const normalizedRedirectUris = redirectUris.map((uri) => { + if (typeof uri !== 'string') { + oauthError(messages.errors.invalidClientMetadata, messages.redirectUrisMustContainStrings); + } + assertValidRedirectUri(uri); + return uri; + }); + + const clientName = + typeof (body as any).client_name === 'string' && (body as any).client_name.trim().length > 0 + ? (body as any).client_name.trim() + : messages.defaultClientName; + const scopes = normalizeClientScopes((body as any).scope); + const client = await createOAuthClient({ + clientId: 'rote_mcp_' + randomUUID(), + clientName, + redirectUris: normalizedRedirectUris, + scopes, + clientUri: typeof (body as any).client_uri === 'string' ? (body as any).client_uri : null, + logoUri: typeof (body as any).logo_uri === 'string' ? (body as any).logo_uri : null, + }); + + return c.json( + { + client_id: client.clientId, + client_id_issued_at: Math.floor(client.createdAt.getTime() / 1000), + client_name: client.clientName, + client_uri: client.clientUri, + logo_uri: client.logoUri, + redirect_uris: client.redirectUris, + grant_types: client.grantTypes, + response_types: client.responseTypes, + token_endpoint_auth_method: 'none', + scope: formatScope(client.scopes), + }, + 201 + ); + }) +); + +oauthRouter.get('/authorize', (c: HonoContext) => + withOAuthErrors(c, async () => { + const responseType = c.req.query('response_type'); + const clientId = c.req.query('client_id'); + const redirectUri = c.req.query('redirect_uri'); + const resource = c.req.query('resource'); + const codeChallenge = c.req.query('code_challenge'); + const codeChallengeMethod = c.req.query('code_challenge_method'); + if (responseType !== 'code') + oauthError(messages.errors.unsupportedResponseType, messages.onlyCodeResponseType); + if (!clientId || !redirectUri || !resource || !codeChallenge || !codeChallengeMethod) + oauthError(messages.errors.invalidRequest, messages.missingAuthorizationParameter); + if (codeChallengeMethod !== 'S256') + oauthError(messages.errors.invalidRequest, messages.onlyPkceS256); + + const client = await findOAuthClient(clientId); + if (!client) oauthError(messages.errors.invalidClient, messages.clientNotFound); + if (!client.redirectUris.includes(redirectUri)) + oauthError(messages.errors.invalidRequest, messages.redirectUriMismatch); + if (resource !== getMcpResource(c)) + oauthError(messages.errors.invalidTarget, messages.invalidResource); + + const request = await createOAuthAuthorizationRequest({ + clientId, + redirectUri, + scopes: normalizeRequestedScopes(c.req.query('scope'), client.scopes), + state: c.req.query('state') || null, + resource, + codeChallenge, + codeChallengeMethod, + expiresAt: expiresIn(OAUTH_AUTHORIZATION_REQUEST_TTL_SECONDS), + }); + return c.redirect(getFrontendAuthorizeUrl(c, request.id), 302); + }) +); + +oauthRouter.get('/authorize/session', authenticateJWT, async (c: HonoContext) => { + const requestId = c.req.query('requestId'); + if (!requestId) throw new Error(messages.codes.requestIdRequired); + const { request, client } = await getPendingRequestWithClient(requestId); + return c.json( + createResponse({ + requestId: request.id, + client: { + clientId: client.clientId, + clientName: client.clientName, + clientUri: client.clientUri, + logoUri: client.logoUri, + }, + scopes: request.scopes, + resource: request.resource, + redirectUri: request.redirectUri, + expiresAt: request.expiresAt, + safeRoutes: mainJson.safeRoutes, + }) + ); +}); + +oauthRouter.post('/authorize/approve', authenticateJWT, async (c: HonoContext) => { + const user = c.get('user') as SafeUser; + const body = await c.req.json().catch(() => ({})); + const requestId = (body as any).requestId; + const decision = (body as any).decision; + if (!requestId || (decision !== 'approve' && decision !== 'deny')) + throw new Error(messages.codes.decisionRequired); + + const { request } = await getPendingRequestWithClient(requestId); + if (decision === 'deny') { + await updateOAuthAuthorizationRequestStatus(request.id, 'denied', user.id); + return c.json( + createResponse({ + redirectUrl: appendOAuthRedirectParams(request.redirectUri, { + error: messages.errors.accessDenied, + state: request.state || undefined, + }), + }) + ); + } + + await updateOAuthAuthorizationRequestStatus(request.id, 'approved', user.id); + await upsertOAuthGrant({ + userId: user.id, + clientId: request.clientId, + scopes: request.scopes, + resource: request.resource, + }); + const code = randomOAuthToken(32); + await createOAuthAuthorizationCode({ + codeHash: sha256Hex(code), + requestId: request.id, + clientId: request.clientId, + userId: user.id, + redirectUri: request.redirectUri, + scopes: request.scopes, + resource: request.resource, + codeChallenge: request.codeChallenge, + codeChallengeMethod: request.codeChallengeMethod, + expiresAt: expiresIn(OAUTH_AUTHORIZATION_CODE_TTL_SECONDS), + }); + return c.json( + createResponse({ + redirectUrl: appendOAuthRedirectParams(request.redirectUri, { + code, + state: request.state || undefined, + }), + }) + ); +}); + +oauthRouter.post('/token', (c: HonoContext) => + withOAuthErrors(c, async () => { + const body = await parseFormBody(c); + const clientId = body.client_id; + if (!clientId) oauthError(messages.errors.invalidRequest, messages.clientIdRequired); + const client = await findOAuthClient(clientId); + if (!client) oauthError(messages.errors.invalidClient, messages.clientNotFound); + + if (body.grant_type === 'authorization_code') { + const tokenResponse = await exchangeAuthorizationCode(c, body, clientId); + if ('error' in tokenResponse) return c.json(tokenResponse, 400); + return c.json(tokenResponse, 200); + } + if (body.grant_type === 'refresh_token') { + const tokenResponse = await exchangeRefreshToken(c, body, clientId); + if ('error' in tokenResponse) return c.json(tokenResponse, 400); + return c.json(tokenResponse, 200); + } + return c.json({ error: messages.errors.unsupportedGrantType }, 400); + }) +); + +async function exchangeAuthorizationCode( + c: HonoContext, + body: Record, + clientId: string +) { + const { code, redirect_uri: redirectUri, code_verifier: codeVerifier, resource } = body; + if (!code || !redirectUri || !codeVerifier || !resource) + oauthError(messages.errors.invalidRequest, messages.missingCodeExchangeParameter); + const record = await consumeOAuthAuthorizationCode(sha256Hex(code)); + if (!record) return { error: messages.errors.invalidGrant }; + const valid = + record.clientId === clientId && + record.redirectUri === redirectUri && + record.resource === resource && + resource === getMcpResource(c) && + record.codeChallengeMethod === 'S256' && + pkceS256(codeVerifier) === record.codeChallenge; + if (!valid) return { error: messages.errors.invalidGrant }; + const tokenResponse = await issueTokenPair({ + c, + userId: record.userid, + clientId, + scopes: record.scopes, + resource: record.resource, + }); + const { refresh_token_id: _refreshTokenId, ...publicResponse } = tokenResponse; + return publicResponse; +} + +async function exchangeRefreshToken( + c: HonoContext, + body: Record, + clientId: string +) { + const refreshToken = body.refresh_token; + const resource = body.resource || getMcpResource(c); + if (!refreshToken) oauthError(messages.errors.invalidRequest, messages.refreshTokenRequired); + const newRefreshToken = randomOAuthToken(48); + const rotation = await rotateOAuthRefreshToken({ + tokenHash: sha256Hex(refreshToken), + clientId, + resource, + newTokenHash: sha256Hex(newRefreshToken), + expiresAt: expiresIn(OAUTH_REFRESH_TOKEN_TTL_SECONDS), + }); + if (!rotation) return { error: messages.errors.invalidGrant }; + const accessToken = await generateMcpAccessToken({ + issuer: getOAuthIssuer(c), + resource: rotation.newToken.resource, + userId: rotation.newToken.userid, + clientId, + scopes: rotation.newToken.scopes, + }); + return { + access_token: accessToken, + token_type: 'Bearer', + expires_in: MCP_ACCESS_TOKEN_TTL_SECONDS, + refresh_token: newRefreshToken, + scope: formatScope(rotation.newToken.scopes), + resource: rotation.newToken.resource, + }; +} +oauthRouter.post('/revoke', (c: HonoContext) => + withOAuthErrors(c, async () => { + const body = await parseFormBody(c); + if (!body.token) oauthError(messages.errors.invalidRequest, messages.tokenRequired); + const token = await findOAuthRefreshToken(sha256Hex(body.token)); + if (token && !token.revokedAt) await revokeOAuthRefreshToken(token.id); + return c.json(createResponse(null, messages.revoked), 200); + }) +); + +export default oauthRouter; diff --git a/server/scripts/runMigrations.ts b/server/scripts/runMigrations.ts index 1d525bfd..082fef59 100644 --- a/server/scripts/runMigrations.ts +++ b/server/scripts/runMigrations.ts @@ -5,7 +5,10 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import { migrate } from 'drizzle-orm/postgres-js/migrator'; import postgres from 'postgres'; -import * as schema from '../drizzle/schema'; +import * as oauthMcpSchema from '../drizzle/oauthMcpSchema'; +import * as baseSchema from '../drizzle/schema'; + +const schema = { ...baseSchema, ...oauthMcpSchema }; const connectionString = process.env.POSTGRESQL_URL || ''; diff --git a/server/server.ts b/server/server.ts index 16b18a42..0090d7bc 100644 --- a/server/server.ts +++ b/server/server.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { rateLimiterMiddleware } from './middleware/limiter'; import { recorderIpAndTime } from './middleware/recorder'; +import oauthMetadataRouter from './route/oauthMetadata'; import routerV2 from './route/v2'; // RESTful API routes import type { SiteConfig } from './types/config'; import { HonoVariables } from './types/hono'; @@ -81,6 +82,7 @@ app.use( ); // RESTful API routes +app.route('/.well-known', oauthMetadataRouter); app.route('/v2/api', routerV2); // 404 handler diff --git a/server/tests/oauth-mcp.test.ts b/server/tests/oauth-mcp.test.ts new file mode 100644 index 00000000..6115fba9 --- /dev/null +++ b/server/tests/oauth-mcp.test.ts @@ -0,0 +1,50 @@ +import { + ALL_SCOPES, + API_BASE, + ensureInitialized, + loginOrRegister, + randomToken, + registerClient, + sha256Base64Url, +} from './oauthMcp/common.test'; +import { authorizeAndExchange } from './oauthMcp/flow.test'; +import { + testAuthorizeValidation, + testDenyFlow, + testMetadata, +} from './oauthMcp/metadataAuthorization.test'; +import { testInsufficientScope, testMcpTools, testProtocol } from './oauthMcp/protocolTools.test'; +import { + testPkceReuseAndAudience, + testRefreshAndRevoke, + testSingleUseTokenConcurrency, +} from './oauthMcp/tokenCases.test'; + +async function main() { + console.log('Running OAuth MCP tests against ' + API_BASE); + await ensureInitialized(); + const appAccessToken = await loginOrRegister(); + await testMetadata(); + const client = await registerClient(); + const codeChallenge = await sha256Base64Url(randomToken(48)); + await testAuthorizeValidation(client.client_id, codeChallenge); + await testDenyFlow(appAccessToken, client.client_id, codeChallenge); + + const token = await authorizeAndExchange({ + appAccessToken, + clientId: client.client_id, + scopes: ALL_SCOPES, + }); + await testPkceReuseAndAudience(appAccessToken, client.client_id); + await testSingleUseTokenConcurrency(appAccessToken, client.client_id); + await testProtocol(token.accessToken); + await testMcpTools(token.accessToken); + await testInsufficientScope(appAccessToken); + await testRefreshAndRevoke(client.client_id, token.refreshToken); + console.log('OAuth MCP tests passed'); +} + +main().catch((error) => { + console.error('OAuth MCP tests failed:', error); + process.exit(1); +}); diff --git a/server/tests/oauthMcp/common.test.ts b/server/tests/oauthMcp/common.test.ts new file mode 100644 index 00000000..c229999d --- /dev/null +++ b/server/tests/oauthMcp/common.test.ts @@ -0,0 +1,149 @@ +import { randomBytes } from 'crypto'; +import testConfig from '../../scripts/testConfig.json'; + +export const BASE_URL = process.env.TEST_BASE_URL || testConfig.testSettings.baseUrl; +export const API_BASE = BASE_URL + testConfig.testSettings.apiBase + '/api'; +export const MCP_RESOURCE = API_BASE + '/mcp'; +export const REDIRECT_URI = 'http://localhost:8765/oauth/callback'; +export const ALL_SCOPES = [ + 'notes:read', + 'notes:write', + 'notes:delete', + 'articles:read', + 'articles:write', + 'articles:delete', + 'reactions:write', + 'reactions:delete', + 'profile:read', + 'profile:write', + 'stats:read', + 'settings:read', + 'settings:write', + 'attachments:write', + 'attachments:delete', + 'video:upload', +]; + +export type Json = Record; + +export function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +export function assertStatus(status: number, expected: number, label: string) { + assert(status === expected, label + ': expected ' + expected + ', got ' + status); +} + +function base64Url(input: ArrayBuffer | Uint8Array): string { + return Buffer.from(input).toString('base64url'); +} + +export async function sha256Base64Url(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return base64Url(digest); +} + +export function randomToken(bytes = 32): string { + return randomBytes(bytes).toString('base64url'); +} + +export async function request( + method: string, + path: string, + options: { + body?: any; + headers?: Record; + redirect?: RequestRedirect; + form?: URLSearchParams; + } = {} +): Promise<{ status: number; headers: Headers; data: any }> { + const headers: Record = { ...(options.headers || {}) }; + let body: BodyInit | undefined; + if (options.form) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + body = options.form.toString(); + } else if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); + } + + const response = await fetch(API_BASE + path, { + method, + headers, + body, + redirect: options.redirect || 'follow', + }); + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') + ? await response.json() + : await response.text(); + return { status: response.status, headers: response.headers, data }; +} + +export async function publicRequest(path: string) { + const response = await fetch(BASE_URL + path); + const contentType = response.headers.get('content-type') || ''; + const data = contentType.includes('application/json') + ? await response.json() + : await response.text(); + return { status: response.status, data }; +} + +export async function ensureInitialized() { + const status = await request('GET', '/admin/status'); + assertStatus(status.status, 200, 'admin status'); + if (status.data.data?.isInitialized) return; + + const setup = await request('POST', '/admin/setup', { + body: { + site: { + ...testConfig.testData.site, + frontendUrl: 'http://localhost:3001', + allowedOrigins: ['http://localhost:3001'], + }, + ui: testConfig.testData.ui, + admin: testConfig.testData.admin, + }, + }); + assert( + setup.status === 200 || setup.status === 400, + 'admin setup should initialize or report already initialized, got ' + setup.status + ); +} + +export async function loginOrRegister(): Promise { + const login = await request('POST', '/auth/login', { + body: { + username: testConfig.testData.admin.username, + password: testConfig.testData.admin.password, + }, + }); + if (login.status === 200 && login.data.data?.accessToken) return login.data.data.accessToken; + + const suffix = Date.now().toString(36); + const register = await request('POST', '/auth/register', { + body: { + username: ('mcpuser' + suffix).slice(0, 20), + password: testConfig.testData.admin.password, + email: 'mcp-' + suffix + '@test.com', + nickname: 'MCP Test User', + }, + }); + assertStatus(register.status, 201, 'fallback user registration'); + assert(register.data.data?.accessToken, 'registration did not return accessToken'); + return register.data.data.accessToken; +} + +export async function registerClient(scopes = ALL_SCOPES): Promise<{ client_id: string }> { + const response = await request('POST', '/oauth/register', { + body: { + client_name: 'Rote MCP OAuth Test', + redirect_uris: [REDIRECT_URI], + scope: scopes.join(' '), + }, + }); + assertStatus(response.status, 201, 'dynamic client registration'); + assert(response.data.client_id, 'registered client_id is missing'); + assert(response.data.token_endpoint_auth_method === 'none', 'public client auth method mismatch'); + return response.data; +} diff --git a/server/tests/oauthMcp/flow.test.ts b/server/tests/oauthMcp/flow.test.ts new file mode 100644 index 00000000..5770b89c --- /dev/null +++ b/server/tests/oauthMcp/flow.test.ts @@ -0,0 +1,164 @@ +import { + MCP_RESOURCE, + REDIRECT_URI, + assert, + assertStatus, + randomToken, + request, + sha256Base64Url, + type Json, +} from './common.test'; + +export async function startAuthorization(input: { + clientId: string; + scopes: string[]; + codeChallenge: string; + state?: string; + resource?: string; + redirectUri?: string; +}) { + const params = new URLSearchParams({ + response_type: 'code', + client_id: input.clientId, + redirect_uri: input.redirectUri || REDIRECT_URI, + scope: input.scopes.join(' '), + resource: input.resource || MCP_RESOURCE, + code_challenge: input.codeChallenge, + code_challenge_method: 'S256', + }); + if (input.state) params.set('state', input.state); + return await request('GET', '/oauth/authorize?' + params, { redirect: 'manual' }); +} + +export function extractRequestId(location: string): string { + const url = new URL(location); + const requestId = url.searchParams.get('requestId'); + assert(requestId, 'authorization redirect missing requestId: ' + location); + return requestId; +} + +export async function authorizeAndExchange(input: { + appAccessToken: string; + clientId: string; + scopes: string[]; + resource?: string; +}) { + const verifier = randomToken(48); + const codeChallenge = await sha256Base64Url(verifier); + const state = randomToken(12); + const authorize = await startAuthorization({ + clientId: input.clientId, + scopes: input.scopes, + codeChallenge, + state, + resource: input.resource, + }); + assertStatus(authorize.status, 302, 'authorization redirect'); + const requestId = extractRequestId(authorize.headers.get('location') || ''); + + const session = await request('GET', '/oauth/authorize/session?requestId=' + requestId, { + headers: { Authorization: 'Bearer ' + input.appAccessToken }, + }); + assertStatus(session.status, 200, 'authorization session'); + assert(session.data.data?.client?.clientId === input.clientId, 'session client mismatch'); + assert(Array.isArray(session.data.data?.scopes), 'session scopes missing'); + + const approve = await request('POST', '/oauth/authorize/approve', { + headers: { Authorization: 'Bearer ' + input.appAccessToken }, + body: { requestId, decision: 'approve' }, + }); + assertStatus(approve.status, 200, 'authorization approval'); + const redirectUrl = approve.data.data?.redirectUrl; + assert(redirectUrl, 'approval redirectUrl missing'); + const redirect = new URL(redirectUrl); + assert(redirect.searchParams.get('state') === state, 'approval state mismatch'); + const code = redirect.searchParams.get('code'); + assert(code, 'authorization code missing'); + + const token = await request('POST', '/oauth/token', { + form: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: input.clientId, + redirect_uri: REDIRECT_URI, + code, + code_verifier: verifier, + resource: input.resource || MCP_RESOURCE, + }), + }); + assertStatus(token.status, 200, 'authorization code exchange'); + assert(token.data.access_token, 'access token missing'); + assert(token.data.refresh_token, 'refresh token missing'); + assert(token.data.token_type === 'Bearer', 'token type mismatch'); + return { + accessToken: token.data.access_token as string, + refreshToken: token.data.refresh_token as string, + code, + verifier, + }; +} + +export async function createApprovedCode(input: { + appAccessToken: string; + clientId: string; + scopes: string[]; + resource?: string; +}) { + const verifier = randomToken(48); + const codeChallenge = await sha256Base64Url(verifier); + const state = randomToken(12); + const authorize = await startAuthorization({ + clientId: input.clientId, + scopes: input.scopes, + codeChallenge, + state, + resource: input.resource, + }); + assertStatus(authorize.status, 302, 'approved code authorization redirect'); + const requestId = extractRequestId(authorize.headers.get('location') || ''); + const approve = await request('POST', '/oauth/authorize/approve', { + headers: { Authorization: 'Bearer ' + input.appAccessToken }, + body: { requestId, decision: 'approve' }, + }); + assertStatus(approve.status, 200, 'approved code authorization approval'); + const redirect = new URL(approve.data.data.redirectUrl); + assert(redirect.searchParams.get('state') === state, 'approved code state mismatch'); + const code = redirect.searchParams.get('code'); + assert(code, 'approved authorization code missing'); + return { code, verifier }; +} + +export async function mcp( + accessToken: string, + method: string, + params?: Json, + id: string | number | null = randomToken(4) +) { + return await request('POST', '/mcp', { + headers: { Authorization: 'Bearer ' + accessToken }, + body: { jsonrpc: '2.0', id, method, params }, + }); +} + +export async function callTool( + accessToken: string, + name: string, + args: Json = {}, + expectOk = true +) { + const response = await mcp(accessToken, 'tools/call', { name, arguments: args }); + if (expectOk) { + assertStatus(response.status, 200, 'MCP tool ' + name); + assert( + !response.data.error, + 'MCP tool ' + name + ' returned error: ' + JSON.stringify(response.data.error) + ); + const text = response.data.result?.content?.[0]?.text; + assert(typeof text === 'string', 'MCP tool ' + name + ' missing text content'); + return JSON.parse(text).data; + } + assert( + response.status >= 400 || response.data.error || response.data.result, + 'MCP tool ' + name + ' should return a result or a clear JSON-RPC error' + ); + return response.data; +} diff --git a/server/tests/oauthMcp/metadataAuthorization.test.ts b/server/tests/oauthMcp/metadataAuthorization.test.ts new file mode 100644 index 00000000..a9269051 --- /dev/null +++ b/server/tests/oauthMcp/metadataAuthorization.test.ts @@ -0,0 +1,92 @@ +import { + API_BASE, + MCP_RESOURCE, + REDIRECT_URI, + assert, + assertStatus, + publicRequest, + request, +} from './common.test'; +import { extractRequestId, startAuthorization } from './flow.test'; + +export async function testMetadata() { + const protectedResource = await publicRequest('/.well-known/oauth-protected-resource'); + assertStatus(protectedResource.status, 200, 'protected resource metadata'); + assert(protectedResource.data.resource === MCP_RESOURCE, 'protected resource mismatch'); + const authServer = await publicRequest('/.well-known/oauth-authorization-server'); + assertStatus(authServer.status, 200, 'authorization server metadata'); + assert( + authServer.data.authorization_endpoint?.endsWith('/v2/api/oauth/authorize'), + 'missing auth endpoint' + ); + assert(authServer.data.token_endpoint?.endsWith('/v2/api/oauth/token'), 'missing token endpoint'); +} + +export async function testAuthorizeValidation(clientId: string, codeChallenge: string) { + const badResponseTypeParams = new URLSearchParams({ + response_type: 'token', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope: 'notes:read', + resource: MCP_RESOURCE, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }); + const badResponseType = await request('GET', '/oauth/authorize?' + badResponseTypeParams, { + redirect: 'manual', + }); + assertStatus(badResponseType.status, 400, 'unsupported response_type'); + assert( + badResponseType.data.error === 'unsupported_response_type', + 'unsupported response_type should return OAuth error' + ); + + const badRedirect = await startAuthorization({ + clientId, + scopes: ['notes:read'], + codeChallenge, + redirectUri: 'http://localhost:9999/wrong', + }); + assertStatus(badRedirect.status, 400, 'bad redirect_uri should be rejected'); + assert(badRedirect.data.error === 'invalid_request', 'bad redirect should return OAuth error'); + + const badScope = await startAuthorization({ + clientId, + scopes: ['notes:read', 'unknown:scope'], + codeChallenge, + }); + assertStatus(badScope.status, 400, 'bad scope should be rejected'); + assert(badScope.data.error === 'invalid_scope', 'bad scope should return OAuth error'); + + const badResource = await startAuthorization({ + clientId, + scopes: ['notes:read'], + codeChallenge, + resource: API_BASE + '/not-mcp', + }); + assertStatus(badResource.status, 400, 'bad resource should be rejected'); + assert(badResource.data.error === 'invalid_target', 'bad resource should return OAuth error'); +} + +export async function testDenyFlow( + appAccessToken: string, + clientId: string, + codeChallenge: string +) { + const authorize = await startAuthorization({ + clientId, + scopes: ['notes:read'], + codeChallenge, + state: 'deny-state', + }); + assertStatus(authorize.status, 302, 'deny flow authorization redirect'); + const requestId = extractRequestId(authorize.headers.get('location') || ''); + const deny = await request('POST', '/oauth/authorize/approve', { + headers: { Authorization: 'Bearer ' + appAccessToken }, + body: { requestId, decision: 'deny' }, + }); + assertStatus(deny.status, 200, 'authorization denial'); + const redirectUrl = new URL(deny.data.data.redirectUrl); + assert(redirectUrl.searchParams.get('error') === 'access_denied', 'deny redirect error mismatch'); + assert(redirectUrl.searchParams.get('state') === 'deny-state', 'deny redirect state mismatch'); +} diff --git a/server/tests/oauthMcp/protocolTools.test.ts b/server/tests/oauthMcp/protocolTools.test.ts new file mode 100644 index 00000000..e3183891 --- /dev/null +++ b/server/tests/oauthMcp/protocolTools.test.ts @@ -0,0 +1,140 @@ +import { assert, assertStatus, registerClient, request, type Json } from './common.test'; +import { authorizeAndExchange, callTool, mcp } from './flow.test'; + +export async function testProtocol(accessToken: string) { + const noToken = await request('POST', '/mcp', { + body: { jsonrpc: '2.0', id: 1, method: 'initialize' }, + }); + assertStatus(noToken.status, 401, 'MCP no token'); + const getWithoutSse = await request('GET', '/mcp', { + headers: { Authorization: 'Bearer ' + accessToken }, + }); + assertStatus(getWithoutSse.status, 405, 'MCP GET no SSE'); + const notification = await request('POST', '/mcp', { + headers: { Authorization: 'Bearer ' + accessToken }, + body: { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }, + }); + assertStatus(notification.status, 202, 'MCP notification'); + const initialize = await mcp(accessToken, 'initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'oauth-mcp-test', version: '1.0.0' }, + }); + assertStatus(initialize.status, 200, 'MCP initialize'); + assert(initialize.data.result?.protocolVersion === '2025-06-18', 'initialize protocol mismatch'); + assertStatus((await mcp(accessToken, 'ping')).status, 200, 'MCP ping'); + const tools = await mcp(accessToken, 'tools/list'); + assertStatus(tools.status, 200, 'MCP tools/list'); + const names = tools.data.result?.tools?.map((tool: Json) => tool.name) || []; + for (const expected of [ + 'notes_create', + 'articles_create', + 'reactions_add', + 'profile_get', + 'statistics_get', + 'attachments_presign_upload', + ]) { + assert(names.includes(expected), 'tools/list missing ' + expected); + } +} + +export async function testMcpTools(accessToken: string) { + const article = await callTool(accessToken, 'articles_create', { + content: 'OAuth MCP test article', + }); + assert(article.id, 'article id missing'); + const note = await callTool(accessToken, 'notes_create', { + title: 'OAuth MCP test note', + content: 'OAuth MCP test note content', + state: 'private', + tags: ['oauth-mcp'], + articleId: article.id, + }); + assert(note.id, 'note id missing'); + await callTool(accessToken, 'notes_list', { limit: 10 }); + await callTool(accessToken, 'notes_search', { keyword: 'OAuth MCP', limit: 10 }); + await callTool(accessToken, 'notes_get', { id: note.id }); + await callTool(accessToken, 'notes_batch_get', { ids: [note.id] }); + await callTool(accessToken, 'notes_update', { + id: note.id, + title: 'OAuth MCP test note updated', + content: 'OAuth MCP test note content updated', + tags: ['oauth-mcp', 'updated'], + articleId: article.id, + }); + await callTool(accessToken, 'articles_list', { limit: 10 }); + await callTool(accessToken, 'articles_get', { id: article.id }); + await callTool(accessToken, 'articles_get_by_note', { noteId: note.id }); + await callTool(accessToken, 'articles_update', { + id: article.id, + content: 'OAuth MCP test article updated', + }); + await callTool(accessToken, 'reactions_add', { + type: 'like', + roteid: note.id, + metadata: { source: 'oauth-mcp-test' }, + }); + await callTool(accessToken, 'reactions_remove', { type: 'like', roteid: note.id }); + const profile = await callTool(accessToken, 'profile_get'); + await callTool(accessToken, 'profile_update', { + nickname: profile.nickname || 'MCP Test User', + description: profile.description || 'MCP OAuth smoke test user', + }); + await callTool(accessToken, 'permissions_get'); + await callTool(accessToken, 'tags_get'); + await callTool(accessToken, 'heatmap_get', { startDate: '2026-01-01', endDate: '2026-12-31' }); + await callTool(accessToken, 'statistics_get'); + const settings = await callTool(accessToken, 'settings_get'); + await callTool(accessToken, 'settings_update', { allowExplore: settings.allowExplore }); + await callTool( + accessToken, + 'attachments_presign_upload', + { files: [{ filename: 'mcp-test.jpg', contentType: 'image/jpeg', size: 1024 }] }, + false + ); + await callTool( + accessToken, + 'attachments_finalize_upload', + { + noteId: note.id, + attachments: [ + { + uuid: crypto.randomUUID(), + originalKey: 'users/not-the-current-user/uploads/mcp-test.jpg', + mimetype: 'image/jpeg', + size: 1024, + }, + ], + }, + false + ); + await callTool( + accessToken, + 'attachments_sort', + { noteId: note.id, attachmentIds: [crypto.randomUUID()] }, + false + ); + await callTool(accessToken, 'attachments_delete_one', { id: crypto.randomUUID() }, false); + await callTool(accessToken, 'attachments_delete_many', { ids: [crypto.randomUUID()] }, false); + await callTool(accessToken, 'notes_delete', { id: note.id }); + await callTool(accessToken, 'articles_delete', { id: article.id }); +} + +export async function testInsufficientScope(appAccessToken: string) { + const client = await registerClient(['notes:read']); + const token = await authorizeAndExchange({ + appAccessToken, + clientId: client.client_id, + scopes: ['notes:read'], + }); + const tools = await mcp(token.accessToken, 'tools/list'); + assertStatus(tools.status, 200, 'limited scope tools/list'); + const names = tools.data.result?.tools?.map((tool: Json) => tool.name) || []; + assert(names.includes('notes_list'), 'limited scope should include notes_list'); + assert(!names.includes('notes_create'), 'limited scope must not include notes_create'); + const denied = await mcp(token.accessToken, 'tools/call', { + name: 'notes_create', + arguments: { content: 'should not be created' }, + }); + assertStatus(denied.status, 403, 'insufficient scope tool call'); +} diff --git a/server/tests/oauthMcp/tokenCases.test.ts b/server/tests/oauthMcp/tokenCases.test.ts new file mode 100644 index 00000000..2dc82ee3 --- /dev/null +++ b/server/tests/oauthMcp/tokenCases.test.ts @@ -0,0 +1,160 @@ +import { + API_BASE, + MCP_RESOURCE, + REDIRECT_URI, + assert, + assertStatus, + registerClient, + request, + randomToken, + sha256Base64Url, +} from './common.test'; +import { + authorizeAndExchange, + createApprovedCode, + extractRequestId, + startAuthorization, +} from './flow.test'; + +export async function testRefreshAndRevoke(clientId: string, refreshToken: string) { + const refresh = await request('POST', '/oauth/token', { + form: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + resource: MCP_RESOURCE, + }), + }); + assertStatus(refresh.status, 200, 'refresh token exchange'); + assert(refresh.data.access_token, 'refreshed access token missing'); + assert(refresh.data.refresh_token, 'rotated refresh token missing'); + assert(refresh.data.refresh_token !== refreshToken, 'refresh token was not rotated'); + + const reuseOld = await request('POST', '/oauth/token', { + form: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + resource: MCP_RESOURCE, + }), + }); + assertStatus(reuseOld.status, 400, 'old refresh token reuse'); + const revoke = await request('POST', '/oauth/revoke', { + form: new URLSearchParams({ token: refresh.data.refresh_token, client_id: clientId }), + }); + assertStatus(revoke.status, 200, 'refresh token revoke'); + const revokedUse = await request('POST', '/oauth/token', { + form: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refresh.data.refresh_token, + resource: MCP_RESOURCE, + }), + }); + assertStatus(revokedUse.status, 400, 'revoked refresh token use'); +} + +export async function testSingleUseTokenConcurrency(appAccessToken: string, clientId: string) { + const { code, verifier } = await createApprovedCode({ + appAccessToken, + clientId, + scopes: ['notes:read'], + }); + const exchangeForm = () => + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code, + code_verifier: verifier, + resource: MCP_RESOURCE, + }); + const codeResults = await Promise.all([ + request('POST', '/oauth/token', { form: exchangeForm() }), + request('POST', '/oauth/token', { form: exchangeForm() }), + ]); + const codeSuccesses = codeResults.filter((result) => result.status === 200); + const codeFailures = codeResults.filter( + (result) => result.status === 400 && result.data.error === 'invalid_grant' + ); + assert(codeSuccesses.length === 1, 'authorization code concurrent exchange must succeed once'); + assert( + codeFailures.length === 1, + 'authorization code concurrent exchange must reject reuse once' + ); + + const refreshToken = codeSuccesses[0].data.refresh_token; + assert(refreshToken, 'concurrent exchange refresh token missing'); + const refreshForm = () => + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + resource: MCP_RESOURCE, + }); + const refreshResults = await Promise.all([ + request('POST', '/oauth/token', { form: refreshForm() }), + request('POST', '/oauth/token', { form: refreshForm() }), + ]); + const refreshSuccesses = refreshResults.filter((result) => result.status === 200); + const refreshFailures = refreshResults.filter( + (result) => result.status === 400 && result.data.error === 'invalid_grant' + ); + assert(refreshSuccesses.length === 1, 'refresh token concurrent rotation must succeed once'); + assert(refreshFailures.length === 1, 'refresh token concurrent rotation must reject reuse once'); +} + +export async function testPkceReuseAndAudience(appAccessToken: string, clientId: string) { + const verifier = randomToken(48); + const codeChallenge = await sha256Base64Url(verifier); + const authorize = await startAuthorization({ clientId, scopes: ['notes:read'], codeChallenge }); + assertStatus(authorize.status, 302, 'PKCE authorization redirect'); + const requestId = extractRequestId(authorize.headers.get('location') || ''); + const approve = await request('POST', '/oauth/authorize/approve', { + headers: { Authorization: 'Bearer ' + appAccessToken }, + body: { requestId, decision: 'approve' }, + }); + assertStatus(approve.status, 200, 'PKCE approval'); + const code = new URL(approve.data.data.redirectUrl).searchParams.get('code'); + assert(code, 'PKCE code missing'); + + const wrongVerifier = await request('POST', '/oauth/token', { + form: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code, + code_verifier: 'wrong-verifier', + resource: MCP_RESOURCE, + }), + }); + assertStatus(wrongVerifier.status, 400, 'wrong PKCE verifier'); + const secondUse = await request('POST', '/oauth/token', { + form: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + code, + code_verifier: verifier, + resource: MCP_RESOURCE, + }), + }); + assertStatus(secondUse.status, 400, 'authorization code reuse after failed PKCE'); + + const badAudienceClient = await registerClient(['notes:read']); + const badAudience = await authorizeAndExchange({ + appAccessToken, + clientId: badAudienceClient.client_id, + scopes: ['notes:read'], + resource: MCP_RESOURCE, + }); + const wrongResource = await request('POST', '/oauth/token', { + form: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: badAudienceClient.client_id, + refresh_token: badAudience.refreshToken, + resource: API_BASE + '/wrong-resource', + }), + }); + assertStatus(wrongResource.status, 400, 'wrong refresh resource'); +} diff --git a/server/types/hono.ts b/server/types/hono.ts index cb744243..e7eb8116 100644 --- a/server/types/hono.ts +++ b/server/types/hono.ts @@ -20,6 +20,13 @@ export interface HonoVariables { user?: SafeUser; dynamicApiUrl?: string; dynamicFrontendUrl?: string; + mcpAuth?: { + token: string; + userId: string; + clientId: string; + scopes: string[]; + resource: string; + }; openKey?: { id: string; userid: string; diff --git a/server/utils/dbMethods/index.ts b/server/utils/dbMethods/index.ts index 334b2d2d..27a96542 100644 --- a/server/utils/dbMethods/index.ts +++ b/server/utils/dbMethods/index.ts @@ -9,6 +9,10 @@ export * from './change'; export * from './common'; export * from './linkPreview'; export * from './note'; +export * from './oauthMcpAuthorization'; +export * from './oauthMcpClients'; +export * from './oauthMcpGrants'; +export * from './oauthMcpRefreshTokens'; export * from './reaction'; export * from './site'; export * from './subscription'; diff --git a/server/utils/dbMethods/oauthMcpAuthorization.ts b/server/utils/dbMethods/oauthMcpAuthorization.ts new file mode 100644 index 00000000..97c3bcdf --- /dev/null +++ b/server/utils/dbMethods/oauthMcpAuthorization.ts @@ -0,0 +1,124 @@ +import { and, eq, gt, isNull, sql } from 'drizzle-orm'; +import { oauthAuthorizationCodes, oauthAuthorizationRequests } from '../../drizzle/oauthMcpSchema'; +import db from '../drizzle'; +import { DatabaseError } from './common'; + +export async function createOAuthAuthorizationRequest(data: { + clientId: string; + redirectUri: string; + scopes: string[]; + state?: string | null; + resource: string; + codeChallenge: string; + codeChallengeMethod: string; + expiresAt: Date; +}) { + try { + const [request] = await db + .insert(oauthAuthorizationRequests) + .values({ + clientId: data.clientId, + redirectUri: data.redirectUri, + scopes: data.scopes, + state: data.state || null, + resource: data.resource, + codeChallenge: data.codeChallenge, + codeChallengeMethod: data.codeChallengeMethod, + expiresAt: data.expiresAt, + createdAt: sql`now()`, + updatedAt: sql`now()`, + }) + .returning(); + return request; + } catch (error) { + throw new DatabaseError('oauth_authorization_request_create_failed', error); + } +} + +export async function findOAuthAuthorizationRequest(id: string) { + try { + const [request] = await db + .select() + .from(oauthAuthorizationRequests) + .where(eq(oauthAuthorizationRequests.id, id)) + .limit(1); + return request || null; + } catch (error) { + throw new DatabaseError('oauth_authorization_request_find_failed:' + id, error); + } +} + +export async function updateOAuthAuthorizationRequestStatus( + id: string, + status: 'approved' | 'denied' | 'expired', + userId?: string +) { + try { + const [request] = await db + .update(oauthAuthorizationRequests) + .set({ + status, + userid: userId, + updatedAt: new Date(), + }) + .where(eq(oauthAuthorizationRequests.id, id)) + .returning(); + return request || null; + } catch (error) { + throw new DatabaseError('oauth_authorization_request_update_failed:' + id, error); + } +} + +export async function createOAuthAuthorizationCode(data: { + codeHash: string; + requestId: string; + clientId: string; + userId: string; + redirectUri: string; + scopes: string[]; + resource: string; + codeChallenge: string; + codeChallengeMethod: string; + expiresAt: Date; +}) { + try { + const [code] = await db + .insert(oauthAuthorizationCodes) + .values({ + codeHash: data.codeHash, + requestId: data.requestId, + clientId: data.clientId, + userid: data.userId, + redirectUri: data.redirectUri, + scopes: data.scopes, + resource: data.resource, + codeChallenge: data.codeChallenge, + codeChallengeMethod: data.codeChallengeMethod, + expiresAt: data.expiresAt, + createdAt: sql`now()`, + }) + .returning(); + return code; + } catch (error) { + throw new DatabaseError('oauth_authorization_code_create_failed', error); + } +} + +export async function consumeOAuthAuthorizationCode(codeHash: string) { + try { + const [code] = await db + .update(oauthAuthorizationCodes) + .set({ consumedAt: new Date() }) + .where( + and( + eq(oauthAuthorizationCodes.codeHash, codeHash), + isNull(oauthAuthorizationCodes.consumedAt), + gt(oauthAuthorizationCodes.expiresAt, new Date()) + ) + ) + .returning(); + return code || null; + } catch (error) { + throw new DatabaseError('oauth_authorization_code_consume_failed', error); + } +} diff --git a/server/utils/dbMethods/oauthMcpClients.ts b/server/utils/dbMethods/oauthMcpClients.ts new file mode 100644 index 00000000..7067701d --- /dev/null +++ b/server/utils/dbMethods/oauthMcpClients.ts @@ -0,0 +1,45 @@ +import { eq, sql } from 'drizzle-orm'; +import { oauthClients } from '../../drizzle/oauthMcpSchema'; +import db from '../drizzle'; +import { DatabaseError } from './common'; + +export async function createOAuthClient(data: { + clientId: string; + clientName: string; + redirectUris: string[]; + scopes: string[]; + clientUri?: string | null; + logoUri?: string | null; +}) { + try { + const [client] = await db + .insert(oauthClients) + .values({ + clientId: data.clientId, + clientName: data.clientName, + clientUri: data.clientUri || null, + logoUri: data.logoUri || null, + redirectUris: data.redirectUris, + scopes: data.scopes, + createdAt: sql`now()`, + updatedAt: sql`now()`, + }) + .returning(); + return client; + } catch (error) { + throw new DatabaseError('oauth_client_create_failed', error); + } +} + +export async function findOAuthClient(clientId: string) { + try { + const [client] = await db + .select() + .from(oauthClients) + .where(eq(oauthClients.clientId, clientId)) + .limit(1); + return client || null; + } catch (error) { + throw new DatabaseError('oauth_client_find_failed:' + clientId, error); + } +} diff --git a/server/utils/dbMethods/oauthMcpGrants.ts b/server/utils/dbMethods/oauthMcpGrants.ts new file mode 100644 index 00000000..24b6e77b --- /dev/null +++ b/server/utils/dbMethods/oauthMcpGrants.ts @@ -0,0 +1,32 @@ +import { sql } from 'drizzle-orm'; +import { oauthGrants } from '../../drizzle/oauthMcpSchema'; +import db from '../drizzle'; +import { DatabaseError } from './common'; + +export async function upsertOAuthGrant(data: { + userId: string; + clientId: string; + scopes: string[]; + resource: string; +}) { + try { + const [grant] = await db + .insert(oauthGrants) + .values({ + userid: data.userId, + clientId: data.clientId, + scopes: data.scopes, + resource: data.resource, + createdAt: sql`now()`, + updatedAt: sql`now()`, + }) + .onConflictDoUpdate({ + target: [oauthGrants.userid, oauthGrants.clientId, oauthGrants.resource], + set: { scopes: data.scopes, updatedAt: sql`now()` }, + }) + .returning(); + return grant; + } catch (error) { + throw new DatabaseError('oauth_grant_upsert_failed', error); + } +} diff --git a/server/utils/dbMethods/oauthMcpRefreshTokens.ts b/server/utils/dbMethods/oauthMcpRefreshTokens.ts new file mode 100644 index 00000000..1e8e13f7 --- /dev/null +++ b/server/utils/dbMethods/oauthMcpRefreshTokens.ts @@ -0,0 +1,112 @@ +import { and, eq, gt, isNull, sql } from 'drizzle-orm'; +import { oauthRefreshTokens } from '../../drizzle/oauthMcpSchema'; +import db from '../drizzle'; +import { DatabaseError } from './common'; + +export async function createOAuthRefreshToken(data: { + tokenHash: string; + clientId: string; + userId: string; + scopes: string[]; + resource: string; + expiresAt: Date; +}) { + try { + const [token] = await db + .insert(oauthRefreshTokens) + .values({ + tokenHash: data.tokenHash, + clientId: data.clientId, + userid: data.userId, + scopes: data.scopes, + resource: data.resource, + expiresAt: data.expiresAt, + createdAt: sql`now()`, + updatedAt: sql`now()`, + }) + .returning(); + return token; + } catch (error) { + throw new DatabaseError('oauth_refresh_token_create_failed', error); + } +} + +export async function findOAuthRefreshToken(tokenHash: string) { + try { + const [token] = await db + .select() + .from(oauthRefreshTokens) + .where(eq(oauthRefreshTokens.tokenHash, tokenHash)) + .limit(1); + return token || null; + } catch (error) { + throw new DatabaseError('oauth_refresh_token_find_failed', error); + } +} + +export async function rotateOAuthRefreshToken(data: { + tokenHash: string; + clientId: string; + resource: string; + newTokenHash: string; + expiresAt: Date; +}) { + try { + return await db.transaction(async (tx) => { + const now = new Date(); + const [oldToken] = await tx + .update(oauthRefreshTokens) + .set({ revokedAt: now, updatedAt: now }) + .where( + and( + eq(oauthRefreshTokens.tokenHash, data.tokenHash), + eq(oauthRefreshTokens.clientId, data.clientId), + eq(oauthRefreshTokens.resource, data.resource), + isNull(oauthRefreshTokens.revokedAt), + gt(oauthRefreshTokens.expiresAt, now) + ) + ) + .returning(); + if (!oldToken) return null; + + const [newToken] = await tx + .insert(oauthRefreshTokens) + .values({ + tokenHash: data.newTokenHash, + clientId: oldToken.clientId, + userid: oldToken.userid, + scopes: oldToken.scopes, + resource: oldToken.resource, + expiresAt: data.expiresAt, + createdAt: sql`now()`, + updatedAt: sql`now()`, + }) + .returning(); + + await tx + .update(oauthRefreshTokens) + .set({ replacedByTokenId: newToken.id, updatedAt: new Date() }) + .where(eq(oauthRefreshTokens.id, oldToken.id)); + return { oldToken, newToken }; + }); + } catch (error) { + throw new DatabaseError('oauth_refresh_token_rotate_failed', error); + } +} + +export async function revokeOAuthRefreshToken(id: string, replacedByTokenId?: string) { + try { + const [token] = await db + .update(oauthRefreshTokens) + .set({ + revokedAt: new Date(), + replacedByTokenId, + updatedAt: new Date(), + }) + .where(eq(oauthRefreshTokens.id, id)) + .returning(); + return token || null; + } catch (error) { + throw new DatabaseError('oauth_refresh_token_revoke_failed:' + id, error); + } +} diff --git a/server/utils/drizzle.ts b/server/utils/drizzle.ts index f8d8a73e..1f2a935e 100644 --- a/server/utils/drizzle.ts +++ b/server/utils/drizzle.ts @@ -2,7 +2,10 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import { existsSync } from 'fs'; import { join } from 'path'; import postgres from 'postgres'; -import * as schema from '../drizzle/schema'; +import * as oauthMcpSchema from '../drizzle/oauthMcpSchema'; +import * as baseSchema from '../drizzle/schema'; + +const schema = { ...baseSchema, ...oauthMcpSchema }; // 创建 postgres 连接 const connectionString = process.env.POSTGRESQL_URL || ''; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 5879a9ba..4058e35e 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -271,6 +271,54 @@ "passwordOptionalHint": "You can skip the password and register with a passkey." } }, + "oauthAuthorize": { + "title": "Authorize Rote MCP", + "subtitle": "{{client}} wants to access your Rote account.", + "client": "Client", + "permissions": "Permissions", + "permissionsDescription": "{{count}} capabilities will be granted, grouped by feature.", + "permissionCount": "{{count}} item(s)", + "resource": "MCP resource", + "approve": "Allow", + "approving": "Allowing...", + "deny": "Deny", + "denying": "Denying...", + "errorTitle": "Authorization unavailable", + "backHome": "Back home", + "errors": { + "missingRequest": "Authorization request is missing.", + "loadFailed": "Failed to load authorization request.", + "submitFailed": "Failed to submit authorization decision." + }, + "permissionGroups": { + "notes": "Notes", + "articles": "Articles", + "reactions": "Reactions", + "profile": "Profile", + "data": "Tags and statistics", + "settings": "User settings", + "attachments": "Attachments and video", + "other": "Other capabilities" + }, + "scopes": { + "notes_read": "Read your notes", + "notes_write": "Create and edit your notes", + "notes_delete": "Delete your notes", + "articles_read": "Read your articles", + "articles_write": "Create and edit your articles", + "articles_delete": "Delete your articles", + "reactions_write": "Add reactions", + "reactions_delete": "Remove reactions", + "profile_read": "Read your profile", + "profile_write": "Edit your profile", + "stats_read": "Read tags and statistics", + "settings_read": "Read your settings", + "settings_write": "Edit your settings", + "attachments_write": "Upload and arrange attachments", + "attachments_delete": "Delete attachments", + "video_upload": "Upload videos" + } + }, "home": { "statistics": "Statistics" }, diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 229204ab..87e7894f 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -275,6 +275,54 @@ "passwordOptionalHint": "パスワードをスキップして、パスキーで登録できます。" } }, + "oauthAuthorize": { + "title": "Rote MCP を認可", + "subtitle": "{{client}} があなたの Rote アカウントへのアクセスを求めています。", + "client": "クライアント", + "permissions": "権限", + "permissionsDescription": "{{count}} 件の機能を、機能別にグループ化して許可します。", + "permissionCount": "{{count}} 件", + "resource": "MCP リソース", + "approve": "許可", + "approving": "許可中...", + "deny": "拒否", + "denying": "拒否中...", + "errorTitle": "認可を利用できません", + "backHome": "ホームへ戻る", + "errors": { + "missingRequest": "認可リクエストがありません。", + "loadFailed": "認可リクエストを読み込めませんでした。", + "submitFailed": "認可の選択を送信できませんでした。" + }, + "permissionGroups": { + "notes": "ノート", + "articles": "記事", + "reactions": "リアクション", + "profile": "プロフィール", + "data": "タグと統計", + "settings": "ユーザー設定", + "attachments": "添付ファイルと動画", + "other": "その他の機能" + }, + "scopes": { + "notes_read": "ノートを読み取る", + "notes_write": "ノートを作成・編集する", + "notes_delete": "ノートを削除する", + "articles_read": "記事を読み取る", + "articles_write": "記事を作成・編集する", + "articles_delete": "記事を削除する", + "reactions_write": "リアクションを追加する", + "reactions_delete": "リアクションを削除する", + "profile_read": "プロフィールを読み取る", + "profile_write": "プロフィールを編集する", + "stats_read": "タグと統計を読み取る", + "settings_read": "設定を読み取る", + "settings_write": "設定を編集する", + "attachments_write": "添付ファイルをアップロード・並べ替える", + "attachments_delete": "添付ファイルを削除する", + "video_upload": "動画をアップロードする" + } + }, "home": { "statistics": "統計" }, diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index c1bd8e78..2f7482c6 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -261,6 +261,54 @@ "passwordOptionalHint": "可以跳过密码设置,使用通行密钥注册。" } }, + "oauthAuthorize": { + "title": "授权 Rote MCP", + "subtitle": "{{client}} 想访问你的 Rote 账号。", + "client": "客户端", + "permissions": "权限", + "permissionsDescription": "将授予 {{count}} 项能力,按功能分组展示。", + "permissionCount": "{{count}} 项", + "resource": "MCP 资源", + "approve": "允许", + "approving": "授权中...", + "deny": "拒绝", + "denying": "拒绝中...", + "errorTitle": "授权不可用", + "backHome": "返回首页", + "errors": { + "missingRequest": "缺少授权请求。", + "loadFailed": "无法加载授权请求。", + "submitFailed": "提交授权决定失败。" + }, + "permissionGroups": { + "notes": "笔记", + "articles": "文章", + "reactions": "反应", + "profile": "个人资料", + "data": "标签与统计", + "settings": "用户设置", + "attachments": "附件与视频", + "other": "其他能力" + }, + "scopes": { + "notes_read": "读取你的笔记", + "notes_write": "创建和编辑你的笔记", + "notes_delete": "删除你的笔记", + "articles_read": "读取你的文章", + "articles_write": "创建和编辑你的文章", + "articles_delete": "删除你的文章", + "reactions_write": "添加反应", + "reactions_delete": "移除反应", + "profile_read": "读取你的个人资料", + "profile_write": "编辑你的个人资料", + "stats_read": "读取标签和统计数据", + "settings_read": "读取你的设置", + "settings_write": "编辑你的设置", + "attachments_write": "上传和排列附件", + "attachments_delete": "删除附件", + "video_upload": "上传视频" + } + }, "home": { "statistics": "统计 / Static" }, diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx index fcf46ea7..81349df5 100644 --- a/web/src/pages/login/index.tsx +++ b/web/src/pages/login/index.tsx @@ -46,6 +46,11 @@ function Login() { usePasskey(); const [showPasskeyPrompt, setShowPasskeyPrompt] = useState(false); const isIosLoginFlow = searchParams.get('type') === 'ioslogin'; + const redirectTarget = searchParams.get('redirect'); + const postLoginRedirect = + redirectTarget && redirectTarget.startsWith('/') && !redirectTarget.startsWith('//') + ? redirectTarget + : '/home'; // 如果注册被禁用,确保 activeTab 是 'login' useEffect(() => { @@ -105,7 +110,7 @@ function Login() { refreshToken?: string | null; }) { if (!isIosLoginFlow) { - navigate('/home'); + navigate(postLoginRedirect); return; } @@ -311,7 +316,7 @@ function Login() { } // 清除 URL 参数并重定向 - navigate('/home', { replace: true }); + navigate(postLoginRedirect, { replace: true }); } else if (oauthStatus === 'error' && errorMessage) { // OAuth 登录失败 toast.error(decodeURIComponent(errorMessage)); @@ -329,12 +334,14 @@ function Login() { // 清除 URL 参数 navigate('/login', { replace: true }); } - }, [searchParams, navigate, mutateProfile, t, isIosLoginFlow]); + }, [searchParams, navigate, mutateProfile, t, isIosLoginFlow, postLoginRedirect]); // 通用 OAuth 登录处理函数 function handleOAuthLogin(provider: string) { const iosLogin = isIosLoginFlow; - const redirectUrl = iosLogin ? '/login?type=ioslogin' : '/login'; + const redirectUrl = iosLogin + ? '/login?type=ioslogin' + : `/login${redirectTarget ? `?redirect=${encodeURIComponent(redirectTarget)}` : ''}`; // 使用完整的 API URL const oauthUrl = `${getApiUrl()}/auth/oauth/${provider}?type=${iosLogin ? 'ioslogin' : 'web'}&redirect=${encodeURIComponent(redirectUrl)}`; window.location.href = oauthUrl; diff --git a/web/src/pages/oauth/authorize.tsx b/web/src/pages/oauth/authorize.tsx new file mode 100644 index 00000000..a2e25ab8 --- /dev/null +++ b/web/src/pages/oauth/authorize.tsx @@ -0,0 +1,244 @@ +import LoadingPlaceholder from '@/components/others/LoadingPlaceholder'; +import { Button } from '@/components/ui/button'; +import { authService } from '@/utils/auth'; +import { + getOAuthAuthorizeSession, + submitOAuthAuthorizeDecision, + type OAuthAuthorizeSession, +} from '@/utils/oauthMcpApi'; +import { Check, ExternalLink, ShieldCheck, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; + +function getLoginRedirectPath() { + return `${window.location.pathname}${window.location.search}`; +} + +function getScopeLabelKey(scope: string) { + return scope.replaceAll(':', '_'); +} + +const OAUTH_SCOPE_GROUPS = [ + { + key: 'notes', + scopes: ['notes:read', 'notes:write', 'notes:delete'], + }, + { + key: 'articles', + scopes: ['articles:read', 'articles:write', 'articles:delete'], + }, + { + key: 'reactions', + scopes: ['reactions:write', 'reactions:delete'], + }, + { + key: 'profile', + scopes: ['profile:read', 'profile:write'], + }, + { + key: 'data', + scopes: ['stats:read'], + }, + { + key: 'settings', + scopes: ['settings:read', 'settings:write'], + }, + { + key: 'attachments', + scopes: ['attachments:write', 'attachments:delete', 'video:upload'], + }, +] as const; + +export default function OAuthAuthorizePage() { + const { t } = useTranslation('translation', { keyPrefix: 'pages.oauthAuthorize' }); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const requestId = searchParams.get('requestId'); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState<'approve' | 'deny' | null>(null); + const [error, setError] = useState(null); + + const scopeLabels = useMemo( + () => + Object.fromEntries( + session?.scopes.map((scope) => [ + scope, + t(`scopes.${getScopeLabelKey(scope)}`, { defaultValue: scope }), + ]) || [] + ), + [session?.scopes, t] + ); + + const permissionGroups = useMemo(() => { + const scopes = session?.scopes || []; + const handledScopes = new Set(); + const groups: Array<{ key: string; scopes: string[] }> = OAUTH_SCOPE_GROUPS.map((group) => { + const groupScopes = group.scopes.filter((scope) => scopes.includes(scope)); + groupScopes.forEach((scope) => handledScopes.add(scope)); + return { + key: group.key, + scopes: groupScopes, + }; + }).filter((group) => group.scopes.length > 0); + + const otherScopes = scopes.filter((scope) => !handledScopes.has(scope)); + if (otherScopes.length > 0) { + groups.push({ + key: 'other', + scopes: otherScopes, + }); + } + + return groups; + }, [session?.scopes]); + + useEffect(() => { + if (!requestId) { + setError(t('errors.missingRequest')); + setLoading(false); + return; + } + + if (!authService.hasValidAccessToken() && !authService.hasValidRefreshToken()) { + navigate(`/login?redirect=${encodeURIComponent(getLoginRedirectPath())}`, { replace: true }); + return; + } + + setLoading(true); + getOAuthAuthorizeSession(requestId) + .then((data) => { + setSession(data); + setError(null); + }) + .catch((err: any) => { + if (err?.response?.status === 401) { + navigate(`/login?redirect=${encodeURIComponent(getLoginRedirectPath())}`, { + replace: true, + }); + return; + } + setError(err?.response?.data?.message || err?.message || t('errors.loadFailed')); + }) + .finally(() => setLoading(false)); + }, [navigate, requestId, t]); + + async function submit(decision: 'approve' | 'deny') { + if (!requestId) return; + setSubmitting(decision); + try { + const result = await submitOAuthAuthorizeDecision(requestId, decision); + window.location.href = result.redirectUrl; + } catch (err: any) { + toast.error(err?.response?.data?.message || err?.message || t('errors.submitFailed')); + setSubmitting(null); + } + } + + if (loading) { + return ; + } + + if (error || !session) { + return ( +
+
+
+ +

{t('errorTitle')}

+
+

{error || t('errors.loadFailed')}

+ +
+
+ ); + } + + return ( +
+
+
+
+ +
+
+

{t('title')}

+

+ {t('subtitle', { client: session.client.clientName })} +

+
+
+ +
+
+
{t('client')}
+
{session.client.clientName}
+ {session.client.clientUri && ( + + {session.client.clientUri} + + + )} +
+ +
+
+ {t('permissions')} +
+

+ {t('permissionsDescription', { count: session.scopes.length })} +

+
+ {permissionGroups.map((group) => ( +
+
+

{t(`permissionGroups.${group.key}`)}

+ + {t('permissionCount', { count: group.scopes.length })} + +
+
+ {group.scopes.map((scope) => ( + + + {scopeLabels[scope]} + + ))} +
+
+ ))} +
+
+ +
+
+ {t('resource')} +
+

{session.resource}

+
+
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/route/main.tsx b/web/src/route/main.tsx index 68498c3d..0da4eb1b 100644 --- a/web/src/route/main.tsx +++ b/web/src/route/main.tsx @@ -21,6 +21,7 @@ import MineFilter from '@/pages/filter'; import HomePage from '@/pages/home'; import Landing from '@/pages/landing'; import Login from '@/pages/login'; +import OAuthAuthorizePage from '@/pages/oauth/authorize'; import ProfilePage from '@/pages/profile'; import SettingsPage from '@/pages/profile/setting'; import SingleRotePage from '@/pages/rote/[roteid]'; @@ -39,6 +40,13 @@ function RootLayout() { ); } +function getSafeLoginRedirect(search: string) { + const redirectTarget = new URLSearchParams(search).get('redirect'); + return redirectTarget && redirectTarget.startsWith('/') && !redirectTarget.startsWith('//') + ? redirectTarget + : '/home'; +} + function LoginRouteEntry() { const { tokenValid, isAuthPending } = useAuthState(); const isIosLogin = new URLSearchParams(window.location.search).get('type') === 'ioslogin'; @@ -47,7 +55,11 @@ function LoginRouteEntry() { return ; } - return tokenValid && !isIosLogin ? : ; + return tokenValid && !isIosLogin ? ( + + ) : ( + + ); } function RootRedirectEntry() { @@ -74,6 +86,15 @@ export default function GlobalRouterProvider() { path: 'login', element: , }, + { + path: 'oauth', + children: [ + { + path: 'authorize', + element: , + }, + ], + }, { path: '404', element: , diff --git a/web/src/utils/oauthMcpApi.ts b/web/src/utils/oauthMcpApi.ts new file mode 100644 index 00000000..15a411e4 --- /dev/null +++ b/web/src/utils/oauthMcpApi.ts @@ -0,0 +1,29 @@ +import { get, post } from './api'; + +export type OAuthAuthorizeSession = { + requestId: string; + client: { + clientId: string; + clientName: string; + clientUri?: string | null; + logoUri?: string | null; + }; + scopes: string[]; + resource: string; + redirectUri: string; + expiresAt: string; +}; + +export function getOAuthAuthorizeSession(requestId: string) { + return get<{ + data: OAuthAuthorizeSession; + }>('/oauth/authorize/session', { requestId }).then((response) => response.data); +} + +export function submitOAuthAuthorizeDecision(requestId: string, decision: 'approve' | 'deny') { + return post<{ + data: { + redirectUrl: string; + }; + }>('/oauth/authorize/approve', { requestId, decision }).then((response) => response.data); +} From 188fb3d3ff18edf305b7cdc1cfe2b85cd2d335d2 Mon Sep 17 00:00:00 2001 From: Rabithua <1289770378@qq.com> Date: Thu, 2 Jul 2026 10:28:58 +0800 Subject: [PATCH 2/2] Refine OAuth MCP domain actions --- server/attachments/errorCodes.json | 24 ++ server/attachments/finalizeUpload.ts | 251 ++++++++++++++++++ server/attachments/presignUpload.ts | 129 +++++++++ .../types.ts} | 4 +- server/mcp/attachmentFinalize.ts | 244 +---------------- server/mcp/attachmentPresign.ts | 132 +-------- server/mcp/errorCodes.json | 23 +- server/mcp/notes.ts | 89 +------ server/notes/actions.ts | 115 ++++++++ server/utils/backgroundTask.ts | 7 + 10 files changed, 552 insertions(+), 466 deletions(-) create mode 100644 server/attachments/errorCodes.json create mode 100644 server/attachments/finalizeUpload.ts create mode 100644 server/attachments/presignUpload.ts rename server/{mcp/attachmentSupport.ts => attachments/types.ts} (90%) create mode 100644 server/notes/actions.ts create mode 100644 server/utils/backgroundTask.ts diff --git a/server/attachments/errorCodes.json b/server/attachments/errorCodes.json new file mode 100644 index 00000000..ef7629c1 --- /dev/null +++ b/server/attachments/errorCodes.json @@ -0,0 +1,24 @@ +{ + "attachmentBatchLimitExceeded": "attachment_batch_limit_exceeded", + "attachmentMediaUnsupportedPrefix": "attachment_media_unsupported:", + "attachmentValidationFailed": "attachment_validation_failed", + "attachmentValidationErrorsPrefix": "attachment_validation_errors:", + "attachmentsRequired": "attachments_required", + "capabilityAttachmentUpload": "capability_required:attachment.upload", + "capabilityVideoUpload": "capability_required:attachment.video.upload", + "compressedFileInvalidPrefix": "compressed_file_invalid:", + "fileCountExceeded": "file_count_exceeded", + "insufficientVideoUpload": "insufficient_scope:video:upload", + "livePhotoOriginalNotImage": "live_photo_original_not_image", + "livePhotoPairedVideoInvalidPrefix": "live_photo_paired_video_invalid:", + "livePhotoPairedVideoMissingPrefix": "live_photo_paired_video_missing:", + "livePhotoPairedVideoNotVideo": "live_photo_paired_video_not_video", + "livePhotoPairedVideoRequired": "live_photo_paired_video_required", + "objectKeyInvalid": "object_key_invalid", + "originalFileNotFoundPrefix": "original_file_not_found:", + "pairedVideoLivePhotoOnlyPrefix": "paired_video_live_photo_only:", + "posterFileInvalidPrefix": "poster_file_invalid:", + "storageNotConfigured": "storage_not_configured", + "uuidMismatchPrefix": "attachment_uuid_mismatch:", + "videoCompressedKeyForbiddenPrefix": "video_compressed_key_forbidden:" +} diff --git a/server/attachments/finalizeUpload.ts b/server/attachments/finalizeUpload.ts new file mode 100644 index 00000000..44655e07 --- /dev/null +++ b/server/attachments/finalizeUpload.ts @@ -0,0 +1,251 @@ +import type { UploadResult } from '../types/main'; +import { + extractCompressedUuid, + extractOriginalUploadUuid, + extractPairedVideoUuid, + extractPosterUuid, +} from './uploadKeys'; +import { getAttachmentUploadPolicy } from './uploadPolicy'; +import { getAttachmentDetailsByRoteId, upsertAttachmentsByOriginalKey } from '../utils/dbMethods'; +import { + MAX_BATCH_SIZE, + inferAttachmentMediaKind, + isImageContentType, + isVideoContentType, + mergeUniqueRoteAttachmentDetails, + validateContentType, + validateFileSize, + validateRoteAttachmentDetails, +} from '../utils/fileValidation'; +import { checkObjectExists } from '../utils/r2'; +import type { FinalizeAttachmentInput } from './types'; +import { requireStorageAvailable } from './types'; +import attachmentErrors from './errorCodes.json'; + +function assertFinalizeInput( + attachments: FinalizeAttachmentInput[] | undefined +): asserts attachments is FinalizeAttachmentInput[] { + if (!attachments || !Array.isArray(attachments) || attachments.length === 0) { + throw new Error(attachmentErrors.attachmentsRequired); + } + if (attachments.length > MAX_BATCH_SIZE) { + throw new Error(attachmentErrors.attachmentBatchLimitExceeded); + } +} + +function validateAttachmentPayload(item: FinalizeAttachmentInput, maxVideoUploadSizeMB: number) { + if (item.mimetype) { + validateContentType(item.mimetype); + validateFileSize(item.size, item.mimetype, maxVideoUploadSizeMB); + } + if (item.mediaKind === 'livePhoto' || item.pairedVideoKey) { + if (!isImageContentType(item.mimetype)) + throw new Error(attachmentErrors.livePhotoOriginalNotImage); + validateContentType(item.pairedVideoMimetype); + if (!isVideoContentType(item.pairedVideoMimetype)) + throw new Error(attachmentErrors.livePhotoPairedVideoNotVideo); + validateFileSize(item.pairedVideoSize, item.pairedVideoMimetype, maxVideoUploadSizeMB); + } +} + +async function collectValidAttachments(attachments: FinalizeAttachmentInput[]) { + const validationErrors: string[] = []; + const validAttachments: FinalizeAttachmentInput[] = []; + for (const item of attachments) { + const mediaKind = inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + key: item.originalKey, + }); + const normalizedAttachment = { ...item }; + const originalExists = await checkObjectExists(item.originalKey); + if (!originalExists) { + validationErrors.push( + attachmentErrors.originalFileNotFoundPrefix + item.originalKey + ':' + item.uuid + ); + continue; + } + + const originalUuid = extractOriginalUploadUuid(item.originalKey); + if (!originalUuid || originalUuid !== item.uuid) { + validationErrors.push(attachmentErrors.uuidMismatchPrefix + item.originalKey); + continue; + } + if (mediaKind === 'video' && normalizedAttachment.compressedKey) { + validationErrors.push(attachmentErrors.videoCompressedKeyForbiddenPrefix + item.originalKey); + continue; + } + if ((mediaKind === 'image' || mediaKind === 'livePhoto') && normalizedAttachment.posterKey) { + normalizedAttachment.posterKey = undefined; + } + if ( + (mediaKind === 'image' || mediaKind === 'livePhoto') && + normalizedAttachment.compressedKey + ) { + const compressedExists = await checkObjectExists(normalizedAttachment.compressedKey); + const compressedUuid = extractCompressedUuid(normalizedAttachment.compressedKey); + if (!compressedExists || !compressedUuid || originalUuid !== compressedUuid) { + validationErrors.push( + attachmentErrors.compressedFileInvalidPrefix + normalizedAttachment.compressedKey + ); + normalizedAttachment.compressedKey = undefined; + } + } + if (mediaKind === 'livePhoto') { + if (!normalizedAttachment.pairedVideoKey) { + validationErrors.push( + attachmentErrors.livePhotoPairedVideoMissingPrefix + item.originalKey + ); + continue; + } + const pairedVideoExists = await checkObjectExists(normalizedAttachment.pairedVideoKey); + const pairedVideoUuid = extractPairedVideoUuid(normalizedAttachment.pairedVideoKey); + if (!pairedVideoExists || !pairedVideoUuid || originalUuid !== pairedVideoUuid) { + validationErrors.push( + attachmentErrors.livePhotoPairedVideoInvalidPrefix + normalizedAttachment.pairedVideoKey + ); + continue; + } + } else if (normalizedAttachment.pairedVideoKey) { + validationErrors.push(attachmentErrors.pairedVideoLivePhotoOnlyPrefix + item.originalKey); + continue; + } + if (mediaKind === 'video' && normalizedAttachment.posterKey) { + const posterExists = await checkObjectExists(normalizedAttachment.posterKey); + const posterUuid = extractPosterUuid(normalizedAttachment.posterKey); + if (!posterExists || !posterUuid || originalUuid !== posterUuid) { + validationErrors.push( + attachmentErrors.posterFileInvalidPrefix + normalizedAttachment.posterKey + ); + normalizedAttachment.posterKey = undefined; + } + } + if (!mediaKind) { + validationErrors.push(attachmentErrors.attachmentMediaUnsupportedPrefix + item.originalKey); + continue; + } + validAttachments.push(normalizedAttachment); + } + + if (validAttachments.length === 0) { + const message = + validationErrors.length === 1 + ? validationErrors[0] + : attachmentErrors.attachmentValidationErrorsPrefix + validationErrors.join(';'); + throw new Error(message || attachmentErrors.attachmentValidationFailed); + } + return validAttachments; +} + +function toUploadResult(urlPrefix: string, item: FinalizeAttachmentInput): UploadResult { + const mediaKind = inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype || null, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + }); + const pairedVideoUrl = + mediaKind === 'livePhoto' && item.pairedVideoKey ? urlPrefix + '/' + item.pairedVideoKey : null; + const details: any = { + size: item.size || 0, + mimetype: item.mimetype || null, + mediaKind, + mtime: new Date().toISOString(), + key: item.originalKey, + }; + if (item.compressedKey) details.compressKey = item.compressedKey; + if (item.posterKey) details.posterKey = item.posterKey; + if (pairedVideoUrl && item.pairedVideoKey) { + details.pairedVideoKey = item.pairedVideoKey; + details.pairedVideoUrl = pairedVideoUrl; + details.pairedVideoMimetype = item.pairedVideoMimetype || null; + details.pairedVideoSize = item.pairedVideoSize || 0; + if (item.pairedVideoFilename) details.pairedVideoFilename = item.pairedVideoFilename; + } + if (item.hash) details.hash = item.hash; + + return { + url: urlPrefix + '/' + item.originalKey, + compressUrl: + (mediaKind === 'image' || mediaKind === 'livePhoto') && item.compressedKey + ? urlPrefix + '/' + item.compressedKey + : null, + posterUrl: mediaKind === 'video' && item.posterKey ? urlPrefix + '/' + item.posterKey : null, + details, + }; +} + +export async function finalizeAttachmentUploads(input: { + userId: string; + scopes: string[]; + noteId?: string; + attachments?: FinalizeAttachmentInput[]; +}) { + const storageConfig = requireStorageAvailable(); + const uploadPolicy = await getAttachmentUploadPolicy(input.userId); + if (!uploadPolicy.canUploadAttachments) + throw new Error(attachmentErrors.capabilityAttachmentUpload); + + assertFinalizeInput(input.attachments); + const prefix = 'users/' + input.userId + '/'; + const invalid = input.attachments.find( + (item) => + !item.originalKey?.startsWith(prefix) || + (item.compressedKey !== undefined && !item.compressedKey.startsWith(prefix)) || + (item.posterKey !== undefined && !item.posterKey.startsWith(prefix)) || + (item.pairedVideoKey !== undefined && !item.pairedVideoKey.startsWith(prefix)) + ); + if (invalid) throw new Error(attachmentErrors.objectKeyInvalid); + input.attachments.forEach((item) => + validateAttachmentPayload(item, uploadPolicy.maxVideoUploadSizeMB) + ); + + const hasVideo = input.attachments.some( + (item) => + inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + key: item.originalKey, + }) === 'video' || + item.mediaKind === 'livePhoto' || + !!item.pairedVideoKey + ); + if (hasVideo && !input.scopes.includes('video:upload')) + throw new Error(attachmentErrors.insufficientVideoUpload); + if (hasVideo && !uploadPolicy.canUploadVideo) + throw new Error(attachmentErrors.capabilityVideoUpload); + + const validAttachments = await collectValidAttachments(input.attachments); + if (input.noteId) { + const currentAttachments = await getAttachmentDetailsByRoteId(input.noteId); + const pendingAttachments = validAttachments.map((item) => ({ + details: { + key: item.originalKey, + mimetype: item.mimetype || null, + mediaKind: inferAttachmentMediaKind({ + mediaKind: item.mediaKind, + mimetype: item.mimetype || null, + compressedKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + }), + compressKey: item.compressedKey, + posterKey: item.posterKey, + pairedVideoKey: item.pairedVideoKey, + }, + })); + validateRoteAttachmentDetails( + mergeUniqueRoteAttachmentDetails(currentAttachments, pendingAttachments) + ); + } + + const uploads = validAttachments.map((item) => toUploadResult(storageConfig.urlPrefix, item)); + return await upsertAttachmentsByOriginalKey(input.userId, input.noteId, uploads); +} diff --git a/server/attachments/presignUpload.ts b/server/attachments/presignUpload.ts new file mode 100644 index 00000000..17ab3b78 --- /dev/null +++ b/server/attachments/presignUpload.ts @@ -0,0 +1,129 @@ +import { randomUUID } from 'crypto'; +import { getUploadExtension } from './uploadKeys'; +import { getAttachmentUploadPolicy } from './uploadPolicy'; +import { + MAX_FILES, + getMediaKindFromContentType, + isImageContentType, + isVideoContentType, + validateContentType, + validateFileSize, +} from '../utils/fileValidation'; +import { presignPutUrl } from '../utils/r2'; +import type { PresignFileInput } from './types'; +import { requireStorageAvailable } from './types'; +import attachmentErrors from './errorCodes.json'; + +function validatePresignFile(file: PresignFileInput, maxVideoUploadSizeMB: number) { + validateContentType(file.contentType); + if (file.mediaKind !== 'livePhoto') { + validateFileSize(file.size, file.contentType, maxVideoUploadSizeMB); + return; + } + + if (!isImageContentType(file.contentType)) { + throw new Error(attachmentErrors.livePhotoOriginalNotImage); + } + if (!file.pairedVideo) { + throw new Error(attachmentErrors.livePhotoPairedVideoRequired); + } + validateContentType(file.pairedVideo.contentType); + if (!isVideoContentType(file.pairedVideo.contentType)) { + throw new Error(attachmentErrors.livePhotoPairedVideoNotVideo); + } + validateFileSize(file.size, file.contentType, maxVideoUploadSizeMB); + validateFileSize(file.pairedVideo.size, file.pairedVideo.contentType, maxVideoUploadSizeMB); +} + +export async function presignAttachmentUploads(input: { + userId: string; + scopes: string[]; + files: PresignFileInput[]; +}) { + requireStorageAvailable(); + const uploadPolicy = await getAttachmentUploadPolicy(input.userId); + if (!uploadPolicy.canUploadAttachments) { + throw new Error(attachmentErrors.capabilityAttachmentUpload); + } + if (input.files.length > MAX_FILES) { + throw new Error(attachmentErrors.fileCountExceeded); + } + + const hasVideo = input.files.some( + (file) => isVideoContentType(file.contentType) || file.mediaKind === 'livePhoto' + ); + if (hasVideo && !input.scopes.includes('video:upload')) { + throw new Error(attachmentErrors.insufficientVideoUpload); + } + if (hasVideo && !uploadPolicy.canUploadVideo) { + throw new Error(attachmentErrors.capabilityVideoUpload); + } + + input.files.forEach((file) => validatePresignFile(file, uploadPolicy.maxVideoUploadSizeMB)); + + const items = await Promise.all( + input.files.map(async (file) => { + const uuid = randomUUID(); + const ext = getUploadExtension(file.filename, file.contentType); + const originalKey = 'users/' + input.userId + '/uploads/' + uuid + ext; + const mediaKind = + file.mediaKind === 'livePhoto' + ? 'livePhoto' + : getMediaKindFromContentType(file.contentType); + const original = await presignPutUrl(originalKey, file.contentType || undefined, 15 * 60); + const result: Record = { + uuid, + original: { + key: originalKey, + putUrl: original.putUrl, + url: original.url, + contentType: file.contentType, + }, + }; + + if (mediaKind === 'image' || mediaKind === 'livePhoto') { + const compressedKey = 'users/' + input.userId + '/compressed/' + uuid + '.webp'; + const compressed = await presignPutUrl(compressedKey, 'image/webp', 15 * 60); + result.compressed = { + key: compressedKey, + putUrl: compressed.putUrl, + url: compressed.url, + contentType: 'image/webp', + }; + } + + if (mediaKind === 'livePhoto') { + const pairedVideo = file.pairedVideo; + if (!pairedVideo) throw new Error(attachmentErrors.livePhotoPairedVideoRequired); + const pairedVideoExt = getUploadExtension(pairedVideo.filename, pairedVideo.contentType); + const pairedVideoKey = 'users/' + input.userId + '/paired-videos/' + uuid + pairedVideoExt; + const pairedVideoUpload = await presignPutUrl( + pairedVideoKey, + pairedVideo.contentType || undefined, + 15 * 60 + ); + result.pairedVideo = { + key: pairedVideoKey, + putUrl: pairedVideoUpload.putUrl, + url: pairedVideoUpload.url, + contentType: pairedVideo.contentType, + }; + } + + if (mediaKind === 'video') { + const posterKey = 'users/' + input.userId + '/posters/' + uuid + '.jpg'; + const poster = await presignPutUrl(posterKey, 'image/jpeg', 15 * 60); + result.poster = { + key: posterKey, + putUrl: poster.putUrl, + url: poster.url, + contentType: 'image/jpeg', + }; + } + + return result; + }) + ); + + return { items }; +} diff --git a/server/mcp/attachmentSupport.ts b/server/attachments/types.ts similarity index 90% rename from server/mcp/attachmentSupport.ts rename to server/attachments/types.ts index 704998cd..7ad7a698 100644 --- a/server/mcp/attachmentSupport.ts +++ b/server/attachments/types.ts @@ -1,6 +1,6 @@ import type { StorageConfig } from '../types/config'; import { getGlobalConfig } from '../utils/config'; -import mcpErrors from './errorCodes.json'; +import attachmentErrors from './errorCodes.json'; export type PresignFileInput = { filename?: string; @@ -39,7 +39,7 @@ export function requireStorageAvailable() { !storageConfig.secretAccessKey || !storageConfig.bucket ) { - throw new Error(mcpErrors.storageNotConfigured); + throw new Error(attachmentErrors.storageNotConfigured); } return storageConfig; } diff --git a/server/mcp/attachmentFinalize.ts b/server/mcp/attachmentFinalize.ts index ff489be7..58c40efd 100644 --- a/server/mcp/attachmentFinalize.ts +++ b/server/mcp/attachmentFinalize.ts @@ -1,242 +1,14 @@ -import { getAttachmentUploadPolicy } from '../attachments/uploadPolicy'; +import { finalizeAttachmentUploads } from '../attachments/finalizeUpload'; +import type { FinalizeAttachmentInput } from '../attachments/types'; import type { HonoContext } from '../types/hono'; -import type { UploadResult } from '../types/main'; -import { - extractCompressedUuid, - extractOriginalUploadUuid, - extractPairedVideoUuid, - extractPosterUuid, -} from '../attachments/uploadKeys'; -import { getAttachmentDetailsByRoteId, upsertAttachmentsByOriginalKey } from '../utils/dbMethods'; -import { - MAX_BATCH_SIZE, - inferAttachmentMediaKind, - isImageContentType, - isVideoContentType, - mergeUniqueRoteAttachmentDetails, - validateContentType, - validateFileSize, - validateRoteAttachmentDetails, -} from '../utils/fileValidation'; -import { checkObjectExists } from '../utils/r2'; -import type { FinalizeAttachmentInput } from './attachmentSupport'; -import mcpErrors from './errorCodes.json'; -import { requireStorageAvailable } from './attachmentSupport'; import { requireAuth } from './shared'; -function assertFinalizeInput( - attachments: FinalizeAttachmentInput[] | undefined -): asserts attachments is FinalizeAttachmentInput[] { - if (!attachments || !Array.isArray(attachments) || attachments.length === 0) { - throw new Error(mcpErrors.attachmentsRequired); - } - if (attachments.length > MAX_BATCH_SIZE) { - throw new Error(mcpErrors.attachmentBatchLimitExceeded); - } -} - -function validateAttachmentPayload(item: FinalizeAttachmentInput, maxVideoUploadSizeMB: number) { - if (item.mimetype) { - validateContentType(item.mimetype); - validateFileSize(item.size, item.mimetype, maxVideoUploadSizeMB); - } - if (item.mediaKind === 'livePhoto' || item.pairedVideoKey) { - if (!isImageContentType(item.mimetype)) throw new Error(mcpErrors.livePhotoOriginalNotImage); - validateContentType(item.pairedVideoMimetype); - if (!isVideoContentType(item.pairedVideoMimetype)) - throw new Error(mcpErrors.livePhotoPairedVideoNotVideo); - validateFileSize(item.pairedVideoSize, item.pairedVideoMimetype, maxVideoUploadSizeMB); - } -} - -async function collectValidAttachments(attachments: FinalizeAttachmentInput[]) { - const validationErrors: string[] = []; - const validAttachments: FinalizeAttachmentInput[] = []; - for (const item of attachments) { - const mediaKind = inferAttachmentMediaKind({ - mediaKind: item.mediaKind, - mimetype: item.mimetype, - compressedKey: item.compressedKey, - posterKey: item.posterKey, - pairedVideoKey: item.pairedVideoKey, - key: item.originalKey, - }); - const normalizedAttachment = { ...item }; - const originalExists = await checkObjectExists(item.originalKey); - if (!originalExists) { - validationErrors.push( - mcpErrors.originalFileNotFoundPrefix + item.originalKey + ':' + item.uuid - ); - continue; - } - - const originalUuid = extractOriginalUploadUuid(item.originalKey); - if (!originalUuid || originalUuid !== item.uuid) { - validationErrors.push(mcpErrors.uuidMismatchPrefix + item.originalKey); - continue; - } - if (mediaKind === 'video' && normalizedAttachment.compressedKey) { - validationErrors.push(mcpErrors.videoCompressedKeyForbiddenPrefix + item.originalKey); - continue; - } - if ((mediaKind === 'image' || mediaKind === 'livePhoto') && normalizedAttachment.posterKey) { - normalizedAttachment.posterKey = undefined; - } - if ( - (mediaKind === 'image' || mediaKind === 'livePhoto') && - normalizedAttachment.compressedKey - ) { - const compressedExists = await checkObjectExists(normalizedAttachment.compressedKey); - const compressedUuid = extractCompressedUuid(normalizedAttachment.compressedKey); - if (!compressedExists || !compressedUuid || originalUuid !== compressedUuid) { - validationErrors.push( - mcpErrors.compressedFileInvalidPrefix + normalizedAttachment.compressedKey - ); - normalizedAttachment.compressedKey = undefined; - } - } - if (mediaKind === 'livePhoto') { - if (!normalizedAttachment.pairedVideoKey) { - validationErrors.push(mcpErrors.livePhotoPairedVideoMissingPrefix + item.originalKey); - continue; - } - const pairedVideoExists = await checkObjectExists(normalizedAttachment.pairedVideoKey); - const pairedVideoUuid = extractPairedVideoUuid(normalizedAttachment.pairedVideoKey); - if (!pairedVideoExists || !pairedVideoUuid || originalUuid !== pairedVideoUuid) { - validationErrors.push( - mcpErrors.livePhotoPairedVideoInvalidPrefix + normalizedAttachment.pairedVideoKey - ); - continue; - } - } else if (normalizedAttachment.pairedVideoKey) { - validationErrors.push(mcpErrors.pairedVideoLivePhotoOnlyPrefix + item.originalKey); - continue; - } - if (mediaKind === 'video' && normalizedAttachment.posterKey) { - const posterExists = await checkObjectExists(normalizedAttachment.posterKey); - const posterUuid = extractPosterUuid(normalizedAttachment.posterKey); - if (!posterExists || !posterUuid || originalUuid !== posterUuid) { - validationErrors.push(mcpErrors.posterFileInvalidPrefix + normalizedAttachment.posterKey); - normalizedAttachment.posterKey = undefined; - } - } - if (!mediaKind) { - validationErrors.push(mcpErrors.attachmentMediaUnsupportedPrefix + item.originalKey); - continue; - } - validAttachments.push(normalizedAttachment); - } - - if (validAttachments.length === 0) { - const message = - validationErrors.length === 1 - ? validationErrors[0] - : mcpErrors.attachmentValidationErrorsPrefix + validationErrors.join(';'); - throw new Error(message || mcpErrors.attachmentValidationFailed); - } - return validAttachments; -} - -function toUploadResult(urlPrefix: string, item: FinalizeAttachmentInput): UploadResult { - const mediaKind = inferAttachmentMediaKind({ - mediaKind: item.mediaKind, - mimetype: item.mimetype || null, - compressedKey: item.compressedKey, - posterKey: item.posterKey, - pairedVideoKey: item.pairedVideoKey, - }); - const pairedVideoUrl = - mediaKind === 'livePhoto' && item.pairedVideoKey ? urlPrefix + '/' + item.pairedVideoKey : null; - const details: any = { - size: item.size || 0, - mimetype: item.mimetype || null, - mediaKind, - mtime: new Date().toISOString(), - key: item.originalKey, - }; - if (item.compressedKey) details.compressKey = item.compressedKey; - if (item.posterKey) details.posterKey = item.posterKey; - if (pairedVideoUrl && item.pairedVideoKey) { - details.pairedVideoKey = item.pairedVideoKey; - details.pairedVideoUrl = pairedVideoUrl; - details.pairedVideoMimetype = item.pairedVideoMimetype || null; - details.pairedVideoSize = item.pairedVideoSize || 0; - if (item.pairedVideoFilename) details.pairedVideoFilename = item.pairedVideoFilename; - } - if (item.hash) details.hash = item.hash; - - return { - url: urlPrefix + '/' + item.originalKey, - compressUrl: - (mediaKind === 'image' || mediaKind === 'livePhoto') && item.compressedKey - ? urlPrefix + '/' + item.compressedKey - : null, - posterUrl: mediaKind === 'video' && item.posterKey ? urlPrefix + '/' + item.posterKey : null, - details, - }; -} - export async function finalizeAttachments(c: HonoContext, args: Record) { const auth = requireAuth(c); - const storageConfig = requireStorageAvailable(); - const uploadPolicy = await getAttachmentUploadPolicy(auth.userId); - if (!uploadPolicy.canUploadAttachments) throw new Error(mcpErrors.capabilityAttachmentUpload); - - const attachments = args.attachments as FinalizeAttachmentInput[] | undefined; - const noteId = typeof args.noteId === 'string' ? args.noteId : undefined; - assertFinalizeInput(attachments); - const prefix = 'users/' + auth.userId + '/'; - const invalid = attachments.find( - (item) => - !item.originalKey?.startsWith(prefix) || - (item.compressedKey !== undefined && !item.compressedKey.startsWith(prefix)) || - (item.posterKey !== undefined && !item.posterKey.startsWith(prefix)) || - (item.pairedVideoKey !== undefined && !item.pairedVideoKey.startsWith(prefix)) - ); - if (invalid) throw new Error(mcpErrors.objectKeyInvalid); - attachments.forEach((item) => validateAttachmentPayload(item, uploadPolicy.maxVideoUploadSizeMB)); - - const hasVideo = attachments.some( - (item) => - inferAttachmentMediaKind({ - mediaKind: item.mediaKind, - mimetype: item.mimetype, - compressedKey: item.compressedKey, - posterKey: item.posterKey, - pairedVideoKey: item.pairedVideoKey, - key: item.originalKey, - }) === 'video' || - item.mediaKind === 'livePhoto' || - !!item.pairedVideoKey - ); - if (hasVideo && !auth.scopes.includes('video:upload')) - throw new Error(mcpErrors.insufficientVideoUpload); - if (hasVideo && !uploadPolicy.canUploadVideo) throw new Error(mcpErrors.capabilityVideoUpload); - - const validAttachments = await collectValidAttachments(attachments); - if (noteId) { - const currentAttachments = await getAttachmentDetailsByRoteId(noteId); - const pendingAttachments = validAttachments.map((item) => ({ - details: { - key: item.originalKey, - mimetype: item.mimetype || null, - mediaKind: inferAttachmentMediaKind({ - mediaKind: item.mediaKind, - mimetype: item.mimetype || null, - compressedKey: item.compressedKey, - posterKey: item.posterKey, - pairedVideoKey: item.pairedVideoKey, - }), - compressKey: item.compressedKey, - posterKey: item.posterKey, - pairedVideoKey: item.pairedVideoKey, - }, - })); - validateRoteAttachmentDetails( - mergeUniqueRoteAttachmentDetails(currentAttachments, pendingAttachments) - ); - } - - const uploads = validAttachments.map((item) => toUploadResult(storageConfig.urlPrefix, item)); - return await upsertAttachmentsByOriginalKey(auth.userId, noteId, uploads); + return await finalizeAttachmentUploads({ + userId: auth.userId, + scopes: auth.scopes, + noteId: typeof args.noteId === 'string' ? args.noteId : undefined, + attachments: args.attachments as FinalizeAttachmentInput[] | undefined, + }); } diff --git a/server/mcp/attachmentPresign.ts b/server/mcp/attachmentPresign.ts index d9414e65..c723ce4f 100644 --- a/server/mcp/attachmentPresign.ts +++ b/server/mcp/attachmentPresign.ts @@ -1,131 +1,15 @@ -import { randomUUID } from 'crypto'; -import { getUploadExtension } from '../attachments/uploadKeys'; -import { getAttachmentUploadPolicy } from '../attachments/uploadPolicy'; -import type { HonoContext } from '../types/hono'; -import { - MAX_FILES, - getMediaKindFromContentType, - isImageContentType, - isVideoContentType, - validateContentType, - validateFileSize, -} from '../utils/fileValidation'; -import { presignPutUrl } from '../utils/r2'; +import { presignAttachmentUploads } from '../attachments/presignUpload'; +import type { PresignFileInput } from '../attachments/types'; import { AttachmentPresignZod } from '../utils/zod'; -import type { PresignFileInput } from './attachmentSupport'; -import mcpErrors from './errorCodes.json'; -import { requireStorageAvailable } from './attachmentSupport'; import { requireAuth } from './shared'; - -function validatePresignFile(file: PresignFileInput, maxVideoUploadSizeMB: number) { - validateContentType(file.contentType); - if (file.mediaKind !== 'livePhoto') { - validateFileSize(file.size, file.contentType, maxVideoUploadSizeMB); - return; - } - - if (!isImageContentType(file.contentType)) { - throw new Error(mcpErrors.livePhotoOriginalNotImage); - } - if (!file.pairedVideo) { - throw new Error(mcpErrors.livePhotoPairedVideoRequired); - } - validateContentType(file.pairedVideo.contentType); - if (!isVideoContentType(file.pairedVideo.contentType)) { - throw new Error(mcpErrors.livePhotoPairedVideoNotVideo); - } - validateFileSize(file.size, file.contentType, maxVideoUploadSizeMB); - validateFileSize(file.pairedVideo.size, file.pairedVideo.contentType, maxVideoUploadSizeMB); -} +import type { HonoContext } from '../types/hono'; export async function presignAttachments(c: HonoContext, args: Record) { const auth = requireAuth(c); - requireStorageAvailable(); AttachmentPresignZod.parse(args); - const files = args.files as PresignFileInput[]; - const uploadPolicy = await getAttachmentUploadPolicy(auth.userId); - if (!uploadPolicy.canUploadAttachments) { - throw new Error(mcpErrors.capabilityAttachmentUpload); - } - if (files.length > MAX_FILES) { - throw new Error(mcpErrors.fileCountExceeded); - } - - const hasVideo = files.some( - (file) => isVideoContentType(file.contentType) || file.mediaKind === 'livePhoto' - ); - if (hasVideo && !auth.scopes.includes('video:upload')) { - throw new Error(mcpErrors.insufficientVideoUpload); - } - if (hasVideo && !uploadPolicy.canUploadVideo) { - throw new Error(mcpErrors.capabilityVideoUpload); - } - - files.forEach((file) => validatePresignFile(file, uploadPolicy.maxVideoUploadSizeMB)); - - const items = await Promise.all( - files.map(async (file) => { - const uuid = randomUUID(); - const ext = getUploadExtension(file.filename, file.contentType); - const originalKey = 'users/' + auth.userId + '/uploads/' + uuid + ext; - const mediaKind = - file.mediaKind === 'livePhoto' - ? 'livePhoto' - : getMediaKindFromContentType(file.contentType); - const original = await presignPutUrl(originalKey, file.contentType || undefined, 15 * 60); - const result: Record = { - uuid, - original: { - key: originalKey, - putUrl: original.putUrl, - url: original.url, - contentType: file.contentType, - }, - }; - - if (mediaKind === 'image' || mediaKind === 'livePhoto') { - const compressedKey = 'users/' + auth.userId + '/compressed/' + uuid + '.webp'; - const compressed = await presignPutUrl(compressedKey, 'image/webp', 15 * 60); - result.compressed = { - key: compressedKey, - putUrl: compressed.putUrl, - url: compressed.url, - contentType: 'image/webp', - }; - } - - if (mediaKind === 'livePhoto') { - const pairedVideo = file.pairedVideo; - if (!pairedVideo) throw new Error(mcpErrors.livePhotoPairedVideoRequired); - const pairedVideoExt = getUploadExtension(pairedVideo.filename, pairedVideo.contentType); - const pairedVideoKey = 'users/' + auth.userId + '/paired-videos/' + uuid + pairedVideoExt; - const pairedVideoUpload = await presignPutUrl( - pairedVideoKey, - pairedVideo.contentType || undefined, - 15 * 60 - ); - result.pairedVideo = { - key: pairedVideoKey, - putUrl: pairedVideoUpload.putUrl, - url: pairedVideoUpload.url, - contentType: pairedVideo.contentType, - }; - } - - if (mediaKind === 'video') { - const posterKey = 'users/' + auth.userId + '/posters/' + uuid + '.jpg'; - const poster = await presignPutUrl(posterKey, 'image/jpeg', 15 * 60); - result.poster = { - key: posterKey, - putUrl: poster.putUrl, - url: poster.url, - contentType: 'image/jpeg', - }; - } - - return result; - }) - ); - - return { items }; + return await presignAttachmentUploads({ + userId: auth.userId, + scopes: auth.scopes, + files: args.files as PresignFileInput[], + }); } diff --git a/server/mcp/errorCodes.json b/server/mcp/errorCodes.json index 2e0f02fc..3b5ae946 100644 --- a/server/mcp/errorCodes.json +++ b/server/mcp/errorCodes.json @@ -4,49 +4,28 @@ "articleNotFoundOrDenied": "article_not_found_or_denied", "attachmentBatchLimitExceeded": "attachment_batch_limit_exceeded", "attachmentIdsRequired": "attachment_ids_required", - "attachmentMediaUnsupportedPrefix": "attachment_media_unsupported:", - "attachmentValidationFailed": "attachment_validation_failed", - "attachmentValidationErrorsPrefix": "attachment_validation_errors:", - "attachmentsRequired": "attachments_required", "archivedBooleanRequired": "archived_boolean_required", "authRequired": "mcp_auth_required", "batchLimitExceeded": "batch_limit_exceeded", - "capabilityAttachmentUpload": "capability_required:attachment.upload", - "capabilityVideoUpload": "capability_required:attachment.video.upload", - "compressedFileInvalidPrefix": "compressed_file_invalid:", "dateRangeRequired": "date_range_required", - "fileCountExceeded": "file_count_exceeded", "idsRequired": "ids_required", "insufficientScope": "insufficient_scope", - "insufficientVideoUpload": "insufficient_scope:video:upload", "invalidCalendarDatePrefix": "invalid_calendar_date:", "invalidDateFormat": "invalid_date_format", "invalidOrigin": "invalid_origin", "invalidRequest": "invalid_request", "invalidUuidPrefix": "invalid_uuid:", - "livePhotoOriginalNotImage": "live_photo_original_not_image", - "livePhotoPairedVideoInvalidPrefix": "live_photo_paired_video_invalid:", - "livePhotoPairedVideoMissingPrefix": "live_photo_paired_video_missing:", - "livePhotoPairedVideoNotVideo": "live_photo_paired_video_not_video", - "livePhotoPairedVideoRequired": "live_photo_paired_video_required", "mcpToolFailed": "mcp_tool_failed", "methodNotFoundPrefix": "method_not_found:", "nonNegativeIntegerRequiredPrefix": "non_negative_integer_required:", "noteNotFound": "note_not_found", "noteOwnershipRequired": "note_ownership_required", "notePrivate": "note_private", - "objectKeyInvalid": "object_key_invalid", - "originalFileNotFoundPrefix": "original_file_not_found:", - "pairedVideoLivePhotoOnlyPrefix": "paired_video_live_photo_only:", "positiveLimitRequired": "positive_limit_required", - "posterFileInvalidPrefix": "poster_file_invalid:", "roteNotFound": "rote_not_found", - "storageNotConfigured": "storage_not_configured", "tagCountExceeded": "tag_count_exceeded", "tagLengthExceeded": "tag_length_exceeded", "toolDefinitionMissingPrefix": "mcp_tool_definition_missing:", "toolNameRequired": "tool_name_required", - "unknownToolPrefix": "unknown_tool:", - "uuidMismatchPrefix": "attachment_uuid_mismatch:", - "videoCompressedKeyForbiddenPrefix": "video_compressed_key_forbidden:" + "unknownToolPrefix": "unknown_tool:" } diff --git a/server/mcp/notes.ts b/server/mcp/notes.ts index 289817be..8e64b6a5 100644 --- a/server/mcp/notes.ts +++ b/server/mcp/notes.ts @@ -1,20 +1,7 @@ import type { HonoContext } from '../types/hono'; -import { - createRote, - deleteEmbeddingsForSource, - deleteRote, - deleteRoteAttachmentsByRoteId, - deleteRoteLinkPreviewsByRoteId, - editRote, - enqueueEmbeddingJob, - findMyRote, - findRoteById, - findRotesByIds, - searchMyRotes, - setNoteArticleId, -} from '../utils/dbMethods'; +import { createUserNote, deleteUserNote, updateUserNote } from '../notes/actions'; +import { findMyRote, findRoteById, findRotesByIds, searchMyRotes } from '../utils/dbMethods'; import { MAX_BATCH_SIZE } from '../utils/fileValidation'; -import { extractUrlsFromContent, parseAndStoreRoteLinkPreviews } from '../utils/linkPreview'; import { NoteCreateZod, NoteUpdateZod, SearchKeywordZod } from '../utils/zod'; import mcpErrors from './errorCodes.json'; import { defineMcpTool } from './registry'; @@ -32,7 +19,7 @@ import type { McpTool } from './types'; async function createNote(c: HonoContext, args: Record) { const auth = requireAuth(c); NoteCreateZod.parse(args); - const result = await createRote({ + return await createUserNote(auth.userId, { content: args.content, title: args.title || '', state: args.state || 'private', @@ -41,73 +28,16 @@ async function createNote(c: HonoContext, args: Record) { pin: !!args.pin, archived: !!args.archived, editor: args.editor, - authorid: auth.userId, - }); - - void enqueueEmbeddingJob('rote', result.id, auth.userId).catch((error) => { - console.error('mcp_rote_embedding_enqueue_failed', error); + articleId: args.articleId, + articleIds: args.articleIds, }); - - const articleIdToSet = - typeof args.articleId === 'string' - ? args.articleId - : Array.isArray(args.articleIds) && args.articleIds.length > 0 - ? args.articleIds[0] - : null; - if (articleIdToSet) { - await setNoteArticleId(result.id, articleIdToSet, auth.userId); - return result; - } - - void parseAndStoreRoteLinkPreviews(result.id, result.content).catch((error) => { - console.error('mcp_link_preview_create_failed', error); - }); - - return result; } async function updateNote(c: HonoContext, args: Record) { const auth = requireAuth(c); const id = assertUuid(args.id, 'note_id'); NoteUpdateZod.parse(args); - await editRote({ ...args, id, authorid: auth.userId }); - - void enqueueEmbeddingJob('rote', id, auth.userId).catch((error) => { - console.error('mcp_rote_embedding_enqueue_failed', error); - }); - - let articleIdToSet: string | null | undefined; - if ('articleId' in args) { - articleIdToSet = typeof args.articleId === 'string' ? args.articleId : (args.articleId ?? null); - } else if (Array.isArray(args.articleIds)) { - articleIdToSet = args.articleIds.length > 0 ? args.articleIds[0] : null; - } - - if (articleIdToSet !== undefined) { - await setNoteArticleId(id, articleIdToSet, auth.userId); - } - - const data = await findRoteById(id); - const hasArticle = Boolean(data?.articleId || data?.article); - const contentProvided = Object.prototype.hasOwnProperty.call(args, 'content'); - const contentForPreview = contentProvided ? args.content : data?.content; - - if (hasArticle && articleIdToSet !== undefined) { - await deleteRoteLinkPreviewsByRoteId(id); - } else if ( - (contentProvided || articleIdToSet !== undefined) && - typeof contentForPreview === 'string' - ) { - const urls = extractUrlsFromContent(contentForPreview); - await deleteRoteLinkPreviewsByRoteId(id); - if (urls.length > 0 && !hasArticle) { - void parseAndStoreRoteLinkPreviews(id, contentForPreview).catch((error) => { - console.error('mcp_link_preview_update_failed', error); - }); - } - } - - return data; + return await updateUserNote(auth.userId, id, args); } export const noteTools: McpTool[] = [ @@ -155,11 +85,6 @@ export const noteTools: McpTool[] = [ defineMcpTool('notes_delete', async (c, args) => { const auth = requireAuth(c); const id = assertUuid(args.id, 'note_id'); - const data = await deleteRote({ id, authorid: auth.userId }); - await deleteRoteAttachmentsByRoteId(id, auth.userId); - void deleteEmbeddingsForSource('rote', id).catch((error) => { - console.error('mcp_rote_embedding_delete_failed', error); - }); - return data; + return await deleteUserNote(auth.userId, id); }), ]; diff --git a/server/notes/actions.ts b/server/notes/actions.ts new file mode 100644 index 00000000..79843ad0 --- /dev/null +++ b/server/notes/actions.ts @@ -0,0 +1,115 @@ +import { + createRote, + deleteEmbeddingsForSource, + deleteRote, + deleteRoteAttachmentsByRoteId, + deleteRoteLinkPreviewsByRoteId, + editRote, + enqueueEmbeddingJob, + findRoteById, + setNoteArticleId, +} from '../utils/dbMethods'; +import { extractUrlsFromContent, parseAndStoreRoteLinkPreviews } from '../utils/linkPreview'; +import { trackBackgroundTask } from '../utils/backgroundTask'; + +type CreateUserNoteInput = { + content: string; + title?: string; + state?: string; + type?: string; + tags: string[]; + pin?: boolean; + archived?: boolean; + editor?: string; + articleId?: string | null; + articleIds?: string[]; +}; + +type UpdateUserNoteInput = Record & { + articleId?: string | null; + articleIds?: string[]; +}; + +function firstArticleId(input: { articleId?: unknown; articleIds?: unknown }): string | null { + if (typeof input.articleId === 'string') return input.articleId; + if (Array.isArray(input.articleIds) && input.articleIds.length > 0) return input.articleIds[0]; + return null; +} + +export async function createUserNote(userId: string, input: CreateUserNoteInput) { + const result = await createRote({ + content: input.content, + title: input.title || '', + state: input.state || 'private', + type: input.type || 'rote', + tags: input.tags, + pin: !!input.pin, + archived: !!input.archived, + editor: input.editor, + authorid: userId, + }); + + trackBackgroundTask( + enqueueEmbeddingJob('rote', result.id, userId), + 'rote_embedding_enqueue_failed' + ); + + const articleId = firstArticleId(input); + if (articleId) { + await setNoteArticleId(result.id, articleId, userId); + return result; + } + + trackBackgroundTask( + parseAndStoreRoteLinkPreviews(result.id, result.content), + 'link_preview_create_failed' + ); + return result; +} + +export async function updateUserNote(userId: string, id: string, input: UpdateUserNoteInput) { + await editRote({ ...input, id, authorid: userId }); + trackBackgroundTask(enqueueEmbeddingJob('rote', id, userId), 'rote_embedding_enqueue_failed'); + + let articleIdToSet: string | null | undefined; + if ('articleId' in input) { + articleIdToSet = + typeof input.articleId === 'string' ? input.articleId : (input.articleId ?? null); + } else if (Array.isArray(input.articleIds)) { + articleIdToSet = input.articleIds.length > 0 ? input.articleIds[0] : null; + } + + if (articleIdToSet !== undefined) { + await setNoteArticleId(id, articleIdToSet, userId); + } + + const data = await findRoteById(id); + const hasArticle = Boolean(data?.articleId || data?.article); + const contentProvided = Object.prototype.hasOwnProperty.call(input, 'content'); + const contentForPreview = contentProvided ? input.content : data?.content; + + if (hasArticle && articleIdToSet !== undefined) { + await deleteRoteLinkPreviewsByRoteId(id); + } else if ( + (contentProvided || articleIdToSet !== undefined) && + typeof contentForPreview === 'string' + ) { + const urls = extractUrlsFromContent(contentForPreview); + await deleteRoteLinkPreviewsByRoteId(id); + if (urls.length > 0 && !hasArticle) { + trackBackgroundTask( + parseAndStoreRoteLinkPreviews(id, contentForPreview), + 'link_preview_update_failed' + ); + } + } + + return data; +} + +export async function deleteUserNote(userId: string, id: string) { + const data = await deleteRote({ id, authorid: userId }); + await deleteRoteAttachmentsByRoteId(id, userId); + trackBackgroundTask(deleteEmbeddingsForSource('rote', id), 'rote_embedding_delete_failed'); + return data; +} diff --git a/server/utils/backgroundTask.ts b/server/utils/backgroundTask.ts new file mode 100644 index 00000000..d53005ef --- /dev/null +++ b/server/utils/backgroundTask.ts @@ -0,0 +1,7 @@ +export function trackBackgroundTask(task: Promise, code: string) { + void task.catch((error) => { + const warning = error instanceof Error ? error : new Error(String(error)); + warning.name = code; + process.emitWarning(warning); + }); +}