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/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9daf5378b..5faa155563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,12 @@ env: EXECUTOR_DISABLE_ANALYTICS: "1" EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1" +# Outside contributions are ported into the private repo, which runs the +# checks there, so fork PRs skip CI here. Jobs that need `changes` skip with it. jobs: changes: name: Changed paths + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 outputs: @@ -78,6 +81,7 @@ jobs: format: name: Format + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -101,6 +105,7 @@ jobs: lint: name: Lint + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 10 steps: @@ -124,6 +129,7 @@ jobs: typecheck: name: Typecheck + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 15 env: @@ -165,6 +171,7 @@ jobs: test: name: Test + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 15 # Run eight independent packages at once and cap each Vitest process at two diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 12b83997c8..b9e59936d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -106,6 +106,16 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # The unplaced front Worker on the `executor.sh/*` route (src/edge/front.ts). + # After executor-cloud, so each deploy changes the edge's settings in + # executor-cloud before the Worker in front of it. + - name: Deploy executor.sh front Worker + run: bun run wrangler deploy -c wrangler.edge.jsonc + working-directory: apps/cloud + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Deploy marker: one event per deploy into the same Axiom dataset the # worker traces land in, so a latency step-change lines up with its # deploy in one query. Skipped (not failed) when the secret is absent, diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 73f7255814..5662270268 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -14,6 +14,7 @@ "executor-*.tgz", "**/routeTree.gen.ts", "**/smoke-harness-bundle.gen.ts", - "apps/cloud/src/services/executor-schema.ts" + "apps/cloud/src/services/executor-schema.ts", + "apps/cloud/src/edge/v2-edge-contract.json" ] } 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 98c1f3997c..dd1c46124f 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/front.test.ts b/apps/cloud/src/edge/front.test.ts new file mode 100644 index 0000000000..d2b3684fc8 --- /dev/null +++ b/apps/cloud/src/edge/front.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { frontResponse, type FrontEnv } from "./front"; + +/** A Worker stand-in that records what it receives and answers with `respond`. */ +const recordingWorker = (respond: () => Response) => { + const received: Request[] = []; + return { + received, + worker: { + fetch: (request: Request) => { + received.push(request); + return Promise.resolve(respond()); + }, + }, + }; +}; + +const settings = (v2: FrontEnv["V2"], marketing?: FrontEnv["MARKETING"]): FrontEnv => ({ + V2: v2, + MARKETING: marketing, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", +}); + +const IMMUTABLE = "public, max-age=31536000, immutable"; + +describe("frontResponse", () => { + it("returns v2's asset with its caching, status and body unchanged, adding only the referrer policy", async () => { + const v2 = recordingWorker( + () => + new Response("console.log(1)", { + status: 200, + headers: { + "cache-control": IMMUTABLE, + "content-type": "text/javascript", + etag: '"abc"', + }, + }), + ); + + const response = await frontResponse( + new Request("https://executor.sh/_astro/page.abc123.js"), + settings(v2.worker), + ); + + expect(v2.received.map((request) => request.url)).toEqual([ + "https://executor.sh/_astro/page.abc123.js", + ]); + expect(response?.status).toBe(200); + expect(response?.headers.get("cache-control")).toBe(IMMUTABLE); + expect(response?.headers.get("content-type")).toBe("text/javascript"); + expect(response?.headers.get("etag")).toBe('"abc"'); + expect(response?.headers.get("referrer-policy")).toBe("no-referrer"); + expect(await response?.text()).toBe("console.log(1)"); + }); + + it("answers v2's redirects itself, as executor-cloud does", async () => { + const v2 = recordingWorker(() => new Response("v2")); + + const response = await frontResponse( + new Request("https://executor.sh/sign-up"), + settings(v2.worker), + ); + + expect(v2.received).toHaveLength(0); + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe("https://app.executor.sh/login?mode=signup"); + expect(response?.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("sends v1's legal pages to v1's marketing worker", async () => { + const v2 = recordingWorker(() => new Response("v2")); + const marketing = recordingWorker(() => new Response("terms")); + + const response = await frontResponse( + new Request("https://executor.sh/terms"), + settings(v2.worker, marketing.worker), + ); + + expect(v2.received).toHaveLength(0); + expect(marketing.received.map((request) => request.url)).toEqual(["https://executor.sh/terms"]); + expect(await response?.text()).toBe("terms"); + expect(response?.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("passes on every request executor-cloud serves itself", () => { + const v2 = recordingWorker(() => new Response("v2")); + const marketing = recordingWorker(() => new Response("terms")); + const env = settings(v2.worker, marketing.worker); + + for (const request of [ + new Request("https://executor.sh/", { headers: { cookie: "wos-session=sealed" } }), + new Request("https://executor.sh/login"), + new Request("https://executor.sh/mcp", { method: "POST" }), + new Request("https://executor.sh/api/auth/callback?code=c&state=s"), + new Request("https://executor.sh/api/oauth/callback?code=c&state=abc"), + new Request("https://executor.sh/acme"), + new Request("https://executor.sh/favicon.ico"), + ]) { + expect(frontResponse(request, env), request.url).toBeNull(); + } + expect(v2.received).toHaveLength(0); + expect(marketing.received).toHaveLength(0); + }); + + it("passes everything on when v2's settings are absent", () => { + expect(frontResponse(new Request("https://executor.sh/_astro/page.abc123.js"), {})).toBeNull(); + }); +}); diff --git a/apps/cloud/src/edge/front.ts b/apps/cloud/src/edge/front.ts new file mode 100644 index 0000000000..910a42eef9 --- /dev/null +++ b/apps/cloud/src/edge/front.ts @@ -0,0 +1,47 @@ +// --------------------------------------------------------------------------- +// The `executor.sh` front Worker (`wrangler.edge.jsonc`, Worker +// `executor-cloud-edge`). +// +// `executor-cloud` is placed near v1's database (`placement.region` in +// wrangler.jsonc), so every request it runs crosses to Virginia first. The +// requests its edge hands to v2 or to v1's marketing worker never use that +// database, yet each paid the trip: a 2 KB `/_astro/*` file took up to 2.6 s. +// This Worker has no placement and runs the same edge decision +// (./marketing.ts) where the request lands, on a zone route +// `executor.sh/*`. A zone route runs before the Worker on the hostname's +// Custom Domain, which Cloudflare treats as the origin, so a request v1 owns +// goes on with `fetch(request)` to `executor-cloud` unchanged: same URL, +// method, headers, cookies, body stream and redirect handling as before. +// +// `executor-cloud` keeps its own copy of the edge, which answers nothing this +// Worker passes on. Deleting this Worker (or its route) returns `executor.sh` +// to exactly that, which is the rollback. +// --------------------------------------------------------------------------- + +import { marketingProxyRequest, v2EdgeResponse, type V2EdgeEnv } from "./marketing"; +import { withPrivateReferrerPolicy } from "./referrer-policy"; + +/** The front Worker's bindings and settings (`wrangler.edge.jsonc`). */ +export interface FrontEnv extends V2EdgeEnv { + /** v1's marketing worker, which serves v1's legal pages. */ + readonly MARKETING?: { readonly fetch: (request: Request) => Promise }; +} + +/** + * Answer an `executor.sh` request that v2 or v1's marketing worker owns, + * exactly as `executor-cloud`'s entry does, including its `no-referrer` + * policy. Returns `null` for every request `executor-cloud` serves itself. + */ +export const frontResponse = (request: Request, env: FrontEnv): Promise | null => { + const v2 = v2EdgeResponse(request, env); + if (v2) return v2.then(withPrivateReferrerPolicy); + const marketingRequest = marketingProxyRequest(request); + if (marketingRequest && env.MARKETING) { + return env.MARKETING.fetch(marketingRequest).then(withPrivateReferrerPolicy); + } + return null; +}; + +export default { + fetch: (request, env) => frontResponse(request, env) ?? fetch(request), +} satisfies ExportedHandler; diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index b6af946dda..146ea0c2ef 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -1,103 +1,987 @@ import { describe, expect, it } from "@effect/vitest"; -import { isMarketingPath, marketingProxyRequest } from "./marketing"; +import { + isMarketingPath, + isSignUpPath, + isV2MarketingPath, + isV2Path, + marketingProxyRequest, + parseV2Edge, + v2EdgeResponse, + v2PageForSlashedPath, + type V2EdgeEnv, + type V2Service, +} from "./marketing"; -// On executor.sh the marketing middleware proxies an allow-list of paths to the -// `executor-marketing` worker; everything else falls through to the auth-gated -// cloud app (the sign-in page). `/blog` and `/llms.txt` are public content, so -// they must be on the allow-list: without it an unauthenticated visit redirects -// to `/login?returnTo=...` and the reader bounces. +// On executor.sh only v1's legal pages (terms, privacy policy and Google OAuth +// disclosure) and their asset root still go to v1's `executor-marketing` +// worker; v2 serves the rest of marketing. describe("isMarketingPath", () => { - const marketing = [ - "/home", - "/privacy", + const v1Marketing = [ "/terms", - "/pricing", - "/about-executor", + "/terms/", + "/privacy", "/google-oauth", + "/_v1-marketing/Layout.css", + "/_v1-marketing/_ph/e", + ]; + for (const pathname of v1Marketing) { + it(`sends ${pathname} to v1's marketing worker`, () => { + expect(isMarketingPath(pathname)).toBe(true); + }); + } + + const notV1Marketing = [ + "/", + "/home", + "/pricing", + "/blog", "/google-workspace", + "/_astro/app.css", + "/_astro/_ph/e", + "/termsandconditions", + "/privacy-team/mcp", + "/google-oauthx", + "/_v1-marketingx", + "/login", + ]; + for (const pathname of notV1Marketing) { + it(`does not send ${pathname} to v1's marketing worker`, () => { + expect(isMarketingPath(pathname)).toBe(false); + }); + } +}); + +describe("marketingProxyRequest", () => { + it("routes v1's terms, method, headers and body unchanged", async () => { + const request = new Request("https://executor.sh/_v1-marketing/_ph/capture?ip=1", { + method: "POST", + headers: { "content-type": "application/json", "x-request-id": "request-1" }, + body: JSON.stringify({ event: "test" }), + }); + + const proxied = marketingProxyRequest(request); + + expect(proxied?.url).toBe("https://executor.sh/_v1-marketing/_ph/capture?ip=1"); + expect(proxied?.method).toBe("POST"); + expect(proxied?.headers.get("x-request-id")).toBe("request-1"); + await expect(proxied?.json()).resolves.toEqual({ event: "test" }); + for (const path of ["/terms", "/privacy", "/google-oauth"]) { + expect(marketingProxyRequest(new Request(`https://executor.sh${path}`))?.url).toBe( + `https://executor.sh${path}`, + ); + } + }); + + it("leaves the homepage and v2's marketing to the v2 edge", () => { + expect(marketingProxyRequest(new Request("https://executor.sh/"))).toBeNull(); + expect(marketingProxyRequest(new Request("https://executor.sh/home"))).toBeNull(); + expect(marketingProxyRequest(new Request("https://executor.sh/pricing"))).toBeNull(); + }); + + it("does not proxy non-production hosts", () => { + expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/terms"))).toBeNull(); + }); +}); + +// v2 on executor.sh: sign-up redirects to v2, and a fixed list of paths is +// forwarded to v2's Worker. Everything else stays with v1. +describe("isV2Path", () => { + const forwarded = [ + "/.well-known/oauth-authorization-server/api/auth", + "/api/auth/callback/google", + "/api/auth/callback/github", + "/git/acme/tools/info/refs", + "/git/acme/tools/git-upload-pack", + "/git/acme/tools/git-receive-pack", + "/.well-known/agent-skills/", + "/.well-known/agent-skills/index.json", + ]; + for (const pathname of forwarded) { + it(`forwards ${pathname} to v2`, () => { + expect(isV2Path(pathname)).toBe(true); + }); + } + + const v1Owned = [ + // v1's WorkOS callback has no provider segment. + "/api/auth/callback", + "/api/auth/callback/", + "/api/auth/callback/google/extra", + "/api/auth/login", + "/api/auth/me", + // v1's own issuer metadata and client metadata document. + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-authorization-server/api/auth/extra", + "/.well-known/oauth-protected-resource/mcp", + "/oauth/client-id-metadata.json", + // v2 does not serve OpenID configuration on executor.sh, and its client + // metadata document's move to executor.sh is pending. + "/api/auth/.well-known/openid-configuration", + "/oauth/client-metadata.json", + // Git remotes need a path under /git/. + "/git", + "/gitlab/acme/tools/info/refs", + "/github", + "/.well-known/agent-skills", + "/.well-known/agent-skillset/index.json", + // The connected-account callback goes by its state, not its path, and + // marketing has its own list. + "/api/oauth/callback", + "/pricing", + // v1 dashboard, org pages and MCP, including org slugs that look similar. + "/", + "/login", + "/mcp", + "/acme/mcp", + "/gitops/mcp", + "/git-team/policies", + "/oauth-team/mcp", + "/api/connections", + ]; + for (const pathname of v1Owned) { + it(`leaves ${pathname} with v1`, () => { + expect(isV2Path(pathname)).toBe(false); + }); + } +}); + +describe("v2PageForSlashedPath", () => { + it("canonicalizes only v2's exact pages", () => { + expect(v2PageForSlashedPath("/pricing/")).toBe("/pricing"); + expect(v2PageForSlashedPath("/google-workspace/")).toBe("/google-workspace"); + expect(v2PageForSlashedPath("/pricing")).toBeNull(); + expect(v2PageForSlashedPath("/pricing.md/")).toBeNull(); + expect(v2PageForSlashedPath("/blog/")).toBeNull(); + expect(v2PageForSlashedPath("/terms/")).toBeNull(); + expect(v2PageForSlashedPath("/")).toBeNull(); + }); +}); + +describe("isSignUpPath", () => { + it("claims /sign-up and /signup only", () => { + expect(isSignUpPath("/sign-up")).toBe(true); + expect(isSignUpPath("/signup")).toBe(true); + expect(isSignUpPath("/sign-up/")).toBe(false); + expect(isSignUpPath("/signup/team")).toBe(false); + expect(isSignUpPath("/sign-in")).toBe(false); + expect(isSignUpPath("/signups")).toBe(false); + }); +}); + +describe("parseV2Edge", () => { + const service: V2Service = { fetch: () => Promise.resolve(new Response(null)) }; + const settings = { + V2: service, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }; + + it("is off when no setting is present", () => { + expect(parseV2Edge({})).toBeNull(); + }); + + it("refuses any setting set without the others", () => { + expect(typeof parseV2Edge({ ...settings, V2: undefined })).toBe("string"); + expect(typeof parseV2Edge({ ...settings, V2_SIGN_UP_URL: undefined })).toBe("string"); + expect(typeof parseV2Edge({ ...settings, V2_OAUTH_STATE_PREFIX: undefined })).toBe("string"); + expect(typeof parseV2Edge({ ...settings, V2_ANALYTICS_PROXY_PATH: undefined })).toBe("string"); + expect(typeof parseV2Edge({ ...settings, V2_ERROR_TUNNEL_PATH: undefined })).toBe("string"); + expect(typeof parseV2Edge({ V2_OAUTH_STATE_PREFIX: "x2." })).toBe("string"); + }); + + it("refuses a sign-up URL that is not absolute http(s)", () => { + expect(typeof parseV2Edge({ ...settings, V2_SIGN_UP_URL: "/login?mode=signup" })).toBe( + "string", + ); + expect(typeof parseV2Edge({ ...settings, V2_SIGN_UP_URL: "javascript:alert(1)" })).toBe( + "string", + ); + }); + + // v1's states are base64url (raw, or the org-wrapped JSON encoding), so a + // prefix made only of base64url characters could claim a v1 callback. + it("refuses a state prefix that a v1 state could start with", () => { + for (const prefix of ["", "x2", "x2-", "x2_", "eyJ", "x2 .", "x2.%", "x2./"]) { + expect(typeof parseV2Edge({ ...settings, V2_OAUTH_STATE_PREFIX: prefix })).toBe("string"); + } + }); + + it("refuses an analytics proxy path other than /api/<16 lowercase hex>", () => { + for (const path of [ + "", + "/api/0123456789abcde", + "/api/0123456789abcdef0", + "/api/0123456789ABCDEF", + "/api/0123456789abcdef/", + "/api/0123456789abcdef/submit", + "/api/connections", + "/0123456789abcdef", + " /api/0123456789abcdef", + "/api/0123456789abcdef,/api/fedcba9876543210", + ]) { + expect(typeof parseV2Edge({ ...settings, V2_ANALYTICS_PROXY_PATH: path })).toBe("string"); + } + }); + + it("refuses an error tunnel path other than /api/<16 lowercase hex>/submit", () => { + for (const path of [ + "", + "/api/fedcba9876543210", + "/api/fedcba9876543210/", + "/api/fedcba9876543210/submit/", + "/api/fedcba9876543210/submitx", + "/api/FEDCBA9876543210/submit", + "/api/fedcba987654321/submit", + "/api/fedcba9876543210/e", + "/fedcba9876543210/submit", + " /api/fedcba9876543210/submit", + ]) { + expect(typeof parseV2Edge({ ...settings, V2_ERROR_TUNNEL_PATH: path })).toBe("string"); + } + }); + + it("parses all settings", () => { + const edge = parseV2Edge(settings); + if (edge === null || typeof edge === "string") return expect.unreachable("settings must parse"); + expect(edge.signUpUrl.href).toBe("https://app.executor.sh/login?mode=signup"); + expect(edge.oauthStatePrefix).toBe("x2."); + expect(edge.analyticsProxyPath).toBe("/api/0123456789abcdef"); + expect(edge.errorTunnelPath).toBe("/api/fedcba9876543210/submit"); + expect(typeof parseV2Edge({ ...settings, V2_OAUTH_STATE_PREFIX: "v2~" })).toBe("object"); + }); +}); + +describe("v2EdgeResponse", () => { + const SIGN_UP_URL = "https://v2.executor.sh/login?mode=signup"; + const STATE_PREFIX = "x2."; + + /** The edge's settings with `service` as v2's Worker. */ + const settings = (service: V2Service, signUpUrl = SIGN_UP_URL): V2EdgeEnv => ({ + V2: service, + V2_SIGN_UP_URL: signUpUrl, + V2_OAUTH_STATE_PREFIX: STATE_PREFIX, + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }); + + /** A v2 service that records what it receives and answers with `respond`. */ + const recordingService = (respond: (request: Request) => Promise | Response) => { + const received: Request[] = []; + const service: V2Service = { + fetch: async (request) => { + received.push(request); + return respond(request); + }, + }; + return { received, service }; + }; + + it("redirects sign-up to v2's configured sign-up page, ignoring the query", async () => { + const { received, service } = recordingService(() => new Response(null)); + + for (const url of ["https://executor.sh/sign-up", "https://executor.sh/signup?ref=docs"]) { + const response = await v2EdgeResponse(new Request(url), settings(service)); + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe(SIGN_UP_URL); + } + expect(received).toHaveLength(0); + }); + + it("follows the configured sign-up URL", async () => { + const response = await v2EdgeResponse( + new Request("https://executor.sh/signup"), + settings( + { fetch: () => Promise.resolve(new Response(null)) }, + "https://app.executor.sh/sign-up", + ), + ); + expect(response?.headers.get("location")).toBe("https://app.executor.sh/sign-up"); + }); + + it("leaves non-GET sign-up requests with v1", () => { + const { service } = recordingService(() => new Response(null)); + expect( + v2EdgeResponse( + new Request("https://executor.sh/sign-up", { method: "POST" }), + settings(service), + ), + ).toBeNull(); + }); + + it("answers edge requests with a 500 on a broken setting and leaves other paths with v1", async () => { + const { received, service } = recordingService(() => new Response(null)); + const response = await v2EdgeResponse( + new Request("https://executor.sh/sign-up"), + settings(service, "/relative"), + ); + expect(response?.status).toBe(500); + expect( + v2EdgeResponse(new Request("https://executor.sh/acme/mcp"), settings(service, "/relative")), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("is off without settings", () => { + expect(v2EdgeResponse(new Request("https://executor.sh/sign-up"), {})).toBeNull(); + }); + + it("only acts on executor.sh", () => { + const { service } = recordingService(() => new Response(null)); + expect( + v2EdgeResponse(new Request("http://executor-cloud.localhost/sign-up"), settings(service)), + ).toBeNull(); + expect( + v2EdgeResponse( + new Request("https://v2.executor.sh/api/auth/callback/google"), + settings(service), + ), + ).toBeNull(); + }); + + it("leaves v1 paths with v1", () => { + const { received, service } = recordingService(() => new Response(null)); + expect( + v2EdgeResponse( + new Request("https://executor.sh/api/auth/callback?code=c"), + settings(service), + ), + ).toBeNull(); + expect( + v2EdgeResponse(new Request("https://executor.sh/gitlab/x/info/refs"), settings(service)), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("forwards with the public host, path and query, and returns v2's redirect unfollowed", async () => { + const { received, service } = recordingService( + () => + new Response(null, { + status: 302, + headers: { location: "https://app.executor.sh/api/auth/callback/google?code=c&state=s" }, + }), + ); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/api/auth/callback/google?code=c&state=s"), + settings(service), + ); + + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe( + "https://app.executor.sh/api/auth/callback/google?code=c&state=s", + ); + expect(received).toHaveLength(1); + expect(received[0]?.url).toBe("https://executor.sh/api/auth/callback/google?code=c&state=s"); + expect(received[0]?.redirect).toBe("manual"); + }); + + it("strips v1's cookies and client forwarding headers but keeps Authorization", async () => { + const { received, service } = recordingService(() => new Response("ok")); + + await v2EdgeResponse( + new Request("https://executor.sh/git/acme/tools/info/refs?service=git-upload-pack", { + headers: { + authorization: "Basic dXNlcjp0b2tlbg==", + cookie: "wos-session=sealed; ph_id=1", + "git-protocol": "version=2", + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "http", + }, + }), + settings(service), + ); + + const forwarded = received[0]; + expect(forwarded?.headers.get("authorization")).toBe("Basic dXNlcjp0b2tlbg=="); + expect(forwarded?.headers.get("git-protocol")).toBe("version=2"); + expect(forwarded?.headers.has("cookie")).toBe(false); + expect(forwarded?.headers.has("x-forwarded-host")).toBe(false); + expect(forwarded?.headers.has("x-forwarded-proto")).toBe(false); + expect(new URL(forwarded?.url ?? "").search).toBe("?service=git-upload-pack"); + }); + + it("returns v2's response unchanged, status and body stream included", async () => { + const upstream = new Response("not found", { + status: 404, + headers: { "content-type": "text/plain", "www-authenticate": 'Basic realm="git"' }, + }); + const { service } = recordingService(() => upstream); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/git/acme/tools/info/refs"), + settings(service), + ); + + expect(response).toBe(upstream); + }); + + it("streams a git push body to v2 without buffering it, preserving the method", async () => { + const encoder = new TextEncoder(); + let source: ReadableStreamDefaultController | undefined; + const body = new ReadableStream({ + start(controller) { + source = controller; + controller.enqueue(encoder.encode("first-pack-chunk")); + }, + }); + // v2 reads the first chunk while the client is still sending: a buffering + // edge would wait for the end of the body and never reach v2. + const { received, service } = recordingService(async (request) => { + const reader = request.body?.getReader(); + if (reader === undefined) return new Response("no body", { status: 500 }); + const first = await reader.read(); + source?.enqueue(encoder.encode("second-pack-chunk")); + source?.close(); + const second = await reader.read(); + const done = await reader.read(); + const decoder = new TextDecoder(); + return new Response( + `${decoder.decode(first.value)}|${decoder.decode(second.value)}|${done.done}`, + ); + }); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/git/acme/tools/git-receive-pack", { + method: "POST", + headers: { "content-type": "application/x-git-receive-pack-request" }, + body, + // @ts-expect-error -- Node's fetch needs `duplex` for a stream body; workerd does not. + duplex: "half", + }), + settings(service), + ); + + expect(received[0]?.method).toBe("POST"); + expect(received[0]?.headers.get("content-type")).toBe("application/x-git-receive-pack-request"); + expect(await response?.text()).toBe("first-pack-chunk|second-pack-chunk|true"); + }); +}); + +// v1 and v2 share `/api/oauth/callback` on executor.sh. v2 starts its state +// with a fixed prefix; the edge reads only the query's `state` to decide. +describe("v2EdgeResponse connected-account callback", () => { + const settings = (service: V2Service): V2EdgeEnv => ({ + V2: service, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }); + + const recordingService = () => { + const received: Request[] = []; + const service: V2Service = { + fetch: async (request) => { + received.push(request); + return new Response(null, { + status: 302, + headers: { location: `https://app.executor.sh${new URL(request.url).search}` }, + }); + }, + }; + return { received, service }; + }; + + it("forwards a callback whose state carries v2's prefix, query unchanged", async () => { + const { received, service } = recordingService(); + const callback = "https://executor.sh/api/oauth/callback?code=c&state=x2.abc123"; + + const response = await v2EdgeResponse( + new Request(callback, { headers: { cookie: "wos-session=sealed" } }), + settings(service), + ); + + expect(response?.status).toBe(302); + expect(received).toHaveLength(1); + expect(received[0]?.url).toBe(callback); + expect(received[0]?.redirect).toBe("manual"); + expect(received[0]?.headers.has("cookie")).toBe(false); + }); + + it("reads a percent-encoded prefix as the prefix", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/api/oauth/callback?state=x2%2Eabc&code=c"), + settings(service), + ); + + expect(received).toHaveLength(1); + }); + + it("forwards v2's provider errors, which carry the state but no code", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/api/oauth/callback?error=access_denied&state=x2.abc"), + settings(service), + ); + + expect(received).toHaveLength(1); + }); + + const v1Callbacks = [ + // v1's raw state and its org-wrapped base64url JSON state. + "https://executor.sh/api/oauth/callback?code=c&state=Q2xpZW50U3RhdGUxMjM0NTY3ODkw", + "https://executor.sh/api/oauth/callback?code=c&state=eyJzdGF0ZSI6InMiLCJvcmdTbHVnIjoiYWNtZSJ9", + // No state, or an empty one. + "https://executor.sh/api/oauth/callback?code=c", + "https://executor.sh/api/oauth/callback?code=c&state=", + "https://executor.sh/api/oauth/callback", + // Lookalikes: the prefix without its dot, another case, inside the + // value, or in another parameter. + "https://executor.sh/api/oauth/callback?code=c&state=x2abc", + "https://executor.sh/api/oauth/callback?code=c&state=x2-abc", + "https://executor.sh/api/oauth/callback?code=c&state=X2.abc", + "https://executor.sh/api/oauth/callback?code=c&state=ax2.abc", + "https://executor.sh/api/oauth/callback?code=c&state=%20x2.abc", + "https://executor.sh/api/oauth/callback?code=x2.abc&state=v1state", + "https://executor.sh/api/oauth/callback?code=c&xstate=x2.abc", + // Only the first state counts. + "https://executor.sh/api/oauth/callback?state=v1state&state=x2.abc", + // Other paths with a v2 state. + "https://executor.sh/api/oauth/callback/?state=x2.abc", + "https://executor.sh/api/oauth/callbacks?state=x2.abc", + "https://executor.sh/api/oauth/callback/extra?state=x2.abc", + "https://executor.sh/acme/api/oauth/callback?state=x2.abc", + ]; + for (const url of v1Callbacks) { + it(`leaves ${new URL(url).pathname}${new URL(url).search} with v1`, () => { + const { received, service } = recordingService(); + expect(v2EdgeResponse(new Request(url), settings(service))).toBeNull(); + expect(received).toHaveLength(0); + }); + } + + it("decides from the query without reading a posted body", () => { + const { received, service } = recordingService(); + const body = new ReadableStream({ + pull: () => expect.unreachable("the edge must not read the body"), + }); + + expect( + v2EdgeResponse( + new Request("https://executor.sh/api/oauth/callback", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + // @ts-expect-error -- Node's fetch needs `duplex` for a stream body; workerd does not. + duplex: "half", + }), + settings(service), + ), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("only acts on executor.sh", () => { + const { received, service } = recordingService(); + expect( + v2EdgeResponse( + new Request("https://v2.executor.sh/api/oauth/callback?state=x2.abc"), + settings(service), + ), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("leaves every callback with v1 when the settings are absent or broken", () => { + const { received, service } = recordingService(); + const request = () => new Request("https://executor.sh/api/oauth/callback?state=x2.abc"); + + expect(v2EdgeResponse(request(), {})).toBeNull(); + expect(v2EdgeResponse(request(), { ...settings(service), V2_SIGN_UP_URL: "/x" })).toBeNull(); + expect( + v2EdgeResponse(request(), { ...settings(service), V2_OAUTH_STATE_PREFIX: "x2" }), + ).toBeNull(); + expect(received).toHaveLength(0); + }); +}); + +// v2's marketing site answers executor.sh: the landing page without a v1 +// session, its pages, files, assets and docs, and its telemetry proxies. A +// pattern is exact, or `/x/*` for everything that starts with `/x/`. +describe("isV2MarketingPath", () => { + const v2Marketing = [ + "/home", + "/about-executor", "/blog", "/blog/", "/blog/some-post", - "/llms.txt", + "/pricing", + "/google-workspace", "/index.md", - "/setup-prompt.md", + "/llms.txt", "/pricing.md", + "/setup-prompt.md", + "/_astro/app.Cld-QA3g.css", + "/authors/author.png", "/og-image.png", - "/_astro/app.css", - // The blog author card loads its avatar from marketing's public/authors; - // without this the pfp 404s on every post. - "/authors/rhys-sullivan.png", + "/pattern-graph-paper.svg", + "/docs", + "/docs/", + "/docs/quickstart", + "/docs/llms.txt", + // `apps` and `experiments` are reserved v1 organization slugs. + "/apps", + "/apps/", + "/apps/detail", + "/experiments/", + "/experiments/hero/b", ]; - for (const pathname of marketing) { - it(`proxies ${pathname} to marketing`, () => { - expect(isMarketingPath(pathname)).toBe(true); + for (const pathname of v2Marketing) { + it(`sends ${pathname} to v2`, () => { + expect(isV2MarketingPath(pathname)).toBe(true); }); } - // App-owned routes must reach the Effect handler, not marketing. `/blogger` - // guards against a bare `startsWith("/blog")` swallowing unrelated words. - const notMarketing = ["/", "/login", "/cloud", "/mcp", "/dashboard", "/blogger"]; - for (const pathname of notMarketing) { - it(`leaves ${pathname} alone`, () => { - expect(isMarketingPath(pathname)).toBe(false); + const v1Owned = [ + // The homepage depends on the session; see v2EdgeResponse. + "/", + // `/demo` is an unreserved v1 organization slug; v2 publishes nothing at + // a bare `/experiments`. + "/demo", + "/demo/workflows", + "/experiments", + // Exact pages match only themselves, and directories only what is below + // them. + "/pricing/extra", + "/home/extra", + "/home/", + "/llms.txt/x", + "/_astro", + "/authors", + "/google-workspace/x", + // v1's legal pages, and its dashboard's favicons. + "/terms", + "/privacy", + "/google-oauth", + "/favicon.ico", + "/favicon-32.png", + "/apple-touch-icon.png", + // Lookalikes and v1 routes. + "/blogger", + "/appsmith", + "/experimentsx/a", + "/docsearch", + "/_astrox/app.css", + "/home-team/policies", + "/pricing-team/mcp", + "/setup", + "/login", + "/api/docs", + "/acme/docs", + ]; + for (const pathname of v1Owned) { + it(`leaves ${pathname} with v1`, () => { + expect(isV2MarketingPath(pathname)).toBe(false); }); } }); -describe("marketingProxyRequest", () => { - it("routes a signed-out homepage request", () => { - const request = new Request("https://executor.sh/?source=test"); +describe("v2EdgeResponse marketing", () => { + const settings = (service: V2Service): V2EdgeEnv => ({ + V2: service, + V2_SIGN_UP_URL: "https://app.executor.sh/login?mode=signup", + V2_OAUTH_STATE_PREFIX: "x2.", + V2_ANALYTICS_PROXY_PATH: "/api/0123456789abcdef", + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210/submit", + }); - const proxied = marketingProxyRequest(request); + const recordingService = (respond: () => Response = () => new Response("v2")) => { + const received: Request[] = []; + const service: V2Service = { + fetch: async (request) => { + received.push(request); + return respond(); + }, + }; + return { received, service }; + }; - expect(proxied?.url).toBe("https://executor.sh/?source=test"); + // A signed-out visitor at `/pricing/` would otherwise reach v1's sign-in + // gate, which redirects to login. + it("redirects v2's slashed exact pages to the page, query kept, without forwarding", async () => { + const { received, service } = recordingService(); + + for (const [target, location] of [ + ["/pricing/", "/pricing"], + ["/pricing/?ref=hn", "/pricing?ref=hn"], + ["/home/", "/home"], + ["/about-executor/", "/about-executor"], + ["/google-workspace/", "/google-workspace"], + ] as const) { + for (const method of ["GET", "HEAD"]) { + const response = await v2EdgeResponse( + new Request(`https://executor.sh${target}`, { + method, + headers: { cookie: "wos-session=sealed" }, + }), + settings(service), + ); + expect(response?.status, `${method} ${target}`).toBe(308); + expect(response?.headers.get("location"), `${method} ${target}`).toBe( + `https://executor.sh${location}`, + ); + } + } + expect(received).toHaveLength(0); }); - it("leaves the signed-in homepage with the cloud application", () => { - const request = new Request("https://executor.sh/", { - headers: { cookie: "other=value; wos-session=sealed" }, - }); + it("forwards the slashed form of a page v2 owns below, such as /blog/", async () => { + const { received, service } = recordingService(); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/docs/"), + settings(service), + ); - expect(marketingProxyRequest(request)).toBeNull(); + expect(await response?.text()).toBe("v2"); + expect(received[0]?.url).toBe("https://executor.sh/docs/"); }); - it("routes public content even when a session cookie is present", () => { - const request = new Request("https://executor.sh/blog/post", { - headers: { cookie: "wos-session=sealed" }, - }); + it("keeps deeper paths, other methods and v1's own slashed pages with v1", () => { + const { received, service } = recordingService(); - expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post"); + for (const [method, target] of [ + ["GET", "/pricing/extra"], + ["GET", "/home/extra/"], + ["GET", "/pricing//"], + ["POST", "/pricing/"], + ["GET", "/terms/"], + ["GET", "/demo/"], + ] as const) { + expect( + v2EdgeResponse(new Request(`https://executor.sh${target}`, { method }), settings(service)), + `${method} ${target}`, + ).toBeNull(); + } + expect(received).toHaveLength(0); }); - it("routes /pricing to the marketing worker", () => { - const request = new Request("https://executor.sh/pricing"); + it("redirects v2 dashboard links to v2's app host, path and query kept", async () => { + const { received, service } = recordingService(); - expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/pricing"); + for (const target of ["/org/acme/apps/app_1/open", "/org/acme?tab=kpis", "/org/acme/"]) { + for (const method of ["GET", "HEAD"]) { + const response = await v2EdgeResponse( + new Request(`https://executor.sh${target}`, { + method, + headers: { cookie: "wos-session=sealed" }, + }), + settings(service), + ); + expect(response?.status, `${method} ${target}`).toBe(308); + expect(response?.headers.get("location"), `${method} ${target}`).toBe( + `https://app.executor.sh${target}`, + ); + } + } + expect(received).toHaveLength(0); }); - it("rewrites the public home alias to the marketing root", () => { - const request = new Request("https://executor.sh/home?source=test"); + it("keeps v1's own /org page and non-navigation methods with v1", () => { + const { received, service } = recordingService(); - expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test"); + for (const [method, target] of [ + ["GET", "/org"], + ["GET", "/acme/org"], + ["GET", "/organization/x"], + ["POST", "/org/acme"], + ] as const) { + expect( + v2EdgeResponse(new Request(`https://executor.sh${target}`, { method }), settings(service)), + `${method} ${target}`, + ).toBeNull(); + } + expect(received).toHaveLength(0); }); - it("preserves the request method, headers, and body", async () => { - const request = new Request("https://executor.sh/_astro/_ph/capture", { - method: "POST", - headers: { "content-type": "application/json", "x-request-id": "request-1" }, - body: JSON.stringify({ event: "test" }), + it("sends the signed-out homepage to v2 with the URL unchanged", async () => { + const { received, service } = recordingService(); + + const response = await v2EdgeResponse( + new Request("https://executor.sh/?hero=b&utm_source=x"), + settings(service), + ); + + expect(await response?.text()).toBe("v2"); + expect(received[0]?.url).toBe("https://executor.sh/?hero=b&utm_source=x"); + expect(received[0]?.redirect).toBe("manual"); + }); + + it("keeps the signed-in homepage with v1's dashboard", () => { + const { received, service } = recordingService(); + + expect( + v2EdgeResponse( + new Request("https://executor.sh/", { + headers: { cookie: "executor_visitor=v; wos-session=sealed" }, + }), + settings(service), + ), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("passes only v2's visitor cookie, never v1's session", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/blog/post", { + headers: { + cookie: + "ph_id=1; executor_visitor=0f1e2d3c-4b5a-4987-a6b5-c4d3e2f1a0b9; wos-session=sealed", + "x-forwarded-host": "attacker.example", + }, + }), + settings(service), + ); + await v2EdgeResponse( + new Request("https://executor.sh/", { headers: { cookie: "ph_id=1; executor_hero=x" } }), + settings(service), + ); + + expect(received[0]?.headers.get("cookie")).toBe( + "executor_visitor=0f1e2d3c-4b5a-4987-a6b5-c4d3e2f1a0b9", + ); + expect(received[0]?.headers.has("x-forwarded-host")).toBe(false); + expect(received[1]?.headers.has("cookie")).toBe(false); + }); + + it("serves /home, docs and assets from v2 without rewriting the path", async () => { + const { received, service } = recordingService(); + + for (const url of [ + "https://executor.sh/home?ref=x", + "https://executor.sh/docs/quickstart", + "https://executor.sh/_astro/app.css", + "https://executor.sh/llms.txt", + ]) { + await v2EdgeResponse(new Request(url), settings(service)); + } + + expect(received.map((request) => request.url)).toEqual([ + "https://executor.sh/home?ref=x", + "https://executor.sh/docs/quickstart", + "https://executor.sh/_astro/app.css", + "https://executor.sh/llms.txt", + ]); + }); + + it("returns v2's response unchanged, its cookies included", async () => { + const upstream = new Response("", { + status: 200, + headers: { + "content-type": "text/html", + "set-cookie": "executor_visitor=v; Path=/; SameSite=Lax; Secure", + vary: "Cookie", + }, }); + const { service } = recordingService(() => upstream); - const proxied = marketingProxyRequest(request); + expect(await v2EdgeResponse(new Request("https://executor.sh/"), settings(service))).toBe( + upstream, + ); + }); - expect(proxied?.method).toBe("POST"); - expect(proxied?.headers.get("x-request-id")).toBe("request-1"); - await expect(proxied?.json()).resolves.toEqual({ event: "test" }); + it("answers marketing with a 500 on broken settings", async () => { + const { received, service } = recordingService(); + + const brokenAnalytics = await v2EdgeResponse(new Request("https://executor.sh/pricing"), { + ...settings(service), + V2_ANALYTICS_PROXY_PATH: "/api/nothex", + }); + const brokenTunnel = await v2EdgeResponse(new Request("https://executor.sh/pricing"), { + ...settings(service), + V2_ERROR_TUNNEL_PATH: "/api/fedcba9876543210", + }); + + expect(brokenAnalytics?.status).toBe(500); + expect(brokenTunnel?.status).toBe(500); + expect(received).toHaveLength(0); + }); + + it("is off without settings and off executor.sh", () => { + const { received, service } = recordingService(); + + expect(v2EdgeResponse(new Request("https://executor.sh/"), {})).toBeNull(); + expect(v2EdgeResponse(new Request("https://executor.sh/pricing"), {})).toBeNull(); + expect( + v2EdgeResponse(new Request("http://executor-cloud.localhost/"), settings(service)), + ).toBeNull(); + expect( + v2EdgeResponse(new Request("http://executor-cloud.localhost/docs"), settings(service)), + ).toBeNull(); + expect(received).toHaveLength(0); + }); + + it("forwards v2's telemetry proxies without cookies, body and method intact", async () => { + const { received, service } = recordingService(); + + await v2EdgeResponse( + new Request("https://executor.sh/api/0123456789abcdef/e/?ip=1", { + method: "POST", + headers: { cookie: "wos-session=sealed", "content-type": "text/plain" }, + body: "event", + }), + settings(service), + ); + await v2EdgeResponse( + new Request("https://executor.sh/api/fedcba9876543210/submit", { method: "POST" }), + settings(service), + ); + expect(received.map((request) => request.url)).toEqual([ + "https://executor.sh/api/0123456789abcdef/e/?ip=1", + "https://executor.sh/api/fedcba9876543210/submit", + ]); + expect(received[0]?.method).toBe("POST"); + expect(received[0]?.headers.has("cookie")).toBe(false); + expect(await received[0]?.text()).toBe("event"); }); - it("does not proxy non-production hosts or app-owned paths", () => { - expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull(); - expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull(); + const v1Api = [ + // Another 16-hex root, and v1's own PostHog proxy (8 hex). + "https://executor.sh/api/aaaaaaaaaaaaaaaa/e/", + "https://executor.sh/api/aaaaaaaaaaaaaaaa/submit", + // The analytics proxy is forwarded below its root, not the root itself. + "https://executor.sh/api/0123456789abcdef", + // The error tunnel is forwarded exactly: not its root or other subpaths. + "https://executor.sh/api/fedcba9876543210", + "https://executor.sh/api/fedcba9876543210/", + "https://executor.sh/api/fedcba9876543210/e/", + "https://executor.sh/api/fedcba9876543210/submit/", + "https://executor.sh/api/fedcba9876543210/submit/x", + "https://executor.sh/api/0a1b2c3d/e/", + // Lookalikes of a configured root. + "https://executor.sh/api/0123456789abcdef0/e/", + "https://executor.sh/api/0123456789abcdefx", + "https://executor.sh/0123456789abcdef/e/", + "https://executor.sh/acme/api/0123456789abcdef/e/", + // v1's API. + "https://executor.sh/api/connections", + "https://executor.sh/api/docs", + ]; + for (const url of v1Api) { + it(`leaves ${new URL(url).pathname} with v1`, () => { + const { received, service } = recordingService(); + expect(v2EdgeResponse(new Request(url), settings(service))).toBeNull(); + expect(received).toHaveLength(0); + }); + } + + it("leaves telemetry roots with v1 when the settings are broken", () => { + const { received, service } = recordingService(); + + expect( + v2EdgeResponse(new Request("https://executor.sh/api/0123456789abcdef/e/"), { + ...settings(service), + V2_SIGN_UP_URL: "/relative", + }), + ).toBeNull(); + expect(received).toHaveLength(0); }); }); diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 3e0fa5493d..23a7dea4c8 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -1,54 +1,374 @@ // --------------------------------------------------------------------------- -// Marketing routes — proxied to the marketing worker via service binding. +// The `executor.sh` edge — decides which Worker answers a public request. // -// On the production domain (`executor.sh`), marketing paths and the -// unauthenticated landing page are served by the separate `executor-marketing` -// worker. This module deliberately has no TanStack Start or cloud application -// imports: the Worker entry calls it before loading the Start server graph. +// On the production domain (`executor.sh`): +// - v2's marketing site answers the landing page without a v1 session, its +// pages, assets and docs, and its browser telemetry proxies; +// - sign-up goes to v2 (a redirect to v2's sign-up page); +// - a fixed list of v2 paths (sign-in issuer metadata, social sign-in +// callbacks, Git smart HTTP and the agent skills index) is forwarded to +// v2's Worker over a service binding; +// - so is a connected-account OAuth callback whose `state` carries v2's +// prefix; +// - v1's terms of service, privacy policy and Google OAuth disclosure (and +// their assets) stay on the `executor-marketing` worker. +// v1 owns everything not listed. This module deliberately has no TanStack +// Start or cloud application imports: the Worker entry calls it before +// loading the Start server graph. // --------------------------------------------------------------------------- import { parseCookie } from "../auth/cookies"; -const MARKETING_PATHS = [ - "/home", - "/setup", - "/privacy", +const PRODUCTION_HOST = "executor.sh"; + +/** v1's session cookie. `/` with it is v1's dashboard; without it, marketing. */ +const SESSION_COOKIE = "wos-session"; + +// --------------------------------------------------------------------------- +// v1's legal pages on the `executor-marketing` worker +// --------------------------------------------------------------------------- + +/** v1's legal pages stay on v1's marketing worker. v2's terms describe only + * v2's billing, and v1's cover the plans v1 customers are on; v1's privacy + * policy and Google OAuth disclosure describe v1's own data handling and + * Google OAuth app. The worker builds its assets under `/_v1-marketing`, + * apart from v2's `/_astro`. Each path matches itself and everything below + * it. */ +const V1_MARKETING_PATHS: ReadonlyArray = [ "/terms", + "/privacy", + "/google-oauth", + "/_v1-marketing", +]; + +/** Whether a pathname belongs to v1's marketing worker on `executor.sh`. */ +export const isMarketingPath = (pathname: string): boolean => + V1_MARKETING_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`)); + +/** + * Project a production request for v1's legal pages (or their assets) onto the + * `executor-marketing` service-binding request. Returns `null` for every + * other request. + */ +export const marketingProxyRequest = (request: Request): Request | null => { + const url = new URL(request.url); + if (url.hostname !== PRODUCTION_HOST || !isMarketingPath(url.pathname)) return null; + return new Request(url, request); +}; + +// --------------------------------------------------------------------------- +// v2 on `executor.sh` +// --------------------------------------------------------------------------- + +const SIGN_UP_PATHS: ReadonlySet = new Set(["/sign-up", "/signup"]); + +/** A path pattern is an exact path, or `/x/*`, which matches every path + * that starts with `/x/` (and not `/x` itself). */ +const matchesPathPattern = (pathname: string, pattern: string): boolean => + pattern.endsWith("/*") ? pathname.startsWith(pattern.slice(0, -1)) : pathname === pattern; + +/** Path patterns v2 answers on `executor.sh`. `/git/*` never matches + * `/gitlab/...`. v2's outbound OAuth client metadata document stays off this + * list: its move to `executor.sh` is pending, and v1's own document + * (`/oauth/client-id-metadata.json`) stays with v1. */ +const V2_PATHS: ReadonlyArray = [ + // Sign-in issuer metadata for the issuer `https://executor.sh/api/auth`. + "/.well-known/oauth-authorization-server/api/auth", + "/git/*", + "/.well-known/agent-skills/*", +]; + +/** v2's social sign-in callback is `/api/auth/callback/`. v1's + * WorkOS callback is the bare `/api/auth/callback`, which stays with v1. */ +const SOCIAL_CALLBACK_PREFIX = "/api/auth/callback/"; + +const isSocialCallbackPath = (pathname: string): boolean => { + if (!pathname.startsWith(SOCIAL_CALLBACK_PREFIX)) return false; + const provider = pathname.slice(SOCIAL_CALLBACK_PREFIX.length); + return provider.length > 0 && !provider.includes("/"); +}; + +/** Whether a pathname is a sign-up entry point that redirects to v2. */ +export const isSignUpPath = (pathname: string): boolean => SIGN_UP_PATHS.has(pathname); + +/** Whether `executor.sh` forwards a pathname to v2's Worker. */ +export const isV2Path = (pathname: string): boolean => + V2_PATHS.some((pattern) => matchesPathPattern(pathname, pattern)) || + isSocialCallbackPath(pathname); + +/** v2's marketing site on `executor.sh`, as path patterns (see + * {@link matchesPathPattern}): the pages, files and assets v2's marketing + * build publishes, and its docs. `/pricing` and `/home` match only + * themselves. `apps` and `experiments` are reserved org slugs, so `/apps`, + * `/apps/*` and `/experiments/*` are v2's; v2 publishes nothing at a bare + * `/experiments`, which stays with v1. `/demo` is a valid, unreserved v1 + * organization slug, so it and everything below it stay with v1. The + * favicons stay with v1 too: v1's dashboard serves identical files. */ +const V2_MARKETING_PATHS: ReadonlyArray = [ + "/home", "/pricing", + "/pricing.md", + "/index.md", + "/setup-prompt.md", + "/llms.txt", "/about-executor", - "/google-oauth", "/google-workspace", "/blog", - "/llms.txt", - "/index.md", - "/setup-prompt.md", - "/pricing.md", - "/api/detect", - "/_astro", - "/authors", + "/blog/*", + "/docs", + "/docs/*", + "/apps", + "/apps/*", + "/experiments/*", + "/_astro/*", + "/authors/*", "/og-image.png", "/pattern-graph-paper.svg", ]; -const SESSION_COOKIE = "wos-session"; +/** Whether a pathname belongs to v2's marketing site on `executor.sh`. `/` + * also does, without a v1 session; see {@link isV2Homepage}. */ +export const isV2MarketingPath = (pathname: string): boolean => + V2_MARKETING_PATHS.some((pattern) => matchesPathPattern(pathname, pattern)); -/** Whether an exact pathname belongs to the public marketing worker. */ -export const isMarketingPath = (pathname: string): boolean => - MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); +/** v2's pages that match only themselves (`/pricing`, `/home`, + * `/about-executor`, `/google-workspace`): exact patterns that are not + * files and have no `/x/*` beside them. Their slashed form (`/pricing/`) + * matches no pattern, so v1's sign-in gate would answer it. */ +const V2_EXACT_PAGES: ReadonlySet = new Set( + V2_MARKETING_PATHS.filter( + (pattern) => + !pattern.endsWith("/*") && + !pattern.includes(".") && + !V2_MARKETING_PATHS.includes(`${pattern}/*`), + ), +); + +/** The page a slashed v2 page (`/pricing/`) canonicalizes to (`/pricing`), + * or `null`. Deeper paths (`/pricing/extra`) stay with v1. */ +export const v2PageForSlashedPath = (pathname: string): string | null => { + if (!pathname.endsWith("/")) return null; + const page = pathname.slice(0, -1); + return V2_EXACT_PAGES.has(page) ? page : null; +}; + +/** v2's dashboard pages are `/org//...` on v2's app host. `org` is a + * reserved v1 slug and v1's own `/org` page has nothing below it, so + * `executor.sh/org/*` is always a v2 dashboard link with the wrong host. */ +const V2_DASHBOARD_PREFIX = "/org/"; + +/** The landing page without a v1 session is v2's marketing homepage. */ +const isV2Homepage = (url: URL, request: Request): boolean => + url.pathname === "/" && parseCookie(request.headers.get("cookie"), SESSION_COOKIE) === null; + +/** The one cookie v2's marketing reads: the anonymous visitor ID that keeps + * a visitor's homepage experiment assignment stable. v2 sets it on + * `executor.sh` itself. Every other cookie, v1's session included, stays + * with v1. */ +const V2_MARKETING_COOKIES: ReadonlyArray = ["executor_visitor"]; + +/** v2's browser telemetry lives under `/api/<16 lowercase hex>`, which v1's + * API never uses. Two settings say which paths are v2's. Both are outputs + * of v2's production infrastructure (stage `v2`; random, retained values), + * which v2 pins in its edge contract; v2 refuses to deploy stage `v2` if + * its outputs differ: + * - `V2_ANALYTICS_PROXY_PATH`, the PostHog browser proxy root (output + * `proxyPath` of v2's `executor-next-posthog` stack), forwarded below + * it (`/*`), not the root itself, which v2 does not serve; + * - `V2_ERROR_TUNNEL_PATH`, the Sentry browser error tunnel (output + * `browserTunnel` of v2's `executor-next-sentry` stack), forwarded + * exactly. */ +const ANALYTICS_PROXY_PATH_PATTERN = /^\/api\/[a-f0-9]{16}$/; +const ERROR_TUNNEL_PATH_PATTERN = /^\/api\/[a-f0-9]{16}\/submit$/; +const TELEMETRY_PATH_SHAPE = /^\/api\/[a-f0-9]{16}(?:\/|$)/; + +/** Request headers v1 never passes to v2 as sent. The cookie header carries + * v1's `wos-session`; v2's own cookies are host-only on its own hosts, + * except marketing's visitor cookie, which is passed on by name. Client-sent + * forwarding headers are dropped so v2 sees no host claim other than the + * request URL's. */ +const V2_STRIPPED_HEADERS = ["cookie", "x-forwarded-host", "x-forwarded-proto"] as const; /** - * Project a production request onto the marketing service-binding request. - * Returns `null` when the cloud application owns the request instead. + * Project an `executor.sh` request onto the request sent to v2's Worker. + * + * Keeps the URL (so v2 sees host `executor.sh`, path and query unchanged), + * method, body stream and every header except {@link V2_STRIPPED_HEADERS}, + * including `Authorization`. The cookie header is rebuilt from `keptCookies` + * only, and dropped when none of them is present. Redirects are returned to + * the client, not followed: v2 answers callbacks with a redirect to its own + * host. */ -export const marketingProxyRequest = (request: Request): Request | null => { - const url = new URL(request.url); - if (url.hostname !== "executor.sh") return null; +export const v2ForwardRequest = ( + request: Request, + keptCookies: ReadonlyArray = [], +): Request => { + const cookieHeader = request.headers.get("cookie"); + const cookies = keptCookies.flatMap((name) => { + const value = parseCookie(cookieHeader, name); + return value === null ? [] : [`${name}=${value}`]; + }); + const headers = new Headers(request.headers); + for (const name of V2_STRIPPED_HEADERS) headers.delete(name); + if (cookies.length > 0) headers.set("cookie", cookies.join("; ")); + return new Request(request, { headers, redirect: "manual" }); +}; - const shouldProxy = - isMarketingPath(url.pathname) || - (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE)); - if (!shouldProxy) return null; +/** The Worker that serves v2, reached over a service binding. */ +export interface V2Service { + readonly fetch: (request: Request) => Promise; +} - if (url.pathname === "/home") url.pathname = "/"; - return new Request(url, request); +/** v1's connected-account OAuth callback. v2 shares it on `executor.sh`: + * a callback whose `state` starts with v2's prefix belongs to v2. */ +const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; + +/** A state prefix is URL-safe as is (so the query value carries it + * unencoded) and includes a character outside base64url. v1's states are + * base64url, so no v1 state can start with such a prefix. */ +const OAUTH_STATE_PREFIX_PATTERN = /^[A-Za-z0-9._~-]*[.~][A-Za-z0-9._~-]*$/; + +/** The edge's raw v2 settings, as the Worker environment holds them. */ +export interface V2EdgeEnv { + /** Service binding to v2's Worker. */ + readonly V2?: V2Service; + /** Absolute URL of v2's sign-up page. */ + readonly V2_SIGN_UP_URL?: string; + /** The prefix v2 puts on every connected-account OAuth `state`. */ + readonly V2_OAUTH_STATE_PREFIX?: string; + /** Root of v2's PostHog browser proxy, `/api/<16 hex>`. */ + readonly V2_ANALYTICS_PROXY_PATH?: string; + /** v2's Sentry browser error tunnel, `/api/<16 hex>/submit`. */ + readonly V2_ERROR_TUNNEL_PATH?: string; +} + +/** What the edge needs to hand requests to v2. */ +export interface V2Edge { + /** v2's Worker. */ + readonly service: V2Service; + /** Absolute URL of v2's sign-up page (the `V2_SIGN_UP_URL` var). */ + readonly signUpUrl: URL; + /** v2's connected-account OAuth state prefix (the `V2_OAUTH_STATE_PREFIX` var). */ + readonly oauthStatePrefix: string; + /** Root of v2's PostHog browser proxy (the `V2_ANALYTICS_PROXY_PATH` var). */ + readonly analyticsProxyPath: string; + /** v2's Sentry browser error tunnel (the `V2_ERROR_TUNNEL_PATH` var). */ + readonly errorTunnelPath: string; +} + +/** + * Parse the edge's v2 settings from the Worker environment. Returns `null` + * when none is set (local dev, test workers), so v1 serves everything, and + * the reason as a string when the deployment is broken: only some of them + * set, a sign-up URL that is not absolute, a state prefix that could match a + * v1 state, or a telemetry path of the wrong shape. + */ +export const parseV2Edge = (env: V2EdgeEnv): V2Edge | string | null => { + const service = env.V2; + const signUpUrl = env.V2_SIGN_UP_URL; + const oauthStatePrefix = env.V2_OAUTH_STATE_PREFIX; + const analyticsProxyPath = env.V2_ANALYTICS_PROXY_PATH; + const errorTunnelPath = env.V2_ERROR_TUNNEL_PATH; + if ( + service === undefined && + signUpUrl === undefined && + oauthStatePrefix === undefined && + analyticsProxyPath === undefined && + errorTunnelPath === undefined + ) { + return null; + } + if ( + service === undefined || + signUpUrl === undefined || + oauthStatePrefix === undefined || + analyticsProxyPath === undefined || + errorTunnelPath === undefined + ) { + return "The V2 binding, V2_SIGN_UP_URL, V2_OAUTH_STATE_PREFIX, V2_ANALYTICS_PROXY_PATH and V2_ERROR_TUNNEL_PATH must be set together"; + } + const parsed = URL.parse(signUpUrl); + if (parsed === null || (parsed.protocol !== "https:" && parsed.protocol !== "http:")) { + return "V2_SIGN_UP_URL must be an absolute http(s) URL"; + } + if (!OAUTH_STATE_PREFIX_PATTERN.test(oauthStatePrefix)) { + return "V2_OAUTH_STATE_PREFIX must be URL-safe and contain '.' or '~'"; + } + if (!ANALYTICS_PROXY_PATH_PATTERN.test(analyticsProxyPath)) { + return "V2_ANALYTICS_PROXY_PATH must be /api/<16 lowercase hex>"; + } + if (!ERROR_TUNNEL_PATH_PATTERN.test(errorTunnelPath)) { + return "V2_ERROR_TUNNEL_PATH must be /api/<16 lowercase hex>/submit"; + } + return { service, signUpUrl: parsed, oauthStatePrefix, analyticsProxyPath, errorTunnelPath }; +}; + +/** Whether a connected-account callback carries v2's state prefix. Reads the + * query only: a callback without `state` in its query stays with v1. */ +const isV2OAuthCallback = (url: URL, prefix: string): boolean => + url.searchParams.get("state")?.startsWith(prefix) === true; + +/** Whether a pathname is below v2's analytics proxy root, or exactly v2's + * error tunnel. */ +const isV2TelemetryPath = (pathname: string, edge: V2Edge): boolean => + matchesPathPattern(pathname, `${edge.analyticsProxyPath}/*`) || pathname === edge.errorTunnelPath; + +/** + * Answer a production request that belongs to v2 and return `null` when v1 + * owns it: + * - sign-up (`GET`, any query) redirects to v2's sign-up page; + * - a `GET` or `HEAD` for a slashed v2 page (`/pricing/`) redirects (308) + * to the page, query kept, as v2's site does on its own hosts; + * - a `GET` or `HEAD` for a v2 dashboard page (`/org//...`) redirects + * (308) to the same path and query on v2's app host, the sign-up URL's + * origin; + * - a {@link isV2Path} request, a connected-account callback whose `state` + * starts with v2's prefix, or a request for v2's analytics proxy or error + * tunnel is forwarded to v2's Worker without cookies; + * - a {@link isV2MarketingPath} request, or `/` without a v1 session, is + * forwarded with only v2's marketing visitor cookie. + * v2's response comes back unchanged (status, headers and body stream). + * + * Broken settings answer the requests the edge always owns with a 500 and + * leave the rest of v1 serving. Which callbacks and `/api/<16 hex>` requests + * are v2's depends on the settings, so v1 keeps those until they parse. + */ +export const v2EdgeResponse = (request: Request, env: V2EdgeEnv): Promise | null => { + const url = new URL(request.url); + if (url.hostname !== PRODUCTION_HOST) return null; + + const signUp = isSignUpPath(url.pathname) && request.method === "GET"; + const slashedPage = + request.method === "GET" || request.method === "HEAD" + ? v2PageForSlashedPath(url.pathname) + : null; + const dashboard = + (request.method === "GET" || request.method === "HEAD") && + url.pathname.startsWith(V2_DASHBOARD_PREFIX); + const marketing = isV2MarketingPath(url.pathname) || isV2Homepage(url, request); + const owned = signUp || slashedPage !== null || dashboard || marketing || isV2Path(url.pathname); + const callback = url.pathname === OAUTH_CALLBACK_PATH; + const telemetry = TELEMETRY_PATH_SHAPE.test(url.pathname); + if (!owned && !callback && !telemetry) return null; + const edge = parseV2Edge(env); + if (edge === null) return null; + if (typeof edge === "string") { + if (!owned) return null; + console.error(`executor.sh v2 edge misconfigured: ${edge}`); + return Promise.resolve(new Response("Service misconfigured", { status: 500 })); + } + if (signUp) return Promise.resolve(Response.redirect(edge.signUpUrl.href, 302)); + if (slashedPage !== null) { + const page = new URL(url); + page.pathname = slashedPage; + return Promise.resolve(Response.redirect(page.href, 308)); + } + if (dashboard) { + const page = new URL(url.pathname + url.search, edge.signUpUrl.origin); + return Promise.resolve(Response.redirect(page.href, 308)); + } + if (marketing) return edge.service.fetch(v2ForwardRequest(request, V2_MARKETING_COOKIES)); + if (callback && !isV2OAuthCallback(url, edge.oauthStatePrefix)) return null; + if (telemetry && !isV2TelemetryPath(url.pathname, edge)) return null; + return edge.service.fetch(v2ForwardRequest(request)); }; diff --git a/apps/cloud/src/edge/production-config.test.ts b/apps/cloud/src/edge/production-config.test.ts new file mode 100644 index 0000000000..f6e3571777 --- /dev/null +++ b/apps/cloud/src/edge/production-config.test.ts @@ -0,0 +1,281 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "@effect/vitest"; +import { Schema } from "effect"; +import { unstable_readConfig } from "wrangler"; + +import { frontResponse } from "./front"; +import { parseV2Edge, v2EdgeResponse, type V2EdgeEnv, type V2Service } from "./marketing"; + +// The deployed edge reads its v2 settings from wrangler.jsonc. These tests read +// that file the way wrangler does and run the edge with the values it ships. +// wrangler's config arrives untyped here, so it is decoded first. +const WranglerConfig = Schema.Struct({ + vars: Schema.Record(Schema.String, Schema.Unknown), + services: Schema.Array(Schema.Struct({ binding: Schema.String, service: Schema.String })), +}); +const config = Schema.decodeUnknownSync(WranglerConfig)( + unstable_readConfig({ config: fileURLToPath(new URL("../../wrangler.jsonc", import.meta.url)) }), +); + +// The front Worker (wrangler.edge.jsonc) runs the same edge on executor.sh +// before `executor-cloud`, with its own copy of the settings. +const EdgeWranglerConfig = Schema.Struct({ + main: Schema.String, + vars: Schema.Record(Schema.String, Schema.Unknown), + services: Schema.Array(Schema.Struct({ binding: Schema.String, service: Schema.String })), + routes: Schema.Array( + Schema.Struct({ pattern: Schema.String, zone_name: Schema.optional(Schema.String) }), + ), + placement: Schema.optional(Schema.Unknown), +}); +const edgeConfig = Schema.decodeUnknownSync(EdgeWranglerConfig)( + unstable_readConfig({ + config: fileURLToPath(new URL("../../wrangler.edge.jsonc", import.meta.url)), + }), +); + +const stringVar = (name: string): string | undefined => { + const value = config.vars[name]; + return typeof value === "string" ? value : undefined; +}; + +const standIn: V2Service = { fetch: () => Promise.resolve(new Response("v2")) }; + +/** The shipped settings, with a stand-in for v2's Worker. */ +const shipped: V2EdgeEnv = { + V2: standIn, + V2_SIGN_UP_URL: stringVar("V2_SIGN_UP_URL"), + V2_OAUTH_STATE_PREFIX: stringVar("V2_OAUTH_STATE_PREFIX"), + V2_ANALYTICS_PROXY_PATH: stringVar("V2_ANALYTICS_PROXY_PATH"), + V2_ERROR_TUNNEL_PATH: stringVar("V2_ERROR_TUNNEL_PATH"), +}; + +const edgeStringVar = (name: string): string | undefined => { + const value = edgeConfig.vars[name]; + return typeof value === "string" ? value : undefined; +}; + +/** The front Worker's shipped settings, with the same stand-in. */ +const frontShipped: V2EdgeEnv = { + V2: standIn, + V2_SIGN_UP_URL: edgeStringVar("V2_SIGN_UP_URL"), + V2_OAUTH_STATE_PREFIX: edgeStringVar("V2_OAUTH_STATE_PREFIX"), + V2_ANALYTICS_PROXY_PATH: edgeStringVar("V2_ANALYTICS_PROXY_PATH"), + V2_ERROR_TUNNEL_PATH: edgeStringVar("V2_ERROR_TUNNEL_PATH"), +}; + +describe("production v2 edge settings", () => { + it("binds V2 to the unplaced marketing gateway", () => { + const bindings = config.services.filter((service) => service.binding === "V2"); + expect(bindings).toHaveLength(1); + expect(bindings[0]?.service).toBe("executor-next-marketing-v2"); + }); + + it("redirects sign-up to v2's sign-up page on app.executor.sh", async () => { + const response = await v2EdgeResponse(new Request("https://executor.sh/sign-up"), shipped); + + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe("https://app.executor.sh/login?mode=signup"); + }); + + it("ships v2's connected-account state prefix", () => { + const edge = parseV2Edge(shipped); + if (edge === null || typeof edge === "string") return expect.unreachable("settings must parse"); + expect(edge.oauthStatePrefix).toBe("x2."); + }); + + it("ships v2's analytics proxy root and error tunnel", () => { + const edge = parseV2Edge(shipped); + if (edge === null || typeof edge === "string") return expect.unreachable("settings must parse"); + expect(edge.analyticsProxyPath).toBe("/api/00e2e1f082a6ef17"); + 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); + }); +}); + +describe("the executor.sh front Worker's settings", () => { + it("runs the front entry on a zone route for every executor.sh path", () => { + expect(edgeConfig.main).toMatch(/src\/edge\/front\.ts$/); + expect(edgeConfig.routes).toEqual([{ pattern: "executor.sh/*", zone_name: "executor.sh" }]); + }); + + // Placement would send every request it answers to the placed region, + // which is the trip this Worker exists to avoid. + it("is not placed", () => { + expect(edgeConfig.placement).toBeUndefined(); + }); + + 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_") && !name.startsWith("V2_MCP_"), + ), + ); + expect(Object.keys(v2Vars(edgeConfig.vars)).toSorted()).toEqual([ + "V2_ANALYTICS_PROXY_PATH", + "V2_ERROR_TUNNEL_PATH", + "V2_OAUTH_STATE_PREFIX", + "V2_SIGN_UP_URL", + ]); + expect(v2Vars(edgeConfig.vars)).toEqual(v2Vars(config.vars)); + }); + + it("binds the same v2 and marketing Workers as executor-cloud", () => { + const bound = (services: typeof config.services) => + services + .filter((service) => service.binding === "V2" || service.binding === "MARKETING") + .toSorted((a, b) => a.binding.localeCompare(b.binding)); + expect(bound(edgeConfig.services)).toEqual(bound(config.services)); + expect(bound(edgeConfig.services)).toHaveLength(2); + }); +}); + +// v2 publishes the exact set of executor.sh requests v1's edge forwards to it. +// `v2-edge-contract.json` is a verbatim copy of v2's edge contract; update it +// only together with v2's contract, never by hand here. Every case runs +// through v1's real edge decision with the shipped settings. +const EdgeContract = Schema.Struct({ + origin: Schema.String, + oauthStatePrefix: Schema.String, + telemetry: Schema.Struct({ analyticsProxy: Schema.String, errorTunnel: Schema.String }), + cases: Schema.Array( + Schema.Struct({ method: Schema.String, target: Schema.String, forwards: Schema.Boolean }), + ), + slashRedirects: Schema.Struct({ + pages: Schema.Array(Schema.String), + cases: Schema.Array(Schema.Struct({ target: Schema.String, location: Schema.String })), + }), +}); +const contract = Schema.decodeUnknownSync(Schema.fromJsonString(EdgeContract))( + readFileSync(fileURLToPath(new URL("./v2-edge-contract.json", import.meta.url)), "utf8"), +); + +const SIGN_UP_TARGETS: ReadonlySet = new Set(["/sign-up", "/signup"]); + +/** Run one contract case through v1's edge with the shipped settings and a + * stand-in for v2's Worker that records every call. */ +const runCase = async (method: string, target: string) => { + const calls: Request[] = []; + const v2: V2Service = { + fetch: (request) => { + calls.push(request); + return Promise.resolve(new Response("v2")); + }, + }; + const request = new Request(`${contract.origin}${target}`, { method }); + const response = await v2EdgeResponse(request, { ...shipped, V2: v2 }); + return { request, calls, response }; +}; + +const forwarded = contract.cases.filter((c) => c.forwards); +const signUps = contract.cases.filter((c) => !c.forwards && SIGN_UP_TARGETS.has(c.target)); +const kept = contract.cases.filter((c) => !c.forwards && !SIGN_UP_TARGETS.has(c.target)); + +describe("v2's edge contract", () => { + it("pins the telemetry paths and state prefix v1 ships", () => { + expect(contract.origin).toBe("https://executor.sh"); + expect(contract.oauthStatePrefix).toBe(stringVar("V2_OAUTH_STATE_PREFIX")); + expect(contract.telemetry.analyticsProxy).toBe(stringVar("V2_ANALYTICS_PROXY_PATH")); + expect(contract.telemetry.errorTunnel).toBe(stringVar("V2_ERROR_TUNNEL_PATH")); + }); + + for (const { method, target } of forwarded) { + it(`forwards ${method} ${target} to v2`, async () => { + const { request, calls, response } = await runCase(method, target); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(request.url); + expect(calls[0]?.method).toBe(method); + expect(await response?.text()).toBe("v2"); + }); + } + + for (const { method, target } of kept) { + it(`keeps ${method} ${target} with v1`, async () => { + const { calls, response } = await runCase(method, target); + expect(calls).toHaveLength(0); + expect(response).toBeNull(); + }); + } + + // The front Worker answers the same requests the same way: everything the + // contract forwards reaches v2 once, and nothing else reaches it. + const runFrontCase = async (method: string, target: string) => { + const calls: Request[] = []; + const v2: V2Service = { + fetch: (request) => { + calls.push(request); + return Promise.resolve(new Response("v2")); + }, + }; + const request = new Request(`${contract.origin}${target}`, { method }); + const response = await frontResponse(request, { ...frontShipped, V2: v2 }); + return { request, calls, response }; + }; + + for (const { method, target } of forwarded) { + it(`front Worker forwards ${method} ${target} to v2`, async () => { + const { request, calls, response } = await runFrontCase(method, target); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(request.url); + expect(calls[0]?.method).toBe(method); + expect(await response?.text()).toBe("v2"); + }); + } + + for (const { method, target } of contract.cases.filter((c) => !c.forwards)) { + it(`front Worker does not forward ${method} ${target} to v2`, async () => { + const { calls } = await runFrontCase(method, target); + expect(calls).toHaveLength(0); + }); + } + + // Sign-up is not forwarded: the edge redirects it to v2's sign-up page. + it("lists both sign-up paths as not forwarded", () => { + expect(signUps.map((c) => c.target).toSorted()).toEqual(["/sign-up", "/signup"]); + }); + + // A slashed exact page is not forwarded: the edge redirects it to the page, + // which is. Every page v2 lists has an example. + it("covers every slashed page v2 lists", () => { + expect( + contract.slashRedirects.cases + .map((c) => new URL(c.target, contract.origin).pathname) + .toSorted(), + ).toEqual(expect.arrayContaining(contract.slashRedirects.pages.map((page) => `${page}/`))); + }); + + for (const { target, location } of contract.slashRedirects.cases) { + for (const method of ["GET", "HEAD"]) { + it(`redirects ${method} ${target} to ${location} without forwarding`, async () => { + const { calls, response } = await runCase(method, target); + expect(calls).toHaveLength(0); + expect(response?.status).toBe(308); + expect(response?.headers.get("location")).toBe(`${contract.origin}${location}`); + }); + } + } + + for (const { method, target } of signUps) { + it(`redirects ${method} ${target} to v2's sign-up page without forwarding`, async () => { + const { calls, response } = await runCase(method, target); + expect(calls).toHaveLength(0); + expect(response?.status).toBe(302); + expect(response?.headers.get("location")).toBe(stringVar("V2_SIGN_UP_URL")); + }); + } +}); diff --git a/apps/cloud/src/edge/v2-edge-contract.json b/apps/cloud/src/edge/v2-edge-contract.json new file mode 100644 index 0000000000..8ad182e0e2 --- /dev/null +++ b/apps/cloud/src/edge/v2-edge-contract.json @@ -0,0 +1,426 @@ +{ + "source": "Executor v2: apps/hosted/cloud/src/contracts/edge-contract.json, generated by apps/hosted/cloud/scripts/edge-contract.ts from productionEdgeForwards", + "origin": "https://executor.sh", + "oauthStatePrefix": "x2.", + "telemetry": { + "analyticsProxy": "/api/00e2e1f082a6ef17", + "errorTunnel": "/api/fd6fab1fbb4883e1/submit" + }, + "rules": [ + "/.well-known/oauth-authorization-server/api/auth", + "/api/auth/callback/", + "/api/oauth/callback, when state starts with x2.", + "/git/*", + "/ (without a v1 session)", + "/home", + "/pricing", + "/pricing.md", + "/index.md", + "/setup-prompt.md", + "/llms.txt", + "/about-executor", + "/google-workspace", + "/blog", + "/blog/*", + "/docs", + "/docs/*", + "/apps", + "/apps/*", + "/experiments/*", + "/_astro/*", + "/authors/*", + "/og-image.png", + "/pattern-graph-paper.svg", + "/.well-known/agent-skills/*", + "/api/00e2e1f082a6ef17/*", + "/api/fd6fab1fbb4883e1/submit" + ], + "cases": [ + { + "method": "GET", + "target": "/.well-known/oauth-authorization-server/api/auth", + "forwards": true + }, + { + "method": "GET", + "target": "/api/auth/callback/google?code=c&state=s", + "forwards": true + }, + { + "method": "GET", + "target": "/api/auth/callback/github", + "forwards": true + }, + { + "method": "GET", + "target": "/api/oauth/callback?code=c&state=x2.abc", + "forwards": true + }, + { + "method": "GET", + "target": "/git/acme/site.git/info/refs?service=git-upload-pack", + "forwards": true + }, + { + "method": "POST", + "target": "/git/acme/site.git/git-receive-pack", + "forwards": true + }, + { + "method": "GET", + "target": "/", + "forwards": true + }, + { + "method": "GET", + "target": "/home", + "forwards": true + }, + { + "method": "GET", + "target": "/pricing", + "forwards": true + }, + { + "method": "GET", + "target": "/pricing.md", + "forwards": true + }, + { + "method": "GET", + "target": "/index.md", + "forwards": true + }, + { + "method": "GET", + "target": "/setup-prompt.md", + "forwards": true + }, + { + "method": "GET", + "target": "/llms.txt", + "forwards": true + }, + { + "method": "GET", + "target": "/about-executor", + "forwards": true + }, + { + "method": "GET", + "target": "/google-workspace", + "forwards": true + }, + { + "method": "GET", + "target": "/blog", + "forwards": true + }, + { + "method": "GET", + "target": "/blog/", + "forwards": true + }, + { + "method": "GET", + "target": "/blog/a-post", + "forwards": true + }, + { + "method": "GET", + "target": "/docs", + "forwards": true + }, + { + "method": "GET", + "target": "/docs/", + "forwards": true + }, + { + "method": "GET", + "target": "/docs/quickstart", + "forwards": true + }, + { + "method": "GET", + "target": "/apps", + "forwards": true + }, + { + "method": "GET", + "target": "/apps/detail", + "forwards": true + }, + { + "method": "GET", + "target": "/experiments/hero/a", + "forwards": true + }, + { + "method": "GET", + "target": "/experiments/demo/posthog", + "forwards": true + }, + { + "method": "GET", + "target": "/_astro/page.abc123.js", + "forwards": true + }, + { + "method": "GET", + "target": "/authors/author.png", + "forwards": true + }, + { + "method": "GET", + "target": "/og-image.png", + "forwards": true + }, + { + "method": "GET", + "target": "/pattern-graph-paper.svg", + "forwards": true + }, + { + "method": "GET", + "target": "/.well-known/agent-skills/index.json", + "forwards": true + }, + { + "method": "POST", + "target": "/api/00e2e1f082a6ef17/e/?ip=0", + "forwards": true + }, + { + "method": "GET", + "target": "/api/00e2e1f082a6ef17/static/array.js", + "forwards": true + }, + { + "method": "POST", + "target": "/api/fd6fab1fbb4883e1/submit", + "forwards": true + }, + { + "method": "GET", + "target": "/api/auth/.well-known/openid-configuration", + "forwards": false + }, + { + "method": "GET", + "target": "/.well-known/oauth-authorization-server", + "forwards": false + }, + { + "method": "GET", + "target": "/.well-known/oauth-protected-resource", + "forwards": false + }, + { + "method": "GET", + "target": "/api/auth/callback?code=c&state=s", + "forwards": false + }, + { + "method": "GET", + "target": "/api/auth/callback/google/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/api/auth/login", + "forwards": false + }, + { + "method": "GET", + "target": "/api/oauth/callback?code=c&state=abc", + "forwards": false + }, + { + "method": "GET", + "target": "/api/oauth/callback?code=c", + "forwards": false + }, + { + "method": "GET", + "target": "/oauth/client-metadata.json", + "forwards": false + }, + { + "method": "GET", + "target": "/oauth/client-id-metadata.json", + "forwards": false + }, + { + "method": "GET", + "target": "/git", + "forwards": false + }, + { + "method": "GET", + "target": "/gitlab/repo", + "forwards": false + }, + { + "method": "GET", + "target": "/demo", + "forwards": false + }, + { + "method": "GET", + "target": "/demo/posthog", + "forwards": false + }, + { + "method": "GET", + "target": "/experiments", + "forwards": false + }, + { + "method": "GET", + "target": "/privacy", + "forwards": false + }, + { + "method": "GET", + "target": "/terms", + "forwards": false + }, + { + "method": "GET", + "target": "/google-oauth", + "forwards": false + }, + { + "method": "GET", + "target": "/_v1-marketing/style.css", + "forwards": false + }, + { + "method": "GET", + "target": "/favicon-32.png", + "forwards": false + }, + { + "method": "GET", + "target": "/favicon-192.png", + "forwards": false + }, + { + "method": "GET", + "target": "/apple-touch-icon.png", + "forwards": false + }, + { + "method": "GET", + "target": "/favicon.ico", + "forwards": false + }, + { + "method": "GET", + "target": "/pricing/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/home/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/about-executor/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/google-workspace/extra", + "forwards": false + }, + { + "method": "POST", + "target": "/pricing/", + "forwards": false + }, + { + "method": "GET", + "target": "/login", + "forwards": false + }, + { + "method": "GET", + "target": "/sign-up", + "forwards": false + }, + { + "method": "GET", + "target": "/signup", + "forwards": false + }, + { + "method": "POST", + "target": "/mcp", + "forwards": false + }, + { + "method": "POST", + "target": "/acme/mcp", + "forwards": false + }, + { + "method": "GET", + "target": "/acme", + "forwards": false + }, + { + "method": "GET", + "target": "/api/00e2e1f082a6ef17", + "forwards": false + }, + { + "method": "GET", + "target": "/api/fd6fab1fbb4883e1", + "forwards": false + }, + { + "method": "POST", + "target": "/api/fd6fab1fbb4883e1/submit/extra", + "forwards": false + }, + { + "method": "GET", + "target": "/api/0123456789abcdef/e/", + "forwards": false + }, + { + "method": "POST", + "target": "/api/webhooks/workos", + "forwards": false + } + ], + "slashRedirects": { + "rule": "GET or HEAD / answers 308 to , query kept, for each of pages", + "pages": ["/home", "/pricing", "/about-executor", "/google-workspace"], + "cases": [ + { + "target": "/pricing/", + "location": "/pricing" + }, + { + "target": "/pricing/?ref=hn", + "location": "/pricing?ref=hn" + }, + { + "target": "/home/", + "location": "/home" + }, + { + "target": "/about-executor/", + "location": "/about-executor" + }, + { + "target": "/google-workspace/", + "location": "/google-workspace" + } + ] + } +} diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 017570cf1a..6603749350 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -137,6 +137,32 @@ declare global { MCP_RESIDENT_RUNTIME_SOFT_CAP?: string; NODE_ENV?: string; + // v2 on executor.sh (wrangler.jsonc `services` + `vars`). Optional so + // 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` + * requests whose state starts with it go to v2. */ + V2_OAUTH_STATE_PREFIX?: string; + /** Root of v2's PostHog browser proxy (`/api/<16 hex>`), which v2's + * marketing pages call on executor.sh; forwarded with its subpaths. */ + V2_ANALYTICS_PROXY_PATH?: string; + /** v2's Sentry browser error tunnel (`/api/<16 hex>/submit`), forwarded + * exactly. */ + V2_ERROR_TUNNEL_PATH?: string; + // Shared with frontend VITE_PUBLIC_SITE_URL?: string; VITE_PUBLIC_OTLP_TRACES_URL?: string; 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/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index e0ca33dbdf..e38bb28418 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -545,15 +545,11 @@ export const routeTree = rootRouteImport ._addFileTypes() import type { getRouter } from './router.tsx' - import type { startInstance } from './start.ts' - declare module '@tanstack/react-start' { interface Register { ssr: true - router: Awaited> - config: Awaited> } } diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 2dde0e8403..ca00c5132b 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -11,7 +11,7 @@ import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; -import { marketingProxyRequest } from "./edge/marketing"; +import { marketingProxyRequest, v2EdgeResponse } from "./edge/marketing"; import { passthroughResponse } from "./edge/passthrough"; import { withPrivateReferrerPolicy } from "./edge/referrer-policy"; import { runWorkOsEventsSync } from "./auth/workos-events-runner"; @@ -308,26 +308,32 @@ const cloudflareHandler = { fetch: async (request, env, ctx) => { isolateRequestSeq += 1; - // Public pages must not enter TanStack Start: its first-request dynamic - // import loads the entire React + Effect server graph and can take seconds - // on a cold isolate. Classify and service-bind marketing at the Worker - // entry, before telemetry or fetchHandler touches that graph. - // Everything that returns before the app-plane dispatch below leaves the - // graph unevaluated for the next request; warm it in the background. - if (!servedByAppPlane(new URL(request.url).pathname, request.method)) { - prewarmAppPlane(ctx); - } + // On `executor.sh`, v2 answers marketing (including `/docs` and its + // telemetry proxies), sign-up, the fixed list of v2 paths and v2's + // connected-account callbacks; v1's legal pages stay on v1's marketing + // worker. + const v2 = v2EdgeResponse(request, env); + if (v2) return v2; const marketingRequest = marketingProxyRequest(request); const marketing: Fetcher | undefined = env.MARKETING; if (marketingRequest && marketing) return marketing.fetch(marketingRequest); + // Only v1-owned requests may warm v1's application. A dynamic import still + // evaluates JavaScript on this isolate's thread under waitUntil; starting it + // for a forwarded page or stylesheet delays the otherwise independent response. + if (!servedByAppPlane(new URL(request.url).pathname, request.method)) { + prewarmAppPlane(ctx); + } + // Same reasoning, same seam: `/docs` and the PostHog proxy forward to an // external origin and never touch the router, React, or the Effect app. // Left in Start's middleware they still paid its lazy `loadEntries` import // first — measured at p50 3.1s on a cold isolate, against p50 33ms for the // request's own work, on a Worker where 1,666 dispatches spread across // 1,608 isolates (so nearly every request is cold). Forward before Start. + // On `executor.sh` v2 answers `/docs` above, so this serves it on other + // hosts; v1's own PostHog proxy is served here on every host. const passthroughPath = new URL(request.url).pathname; const passthrough = passthroughResponse(request, passthroughPath); if (passthrough) return passthrough; 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. + + + +
+ +