diff --git a/.changeset/account-scoped-tool-policies.md b/.changeset/account-scoped-tool-policies.md deleted file mode 100644 index a258479d0a..0000000000 --- a/.changeset/account-scoped-tool-policies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/react": patch ---- - -Tool policies set from an account section of the integration Tools tab now apply to that connection only, and each account header gets a menu to set a policy for the whole connection. Members no longer see policy controls they cannot use, and a refused policy write shows the server's reason. diff --git a/.changeset/connection-oauth-default.md b/.changeset/connection-oauth-default.md deleted file mode 100644 index 35cfce58c2..0000000000 --- a/.changeset/connection-oauth-default.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"executor": patch ---- - -Prefer browser sign-in when a matching OAuth client is available, while preserving a user’s chosen method when clients finish loading. diff --git a/.changeset/cross-session-resume-org-write.md b/.changeset/cross-session-resume-org-write.md deleted file mode 100644 index 299d43bb24..0000000000 --- a/.changeset/cross-session-resume-org-write.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/cloud": patch ---- - -An admin who resumes a paused execution from a different MCP session (for example after the client reconnects) keeps workspace-write access. The forwarded resume now carries the requester's access to the session that owns the execution, so a pending `addServer`, `addSpec`, or similar write no longer fails with `org_write_denied`. diff --git a/.changeset/fair-admin-integrations.md b/.changeset/fair-admin-integrations.md deleted file mode 100644 index a8e83972b8..0000000000 --- a/.changeset/fair-admin-integrations.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/react": patch ---- - -Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations. diff --git a/.changeset/oauth-discovered-scope-budget.md b/.changeset/oauth-discovered-scope-budget.md deleted file mode 100644 index c71d10c41e..0000000000 --- a/.changeset/oauth-discovered-scope-budget.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/sdk": patch ---- - -Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy". diff --git a/.changeset/organization-settings-verification.md b/.changeset/organization-settings-verification.md deleted file mode 100644 index bae2246529..0000000000 --- a/.changeset/organization-settings-verification.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/cloud": patch ---- - -Require authenticator verification for organization settings while preserving shared workspace reads and API-key access. diff --git a/.changeset/quiet-connection-setup.md b/.changeset/quiet-connection-setup.md deleted file mode 100644 index 9d75373928..0000000000 --- a/.changeset/quiet-connection-setup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"executor": patch ---- - -Accept Slack bot and user OAuth token envelopes during sign-in and token refresh. diff --git a/.changeset/toolkit-list-scope.md b/.changeset/toolkit-list-scope.md deleted file mode 100644 index 2af6981f88..0000000000 --- a/.changeset/toolkit-list-scope.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@executor-js/sdk": patch -"@executor-js/plugin-toolkits": patch ---- - -Toolkit sessions no longer walk the whole workspace catalog on connect, search, or describe: the toolkit's access patterns narrow the tool rows core reads. Tools reads no longer wait on re-listing catalogs that are only older than the freshness TTL; those rebuild in the background while the read answers from the persisted rows. Stale-marked and config-revised catalogs still gate the read within the grace budget. diff --git a/.changeset/trusted-oauth-token-regions.md b/.changeset/trusted-oauth-token-regions.md deleted file mode 100644 index 9f3abec23a..0000000000 --- a/.changeset/trusted-oauth-token-regions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/sdk": patch ---- - -Restrict callback-selected OAuth token endpoints to supported Datadog regions and validate persisted regional endpoints before refreshing credentials. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 7d64d39efb..1f1b00e9b4 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,23 @@ # executor +## 1.6.11 + +### Patch Changes + +- [#2066](https://github.com/UsefulSoftwareCo/executor/pull/2066) [`c6d1083`](https://github.com/UsefulSoftwareCo/executor/commit/c6d10838f741ece700922fb2469782f79753b7f7) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Prefer browser sign-in when a matching OAuth client is available, while preserving a user’s chosen method when clients finish loading. + +- [#2254](https://github.com/UsefulSoftwareCo/executor/pull/2254) [`6e768da`](https://github.com/UsefulSoftwareCo/executor/commit/6e768da9dad13c7c469d2c2071bf542157f1033c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Send a default User-Agent on remote MCP requests so servers that reject requests without one accept the connection. A configured User-Agent header still wins. + +- [#2258](https://github.com/UsefulSoftwareCo/executor/pull/2258) [`8e4a8a4`](https://github.com/UsefulSoftwareCo/executor/commit/8e4a8a40048e8252f4d7f182456da62ca1d5c296) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Send the default User-Agent on the MCP endpoint probe and its OAuth metadata request too, so adding a server that rejects requests without one no longer fails. A configured User-Agent still wins. + +- [#2050](https://github.com/UsefulSoftwareCo/executor/pull/2050) [`24adb12`](https://github.com/UsefulSoftwareCo/executor/commit/24adb127f304e2b145b462c3805a9e11b2a524e0) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Accept Slack bot and user OAuth token envelopes during sign-in and token refresh. + +- Updated dependencies [[`2e5aa16`](https://github.com/UsefulSoftwareCo/executor/commit/2e5aa16bedb4ba74448b3f9338754764b40519a0), [`4a08d8d`](https://github.com/UsefulSoftwareCo/executor/commit/4a08d8db6f74612e07f17494e222ff038d8af944), [`c03c9ad`](https://github.com/UsefulSoftwareCo/executor/commit/c03c9adc1fcd4a3f2b0a839878f7310f5fc835b4)]: + - @executor-js/sdk@1.6.11 + - @executor-js/local@1.6.11 + - @executor-js/api@1.4.74 + - @executor-js/runtime-quickjs@1.6.11 + ## 1.6.10 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index f298ce7c4c..255ff37e62 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.6.10", + "version": "1.6.11", "private": true, "bin": { "executor": "./bin/executor.ts" diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index 691c2e786b..3820769164 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,34 @@ # @executor-js/cloud +## 1.4.72 + +### Patch Changes + +- [#2099](https://github.com/UsefulSoftwareCo/executor/pull/2099) [`9e143d9`](https://github.com/UsefulSoftwareCo/executor/commit/9e143d99c28f4c26d9e2cb1b136658048fecd3cb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - An admin who resumes a paused execution from a different MCP session (for example after the client reconnects) keeps workspace-write access. The forwarded resume now carries the requester's access to the session that owns the execution, so a pending `addServer`, `addSpec`, or similar write no longer fails with `org_write_denied`. + +- [#2119](https://github.com/UsefulSoftwareCo/executor/pull/2119) [`4a52307`](https://github.com/UsefulSoftwareCo/executor/commit/4a52307c0bdc7606107c22e15574c24d0254777c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Require authenticator verification for organization settings while preserving shared workspace reads and API-key access. + +- [#2266](https://github.com/UsefulSoftwareCo/executor/pull/2266) [`90e415c`](https://github.com/UsefulSoftwareCo/executor/commit/90e415c98c5414e019695e8501439755a8bc838a) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Link eligible single-member free organizations to Executor v2's workspace import from the sidebar. + +- [#2269](https://github.com/UsefulSoftwareCo/executor/pull/2269) [`49bd295`](https://github.com/UsefulSoftwareCo/executor/commit/49bd2959b4dd203b2a413304f7b3d4d5238d50a4) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Tell users when their organization has moved to Executor v2, with a link to it in v2 and a way to say why they are staying on v1. + +- Updated dependencies [[`29413d8`](https://github.com/UsefulSoftwareCo/executor/commit/29413d8dafaeabfd9b9f10e5fddd1f5b735ffa30), [`31b24b8`](https://github.com/UsefulSoftwareCo/executor/commit/31b24b8564899b137106baf28f66fb3f15c33633), [`2e5aa16`](https://github.com/UsefulSoftwareCo/executor/commit/2e5aa16bedb4ba74448b3f9338754764b40519a0), [`4a08d8d`](https://github.com/UsefulSoftwareCo/executor/commit/4a08d8db6f74612e07f17494e222ff038d8af944), [`c03c9ad`](https://github.com/UsefulSoftwareCo/executor/commit/c03c9adc1fcd4a3f2b0a839878f7310f5fc835b4)]: + - @executor-js/react@1.4.74 + - @executor-js/sdk@1.6.11 + - @executor-js/plugin-toolkits@1.5.46 + - @executor-js/mcp-apps-shell@1.4.22 + - @executor-js/plugin-graphql@1.6.11 + - @executor-js/plugin-mcp@1.6.11 + - @executor-js/plugin-openapi@1.6.11 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/api@1.4.74 + - @executor-js/execution@1.6.11 + - @executor-js/vite-plugin@0.0.71 + - @executor-js/cloudflare@0.0.53 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/runtime-quickjs@1.6.11 + ## 1.4.71 ### Patch Changes 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/package.json b/apps/cloud/package.json index 6bf5a8c91a..33f48c689f 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.71", + "version": "1.4.72", "private": true, "type": "module", "scripts": { diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index a43456e1ed..2f93064573 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -158,6 +158,7 @@ const stubDirectory = Layer.succeed(MemberDirectory)({ const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("revoke does not touch billing"), ensureCustomer: () => Effect.die("revoke does not touch billing"), + findCustomer: () => Effect.die("revoke does not touch billing"), checkExecutionBalance: () => Effect.die("revoke does not touch billing"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 4ff1fc25e1..47ad43cfa7 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -34,6 +34,24 @@ const AuthOrganizationsResponse = Schema.Struct({ activeOrganizationId: Schema.NullOr(Schema.String), }); +// Executor v2's workspace import for the selected org: its page URL when v2 +// would let the signed-in user import the org, otherwise null. Nothing else +// about the membership or billing reads behind it is returned. +// +// `moved` is set instead when the org has already been imported (v2 records +// the move on the WorkOS organization): where it lives in v2 and when it +// moved. It drives a notice only; the org keeps working in v1. +const V2MovedResponse = Schema.Struct({ + v2Url: Schema.String, + v2Slug: Schema.String, + movedAt: Schema.String, +}); + +const V2ImportResponse = Schema.Struct({ + url: Schema.NullOr(Schema.String), + moved: Schema.NullOr(V2MovedResponse), +}); + const CreateOrganizationBody = Schema.Struct({ name: Schema.String, }); @@ -231,6 +249,12 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") error: [WorkOSError, UserStoreError, MemberDirectoryError], }), ) + .add( + HttpApiEndpoint.get("v2Import", "/auth/v2-import", { + success: V2ImportResponse, + error: NoOrganization, + }), + ) .add( HttpApiEndpoint.post("createOrganization", "/auth/create-organization", { payload: CreateOrganizationBody, diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index f5e42b16ea..e6857d0aba 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -42,6 +42,8 @@ import { type AuthorizeOrganizationOptions, } from "./organization"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { liveV2ImportSource, v2ImportAnswer } from "./v2-import"; +import { liveV2MovedSource } from "./v2-moved"; const COOKIE_OPTIONS = { path: "/", @@ -439,6 +441,18 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }; }), ) + .handle("v2Import", () => + Effect.gen(function* () { + const session = yield* requireSelectedOrganization; + return yield* v2ImportAnswer({ + env, + importSource: liveV2ImportSource, + movedSource: liveV2MovedSource, + userId: session.accountId, + organizationId: session.organizationId, + }); + }), + ) .handle("createOrganization", ({ payload }) => Effect.gen(function* () { const workos = yield* WorkOSClient; diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index d166e49b83..e5c3173e5a 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -175,6 +175,7 @@ const seedOrganization = (id: string) => const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("feeders do not read billing"), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("feeders do not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -673,6 +674,7 @@ describe("session handlers read membership from the mirror", () => { const deletingAutumn = Layer.succeed(AutumnService)({ use: () => Effect.succeed({} as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("deletion does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -866,6 +868,7 @@ describe("session handlers read membership from the mirror", () => { return Effect.fail(failure); }), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("deletion does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1234,6 +1237,7 @@ describe("account service writes through to the mirror", () => { subscriptions: [{ planId: "team", status: "active" }], } as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("invite does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1314,6 +1318,7 @@ describe("account service writes through to the mirror", () => { const freeAutumn = Layer.succeed(AutumnService)({ use: () => Effect.succeed({ subscriptions: [] } as never), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("invite does not check balances"), trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -1541,6 +1546,7 @@ describe("seat reporter", () => { const recording = Layer.succeed(AutumnService)({ use: () => Effect.die("the seat reporter sets seats, it does not read"), ensureCustomer: () => Effect.void, + findCustomer: () => Effect.die("feeders do not read billing"), checkExecutionBalance: () => Effect.die("the seat reporter does not check balances"), trackExecution: () => Effect.void, setMemberSeats: (organizationId, seats) => diff --git a/apps/cloud/src/auth/v2-import.test.ts b/apps/cloud/src/auth/v2-import.test.ts new file mode 100644 index 0000000000..584f7cb568 --- /dev/null +++ b/apps/cloud/src/auth/v2-import.test.ts @@ -0,0 +1,284 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { AutumnService } from "../extensions/billing/service"; +import { + isPaidForV2Import, + liveV2ImportSource, + v2ImportEligible, + v2ImportUrl, + type V2ImportSource, + type V2ImportSubscription, +} from "./v2-import"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; + +const USER = "user_signed_in"; +const OTHER = "user_other"; +const ORG = "org_example"; + +interface Facts { + readonly emailVerified?: boolean; + readonly members?: ReadonlyArray; + readonly subscriptions?: ReadonlyArray; + readonly failing?: "user" | "members" | "billing"; +} + +/** A source answering from fixed facts, recording which reads were made. */ +const sourceOf = (facts: Facts) => { + const reads: string[] = []; + const answer = (read: string, value: A) => + Effect.suspend(() => { + reads.push(read); + return facts.failing === read ? Effect.fail("unavailable") : Effect.succeed(value); + }); + const source: V2ImportSource = { + emailVerified: (userId) => { + expect(userId).toBe(USER); + return answer("user", facts.emailVerified ?? true); + }, + activeMemberIds: (organizationId) => { + expect(organizationId).toBe(ORG); + return answer("members", facts.members ?? [USER]); + }, + subscriptions: (organizationId) => { + expect(organizationId).toBe(ORG); + return answer("billing", facts.subscriptions ?? [{ planId: "free", status: "active" }]); + }, + }; + return { source, reads }; +}; + +describe("v2 import eligibility", () => { + it.effect("admits a verified user who is the free org's only active member", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({}); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(true); + expect(reads).toEqual(["user", "members", "billing"]); + }), + ); + + it.effect("admits an org on the free pay-as-you-go plan or with no subscription", () => + Effect.gen(function* () { + for (const subscriptions of [[{ planId: "free-pay-as-you-go", status: "active" }], []]) { + const { source } = sourceOf({ subscriptions }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(true); + } + }), + ); + + it.effect("refuses an unverified email without reading the org", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({ emailVerified: false }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + expect(reads).toEqual(["user"]); + }), + ); + + it.effect("refuses an org with more than one active member, without reading billing", () => + Effect.gen(function* () { + const { source, reads } = sourceOf({ members: [USER, OTHER] }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + expect(reads).toEqual(["user", "members"]); + }), + ); + + it.effect("refuses an org whose only active member is someone else", () => + Effect.gen(function* () { + for (const members of [[OTHER], []]) { + const { source } = sourceOf({ members }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + } + }), + ); + + it.effect("refuses an org on a paid plan", () => + Effect.gen(function* () { + const { source } = sourceOf({ subscriptions: [{ planId: "team", status: "active" }] }); + expect(yield* v2ImportEligible(source, USER, ORG)).toBe(false); + }), + ); + + it.effect("refuses when any read is unavailable", () => + Effect.gen(function* () { + for (const failing of ["user", "members", "billing"] as const) { + const { source } = sourceOf({ failing }); + expect(yield* v2ImportEligible(source, USER, ORG), failing).toBe(false); + } + }), + ); +}); + +describe("v2 import billing read", () => { + const AUTUMN_URL = "https://autumn.example.test"; + interface AutumnRequest { + readonly url: string; + readonly method: string; + readonly body: string; + } + + let requests: AutumnRequest[] = []; + const originalFetch = globalThis.fetch; + const { AUTUMN_SECRET_KEY, AUTUMN_API_URL } = env; + + /** Autumn answering every request with `answer`, recording each request. */ + const autumnAnswers = (answer: () => Response | Promise) => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: String(init?.body), + }); + return answer(); + }) as typeof fetch; + }; + const customer = (subscriptions: ReadonlyArray<{ plan_id: string; status: string }>) => + Response.json({ id: ORG, name: null, balances: {}, subscriptions, purchases: [] }); + + beforeEach(() => { + requests = []; + env.AUTUMN_SECRET_KEY = "am_sk_test"; + env.AUTUMN_API_URL = AUTUMN_URL; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + env.AUTUMN_SECRET_KEY = AUTUMN_SECRET_KEY; + env.AUTUMN_API_URL = AUTUMN_API_URL; + }); + + // WorkOS is not read here: the user and member answers come from `sourceOf`. + const unreadWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), + ); + + /** The eligibility answer for a verified sole member, billing read from Autumn. */ + const eligibleWithLiveBilling = Effect.gen(function* () { + const live = yield* liveV2ImportSource; + const source = { ...sourceOf({}).source, subscriptions: live.subscriptions }; + return yield* v2ImportEligible(source, USER, ORG); + }).pipe(Effect.provide(Layer.merge(AutumnService.Default, unreadWorkOS))); + + it.effect("looks the customer up read-only, so an org Autumn has never seen stays absent", () => + Effect.gen(function* () { + autumnAnswers(() => + Response.json( + { message: `Customer ${ORG} not found`, code: "customer_not_found" }, + { status: 404 }, + ), + ); + expect(yield* eligibleWithLiveBilling, "no customer is free").toBe(true); + expect(requests).toEqual([ + { url: `${AUTUMN_URL}/v1/customers.get`, method: "POST", body: `{"customer_id":"${ORG}"}` }, + ]); + }), + ); + + it.effect("admits a customer on a free plan or with no subscription", () => + Effect.gen(function* () { + for (const subscriptions of [ + [{ plan_id: "free", status: "active" }], + [{ plan_id: "free-pay-as-you-go", status: "active" }], + [], + ]) { + autumnAnswers(() => customer(subscriptions)); + expect(yield* eligibleWithLiveBilling, JSON.stringify(subscriptions)).toBe(true); + } + }), + ); + + it.effect("refuses a customer on a paid plan", () => + Effect.gen(function* () { + autumnAnswers(() => customer([{ plan_id: "team", status: "active" }])); + expect(yield* eligibleWithLiveBilling).toBe(false); + }), + ); + + it.effect("admits a customer whose paid plan has expired", () => + Effect.gen(function* () { + autumnAnswers(() => + customer([ + { plan_id: "free", status: "active" }, + { plan_id: "team", status: "expired" }, + ]), + ); + expect(yield* eligibleWithLiveBilling).toBe(true); + }), + ); + + it.effect("refuses when the lookup fails or answers something unexpected", () => + Effect.gen(function* () { + const failures: ReadonlyArray Response | Promise]> = [ + [ + "network error", + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fetch rejects with a TypeError on a network failure; that rejection is the fixture + () => Promise.reject(new TypeError("fetch failed")), + ], + ["server error", () => Response.json({ message: "boom" }, { status: 500 })], + ["unauthorized", () => Response.json({ code: "invalid_secret_key" }, { status: 401 })], + ["404 without customer_not_found", () => new Response("Not Found", { status: 404 })], + ["body without subscriptions", () => Response.json({ id: ORG })], + ["body that is not JSON", () => new Response("", { status: 200 })], + ]; + for (const [name, answer] of failures) { + autumnAnswers(answer); + expect(yield* eligibleWithLiveBilling, name).toBe(false); + } + }), + ); + + it.effect("refuses without an Autumn secret, without calling Autumn", () => + Effect.gen(function* () { + delete env.AUTUMN_SECRET_KEY; + autumnAnswers(() => customer([])); + expect(yield* eligibleWithLiveBilling).toBe(false); + expect(requests).toEqual([]); + }), + ); +}); + +describe("v2 import paid plans", () => { + it("counts every plan outside v2's free list that has not expired", () => { + expect(isPaidForV2Import([{ planId: "team", status: "active" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "enterprise", status: "trialing" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "team", status: "past_due" }])).toBe(true); + expect(isPaidForV2Import([{ planId: "team", status: "scheduled" }])).toBe(true); + // A grandfathered or later plan v1 never listed is paid too. + expect(isPaidForV2Import([{ planId: "professional", status: "active" }])).toBe(true); + expect( + isPaidForV2Import([ + { planId: "free", status: "active" }, + { planId: "team", status: "active" }, + ]), + ).toBe(true); + }); + + it("does not count free plans or expired subscriptions", () => { + expect(isPaidForV2Import([])).toBe(false); + expect(isPaidForV2Import([{ planId: "free", status: "active" }])).toBe(false); + expect(isPaidForV2Import([{ planId: "free-pay-as-you-go", status: "active" }])).toBe(false); + expect(isPaidForV2Import([{ planId: "team", status: "expired" }])).toBe(false); + }); +}); + +describe("v2 import url", () => { + const configured = { + V2: { fetch: () => Promise.resolve(new Response(null)) }, + V2_SIGN_UP_URL: "https://app.example.test/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }; + + it("is the import page on v2's app host, the sign-up page's origin", () => { + expect(v2ImportUrl(configured)?.href).toBe("https://app.example.test/v1-import"); + }); + + it("is absent without the v2 settings or with broken ones", () => { + expect(v2ImportUrl({})).toBeNull(); + expect(v2ImportUrl({ ...configured, V2_SIGN_UP_URL: "not a url" })).toBeNull(); + expect(v2ImportUrl({ ...configured, V2: undefined })).toBeNull(); + }); +}); diff --git a/apps/cloud/src/auth/v2-import.ts b/apps/cloud/src/auth/v2-import.ts new file mode 100644 index 0000000000..10a811dd3b --- /dev/null +++ b/apps/cloud/src/auth/v2-import.ts @@ -0,0 +1,144 @@ +// --------------------------------------------------------------------------- +// The dashboard's link to Executor v2's workspace import. +// +// v2 imports a v1 organization only when all of these hold (v2's +// `V1Import.choices`), and v1 shows the link under exactly the same rules: +// 1. the signed-in user's email is verified, +// 2. the organization has exactly one active member, and it is that user, +// 3. the organization is not on a paid v1 plan: no Autumn subscription to a +// plan outside v2's free list that has not expired. +// Every failure to read one of them hides the link. Only the yes/no answer +// leaves the server; memberships and billing never do. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing"; +import { AutumnService, type AutumnSubscription } from "../extensions/billing/service"; +import { v2Moved, type V2Moved, type V2MovedSource } from "./v2-moved"; +import { WorkOSClient } from "./workos"; + +/** v2's import page, on v2's app host. */ +const V2_IMPORT_PATH = "/v1-import"; + +/** + * v2's app host, or `null` where v1 does not hand requests to v2 (local dev + * without the v2 settings, test workers, or broken settings). It is the origin + * of v2's sign-up page, the same host the edge sends v2 dashboard links to. + */ +export const v2AppOrigin = (env: V2EdgeEnv): URL | null => { + const edge = parseV2Edge(env); + if (edge === null || typeof edge === "string") return null; + return new URL(edge.signUpUrl.origin); +}; + +/** The absolute URL of v2's import page, or `null` where v1 has no v2 host. */ +export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { + const origin = v2AppOrigin(env); + return origin === null ? null : new URL(V2_IMPORT_PATH, origin); +}; + +/** v1's plans that cost nothing, as v2's import counts them (v2's + * `freePlans`). Every other plan, including one added later, is paid. */ +export const V2_IMPORT_FREE_PLAN_IDS: ReadonlyArray = ["free", "free-pay-as-you-go"]; + +export type V2ImportSubscription = AutumnSubscription; + +/** Whether v2 treats the organization as on a paid v1 plan. */ +export const isPaidForV2Import = (subscriptions: ReadonlyArray): boolean => + subscriptions.some( + (subscription) => + !V2_IMPORT_FREE_PLAN_IDS.includes(subscription.planId) && subscription.status !== "expired", + ); + +/** The reads the eligibility check makes, each against the system of record. */ +export interface V2ImportSource { + readonly emailVerified: (userId: string) => Effect.Effect; + /** The user ids of the organization's active members. */ + readonly activeMemberIds: ( + organizationId: string, + ) => Effect.Effect, unknown>; + readonly subscriptions: ( + organizationId: string, + ) => Effect.Effect, unknown>; +} + +/** + * Whether v2 would let this user import this organization. Reads in order and + * stops at the first refusal, so most refusals cost one or two calls. Never + * fails: an unavailable read answers `false`. + */ +export const v2ImportEligible = ( + source: V2ImportSource, + userId: string, + organizationId: string, +): Effect.Effect => + Effect.gen(function* () { + if (!(yield* source.emailVerified(userId))) return false; + const members = yield* source.activeMemberIds(organizationId); + if (members.length !== 1 || members[0] !== userId) return false; + return !isPaidForV2Import(yield* source.subscriptions(organizationId)); + }).pipe( + Effect.orElseSucceed(() => false), + Effect.withSpan("v2Import.eligible"), + ); + +/** The reads over v1's WorkOS (live, not the membership mirror, as v2 reads + * them) and Autumn. Billing is read without provisioning: an organization + * Autumn has never seen has no subscriptions, so it is free, the same answer + * v2 gives for a missing customer. */ +export const liveV2ImportSource = Effect.gen(function* () { + const workos = yield* WorkOSClient; + const autumn = yield* AutumnService; + return { + emailVerified: (userId) => + workos.getUser(userId).pipe(Effect.map((user) => user.emailVerified)), + activeMemberIds: (organizationId) => + workos + .listOrgMembers(organizationId, ["active"]) + .pipe( + Effect.map((list) => + list.data + .filter((membership) => membership.status === "active") + .map((membership) => membership.userId), + ), + ), + subscriptions: (organizationId) => + autumn + .findCustomer(organizationId) + .pipe(Effect.map((customer) => customer?.subscriptions ?? [])), + } satisfies V2ImportSource; +}); + +/** What the dashboard shows for the selected org: the import link, or where + * the org now lives in v2. At most one is set. */ +export interface V2ImportAnswer { + readonly url: string | null; + readonly moved: V2Moved | null; +} + +/** + * The `GET /api/auth/v2-import` answer. Without a v2 host there is nothing to + * link to. An org that has already moved is not offered the import again, so + * its eligibility is never read. Never fails. + */ +export const v2ImportAnswer = (input: { + readonly env: V2EdgeEnv; + readonly importSource: Effect.Effect; + readonly movedSource: Effect.Effect; + readonly userId: string; + readonly organizationId: string; +}): Effect.Effect => + Effect.gen(function* () { + const v2Origin = v2AppOrigin(input.env); + const importUrl = v2ImportUrl(input.env); + if (v2Origin === null || importUrl === null) return { url: null, moved: null }; + const moved = yield* v2Moved(yield* input.movedSource, input.organizationId, v2Origin); + if (moved !== null) return { url: null, moved }; + const eligible = yield* v2ImportEligible( + yield* input.importSource, + input.userId, + input.organizationId, + ); + return { url: eligible ? importUrl.href : null, moved: null }; + }); diff --git a/apps/cloud/src/auth/v2-moved.test.ts b/apps/cloud/src/auth/v2-moved.test.ts new file mode 100644 index 0000000000..10f2674cbc --- /dev/null +++ b/apps/cloud/src/auth/v2-moved.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { v2ImportAnswer, type V2ImportSource } from "./v2-import"; +import { + liveV2MovedSource, + parseV2Moved, + v2Moved, + V2_MOVED_AT_KEY, + V2_MOVED_SLUG_KEY, + type V2MovedSource, +} from "./v2-moved"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { WorkOSError } from "./errors"; + +const USER = "user_signed_in"; +const ORG = "org_example"; +const V2_ORIGIN = new URL("https://app.example.test"); +const MOVED_AT = "2026-10-09T17:30:00.000Z"; + +const movedMetadata = (overrides: Record = {}) => ({ + [V2_MOVED_SLUG_KEY]: "example-team", + [V2_MOVED_AT_KEY]: MOVED_AT, + ...overrides, +}); + +describe("v2 moved metadata", () => { + it("reads the v2 slug and move time into the org's v2 page", () => { + expect(parseV2Moved(movedMetadata(), V2_ORIGIN)).toEqual({ + v2Url: "https://app.example.test/org/example-team", + v2Slug: "example-team", + movedAt: MOVED_AT, + }); + }); + + it("ignores unrelated metadata keys", () => { + expect(parseV2Moved(movedMetadata({ plan_note: "anything" }), V2_ORIGIN)?.v2Slug).toBe( + "example-team", + ); + }); + + it("normalizes a timestamp with an offset to UTC", () => { + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_AT_KEY]: "2026-10-09T10:30:00-07:00" }), V2_ORIGIN) + ?.movedAt, + ).toBe(MOVED_AT); + }); + + it("is not moved without both keys", () => { + expect(parseV2Moved({}, V2_ORIGIN)).toBeNull(); + expect(parseV2Moved({ [V2_MOVED_SLUG_KEY]: "example-team" }, V2_ORIGIN)).toBeNull(); + expect(parseV2Moved({ [V2_MOVED_AT_KEY]: MOVED_AT }, V2_ORIGIN)).toBeNull(); + }); + + it("is not moved when the slug is not a v2 slug", () => { + for (const slug of [ + "", + "Example-Team", + "example_team", + "-example", + "example-", + "example--team", + "example/team", + "../admin", + "example team", + "a".repeat(46), + 42, + null, + ]) { + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: slug }), V2_ORIGIN), + String(slug), + ).toBeNull(); + } + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_SLUG_KEY]: "a".repeat(45) }), V2_ORIGIN), + "45 characters is v2's longest slug", + ).not.toBeNull(); + }); + + it("is not moved when the move time is not an ISO timestamp", () => { + for (const movedAt of [ + "", + "2026-10-09", + "2026-10-09T17:30:00", + "October 9, 2026", + "2026-13-40T99:99:00Z", + "1791000000000", + 1791000000000, + null, + ]) { + expect( + parseV2Moved(movedMetadata({ [V2_MOVED_AT_KEY]: movedAt }), V2_ORIGIN), + String(movedAt), + ).toBeNull(); + } + }); +}); + +/** A metadata read answering `metadata`, or failing; records each read. */ +const movedSourceOf = (metadata: Record | "unavailable") => { + const reads: string[] = []; + const source: V2MovedSource = { + organizationMetadata: (organizationId) => + Effect.suspend(() => { + reads.push(organizationId); + return metadata === "unavailable" ? Effect.fail("unavailable") : Effect.succeed(metadata); + }), + }; + return { source, reads }; +}; + +/** An import source that admits the user, recording whether it was asked. */ +const importSourceOf = (eligible: boolean) => { + const reads: string[] = []; + const source: V2ImportSource = { + emailVerified: () => Effect.sync(() => (reads.push("user"), true)), + activeMemberIds: () => Effect.sync(() => (reads.push("members"), eligible ? [USER] : [])), + subscriptions: () => Effect.sync(() => (reads.push("billing"), [])), + }; + return { source, reads }; +}; + +describe("v2 moved read", () => { + it.effect("reads the selected org's metadata", () => + Effect.gen(function* () { + const { source, reads } = movedSourceOf(movedMetadata()); + expect((yield* v2Moved(source, ORG, V2_ORIGIN))?.v2Slug).toBe("example-team"); + expect(reads).toEqual([ORG]); + }), + ); + + it.effect("is not moved when the read fails", () => + Effect.gen(function* () { + const { source } = movedSourceOf("unavailable"); + expect(yield* v2Moved(source, ORG, V2_ORIGIN)).toBeNull(); + }), + ); +}); + +describe("v2 moved live read", () => { + /** WorkOS answering `getOrganization` with `answer`; every other call dies. */ + const workosAnswering = (answer: (organizationId: string) => Effect.Effect) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => + prop === "getOrganization" + ? answer + : () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), + ); + + const organization = (metadata: Record) => ({ + object: "organization", + id: ORG, + name: "Example Org", + allowProfilesOutsideOrganization: false, + domains: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + externalId: null, + metadata, + }); + + const movedLive = (layer: Layer.Layer) => + Effect.gen(function* () { + return yield* v2Moved(yield* liveV2MovedSource, ORG, V2_ORIGIN); + }).pipe(Effect.provide(layer)); + + it.effect("reads the move from the WorkOS organization's metadata", () => + Effect.gen(function* () { + const asked: string[] = []; + const moved = yield* movedLive( + workosAnswering((organizationId) => + Effect.sync(() => (asked.push(organizationId), organization(movedMetadata()))), + ), + ); + expect(moved?.v2Url).toBe("https://app.example.test/org/example-team"); + expect(asked).toEqual([ORG]); + }), + ); + + it.effect("is not moved for an organization without the keys", () => + Effect.gen(function* () { + expect(yield* movedLive(workosAnswering(() => Effect.succeed(organization({}))))).toBeNull(); + }), + ); + + it.effect("is not moved when WorkOS fails", () => + Effect.gen(function* () { + const failure = new WorkOSError({ status: 503 }); + expect(yield* movedLive(workosAnswering(() => Effect.fail(failure)))).toBeNull(); + }), + ); +}); + +describe("v2 import answer", () => { + const configured = { + V2: { fetch: () => Promise.resolve(new Response(null)) }, + V2_SIGN_UP_URL: "https://app.example.test/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }; + + const answer = ( + env: typeof configured | Record, + moved: ReturnType, + importing: ReturnType, + ) => + v2ImportAnswer({ + env, + importSource: Effect.succeed(importing.source), + movedSource: Effect.succeed(moved.source), + userId: USER, + organizationId: ORG, + }); + + it.effect("answers where a moved org lives, without offering the import again", () => + Effect.gen(function* () { + const moved = movedSourceOf(movedMetadata()); + const importing = importSourceOf(true); + expect(yield* answer(configured, moved, importing)).toEqual({ + url: null, + moved: { + v2Url: "https://app.example.test/org/example-team", + v2Slug: "example-team", + movedAt: MOVED_AT, + }, + }); + expect(importing.reads, "a moved org's eligibility is never read").toEqual([]); + }), + ); + + it.effect("offers the import to an eligible org that has not moved", () => + Effect.gen(function* () { + expect(yield* answer(configured, movedSourceOf({}), importSourceOf(true))).toEqual({ + url: "https://app.example.test/v1-import", + moved: null, + }); + expect(yield* answer(configured, movedSourceOf({}), importSourceOf(false))).toEqual({ + url: null, + moved: null, + }); + }), + ); + + it.effect("treats malformed or unreadable metadata as not moved", () => + Effect.gen(function* () { + for (const moved of [ + movedSourceOf(movedMetadata({ [V2_MOVED_SLUG_KEY]: "Not A Slug" })), + movedSourceOf(movedMetadata({ [V2_MOVED_AT_KEY]: "yesterday" })), + movedSourceOf("unavailable"), + ]) { + expect(yield* answer(configured, moved, importSourceOf(true))).toEqual({ + url: "https://app.example.test/v1-import", + moved: null, + }); + } + }), + ); + + it.effect("answers nothing without a v2 host, without reading anything", () => + Effect.gen(function* () { + const moved = movedSourceOf(movedMetadata()); + const importing = importSourceOf(true); + expect(yield* answer({}, moved, importing)).toEqual({ url: null, moved: null }); + expect(moved.reads).toEqual([]); + expect(importing.reads).toEqual([]); + }), + ); +}); diff --git a/apps/cloud/src/auth/v2-moved.ts b/apps/cloud/src/auth/v2-moved.ts new file mode 100644 index 0000000000..4d8da5c811 --- /dev/null +++ b/apps/cloud/src/auth/v2-moved.ts @@ -0,0 +1,86 @@ +// --------------------------------------------------------------------------- +// Whether the selected organization has moved to Executor v2. +// +// When a user finishes importing a v1 organization, v2 writes two keys onto +// the v1 WorkOS organization's metadata: +// executor_v2_org_slug the v2 organization's slug (its `/org/` URL) +// executor_v2_moved_at when the import finished, as an ISO 8601 timestamp +// Both must be present and well formed; anything else, and every failure to +// read the organization, answers "not moved". The answer only drives a notice: +// a moved organization keeps working in v1. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { WorkOSClient } from "./workos"; + +export const V2_MOVED_SLUG_KEY = "executor_v2_org_slug"; +export const V2_MOVED_AT_KEY = "executor_v2_moved_at"; + +/** v2's organization slug rule (`OrganizationSlug` in v2's hosted contracts). */ +const V2_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const V2_SLUG_MAX_LENGTH = 45; + +/** An ISO 8601 date and time with an explicit offset, as `Date#toISOString` + * writes it. A bare date or a local time is ambiguous, so it is refused. */ +const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/; + +export interface V2Moved { + /** The v2 organization's page: `/org/`. */ + readonly v2Url: string; + readonly v2Slug: string; + /** When the import finished, normalized to `Date#toISOString`. */ + readonly movedAt: string; +} + +/** The move recorded in an organization's metadata, or `null` when there is + * none or it is malformed. */ +export const parseV2Moved = ( + metadata: Readonly>, + v2Origin: URL, +): V2Moved | null => { + const slug = metadata[V2_MOVED_SLUG_KEY]; + const movedAt = metadata[V2_MOVED_AT_KEY]; + if (typeof slug !== "string" || slug.length > V2_SLUG_MAX_LENGTH || !V2_SLUG.test(slug)) { + return null; + } + if (typeof movedAt !== "string" || !ISO_TIMESTAMP.test(movedAt)) return null; + const time = Date.parse(movedAt); + if (!Number.isFinite(time)) return null; + return { + v2Url: new URL(`/org/${slug}`, v2Origin).href, + v2Slug: slug, + movedAt: new Date(time).toISOString(), + }; +}; + +/** The read the check makes. */ +export interface V2MovedSource { + readonly organizationMetadata: ( + organizationId: string, + ) => Effect.Effect>, unknown>; +} + +/** Whether the organization has moved to v2. Never fails: an unavailable + * read answers `null`, so a WorkOS outage shows no notice. */ +export const v2Moved = ( + source: V2MovedSource, + organizationId: string, + v2Origin: URL, +): Effect.Effect => + source.organizationMetadata(organizationId).pipe( + Effect.map((metadata) => parseV2Moved(metadata, v2Origin)), + Effect.orElseSucceed(() => null), + Effect.withSpan("v2Moved.read"), + ); + +/** The organization's metadata, read live from WorkOS (where v2 writes it). */ +export const liveV2MovedSource = Effect.gen(function* () { + const workos = yield* WorkOSClient; + return { + organizationMetadata: (organizationId) => + workos + .getOrganization(organizationId) + .pipe(Effect.map((organization) => organization.metadata)), + } satisfies V2MovedSource; +}); 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/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index 75de6e1fb6..666ac359f7 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -4,7 +4,7 @@ import { env } from "cloudflare:workers"; import { Autumn } from "autumn-js"; -import { Context, Data, Effect, Layer } from "effect"; +import { Context, Data, Effect, Layer, Option, Schema } from "effect"; import { captureCauseEffect } from "../../observability"; @@ -69,6 +69,27 @@ export const autumnStatusOf = (failure: AutumnFailure): number | undefined => { return typeof statusCode === "number" ? statusCode : undefined; }; +// --------------------------------------------------------------------------- +// Read-only customer lookup +// --------------------------------------------------------------------------- + +/** Autumn's API host, the autumn-js SDK's default server. */ +const AUTUMN_DEFAULT_URL = "https://api.useautumn.com"; +/** The API version autumn-js 1.2.8 sends, so this read sees the same shapes + * as the SDK's calls. */ +const AUTUMN_API_VERSION = "2.2.0"; + +/** One of an Autumn customer's subscriptions, as plan id and status. */ +export type AutumnSubscription = { readonly planId: string; readonly status: string }; + +/** The fields of a `customers.get` answer the app reads; the rest is ignored. */ +const CustomerBody = Schema.Struct({ + subscriptions: Schema.Array(Schema.Struct({ plan_id: Schema.String, status: Schema.String })), +}); +const decodeCustomerBody = Schema.decodeUnknownOption(CustomerBody); +/** Autumn's error body; only its code is read. */ +const decodeErrorBody = Schema.decodeUnknownOption(Schema.Struct({ code: Schema.String })); + // --------------------------------------------------------------------------- // Service interface // --------------------------------------------------------------------------- @@ -80,6 +101,18 @@ export type IAutumnService = Readonly<{ * never seen it. Idempotent — safe to call on every org creation. */ ensureCustomer: (organizationId: string) => Effect.Effect; + /** + * Read the organization's Autumn customer without creating it: its + * subscriptions, or `null` when Autumn has no customer with this id. Any + * other answer, including a 404 that is not `customer_not_found`, fails. + */ + findCustomer: ( + organizationId: string, + ) => Effect.Effect< + { readonly subscriptions: ReadonlyArray } | null, + AutumnError, + never + >; checkExecutionBalance: ( organizationId: string, ) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>; @@ -116,6 +149,7 @@ const make = Effect.sync(() => { return { use: () => notConfigured, ensureCustomer: () => notConfigured, + findCustomer: () => notConfigured, checkExecutionBalance: () => notConfigured, trackExecution: () => Effect.void, setMemberSeats: () => Effect.void, @@ -145,6 +179,61 @@ const make = Effect.sync(() => { const ensureCustomer = (organizationId: string) => Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId }))); + // autumn-js 1.2.8 has no read-only customer call, so `customers.get` goes + // over HTTP with the SDK's secret, server and API version. + const customersGetUrl = `${(env.AUTUMN_API_URL || AUTUMN_DEFAULT_URL).replace(/\/+$/, "")}/v1/customers.get`; + + const findCustomer = (organizationId: string) => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); + const failed = (message: string, cause?: unknown) => new AutumnError({ message, cause }); + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(customersGetUrl, { + method: "POST", + headers: { + authorization: `Bearer ${secretKey}`, + accept: "application/json", + "content-type": "application/json", + "x-api-version": AUTUMN_API_VERSION, + }, + body: JSON.stringify({ customer_id: organizationId }), + signal, + }), + catch: (cause) => failed("Autumn customer lookup failed", cause), + }); + yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }); + if (response.status !== 200 && response.status !== 404) { + return yield* failed(`Autumn customer lookup answered ${response.status}`, { + statusCode: response.status, + }); + } + const body = yield* Effect.tryPromise({ + try: (): Promise => response.json(), + catch: (cause) => failed("Autumn customer lookup returned invalid JSON", cause), + }); + if (response.status === 404) { + // Only Autumn's own code means "no customer"; any other 404, such as + // an unknown route, is a failure. + const missing = Option.exists( + decodeErrorBody(body), + (error) => error.code === CUSTOMER_NOT_FOUND_CODE, + ); + if (missing) return null; + return yield* failed("Autumn customer lookup answered 404", { statusCode: 404 }); + } + const customer = decodeCustomerBody(body); + if (Option.isNone(customer)) { + return yield* failed("Autumn customer lookup returned an unexpected body"); + } + return { + subscriptions: customer.value.subscriptions.map((subscription) => ({ + planId: subscription.plan_id, + status: subscription.status, + })), + }; + }).pipe(Effect.withSpan("autumn.findCustomer")); + /** * Run `operation`; if Autumn answers "no such customer", provision the * organization's customer and run it ONCE more. @@ -239,6 +328,7 @@ const make = Effect.sync(() => { return { use, ensureCustomer, + findCustomer, checkExecutionBalance, trackExecution, setMemberSeats, 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/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index b5c99af906..07c5e38f01 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -227,6 +227,7 @@ const stubDb = Layer.succeed(DbService)({ db: {} as never }); const stubAutumn = Layer.succeed(AutumnService)({ use: unread("the delete does not consult billing"), ensureCustomer: unread("the delete does not provision billing"), + findCustomer: unread("the delete does not read billing"), checkExecutionBalance: unread("the delete does not check balances"), trackExecution: unread("the delete does not track usage"), setMemberSeats: unread("the delete does not count seats"), diff --git a/apps/cloud/src/web/auth.tsx b/apps/cloud/src/web/auth.tsx index 8e46a6cad8..10070a026c 100644 --- a/apps/cloud/src/web/auth.tsx +++ b/apps/cloud/src/web/auth.tsx @@ -34,6 +34,13 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( }), ); +// Whether the selected org can move to Executor v2, and where. Eligibility +// reads WorkOS and Autumn, so it is fetched once per dashboard load and kept. +export const v2ImportAtom = CloudApiClient.query("cloudAuth", "v2Import", { + timeToLive: "5 minutes", + reactivityKeys: [ReactivityKey.auth], +}); + export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); export const deleteOrganization = CloudApiClient.mutation("cloudAuth", "deleteOrganization"); diff --git a/apps/cloud/src/web/components/v2-import-link.tsx b/apps/cloud/src/web/components/v2-import-link.tsx new file mode 100644 index 0000000000..b53668339c --- /dev/null +++ b/apps/cloud/src/web/components/v2-import-link.tsx @@ -0,0 +1,57 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { trackEvent } from "@executor-js/react/api/analytics"; +import { v2ImportAtom } from "../auth"; + +// --------------------------------------------------------------------------- +// Cloud-only sidebar link to Executor v2's workspace import. The server +// decides who sees it (`auth/v2-import.ts`) and answers with the page URL only +// for users v2 would let import the selected org; everyone else, and every +// failure, renders nothing. Opens in a new tab, like the sidebar's Docs link. +// +// Once the org has moved, the link opens the org in v2 instead: the moved +// notice can be snoozed, and this stays the way back to v2. +// --------------------------------------------------------------------------- + +function MoveIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function V2ImportLink() { + const result = useAtomValue(v2ImportAtom); + if (!AsyncResult.isSuccess(result)) return null; + const { url, moved } = result.value; + const link = + moved !== null + ? { + href: moved.v2Url, + label: "Open in Executor v2", + track: () => trackEvent("v2_moved_open_clicked", { via: "sidebar" }), + } + : url !== null + ? { href: url, label: "Move to Executor v2", track: () => trackEvent("v2_import_opened") } + : null; + if (link === null) return null; + return ( + + + {link.label} + + ); +} diff --git a/apps/cloud/src/web/components/v2-moved-notice.tsx b/apps/cloud/src/web/components/v2-moved-notice.tsx new file mode 100644 index 0000000000..4c7f981e3d --- /dev/null +++ b/apps/cloud/src/web/components/v2-moved-notice.tsx @@ -0,0 +1,207 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import { trackEvent } from "@executor-js/react/api/analytics"; +import { Button } from "@executor-js/react/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@executor-js/react/components/dialog"; +import { Label } from "@executor-js/react/components/label"; +import { Textarea } from "@executor-js/react/components/textarea"; +import { useAuth, v2ImportAtom } from "../auth"; +import { + browserSnoozeStorage, + isV2MovedSnoozed, + snoozeV2Moved, + V2_MOVED_DISMISS_SNOOZE_MS, + V2_MOVED_FEEDBACK_SNOOZE_MS, +} from "../v2-moved-snooze"; + +// --------------------------------------------------------------------------- +// Cloud-only notice for an organization v2 has already imported (the server +// reads the move from the WorkOS organization, `auth/v2-moved.ts`). It opens +// once per dashboard load unless snoozed for this org in this browser: +// - ✕ / Escape / opening v2 hidden for 7 days, nothing asked +// - "Keep using v1" step 2 asks why; sending hides it for 30 days +// v1 keeps working for a moved org; this only tells the user where it went. +// --------------------------------------------------------------------------- + +type Step = "notice" | "feedback"; + +const FEEDBACK_MAX_LENGTH = 2000; + +const movedDate = (movedAt: string): string => + new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format( + new Date(movedAt), + ); + +function ArrowIcon() { + return ( + + + + ); +} + +export function V2MovedNotice() { + const auth = useAuth(); + const result = useAtomValue(v2ImportAtom); + const moved = AsyncResult.isSuccess(result) ? result.value.moved : null; + const organization = auth.status === "authenticated" ? auth.organization : null; + const organizationId = organization?.id ?? null; + + const [open, setOpen] = useState(false); + const [step, setStep] = useState("notice"); + const [feedback, setFeedback] = useState(""); + const shownFor = useRef(null); + const feedbackId = useId(); + + // Decide once per org per page load, after hydration: the snooze lives in + // localStorage, which the server render cannot see. + useEffect(() => { + if (moved === null || organizationId === null) return; + if (shownFor.current === organizationId) return; + shownFor.current = organizationId; + if (isV2MovedSnoozed(browserSnoozeStorage(), organizationId, Date.now())) return; + setStep("notice"); + setFeedback(""); + setOpen(true); + trackEvent("v2_moved_modal_shown"); + }, [moved, organizationId]); + + if (moved === null || organization === null) return null; + + const close = (snoozeMs: number) => { + snoozeV2Moved(browserSnoozeStorage(), organization.id, Date.now(), snoozeMs); + setOpen(false); + }; + + const trimmed = feedback.trim(); + + return ( + { + if (next) return; + trackEvent("v2_moved_dismissed", { step }); + close(V2_MOVED_DISMISS_SNOOZE_MS); + }} + > + {/* One shrinkable column: a grid item's default min-width is its content, which let a long + organization name push the pair and the buttons past the dialog's edge. */} + + {step === "notice" ? ( + <> + + + Moved {movedDate(moved.movedAt)} + + + {organization.name} now lives in Executor v2 + + + +
+ + v1 · {organization.name} + + + → + + + v2 · {moved.v2Slug} + +
+ + + Changes made here won't sync to v2. + + + + + + + + ) : ( +
{ + event.preventDefault(); + if (trimmed === "") return; + trackEvent("v2_moved_feedback_sent", { feedback: trimmed }); + close(V2_MOVED_FEEDBACK_SNOOZE_MS); + }} + > + + What went wrong? + + Tell us why you're staying on v1, so we can fix it. + + + +
+ +