From 6231424e88a423af36c17cfc455bc160903de7e3 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Wed, 6 May 2026 22:41:02 -0400 Subject: [PATCH 1/3] feat: add personal access tokens (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add personal access tokens * feat: replace MCP tokens with OAuth foundation * fix: keep OAuth constants private in auth foundation * fix: clean up mcp oauth branch scope * fix: expose oauth metadata endpoints * fix: trim mcp oauth config to non-default options Drop OIDC scopes, the org-id JWT claim, and the openid-configuration metadata endpoint since the MCP integration is OAuth-only and the org gets resolved server-side. Also remove options that just duplicated better-auth defaults. * fix: drop redundant oauth metadata helpers Remove `session.storeSessionInDatabase: true` since better-auth only enforces it when secondaryStorage is configured. Inline the `getHostedBaseUrlForOAuthMetadata` alias and skip the async `getOAuthServerConfig()` call in the protected-resource metadata handler — the issuer is just `baseURL` without a custom jwt.issuer override. * docs: explain cache headers on mcp metadata response * Use escaped file routes for OAuth metadata * save --- cli-auth.ts | 7 +- drizzle/0012_closed_impossible_man.sql | 89 + drizzle/meta/0012_snapshot.json | 2634 +++++++++++++++++ drizzle/meta/_journal.json | 7 + knip.jsonc | 1 + package.json | 1 + pnpm-lock.yaml | 22 + src/db/better-auth-schema.ts | 162 +- src/lib/auth-client.ts | 2 + src/lib/auth-config.ts | 32 +- src/lib/auth.ts | 5 +- src/lib/oauth-resource.ts | 6 + src/routeTree.gen.ts | 68 + .../oauth-authorization-server.ts | 31 + .../oauth-protected-resource/mcp.ts | 46 + src/routes/_authenticated.oauth-consent.tsx | 73 + src/routes/_authenticated.tsx | 4 +- 17 files changed, 3178 insertions(+), 12 deletions(-) create mode 100644 drizzle/0012_closed_impossible_man.sql create mode 100644 drizzle/meta/0012_snapshot.json create mode 100644 src/lib/oauth-resource.ts create mode 100644 src/routes/[.]well-known/oauth-authorization-server.ts create mode 100644 src/routes/[.]well-known/oauth-protected-resource/mcp.ts create mode 100644 src/routes/_authenticated.oauth-consent.tsx diff --git a/cli-auth.ts b/cli-auth.ts index 5b69641..9948c92 100644 --- a/cli-auth.ts +++ b/cli-auth.ts @@ -1,11 +1,12 @@ import { randomUUID } from "node:crypto"; import { betterAuth } from "better-auth"; -import { baseAuthConfig } from "./src/lib/auth-config"; +import { createBaseAuthConfig } from "./src/lib/auth-config"; const CLI_DEV_BASE_URL = "http://localhost:3000"; +const baseUrl = process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL; export const auth = betterAuth({ - baseURL: process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL, + baseURL: baseUrl, secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(), - ...baseAuthConfig, + ...createBaseAuthConfig(baseUrl), }); diff --git a/drizzle/0012_closed_impossible_man.sql b/drizzle/0012_closed_impossible_man.sql new file mode 100644 index 0000000..a76573b --- /dev/null +++ b/drizzle/0012_closed_impossible_man.sql @@ -0,0 +1,89 @@ +CREATE TABLE `jwks` ( + `id` text PRIMARY KEY NOT NULL, + `public_key` text NOT NULL, + `private_key` text NOT NULL, + `created_at` integer NOT NULL, + `expires_at` integer +); +--> statement-breakpoint +CREATE TABLE `oauth_access_token` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `client_id` text NOT NULL, + `session_id` text, + `user_id` text, + `reference_id` text, + `refresh_id` text, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `scopes` text NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`refresh_id`) REFERENCES `oauth_refresh_token`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_access_token_token_unique` ON `oauth_access_token` (`token`);--> statement-breakpoint +CREATE TABLE `oauth_client` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `client_secret` text, + `disabled` integer DEFAULT false, + `skip_consent` integer, + `enable_end_session` integer, + `subject_type` text, + `scopes` text, + `user_id` text, + `created_at` integer, + `updated_at` integer, + `name` text, + `uri` text, + `icon` text, + `contacts` text, + `tos` text, + `policy` text, + `software_id` text, + `software_version` text, + `software_statement` text, + `redirect_uris` text NOT NULL, + `post_logout_redirect_uris` text, + `token_endpoint_auth_method` text, + `grant_types` text, + `response_types` text, + `public` integer, + `type` text, + `require_pkce` integer, + `reference_id` text, + `metadata` text, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint +CREATE TABLE `oauth_consent` ( + `id` text PRIMARY KEY NOT NULL, + `client_id` text NOT NULL, + `user_id` text, + `reference_id` text, + `scopes` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `oauth_refresh_token` ( + `id` text PRIMARY KEY NOT NULL, + `token` text NOT NULL, + `client_id` text NOT NULL, + `session_id` text, + `user_id` text NOT NULL, + `reference_id` text, + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `revoked` integer, + `auth_time` integer, + `scopes` text NOT NULL, + FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..79080ec --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,2634 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4aecfb5a-9351-40f4-b306-4e65e5d29fe7", + "prevId": "23436739-8687-4515-96f9-26c79db71c35", + "tables": { + "audit_lighthouse_results": { + "name": "audit_lighthouse_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strategy": { + "name": "strategy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "performance_score": { + "name": "performance_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accessibility_score": { + "name": "accessibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_practices_score": { + "name": "best_practices_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_score": { + "name": "seo_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lcp_ms": { + "name": "lcp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cls": { + "name": "cls", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inp_ms": { + "name": "inp_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_size_bytes": { + "name": "payload_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_lighthouse_results_audit_id_idx": { + "name": "audit_lighthouse_results_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_lighthouse_results_audit_id_audits_id_fk": { + "name": "audit_lighthouse_results_audit_id_audits_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_lighthouse_results_page_id_audit_pages_id_fk": { + "name": "audit_lighthouse_results_page_id_audit_pages_id_fk", + "tableFrom": "audit_lighthouse_results", + "tableTo": "audit_pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_pages": { + "name": "audit_pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "audit_id": { + "name": "audit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "robots_meta": { + "name": "robots_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_title": { + "name": "og_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_description": { + "name": "og_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1_count": { + "name": "h1_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h2_count": { + "name": "h2_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h3_count": { + "name": "h3_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h4_count": { + "name": "h4_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h5_count": { + "name": "h5_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "h6_count": { + "name": "h6_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "heading_order_json": { + "name": "heading_order_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_total": { + "name": "images_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_missing_alt": { + "name": "images_missing_alt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "images_json": { + "name": "images_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_link_count": { + "name": "internal_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "external_link_count": { + "name": "external_link_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_structured_data": { + "name": "has_structured_data", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "hreflang_tags_json": { + "name": "hreflang_tags_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_indexable": { + "name": "is_indexable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_pages_audit_id_idx": { + "name": "audit_pages_audit_id_idx", + "columns": [ + "audit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_pages_audit_id_audits_id_fk": { + "name": "audit_pages_audit_id_audits_id_fk", + "tableFrom": "audit_pages", + "tableTo": "audits", + "columnsFrom": [ + "audit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audits": { + "name": "audits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_url": { + "name": "start_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "pages_crawled": { + "name": "pages_crawled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "pages_total": { + "name": "pages_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_total": { + "name": "lighthouse_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_completed": { + "name": "lighthouse_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "lighthouse_failed": { + "name": "lighthouse_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_phase": { + "name": "current_phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'discovery'" + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audits_project_id_idx": { + "name": "audits_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "audits_started_by_user_id_idx": { + "name": "audits_started_by_user_id_idx", + "columns": [ + "started_by_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audits_project_id_projects_id_fk": { + "name": "audits_project_id_projects_id_fk", + "tableFrom": "audits", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "delegated_users": { + "name": "delegated_users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "delegated_users_email_unique": { + "name": "delegated_users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "keyword_metrics": { + "name": "keyword_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competition": { + "name": "competition", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_searches": { + "name": "monthly_searches", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "keyword_metrics_unique_project_keyword_location_language": { + "name": "keyword_metrics_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "keyword_metrics_lookup_idx": { + "name": "keyword_metrics_lookup_idx", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code", + "fetched_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "keyword_metrics_project_id_projects_id_fk": { + "name": "keyword_metrics_project_id_projects_id_fk", + "tableFrom": "keyword_metrics", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_check_runs": { + "name": "rank_check_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "keywords_total": { + "name": "keywords_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "keywords_checked": { + "name": "keywords_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_subset_run": { + "name": "is_subset_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rank_check_runs_config_idx": { + "name": "rank_check_runs_config_idx", + "columns": [ + "config_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_project_idx": { + "name": "rank_check_runs_project_idx", + "columns": [ + "project_id", + "started_at" + ], + "isUnique": false + }, + "rank_check_runs_one_active_per_config_idx": { + "name": "rank_check_runs_one_active_per_config_idx", + "columns": [ + "config_id" + ], + "isUnique": true, + "where": "\"rank_check_runs\".\"status\" IN ('pending', 'running')" + } + }, + "foreignKeys": { + "rank_check_runs_config_id_rank_tracking_configs_id_fk": { + "name": "rank_check_runs_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rank_check_runs_project_id_projects_id_fk": { + "name": "rank_check_runs_project_id_projects_id_fk", + "tableFrom": "rank_check_runs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_snapshots": { + "name": "rank_snapshots", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tracking_keyword_id": { + "name": "tracking_keyword_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "serp_features": { + "name": "serp_features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_snapshots_run_idx": { + "name": "rank_snapshots_run_idx", + "columns": [ + "run_id" + ], + "isUnique": false + }, + "rank_snapshots_keyword_device_idx": { + "name": "rank_snapshots_keyword_device_idx", + "columns": [ + "tracking_keyword_id", + "device", + "checked_at" + ], + "isUnique": false + }, + "rank_snapshots_run_keyword_device_idx": { + "name": "rank_snapshots_run_keyword_device_idx", + "columns": [ + "run_id", + "tracking_keyword_id", + "device" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_snapshots_run_id_rank_check_runs_id_fk": { + "name": "rank_snapshots_run_id_rank_check_runs_id_fk", + "tableFrom": "rank_snapshots", + "tableTo": "rank_check_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_configs": { + "name": "rank_tracking_configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "devices": { + "name": "devices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'both'" + }, + "serp_depth": { + "name": "serp_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_interval": { + "name": "schedule_interval", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'weekly'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_skip_reason": { + "name": "last_skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_configs_project_domain_location_idx": { + "name": "rank_tracking_configs_project_domain_location_idx", + "columns": [ + "project_id", + "domain", + "location_code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_configs_project_id_projects_id_fk": { + "name": "rank_tracking_configs_project_id_projects_id_fk", + "tableFrom": "rank_tracking_configs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rank_tracking_keywords": { + "name": "rank_tracking_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "search_volume": { + "name": "search_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyword_difficulty": { + "name": "keyword_difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpc": { + "name": "cpc", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics_fetched_at": { + "name": "metrics_fetched_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "rank_tracking_keywords_config_keyword_idx": { + "name": "rank_tracking_keywords_config_keyword_idx", + "columns": [ + "config_id", + "keyword" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk": { + "name": "rank_tracking_keywords_config_id_rank_tracking_configs_id_fk", + "tableFrom": "rank_tracking_keywords", + "tableTo": "rank_tracking_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "saved_keywords": { + "name": "saved_keywords", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location_code": { + "name": "location_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2840 + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "saved_keywords_unique_project_keyword_location_language": { + "name": "saved_keywords_unique_project_keyword_location_language", + "columns": [ + "project_id", + "keyword", + "location_code", + "language_code" + ], + "isUnique": true + }, + "saved_keywords_project_created_idx": { + "name": "saved_keywords_project_created_idx", + "columns": [ + "project_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "saved_keywords_project_id_projects_id_fk": { + "name": "saved_keywords_project_id_projects_id_fk", + "tableFrom": "saved_keywords", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jwks": { + "name": "jwks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_token": { + "name": "oauth_access_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contacts": { + "name": "contacts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_types": { + "name": "grant_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_types": { + "name": "response_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token": { + "name": "oauth_refresh_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_time": { + "name": "auth_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "analytics_opted_out": { + "name": "analytics_opted_out", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6c442ba..9b1b6f8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1778031161783, "tag": "0011_colorful_dark_beast", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1778113978173, + "tag": "0012_closed_impossible_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/knip.jsonc b/knip.jsonc index c4f00af..57533b7 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -11,6 +11,7 @@ "drizzle.config.ts", // DB schema — exports consumed via `import * as schema` / drizzle() "src/db/index.ts", + "src/db/app.schema.ts", "src/db/better-auth-schema.ts", ], "project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"], diff --git a/package.json b/package.json index 597e1f9..9918d1f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ } }, "dependencies": { + "@better-auth/oauth-provider": "^1.5.5", "@every-app/sdk": "^0.1.14", "@tanstack/query-core": "^5.90.9", "@tanstack/react-form": "^1.25.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a91b456..50e8c38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@better-auth/oauth-provider': + specifier: ^1.5.5 + version: 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6)) '@every-app/sdk': specifier: ^0.1.14 version: 0.1.14(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(jose@6.1.3)(react@19.2.4) @@ -325,6 +328,15 @@ packages: '@better-auth/utils': ^0.3.0 mongodb: ^6.0.0 || ^7.0.0 + '@better-auth/oauth-provider@1.5.5': + resolution: {integrity: sha512-zH2uKtvd6406MysWCTBldPHTKCXEK8caMrNId03bh4ej4f2vU8+GfNGE+IyxARucHGI1T+Og7QrUgKAeA2jQUQ==} + peerDependencies: + '@better-auth/core': 1.5.5 + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.5.5 + better-call: 1.3.2 + '@better-auth/prisma-adapter@1.5.5': resolution: {integrity: sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg==} peerDependencies: @@ -4216,6 +4228,16 @@ snapshots: '@better-auth/utils': 0.3.1 mongodb: 7.1.0 + '@better-auth/oauth-provider@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))': + dependencies: + '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + better-call: 1.3.2(zod@4.3.6) + jose: 6.1.3 + zod: 4.3.6 + '@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) diff --git a/src/db/better-auth-schema.ts b/src/db/better-auth-schema.ts index 60e8453..8207042 100644 --- a/src/db/better-auth-schema.ts +++ b/src/db/better-auth-schema.ts @@ -151,18 +151,116 @@ export const invitation = sqliteTable( ], ); +export const jwks = sqliteTable("jwks", { + id: text("id").primaryKey(), + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }), +}); + +export const oauthClient = sqliteTable("oauth_client", { + id: text("id").primaryKey(), + clientId: text("client_id").notNull().unique(), + clientSecret: text("client_secret"), + disabled: integer("disabled", { mode: "boolean" }).default(false), + skipConsent: integer("skip_consent", { mode: "boolean" }), + enableEndSession: integer("enable_end_session", { mode: "boolean" }), + subjectType: text("subject_type"), + scopes: text("scopes", { mode: "json" }), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }), + name: text("name"), + uri: text("uri"), + icon: text("icon"), + contacts: text("contacts", { mode: "json" }), + tos: text("tos"), + policy: text("policy"), + softwareId: text("software_id"), + softwareVersion: text("software_version"), + softwareStatement: text("software_statement"), + redirectUris: text("redirect_uris", { mode: "json" }).notNull(), + postLogoutRedirectUris: text("post_logout_redirect_uris", { mode: "json" }), + tokenEndpointAuthMethod: text("token_endpoint_auth_method"), + grantTypes: text("grant_types", { mode: "json" }), + responseTypes: text("response_types", { mode: "json" }), + public: integer("public", { mode: "boolean" }), + type: text("type"), + requirePKCE: integer("require_pkce", { mode: "boolean" }), + referenceId: text("reference_id"), + metadata: text("metadata", { mode: "json" }), +}); + +export const oauthRefreshToken = sqliteTable("oauth_refresh_token", { + id: text("id").primaryKey(), + token: text("token").notNull(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + sessionId: text("session_id").references(() => session.id, { + onDelete: "set null", + }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + referenceId: text("reference_id"), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + revoked: integer("revoked", { mode: "timestamp_ms" }), + authTime: integer("auth_time", { mode: "timestamp_ms" }), + scopes: text("scopes", { mode: "json" }).notNull(), +}); + +export const oauthAccessToken = sqliteTable("oauth_access_token", { + id: text("id").primaryKey(), + token: text("token").notNull().unique(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + sessionId: text("session_id").references(() => session.id, { + onDelete: "set null", + }), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + referenceId: text("reference_id"), + refreshId: text("refresh_id").references(() => oauthRefreshToken.id, { + onDelete: "cascade", + }), + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + scopes: text("scopes", { mode: "json" }).notNull(), +}); + +export const oauthConsent = sqliteTable("oauth_consent", { + id: text("id").primaryKey(), + clientId: text("client_id") + .notNull() + .references(() => oauthClient.clientId, { onDelete: "cascade" }), + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + referenceId: text("reference_id"), + scopes: text("scopes", { mode: "json" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), +}); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), members: many(member), invitations: many(invitation), + oauthClients: many(oauthClient), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), + oauthConsents: many(oauthConsent), })); -export const sessionRelations = relations(session, ({ one }) => ({ +export const sessionRelations = relations(session, ({ one, many }) => ({ user: one(user, { fields: [session.userId], references: [user.id], }), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), })); export const accountRelations = relations(account, ({ one }) => ({ @@ -198,3 +296,65 @@ export const invitationRelations = relations(invitation, ({ one }) => ({ references: [user.id], }), })); + +export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({ + user: one(user, { + fields: [oauthClient.userId], + references: [user.id], + }), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), + oauthConsents: many(oauthConsent), +})); + +export const oauthRefreshTokenRelations = relations( + oauthRefreshToken, + ({ one, many }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthRefreshToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthRefreshToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthRefreshToken.userId], + references: [user.id], + }), + oauthAccessTokens: many(oauthAccessToken), + }), +); + +export const oauthAccessTokenRelations = relations( + oauthAccessToken, + ({ one }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthAccessToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthAccessToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthAccessToken.userId], + references: [user.id], + }), + oauthRefreshToken: one(oauthRefreshToken, { + fields: [oauthAccessToken.refreshId], + references: [oauthRefreshToken.id], + }), + }), +); + +export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthConsent.clientId], + references: [oauthClient.clientId], + }), + user: one(user, { + fields: [oauthConsent.userId], + references: [user.id], + }), +})); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index f30dfb1..c3be5cf 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -3,6 +3,7 @@ import { inferAdditionalFields, organizationClient, } from "better-auth/client/plugins"; +import { oauthProviderClient } from "@better-auth/oauth-provider/client"; import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog"; import { userAdditionalFields } from "@/lib/auth-options"; import { getSignInHrefForLocation } from "@/lib/auth-redirect"; @@ -11,6 +12,7 @@ export const authClient = createAuthClient({ baseURL: typeof window !== "undefined" ? window.location.origin : "", plugins: [ organizationClient(), + oauthProviderClient(), inferAdditionalFields({ user: userAdditionalFields }), ], }); diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts index 68deac0..357fe1b 100644 --- a/src/lib/auth-config.ts +++ b/src/lib/auth-config.ts @@ -1,7 +1,29 @@ -import { organization } from "better-auth/plugins"; +import { oauthProvider } from "@better-auth/oauth-provider"; +import { jwt, organization } from "better-auth/plugins"; import { baseAuthOptions } from "@/lib/auth-options"; +import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource"; -export const baseAuthConfig = { - ...baseAuthOptions, - plugins: [organization()], -}; +export function createBaseAuthConfig(baseUrl: string) { + const mcpResource = getMcpResource(baseUrl); + + return { + ...baseAuthOptions, + plugins: [ + organization(), + jwt(), + oauthProvider({ + loginPage: "/sign-in", + consentPage: "/oauth-consent", + signup: { + page: "/sign-up", + }, + scopes: ["offline_access", MCP_SCOPE], + allowDynamicClientRegistration: true, + // TODO: drop once the MCP spec settles on a replacement for + // unauthenticated DCR — better-auth has flagged this option for removal. + allowUnauthenticatedClientRegistration: true, + validAudiences: [mcpResource], + }), + ], + }; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d60e42b..afecac5 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,7 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; import { db } from "@/db"; import { z } from "zod"; -import { baseAuthConfig } from "@/lib/auth-config"; +import { createBaseAuthConfig } from "@/lib/auth-config"; import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization"; import { sendHostedPasswordResetEmail, @@ -25,6 +25,7 @@ const hostedBaseUrlSchema = z function createAuth() { const baseUrl = getHostedBaseUrl(); const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; + const baseAuthConfig = createBaseAuthConfig(baseUrl); const auth = betterAuth({ baseURL: baseUrl, @@ -102,7 +103,7 @@ function getTrustedOrigins(baseUrl: string) { return trustedOrigins; } -function getHostedBaseUrl() { +export function getHostedBaseUrl() { const baseUrl = env.BETTER_AUTH_URL?.trim(); if (!baseUrl) { diff --git a/src/lib/oauth-resource.ts b/src/lib/oauth-resource.ts new file mode 100644 index 0000000..deeea4c --- /dev/null +++ b/src/lib/oauth-resource.ts @@ -0,0 +1,6 @@ +const MCP_RESOURCE_PATH = "/mcp"; +export const MCP_SCOPE = "mcp"; + +export function getMcpResource(baseUrl: string) { + return new URL(MCP_RESOURCE_PATH, baseUrl).toString(); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 4d96081..169bbfd 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,14 +18,17 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route' import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe' +import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AppSupportRouteImport } from './routes/_app/support' import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppBillingRouteImport } from './routes/_app/billing' +import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' +import { Route as DotwellKnownOauthProtectedResourceMcpRouteImport } from './routes/[.]well-known/oauth-protected-resource/mcp' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' @@ -83,6 +86,12 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({ path: '/subscribe', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedOauthConsentRoute = + AuthenticatedOauthConsentRouteImport.update({ + id: '/oauth-consent', + path: '/oauth-consent', + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthSignUpRoute = AuthSignUpRouteImport.update({ id: '/sign-up', path: '/sign-up', @@ -108,6 +117,12 @@ const AppBillingRoute = AppBillingRouteImport.update({ path: '/billing', getParentRoute: () => AppRouteRoute, } as any) +const DotwellKnownOauthAuthorizationServerRoute = + DotwellKnownOauthAuthorizationServerRouteImport.update({ + id: '/.well-known/oauth-authorization-server', + path: '/.well-known/oauth-authorization-server', + getParentRoute: () => rootRouteImport, + } as any) const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ id: '/api/autumn/$', path: '/api/autumn/$', @@ -123,6 +138,12 @@ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ path: '/help/dataforseo-api-key', getParentRoute: () => AppRouteRoute, } as any) +const DotwellKnownOauthProtectedResourceMcpRoute = + DotwellKnownOauthProtectedResourceMcpRouteImport.update({ + id: '/.well-known/oauth-protected-resource/mcp', + path: '/.well-known/oauth-protected-resource/mcp', + getParentRoute: () => rootRouteImport, + } as any) const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({ id: '/p/$projectId', path: '/p/$projectId', @@ -213,13 +234,16 @@ export interface FileRoutesByFullPath { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute '/billing': typeof AppBillingRoute '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute + '/oauth-consent': typeof AuthenticatedOauthConsentRoute '/subscribe': typeof AuthenticatedSubscribeRoute '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren + '/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute @@ -243,12 +267,15 @@ export interface FileRoutesByTo { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute '/billing': typeof AppBillingRoute '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute '/sign-in': typeof AuthSignInRoute '/sign-up': typeof AuthSignUpRoute + '/oauth-consent': typeof AuthenticatedOauthConsentRoute '/subscribe': typeof AuthenticatedSubscribeRoute + '/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute @@ -274,14 +301,17 @@ export interface FileRoutesById { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute '/_app/billing': typeof AppBillingRoute '/_app/settings': typeof AppSettingsRoute '/_app/support': typeof AppSupportRoute '/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-up': typeof AuthSignUpRoute + '/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/_app/': typeof AppIndexRoute '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren + '/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute @@ -307,13 +337,16 @@ export interface FileRouteTypes { | '/forgot-password' | '/reset-password' | '/verify-email' + | '/.well-known/oauth-authorization-server' | '/billing' | '/settings' | '/support' | '/sign-in' | '/sign-up' + | '/oauth-consent' | '/subscribe' | '/p/$projectId' + | '/.well-known/oauth-protected-resource/mcp' | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' @@ -337,12 +370,15 @@ export interface FileRouteTypes { | '/forgot-password' | '/reset-password' | '/verify-email' + | '/.well-known/oauth-authorization-server' | '/billing' | '/settings' | '/support' | '/sign-in' | '/sign-up' + | '/oauth-consent' | '/subscribe' + | '/.well-known/oauth-protected-resource/mcp' | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' @@ -367,14 +403,17 @@ export interface FileRouteTypes { | '/forgot-password' | '/reset-password' | '/verify-email' + | '/.well-known/oauth-authorization-server' | '/_app/billing' | '/_app/settings' | '/_app/support' | '/_auth/sign-in' | '/_auth/sign-up' + | '/_authenticated/oauth-consent' | '/_authenticated/subscribe' | '/_app/' | '/_project/p/$projectId' + | '/.well-known/oauth-protected-resource/mcp' | '/_app/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' @@ -402,6 +441,8 @@ export interface RootRouteChildren { ForgotPasswordRoute: typeof ForgotPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailRoute: typeof VerifyEmailRoute + DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute + DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute } @@ -471,6 +512,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSubscribeRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/oauth-consent': { + id: '/_authenticated/oauth-consent' + path: '/oauth-consent' + fullPath: '/oauth-consent' + preLoaderRoute: typeof AuthenticatedOauthConsentRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_auth/sign-up': { id: '/_auth/sign-up' path: '/sign-up' @@ -506,6 +554,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppBillingRouteImport parentRoute: typeof AppRouteRoute } + '/.well-known/oauth-authorization-server': { + id: '/.well-known/oauth-authorization-server' + path: '/.well-known/oauth-authorization-server' + fullPath: '/.well-known/oauth-authorization-server' + preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport + parentRoute: typeof rootRouteImport + } '/api/autumn/$': { id: '/api/autumn/$' path: '/api/autumn/$' @@ -527,6 +582,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport parentRoute: typeof AppRouteRoute } + '/.well-known/oauth-protected-resource/mcp': { + id: '/.well-known/oauth-protected-resource/mcp' + path: '/.well-known/oauth-protected-resource/mcp' + fullPath: '/.well-known/oauth-protected-resource/mcp' + preLoaderRoute: typeof DotwellKnownOauthProtectedResourceMcpRouteImport + parentRoute: typeof rootRouteImport + } '/_project/p/$projectId': { id: '/_project/p/$projectId' path: '/p/$projectId' @@ -748,10 +810,12 @@ const AuthRouteChildren: AuthRouteChildren = { const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) interface AuthenticatedRouteChildren { + AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute } const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { + AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute, AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute, } @@ -767,6 +831,10 @@ const rootRouteChildren: RootRouteChildren = { ForgotPasswordRoute: ForgotPasswordRoute, ResetPasswordRoute: ResetPasswordRoute, VerifyEmailRoute: VerifyEmailRoute, + DotwellKnownOauthAuthorizationServerRoute: + DotwellKnownOauthAuthorizationServerRoute, + DotwellKnownOauthProtectedResourceMcpRoute: + DotwellKnownOauthProtectedResourceMcpRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute, } diff --git a/src/routes/[.]well-known/oauth-authorization-server.ts b/src/routes/[.]well-known/oauth-authorization-server.ts new file mode 100644 index 0000000..5d9acd1 --- /dev/null +++ b/src/routes/[.]well-known/oauth-authorization-server.ts @@ -0,0 +1,31 @@ +import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider"; +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; +import { isHostedAuthMode } from "@/lib/auth-mode"; + +function unavailableMetadataResponse() { + if (!isHostedAuthMode(env.AUTH_MODE)) { + return new Response("Not found", { status: 404 }); + } + + return new Response("Missing Better Auth hosted configuration", { + status: 500, + }); +} + +export const Route = createFileRoute("/.well-known/oauth-authorization-server")( + { + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) { + return unavailableMetadataResponse(); + } + + return oauthProviderAuthServerMetadata(getAuth())(request); + }, + }, + }, + }, +); diff --git a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts new file mode 100644 index 0000000..aa3e60b --- /dev/null +++ b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts @@ -0,0 +1,46 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth"; +import { isHostedAuthMode } from "@/lib/auth-mode"; +import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource"; + +function unavailableMetadataResponse() { + if (!isHostedAuthMode(env.AUTH_MODE)) { + return new Response("Not found", { status: 404 }); + } + + return new Response("Missing Better Auth hosted configuration", { + status: 500, + }); +} + +export const Route = createFileRoute( + "/.well-known/oauth-protected-resource/mcp", +)({ + server: { + handlers: { + GET: async () => { + if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) { + return unavailableMetadataResponse(); + } + + const baseUrl = getHostedBaseUrl(); + const authServerMetadata = await getAuth().api.getOAuthServerConfig(); + const metadata = { + resource: getMcpResource(baseUrl), + authorization_servers: [authServerMetadata.issuer], + scopes_supported: [MCP_SCOPE], + resource_name: "OpenSEO MCP", + }; + + return new Response(JSON.stringify(metadata), { + headers: { + "Cache-Control": + "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400", + "Content-Type": "application/json", + }, + }); + }, + }, + }, +}); diff --git a/src/routes/_authenticated.oauth-consent.tsx b/src/routes/_authenticated.oauth-consent.tsx new file mode 100644 index 0000000..c62c8b3 --- /dev/null +++ b/src/routes/_authenticated.oauth-consent.tsx @@ -0,0 +1,73 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ShieldCheck } from "lucide-react"; +import { useState } from "react"; +import { authClient } from "@/lib/auth-client"; + +export const Route = createFileRoute("/_authenticated/oauth-consent")({ + component: OAuthConsentPage, +}); + +function OAuthConsentPage() { + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + async function respond(accept: boolean) { + setError(null); + setIsSubmitting(true); + + const { data, error: consentError } = await authClient.oauth2.consent({ + accept, + }); + + if (consentError) { + setError(consentError.message ?? "Unable to complete authorization."); + setIsSubmitting(false); + return; + } + + if (data?.redirect && data.url) { + window.location.assign(data.url); + return; + } + + setError("Authorization response did not include a redirect URL."); + setIsSubmitting(false); + } + + return ( +
+
+
+ +
+
+

Authorize MCP access

+

+ Allow this MCP client to access your OpenSEO workspace. +

+
+
+ + {error ?

{error}

: null} + +
+ + +
+
+ ); +} diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx index caf92b4..503f6e7 100644 --- a/src/routes/_authenticated.tsx +++ b/src/routes/_authenticated.tsx @@ -18,7 +18,9 @@ function AuthenticatedShellLayout() { if (!session?.user?.id) { void navigate({ to: "/sign-in", - search: { redirect: window.location.pathname }, + search: { + redirect: `${window.location.pathname}${window.location.search}`, + }, }); } }, [isPending, isHostedMode, session?.user?.id, navigate]); From 0ff7bc96b28fd51333284041216c2a6f0c60bdfc Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Thu, 7 May 2026 23:38:00 -0400 Subject: [PATCH 2/3] Add stateless MCP server (#162) --- .github/workflows/ci.yml | 2 - .github/workflows/sourcemaps.yml | 2 - Dockerfile.selfhost | 2 +- package.json | 5 +- pnpm-lock.yaml | 1271 ++++++++++++++++- src/lib/auth-config.ts | 54 +- src/lib/auth.ts | 3 + src/lib/oauth-provider-resource-client.ts | 14 + src/lib/oauth-resource.ts | 16 + src/routeTree.gen.ts | 67 +- .../oauth-authorization-server/api/auth.ts | 31 + .../oauth-protected-resource/mcp.ts | 17 +- .../[.]well-known/openid-configuration.ts | 29 + src/routes/_authenticated.oauth-consent.tsx | 128 +- src/routes/api/auth/$.test.ts | 83 ++ src/routes/api/auth/$.ts | 45 +- src/server.ts | 14 +- src/server/mcp/context.ts | 26 + src/server/mcp/handler.test.ts | 252 ++++ src/server/mcp/handler.ts | 150 ++ src/server/mcp/server.ts | 63 + src/serverFunctions/oauth.ts | 33 + 22 files changed, 2240 insertions(+), 67 deletions(-) create mode 100644 src/lib/oauth-provider-resource-client.ts create mode 100644 src/routes/[.]well-known/oauth-authorization-server/api/auth.ts create mode 100644 src/routes/[.]well-known/openid-configuration.ts create mode 100644 src/routes/api/auth/$.test.ts create mode 100644 src/server/mcp/context.ts create mode 100644 src/server/mcp/handler.test.ts create mode 100644 src/server/mcp/handler.ts create mode 100644 src/server/mcp/server.ts create mode 100644 src/serverFunctions/oauth.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05fb6a7..f25c4ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/sourcemaps.yml b/.github/workflows/sourcemaps.yml index 6e0aa3e..b79dd57 100644 --- a/.github/workflows/sourcemaps.yml +++ b/.github/workflows/sourcemaps.yml @@ -20,8 +20,6 @@ jobs: uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/Dockerfile.selfhost b/Dockerfile.selfhost index 532fde4..bb0b6e7 100644 --- a/Dockerfile.selfhost +++ b/Dockerfile.selfhost @@ -6,7 +6,7 @@ ENV PATH=$PNPM_HOME:$PATH WORKDIR /app -RUN corepack enable +RUN corepack enable && corepack prepare pnpm@10.30.1 --activate COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile diff --git a/package.json b/package.json index 9918d1f..b0ed436 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "sideEffects": false, "version": "0.0.10", "type": "module", + "packageManager": "pnpm@10.30.1", "scripts": { - "dev": "AUTH_MODE=local_noauth vite dev", + "dev": "vite dev", "dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log", "dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log", "build": "vite build && tsc --noEmit", @@ -51,6 +52,7 @@ "dependencies": { "@better-auth/oauth-provider": "^1.5.5", "@every-app/sdk": "^0.1.14", + "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/query-core": "^5.90.9", "@tanstack/react-form": "^1.25.0", "@tanstack/react-query": "^5.90.9", @@ -58,6 +60,7 @@ "@tanstack/react-router-devtools": "^1.166.11", "@tanstack/react-start": "^1.167.16", "@tanstack/react-table": "^8.21.3", + "agents": "0.12.3", "autumn-js": "^1.1.7", "better-auth": "^1.5.5", "cheerio": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50e8c38..6b86017 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,13 @@ importers: dependencies: '@better-auth/oauth-provider': specifier: ^1.5.5 - version: 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6)) + version: 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6)) '@every-app/sdk': specifier: ^0.1.14 version: 0.1.14(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(jose@6.1.3)(react@19.2.4) + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@tanstack/query-core': specifier: ^5.90.9 version: 5.90.20 @@ -35,12 +38,15 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + agents: + specifier: 0.12.3 + version: 0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.176(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6) autumn-js: specifier: ^1.1.7 - version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4) + version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4) better-auth: specifier: ^1.5.5 - version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) cheerio: specifier: ^1.2.0 version: 1.2.0 @@ -85,7 +91,7 @@ importers: version: 10.1.0(@types/react@19.2.14)(react@19.2.4) recharts: specifier: ^3.7.0 - version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1) + version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.6)(react@19.2.4)(redux@5.0.1) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -180,6 +186,22 @@ importers: packages: + '@ai-sdk/gateway@3.0.111': + resolution: {integrity: sha512-gzdRuEH9Mqeuu8zG6j4of3EH3fFJUI0UIubyeaA8gep6KzhCJF7uaTfagSE7x2vLAf381g/NrxsXhhH7Hon9iA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@4.0.27': + resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@3.0.10': + resolution: {integrity: sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==} + engines: {node: '>=18'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -200,14 +222,28 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.29.3': + resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} @@ -218,10 +254,24 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -248,6 +298,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-proposal-decorators@7.29.0': + resolution: {integrity: sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -272,6 +334,14 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime-corejs3@7.29.2': + resolution: {integrity: sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -361,6 +431,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@cloudflare/kv-asset-handler@0.4.2': resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} @@ -420,15 +493,24 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -889,6 +971,12 @@ packages: jose: ^6.0.0 react: ^18.0.0 || ^19.0.0 + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -1122,12 +1210,28 @@ packages: cpu: [x64] os: [win32] - '@mongodb-js/saslprep@1.4.6': - resolution: {integrity: sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mongodb-js/saslprep@1.4.11': + resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} @@ -1171,6 +1275,10 @@ packages: resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -1239,6 +1347,9 @@ packages: resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} + '@oxc-project/types@0.129.0': + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} + '@oxc-resolver/binding-android-arm-eabi@11.18.0': resolution: {integrity: sha512-EhwJNzbfLwQQIeyak3n08EB3UHknMnjy1dFyL98r3xlorje2uzHOT2vkB5nB1zqtTtzT31uSot3oGZFfODbGUg==} cpu: [arm] @@ -1555,6 +1666,121 @@ packages: react-redux: optional: true + '@rolldown/binding-android-arm64@1.0.0': + resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0': + resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0': + resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0': + resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0': + resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0': + resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0': + resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.0': + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2165,6 +2391,10 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2204,6 +2434,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -2213,6 +2447,58 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + agents@0.12.3: + resolution: {integrity: sha512-DZn90m9TEhaVjAb9UAsc5BeBlZRaC0eet18Hz1uMXr+YZdsRrL/RkSSyzf3v3xkBmnBYCCVw7+3/i4bVsYCVRQ==} + hasBin: true + peerDependencies: + '@cloudflare/ai-chat': '>=0.6.1 <1.0.0' + '@cloudflare/codemode': '>=0.3.4 <1.0.0' + '@tanstack/ai': '>=0.10.2 <1.0.0' + '@x402/core': ^2.0.0 + '@x402/evm': ^2.0.0 + ai: ^6.0.0 + react: ^19.0.0 + vite: '>=6.0.0 <9.0.0' + zod: ^4.0.0 + peerDependenciesMeta: + '@cloudflare/ai-chat': + optional: true + '@cloudflare/codemode': + optional: true + '@tanstack/ai': + optional: true + '@x402/core': + optional: true + '@x402/evm': + optional: true + vite: + optional: true + + ai@6.0.176: + resolution: {integrity: sha512-dhxDef3VCIxaFr6tKyG0BrkkCelmnporlen8nHajIwCk7S4PvIaSVI/iyJenhFOZ9KBoKjCAoUs6TzZ3yrSjxw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2346,6 +2632,10 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2365,6 +2655,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2373,6 +2667,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001774: resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} @@ -2414,6 +2712,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + cloudflare@5.2.0: resolution: {integrity: sha512-dVzqDpPFYR9ApEC9e+JJshFJZXcw4HzM8W+3DHzO5oy9+8rLC53G7x6fEf9A7/gSuSCxuvndzui5qJKftfIM9A==} @@ -2428,19 +2730,46 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@2.0.1: resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js-pure@3.49.0: + resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cron-schedule@6.0.0: + resolution: {integrity: sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ==} + engines: {node: '>=20'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2542,6 +2871,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2677,9 +3010,19 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -2748,6 +3091,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -2763,6 +3109,13 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -2770,20 +3123,44 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.1: + resolution: {integrity: sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-xml-builder@1.0.0: resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==} @@ -2821,6 +3198,10 @@ packages: resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} engines: {node: '>=14.16'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} @@ -2841,6 +3222,14 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2853,6 +3242,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2911,12 +3308,20 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -2924,12 +3329,19 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} immer@11.1.4: resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2937,6 +3349,14 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -2969,6 +3389,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isbot@5.1.37: resolution: {integrity: sha512-5bcicX81xf6NlTEV8rWdg7Pk01LFizDetuYGHx6d/f6y3lR2/oo8IfxjzJqn1UdDEyCcwT9e7NRloj8DwCYujQ==} engines: {node: '>=18'} @@ -3001,6 +3424,15 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3179,9 +3611,17 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3278,10 +3718,21 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimetext@3.0.28: + resolution: {integrity: sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==} + miniflare@4.20260219.0: resolution: {integrity: sha512-EIb5wXbWUnnC60XU2aiFOPNd4fgTXzECkwRSOXZ1vdcY9WZaEE9rVf+h+Apw+WkOHRkp3Dr9/ZhQ5y1R+9iZ4Q==} engines: {node: '>=18.0.0'} @@ -3294,8 +3745,8 @@ packages: resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} engines: {node: '>=20.19.0'} - mongodb@7.1.0: - resolution: {integrity: sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==} + mongodb@7.2.0: + resolution: {integrity: sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==} engines: {node: '>=20.19.0'} peerDependencies: '@aws-sdk/credential-providers': ^3.806.0 @@ -3329,10 +3780,19 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + nanostores@1.1.1: resolution: {integrity: sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg==} engines: {node: ^20.0.0 || >=22.0.0} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3361,6 +3821,21 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oxc-resolver@11.18.0: resolution: {integrity: sha512-Fv/b05AfhpYoCDvsog6tgsDm2yIwIeJafpMFLncNwKHRYu+Y1xQu5Q/rgUn7xBfuhNgjtPO7C0jCf7p2fLDj1g==} @@ -3393,6 +3868,23 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + partyserver@0.5.5: + resolution: {integrity: sha512-7zub8oV8Od9dY2aXGrgzhX5GLceaWOg7xB5VWXtDcqt2BWVDIOCAgaF0AmBMSu3AXhJHsFdzPnA8SSZdybXMbQ==} + peerDependencies: + '@cloudflare/workers-types': ^4.20260424.1 + + partysocket@1.1.18: + resolution: {integrity: sha512-SyuvH9VavWOSa14v6dYdp3yfSUDII4BQB1+TkGOFBkjfZKjnDBiba4fhdhwBlqGBkqw4ea3gTA1DYhSffX24Wg==} + peerDependencies: + react: '>=17' + peerDependenciesMeta: + react: + optional: true + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3400,6 +3892,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3426,6 +3921,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + portless@0.5.2: resolution: {integrity: sha512-LnJvnFUduG4QSIDqc4og9WCLRA6L0+btA96nn6icY6cxyEAR44cnWaIxVzmw7y74KQ+R7zJM1HpenankphympA==} engines: {node: '>=20'} @@ -3466,10 +3965,18 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} @@ -3480,13 +3987,21 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: react: ^19.2.4 - react-is@19.2.4: - resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} @@ -3553,6 +4068,10 @@ packages: remeda@2.33.6: resolution: {integrity: sha512-tazDGH7s75kUPGBKLvhgBEHMgW+TdDFhjUAMdQj57IoWz6HsGa5D2RX5yDUz6IIqiRRvZiaEHzCzWdTeixc/Kg==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -3567,6 +4086,11 @@ packages: resolution: {integrity: sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==} engines: {node: '>=10.0.0'} + rolldown@1.0.0: + resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -3581,6 +4105,10 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3599,6 +4127,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.5.2: resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} engines: {node: '>=10'} @@ -3609,9 +4141,16 @@ packages: resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@3.0.1: resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3628,6 +4167,22 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3677,12 +4232,24 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -3746,6 +4313,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -3777,6 +4348,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3820,6 +4395,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin@2.3.11: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} @@ -3835,6 +4414,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3999,6 +4582,13 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -4039,15 +4629,32 @@ packages: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -4059,6 +4666,24 @@ packages: snapshots: + '@ai-sdk/gateway@3.0.111(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) + '@vercel/oidc': 3.2.0 + zod: 4.3.6 + + '@ai-sdk/provider-utils@4.0.27(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.10 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.8 + zod: 4.3.6 + + '@ai-sdk/provider@3.0.10': + dependencies: + json-schema: 0.4.0 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4101,6 +4726,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -4109,8 +4738,28 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -4127,8 +4776,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -4148,6 +4817,20 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/plugin-proposal-decorators@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -4168,6 +4851,12 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime-corejs3@7.29.2': + dependencies: + core-js-pure: 3.49.0 + + '@babel/runtime@7.29.2': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -4222,18 +4911,18 @@ snapshots: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0)': + '@better-auth/mongo-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.2.0)': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - mongodb: 7.1.0 + mongodb: 7.2.0 - '@better-auth/oauth-provider@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))': + '@better-auth/oauth-provider@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) better-call: 1.3.2(zod@4.3.6) jose: 6.1.3 zod: 4.3.6 @@ -4253,6 +4942,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@cfworker/json-schema@4.1.1': {} + '@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/unenv-preset@2.14.0(unenv@2.0.0-rc.24)(workerd@1.20260219.0)': @@ -4297,12 +4988,23 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -4313,6 +5015,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -4553,6 +5260,10 @@ snapshots: jsonc-parser: 3.3.1 react: 19.2.4 + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -4735,7 +5446,31 @@ snapshots: '@libsql/win32-x64-msvc@0.5.22': optional: true - '@mongodb-js/saslprep@1.4.6': + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.18) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.1(express@5.2.1) + hono: 4.12.18 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + + '@mongodb-js/saslprep@1.4.11': dependencies: sparse-bitfield: 3.0.3 @@ -4746,6 +5481,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@neon-rs/load@0.0.4': {} '@noble/ciphers@2.1.1': {} @@ -4785,6 +5527,8 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': @@ -4857,6 +5601,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.40.0': {} + '@oxc-project/types@0.129.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.18.0': optional: true @@ -5047,6 +5793,66 @@ snapshots: react: 19.2.4 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + '@rolldown/binding-android-arm64@1.0.0': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0': + optional: true + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + picomatch: 4.0.4 + rolldown: 1.0.0 + optionalDependencies: + '@babel/runtime': 7.29.2 + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + + '@rolldown/pluginutils@1.0.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.40': {} @@ -5664,6 +6470,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -5722,12 +6530,65 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn@8.16.0: {} agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 + agents@0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.176(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6): + dependencies: + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) + '@cfworker/json-schema': 4.1.1 + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + ai: 6.0.176(zod@4.3.6) + cron-schedule: 6.0.0 + mimetext: 3.0.28 + nanoid: 5.1.11 + partyserver: 0.5.5(@cloudflare/workers-types@4.20260302.0) + partysocket: 1.1.18(react@19.2.4) + react: 19.2.4 + yargs: 18.0.0 + zod: 4.3.6 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + transitivePeerDependencies: + - '@babel/core' + - '@babel/plugin-transform-runtime' + - '@babel/runtime' + - '@cloudflare/workers-types' + - rolldown + - supports-color + + ai@6.0.176(zod@4.3.6): + dependencies: + '@ai-sdk/gateway': 3.0.111(zod@4.3.6) + '@ai-sdk/provider': 3.0.10 + '@ai-sdk/provider-utils': 4.0.27(zod@4.3.6) + '@opentelemetry/api': 1.9.0 + zod: 4.3.6 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + ansis@4.2.0: {} anymatch@3.1.3: @@ -5745,14 +6606,16 @@ snapshots: asynckit@0.4.0: {} - autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(react@19.2.4): + autumn-js@1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4): dependencies: query-string: 9.3.1 rou3: 0.6.3 zod: 4.3.6 optionalDependencies: - better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) better-call: 1.3.2(zod@4.3.6) + express: 5.2.1 + hono: 4.12.18 react: 19.2.4 babel-dead-code-elimination@1.0.12: @@ -5768,13 +6631,13 @@ snapshots: baseline-browser-mapping@2.10.0: {} - better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)): + better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1) '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12)) '@better-auth/kysely-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.12) '@better-auth/memory-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.1.0) + '@better-auth/mongo-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(mongodb@7.2.0) '@better-auth/prisma-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1) '@better-auth/telemetry': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)) '@better-auth/utils': 0.3.1 @@ -5791,7 +6654,7 @@ snapshots: '@tanstack/react-start': 1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) drizzle-kit: 0.31.9 drizzle-orm: 0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12) - mongodb: 7.1.0 + mongodb: 7.2.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) solid-js: 1.9.11 @@ -5812,6 +6675,20 @@ snapshots: blake3-wasm@2.1.5: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} braces@3.0.3: @@ -5830,6 +6707,8 @@ snapshots: buffer-from@1.1.2: {} + bytes@3.1.2: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -5837,6 +6716,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001774: {} ccount@2.0.1: {} @@ -5896,6 +6780,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + cloudflare@5.2.0: dependencies: '@types/node': 18.19.130 @@ -5916,14 +6806,31 @@ snapshots: comma-separated-tokens@2.0.3: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} cookie-es@2.0.1: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} + core-js-pure@3.49.0: {} + core-js@3.49.0: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cron-schedule@6.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6006,6 +6913,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.0.2: {} @@ -6062,8 +6971,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ee-first@1.1.1: {} + electron-to-chromium@1.5.302: {} + emoji-regex@10.6.0: {} + + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -6193,6 +7108,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} esprima@4.0.1: {} @@ -6203,16 +7120,66 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + event-target-polyfill@0.0.4: {} + event-target-shim@5.0.1: {} eventemitter3@5.0.4: {} + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + expect-type@1.3.0: {} + express-rate-limit@8.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6221,6 +7188,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.2: {} + fast-xml-builder@1.0.0: {} fast-xml-parser@5.4.1: @@ -6253,6 +7222,17 @@ snapshots: filter-obj@5.1.0: {} + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + form-data-encoder@1.7.2: {} form-data@4.0.5: @@ -6276,6 +7256,10 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -6283,6 +7267,10 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6358,6 +7346,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hono@4.12.18: {} + html-url-attributes@3.0.1: {} htmlparser2@10.1.0: @@ -6367,6 +7357,14 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -6375,14 +7373,24 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + immer@10.2.0: {} immer@11.1.4: {} + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} internmap@2.0.3: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -6408,6 +7416,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + isbot@5.1.37: {} isexe@2.0.0: {} @@ -6428,6 +7438,12 @@ snapshots: jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-schema@0.4.0: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -6697,8 +7713,12 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + media-typer@1.1.0: {} + memory-pager@1.5.0: {} + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.3: @@ -6899,10 +7919,23 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimetext@3.0.28: + dependencies: + '@babel/runtime': 7.29.2 + '@babel/runtime-corejs3': 7.29.2 + js-base64: 3.7.8 + mime-types: 2.1.35 + miniflare@4.20260219.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -6922,9 +7955,9 @@ snapshots: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 - mongodb@7.1.0: + mongodb@7.2.0: dependencies: - '@mongodb-js/saslprep': 1.4.6 + '@mongodb-js/saslprep': 1.4.11 bson: 7.2.0 mongodb-connection-string-url: 7.0.1 @@ -6932,8 +7965,12 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.11: {} + nanostores@1.1.1: {} + negotiator@1.0.0: {} + node-domexception@1.0.0: {} node-fetch@2.7.0: @@ -6954,6 +7991,18 @@ snapshots: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + oxc-resolver@11.18.0: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.18.0 @@ -7034,10 +8083,25 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + + partyserver@0.5.5(@cloudflare/workers-types@4.20260302.0): + dependencies: + '@cloudflare/workers-types': 4.20260302.0 + nanoid: 5.1.11 + + partysocket@1.1.18(react@19.2.4): + dependencies: + event-target-polyfill: 0.0.4 + optionalDependencies: + react: 19.2.4 + path-key@3.1.1: {} path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -7052,6 +8116,8 @@ snapshots: picomatch@4.0.4: {} + pkce-challenge@5.0.1: {} + portless@0.5.2: dependencies: chalk: 5.6.2 @@ -7105,8 +8171,17 @@ snapshots: '@types/node': 22.19.11 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + query-selector-shadow-dom@1.0.1: {} query-string@9.3.1: @@ -7117,12 +8192,21 @@ snapshots: queue-microtask@1.2.3: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 scheduler: 0.27.0 - react-is@19.2.4: {} + react-is@19.2.6: {} react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): dependencies: @@ -7167,7 +8251,7 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1): + recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.6)(react@19.2.4)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) clsx: 2.1.1 @@ -7177,7 +8261,7 @@ snapshots: immer: 10.2.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-is: 19.2.4 + react-is: 19.2.6 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 @@ -7229,6 +8313,8 @@ snapshots: remeda@2.33.6: {} + require-from-string@2.0.2: {} + reselect@5.1.1: {} resolve-pkg-maps@1.0.0: {} @@ -7237,6 +8323,27 @@ snapshots: robots-parser@3.0.1: {} + rolldown@1.0.0: + dependencies: + '@oxc-project/types': 0.129.0 + '@rolldown/pluginutils': 1.0.0 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0 + '@rolldown/binding-darwin-arm64': 1.0.0 + '@rolldown/binding-darwin-x64': 1.0.0 + '@rolldown/binding-freebsd-x64': 1.0.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0 + '@rolldown/binding-linux-arm64-gnu': 1.0.0 + '@rolldown/binding-linux-arm64-musl': 1.0.0 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0 + '@rolldown/binding-linux-s390x-gnu': 1.0.0 + '@rolldown/binding-linux-x64-gnu': 1.0.0 + '@rolldown/binding-linux-x64-musl': 1.0.0 + '@rolldown/binding-openharmony-arm64': 1.0.0 + '@rolldown/binding-wasm32-wasi': 1.0.0 + '@rolldown/binding-win32-arm64-msvc': 1.0.0 + '@rolldown/binding-win32-x64-msvc': 1.0.0 + rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -7274,6 +8381,16 @@ snapshots: rou3@0.8.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -7286,14 +8403,41 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + seroval-plugins@1.5.2(seroval@1.5.2): dependencies: seroval: 1.5.2 seroval@1.5.2: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@3.0.1: {} + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -7333,6 +8477,34 @@ snapshots: shell-quote@1.8.3: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} smol-toml@1.6.0: {} @@ -7371,13 +8543,25 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} strip-literal@3.1.0: @@ -7427,6 +8611,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tr46@0.0.3: {} tr46@5.1.1: @@ -7450,6 +8636,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.3: {} @@ -7499,6 +8691,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 @@ -7516,6 +8710,8 @@ snapshots: dependencies: react: 19.2.4 + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -7700,6 +8896,14 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + ws@8.18.0: {} ws@8.19.0: {} @@ -7713,8 +8917,21 @@ snapshots: '@oozcitak/util': 10.0.0 js-yaml: 4.1.1 + y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 @@ -7728,6 +8945,10 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zod-to-json-schema@3.25.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.25.76: {} zod@4.3.6: {} diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts index 357fe1b..5ee7f49 100644 --- a/src/lib/auth-config.ts +++ b/src/lib/auth-config.ts @@ -1,10 +1,29 @@ import { oauthProvider } from "@better-auth/oauth-provider"; import { jwt, organization } from "better-auth/plugins"; import { baseAuthOptions } from "@/lib/auth-options"; -import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource"; +import { getActiveOrganizationId } from "@/lib/auth-session"; +import { + getMcpOrganizationIdClaim, + getMcpResource, + MCP_SCOPE, +} from "@/lib/oauth-resource"; + +const MCP_OAUTH_SCOPES = ["offline_access", MCP_SCOPE]; + +function assertSingleMcpAudience(audiences: string[]) { + if (audiences.length !== 1) { + throw new Error( + "MCP OAuth resource injection requires exactly one valid audience", + ); + } +} export function createBaseAuthConfig(baseUrl: string) { const mcpResource = getMcpResource(baseUrl); + const mcpOrganizationIdClaim = getMcpOrganizationIdClaim(baseUrl); + const validAudiences = [mcpResource]; + + assertSingleMcpAudience(validAudiences); return { ...baseAuthOptions, @@ -17,12 +36,41 @@ export function createBaseAuthConfig(baseUrl: string) { signup: { page: "/sign-up", }, - scopes: ["offline_access", MCP_SCOPE], + scopes: MCP_OAUTH_SCOPES, + // We publish /.well-known/oauth-authorization-server/api/auth via + // TanStack routes, so silence Better Auth's metadata reminder. + silenceWarnings: { + oauthAuthServerConfig: true, + }, allowDynamicClientRegistration: true, + clientRegistrationDefaultScopes: MCP_OAUTH_SCOPES, + clientRegistrationAllowedScopes: MCP_OAUTH_SCOPES, // TODO: drop once the MCP spec settles on a replacement for // unauthenticated DCR — better-auth has flagged this option for removal. allowUnauthenticatedClientRegistration: true, - validAudiences: [mcpResource], + // Single allowed audience — see `routes/api/auth/$.ts`, which defaults + // missing `resource` on /oauth2/token to this value. Adding a second + // audience here would make that injection unsafe (we'd no longer know + // which to pick) and require scope-conditional logic in the route. + validAudiences, + postLogin: { + page: "/oauth-consent", + shouldRedirect: () => false, + consentReferenceId: ({ session, scopes }) => { + if (!scopes.includes(MCP_SCOPE)) { + return undefined; + } + + return getActiveOrganizationId({ session }) ?? undefined; + }, + }, + customAccessTokenClaims: ({ referenceId, scopes }) => { + if (!scopes.includes(MCP_SCOPE)) { + return {}; + } + + return referenceId ? { [mcpOrganizationIdClaim]: referenceId } : {}; + }, }), ], }; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index afecac5..77cda79 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -30,6 +30,9 @@ function createAuth() { const auth = betterAuth({ baseURL: baseUrl, secret: getHostedSecret(), + // Disable Better Auth's generic /token endpoint so OAuth access tokens only + // flow through /oauth2/token, where the MCP resource shim can run. + disabledPaths: ["/token"], ...baseAuthConfig, emailAndPassword: { ...baseAuthConfig.emailAndPassword, diff --git a/src/lib/oauth-provider-resource-client.ts b/src/lib/oauth-provider-resource-client.ts new file mode 100644 index 0000000..8ab19ee --- /dev/null +++ b/src/lib/oauth-provider-resource-client.ts @@ -0,0 +1,14 @@ +import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; +import { getAuth } from "@/lib/auth"; + +type ResourceClientAuth = Parameters[0]; + +export function getOAuthProviderResourceActions() { + // Better Auth documents passing the server auth instance here, but the + // resource-client package currently types the generic too narrowly for the + // concrete `betterAuth(...)` return type. + return oauthProviderResourceClient( + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + getAuth() as unknown as ResourceClientAuth, + ).getActions(); +} diff --git a/src/lib/oauth-resource.ts b/src/lib/oauth-resource.ts index deeea4c..9ae51c0 100644 --- a/src/lib/oauth-resource.ts +++ b/src/lib/oauth-resource.ts @@ -4,3 +4,19 @@ export const MCP_SCOPE = "mcp"; export function getMcpResource(baseUrl: string) { return new URL(MCP_RESOURCE_PATH, baseUrl).toString(); } + +export function getMcpOrganizationIdClaim(baseUrl: string) { + return new URL( + `${MCP_RESOURCE_PATH}/claims/organization-id`, + baseUrl, + ).toString(); +} + +export function getMcpProtectedResourceMetadataUrl(resource: string) { + const url = new URL(resource); + const pathname = url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + + return `${url.origin}/.well-known/oauth-protected-resource${pathname}`; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 169bbfd..7ac9750 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AppSupportRouteImport } from './routes/_app/support' import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppBillingRouteImport } from './routes/_app/billing' +import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration' import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -40,6 +41,7 @@ import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_proj import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks' import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit' import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai' +import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.]well-known/oauth-authorization-server/api/auth' import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index' import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index' import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId' @@ -117,6 +119,12 @@ const AppBillingRoute = AppBillingRouteImport.update({ path: '/billing', getParentRoute: () => AppRouteRoute, } as any) +const DotwellKnownOpenidConfigurationRoute = + DotwellKnownOpenidConfigurationRouteImport.update({ + id: '/.well-known/openid-configuration', + path: '/.well-known/openid-configuration', + getParentRoute: () => rootRouteImport, + } as any) const DotwellKnownOauthAuthorizationServerRoute = DotwellKnownOauthAuthorizationServerRouteImport.update({ id: '/.well-known/oauth-authorization-server', @@ -204,6 +212,12 @@ const ProjectPProjectIdAiRoute = ProjectPProjectIdAiRouteImport.update({ path: '/ai', getParentRoute: () => ProjectPProjectIdRouteRoute, } as any) +const DotwellKnownOauthAuthorizationServerApiAuthRoute = + DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({ + id: '/api/auth', + path: '/api/auth', + getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute, + } as any) const ProjectPProjectIdRankTrackingIndexRoute = ProjectPProjectIdRankTrackingIndexRouteImport.update({ id: '/', @@ -234,7 +248,8 @@ export interface FileRoutesByFullPath { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute - '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/billing': typeof AppBillingRoute '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute @@ -247,6 +262,7 @@ export interface FileRoutesByFullPath { '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute + '/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute @@ -267,7 +283,8 @@ export interface FileRoutesByTo { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute - '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/billing': typeof AppBillingRoute '/settings': typeof AppSettingsRoute '/support': typeof AppSupportRoute @@ -279,6 +296,7 @@ export interface FileRoutesByTo { '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute + '/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute @@ -301,7 +319,8 @@ export interface FileRoutesById { '/forgot-password': typeof ForgotPasswordRoute '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute - '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute + '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/_app/billing': typeof AppBillingRoute '/_app/settings': typeof AppSettingsRoute '/_app/support': typeof AppSupportRoute @@ -315,6 +334,7 @@ export interface FileRoutesById { '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute + '/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute '/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute @@ -338,6 +358,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/.well-known/oauth-authorization-server' + | '/.well-known/openid-configuration' | '/billing' | '/settings' | '/support' @@ -350,6 +371,7 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' + | '/.well-known/oauth-authorization-server/api/auth' | '/p/$projectId/ai' | '/p/$projectId/audit' | '/p/$projectId/backlinks' @@ -371,6 +393,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/.well-known/oauth-authorization-server' + | '/.well-known/openid-configuration' | '/billing' | '/settings' | '/support' @@ -382,6 +405,7 @@ export interface FileRouteTypes { | '/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' + | '/.well-known/oauth-authorization-server/api/auth' | '/p/$projectId/ai' | '/p/$projectId/backlinks' | '/p/$projectId/brand-lookup' @@ -404,6 +428,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/.well-known/oauth-authorization-server' + | '/.well-known/openid-configuration' | '/_app/billing' | '/_app/settings' | '/_app/support' @@ -417,6 +442,7 @@ export interface FileRouteTypes { | '/_app/help/dataforseo-api-key' | '/api/auth/$' | '/api/autumn/$' + | '/.well-known/oauth-authorization-server/api/auth' | '/_project/p/$projectId/ai' | '/_project/p/$projectId/audit' | '/_project/p/$projectId/backlinks' @@ -441,7 +467,8 @@ export interface RootRouteChildren { ForgotPasswordRoute: typeof ForgotPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailRoute: typeof VerifyEmailRoute - DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute + DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute @@ -554,6 +581,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppBillingRouteImport parentRoute: typeof AppRouteRoute } + '/.well-known/openid-configuration': { + id: '/.well-known/openid-configuration' + path: '/.well-known/openid-configuration' + fullPath: '/.well-known/openid-configuration' + preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport + parentRoute: typeof rootRouteImport + } '/.well-known/oauth-authorization-server': { id: '/.well-known/oauth-authorization-server' path: '/.well-known/oauth-authorization-server' @@ -666,6 +700,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectPProjectIdAiRouteImport parentRoute: typeof ProjectPProjectIdRouteRoute } + '/.well-known/oauth-authorization-server/api/auth': { + id: '/.well-known/oauth-authorization-server/api/auth' + path: '/api/auth' + fullPath: '/.well-known/oauth-authorization-server/api/auth' + preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRouteImport + parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute + } '/_project/p/$projectId/rank-tracking/': { id: '/_project/p/$projectId/rank-tracking/' path: '/' @@ -823,6 +864,21 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( AuthenticatedRouteChildren, ) +interface DotwellKnownOauthAuthorizationServerRouteChildren { + DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute +} + +const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren = + { + DotwellKnownOauthAuthorizationServerApiAuthRoute: + DotwellKnownOauthAuthorizationServerApiAuthRoute, + } + +const DotwellKnownOauthAuthorizationServerRouteWithChildren = + DotwellKnownOauthAuthorizationServerRoute._addFileChildren( + DotwellKnownOauthAuthorizationServerRouteChildren, + ) + const rootRouteChildren: RootRouteChildren = { AppRouteRoute: AppRouteRouteWithChildren, ProjectRouteRoute: ProjectRouteRouteWithChildren, @@ -832,7 +888,8 @@ const rootRouteChildren: RootRouteChildren = { ResetPasswordRoute: ResetPasswordRoute, VerifyEmailRoute: VerifyEmailRoute, DotwellKnownOauthAuthorizationServerRoute: - DotwellKnownOauthAuthorizationServerRoute, + DotwellKnownOauthAuthorizationServerRouteWithChildren, + DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute, DotwellKnownOauthProtectedResourceMcpRoute: DotwellKnownOauthProtectedResourceMcpRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, diff --git a/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts b/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts new file mode 100644 index 0000000..0b6dc2f --- /dev/null +++ b/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts @@ -0,0 +1,31 @@ +import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider"; +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; +import { isHostedAuthMode } from "@/lib/auth-mode"; + +function unavailableMetadataResponse() { + if (!isHostedAuthMode(env.AUTH_MODE)) { + return new Response("Not found", { status: 404 }); + } + + return new Response("Missing Better Auth hosted configuration", { + status: 500, + }); +} + +export const Route = createFileRoute( + "/.well-known/oauth-authorization-server/api/auth", +)({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) { + return unavailableMetadataResponse(); + } + + return oauthProviderAuthServerMetadata(getAuth())(request); + }, + }, + }, +}); diff --git a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts index aa3e60b..c992b91 100644 --- a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts +++ b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts @@ -1,7 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { env } from "cloudflare:workers"; -import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth"; +import { getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth"; import { isHostedAuthMode } from "@/lib/auth-mode"; +import { getOAuthProviderResourceActions } from "@/lib/oauth-provider-resource-client"; import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource"; function unavailableMetadataResponse() { @@ -25,13 +26,13 @@ export const Route = createFileRoute( } const baseUrl = getHostedBaseUrl(); - const authServerMetadata = await getAuth().api.getOAuthServerConfig(); - const metadata = { - resource: getMcpResource(baseUrl), - authorization_servers: [authServerMetadata.issuer], - scopes_supported: [MCP_SCOPE], - resource_name: "OpenSEO MCP", - }; + const metadata = + await getOAuthProviderResourceActions().getProtectedResourceMetadata({ + resource: getMcpResource(baseUrl), + authorization_servers: [`${baseUrl}/api/auth`], + scopes_supported: [MCP_SCOPE], + resource_name: "OpenSEO MCP", + }); return new Response(JSON.stringify(metadata), { headers: { diff --git a/src/routes/[.]well-known/openid-configuration.ts b/src/routes/[.]well-known/openid-configuration.ts new file mode 100644 index 0000000..84c10aa --- /dev/null +++ b/src/routes/[.]well-known/openid-configuration.ts @@ -0,0 +1,29 @@ +import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider"; +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; +import { isHostedAuthMode } from "@/lib/auth-mode"; + +function unavailableMetadataResponse() { + if (!isHostedAuthMode(env.AUTH_MODE)) { + return new Response("Not found", { status: 404 }); + } + + return new Response("Missing Better Auth hosted configuration", { + status: 500, + }); +} + +export const Route = createFileRoute("/.well-known/openid-configuration")({ + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) { + return unavailableMetadataResponse(); + } + + return oauthProviderOpenIdConfigMetadata(getAuth())(request); + }, + }, + }, +}); diff --git a/src/routes/_authenticated.oauth-consent.tsx b/src/routes/_authenticated.oauth-consent.tsx index c62c8b3..2c8e16b 100644 --- a/src/routes/_authenticated.oauth-consent.tsx +++ b/src/routes/_authenticated.oauth-consent.tsx @@ -1,16 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { ShieldCheck } from "lucide-react"; +import { Check, Database, KeyRound, User } from "lucide-react"; import { useState } from "react"; -import { authClient } from "@/lib/auth-client"; +import { authClient, useSession } from "@/lib/auth-client"; +import { getOAuthClientInfo } from "@/serverFunctions/oauth"; export const Route = createFileRoute("/_authenticated/oauth-consent")({ component: OAuthConsentPage, }); +const SCOPES = [ + { + icon: Database, + label: "Read your OpenSEO data", + description: "Projects, keyword reports, and audit results.", + }, + { + icon: KeyRound, + label: "Act on your behalf via MCP", + description: "Run tools and write results back to your workspace.", + }, +]; + function OAuthConsentPage() { + const { data: session } = useSession(); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const clientId = + typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("client_id") + : null; + + const clientInfoQuery = useQuery({ + queryKey: ["oauth-client-info", clientId], + queryFn: () => + clientId + ? getOAuthClientInfo({ data: { clientId } }) + : Promise.resolve(null), + enabled: Boolean(clientId), + staleTime: 60_000, + }); + + const clientName = clientInfoQuery.data?.name ?? null; + const userEmail = session?.user?.email ?? null; + const isLoadingClient = clientInfoQuery.isLoading; + const named = Boolean(clientName); + async function respond(accept: boolean) { setError(null); setIsSubmitting(true); @@ -35,29 +71,85 @@ function OAuthConsentPage() { } return ( -
-
-
- -
-
-

Authorize MCP access

-

- Allow this MCP client to access your OpenSEO workspace. -

-
+
+
+ OpenSEO + {isLoadingClient ? ( +
+ ) : ( +

+ {named ? ( + <> + Authorize {clientName} + + ) : ( + "Authorize MCP access" + )} +

+ )} +

+ {named + ? `${clientName} is requesting access to your OpenSEO workspace.` + : "An MCP client is requesting access to your OpenSEO workspace."} +

- {error ?

{error}

: null} + {!named && !isLoadingClient ? ( +
+ This client did not provide a name during registration. Only continue + if you started this connection yourself. +
+ ) : null} -
+ {userEmail ? ( +
+
+ +
+
+
Signed in as
+
{userEmail}
+
+
+ ) : null} + +
+
+ {named ? `This will allow ${clientName} to` : "This will allow it to"} +
+
    + {SCOPES.map((scope) => ( +
  • + +
    +
    {scope.label}
    +
    + {scope.description} +
    +
    +
  • + ))} +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ +

+ You can revoke access at any time in Settings. +

); } diff --git a/src/routes/api/auth/$.test.ts b/src/routes/api/auth/$.test.ts new file mode 100644 index 0000000..af9e4e4 --- /dev/null +++ b/src/routes/api/auth/$.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("cloudflare:workers", () => ({ + env: { + AUTH_MODE: "hosted", + }, +})); + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (routeConfig: unknown) => routeConfig, +})); + +vi.mock("@/lib/auth", () => ({ + getAuth: () => ({ handler: vi.fn() }), + getHostedBaseUrl: () => "https://open-seo.test", + hasHostedAuthConfig: () => true, +})); + +describe("maybeInjectMcpResource", () => { + it("injects the MCP resource into form token requests when missing", async () => { + const { maybeInjectMcpResource } = await import("@/routes/api/auth/$"); + const request = new Request("https://open-seo.test/api/auth/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: "code_123", + }), + }); + + const result = await maybeInjectMcpResource(request); + const params = new URLSearchParams(await result.text()); + + expect(params.get("resource")).toBe("https://open-seo.test/mcp"); + expect(params.get("grant_type")).toBe("authorization_code"); + expect(params.get("code")).toBe("code_123"); + }); + + it("leaves token requests alone when a resource is already present", async () => { + const { maybeInjectMcpResource } = await import("@/routes/api/auth/$"); + const request = new Request("https://open-seo.test/api/auth/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + resource: "https://other-resource.test/mcp", + }), + }); + + await expect(maybeInjectMcpResource(request)).resolves.toBe(request); + }); + + it("skips requests that are not matching form POST token requests", async () => { + const { maybeInjectMcpResource } = await import("@/routes/api/auth/$"); + const requests = [ + new Request("https://open-seo.test/api/auth/oauth2/token", { + method: "GET", + }), + new Request("https://open-seo.test/api/auth/oauth2/authorize", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ grant_type: "authorization_code" }), + }), + new Request("https://open-seo.test/api/auth/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ grant_type: "authorization_code" }), + }), + ]; + + for (const request of requests) { + await expect(maybeInjectMcpResource(request)).resolves.toBe(request); + } + }); +}); diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts index c59b28b..0cdebeb 100644 --- a/src/routes/api/auth/$.ts +++ b/src/routes/api/auth/$.ts @@ -1,9 +1,48 @@ import { createFileRoute } from "@tanstack/react-router"; import { env } from "cloudflare:workers"; -import { getAuth, hasHostedAuthConfig } from "@/lib/auth"; +import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth"; import { isHostedAuthMode } from "@/lib/auth-mode"; +import { getMcpResource } from "@/lib/oauth-resource"; -function handleAuthRequest(request: Request) { +const TOKEN_PATH = "/api/auth/oauth2/token"; + +// Inject RFC 8707 `resource` into /oauth2/token requests when the client +// omitted it. Some MCP clients (notably codex as of 2026-05) skip the +// resource indicator, which makes better-auth issue an opaque access token +// (see `checkResource` in @better-auth/oauth-provider — audience comes from +// `ctx.body.resource` at token-issuance time, not from the stored authorize +// query). Without an audience to bind, no `aud` claim → opaque token → no +// local JWT verify on the resource side. +// +// We only have one valid audience (`validAudiences: [mcpResource]` in +// auth-config.ts), so it is safe to default missing resources to it. Remove +// this shim once MCP clients reliably pass `resource` per spec. +export async function maybeInjectMcpResource( + request: Request, +): Promise { + if (request.method !== "POST") return request; + + const url = new URL(request.url); + if (url.pathname !== TOKEN_PATH) return request; + + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.includes("application/x-www-form-urlencoded")) + return request; + + const body = await request.clone().text(); + const params = new URLSearchParams(body); + if (params.has("resource")) return request; + + params.set("resource", getMcpResource(getHostedBaseUrl())); + + return new Request(request.url, { + method: request.method, + headers: request.headers, + body: params.toString(), + }); +} + +async function handleAuthRequest(request: Request) { if (!isHostedAuthMode(env.AUTH_MODE)) { return new Response("Not found", { status: 404, @@ -17,7 +56,7 @@ function handleAuthRequest(request: Request) { } const auth = getAuth(); - return auth.handler(request); + return auth.handler(await maybeInjectMcpResource(request)); } export const Route = createFileRoute("/api/auth/$")({ diff --git a/src/server.ts b/src/server.ts index b5cde11..303ec8f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,8 +7,20 @@ import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rank import { customerHasPaidPlan } from "@/server/billing/subscription"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { computeNextCheckAt } from "@/shared/rank-tracking"; +import { handleMcpRequest, MCP_ROUTE } from "@/server/mcp/handler"; -const fetch = createStartHandler(defaultStreamHandler); +const appFetch = createStartHandler(defaultStreamHandler); +const fetch = ( + request: Request, + env: Env, + ctx: ExecutionContext, +): Response | Promise => { + if (new URL(request.url).pathname === MCP_ROUTE) { + return handleMcpRequest(request, env, ctx); + } + + return appFetch(request); +}; // Export Workflow classes as named exports export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow"; diff --git a/src/server/mcp/context.ts b/src/server/mcp/context.ts new file mode 100644 index 0000000..1a1f669 --- /dev/null +++ b/src/server/mcp/context.ts @@ -0,0 +1,26 @@ +import { getMcpAuthContext } from "agents/mcp"; +import { z } from "zod"; + +export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth"; + +const mcpToolAuthContextSchema = z.object({ + userId: z.string().min(1), + organizationId: z.string().min(1), + clientId: z.string().nullable(), + scopes: z.array(z.string()), + audience: z.string().min(1), + subject: z.string().min(1), +}); + +type McpToolAuthContext = z.infer; + +export function requireMcpToolAuthContext(): McpToolAuthContext { + const rawContext = getMcpAuthContext()?.props[MCP_AUTH_CONTEXT_PROP]; + const result = mcpToolAuthContextSchema.safeParse(rawContext); + + if (!result.success) { + throw new Error(`MCP auth context missing: ${result.error.message}`); + } + + return result.data; +} diff --git a/src/server/mcp/handler.test.ts b/src/server/mcp/handler.test.ts new file mode 100644 index 0000000..d20e2ea --- /dev/null +++ b/src/server/mcp/handler.test.ts @@ -0,0 +1,252 @@ +import type { CreateMcpHandlerOptions } from "agents/mcp"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +const verifyMocks = vi.hoisted(() => ({ + verifyJwsAccessToken: vi.fn(), +})); + +const serverMocks = vi.hoisted(() => ({ + nextServerId: 0, + createdServerIds: [] as number[], + serverIds: new WeakMap(), +})); + +vi.mock("@/lib/auth", () => ({ + getAuth: () => ({ api: { getJwks: vi.fn() } }), + getHostedBaseUrl: () => "https://open-seo.test", + hasHostedAuthConfig: () => true, +})); + +vi.mock("better-auth/oauth2", () => ({ + verifyJwsAccessToken: verifyMocks.verifyJwsAccessToken, +})); + +vi.mock("@/server/mcp/server", () => ({ + createOpenSeoMcpServer: () => { + serverMocks.nextServerId += 1; + const server = new McpServer({ name: "Test MCP", version: "0.0.0" }); + serverMocks.createdServerIds.push(serverMocks.nextServerId); + serverMocks.serverIds.set(server, serverMocks.nextServerId); + return server; + }, +})); + +vi.mock("agents/mcp", () => ({ + createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { + return async () => + new Response( + JSON.stringify({ + serverId: serverMocks.serverIds.get(_server), + options, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, +})); + +const ctx: ExecutionContext = { + waitUntil() {}, + passThroughOnException() {}, + props: {}, +}; + +const transportOptionsSchema = z.object({ + serverId: z.number().optional(), + options: z.object({ + route: z.string().optional(), + enableJsonResponse: z.boolean().optional(), + authContext: z + .object({ + props: z.record(z.string(), z.unknown()), + }) + .optional(), + }), +}); + +function createMcpRequest(token: string) { + return new Request("https://open-seo.test/mcp", { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + }), + }); +} + +const jwtShapedToken = "header.payload.signature"; +const organizationIdClaim = "https://open-seo.test/mcp/claims/organization-id"; + +function createAccessTokenPayload( + overrides: Record = {}, +): Record { + return { + sub: "user_123", + azp: "client_123", + scope: "offline_access mcp", + aud: "https://open-seo.test/mcp", + [organizationIdClaim]: "org_123", + ...overrides, + }; +} + +describe("handleMcpRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + serverMocks.nextServerId = 0; + serverMocks.createdServerIds = []; + serverMocks.serverIds = new WeakMap(); + verifyMocks.verifyJwsAccessToken.mockResolvedValue( + createAccessTokenPayload(), + ); + }); + + it("accepts access tokens verified by Better Auth", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + + const response = await handleMcpRequest( + createMcpRequest(jwtShapedToken), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect( + body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP], + ).toMatchObject({ + userId: "user_123", + organizationId: "org_123", + clientId: "client_123", + scopes: ["offline_access", "mcp"], + }); + expect(body.options.route).toBe("/mcp"); + expect(body.options.enableJsonResponse).toBe(true); + + const functionMatcher: unknown = expect.any(Function); + expect(verifyMocks.verifyJwsAccessToken).toHaveBeenCalledWith( + jwtShapedToken, + expect.objectContaining({ + verifyOptions: { + audience: "https://open-seo.test/mcp", + issuer: "https://open-seo.test/api/auth", + }, + jwksFetch: functionMatcher, + }), + ); + }); + + it("creates a fresh server for each request without persisted transport state", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + + const first = await handleMcpRequest( + createMcpRequest(jwtShapedToken), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + const second = await handleMcpRequest( + createMcpRequest(jwtShapedToken), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + const firstBody = transportOptionsSchema.parse(await first.json()); + const secondBody = transportOptionsSchema.parse(await second.json()); + + expect(serverMocks.createdServerIds).toEqual([1, 2]); + expect(firstBody.serverId).toBe(1); + expect(secondBody.serverId).toBe(2); + expect(firstBody.options).not.toHaveProperty("sessionIdGenerator"); + expect(firstBody.options).not.toHaveProperty("storage"); + expect(firstBody.options).not.toHaveProperty("transport"); + }); + + it("lets the MCP transport handle OPTIONS without token verification", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + + const response = await handleMcpRequest( + new Request("https://open-seo.test/mcp", { method: "OPTIONS" }), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect(verifyMocks.verifyJwsAccessToken).not.toHaveBeenCalled(); + expect(body.options.authContext).toBeUndefined(); + }); + + it("returns 401 when Better Auth rejects the access token", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + verifyMocks.verifyJwsAccessToken.mockRejectedValue( + new Error("invalid audience"), + ); + + const response = await handleMcpRequest( + createMcpRequest(jwtShapedToken), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + 'Bearer resource_metadata="https://open-seo.test/.well-known/oauth-protected-resource/mcp"', + ); + }); + + it("returns 403 when the verified token is missing MCP organization context", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + verifyMocks.verifyJwsAccessToken.mockResolvedValue( + createAccessTokenPayload({ + [organizationIdClaim]: undefined, + }), + ); + + const response = await handleMcpRequest( + createMcpRequest(jwtShapedToken), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + + expect(response.status).toBe(403); + }); + + it("returns 401 when the token is missing the required mcp scope", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + verifyMocks.verifyJwsAccessToken.mockResolvedValue( + createAccessTokenPayload({ scope: "offline_access" }), + ); + + const response = await handleMcpRequest( + createMcpRequest(jwtShapedToken), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + + expect(response.status).toBe(401); + }); +}); diff --git a/src/server/mcp/handler.ts b/src/server/mcp/handler.ts new file mode 100644 index 0000000..638d837 --- /dev/null +++ b/src/server/mcp/handler.ts @@ -0,0 +1,150 @@ +import { createMcpHandler } from "agents/mcp"; +import { verifyJwsAccessToken } from "better-auth/oauth2"; +import type { JWTPayload } from "jose"; +import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth"; +import { isHostedAuthMode } from "@/lib/auth-mode"; +import { + getMcpOrganizationIdClaim, + getMcpProtectedResourceMetadataUrl, + getMcpResource, + MCP_SCOPE, +} from "@/lib/oauth-resource"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; +import { createOpenSeoMcpServer } from "@/server/mcp/server"; + +// MCP request flow: +// 1. Resource (`resource=`) is injected into /oauth2/token requests by +// `routes/api/auth/$.ts` so Better Auth always issues audience-bound JWTs +// (some MCP clients skip RFC 8707; without it tokens would be opaque). +// 2. Here we verify the JWT in-process via `verifyJwsAccessToken`, reading +// the JWKS through `auth.api.getJwks()` rather than HTTP self-fetching +// `/api/auth/jwks` (which 500s under workerd dev's self-routing and is +// pointless in prod since the auth server and resource server are the +// same Worker). +// 3. We expect `iss = baseURL + basePath` (basePath defaults to `/api/auth`) +// and `aud = mcpResource`, both confirmed against the published +// /.well-known/oauth-authorization-server metadata. + +export const MCP_ROUTE = "/mcp"; + +type McpAccessTokenPayload = JWTPayload & { + azp?: unknown; + client_id?: unknown; + scope?: unknown; +}; + +function getTokenScopes(payload: McpAccessTokenPayload) { + return typeof payload.scope === "string" + ? payload.scope.split(/\s+/).filter(Boolean) + : []; +} + +function getStringClaim(payload: Record, claim: string) { + const value = payload[claim]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function unauthorizedResponse(resource: string) { + return new Response("Unauthorized", { + status: 401, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "WWW-Authenticate", + "WWW-Authenticate": `Bearer resource_metadata="${getMcpProtectedResourceMetadataUrl( + resource, + )}"`, + }, + }); +} + +export async function handleMcpRequest( + request: Request, + env: { AUTH_MODE?: unknown }, + ctx: ExecutionContext, +) { + const authMode = + typeof env.AUTH_MODE === "string" ? env.AUTH_MODE : undefined; + + if (!isHostedAuthMode(authMode)) { + return new Response("Not found", { status: 404 }); + } + + if (!hasHostedAuthConfig()) { + return new Response("Missing Better Auth hosted configuration", { + status: 500, + }); + } + + const baseUrl = getHostedBaseUrl(); + const auth = getAuth(); + const mcpResource = getMcpResource(baseUrl); + const issuer = `${baseUrl}/api/auth`; + const organizationIdClaim = getMcpOrganizationIdClaim(baseUrl); + const server = createOpenSeoMcpServer(); + + if (request.method === "OPTIONS") { + return createMcpHandler(server, { + route: MCP_ROUTE, + enableJsonResponse: true, + })(request, env, ctx); + } + + const accessToken = + request.headers + .get("Authorization") + ?.replace(/^Bearer\s+/i, "") + .trim() || undefined; + + let payload: McpAccessTokenPayload; + try { + if (!accessToken) throw new Error("missing access token"); + payload = await verifyJwsAccessToken(accessToken, { + jwksFetch: () => auth.api.getJwks(), + verifyOptions: { audience: mcpResource, issuer }, + }); + } catch { + return unauthorizedResponse(mcpResource); + } + + const scopes = getTokenScopes(payload); + if (!scopes.includes(MCP_SCOPE)) { + return unauthorizedResponse(mcpResource); + } + + const userId = getStringClaim(payload, "sub"); + const organizationId = getStringClaim(payload, organizationIdClaim); + const clientId = + getStringClaim(payload, "azp") ?? getStringClaim(payload, "client_id"); + + if (!userId || !organizationId) { + return new Response( + userId + ? "MCP organization context required" + : "MCP user context required", + { + status: 403, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "WWW-Authenticate", + }, + }, + ); + } + + return createMcpHandler(server, { + route: MCP_ROUTE, + enableJsonResponse: true, + authContext: { + props: { + [MCP_AUTH_CONTEXT_PROP]: { + userId, + organizationId, + clientId, + scopes, + audience: mcpResource, + subject: userId, + }, + }, + }, + })(request, env, ctx); +} diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts new file mode 100644 index 0000000..dbe5f13 --- /dev/null +++ b/src/server/mcp/server.ts @@ -0,0 +1,63 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ProjectService } from "@/server/features/projects/services/ProjectService"; +import { requireMcpToolAuthContext } from "@/server/mcp/context"; + +function jsonToolResult(data: Record) { + return { + structuredContent: data, + content: [ + { + type: "text" as const, + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +export function createOpenSeoMcpServer() { + const server = new McpServer({ + name: "OpenSEO MCP", + version: "0.0.10", + }); + + server.registerTool( + "whoami", + { + title: "Who am I", + description: "Return the verified OpenSEO user and organization context.", + }, + async () => { + const auth = requireMcpToolAuthContext(); + + return jsonToolResult({ + userId: auth.userId, + activeOrganizationId: auth.organizationId, + account: { + clientId: auth.clientId, + scopes: auth.scopes, + audience: auth.audience, + subject: auth.subject, + }, + }); + }, + ); + + server.registerTool( + "list_projects", + { + title: "List projects", + description: "List projects in the verified OpenSEO organization.", + }, + async () => { + const auth = requireMcpToolAuthContext(); + const projects = await ProjectService.listProjects(auth.organizationId); + + return jsonToolResult({ + activeOrganizationId: auth.organizationId, + projects, + }); + }, + ); + + return server; +} diff --git a/src/serverFunctions/oauth.ts b/src/serverFunctions/oauth.ts new file mode 100644 index 0000000..f425794 --- /dev/null +++ b/src/serverFunctions/oauth.ts @@ -0,0 +1,33 @@ +import { createServerFn } from "@tanstack/react-start"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/db"; +import { oauthClient } from "@/db/better-auth-schema"; +import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; + +const getOAuthClientInfoSchema = z.object({ + clientId: z.string().min(1), +}); + +export const getOAuthClientInfo = createServerFn({ method: "POST" }) + .middleware(requireAuthenticatedContext) + .inputValidator((data: unknown) => getOAuthClientInfoSchema.parse(data)) + .handler(async ({ data }) => { + const row = await db + .select({ + name: oauthClient.name, + icon: oauthClient.icon, + uri: oauthClient.uri, + }) + .from(oauthClient) + .where(eq(oauthClient.clientId, data.clientId)) + .get(); + + if (!row) return null; + + return { + name: row.name ?? null, + icon: row.icon ?? null, + uri: row.uri ?? null, + }; + }); From 57790b930fdd9fc8e49730999444e54e7edb6aa3 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Fri, 8 May 2026 00:34:29 -0400 Subject: [PATCH 3/3] feat: add OpenSEO MCP tools (#161) --- src/server/mcp/context.ts | 50 +++++++++ src/server/mcp/formatters.test.ts | 47 ++++++++ src/server/mcp/formatters.ts | 34 ++++++ src/server/mcp/handler.test.ts | 28 +++++ src/server/mcp/handler.ts | 15 ++- src/server/mcp/project-auth.test.ts | 98 +++++++++++++++++ src/server/mcp/project-auth.ts | 40 +++++++ src/server/mcp/schemas.ts | 24 +++++ src/server/mcp/server.ts | 100 ++++++++--------- .../mcp/tools/get-backlinks-overview.ts | 81 ++++++++++++++ .../tools/get-domain-keyword-suggestions.ts | 67 ++++++++++++ src/server/mcp/tools/get-domain-overview.ts | 63 +++++++++++ src/server/mcp/tools/get-rank-tracker.ts | 88 +++++++++++++++ src/server/mcp/tools/get-serp-results.ts | 100 +++++++++++++++++ src/server/mcp/tools/list-projects.ts | 40 +++++++ src/server/mcp/tools/list-saved-keywords.ts | 46 ++++++++ src/server/mcp/tools/research-keywords.ts | 101 ++++++++++++++++++ src/server/mcp/tools/save-keywords.ts | 51 +++++++++ src/server/mcp/tools/whoami.ts | 68 ++++++++++++ src/server/mcp/urls.ts | 18 ++++ src/server/mcp/user-email.ts | 12 +++ 21 files changed, 1122 insertions(+), 49 deletions(-) create mode 100644 src/server/mcp/formatters.test.ts create mode 100644 src/server/mcp/formatters.ts create mode 100644 src/server/mcp/project-auth.test.ts create mode 100644 src/server/mcp/project-auth.ts create mode 100644 src/server/mcp/schemas.ts create mode 100644 src/server/mcp/tools/get-backlinks-overview.ts create mode 100644 src/server/mcp/tools/get-domain-keyword-suggestions.ts create mode 100644 src/server/mcp/tools/get-domain-overview.ts create mode 100644 src/server/mcp/tools/get-rank-tracker.ts create mode 100644 src/server/mcp/tools/get-serp-results.ts create mode 100644 src/server/mcp/tools/list-projects.ts create mode 100644 src/server/mcp/tools/list-saved-keywords.ts create mode 100644 src/server/mcp/tools/research-keywords.ts create mode 100644 src/server/mcp/tools/save-keywords.ts create mode 100644 src/server/mcp/tools/whoami.ts create mode 100644 src/server/mcp/urls.ts create mode 100644 src/server/mcp/user-email.ts diff --git a/src/server/mcp/context.ts b/src/server/mcp/context.ts index 1a1f669..39ba0f4 100644 --- a/src/server/mcp/context.ts +++ b/src/server/mcp/context.ts @@ -1,19 +1,35 @@ import { getMcpAuthContext } from "agents/mcp"; import { z } from "zod"; +import type { BillingCustomerContext } from "@/server/billing/subscription"; +import { buildDashboardUrl } from "@/server/mcp/urls"; + +type McpAuth = { + userId: string; + userEmail: string; + organizationId: string; + scopes: string[]; + clientId: string | null; + audience: string; + subject: string; +}; export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth"; const mcpToolAuthContextSchema = z.object({ userId: z.string().min(1), + userEmail: z.string().min(1), organizationId: z.string().min(1), clientId: z.string().nullable(), scopes: z.array(z.string()), audience: z.string().min(1), subject: z.string().min(1), + baseUrl: z.string().url(), }); type McpToolAuthContext = z.infer; +export type ToolExtra = unknown; + export function requireMcpToolAuthContext(): McpToolAuthContext { const rawContext = getMcpAuthContext()?.props[MCP_AUTH_CONTEXT_PROP]; const result = mcpToolAuthContextSchema.safeParse(rawContext); @@ -24,3 +40,37 @@ export function requireMcpToolAuthContext(): McpToolAuthContext { return result.data; } + +export function getAuth(_extra?: ToolExtra): McpAuth { + const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext(); + return auth; +} + +export function getBaseUrl(_extra?: ToolExtra): string { + return requireMcpToolAuthContext().baseUrl; +} + +export function buildBillingCustomer( + auth: McpAuth, + projectId: string, +): BillingCustomerContext { + return { + userId: auth.userId, + userEmail: auth.userEmail, + organizationId: auth.organizationId, + projectId, + }; +} + +export function buildProjectMeta( + context: { auth: Pick; baseUrl: string }, + projectId: string, + path?: string, + params?: Record, +) { + return { + organizationId: context.auth.organizationId, + projectId, + url: path ? buildDashboardUrl(context.baseUrl, path, params) : undefined, + }; +} diff --git a/src/server/mcp/formatters.test.ts b/src/server/mcp/formatters.test.ts new file mode 100644 index 0000000..2c20680 --- /dev/null +++ b/src/server/mcp/formatters.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { mcpResponse } from "./formatters"; + +describe("mcpResponse", () => { + it("returns content as a text block", () => { + const result = mcpResponse({ text: "hi" }); + expect(result.content).toEqual([{ type: "text", text: "hi" }]); + }); + + it("includes _meta only when meta is provided", () => { + const bare = mcpResponse({ text: "hi" }); + expect(bare._meta).toBeUndefined(); + + const withMeta = mcpResponse({ + text: "hi", + meta: { url: "https://app.openseo.so/p/1", projectId: "1" }, + }); + expect(withMeta._meta).toEqual({ + url: "https://app.openseo.so/p/1", + projectId: "1", + }); + }); + + it("drops undefined meta keys", () => { + const result = mcpResponse({ + text: "hi", + meta: { + url: "https://app.openseo.so", + organizationId: undefined, + creditsCharged: 0, + }, + }); + expect(result._meta).toEqual({ + url: "https://app.openseo.so", + creditsCharged: 0, + }); + expect(result._meta).not.toHaveProperty("organizationId"); + }); + + it("attaches structuredContent when provided", () => { + const result = mcpResponse({ + text: "hi", + structuredContent: { foo: "bar" }, + }); + expect(result.structuredContent).toEqual({ foo: "bar" }); + }); +}); diff --git a/src/server/mcp/formatters.ts b/src/server/mcp/formatters.ts new file mode 100644 index 0000000..4039228 --- /dev/null +++ b/src/server/mcp/formatters.ts @@ -0,0 +1,34 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +type McpResponseMeta = { + url?: string; + organizationId?: string; + projectId?: string; + runId?: string; + creditsCharged?: number; + creditsRemaining?: number; +}; + +export function mcpResponse(opts: { + text: string; + meta?: McpResponseMeta; + structuredContent?: Record; +}): CallToolResult { + const result: CallToolResult = { + content: [{ type: "text", text: opts.text }], + }; + if (opts.structuredContent) { + result.structuredContent = opts.structuredContent; + } + if (opts.meta) { + // Drop undefined keys so the wire payload stays clean. + const meta: Record = {}; + for (const [key, value] of Object.entries(opts.meta)) { + if (value !== undefined) meta[key] = value; + } + if (Object.keys(meta).length > 0) { + result._meta = meta; + } + } + return result; +} diff --git a/src/server/mcp/handler.test.ts b/src/server/mcp/handler.test.ts index d20e2ea..03b49f9 100644 --- a/src/server/mcp/handler.test.ts +++ b/src/server/mcp/handler.test.ts @@ -8,6 +8,10 @@ const verifyMocks = vi.hoisted(() => ({ verifyJwsAccessToken: vi.fn(), })); +const userEmailMocks = vi.hoisted(() => ({ + getMcpUserEmail: vi.fn(), +})); + const serverMocks = vi.hoisted(() => ({ nextServerId: 0, createdServerIds: [] as number[], @@ -34,6 +38,10 @@ vi.mock("@/server/mcp/server", () => ({ }, })); +vi.mock("@/server/mcp/user-email", () => ({ + getMcpUserEmail: userEmailMocks.getMcpUserEmail, +})); + vi.mock("agents/mcp", () => ({ createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { return async () => @@ -110,6 +118,7 @@ describe("handleMcpRequest", () => { verifyMocks.verifyJwsAccessToken.mockResolvedValue( createAccessTokenPayload(), ); + userEmailMocks.getMcpUserEmail.mockResolvedValue("alice@example.com"); }); it("accepts access tokens verified by Better Auth", async () => { @@ -129,9 +138,13 @@ describe("handleMcpRequest", () => { body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP], ).toMatchObject({ userId: "user_123", + userEmail: "alice@example.com", organizationId: "org_123", clientId: "client_123", scopes: ["offline_access", "mcp"], + audience: "https://open-seo.test/mcp", + subject: "user_123", + baseUrl: "https://open-seo.test", }); expect(body.options.route).toBe("/mcp"); expect(body.options.enableJsonResponse).toBe(true); @@ -233,6 +246,21 @@ describe("handleMcpRequest", () => { expect(response.status).toBe(403); }); + it("returns 403 when the verified user is not found", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + userEmailMocks.getMcpUserEmail.mockResolvedValue(null); + + const response = await handleMcpRequest( + createMcpRequest(jwtShapedToken), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + + expect(response.status).toBe(403); + }); + it("returns 401 when the token is missing the required mcp scope", async () => { const { handleMcpRequest } = await import("@/server/mcp/handler"); verifyMocks.verifyJwsAccessToken.mockResolvedValue( diff --git a/src/server/mcp/handler.ts b/src/server/mcp/handler.ts index 638d837..4a6940b 100644 --- a/src/server/mcp/handler.ts +++ b/src/server/mcp/handler.ts @@ -11,6 +11,7 @@ import { } from "@/lib/oauth-resource"; import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; import { createOpenSeoMcpServer } from "@/server/mcp/server"; +import { getMcpUserEmail } from "@/server/mcp/user-email"; // MCP request flow: // 1. Resource (`resource=`) is injected into /oauth2/token requests by @@ -24,7 +25,6 @@ import { createOpenSeoMcpServer } from "@/server/mcp/server"; // 3. We expect `iss = baseURL + basePath` (basePath defaults to `/api/auth`) // and `aud = mcpResource`, both confirmed against the published // /.well-known/oauth-authorization-server metadata. - export const MCP_ROUTE = "/mcp"; type McpAccessTokenPayload = JWTPayload & { @@ -131,6 +131,17 @@ export async function handleMcpRequest( ); } + const userEmail = await getMcpUserEmail(userId); + if (!userEmail) { + return new Response("MCP user context required", { + status: 403, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "WWW-Authenticate", + }, + }); + } + return createMcpHandler(server, { route: MCP_ROUTE, enableJsonResponse: true, @@ -138,11 +149,13 @@ export async function handleMcpRequest( props: { [MCP_AUTH_CONTEXT_PROP]: { userId, + userEmail, organizationId, clientId, scopes, audience: mcpResource, subject: userId, + baseUrl, }, }, }, diff --git a/src/server/mcp/project-auth.test.ts b/src/server/mcp/project-auth.test.ts new file mode 100644 index 0000000..d432e9b --- /dev/null +++ b/src/server/mcp/project-auth.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; + +const mocks = vi.hoisted(() => ({ + getMcpAuthContext: vi.fn(), + getProjectForOrganization: vi.fn(), +})); + +vi.mock("agents/mcp", () => ({ + getMcpAuthContext: mocks.getMcpAuthContext, +})); + +vi.mock("@/server/features/projects/services/ProjectService", () => ({ + ProjectService: { + getProjectForOrganization: mocks.getProjectForOrganization, + }, +})); + +const authContext = { + userId: "user_123", + userEmail: "alice@example.com", + organizationId: "org_123", + clientId: "client_123", + scopes: ["mcp"], + audience: "https://open-seo.test/mcp", + subject: "user_123", + baseUrl: "https://open-seo.test", +}; + +describe("withMcpProjectAuth", () => { + beforeEach(() => { + vi.resetModules(); + mocks.getMcpAuthContext.mockReset(); + mocks.getProjectForOrganization.mockReset(); + mocks.getMcpAuthContext.mockReturnValue({ + props: { [MCP_AUTH_CONTEXT_PROP]: authContext }, + }); + }); + + it("checks project access for the authenticated organization", async () => { + const { withMcpProjectAuth } = await import("@/server/mcp/project-auth"); + const handler = vi.fn().mockResolvedValue("ok"); + + const wrapped = withMcpProjectAuth(handler); + await expect( + wrapped({ projectId: "project_123" }, undefined), + ).resolves.toBe("ok"); + + expect(mocks.getProjectForOrganization).toHaveBeenCalledWith( + "org_123", + "project_123", + ); + }); + + it("passes auth, baseUrl, and billing context to the wrapped handler", async () => { + const { withMcpProjectAuth } = await import("@/server/mcp/project-auth"); + const handler = vi.fn().mockReturnValue("ok"); + + const wrapped = withMcpProjectAuth(handler); + await wrapped({ projectId: "project_123" }, undefined); + + expect(handler).toHaveBeenCalledWith( + { projectId: "project_123" }, + { + auth: { + userId: "user_123", + userEmail: "alice@example.com", + organizationId: "org_123", + clientId: "client_123", + scopes: ["mcp"], + audience: "https://open-seo.test/mcp", + subject: "user_123", + }, + baseUrl: "https://open-seo.test", + billing: { + userId: "user_123", + userEmail: "alice@example.com", + organizationId: "org_123", + projectId: "project_123", + }, + }, + ); + }); + + it("propagates project access failures without calling the wrapped handler", async () => { + const error = new Error("project not found"); + mocks.getProjectForOrganization.mockRejectedValue(error); + const { withMcpProjectAuth } = await import("@/server/mcp/project-auth"); + const handler = vi.fn(); + + const wrapped = withMcpProjectAuth(handler); + await expect(wrapped({ projectId: "project_123" }, undefined)).rejects.toBe( + error, + ); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/mcp/project-auth.ts b/src/server/mcp/project-auth.ts new file mode 100644 index 0000000..a224572 --- /dev/null +++ b/src/server/mcp/project-auth.ts @@ -0,0 +1,40 @@ +import { ProjectService } from "@/server/features/projects/services/ProjectService"; +import { + buildBillingCustomer, + requireMcpToolAuthContext, + type ToolExtra, +} from "@/server/mcp/context"; + +type ProjectScopedArgs = { + projectId: string; +}; + +async function requireProjectAccess(_extra: ToolExtra, projectId: string) { + const { baseUrl, ...auth } = requireMcpToolAuthContext(); + + // This lookup enforces that the project belongs to the authenticated org. + await ProjectService.getProjectForOrganization( + auth.organizationId, + projectId, + ); + + return { + auth, + baseUrl, + billing: buildBillingCustomer(auth, projectId), + }; +} + +type McpProjectAuthContext = Awaited>; + +export function withMcpProjectAuth( + handler: ( + args: TArgs, + context: McpProjectAuthContext, + ) => Promise | TResult, +) { + return async (args: TArgs, extra: ToolExtra) => { + const context = await requireProjectAccess(extra, args.projectId); + return handler(args, context); + }; +} diff --git a/src/server/mcp/schemas.ts b/src/server/mcp/schemas.ts new file mode 100644 index 0000000..db22a6e --- /dev/null +++ b/src/server/mcp/schemas.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const DEFAULT_LOCATION_CODE = 2840; +export const DEFAULT_LANGUAGE_CODE = "en"; + +export const projectIdSchema = z + .string() + .min(1) + .describe( + "Required. The OpenSEO project ID to scope this call to. Get one from list_projects.", + ); + +export const locationCodeSchema = z + .number() + .int() + .positive() + .describe( + "DataForSEO location code. Defaults to 2840 (United States). See dataforseo.com/help-center/locations.", + ); + +export const languageCodeSchema = z + .string() + .min(2) + .describe("Language code (e.g. 'en', 'es', 'fr'). Defaults to 'en'."); diff --git a/src/server/mcp/server.ts b/src/server/mcp/server.ts index dbe5f13..ba24bc0 100644 --- a/src/server/mcp/server.ts +++ b/src/server/mcp/server.ts @@ -1,18 +1,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { ProjectService } from "@/server/features/projects/services/ProjectService"; -import { requireMcpToolAuthContext } from "@/server/mcp/context"; - -function jsonToolResult(data: Record) { - return { - structuredContent: data, - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; -} +import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview"; +import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions"; +import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview"; +import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker"; +import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results"; +import { listProjectsTool } from "@/server/mcp/tools/list-projects"; +import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords"; +import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords"; +import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords"; +import { whoamiTool } from "@/server/mcp/tools/whoami"; export function createOpenSeoMcpServer() { const server = new McpServer({ @@ -20,43 +16,51 @@ export function createOpenSeoMcpServer() { version: "0.0.10", }); + server.registerTool(whoamiTool.name, whoamiTool.config, whoamiTool.handler); server.registerTool( - "whoami", - { - title: "Who am I", - description: "Return the verified OpenSEO user and organization context.", - }, - async () => { - const auth = requireMcpToolAuthContext(); - - return jsonToolResult({ - userId: auth.userId, - activeOrganizationId: auth.organizationId, - account: { - clientId: auth.clientId, - scopes: auth.scopes, - audience: auth.audience, - subject: auth.subject, - }, - }); - }, + listProjectsTool.name, + listProjectsTool.config, + listProjectsTool.handler, ); - server.registerTool( - "list_projects", - { - title: "List projects", - description: "List projects in the verified OpenSEO organization.", - }, - async () => { - const auth = requireMcpToolAuthContext(); - const projects = await ProjectService.listProjects(auth.organizationId); - - return jsonToolResult({ - activeOrganizationId: auth.organizationId, - projects, - }); - }, + listSavedKeywordsTool.name, + listSavedKeywordsTool.config, + listSavedKeywordsTool.handler, + ); + server.registerTool( + researchKeywordsTool.name, + researchKeywordsTool.config, + researchKeywordsTool.handler, + ); + server.registerTool( + saveKeywordsTool.name, + saveKeywordsTool.config, + saveKeywordsTool.handler, + ); + server.registerTool( + getDomainOverviewTool.name, + getDomainOverviewTool.config, + getDomainOverviewTool.handler, + ); + server.registerTool( + getDomainKeywordSuggestionsTool.name, + getDomainKeywordSuggestionsTool.config, + getDomainKeywordSuggestionsTool.handler, + ); + server.registerTool( + getBacklinksOverviewTool.name, + getBacklinksOverviewTool.config, + getBacklinksOverviewTool.handler, + ); + server.registerTool( + getSerpResultsTool.name, + getSerpResultsTool.config, + getSerpResultsTool.handler, + ); + server.registerTool( + getRankTrackerTool.name, + getRankTrackerTool.config, + getRankTrackerTool.handler, ); return server; diff --git a/src/server/mcp/tools/get-backlinks-overview.ts b/src/server/mcp/tools/get-backlinks-overview.ts new file mode 100644 index 0000000..44d50e1 --- /dev/null +++ b/src/server/mcp/tools/get-backlinks-overview.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { projectIdSchema } from "@/server/mcp/schemas"; + +const inputSchema = { + projectId: projectIdSchema, + target: z + .string() + .min(1) + .describe( + "Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').", + ), + scope: z + .enum(["domain", "page"]) + .optional() + .describe( + "'domain' analyzes the whole domain; 'page' analyzes a specific URL. Defaults to 'domain'.", + ), + hideSpam: z + .boolean() + .optional() + .describe("Filter out spammy referring domains. Defaults to true."), +} as const; + +type Args = z.infer>; + +function formatMetric(value: unknown) { + return typeof value === "number" || typeof value === "string" ? value : "?"; +} + +export const getBacklinksOverviewTool = { + name: "get_backlinks_overview", + config: { + title: "Get backlinks overview", + description: + "Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~200-500 typical). Requires that the user's DataForSEO account has Backlinks enabled.", + inputSchema, + }, + handler: withMcpProjectAuth(async (args: Args, context) => { + const lookup = { target: args.target, scope: args.scope }; + const spamOptions = { hideSpam: args.hideSpam ?? true }; + const [overview, refDomains] = await Promise.all([ + BacklinksService.profileOverview(lookup, context.billing, spamOptions), + BacklinksService.profileReferringDomains( + lookup, + context.billing, + spamOptions, + ), + ]); + const topDomains = refDomains.rows ?? []; + const overviewRecord = + overview && typeof overview === "object" + ? (overview as Record) + : {}; + const text = [ + `Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`, + `- backlinks: ${formatMetric(overviewRecord.backlinks)}`, + `- referring domains: ${formatMetric(overviewRecord.referring_domains)}`, + `- referring pages: ${formatMetric(overviewRecord.referring_pages)}`, + `- rank: ${formatMetric(overviewRecord.rank)}`, + "", + `Top referring domains (${Math.min(topDomains.length, 10)} shown):`, + ...topDomains + .slice(0, 10) + .map((d) => `- ${d.domain ?? "?"} backlinks:${d.backlinks ?? "?"}`), + ].join("\n"); + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/backlinks`, + { target: args.target }, + ), + structuredContent: { overview, referringDomains: refDomains }, + }); + }), +}; diff --git a/src/server/mcp/tools/get-domain-keyword-suggestions.ts b/src/server/mcp/tools/get-domain-keyword-suggestions.ts new file mode 100644 index 0000000..ba73616 --- /dev/null +++ b/src/server/mcp/tools/get-domain-keyword-suggestions.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { DomainService } from "@/server/features/domain/services/DomainService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + DEFAULT_LANGUAGE_CODE, + DEFAULT_LOCATION_CODE, + languageCodeSchema, + locationCodeSchema, + projectIdSchema, +} from "@/server/mcp/schemas"; + +const inputSchema = { + projectId: projectIdSchema, + domain: z + .string() + .min(1) + .describe("Competitor or reference domain to extract keywords from."), + locationCode: locationCodeSchema.optional(), + languageCode: languageCodeSchema.optional(), +} as const; + +type Args = z.infer>; + +export const getDomainKeywordSuggestionsTool = { + name: "get_domain_keyword_suggestions", + config: { + title: "Get domain keyword opportunities", + description: + "Returns the organic keywords a domain ranks for, including position and available metrics. Use after get_domain_overview when you want the detailed keyword opportunity list for a competitor or reference domain. Charges credits (~100-300 typical). Cached for 12 hours.", + inputSchema, + }, + handler: withMcpProjectAuth(async (args: Args, context) => { + const keywords = await DomainService.getSuggestedKeywords( + { + domain: args.domain, + locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, + languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + organizationId: context.auth.organizationId, + projectId: args.projectId, + }, + context.billing, + ); + const text = [ + `Top keywords for ${args.domain} (${keywords.length}):`, + ...keywords + .slice(0, 25) + .map( + (kw) => + `- "${kw.keyword}" #${kw.position ?? "?"} vol:${kw.searchVolume ?? "?"} kd:${kw.keywordDifficulty ?? "?"}`, + ), + ].join("\n"); + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/domain`, + { + domain: args.domain, + }, + ), + structuredContent: { keywords }, + }); + }), +}; diff --git a/src/server/mcp/tools/get-domain-overview.ts b/src/server/mcp/tools/get-domain-overview.ts new file mode 100644 index 0000000..b5e8d3c --- /dev/null +++ b/src/server/mcp/tools/get-domain-overview.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { DomainService } from "@/server/features/domain/services/DomainService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + DEFAULT_LANGUAGE_CODE, + DEFAULT_LOCATION_CODE, + languageCodeSchema, + locationCodeSchema, + projectIdSchema, +} from "@/server/mcp/schemas"; + +const inputSchema = { + projectId: projectIdSchema, + domain: z.string().min(1).describe("Domain to analyze (e.g. 'example.com')."), + includeSubdomains: z.boolean().optional().default(false), + locationCode: locationCodeSchema.optional(), + languageCode: languageCodeSchema.optional(), +} as const; + +type Args = z.infer>; + +export const getDomainOverviewTool = { + name: "get_domain_overview", + config: { + title: "Get domain overview", + description: + "Returns a high-level view of a domain's organic footprint: estimated organic traffic, organic keyword count, backlinks, and referring domains. Use this first for domain research; for the detailed ranked-keyword list, call get_domain_keyword_suggestions next. Charges credits (~100-300 typical). Cached for 12 hours per domain.", + inputSchema, + }, + handler: withMcpProjectAuth(async (args: Args, context) => { + const result = await DomainService.getOverview( + { + projectId: args.projectId, + domain: args.domain, + includeSubdomains: args.includeSubdomains, + locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, + languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + }, + context.billing, + ); + const text = [ + `Domain: ${result.domain}`, + `Organic traffic: ${result.organicTraffic ?? "?"}`, + `Organic keywords: ${result.organicKeywords ?? "?"}`, + `Backlinks: ${result.backlinks ?? "?"}`, + `Referring domains: ${result.referringDomains ?? "?"}`, + ].join("\n"); + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/domain`, + { + domain: args.domain, + }, + ), + structuredContent: result, + }); + }), +}; diff --git a/src/server/mcp/tools/get-rank-tracker.ts b/src/server/mcp/tools/get-rank-tracker.ts new file mode 100644 index 0000000..22b08bb --- /dev/null +++ b/src/server/mcp/tools/get-rank-tracker.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository"; +import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { projectIdSchema } from "@/server/mcp/schemas"; + +const inputSchema = { + projectId: projectIdSchema, + trackerId: z + .string() + .optional() + .describe( + "Rank tracker config ID. If omitted, lists all rank trackers in the project.", + ), +} as const; + +type Args = z.infer>; + +export const getRankTrackerTool = { + name: "get_rank_tracker", + config: { + title: "Get rank tracker", + description: + "Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Free — reads from OpenSEO state, no DataForSEO call. To trigger a new check, use the dashboard.", + inputSchema, + }, + handler: withMcpProjectAuth(async (args: Args, context) => { + if (!args.trackerId) { + const configs = await RankTrackingRepository.getConfigsForProject( + args.projectId, + ); + const text = + configs.length === 0 + ? "No rank trackers configured for this project." + : `Rank trackers (${configs.length}):\n` + + configs + .map( + (c) => + `- ${c.id} ${c.domain} loc:${c.locationCode} schedule:${c.scheduleInterval}`, + ) + .join("\n"); + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/rank-tracking`, + ), + structuredContent: { configs }, + }); + } + + const config = await RankTrackingRepository.getConfigById({ + configId: args.trackerId, + projectId: args.projectId, + }); + if (!config) { + return mcpResponse({ + text: `Rank tracker ${args.trackerId} not found in project ${args.projectId}.`, + meta: buildProjectMeta(context, args.projectId), + }); + } + const results = await getLatestResults(args.trackerId, args.projectId); + const text = [ + `Tracker ${config.id} (${config.domain}):`, + `Schedule: ${config.scheduleInterval}, devices: ${config.devices}, depth: ${config.serpDepth}`, + `Latest run: ${results.run?.lastCheckedAt ?? "never"}`, + `Keywords (${results.rows.length}):`, + ...results.rows + .slice(0, 25) + .map( + (r) => + `- "${r.keyword}" desktop:#${r.desktop.position ?? "-"} (was ${r.desktop.previousPosition ?? "-"}) mobile:#${r.mobile.position ?? "-"}`, + ), + ].join("\n"); + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/rank-tracking/${args.trackerId}`, + ), + structuredContent: { config, results }, + }); + }), +}; diff --git a/src/server/mcp/tools/get-serp-results.ts b/src/server/mcp/tools/get-serp-results.ts new file mode 100644 index 0000000..e74a657 --- /dev/null +++ b/src/server/mcp/tools/get-serp-results.ts @@ -0,0 +1,100 @@ +import { z } from "zod"; +import { createDataforseoClient } from "@/server/lib/dataforseoClient"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + DEFAULT_LANGUAGE_CODE, + DEFAULT_LOCATION_CODE, + languageCodeSchema, + locationCodeSchema, + projectIdSchema, +} from "@/server/mcp/schemas"; + +const querySchema = z.object({ + keyword: z.string().min(1), + locationCode: locationCodeSchema.optional(), + languageCode: languageCodeSchema.optional(), +}); + +const inputSchema = { + projectId: projectIdSchema, + queries: z + .array(querySchema) + .min(1) + .max(10) + .describe( + "1-10 queries. Bulk-friendly — prefer this over multiple single-query calls.", + ), +} as const; + +type Args = z.infer>; + +export const getSerpResultsTool = { + name: "get_serp_results", + config: { + title: "Get Google SERP results", + description: + "Fetch live Google organic search results for 1-10 keywords. Use this to inspect who ranks for a query, verify competitors, compare SERPs across keywords, or gather source URLs before content planning. Charges credits per keyword (~30-60 each). Does not save results to OpenSEO. Per-keyword errors don't fail the batch.", + inputSchema, + }, + handler: withMcpProjectAuth(async (args: Args, context) => { + const client = createDataforseoClient(context.billing); + + const results = await Promise.all( + args.queries.map(async (q) => { + try { + const items = await client.serp.live({ + keyword: q.keyword, + locationCode: q.locationCode ?? DEFAULT_LOCATION_CODE, + languageCode: q.languageCode ?? DEFAULT_LANGUAGE_CODE, + }); + // Trim noise — return only essentials per item. + const trimmed = items.slice(0, 20).map((item) => ({ + type: item.type, + rank: item.rank_absolute ?? item.rank_group ?? null, + title: item.title ?? null, + url: item.url ?? null, + domain: item.domain ?? null, + description: item.description ?? null, + })); + return { keyword: q.keyword, ok: true as const, items: trimmed }; + } catch (error) { + return { + keyword: q.keyword, + ok: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + + const okCount = results.filter((r) => r.ok).length; + const text = + results + .map((r) => { + if (r.ok) { + const top = r.items.slice(0, 3); + return `"${r.keyword}" (${r.items.length} results):\n${top + .map( + (it) => + ` #${it.rank ?? "?"} ${it.domain ?? "?"} — ${it.title ?? "?"}`, + ) + .join("\n")}`; + } + return `"${r.keyword}": FAILED — ${r.error}`; + }) + .join("\n\n") + + `\n\n${okCount} of ${results.length} queries succeeded.`; + + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/keywords`, + ), + structuredContent: { results }, + }); + }), +}; diff --git a/src/server/mcp/tools/list-projects.ts b/src/server/mcp/tools/list-projects.ts new file mode 100644 index 0000000..60949d3 --- /dev/null +++ b/src/server/mcp/tools/list-projects.ts @@ -0,0 +1,40 @@ +import { ProjectService } from "@/server/features/projects/services/ProjectService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { getAuth, getBaseUrl, type ToolExtra } from "@/server/mcp/context"; +import { buildDashboardUrl } from "@/server/mcp/urls"; + +export const listProjectsTool = { + name: "list_projects", + config: { + title: "List projects", + description: + "Lists all projects in the user's organization. Free — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.", + inputSchema: {} as Record, + }, + handler: async (_args: Record, extra: ToolExtra) => { + const auth = getAuth(extra); + const baseUrl = getBaseUrl(extra); + const projects = await ProjectService.listProjects(auth.organizationId); + const lines = + projects.length === 0 + ? ["No projects yet. Create one in the dashboard."] + : projects.map( + (p) => `- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""}`, + ); + return mcpResponse({ + text: `Projects (${projects.length}):\n${lines.join("\n")}`, + meta: { + organizationId: auth.organizationId, + url: buildDashboardUrl(baseUrl, "/"), + }, + structuredContent: { + projects: projects.map((p) => ({ + id: p.id, + name: p.name, + domain: p.domain, + url: buildDashboardUrl(baseUrl, `/p/${p.id}`), + })), + }, + }); + }, +}; diff --git a/src/server/mcp/tools/list-saved-keywords.ts b/src/server/mcp/tools/list-saved-keywords.ts new file mode 100644 index 0000000..3e8fcd3 --- /dev/null +++ b/src/server/mcp/tools/list-saved-keywords.ts @@ -0,0 +1,46 @@ +import type { z } from "zod"; +import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { projectIdSchema } from "@/server/mcp/schemas"; + +const inputSchema = { + projectId: projectIdSchema, +} as const; + +export const listSavedKeywordsTool = { + name: "list_saved_keywords", + config: { + title: "List saved keywords", + description: + "Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC if available). Free — reads from OpenSEO's database, no DataForSEO call.", + inputSchema, + }, + handler: withMcpProjectAuth( + async (args: z.infer>, context) => { + const { rows } = await KeywordResearchService.getSavedKeywords({ + projectId: args.projectId, + }); + const text = + rows.length === 0 + ? "No saved keywords yet." + : `Saved keywords (${rows.length}):\n` + + rows + .map( + (r) => + `- ${r.keyword} vol:${r.searchVolume ?? "?"} kd:${r.keywordDifficulty ?? "?"} cpc:${r.cpc != null ? `$${r.cpc.toFixed(2)}` : "?"}`, + ) + .join("\n"); + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/saved`, + ), + structuredContent: { rows }, + }); + }, + ), +}; diff --git a/src/server/mcp/tools/research-keywords.ts b/src/server/mcp/tools/research-keywords.ts new file mode 100644 index 0000000..45537ae --- /dev/null +++ b/src/server/mcp/tools/research-keywords.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + DEFAULT_LANGUAGE_CODE, + DEFAULT_LOCATION_CODE, + languageCodeSchema, + locationCodeSchema, + projectIdSchema, +} from "@/server/mcp/schemas"; + +const seedSchema = z.object({ + seed: z.string().min(1).describe("Seed keyword to research."), + locationCode: locationCodeSchema.optional(), + languageCode: languageCodeSchema.optional(), +}); + +const inputSchema = { + projectId: projectIdSchema, + seeds: z + .array(seedSchema) + .min(1) + .max(5) + .describe( + "1-5 seed keywords. Each seed is researched independently and returns related keywords with volume/difficulty/CPC. Bulk-friendly — prefer this over multiple single-seed calls.", + ), + resultLimit: z + .union([z.literal(150), z.literal(300), z.literal(500)]) + .optional() + .describe("Max keywords returned per seed. Defaults to 150."), +} as const; + +type Args = z.infer>; + +export const researchKeywordsTool = { + name: "research_keywords", + config: { + title: "Research keywords (bulk)", + description: + "Research keyword data (search volume, difficulty, CPC, related ideas) for 1-5 seed keywords in one call. Charges credits per seed (~50-200 credits each, varies by source). Returns per-seed results — a single bad seed won't fail the batch.", + inputSchema, + }, + handler: withMcpProjectAuth(async (args: Args, context) => { + const results = await Promise.all( + args.seeds.map(async (item) => { + try { + const data = await KeywordResearchService.research( + { + projectId: args.projectId, + keywords: [item.seed], + locationCode: item.locationCode ?? DEFAULT_LOCATION_CODE, + languageCode: item.languageCode ?? DEFAULT_LANGUAGE_CODE, + resultLimit: args.resultLimit ?? 150, + mode: "auto", + }, + context.billing, + ); + return { + seed: item.seed, + ok: true as const, + rowCount: data.rows.length, + source: data.source, + usedFallback: data.usedFallback, + topRows: data.rows.slice(0, 20), + }; + } catch (error) { + return { + seed: item.seed, + ok: false as const, + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + + const okCount = results.filter((r) => r.ok).length; + const failCount = results.length - okCount; + const text = + results + .map((r) => { + if (r.ok) { + return `- "${r.seed}": ${r.rowCount} keywords (source: ${r.source})`; + } + return `- "${r.seed}": FAILED — ${r.error}`; + }) + .join("\n") + + `\n\nResearched ${okCount} of ${results.length} seeds${failCount > 0 ? ` (${failCount} failed)` : ""}.`; + + return mcpResponse({ + text, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/keywords`, + ), + structuredContent: { results }, + }); + }), +}; diff --git a/src/server/mcp/tools/save-keywords.ts b/src/server/mcp/tools/save-keywords.ts new file mode 100644 index 0000000..ff2e2d4 --- /dev/null +++ b/src/server/mcp/tools/save-keywords.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { buildProjectMeta } from "@/server/mcp/context"; +import { withMcpProjectAuth } from "@/server/mcp/project-auth"; +import { + DEFAULT_LANGUAGE_CODE, + DEFAULT_LOCATION_CODE, + languageCodeSchema, + locationCodeSchema, + projectIdSchema, +} from "@/server/mcp/schemas"; + +const inputSchema = { + projectId: projectIdSchema, + keywords: z + .array(z.string().min(1)) + .min(1) + .max(100) + .describe("Keywords to save (1-100)."), + locationCode: locationCodeSchema.optional(), + languageCode: languageCodeSchema.optional(), +} as const; + +type Args = z.infer>; + +export const saveKeywordsTool = { + name: "save_keywords", + config: { + title: "Save keywords", + description: + "Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op.", + inputSchema, + }, + handler: withMcpProjectAuth(async (args: Args, context) => { + await KeywordResearchService.saveKeywords({ + projectId: args.projectId, + keywords: args.keywords, + locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE, + languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE, + }); + return mcpResponse({ + text: `Saved ${args.keywords.length} keyword(s) to project ${args.projectId}.`, + meta: buildProjectMeta( + context, + args.projectId, + `/p/${args.projectId}/saved`, + ), + }); + }), +}; diff --git a/src/server/mcp/tools/whoami.ts b/src/server/mcp/tools/whoami.ts new file mode 100644 index 0000000..4a07b82 --- /dev/null +++ b/src/server/mcp/tools/whoami.ts @@ -0,0 +1,68 @@ +import { autumn } from "@/server/billing/autumn"; +import { + AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, + AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, +} from "@/shared/billing"; +import { mcpResponse } from "@/server/mcp/formatters"; +import { getAuth, type ToolExtra } from "@/server/mcp/context"; +import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; + +async function checkBalance(featureId: string, customerId: string) { + try { + const result = await autumn.check({ customerId, featureId }); + return result.balance?.remaining ?? null; + } catch { + return null; + } +} + +export const whoamiTool = { + name: "whoami", + config: { + title: "Who am I", + description: + "Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Free — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.", + inputSchema: {} as Record, + }, + handler: async (_args: Record, extra: ToolExtra) => { + const auth = getAuth(extra); + const isHosted = await isHostedServerAuthMode(); + let creditsRemaining: number | null = null; + if (isHosted) { + const [base, topup] = await Promise.all([ + checkBalance(AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, auth.organizationId), + checkBalance( + AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, + auth.organizationId, + ), + ]); + creditsRemaining = (base ?? 0) + (topup ?? 0); + } + const lines = [ + `User: ${auth.userId} (${auth.userEmail})`, + `Organization: ${auth.organizationId}`, + `Mode: ${isHosted ? "hosted" : "self-hosted"}`, + `Scopes: ${auth.scopes.length > 0 ? auth.scopes.join(", ") : "none"}`, + ]; + if (isHosted) { + lines.push( + `Credits remaining: ${creditsRemaining != null ? creditsRemaining.toLocaleString() : "unknown"}`, + ); + } + return mcpResponse({ + text: lines.join("\n"), + meta: { + organizationId: auth.organizationId, + creditsRemaining: creditsRemaining ?? undefined, + }, + structuredContent: { + userId: auth.userId, + userEmail: auth.userEmail, + organizationId: auth.organizationId, + scopes: auth.scopes, + mode: isHosted ? "hosted" : "self-hosted", + creditsRemaining, + }, + }); + }, +}; diff --git a/src/server/mcp/urls.ts b/src/server/mcp/urls.ts new file mode 100644 index 0000000..8bc99a5 --- /dev/null +++ b/src/server/mcp/urls.ts @@ -0,0 +1,18 @@ +// Dashboard URL builder. The base URL is derived per-request from the incoming +// MCP request's origin so it works correctly across hosted, self-hosted, and +// dev environments without needing an env var. + +export function buildDashboardUrl( + baseUrl: string, + path: string, + params?: Record, +): string { + const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value == null) continue; + url.searchParams.set(key, String(value)); + } + } + return url.toString(); +} diff --git a/src/server/mcp/user-email.ts b/src/server/mcp/user-email.ts new file mode 100644 index 0000000..bf7e977 --- /dev/null +++ b/src/server/mcp/user-email.ts @@ -0,0 +1,12 @@ +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { user } from "@/db/schema"; + +export async function getMcpUserEmail(userId: string) { + const authUser = await db.query.user.findFirst({ + columns: { email: true }, + where: eq(user.id, userId), + }); + + return authUser?.email ?? null; +}