diff --git a/apps/cloud/drizzle/0021_org_moved_to_v2.sql b/apps/cloud/drizzle/0021_org_moved_to_v2.sql new file mode 100644 index 0000000000..3cdf9c1c8a --- /dev/null +++ b/apps/cloud/drizzle/0021_org_moved_to_v2.sql @@ -0,0 +1,9 @@ +CREATE TABLE "org_moved_to_v2" ( + "org_id" text PRIMARY KEY NOT NULL, + "state" text NOT NULL, + "v2_org_id" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "org_moved_to_v2_state_check" CHECK ("org_moved_to_v2"."state" in ('moving', 'moved')) +); +--> statement-breakpoint +ALTER TABLE "org_moved_to_v2" ADD CONSTRAINT "org_moved_to_v2_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0021_snapshot.json b/apps/cloud/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000000..cff207f796 --- /dev/null +++ b/apps/cloud/drizzle/meta/0021_snapshot.json @@ -0,0 +1,1813 @@ +{ + "id": "6b0d43ca-a04b-4dc8-b637-982f7ebbc3b8", + "prevId": "88f2845b-be28-4ad3-92b2-2cac819478e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_moved_to_v2": { + "name": "org_moved_to_v2", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "v2_org_id": { + "name": "v2_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_moved_to_v2_org_id_organizations_id_fk": { + "name": "org_moved_to_v2_org_id_organizations_id_fk", + "tableFrom": "org_moved_to_v2", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "org_moved_to_v2_state_check": { + "name": "org_moved_to_v2_state_check", + "value": "\"org_moved_to_v2\".\"state\" in ('moving', 'moved')" + } + }, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 73842e4d59..7d8b7a313c 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1789575639971, "tag": "0020_workos_sync_drained_at", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1791910002100, + "tag": "0021_org_moved_to_v2", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index a7268e2764..32a404f535 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -205,6 +205,7 @@ const NOT_ORG_OWNED: Record = { membership_tombstones: "deleted by organization id", accounts: "shared across orgs — deliberately survives", workos_sync: "the WorkOS Events API cursor, instance-wide and not org-scoped", + org_moved_to_v2: "written by v2, keyed by the organization row that is kept as a tombstone", }; const countTenantRows = async (db: DrizzleDb, tenant: string): Promise => { diff --git a/apps/cloud/src/db/schema.ts b/apps/cloud/src/db/schema.ts index e0ecd2d664..d9fa135d17 100644 --- a/apps/cloud/src/db/schema.ts +++ b/apps/cloud/src/db/schema.ts @@ -25,7 +25,15 @@ // clobbering a newer one. import { sql } from "drizzle-orm"; -import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { + check, + index, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; /** * Login identity + mirrored WorkOS profile. The `id` is the WorkOS user ID. @@ -115,6 +123,30 @@ export const organizations = pgTable( }), ); +/** + * Whether an organization's MCP traffic has moved to Executor v2. v2 writes + * this table (a separate database role that may only INSERT/UPDATE it); v1 + * only reads it, on the MCP auth path. + * + * `moving` changes nothing for MCP requests. `moved` with a `v2_org_id` means + * the organization's MCP URLs are forwarded to v2. No row means not moved. + * `v2_org_id` stays null until the v2 organization exists. + */ +export const orgMovedToV2 = pgTable( + "org_moved_to_v2", + { + orgId: text("org_id") + .primaryKey() + .references(() => organizations.id, { onDelete: "cascade" }), + state: text("state").notNull(), + v2OrgId: text("v2_org_id"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + stateCheck: check("org_moved_to_v2_state_check", sql`${t.state} in ('moving', 'moved')`), + }), +); + /** * Account ↔ organization link, mirroring the WorkOS organization membership. * Answers "which workspaces does this account belong to?" and "is this caller diff --git a/apps/cloud/src/edge/production-config.test.ts b/apps/cloud/src/edge/production-config.test.ts index 95c82fcac6..f6e3571777 100644 --- a/apps/cloud/src/edge/production-config.test.ts +++ b/apps/cloud/src/edge/production-config.test.ts @@ -93,6 +93,13 @@ describe("production v2 edge settings", () => { expect(edge.errorTunnelPath).toBe("/api/fd6fab1fbb4883e1/submit"); }); + it("binds V2_MCP to v2's MCP Worker and names its origin", () => { + const bindings = config.services.filter((service) => service.binding === "V2_MCP"); + expect(bindings).toHaveLength(1); + expect(bindings[0]?.service).toBe("executor-next-hosted-api-v2-qs32brgjwvt7ytx4"); + expect(stringVar("V2_MCP_ORIGIN")).toBe("https://mcp.executor.sh"); + }); + it("keeps the MARKETING binding for v1's terms", () => { expect(config.services.filter((service) => service.binding === "MARKETING")).toHaveLength(1); }); @@ -111,8 +118,14 @@ describe("the executor.sh front Worker's settings", () => { }); it("ships the same v2 settings as executor-cloud", () => { + // The MCP forwarding settings (`V2_MCP_*`) belong to executor-cloud alone: + // the edge never serves MCP. const v2Vars = (vars: Readonly>) => - Object.fromEntries(Object.entries(vars).filter(([name]) => name.startsWith("V2_"))); + Object.fromEntries( + Object.entries(vars).filter( + ([name]) => name.startsWith("V2_") && !name.startsWith("V2_MCP_"), + ), + ); expect(Object.keys(v2Vars(edgeConfig.vars)).toSorted()).toEqual([ "V2_ANALYTICS_PROXY_PATH", "V2_ERROR_TUNNEL_PATH", diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 3e1fb0d8c1..6603749350 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -141,6 +141,16 @@ declare global { // local dev and test workers without them serve everything from v1. /** Service binding to v2's API Worker; `edge/marketing.ts` forwards to it. */ V2?: Fetcher; + /** Service binding to v2's MCP Worker; `mcp/v2-forward.ts` forwards the + * MCP requests of organizations that have moved to v2. */ + V2_MCP?: Fetcher; + /** Origin of v2's MCP endpoint (`/mcp`). */ + V2_MCP_ORIGIN?: string; + /** Shared secret that signs the credential on forwarded MCP requests. + * Set with `wrangler secret put V2_MCP_FORWARD_SECRET`; while unset or + * without the binding, a moved organization's MCP requests answer a + * retryable 503 and are never served from v1. */ + V2_MCP_FORWARD_SECRET?: string; /** Absolute URL `/sign-up` and `/signup` redirect to. */ V2_SIGN_UP_URL?: string; /** Prefix of v2's connected-account OAuth `state`; `/api/oauth/callback` diff --git a/apps/cloud/src/mcp/agent-handler.test.ts b/apps/cloud/src/mcp/agent-handler.test.ts new file mode 100644 index 0000000000..826095a73b --- /dev/null +++ b/apps/cloud/src/mcp/agent-handler.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "@effect/vitest"; +// oxlint-disable-next-line executor/no-vitest-import -- vi.mock is hoisted only when imported from vitest itself +import { vi } from "vitest"; +import { Effect, Layer } from "effect"; + +import { McpAuthProvider, authenticated, type Principal } from "@executor-js/host-mcp"; + +import type { CloudPrincipal } from "./auth-provider"; + +// The handler's collaborators are replaced so the test sees only its ordering: +// which path a request takes after authentication. +const mocks = vi.hoisted(() => ({ + principal: undefined as unknown, + validateMcpSessionOwner: vi.fn(), + serveFetch: vi.fn(), + forwardToV2: vi.fn(), +})); + +vi.mock("./auth-provider", () => ({ + cloudMcpAuth: Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: () => "https://executor.test/.well-known/oauth-protected-resource/mcp", + authenticate: () => Effect.succeed(authenticated(mocks.principal as Principal)), + }), +})); +vi.mock("../observability/telemetry", () => ({ WorkerTelemetryLive: Layer.empty })); +vi.mock("../observability/memory-metrics", () => ({ + wrapMcpSseResponse: (_request: Request, _env: unknown, response: Response) => response, +})); +vi.mock("./session-durable-object", () => ({ + McpSessionDOSqlite: { + serve: () => ({ fetch: mocks.serveFetch }), + }, +})); +vi.mock("@executor-js/cloudflare/mcp/session-stub", () => ({ + mcpSessionStub: () => ({ validateMcpSessionOwner: mocks.validateMcpSessionOwner }), +})); +vi.mock("./toolkit-lookup", () => ({ resolveToolkitRowId: vi.fn() })); +vi.mock("./v2-forward", async (importOriginal) => ({ + ...(await importOriginal()), + forwardToV2: mocks.forwardToV2, +})); + +const { makeCloudMcpAgentHandler } = await import("./agent-handler"); + +const base: Principal = { + accountId: "user_EXAMPLE", + organizationId: "org_EXAMPLE", + organizationName: "Example", + orgRoleModel: "organization", + orgRole: "member", + email: "", + name: null, + avatarUrl: null, + roles: [], +}; + +// oxlint-disable-next-line executor/no-double-cast -- minimal Env stub: the mocked collaborators never read it +const env = { MCP_SESSION: {} } as unknown as Env; +const ctx = {} as ExecutionContext; + +const post = (headers: Record = {}) => + new Request("https://executor.sh/mcp", { method: "POST", headers, body: "{}" }); + +describe("cloud MCP handler v2 forwarding", () => { + it("forwards a moved org with an mcp-session-id without touching the v1 session DO", async () => { + const principal: CloudPrincipal = { ...base, movedToV2: { v2OrganizationId: "v2-org" } }; + mocks.principal = principal; + mocks.forwardToV2.mockResolvedValue(new Response("from v2", { status: 200 })); + + const response = await makeCloudMcpAgentHandler()( + post({ "mcp-session-id": "v2-issued-session" }), + env, + ctx, + ); + + expect(await response.text()).toBe("from v2"); + expect(mocks.forwardToV2).toHaveBeenCalledTimes(1); + expect(mocks.forwardToV2.mock.calls[0]?.[2]).toEqual({ v2OrganizationId: "v2-org" }); + expect(mocks.validateMcpSessionOwner).not.toHaveBeenCalled(); + expect(mocks.serveFetch).not.toHaveBeenCalled(); + }); + + it("leaves a non-moved org on the v1 session path", async () => { + mocks.principal = base; + mocks.forwardToV2.mockClear(); + mocks.validateMcpSessionOwner.mockResolvedValue("ok"); + mocks.serveFetch.mockResolvedValue(new Response("from v1", { status: 200 })); + + const response = await makeCloudMcpAgentHandler()( + post({ "mcp-session-id": "v1-session" }), + env, + ctx, + ); + + expect(await response.text()).toBe("from v1"); + expect(mocks.forwardToV2).not.toHaveBeenCalled(); + expect(mocks.validateMcpSessionOwner).toHaveBeenCalledTimes(1); + expect(mocks.serveFetch).toHaveBeenCalledTimes(1); + }); + + it("answers OPTIONS before authenticating, even for a moved org", async () => { + mocks.principal = { ...base, movedToV2: { v2OrganizationId: "v2-org" } }; + mocks.forwardToV2.mockClear(); + + const response = await makeCloudMcpAgentHandler()( + new Request("https://executor.sh/mcp", { method: "OPTIONS" }), + env, + ctx, + ); + + expect(response.status).toBe(204); + expect(mocks.forwardToV2).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index d10233953a..c4072eaedb 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -30,9 +30,11 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { wrapMcpSseResponse } from "../observability/memory-metrics"; import { WorkerTelemetryLive } from "../observability/telemetry"; -import { cloudMcpAuth } from "./auth-provider"; +import { cloudMcpAuth, type CloudPrincipal } from "./auth-provider"; import { isMcpSessionMetaUnavailable } from "./session-meta"; import { McpSessionDOSqlite } from "./session-durable-object"; +import { resolveToolkitRowId } from "./toolkit-lookup"; +import { FORWARD_SPAN_ATTRIBUTE, forwardToV2 } from "./v2-forward"; import { parseTraceparent } from "./traceparent"; const MCP_SESSION_UNAVAILABLE_MESSAGE = "Session storage temporarily unavailable - please retry"; @@ -256,6 +258,21 @@ export const makeCloudMcpAgentHandler = () => { }); } + // An organization that has moved to v2 is served by v2 instead of the + // session Durable Objects. This runs before the session owner check: v2 + // issues its own session ids, which no v1 DO knows. + const { movedToV2 } = outcome.principal as CloudPrincipal; + if (movedToV2) { + return runTraced( + request, + Effect.promise(() => + forwardToV2(request, outcome.principal, movedToV2, env, { resolveToolkitRowId }), + ).pipe( + Effect.withSpan("mcp.forward.v2", { attributes: { [FORWARD_SPAN_ATTRIBUTE]: true } }), + ), + ); + } + const resource = resourceFromPath(request); if (sessionId) { diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index 1a88793d69..7a6d05f4cf 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -25,7 +25,7 @@ import { Data, Effect, Layer, Predicate } from "effect"; import { McpAuthProvider, orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; import { WorkOSError } from "../auth/errors"; -import { cloudMcpAuthProviderLayer } from "./auth-provider"; +import { cloudMcpAuthProviderLayer, type CloudPrincipal } from "./auth-provider"; import { MCP_ORGANIZATION_HEADER, McpAuth, @@ -175,6 +175,31 @@ describe("cloud MCP org-authorization classification", () => { }), ); + it.effect("a moved org's mark rides on the principal; an unmoved org carries none", () => + Effect.gen(function* () { + const moved = yield* authenticateWith( + Layer.succeed(McpOrganizationAuth)({ + authorize: () => + Effect.succeed({ + id: ORG_ID, + name: "Stub Org", + memberRole: "member", + movedToV2: { v2OrganizationId: "v2-org" }, + }), + }), + ); + const unmoved = yield* authenticateWith(stubOrgAuthActive); + const movedMark = Predicate.isTagged(moved, "Authenticated") + ? (moved.principal as CloudPrincipal).movedToV2 + : "not authenticated"; + const unmovedMark = Predicate.isTagged(unmoved, "Authenticated") + ? (unmoved.principal as CloudPrincipal).movedToV2 + : "not authenticated"; + expect(movedMark).toEqual({ v2OrganizationId: "v2-org" }); + expect(unmovedMark).toBeUndefined(); + }), + ); + it.effect("missing bearer still short-circuits to Unauthorized", () => Effect.gen(function* () { const outcome = yield* authenticateWith(stubOrgAuthActive, stubAuthMissingBearer); diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index b05685af58..394cd7fd35 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -62,6 +62,7 @@ import { type McpAuthResult, type VerifiedToken, } from "./auth"; +import type { MovedToV2 } from "./moved-to-v2"; import { annotateMcpRequest } from "./telemetry"; import { authorizationServerMetadataResponse, @@ -92,6 +93,12 @@ const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the w const ORGANIZATION_AUTHORIZE_UNAVAILABLE = "Organization authorization temporarily unavailable - please retry"; +/** + * The principal cloud's auth hands the MCP handler: the shared shape plus the + * org's moved-to-v2 mark, which only the cloud handler reads. + */ +export type CloudPrincipal = Principal & { readonly movedToV2?: MovedToV2 }; + /** * Enrich a cloud {@link VerifiedToken} (which carries only accountId + * organizationId) into the full {@link Principal} the seam validates. @@ -107,7 +114,7 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = const principalFromToken = ( token: VerifiedToken, organization: AuthorizedMcpOrganization, -): Principal => ({ +): CloudPrincipal => ({ accountId: token.accountId, organizationId: organization.id, organizationName: organization.name, @@ -118,6 +125,7 @@ const principalFromToken = ( name: null, avatarUrl: null, roles: [], + ...(organization.movedToV2 ? { movedToV2: organization.movedToV2 } : {}), }); export const cloudMcpAuthProviderLayer: Layer.Layer< diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 9d34df7c57..8f2edd819d 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -21,6 +21,7 @@ import { makeMemberDirectoryLayer } from "../auth/member-directory"; import { makeWorkOsMirrorLayer } from "../auth/workos-mirror"; import { CoreSharedServices } from "../auth/workos"; import { makeDbLayer } from "../db/db"; +import { readMovedToV2, type MovedToV2 } from "./moved-to-v2"; import { bearerChallenge } from "./responses"; import { McpJwtVerificationError, verifyWorkOSMcpAccessToken, type VerifiedToken } from "./jwt"; @@ -174,6 +175,8 @@ export type AuthorizedMcpOrganization = { readonly name: string; readonly slug?: string; readonly memberRole: "admin" | "member"; + /** Set when the org's MCP traffic is forwarded to Executor v2. */ + readonly movedToV2?: MovedToV2; }; export class McpOrganizationAuth extends Context.Service< @@ -234,24 +237,32 @@ const resolveOrgSelector = (selector: string) => return org?.id ?? null; }); +// The moved-to-v2 flag is read in the same step as the membership check, as a +// second query issued alongside it on the same connection, so it adds no +// sequential round trip. A failed read fails the whole authorization, which the +// provider renders as the retryable "unavailable" outcome: an org that might be +// moved is never served from v1 on a guess. +const authorizeWithMovedFlag = (accountId: string, organizationId: string) => + Effect.gen(function* () { + const [org, movedToV2] = yield* Effect.all( + [authorizeOrganization(accountId, organizationId), readMovedToV2(organizationId)], + { concurrency: "unbounded" }, + ); + if (!org) return null; + return { + id: org.id, + name: org.name, + slug: org.slug, + memberRole: org.memberRole, + ...(movedToV2 ? { movedToV2 } : {}), + } satisfies AuthorizedMcpOrganization; + }); + export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ authorize: (accountId, organizationSelector) => resolveOrgSelector(organizationSelector).pipe( Effect.flatMap((organizationId) => - organizationId - ? authorizeOrganization(accountId, organizationId).pipe( - Effect.map((org) => - org - ? ({ - id: org.id, - name: org.name, - slug: org.slug, - memberRole: org.memberRole, - } as const) - : null, - ), - ) - : Effect.succeed(null), + organizationId ? authorizeWithMovedFlag(accountId, organizationId) : Effect.succeed(null), ), Effect.provide(makeMcpOrganizationAuthServices()), ), diff --git a/apps/cloud/src/mcp/moved-to-v2.ts b/apps/cloud/src/mcp/moved-to-v2.ts new file mode 100644 index 0000000000..b5c9ea803c --- /dev/null +++ b/apps/cloud/src/mcp/moved-to-v2.ts @@ -0,0 +1,42 @@ +// --------------------------------------------------------------------------- +// Whether an organization's MCP traffic has moved to Executor v2. +// +// Read from `org_moved_to_v2`, which v2 writes and v1 only reads. Only a row in +// state `moved` that names the v2 organization forwards: no row, `moving`, or a +// `moved` row whose v2 organization does not exist yet all mean "serve from v1". +// A failed read is NOT "not moved": it fails, and the MCP auth path turns that +// into a retryable "unavailable" rather than serving a possibly moved org from +// v1. +// --------------------------------------------------------------------------- + +import { eq } from "drizzle-orm"; +import { Data, Effect } from "effect"; + +import { DbService } from "../db/db"; +import { orgMovedToV2 } from "../db/schema"; + +/** The v2 organization an org's MCP traffic is forwarded to. */ +export interface MovedToV2 { + readonly v2OrganizationId: string; +} + +export class MovedToV2ReadError extends Data.TaggedError("MovedToV2ReadError")<{ + readonly cause: unknown; +}> {} + +export const readMovedToV2 = ( + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + const { db } = yield* DbService; + const rows = yield* Effect.tryPromise({ + try: () => + db + .select({ state: orgMovedToV2.state, v2OrgId: orgMovedToV2.v2OrgId }) + .from(orgMovedToV2) + .where(eq(orgMovedToV2.orgId, organizationId)), + catch: (cause) => new MovedToV2ReadError({ cause }), + }); + const row = rows[0]; + return row?.state === "moved" && row.v2OrgId ? { v2OrganizationId: row.v2OrgId } : null; + }).pipe(Effect.withSpan("mcp.auth.read_moved_to_v2")); diff --git a/apps/cloud/src/mcp/toolkit-lookup.ts b/apps/cloud/src/mcp/toolkit-lookup.ts new file mode 100644 index 0000000000..7524716601 --- /dev/null +++ b/apps/cloud/src/mcp/toolkit-lookup.ts @@ -0,0 +1,39 @@ +// --------------------------------------------------------------------------- +// Resolving a toolkit URL's slug to the toolkit's storage row id, for forwarding. +// +// Toolkits live in plugin storage (`@executor-js/plugin-toolkits`), owned by the +// org or by a single user. v2 imported each toolkit under its `plugin_storage` +// row id (not the toolkit's own `id`, which is the row's `key`), and derives the +// toolkit's v2 connection from that row id, so this reads the row id itself. +// The plugin's API hides it, hence the direct read. Visibility matches the v1 +// session: the org's toolkits plus the caller's own personal ones, nobody +// else's. Among rows with the slug the org's toolkit wins over the caller's own, +// as in the plugin's slug resolution (`getBySlugEntry`). +// --------------------------------------------------------------------------- + +import { sql } from "drizzle-orm"; +import { Effect } from "effect"; + +import { DbService, makeDbLayer } from "../db/db"; + +export const resolveToolkitRowId = ( + organizationId: string, + accountId: string, + slug: string, +): Promise => + Effect.runPromise( + Effect.gen(function* () { + const { db } = yield* DbService; + const result = yield* Effect.promise(() => + db.execute<{ row_id: string }>(sql` + select row_id from plugin_storage + where tenant = ${organizationId} and plugin_id = 'toolkits' and collection = 'toolkits' + and (owner = 'org' or (owner = 'user' and subject = ${accountId})) + and (data::jsonb)->>'slug' = ${slug} + order by case when owner = 'org' then 0 else 1 end, key + limit 1 + `), + ); + return [...result][0]?.row_id ?? null; + }).pipe(Effect.provide(makeDbLayer()), Effect.scoped), + ); diff --git a/apps/cloud/src/mcp/v2-forward.test.ts b/apps/cloud/src/mcp/v2-forward.test.ts new file mode 100644 index 0000000000..665ed855ee --- /dev/null +++ b/apps/cloud/src/mcp/v2-forward.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { Data, Effect, Schema } from "effect"; + +import { + FORWARD_TOKEN_TTL_SECONDS, + PASSTHROUGH_MOVED_MESSAGE, + TOOLKIT_NOT_FOUND_MESSAGE, + buildV2Url, + forwardHeaders, + forwardToV2, + mintForwardToken, + relayV2Response, + toolkitConnectionId, + type V2ForwardDeps, + type V2ForwardEnv, +} from "./v2-forward"; + +const SECRET = "test-secret-0123456789abcdef0123456789"; +const V2_ORG = "00000000-0000-4000-8000-000000000001"; +const ORIGIN = "https://mcp.example.test"; +const NOW = 1_900_000_000; + +const decodeJson = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const decodePayload = (token: string): Record => { + const payload = token.split(".")[1] ?? ""; + return decodeJson(atob(payload.replace(/-/g, "+").replace(/_/g, "/"))); +}; + +class SimulatedOutage extends Data.TaggedError("SimulatedOutage")<{}> {} + +/** A promise that rejects the way a failing service binding or database does. */ +const outage = (): Promise => Effect.runPromise(Effect.fail(new SimulatedOutage())); + +const principal = { accountId: "user_EXAMPLE", organizationId: "org_EXAMPLE" } as const; +const moved = { v2OrganizationId: V2_ORG } as const; + +const makeEnv = (respond: (request: Request) => Response | Promise) => { + const seen: Request[] = []; + const env: V2ForwardEnv = { + V2_MCP: { + fetch: (request) => { + seen.push(request); + return Promise.resolve(respond(request)); + }, + }, + V2_MCP_ORIGIN: ORIGIN, + V2_MCP_FORWARD_SECRET: SECRET, + }; + return { env, seen }; +}; + +const makeDeps = (rows: Record = {}): V2ForwardDeps => ({ + resolveToolkitRowId: (_org, _account, slug) => Promise.resolve(rows[slug] ?? null), + nowSeconds: () => NOW, + log: () => undefined, +}); + +describe("forward token", () => { + it("matches the wire-contract test vector", async () => { + const token = await mintForwardToken(SECRET, { + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: 2000000000, + }); + expect(token).toBe( + "v1fwd.eyJ2IjoxLCJhY2NvdW50IjoidXNlcl9FWEFNUExFIiwib3JnYW5pemF0aW9uIjoib3JnX0VYQU1QTEUiLCJleHAiOjIwMDAwMDAwMDB9.bC5em3xeD9AGJD2Gj8DCzzAu6gTKd3rnelfBCSL0-Dg", + ); + }); + + it("names the toolkit and its connection for a toolkit URL", async () => { + const token = await mintForwardToken(SECRET, { + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: 2000000000, + toolkit: { id: "tk_row_example", connection: "v1-abc" }, + }); + expect(decodePayload(token)).toEqual({ + v: 1, + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: 2000000000, + toolkit: "tk_row_example", + connection: "v1-abc", + }); + }); +}); + +describe("toolkit connection id", () => { + it("matches the wire-contract test vector", async () => { + expect(await toolkitConnectionId(V2_ORG, "tk_row_example")).toBe( + "v1-e81c8640683211fc13e4c20d5bbb35d9bbc22935", + ); + }); +}); + +describe("v2 URL", () => { + const url = (query: string, connection?: string) => + buildV2Url(ORIGIN, new Request(`https://executor.sh/mcp${query}`), connection).href; + + it("targets /mcp with no query by default", () => { + expect(url("")).toBe(`${ORIGIN}/mcp`); + }); + + it("carries a non-default elicitation mode only", () => { + expect(url("?elicitation_mode=native")).toBe(`${ORIGIN}/mcp?elicitation_mode=native`); + expect(url("?elicitation_mode=browser")).toBe(`${ORIGIN}/mcp?elicitation_mode=browser`); + expect(url("?elicitation_mode=model")).toBe(`${ORIGIN}/mcp`); + expect(url("?elicitation_mode=bogus")).toBe(`${ORIGIN}/mcp`); + }); + + it("drops every other v1 parameter", () => { + expect(url("?search_tools=true&artifacts=false&mode=codemode&x=1")).toBe(`${ORIGIN}/mcp`); + }); + + it("adds the connection for a toolkit URL", () => { + expect(url("?elicitation_mode=native", "v1-abc")).toBe( + `${ORIGIN}/mcp?elicitation_mode=native&connection=v1-abc`, + ); + }); +}); + +describe("forwarded headers", () => { + it("replaces credentials and drops v1 routing and internal headers", () => { + const headers = forwardHeaders( + new Headers({ + authorization: "Bearer client-token", + cookie: "session=1", + origin: "https://client.example", + host: "executor.sh", + "x-executor-mcp-organization": "acme", + "x-executor-organization": "acme", + "x-executor-mcp-account-id": "user_other", + "x-executor-mcp-organization-id": "org_other", + "x-executor-mcp-resource-key": "default", + "x-executor-org-write-access": "allowed", + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-session-id": "v2-session", + "mcp-protocol-version": "2025-06-18", + "last-event-id": "7", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "vendor=1", + }), + "v1fwd.token", + ); + expect(Object.fromEntries(headers)).toEqual({ + authorization: "Bearer v1fwd.token", + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-session-id": "v2-session", + "mcp-protocol-version": "2025-06-18", + "last-event-id": "7", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + tracestate: "vendor=1", + }); + }); +}); + +describe("v2 response relay", () => { + it("relays status, headers and body, dropping www-authenticate", async () => { + const response = relayV2Response( + new Response("event: message\n\n", { + status: 200, + headers: { + "content-type": "text/event-stream", + "mcp-session-id": "v2-session", + "www-authenticate": 'Bearer realm="v2"', + }, + }), + () => undefined, + ); + expect(response.status).toBe(200); + expect(response.headers.get("www-authenticate")).toBeNull(); + expect(response.headers.get("mcp-session-id")).toBe("v2-session"); + expect(await response.text()).toBe("event: message\n\n"); + }); + + it("keeps a non-401 error as v2 answered it", async () => { + const response = relayV2Response(new Response("nope", { status: 404 }), () => undefined); + expect(response.status).toBe(404); + expect(await response.text()).toBe("nope"); + }); + + it("turns a 401 into a retryable 503 with no challenge, and logs it", async () => { + const log = vi.fn(); + const response = relayV2Response( + new Response("{}", { status: 401, headers: { "www-authenticate": "Bearer" } }), + log, + ); + expect(response.status).toBe(503); + expect(response.headers.get("www-authenticate")).toBeNull(); + expect(response.headers.get("retry-after")).not.toBeNull(); + expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32001); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ event: "mcp_v2_forward_credential_refused" }), + ); + }); +}); + +describe("forwardToV2", () => { + it("sends the same method and body to v2 with a fresh signed token", async () => { + const { env, seen } = makeEnv(() => new Response("ok", { status: 202 })); + const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + const response = await forwardToV2( + new Request("https://executor.sh/mcp?elicitation_mode=native&search_tools=true", { + method: "POST", + headers: { + authorization: "Bearer client-token", + "content-type": "application/json", + "mcp-session-id": "v2-session", + }, + body, + }), + principal, + moved, + env, + makeDeps(), + ); + + expect(response.status).toBe(202); + expect(seen).toHaveLength(1); + const forwarded = seen[0]!; + expect(forwarded.method).toBe("POST"); + expect(forwarded.url).toBe(`${ORIGIN}/mcp?elicitation_mode=native`); + expect(forwarded.headers.get("mcp-session-id")).toBe("v2-session"); + expect(await forwarded.text()).toBe(body); + + const token = forwarded.headers.get("authorization")?.replace("Bearer ", "") ?? ""; + expect(decodePayload(token)).toEqual({ + v: 1, + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: NOW + FORWARD_TOKEN_TTL_SECONDS, + }); + expect(token).toBe( + await mintForwardToken(SECRET, { + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: NOW + FORWARD_TOKEN_TTL_SECONDS, + }), + ); + }); + + it("sends a GET without a body", async () => { + const { env, seen } = makeEnv(() => new Response(null, { status: 200 })); + await forwardToV2( + new Request("https://executor.sh/mcp", { headers: { "mcp-session-id": "s" } }), + principal, + moved, + env, + makeDeps(), + ); + expect(seen[0]?.method).toBe("GET"); + expect(seen[0]?.body).toBeNull(); + }); + + it("resolves a toolkit slug to its row and names the connection", async () => { + const { env, seen } = makeEnv(() => new Response("ok")); + await forwardToV2( + new Request("https://executor.sh/mcp/toolkits/deploy", { method: "POST", body: "{}" }), + principal, + moved, + env, + makeDeps({ deploy: "tk_row_example" }), + ); + const connection = "v1-e81c8640683211fc13e4c20d5bbb35d9bbc22935"; + expect(seen[0]?.url).toBe(`${ORIGIN}/mcp?connection=${connection}`); + const token = seen[0]?.headers.get("authorization")?.replace("Bearer ", "") ?? ""; + expect(decodePayload(token)).toMatchObject({ toolkit: "tk_row_example", connection }); + }); + + it("answers an unknown toolkit without calling v2", async () => { + const { env, seen } = makeEnv(() => new Response("ok")); + const response = await forwardToV2( + new Request("https://executor.sh/mcp/toolkits/missing", { method: "POST", body: "{}" }), + principal, + moved, + env, + makeDeps(), + ); + expect(response.status).toBe(404); + expect(((await response.json()) as { error: { message: string } }).error.message).toBe( + TOOLKIT_NOT_FOUND_MESSAGE, + ); + expect(seen).toHaveLength(0); + }); + + it("refuses passthrough mode without calling v2", async () => { + const { env, seen } = makeEnv(() => new Response("ok")); + const response = await forwardToV2( + new Request("https://executor.sh/mcp?mode=passthrough", { method: "POST", body: "{}" }), + principal, + moved, + env, + makeDeps(), + ); + expect(response.status).toBe(400); + expect(((await response.json()) as { error: { message: string } }).error.message).toBe( + PASSTHROUGH_MOVED_MESSAGE, + ); + expect(seen).toHaveLength(0); + }); + + it("answers a retryable 503 when v2 answers 401", async () => { + const { env } = makeEnv( + () => new Response("{}", { status: 401, headers: { "www-authenticate": "Bearer" } }), + ); + const response = await forwardToV2( + new Request("https://executor.sh/mcp", { method: "POST", body: "{}" }), + principal, + moved, + env, + makeDeps(), + ); + expect(response.status).toBe(503); + expect(response.headers.get("www-authenticate")).toBeNull(); + }); + + it("answers a retryable 503 when the binding fails", async () => { + const { env } = makeEnv(() => outage()); + const response = await forwardToV2( + new Request("https://executor.sh/mcp", { method: "POST", body: "{}" }), + principal, + moved, + env, + makeDeps(), + ); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).not.toBeNull(); + }); + + it("answers a retryable 503 when the toolkit lookup fails", async () => { + const { env, seen } = makeEnv(() => new Response("ok")); + const response = await forwardToV2( + new Request("https://executor.sh/mcp/toolkits/deploy", { method: "POST", body: "{}" }), + principal, + moved, + env, + { ...makeDeps(), resolveToolkitRowId: outage }, + ); + expect(response.status).toBe(503); + expect(seen).toHaveLength(0); + }); + + it("answers a retryable 503, never v1 data, when the binding or secret is missing", async () => { + const request = () => new Request("https://executor.sh/mcp", { method: "POST", body: "{}" }); + const { env } = makeEnv(() => new Response("ok")); + for (const missing of ["V2_MCP", "V2_MCP_ORIGIN", "V2_MCP_FORWARD_SECRET"] as const) { + const response = await forwardToV2( + request(), + principal, + moved, + { ...env, [missing]: undefined }, + makeDeps(), + ); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).not.toBeNull(); + } + }); +}); diff --git a/apps/cloud/src/mcp/v2-forward.ts b/apps/cloud/src/mcp/v2-forward.ts new file mode 100644 index 0000000000..54ef6fdf44 --- /dev/null +++ b/apps/cloud/src/mcp/v2-forward.ts @@ -0,0 +1,258 @@ +// --------------------------------------------------------------------------- +// Forwarding MCP requests of a moved organization to Executor v2. +// +// An organization that has moved keeps using its v1 MCP URLs. v1 still +// authenticates the caller (AuthKit token or user API key, plus membership), +// then, instead of dispatching to a session Durable Object, forwards the +// request to v2's MCP endpoint over the `V2_MCP` service binding with a signed +// credential naming the v1 account and organization: +// +// Authorization: Bearer v1fwd.. +// +// payload base64url (no padding) of the UTF-8 JSON +// {"v":1,"account","organization","exp"[,"toolkit","connection"]} +// sig base64url (no padding) of HMAC-SHA256 over "v1fwd." + payload, +// keyed by the shared secret's UTF-8 bytes +// +// A fresh token is minted per forwarded request and expires after ten +// minutes. Nothing here falls back to v1 data: when the binding or secret is +// missing, or v2 cannot be reached, the caller gets a retryable 503. +// --------------------------------------------------------------------------- + +import { jsonRpcErrorBody, UNAVAILABLE_RETRY_AFTER_SECONDS } from "@executor-js/host-mcp"; +import { readElicitationMode, readToolMode } from "@executor-js/cloudflare/mcp/do-headers"; +import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; + +import type { MovedToV2 } from "./moved-to-v2"; +import { MCP_ORGANIZATION_HEADER, toolkitSlugFromRequest } from "./auth"; + +const TOKEN_PREFIX = "v1fwd"; +const TOKEN_VERSION = 1; +export const FORWARD_TOKEN_TTL_SECONDS = 600; + +/** Span and log attribute marking a request served by v2. */ +export const FORWARD_SPAN_ATTRIBUTE = "mcp.forward.v2"; + +const TEMPORARILY_UNAVAILABLE_MESSAGE = "Executor is temporarily unavailable - please retry"; + +const encoder = new TextEncoder(); + +const base64Url = (bytes: Uint8Array): string => { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +const hex = (bytes: ArrayBuffer): string => + Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join(""); + +export interface ForwardTokenClaims { + readonly account: string; + readonly organization: string; + /** Expiry, in unix seconds. */ + readonly exp: number; + /** The v1 toolkit row id and the v2 connection id, for a toolkit URL. */ + readonly toolkit?: { readonly id: string; readonly connection: string }; +} + +export const mintForwardToken = async ( + secret: string, + claims: ForwardTokenClaims, +): Promise => { + const payload = base64Url( + encoder.encode( + JSON.stringify({ + v: TOKEN_VERSION, + account: claims.account, + organization: claims.organization, + exp: claims.exp, + ...(claims.toolkit + ? { toolkit: claims.toolkit.id, connection: claims.toolkit.connection } + : {}), + }), + ), + ); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(`${TOKEN_PREFIX}.${payload}`), + ); + return `${TOKEN_PREFIX}.${payload}.${base64Url(new Uint8Array(signature))}`; +}; + +/** The v2 connection that stands for a v1 toolkit in a v2 organization. */ +export const toolkitConnectionId = async ( + v2OrganizationId: string, + toolkitRowId: string, +): Promise => { + const digest = await crypto.subtle.digest( + "SHA-256", + encoder.encode(JSON.stringify([v2OrganizationId, toolkitRowId])), + ); + return `v1-${hex(digest).slice(0, 40)}`; +}; + +/** v2's MCP endpoint for a request: only the elicitation mode and toolkit connection carry over. */ +export const buildV2Url = (origin: string, request: Request, connection?: string): URL => { + const url = new URL("/mcp", origin); + const mode = readElicitationMode(request); + if (mode !== "model") url.searchParams.set("elicitation_mode", mode); + if (connection !== undefined) url.searchParams.set("connection", connection); + return url; +}; + +// Credentials and v1's own routing are replaced by the forwarding token, so +// they never reach v2. Everything else (content negotiation, the MCP session +// and protocol headers, trace context) is copied. +const DROPPED_HEADERS: ReadonlySet = new Set([ + "authorization", + "cookie", + "origin", + "host", + MCP_ORGANIZATION_HEADER, + EXECUTOR_ORG_SELECTOR_HEADER, +]); + +// v1's internal headers (the verified identity the worker stamps for its own +// session Durable Objects, org write access) are all under this prefix. +const INTERNAL_HEADER_PREFIX = "x-executor-"; + +export const forwardHeaders = (source: Headers, token: string): Headers => { + const headers = new Headers(); + for (const [name, value] of source) { + if (DROPPED_HEADERS.has(name) || name.startsWith(INTERNAL_HEADER_PREFIX)) continue; + headers.append(name, value); + } + headers.set("authorization", `Bearer ${token}`); + return headers; +}; + +const unavailableResponse = (): Response => + jsonRpcErrorBody(503, -32001, TEMPORARILY_UNAVAILABLE_MESSAGE, { + retryAfterSeconds: UNAVAILABLE_RETRY_AFTER_SECONDS, + }); + +/** + * v2's answer, relayed as it is, except that v2 never gets to start an OAuth + * flow with the client: its challenge is dropped, and a 401 (v2 refused our + * forwarding credential, which the client cannot fix) becomes a retryable 503. + */ +export const relayV2Response = ( + response: Response, + log: (event: object, cause?: unknown) => void, +): Response => { + if (response.status === 401) { + void response.body?.cancel(); + log({ event: "mcp_v2_forward_credential_refused", status: 401 }); + return unavailableResponse(); + } + if (!response.headers.has("www-authenticate")) return response; + const headers = new Headers(response.headers); + headers.delete("www-authenticate"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +export const PASSTHROUGH_MOVED_MESSAGE = + "This workspace has moved to Executor v2, which has no passthrough mode. Remove mode=passthrough from the MCP URL."; + +export const TOOLKIT_NOT_FOUND_MESSAGE = "Toolkit not found"; + +/** The bindings forwarding needs; absent in workers that do not forward. */ +export interface V2ForwardEnv { + readonly V2_MCP?: { fetch: (request: Request) => Promise }; + readonly V2_MCP_ORIGIN?: string; + readonly V2_MCP_FORWARD_SECRET?: string; +} + +export interface V2ForwardPrincipal { + readonly accountId: string; + readonly organizationId: string; + readonly movedToV2?: MovedToV2; +} + +export interface V2ForwardDeps { + /** Resolves a toolkit slug to its v1 row id for this caller and org, or null when unknown. */ + readonly resolveToolkitRowId: ( + organizationId: string, + accountId: string, + slug: string, + ) => Promise; + /** Current time in unix seconds. */ + readonly nowSeconds?: () => number; + readonly log?: (event: object, cause?: unknown) => void; +} + +const defaultLog = (event: object, cause?: unknown): void => + cause === undefined + ? console.warn(JSON.stringify(event)) + : console.warn(JSON.stringify(event), cause); + +/** + * Forward `request` to v2 for a principal whose organization has moved. + * Returns the response to send the client; never touches v1 session state. + */ +export const forwardToV2 = async ( + request: Request, + principal: V2ForwardPrincipal, + moved: MovedToV2, + env: V2ForwardEnv, + deps: V2ForwardDeps, +): Promise => { + const log = deps.log ?? defaultLog; + + if (readToolMode(request) === "passthrough") { + return jsonRpcErrorBody(400, -32600, PASSTHROUGH_MOVED_MESSAGE); + } + + const { V2_MCP: binding, V2_MCP_ORIGIN: origin, V2_MCP_FORWARD_SECRET: secret } = env; + if (!binding || !origin || !secret) { + log({ event: "mcp_v2_forward_unconfigured", hasBinding: !!binding, hasOrigin: !!origin }); + return unavailableResponse(); + } + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the toolkit lookup, token signing and the service binding reject with plain platform errors; every one of them is a retryable 503, never a fallback to v1 + try { + const slug = toolkitSlugFromRequest(request); + let toolkit: ForwardTokenClaims["toolkit"]; + if (slug !== null) { + const rowId = await deps.resolveToolkitRowId( + principal.organizationId, + principal.accountId, + slug, + ); + if (rowId === null) return jsonRpcErrorBody(404, -32001, TOOLKIT_NOT_FOUND_MESSAGE); + toolkit = { id: rowId, connection: await toolkitConnectionId(moved.v2OrganizationId, rowId) }; + } + + const nowSeconds = deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000); + const token = await mintForwardToken(secret, { + account: principal.accountId, + organization: principal.organizationId, + exp: nowSeconds + FORWARD_TOKEN_TTL_SECONDS, + ...(toolkit ? { toolkit } : {}), + }); + + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + const forwarded = new Request(buildV2Url(origin, request, toolkit?.connection), { + method: request.method, + headers: forwardHeaders(request.headers, token), + ...(hasBody ? { body: request.body, duplex: "half" } : {}), + } as RequestInit); + + return relayV2Response(await binding.fetch(forwarded), log); + } catch (error) { + log({ event: "mcp_v2_forward_failed" }, error); + return unavailableResponse(); + } +}; diff --git a/apps/cloud/src/mcp/v2-moved-flag.node.test.ts b/apps/cloud/src/mcp/v2-moved-flag.node.test.ts new file mode 100644 index 0000000000..cf017a1317 --- /dev/null +++ b/apps/cloud/src/mcp/v2-moved-flag.node.test.ts @@ -0,0 +1,147 @@ +// --------------------------------------------------------------------------- +// The moved-to-v2 flag and the toolkit slug lookup, against the real PGlite +// Postgres every cloud unit test runs on (scripts/test-globalsetup.ts). +// +// What this pins: +// - only a `moved` row that names its v2 organization forwards; no row, +// `moving`, or `moved` without a v2 organization all read as not moved +// - the table rejects any other state +// - a toolkit slug resolves to the row the v1 session would pick: the +// org's toolkit wins over the caller's own, another user's personal +// toolkit and another org's are invisible, and an unknown slug is null +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { sql } from "drizzle-orm"; +import { Effect, Layer } from "effect"; + +import { UserStoreService, makeUserStoreLayer } from "../auth/context"; +import { DbService, makeDbLayer } from "../db/db"; +import { orgMovedToV2 } from "../db/schema"; +import { PluginsProvider, makeScopedExecutor } from "@executor-js/api/server"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; + +import { CloudDbProvider, CloudHostConfig } from "../engine/execution-stack"; +import { readMovedToV2 } from "./moved-to-v2"; +import { resolveToolkitRowId } from "./toolkit-lookup"; + +const toolkitsOnly = Layer.succeed(PluginsProvider)({ plugins: () => [toolkitsPlugin()] }); + +const dbLayer = makeDbLayer(); +const layer = Layer.mergeAll(dbLayer, makeUserStoreLayer().pipe(Layer.provide(dbLayer))); + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.scoped)); + +const freshOrg = () => + Effect.gen(function* () { + const id = `org_${crypto.randomUUID().replaceAll("-", "")}`; + const store = yield* UserStoreService; + yield* store.use("upsertOrganization", (s) => + s.upsertOrganization({ id, name: "Moved Org", updatedAt: new Date() }), + ); + return id; + }); + +const setState = (orgId: string, state: string, v2OrgId: string | null) => + Effect.gen(function* () { + const { db } = yield* DbService; + yield* Effect.promise(() => db.insert(orgMovedToV2).values({ orgId, state, v2OrgId })); + }); + +const movedToV2 = readMovedToV2; + +describe("moved-to-v2 flag", () => { + it("reads only a moved row with a v2 organization as moved", async () => { + const result = await run( + Effect.gen(function* () { + const none = yield* freshOrg(); + const moving = yield* freshOrg(); + const movingNoOrg = yield* freshOrg(); + const movedNoOrg = yield* freshOrg(); + const moved = yield* freshOrg(); + yield* setState(moving, "moving", "v2-org-moving"); + yield* setState(movingNoOrg, "moving", null); + yield* setState(movedNoOrg, "moved", null); + yield* setState(moved, "moved", "v2-org-moved"); + return { + none: yield* movedToV2(none), + moving: yield* movedToV2(moving), + movingNoOrg: yield* movedToV2(movingNoOrg), + movedNoOrg: yield* movedToV2(movedNoOrg), + moved: yield* movedToV2(moved), + }; + }), + ); + expect(result).toEqual({ + none: null, + moving: null, + movingNoOrg: null, + movedNoOrg: null, + moved: { v2OrganizationId: "v2-org-moved" }, + }); + }); + + it("refuses a state other than moving or moved", async () => { + await expect( + run( + Effect.gen(function* () { + const org = yield* freshOrg(); + yield* setState(org, "paused", null); + }), + ), + ).rejects.toThrow(); + }); +}); + +describe("toolkit slug lookup", () => { + const createToolkit = ( + organizationId: string, + accountId: string, + owner: "org" | "user", + slug: string, + ) => + Effect.gen(function* () { + const executor = yield* makeScopedExecutor]>( + accountId, + organizationId, + "", + ); + const created = yield* executor.toolkits.create({ owner, name: slug, slug }); + // v2 keys an imported toolkit by its storage row id, which is not the toolkit's id. + const { db } = yield* DbService; + const rows = yield* Effect.promise(() => + db.execute<{ row_id: string }>(sql` + select row_id from plugin_storage + where tenant = ${organizationId} and plugin_id = 'toolkits' + and collection = 'toolkits' and key = ${created.id} + `), + ); + const rowId = [...rows][0]?.row_id; + expect(rowId).toBeDefined(); + expect(rowId).not.toBe(created.id); + return rowId; + }).pipe( + Effect.provide(Layer.mergeAll(CloudDbProvider, toolkitsOnly, CloudHostConfig)), + Effect.provide(makeDbLayer()), + Effect.scoped, + ); + + it("resolves the row the v1 session would pick for this caller", async () => { + const org = await run(freshOrg()); + const otherOrg = await run(freshOrg()); + const run2 = (effect: Effect.Effect) => Effect.runPromise(effect); + + const orgDeploy = await run2(createToolkit(org, "user_me", "org", "deploy")); + const notes = await run2(createToolkit(org, "user_me", "user", "notes")); + const theirs = await run2(createToolkit(org, "user_other", "user", "secret")); + await run2(createToolkit(otherOrg, "user_me", "org", "elsewhere")); + + expect(await resolveToolkitRowId(org, "user_me", "deploy")).toBe(orgDeploy); + expect(await resolveToolkitRowId(org, "user_me", "notes")).toBe(notes); + expect(await resolveToolkitRowId(org, "user_me", "secret")).toBeNull(); + expect(await resolveToolkitRowId(org, "user_me", "elsewhere")).toBeNull(); + expect(await resolveToolkitRowId(org, "user_me", "missing")).toBeNull(); + expect(await resolveToolkitRowId(org, "user_other", "secret")).toBe(theirs); + }); +}); diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 21cba7aac1..b4ab49bc61 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -108,6 +108,14 @@ // this binding; this is the only place v1 names it. "service": "executor-next-marketing-v2", }, + // v2's MCP Worker. The MCP requests of an organization that has moved to + // v2 are forwarded to it (src/mcp/v2-forward.ts) with a credential signed + // by the V2_MCP_FORWARD_SECRET secret, which v2 shares. Like the binding + // above, the name must equal the one v2's deployment pins. + { + "binding": "V2_MCP", + "service": "executor-next-hosted-api-v2-qs32brgjwvt7ytx4", + }, ], "hyperdrive": [ { @@ -152,6 +160,10 @@ // /api/oauth/callback requests carrying it go to v2. It must equal the // prefix v2 mints. "V2_OAUTH_STATE_PREFIX": "x2.", + // Origin of v2's MCP endpoint; moved organizations' requests go to + // `/mcp` through the V2_MCP binding. The signing secret, + // V2_MCP_FORWARD_SECRET, is a Worker secret (`wrangler secret put`). + "V2_MCP_ORIGIN": "https://mcp.executor.sh", // v2's browser telemetry on executor.sh. Both values are outputs of v2's // production infrastructure (stage `v2`; random and retained, so they do // not change between deploys). v2 pins them in its edge contract and