diff --git a/.changeset/account-scoped-tool-policies.md b/.changeset/account-scoped-tool-policies.md deleted file mode 100644 index a258479d0a..0000000000 --- a/.changeset/account-scoped-tool-policies.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/react": patch ---- - -Tool policies set from an account section of the integration Tools tab now apply to that connection only, and each account header gets a menu to set a policy for the whole connection. Members no longer see policy controls they cannot use, and a refused policy write shows the server's reason. diff --git a/.changeset/connection-oauth-default.md b/.changeset/connection-oauth-default.md deleted file mode 100644 index 35cfce58c2..0000000000 --- a/.changeset/connection-oauth-default.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"executor": patch ---- - -Prefer browser sign-in when a matching OAuth client is available, while preserving a user’s chosen method when clients finish loading. diff --git a/.changeset/cross-session-resume-org-write.md b/.changeset/cross-session-resume-org-write.md deleted file mode 100644 index 299d43bb24..0000000000 --- a/.changeset/cross-session-resume-org-write.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/cloud": patch ---- - -An admin who resumes a paused execution from a different MCP session (for example after the client reconnects) keeps workspace-write access. The forwarded resume now carries the requester's access to the session that owns the execution, so a pending `addServer`, `addSpec`, or similar write no longer fails with `org_write_denied`. diff --git a/.changeset/fair-admin-integrations.md b/.changeset/fair-admin-integrations.md deleted file mode 100644 index a8e83972b8..0000000000 --- a/.changeset/fair-admin-integrations.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/react": patch ---- - -Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations. diff --git a/.changeset/oauth-discovered-scope-budget.md b/.changeset/oauth-discovered-scope-budget.md deleted file mode 100644 index c71d10c41e..0000000000 --- a/.changeset/oauth-discovered-scope-budget.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/sdk": patch ---- - -Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy". diff --git a/.changeset/organization-settings-verification.md b/.changeset/organization-settings-verification.md deleted file mode 100644 index bae2246529..0000000000 --- a/.changeset/organization-settings-verification.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/cloud": patch ---- - -Require authenticator verification for organization settings while preserving shared workspace reads and API-key access. diff --git a/.changeset/quiet-connection-setup.md b/.changeset/quiet-connection-setup.md deleted file mode 100644 index 9d75373928..0000000000 --- a/.changeset/quiet-connection-setup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"executor": patch ---- - -Accept Slack bot and user OAuth token envelopes during sign-in and token refresh. diff --git a/.changeset/toolkit-list-scope.md b/.changeset/toolkit-list-scope.md deleted file mode 100644 index 2af6981f88..0000000000 --- a/.changeset/toolkit-list-scope.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@executor-js/sdk": patch -"@executor-js/plugin-toolkits": patch ---- - -Toolkit sessions no longer walk the whole workspace catalog on connect, search, or describe: the toolkit's access patterns narrow the tool rows core reads. Tools reads no longer wait on re-listing catalogs that are only older than the freshness TTL; those rebuild in the background while the read answers from the persisted rows. Stale-marked and config-revised catalogs still gate the read within the grace budget. diff --git a/.changeset/trusted-oauth-token-regions.md b/.changeset/trusted-oauth-token-regions.md deleted file mode 100644 index 9f3abec23a..0000000000 --- a/.changeset/trusted-oauth-token-regions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/sdk": patch ---- - -Restrict callback-selected OAuth token endpoints to supported Datadog regions and validate persisted regional endpoints before refreshing credentials. diff --git a/.changeset/v1-move-freeze.md b/.changeset/v1-move-freeze.md new file mode 100644 index 0000000000..8cfcf4e358 --- /dev/null +++ b/.changeset/v1-move-freeze.md @@ -0,0 +1,7 @@ +--- +"@executor-js/sdk": patch +"@executor-js/api": patch +"@executor-js/cloud": patch +--- + +Hosts can pause every change to a workspace with `workspaceWriteGate`: connection and OAuth writes, OAuth completion (before the code is exchanged) and OAuth token refreshes are refused with the host's message, while reads and tool calls keep working. The OAuth callback popup now shows why a refusal the user can act on happened. Executor Cloud uses this to freeze an organization while it moves to Executor v2, refuses changes to it once it has moved, and opens a moved organization's dashboard in v2. diff --git a/.changeset/v2-session-handoff.md b/.changeset/v2-session-handoff.md new file mode 100644 index 0000000000..332eb4a113 --- /dev/null +++ b/.changeset/v2-session-handoff.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Executor Cloud hands a signed-in member of a workspace that has moved to Executor v2 to v2 with a short-lived signed token, so v2 opens the workspace without asking them to sign in again. It is off until `V2_SESSION_HANDOFF_SECRET` is set. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 7d64d39efb..1f1b00e9b4 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,23 @@ # executor +## 1.6.11 + +### Patch Changes + +- [#2066](https://github.com/UsefulSoftwareCo/executor/pull/2066) [`c6d1083`](https://github.com/UsefulSoftwareCo/executor/commit/c6d10838f741ece700922fb2469782f79753b7f7) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Prefer browser sign-in when a matching OAuth client is available, while preserving a user’s chosen method when clients finish loading. + +- [#2254](https://github.com/UsefulSoftwareCo/executor/pull/2254) [`6e768da`](https://github.com/UsefulSoftwareCo/executor/commit/6e768da9dad13c7c469d2c2071bf542157f1033c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Send a default User-Agent on remote MCP requests so servers that reject requests without one accept the connection. A configured User-Agent header still wins. + +- [#2258](https://github.com/UsefulSoftwareCo/executor/pull/2258) [`8e4a8a4`](https://github.com/UsefulSoftwareCo/executor/commit/8e4a8a40048e8252f4d7f182456da62ca1d5c296) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Send the default User-Agent on the MCP endpoint probe and its OAuth metadata request too, so adding a server that rejects requests without one no longer fails. A configured User-Agent still wins. + +- [#2050](https://github.com/UsefulSoftwareCo/executor/pull/2050) [`24adb12`](https://github.com/UsefulSoftwareCo/executor/commit/24adb127f304e2b145b462c3805a9e11b2a524e0) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Accept Slack bot and user OAuth token envelopes during sign-in and token refresh. + +- Updated dependencies [[`2e5aa16`](https://github.com/UsefulSoftwareCo/executor/commit/2e5aa16bedb4ba74448b3f9338754764b40519a0), [`4a08d8d`](https://github.com/UsefulSoftwareCo/executor/commit/4a08d8db6f74612e07f17494e222ff038d8af944), [`c03c9ad`](https://github.com/UsefulSoftwareCo/executor/commit/c03c9adc1fcd4a3f2b0a839878f7310f5fc835b4)]: + - @executor-js/sdk@1.6.11 + - @executor-js/local@1.6.11 + - @executor-js/api@1.4.74 + - @executor-js/runtime-quickjs@1.6.11 + ## 1.6.10 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index f298ce7c4c..255ff37e62 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.6.10", + "version": "1.6.11", "private": true, "bin": { "executor": "./bin/executor.ts" diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index 691c2e786b..3820769164 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,34 @@ # @executor-js/cloud +## 1.4.72 + +### Patch Changes + +- [#2099](https://github.com/UsefulSoftwareCo/executor/pull/2099) [`9e143d9`](https://github.com/UsefulSoftwareCo/executor/commit/9e143d99c28f4c26d9e2cb1b136658048fecd3cb) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - An admin who resumes a paused execution from a different MCP session (for example after the client reconnects) keeps workspace-write access. The forwarded resume now carries the requester's access to the session that owns the execution, so a pending `addServer`, `addSpec`, or similar write no longer fails with `org_write_denied`. + +- [#2119](https://github.com/UsefulSoftwareCo/executor/pull/2119) [`4a52307`](https://github.com/UsefulSoftwareCo/executor/commit/4a52307c0bdc7606107c22e15574c24d0254777c) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Require authenticator verification for organization settings while preserving shared workspace reads and API-key access. + +- [#2266](https://github.com/UsefulSoftwareCo/executor/pull/2266) [`90e415c`](https://github.com/UsefulSoftwareCo/executor/commit/90e415c98c5414e019695e8501439755a8bc838a) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Link eligible single-member free organizations to Executor v2's workspace import from the sidebar. + +- [#2269](https://github.com/UsefulSoftwareCo/executor/pull/2269) [`49bd295`](https://github.com/UsefulSoftwareCo/executor/commit/49bd2959b4dd203b2a413304f7b3d4d5238d50a4) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Tell users when their organization has moved to Executor v2, with a link to it in v2 and a way to say why they are staying on v1. + +- Updated dependencies [[`29413d8`](https://github.com/UsefulSoftwareCo/executor/commit/29413d8dafaeabfd9b9f10e5fddd1f5b735ffa30), [`31b24b8`](https://github.com/UsefulSoftwareCo/executor/commit/31b24b8564899b137106baf28f66fb3f15c33633), [`2e5aa16`](https://github.com/UsefulSoftwareCo/executor/commit/2e5aa16bedb4ba74448b3f9338754764b40519a0), [`4a08d8d`](https://github.com/UsefulSoftwareCo/executor/commit/4a08d8db6f74612e07f17494e222ff038d8af944), [`c03c9ad`](https://github.com/UsefulSoftwareCo/executor/commit/c03c9adc1fcd4a3f2b0a839878f7310f5fc835b4)]: + - @executor-js/react@1.4.74 + - @executor-js/sdk@1.6.11 + - @executor-js/plugin-toolkits@1.5.46 + - @executor-js/mcp-apps-shell@1.4.22 + - @executor-js/plugin-graphql@1.6.11 + - @executor-js/plugin-mcp@1.6.11 + - @executor-js/plugin-openapi@1.6.11 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/api@1.4.74 + - @executor-js/execution@1.6.11 + - @executor-js/vite-plugin@0.0.71 + - @executor-js/cloudflare@0.0.53 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/runtime-quickjs@1.6.11 + ## 1.4.71 ### Patch Changes diff --git a/apps/cloud/drizzle/0021_org_moved_to_v2.sql b/apps/cloud/drizzle/0021_org_moved_to_v2.sql new file mode 100644 index 0000000000..3cdf9c1c8a --- /dev/null +++ b/apps/cloud/drizzle/0021_org_moved_to_v2.sql @@ -0,0 +1,9 @@ +CREATE TABLE "org_moved_to_v2" ( + "org_id" text PRIMARY KEY NOT NULL, + "state" text NOT NULL, + "v2_org_id" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "org_moved_to_v2_state_check" CHECK ("org_moved_to_v2"."state" in ('moving', 'moved')) +); +--> statement-breakpoint +ALTER TABLE "org_moved_to_v2" ADD CONSTRAINT "org_moved_to_v2_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql b/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql new file mode 100644 index 0000000000..6f7d999111 --- /dev/null +++ b/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql @@ -0,0 +1,32 @@ +-- `org_moved_to_v2` gains the v2 organization's slug (v2 writes it with +-- `moved`; the v1 dashboard redirects to `/org/` on v2) and a third +-- state, `rolled_back` (v2 gave the organization back; v1 serves it as if +-- there were no row). The constraint swap is safe online: the table holds at +-- most one row per organization that started a move. +ALTER TABLE "org_moved_to_v2" DROP CONSTRAINT "org_moved_to_v2_state_check";--> statement-breakpoint +ALTER TABLE "org_moved_to_v2" ADD COLUMN "v2_org_slug" text;--> statement-breakpoint +ALTER TABLE "org_moved_to_v2" ADD CONSTRAINT "org_moved_to_v2_state_check" CHECK ("org_moved_to_v2"."state" in ('moving', 'moved', 'rolled_back'));--> statement-breakpoint +-- v2 writes this table as `v1_import_writer`, with +-- INSERT INTO org_moved_to_v2 (org_id, state, v2_org_id, v2_org_slug) VALUES (...) +-- ON CONFLICT (org_id) DO UPDATE SET state = excluded.state, +-- v2_org_id = excluded.v2_org_id, v2_org_slug = excluded.v2_org_slug +-- which needs INSERT and UPDATE on the table and SELECT on the columns the +-- upsert names (the conflict target and the `excluded` reads). It gets nothing +-- else: no DELETE, no SELECT of `updated_at`, nothing on any other table, and +-- only USAGE on the schema, which PlanetScale does not give PUBLIC. +-- +-- The grant runs only when the role already exists. When the role is created +-- after this migration has run, run these three statements once, after +-- `CREATE ROLE v1_import_writer ...`: +-- GRANT USAGE ON SCHEMA public TO v1_import_writer; +-- GRANT INSERT, UPDATE ON org_moved_to_v2 TO v1_import_writer; +-- GRANT SELECT (org_id, state, v2_org_id, v2_org_slug) ON org_moved_to_v2 TO v1_import_writer; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'v1_import_writer') THEN + -- PlanetScale gives PUBLIC no USAGE on `public`, so the role needs its own. + GRANT USAGE ON SCHEMA public TO v1_import_writer; + GRANT INSERT, UPDATE ON org_moved_to_v2 TO v1_import_writer; + GRANT SELECT (org_id, state, v2_org_id, v2_org_slug) ON org_moved_to_v2 TO v1_import_writer; + END IF; +END $$; 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/0022_snapshot.json b/apps/cloud/drizzle/meta/0022_snapshot.json new file mode 100644 index 0000000000..d9f22b86cb --- /dev/null +++ b/apps/cloud/drizzle/meta/0022_snapshot.json @@ -0,0 +1,1819 @@ +{ + "id": "9d3bc667-63af-4a1b-99d9-b490ffcd80ef", + "prevId": "6b0d43ca-a04b-4dc8-b637-982f7ebbc3b8", + "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 + }, + "v2_org_slug": { + "name": "v2_org_slug", + "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', 'rolled_back')" + } + }, + "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..f751865445 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1789575639971, "tag": "0020_workos_sync_drained_at", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1791910002100, + "tag": "0021_org_moved_to_v2", + "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1792714563896, + "tag": "0022_org_moved_to_v2_slug", + "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/account-api.ts b/apps/cloud/src/account/account-api.ts index 99a609877a..b69415f9b9 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -11,6 +11,8 @@ import { } from "@executor-js/api/server"; import { ApiKeyService } from "../auth/api-keys"; +import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; +import { frozenWriteResponse } from "../org/move-to-v2"; import { UserStoreService } from "../auth/context"; import { WorkOsMirror } from "../auth/workos-mirror"; import { sessionFromSealed, type Session } from "../auth/middleware"; @@ -77,6 +79,24 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ // session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie). const session: Session | null = resolved ? sessionFromSealed(resolved, "") : null; + // The account plane's freeze (see `../org/move-to-v2`): every + // non-GET `/account/*` route changes the organization the selector + // names (its API keys, members, roles, name), so one check here covers + // them all. Only a write by a member of the selected organization is + // checked; anything else is refused by the service as before. + if (resolved && request.method !== "GET" && request.method !== "HEAD") { + const selector = request.headers[ORG_SELECTOR_HEADER]; + const org = selector + ? yield* authorizeOrganizationSelector(resolved.userId, selector).pipe( + Effect.orElseSucceed(() => null), + ) + : null; + if (org) { + const refused = yield* frozenWriteResponse(org.id); + if (refused) return refused; + } + } + const proof = resolved ? yield* readAdminMfaProof( env.WORKOS_COOKIE_PASSWORD, diff --git a/apps/cloud/src/account/account-freeze.node.test.ts b/apps/cloud/src/account/account-freeze.node.test.ts new file mode 100644 index 0000000000..4bfe4923db --- /dev/null +++ b/apps/cloud/src/account/account-freeze.node.test.ts @@ -0,0 +1,220 @@ +// --------------------------------------------------------------------------- +// The account plane's freeze over HTTP: every non-GET `/account/*` route +// changes the organization the selector names, and the account middleware +// refuses those for an organization moving to (or moved to) Executor v2 before +// the WorkOS account service runs. The move state is read from the real PGlite +// test database; WorkOS, the user store and the membership mirror are stubs, +// and WorkOS dies on anything but session authentication and the one update +// the open organization's request is allowed to make. +// --------------------------------------------------------------------------- + +import { verifiedSettingsCookie } from "../../test-stubs/verified-settings"; +import { afterAll, describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; + +import { MemberDirectory } from "@executor-js/api/server"; + +import { UserStoreService } from "../auth/context"; +import { ORG_SELECTOR_HEADER } from "../auth/organization"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; +import { DbService, makeDbLayer } from "../db/db"; +import { organizations, orgMovedToV2 } from "../db/schema"; +import { + ORG_MOVED_CODE, + ORG_MOVED_MESSAGE, + ORG_MOVING_CODE, + ORG_MOVING_MESSAGE, +} from "../org/move-to-v2"; +import { makeAccountApiLive } from "./account-api"; + +const CALLER = "user_synthetic_admin"; +const createdAt = new Date("2026-01-01T00:00:00.000Z"); + +const organizationRow = (id: string) => ({ + id, + name: `Org ${id}`, + slug: id, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, + createdAt, +}); + +const unreadStore = (why: string) => () => Effect.runPromise(Effect.die(why)); +const stubUsers = Layer.succeed(UserStoreService)({ + use: (_op, fn) => + Effect.promise(() => + fn({ + ensureAccount: unreadStore("accounts are not minted here"), + getAccount: unreadStore("accounts are not read here"), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...organizationRow(org.id), + name: org.name, + }), + getOrganization: async (id: string) => organizationRow(id), + getOrganizationBySlug: unreadStore("selectors here are ids"), + markOrganizationDeleted: unreadStore("nothing is deleted here"), + deleteOrganizationCascade: unreadStore("nothing is deleted here"), + }), + ), +}); + +// The caller is an active admin of every organization a test names. +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + accountId === CALLER + ? { + accountId, + membershipId: `om_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "admin", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("not read here"), + membershipsOf: () => Effect.die("not read here"), + members: () => Effect.die("not read here"), + membersById: () => Effect.die("not read here"), + findByEmail: () => Effect.die("not read here"), +}); + +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + +/** WorkOS as the account plane sees it; every other call it gets is recorded. */ +const stubWorkOS = (calls: string[]) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + const method = String(prop); + if (method === "authenticateSealedSession") { + return () => + Effect.succeed({ + userId: CALLER, + sessionId: "test-settings-session", + email: "admin@placeholder.test", + organizationId: null, + }); + } + if (method === "updateOrganization") { + return (id: string, name: string) => + Effect.sync(() => { + calls.push(method); + return { id, name, updatedAt: createdAt.toISOString() }; + }); + } + return () => + Effect.sync(() => { + calls.push(method); + }).pipe(Effect.andThen(Effect.die(`unexpected WorkOSClient.${method} call`))); + }, + }), + ); + +const apps: { dispose: () => Promise }[] = []; +afterAll(async () => { + await Promise.all(apps.map((app) => app.dispose())); +}); + +const send = async (organizationId: string, method: string, path: string, body?: unknown) => { + const calls: string[] = []; + const rsLive = Layer.mergeAll(makeDbLayer(), stubUsers, stubDirectory, stubMirror); + const app = HttpRouter.toWebHandler( + makeAccountApiLive(rsLive).pipe( + Layer.provide(stubWorkOS(calls)), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true }, + ); + apps.push(app); + const response = await app.handler( + new Request(`https://executor.test${path}`, { + method, + headers: { + cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`, + [ORG_SELECTOR_HEADER]: organizationId, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + undefined as never, + ); + return { + status: response.status, + retryAfter: response.headers.get("retry-after"), + body: (await response.json()) as unknown, + calls, + }; +}; + +const seedOrg = (state?: "moving" | "moved" | "rolled_back") => + Effect.runPromise( + Effect.gen(function* () { + const { db } = yield* DbService; + const id = `org_${crypto.randomUUID().replaceAll("-", "")}`; + yield* Effect.promise(() => + db.insert(organizations).values({ id, name: "Synthetic Org", slug: id.slice(4, 20) }), + ); + if (state) yield* Effect.promise(() => db.insert(orgMovedToV2).values({ orgId: id, state })); + return id; + }).pipe(Effect.provide(makeDbLayer())), + ); + +describe("account plane freeze", () => { + it("refuses member, key and settings changes while the organization moves", async () => { + const org = await seedOrg("moving"); + for (const [method, path, body] of [ + ["POST", "/account/members/invite", { email: "new@placeholder.test" }], + ["PATCH", "/account/members/om_1/role", { roleSlug: "member" }], + ["DELETE", "/account/members/om_1", undefined], + ["POST", "/account/api-keys", { name: "ci" }], + ["DELETE", "/account/api-keys/key_1", undefined], + ["POST", "/account/org-api-keys", { name: "ci" }], + ["PATCH", "/account/name", { name: "Renamed" }], + ] as const) { + const response = await send(org, method, path, body); + expect(response).toEqual({ + status: 423, + retryAfter: "60", + body: { error: ORG_MOVING_MESSAGE, code: ORG_MOVING_CODE }, + calls: [], + }); + } + }); + + it("refuses them for good once the organization has moved", async () => { + const org = await seedOrg("moved"); + const response = await send(org, "PATCH", "/account/name", { name: "Renamed" }); + expect(response).toEqual({ + status: 423, + retryAfter: null, + body: { error: ORG_MOVED_MESSAGE, code: ORG_MOVED_CODE }, + calls: [], + }); + }); + + it("serves the change when the organization is not moving, or was given back", async () => { + for (const org of [await seedOrg(), await seedOrg("rolled_back")]) { + const response = await send(org, "PATCH", "/account/name", { name: "Renamed" }); + expect(response).toEqual({ + status: 200, + retryAfter: null, + body: { name: "Renamed" }, + calls: ["updateOrganization"], + }); + } + }); +}); 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..533f750981 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, }); @@ -166,6 +184,22 @@ export class OrganizationDeletionForbidden extends Schema.TaggedErrorClass()( + "OrganizationFrozen", + { code: Schema.String, message: Schema.String }, + { httpApiStatus: 423 }, +) {} + +// Whether the organization is moving to v2 could not be read, so a change to +// it is refused rather than risked. Retryable. +export class OrganizationStateUnavailable extends Schema.TaggedErrorClass()( + "OrganizationStateUnavailable", + { code: Schema.String, message: Schema.String }, + { httpApiStatus: 503 }, +) {} + // An org deletion that started but did not finish: the org is marked deleted // (every session is refused) and a later step — `step` — failed before the // local purge ran. The admin's own membership row is still there, so the @@ -231,6 +265,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, @@ -247,6 +287,8 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") NoOrganization, OrganizationDeletionForbidden, OrganizationDeletionIncomplete, + OrganizationFrozen, + OrganizationStateUnavailable, ], }), ) diff --git a/apps/cloud/src/auth/doc-gate.ts b/apps/cloud/src/auth/doc-gate.ts index 7fcd3b0748..28b4f4af2e 100644 --- a/apps/cloud/src/auth/doc-gate.ts +++ b/apps/cloud/src/auth/doc-gate.ts @@ -36,15 +36,19 @@ import { type AuthHint, } from "@executor-js/react/multiplayer/auth-hint"; +import { env } from "cloudflare:workers"; + import { isAppOwnedPath } from "../app-paths"; import { makeDbLayer } from "../db/db"; +import { v2AppOrigin } from "../edge/marketing"; +import { movedDashboardLocation } from "../org/move-to-v2"; import { makeUserStoreLayer, UserStoreService } from "./context"; import { parseCookie } from "./cookies"; import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { makeMemberDirectoryLayer } from "./member-directory"; import { makeWorkOsMirrorLayer } from "./workos-mirror"; import { sealedSessionDisplayName } from "./middleware"; -import { authorizeOrganizationSelector } from "./organization"; +import { authorizeOrganization, authorizeOrganizationSelector } from "./organization"; import { loginPath, safeReturnTo } from "./return-to"; import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths"; import { WorkOSClient } from "./workos"; @@ -191,6 +195,55 @@ const authorizeLastOrgSlug = async ( return Exit.isSuccess(exit) ? exit.value : null; }; +// ── Moved to v2 ────────────────────────────────────────────────────────────── + +/** + * Where to send this page load when the organization it opens has moved to + * v2, or `null` to serve the v1 dashboard (see `movedDashboardLocation`). The + * organization is the one the URL names (`//…`), or for a bare path the + * session's own, the one the client canonicalizes onto. With + * `V2_SESSION_HANDOFF_SECRET` set, the location is v2's session handoff, which + * signs the member in to v2 as the user its import mapped. A failed read serves + * the dashboard: this is navigation only, and every write the dashboard could + * make is refused server-side while the organization is moving or moved. + */ +const movedToV2Location = async ( + session: VerifiedSession, + firstSegment: string, +): Promise => { + const v2Origin = v2AppOrigin(env); + if (v2Origin === null) return null; + const target = isValidOrgSlug(firstSegment) + ? { slug: firstSegment } + : session.organizationId + ? { organizationId: session.organizationId } + : null; + if (target === null) return null; + const exit = await getRuntime().runPromiseExit( + movedDashboardLocation({ + target, + v2Origin, + // Only once the session has verified: the token vouches for its user. + handoff: env.V2_SESSION_HANDOFF_SECRET + ? { secret: env.V2_SESSION_HANDOFF_SECRET, user: session.userId } + : undefined, + isMember: (organizationId) => + authorizeOrganization(session.userId, organizationId).pipe( + Effect.map((org) => org !== null), + ), + }).pipe( + Effect.provide( + Layer.mergeAll( + makeUserStoreLayer(), + makeMemberDirectoryLayer(), + makeWorkOsMirrorLayer(), + ).pipe(Layer.provideMerge(makeDbLayer())), + ), + ), + ); + return Exit.isSuccess(exit) ? exit.value : null; +}; + const hintSetCookie = (hint: AuthHint) => `${AUTH_HINT_COOKIE}=${encodeAuthHint(hint)}; ${HINT_COOKIE_ATTRIBUTES}; Max-Age=${AUTH_HINT_MAX_AGE_SECONDS}`; @@ -294,6 +347,12 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( } } + // An organization that has moved to v2 is opened there instead. + if (!ONBOARDING_PATHS.has(pathname)) { + const movedTo = await movedToV2Location(session, firstSegment); + if (movedTo) return redirect(movedTo, { refreshedSession: session.refreshedSession }); + } + // Serve the shell, minting the auth-hint cookie when the browser lacks a // current one so the client's post-mount cookie read seeds the verified // identity. Set-cookie writes ride on the shell response. diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index f5e42b16ea..67e761804d 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -12,6 +12,8 @@ import { McpSessionForbiddenError, OrganizationDeletionForbidden, OrganizationDeletionIncomplete, + OrganizationFrozen, + OrganizationStateUnavailable, } from "./api"; import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. @@ -42,6 +44,9 @@ 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"; +import { assertOrgWritable, orgFreezeRefusal } from "../org/move-to-v2"; const COOKIE_OPTIONS = { path: "/", @@ -439,6 +444,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; @@ -586,6 +603,28 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( return yield* new OrganizationDeletionForbidden(); } + // An organization moving to v2 (or moved) is not deleted from + // under the move: v2 is copying it, or already serves it. + yield* assertOrgWritable(organizationId).pipe( + Effect.catchTags({ + OrgFrozenError: (error) => { + const refusal = orgFreezeRefusal(error); + return Effect.fail( + new OrganizationFrozen({ code: refusal.code, message: refusal.message }), + ); + }, + OrgMoveStateReadError: (error) => { + const refusal = orgFreezeRefusal(error); + return Effect.fail( + new OrganizationStateUnavailable({ + code: refusal.code, + message: refusal.message, + }), + ); + }, + }), + ); + // Four steps, each idempotent, so a request that failed part-way // can be sent again and finish the job. The local purge is the LAST // step that can fail: it removes the org's membership rows — the 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..57d156c887 --- /dev/null +++ b/apps/cloud/src/auth/v2-import.ts @@ -0,0 +1,133 @@ +// --------------------------------------------------------------------------- +// 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 { v2AppOrigin, 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"; + +/** 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..269c6c54d2 --- /dev/null +++ b/apps/cloud/src/auth/v2-moved.ts @@ -0,0 +1,88 @@ +// --------------------------------------------------------------------------- +// 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; + +/** Whether `slug` is a well-formed v2 organization slug. */ +export const isV2OrgSlug = (slug: unknown): slug is string => + typeof slug === "string" && slug.length <= V2_SLUG_MAX_LENGTH && V2_SLUG.test(slug); + +/** 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 (!isV2OrgSlug(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/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 7165a93042..182b04507a 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -52,6 +52,13 @@ import type { ResolvedPrincipal, } from "@executor-js/api/server"; +import { DbService } from "../db/db"; +import { + assertOrgWritable, + isFrozenWriteRequest, + orgFreezeRefusal, + orgFreezeRefusalForCode, +} from "../org/move-to-v2"; import { ApiKeyService } from "./api-keys"; import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; import { WorkOsMirror } from "./workos-mirror"; @@ -390,6 +397,35 @@ export const resolveProtectedPrincipal = ( return yield* resolveSessionPrincipal(request); }); +/** + * The protected plane's freeze: a member's request that would change an + * organization moving to (or moved to) Executor v2 is refused here, once, for + * every route behind this plane (integrations, connections, secrets, policies, + * toolkits, artifacts, plugin settings, OAuth start/complete and clients), + * whichever credential sent it. Executions are exempt (see + * `isFrozenWriteRequest`): they keep running, and the executor's own gate + * refuses the writes and token refreshes inside them; so is the OAuth callback + * page, which that gate refuses before the code is exchanged. The state is + * read on every such request; a failed read refuses too. Platform (org API + * key) principals are read-only already and are refused as such downstream. + * + * The refusal rides the shared `Unavailable` failure, carrying the freeze code + * and message; `cloudIdentityFailureStrategy` renders it with the freeze status. + */ +const refuseFrozenWrite = ( + request: Request, + principal: ResolvedPrincipal, +): Effect.Effect => { + if (principal.kind === "platform") return Effect.void; + if (!isFrozenWriteRequest(request.method, new URL(request.url).pathname)) return Effect.void; + return assertOrgWritable(principal.organizationId).pipe( + Effect.mapError((error) => { + const refusal = orgFreezeRefusal(error); + return new Unavailable({ code: refusal.code, message: refusal.message }); + }), + ); +}; + /** * Cloud's NEUTRAL `IdentityProvider` Layer. Closes over the long-lived * `WorkOSClient` + `ApiKeyService`; the request-scoped `UserStoreService` + @@ -403,16 +439,17 @@ export const resolveProtectedPrincipal = ( export const workosIdentityLayer: Layer.Layer< IdentityProvider, never, - WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror | DbService > = Layer.effect( IdentityProvider, Effect.gen(function* () { const context = yield* Effect.context< - WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror | DbService >(); return IdentityProvider.of({ authenticate: (request) => resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe( + Effect.tap((principal) => refuseFrozenWrite(request, principal)), // `UserStoreError` / `WorkOSError` are org-resolution infra failures — // surface as a 500 defect, exactly as the old inline resolver let them // bubble. The narrow `die` here is the runtime edge for that infra @@ -436,18 +473,32 @@ export const workosIdentityLayer: Layer.Layer< // Render a shared identity failure as cloud's exact `{ error, code }` JSON body // at the given status. `code` + `message` ride on the shared error (cloud always // supplies both); the defaults only guard the self-host-produced bare errors. +// A freeze refusal (see `refuseFrozenWrite`) is rendered with its own status +// and `Retry-After` rather than the tag's. const renderIdentityFailure = (status: number, fallbackCode: string, fallbackMessage: string) => - (failure: { readonly code?: string; readonly message?: string }) => - Effect.succeed( + (failure: { readonly code?: string; readonly message?: string }) => { + const freeze = failure.code === undefined ? null : orgFreezeRefusalForCode(failure.code); + return Effect.succeed( HttpServerResponse.jsonUnsafe( { error: failure.message ?? fallbackMessage, code: failure.code ?? fallbackCode, }, - { status }, + freeze === null + ? { status } + : { + status: freeze.status, + headers: { + "cache-control": "no-store", + ...(freeze.retryAfterSeconds === undefined + ? {} + : { "retry-after": String(freeze.retryAfterSeconds) }), + }, + }, ), ); + }; /** * Cloud's failure-rendering STRATEGY. Where self-host's `textFailureStrategy` 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..174af8a6e1 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,41 @@ export const organizations = pgTable( }), ); +/** + * Where an organization stands in its move to Executor v2. v2 writes this + * table (the `v1_import_writer` role, which may only INSERT/UPDATE it and read + * the columns its upsert names); v1 only reads it. + * + * moving v2 is copying the organization. v1 freezes it: writes, + * OAuth callbacks and OAuth refresh are refused; MCP reads and + * tool calls that need neither keep working. + * moved the organization lives in v2. With a `v2_org_id`, its MCP + * URLs are forwarded to v2; the dashboard redirects to + * `/org/` on v2; writes are refused. + * rolled_back v2 gave the organization back. v1 serves it as if there were + * no row. + * + * `v2_org_id` and `v2_org_slug` stay 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"), + v2OrgSlug: text("v2_org_slug"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + stateCheck: check( + "org_moved_to_v2_state_check", + sql`${t.state} in ('moving', 'moved', 'rolled_back')`, + ), + }), +); + /** * 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/marketing.ts b/apps/cloud/src/edge/marketing.ts index 23a7dea4c8..283791b154 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -303,6 +303,17 @@ export const parseV2Edge = (env: V2EdgeEnv): V2Edge | string | null => { return { service, signUpUrl: parsed, oauthStatePrefix, analyticsProxyPath, errorTunnelPath }; }; +/** + * 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); +}; + /** 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 => diff --git a/apps/cloud/src/edge/production-config.test.ts b/apps/cloud/src/edge/production-config.test.ts index 95c82fcac6..f6e3571777 100644 --- a/apps/cloud/src/edge/production-config.test.ts +++ b/apps/cloud/src/edge/production-config.test.ts @@ -93,6 +93,13 @@ describe("production v2 edge settings", () => { expect(edge.errorTunnelPath).toBe("/api/fd6fab1fbb4883e1/submit"); }); + it("binds V2_MCP to v2's MCP Worker and names its origin", () => { + const bindings = config.services.filter((service) => service.binding === "V2_MCP"); + expect(bindings).toHaveLength(1); + expect(bindings[0]?.service).toBe("executor-next-hosted-api-v2-qs32brgjwvt7ytx4"); + expect(stringVar("V2_MCP_ORIGIN")).toBe("https://mcp.executor.sh"); + }); + it("keeps the MARKETING binding for v1's terms", () => { expect(config.services.filter((service) => service.binding === "MARKETING")).toHaveLength(1); }); @@ -111,8 +118,14 @@ describe("the executor.sh front Worker's settings", () => { }); it("ships the same v2 settings as executor-cloud", () => { + // The MCP forwarding settings (`V2_MCP_*`) belong to executor-cloud alone: + // the edge never serves MCP. const v2Vars = (vars: Readonly>) => - Object.fromEntries(Object.entries(vars).filter(([name]) => name.startsWith("V2_"))); + Object.fromEntries( + Object.entries(vars).filter( + ([name]) => name.startsWith("V2_") && !name.startsWith("V2_MCP_"), + ), + ); expect(Object.keys(v2Vars(edgeConfig.vars)).toSorted()).toEqual([ "V2_ANALYTICS_PROXY_PATH", "V2_ERROR_TUNNEL_PATH", diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index de3ae826a0..abf9af49bd 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -49,6 +49,7 @@ import executorConfig from "../../executor.config"; import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout"; import { DbService } from "../db/db"; import { cloudDbProviderLayer } from "../db/fuma"; +import { cloudWorkspaceWriteGate } from "../org/move-to-v2"; import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients"; export { makeExecutionStack } from "@executor-js/api/server"; @@ -112,6 +113,10 @@ export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, ( // gate; everywhere else the seam stays empty and the profile is attempted as // before. Gating happens at connect only — see the SDK contract. enterpriseManagedRollout: cloudEnterpriseManagedRollout(), + // An organization moving to (or moved to) Executor v2 is frozen: connection + // and OAuth writes and OAuth token refreshes are refused from every plane, + // read live on each guarded operation. See `../org/move-to-v2`. + workspaceWriteGate: cloudWorkspaceWriteGate, })); export const CloudCodeExecutorProvider: Layer.Layer = Layer.sync( diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 3e1fb0d8c1..c972d53541 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -141,6 +141,23 @@ declare global { // local dev and test workers without them serve everything from v1. /** Service binding to v2's API Worker; `edge/marketing.ts` forwards to it. */ V2?: Fetcher; + /** Service binding to v2's MCP Worker; `mcp/v2-forward.ts` forwards the + * MCP requests of organizations that have moved to v2. */ + V2_MCP?: Fetcher; + /** Origin of v2's MCP endpoint (`/mcp`). */ + V2_MCP_ORIGIN?: string; + /** Shared secret that signs the credential on forwarded MCP requests. + * Set with `wrangler secret put V2_MCP_FORWARD_SECRET`; while unset or + * without the binding, a moved organization's MCP requests answer a + * retryable 503 and are never served from v1. */ + V2_MCP_FORWARD_SECRET?: string; + /** Shared secret that signs the session handoff token the dashboard + * gives a signed-in member of a moved organization, so v2 opens it + * without a second sign-in (`org/v2-session-handoff.ts`). Set with + * `wrangler secret put V2_SESSION_HANDOFF_SECRET`, the same value as + * v2's V1_SESSION_HANDOFF_SECRET; while unset, the dashboard redirects + * to the organization's v2 page and v2 asks the member to sign in. */ + V2_SESSION_HANDOFF_SECRET?: string; /** Absolute URL `/sign-up` and `/signup` redirect to. */ V2_SIGN_UP_URL?: string; /** Prefix of v2's connected-account OAuth `state`; `/api/oauth/callback` diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index 2ee329df2d..3bdd90aa87 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -1,12 +1,19 @@ -import { describe, expect, it } from "@effect/vitest"; +import { afterAll, describe, expect, it } from "@effect/vitest"; +import { routeConfigs } from "autumn-js/backend"; import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; -import { MemberDirectory } from "@executor-js/api/server"; +import { MemberDirectory, requestScopedMiddleware } from "@executor-js/api/server"; import { UserStoreService } from "../../auth/context"; import { WorkOSClient, type WorkOSClientService } from "../../auth/workos"; import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror"; +import { DbService, makeDbLayer } from "../../db/db"; +import { organizations, orgMovedToV2 } from "../../db/schema"; +import { ORG_MOVING_CODE, ORG_MOVING_MESSAGE } from "../../org/move-to-v2"; import { + AutumnRoutesLive, + BILLING_READ_PATHS, CHECKOUT_TAX_ID_PARAMS, resolveBillingOrganization, withCheckoutTaxIdCollection, @@ -163,3 +170,128 @@ describe("billing checkout tax ID collection", () => { expect(withCheckoutTaxIdCollection("/api/billing/previewAttach", body)).toBe(body); }); }); + +// --------------------------------------------------------------------------- +// Billing's freeze (see `../../org/move-to-v2`): while an organization moves +// to v2, only the reads the billing pages make reach Autumn. +// --------------------------------------------------------------------------- + +describe("billing freeze", () => { + it("serves only Autumn's read routes while an organization moves", () => { + const routes = routeConfigs.map((config) => `/api/billing/${config.route}`); + for (const path of BILLING_READ_PATHS) expect(routes).toContain(path); + // A route Autumn adds later is refused until it is listed as a read. + expect( + routes + .filter((path) => !BILLING_READ_PATHS.has(path)) + .map((path) => path.slice("/api/billing/".length)) + .sort(), + ).toEqual([ + "attach", + "createReferralCode", + "multiAttach", + "openCustomerPortal", + "redeemReferralCode", + "setupPayment", + "updateSubscription", + ]); + }); + + // MEMBER belongs to every organization, as the mirror reports it. + const anyOrgDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + accountId === MEMBER + ? { + accountId, + membershipId: `om_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "admin", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("not read here"), + membershipsOf: () => Effect.die("not read here"), + members: () => Effect.die("not read here"), + membersById: () => Effect.die("not read here"), + findByEmail: () => Effect.die("not read here"), + }); + + const sessionWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => + prop === "authenticateRequest" + ? () => Effect.succeed({ userId: MEMBER, email: "member@placeholder.test" }) + : () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), + ); + + const apps: { dispose: () => Promise }[] = []; + afterAll(async () => { + await Promise.all(apps.map((app) => app.dispose())); + }); + + const post = async (organizationId: string, route: string) => { + const rsLive = Layer.mergeAll(makeDbLayer(), stubUsers, anyOrgDirectory, stubMirror); + const app = HttpRouter.toWebHandler( + AutumnRoutesLive.pipe( + Layer.provide(requestScopedMiddleware(rsLive).layer), + // The route reads WorkOS from the app's boot context, as in production. + Layer.provideMerge(sessionWorkOS), + Layer.provide(HttpServer.layerServices), + ), + { disableLogger: true }, + ); + apps.push(app); + const response = await app.handler( + new Request(`https://executor.test/api/billing/${route}`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-executor-organization": organizationId, + }, + body: JSON.stringify({ planId: "team" }), + }), + undefined as never, + ); + return { status: response.status, body: (await response.json()) as { code?: string } }; + }; + + const seedMovingOrg = () => + Effect.runPromise( + Effect.gen(function* () { + const { db } = yield* DbService; + const id = `org_${crypto.randomUUID().replaceAll("-", "")}`; + yield* Effect.promise(() => + db.insert(organizations).values({ id, name: "Synthetic Org", slug: id.slice(4, 20) }), + ); + yield* Effect.promise(() => db.insert(orgMovedToV2).values({ orgId: id, state: "moving" })); + return id; + }).pipe(Effect.provide(makeDbLayer())), + ); + + it("refuses a plan change for a moving organization before calling Autumn", async () => { + const org = await seedMovingOrg(); + for (const route of ["attach", "updateSubscription", "openCustomerPortal"]) { + expect(await post(org, route)).toEqual({ + status: 423, + body: { error: ORG_MOVING_MESSAGE, code: ORG_MOVING_CODE }, + }); + } + }); + + it("still hands a moving organization's billing reads to Autumn", async () => { + const org = await seedMovingOrg(); + // No Autumn key is configured here, so Autumn's own handler refuses the + // request: proof it got past the freeze. + expect(await post(org, "getOrCreateCustomer")).toMatchObject({ + body: { code: "billing_request_failed" }, + }); + }); +}); diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index d6205dc0e1..58bb192e29 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -4,6 +4,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import { autumnHandler } from "autumn-js/backend"; import { WorkOSClient } from "../../auth/workos"; +import { frozenWriteResponse } from "../../org/move-to-v2"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../../auth/organization"; import { HttpResponseError, @@ -17,6 +18,20 @@ type BillingSession = { const ATTACH_PATH = "/api/billing/attach"; +/** The billing routes that only read, served while an organization moves. */ +export const BILLING_READ_PATHS: ReadonlySet = new Set( + [ + "getOrCreateCustomer", + "getEntity", + "listPlans", + "listEvents", + "aggregateEvents", + "previewAttach", + "previewMultiAttach", + "previewUpdateSubscription", + ].map((route) => `/api/billing/${route}`), +); + // Stripe Checkout hides the VAT / tax ID field unless the session asks for it. // Autumn always passes an existing Stripe customer, and Stripe then requires // `customer_update.name = "auto"` so it can save the business name. Set on the @@ -89,6 +104,16 @@ const handler = Effect.gen(function* () { const org = yield* resolveBillingOrganization(webRequest, session); const url = new URL(webRequest.url); + + // Billing's freeze (see `../../org/move-to-v2`): a plan change, checkout, + // payment setup, portal session or referral for an organization moving to + // v2 could be charged twice or lost, so everything but the reads the + // billing pages make is refused. A route added later is refused until it + // is listed here. + if (!BILLING_READ_PATHS.has(url.pathname)) { + const refused = yield* frozenWriteResponse(org.id); + if (refused) return refused; + } const body = request.method !== "GET" && request.method !== "HEAD" ? yield* Effect.mapError( 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..4e413b7c28 --- /dev/null +++ b/apps/cloud/src/mcp/moved-to-v2.ts @@ -0,0 +1,33 @@ +// --------------------------------------------------------------------------- +// Whether an organization's MCP traffic has moved to Executor v2. +// +// Read from `org_moved_to_v2` (see `../org/move-to-v2`). Only a row in state +// `moved` that names the v2 organization forwards: no row, `rolled_back`, +// `moving`, or a `moved` row whose v2 organization does not exist yet all mean +// "serve from v1" (a `moving` org is served frozen: see `../org/move-to-v2`). +// 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 { Effect } from "effect"; + +import { DbService } from "../db/db"; +import { readOrgMoveState, type OrgMoveStateReadError } from "../org/move-to-v2"; + +/** The v2 organization an org's MCP traffic is forwarded to. */ +export interface MovedToV2 { + readonly v2OrganizationId: string; +} + +export const readMovedToV2 = ( + organizationId: string, +): Effect.Effect => + readOrgMoveState(organizationId).pipe( + Effect.map((moveState) => + moveState?.state === "moved" && moveState.v2OrganizationId + ? { v2OrganizationId: moveState.v2OrganizationId } + : null, + ), + 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..9230a94e5c --- /dev/null +++ b/apps/cloud/src/mcp/v2-forward.test.ts @@ -0,0 +1,374 @@ +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", () => { + // `v1-` + the first 40 hex chars of SHA-256 over + // JSON.stringify([v2OrganizationId, toolkitRowId, accountId]). + it("matches the wire-contract test vector", async () => { + expect(await toolkitConnectionId(V2_ORG, "tk_row_example", "user_EXAMPLE")).toBe( + "v1-59b67fa9ffe6d72010b3b9fa56f5bdb38199f2e1", + ); + }); + + it("names one connection per member of the toolkit", async () => { + const mine = await toolkitConnectionId(V2_ORG, "tk_row_example", "user_EXAMPLE"); + const theirs = await toolkitConnectionId(V2_ORG, "tk_row_example", "user_OTHER"); + expect(theirs).toBe("v1-7bb309a1759624b5a62f0d3a3601e3bbc48af0e9"); + expect(theirs).not.toBe(mine); + expect(await toolkitConnectionId(V2_ORG, "tk_row_example", "user_EXAMPLE")).toBe(mine); + }); +}); + +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" }), + ); + // The caller's own connection to the toolkit: `principal.accountId` is user_EXAMPLE. + const connection = "v1-59b67fa9ffe6d72010b3b9fa56f5bdb38199f2e1"; + 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..9ed1061e0a --- /dev/null +++ b/apps/cloud/src/mcp/v2-forward.ts @@ -0,0 +1,267 @@ +// --------------------------------------------------------------------------- +// 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, for one member, in a v2 + * organization. v2 imports a toolkit as one scoped connection per member, so + * the id names the member too: `accountId` is the caller's WorkOS user id, the + * same value v1 stores as `subject` on that member's own rows. + */ +export const toolkitConnectionId = async ( + v2OrganizationId: string, + toolkitRowId: string, + accountId: string, +): Promise => { + const digest = await crypto.subtle.digest( + "SHA-256", + encoder.encode(JSON.stringify([v2OrganizationId, toolkitRowId, accountId])), + ); + 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, principal.accountId), + }; + } + + 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..26ecd39669 --- /dev/null +++ b/apps/cloud/src/mcp/v2-moved-flag.node.test.ts @@ -0,0 +1,152 @@ +// --------------------------------------------------------------------------- +// 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`, `rolled_back`, 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 rolledBack = 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(rolledBack, "rolled_back", "v2-org-rolled-back"); + yield* setState(moved, "moved", "v2-org-moved"); + return { + none: yield* movedToV2(none), + moving: yield* movedToV2(moving), + movingNoOrg: yield* movedToV2(movingNoOrg), + movedNoOrg: yield* movedToV2(movedNoOrg), + rolledBack: yield* movedToV2(rolledBack), + moved: yield* movedToV2(moved), + }; + }), + ); + expect(result).toEqual({ + none: null, + moving: null, + movingNoOrg: null, + movedNoOrg: null, + rolledBack: null, + moved: { v2OrganizationId: "v2-org-moved" }, + }); + }); + + it("refuses a state other than moving, moved or rolled_back", 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/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 848f347889..1771684934 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -15,6 +15,7 @@ import { WorkOsMirror } from "../auth/workos-mirror"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { WorkOSClient } from "../auth/workos"; import { DbService } from "../db/db"; +import { frozenWriteResponse } from "./move-to-v2"; const unauthorized = () => HttpServerResponse.jsonUnsafe( @@ -86,6 +87,13 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ { status: 403, headers: { "cache-control": "no-store" } }, ); + // The domain routes' freeze (see `./move-to-v2`): verifying or + // deleting a domain changes the organization. + if (request.method !== "GET" && request.method !== "HEAD") { + const refused = yield* frozenWriteResponse(org.id); + if (refused) return refused; + } + const session = sessionFromSealed(result, cookieValue); const auth = AuthContext.of({ accountId: session.accountId, diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index b5c99af906..f88119851e 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -9,11 +9,13 @@ import { UserStoreService } from "../auth/context"; import { ORG_SELECTOR_HEADER } from "../auth/organization"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; -import { DbService } from "../db/db"; +import { DbService, makeDbLayer } from "../db/db"; +import { organizations, orgMovedToV2 } from "../db/schema"; import { AutumnService } from "../extensions/billing/service"; import { OrgHttpApi, Forbidden } from "./api"; import { OrgMemberRole, orgAuthMiddleware } from "./auth-middleware"; import { OrgHandlers, assertDomainInSessionOrg, requireAdmin } from "./handlers"; +import { ORG_MOVED_CODE, ORG_MOVING_CODE, ORG_MOVING_MESSAGE } from "./move-to-v2"; // --------------------------------------------------------------------------- // Domain-handler guards. The member / role / invite / org-name endpoints moved @@ -158,10 +160,10 @@ const DOMAIN = "dom_1"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); // The mirror's row for the caller, at whatever role a test sets. -const callerRow = (role: "admin" | "member"): DirectoryMember => ({ +const callerRow = (role: "admin" | "member", org: string): DirectoryMember => ({ accountId: CALLER, - membershipId: `om_${CALLER}_${ORG}`, - organizationId: ORG, + membershipId: `om_${CALLER}_${org}`, + organizationId: org, email: null, name: null, avatarUrl: null, @@ -171,10 +173,10 @@ const callerRow = (role: "admin" | "member"): DirectoryMember => ({ }); const unread = (why: string) => () => Effect.die(why); -const stubDirectory = (role: "admin" | "member") => +const stubDirectory = (role: "admin" | "member", org: string) => Layer.succeed(MemberDirectory)({ membership: (accountId, organizationId) => - Effect.succeed(accountId === CALLER && organizationId === ORG ? callerRow(role) : null), + Effect.succeed(accountId === CALLER && organizationId === org ? callerRow(role, org) : null), membershipById: unread("the org plane does not look up by membership id"), membershipsOf: unread("the org plane reads one membership, not the list"), members: unread("the org plane does not list members"), @@ -220,13 +222,13 @@ const stubMirror = Layer.succeed( }), ); -// The handlers never reach the database here: the directory and the store are -// stubbed above, so the request-scoped `DbService` is a placeholder. -const stubDb = Layer.succeed(DbService)({ db: {} as never }); +// The directory and the store are stubbed above; the one database read is the +// organization's move-to-v2 state (`./move-to-v2`), against the test database. 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"), @@ -235,25 +237,25 @@ const stubAutumn = Layer.succeed(AutumnService)({ // WorkOS as the org plane sees it: session authentication and the domain to // delete. `stubWorkOS` dies on anything else, so a `listUserMemberships` call // would fail the test — membership is never re-checked against WorkOS. -const workosForCaller = (deleted: string[]) => +const workosForCaller = (deleted: string[], org: string) => stubWorkOS({ authenticateSealedSession: () => Effect.succeed({ userId: CALLER, sessionId: "test-settings-session", email: "caller@placeholder.test", - organizationId: ORG, + organizationId: org, }), getOrganizationDomain: () => - Effect.succeed({ id: DOMAIN, organizationId: ORG, domain: "acme.test" }), + Effect.succeed({ id: DOMAIN, organizationId: org, domain: "acme.test" }), deleteOrganizationDomain: (domainId: string) => Effect.sync(() => { deleted.push(domainId); }), }); -const orgApp = (role: "admin" | "member", workos: Layer.Layer) => { - const rsLive = Layer.mergeAll(stubDb, stubUsers, stubDirectory(role), stubMirror); +const orgApp = (role: "admin" | "member", workos: Layer.Layer, org: string) => { + const rsLive = Layer.mergeAll(makeDbLayer(), stubUsers, stubDirectory(role, org), stubMirror); const App = HttpApiBuilder.layer(OrgHttpApi).pipe( Layer.provide(OrgHandlers), Layer.provide(orgAuthMiddleware(rsLive)), @@ -269,25 +271,44 @@ afterAll(async () => { await Promise.all(apps.map((app) => app.dispose())); }); -const deleteDomain = async (role: "admin" | "member") => { +const deleteDomain = async (role: "admin" | "member", org: string = ORG) => { const deleted: string[] = []; - const app = orgApp(role, workosForCaller(deleted)); + const app = orgApp(role, workosForCaller(deleted, org), org); apps.push(app); const response = await app.handler( new Request(`https://executor.test/org/domains/${DOMAIN}`, { method: "DELETE", headers: { cookie: `wos-session=sealed; ${await verifiedSettingsCookie(CALLER)}`, - [ORG_SELECTOR_HEADER]: ORG, + [ORG_SELECTOR_HEADER]: org, }, }), // beta.59: the handler type expects a context argument; this layer stack // needs none at runtime — pass undefined like the api.request-scope tests. undefined as never, ); - return { status: response.status, deleted }; + return { + status: response.status, + retryAfter: response.headers.get("retry-after"), + body: (await response.json()) as unknown, + deleted, + }; }; +/** An organization in the test database whose move to v2 is in `state`. */ +const seedMovingOrg = (state: "moving" | "moved") => + Effect.runPromise( + Effect.gen(function* () { + const { db } = yield* DbService; + const id = `org_${crypto.randomUUID().replaceAll("-", "")}`; + yield* Effect.promise(() => + db.insert(organizations).values({ id, name: "Synthetic Org", slug: id.slice(4, 20) }), + ); + yield* Effect.promise(() => db.insert(orgMovedToV2).values({ orgId: id, state })); + return id; + }).pipe(Effect.provide(makeDbLayer())), + ); + describe("Org domain handlers over HTTP: the admin gate is the authorized role", () => { it("lets a mirrored admin delete a domain", async () => { const { status, deleted } = await deleteDomain("admin"); @@ -300,4 +321,22 @@ describe("Org domain handlers over HTTP: the admin gate is the authorized role", expect(status).toBe(403); expect(deleted).toEqual([]); }); + + it("refuses an admin's change to an organization moving to v2", async () => { + const { status, retryAfter, body, deleted } = await deleteDomain( + "admin", + await seedMovingOrg("moving"), + ); + expect(status).toBe(423); + expect(retryAfter).toBe("60"); + expect(body).toEqual({ error: ORG_MOVING_MESSAGE, code: ORG_MOVING_CODE }); + expect(deleted).toEqual([]); + }); + + it("refuses an admin's change to an organization that moved to v2", async () => { + const { status, body, deleted } = await deleteDomain("admin", await seedMovingOrg("moved")); + expect(status).toBe(423); + expect(body).toMatchObject({ code: ORG_MOVED_CODE }); + expect(deleted).toEqual([]); + }); }); diff --git a/apps/cloud/src/org/move-to-v2.node.test.ts b/apps/cloud/src/org/move-to-v2.node.test.ts new file mode 100644 index 0000000000..fdd92ee680 --- /dev/null +++ b/apps/cloud/src/org/move-to-v2.node.test.ts @@ -0,0 +1,648 @@ +// --------------------------------------------------------------------------- +// The move-to-v2 freeze against the real PGlite Postgres every cloud unit test +// runs on (scripts/test-globalsetup.ts), which has every migration applied. +// +// What this pins: +// - the state read: no row and `rolled_back` are writable, `moving` and +// `moved` are frozen, and a failed read is an error (fail closed) +// - the executor's gate (`cloudWorkspaceWriteGate`) on its own connection, +// and a write through cloud's executor stack on this single-session +// database: the gate is read before the write's transaction opens +// - the protected plane's refusal in the identity provider, and how cloud's +// failure strategy renders it +// - the dashboard redirect decision for a moved organization +// - migration 0022: the slug column, the three states, and the +// `v1_import_writer` grants (upsert every state; no DELETE, no +// `updated_at`, nothing else) +// --------------------------------------------------------------------------- + +import { createHmac } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "@effect/vitest"; +import { eq } from "drizzle-orm"; +import { Effect, Layer, Option, Schema } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; + +import { + IdentityProvider, + MemberDirectory, + PluginsProvider, + Unavailable, + makeScopedExecutor, +} from "@executor-js/api/server"; + +import { ApiKeyService } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { cloudIdentityFailureStrategy, workosIdentityLayer } from "../auth/workos-auth-provider"; +import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; +import { DbService, makeDbLayer } from "../db/db"; +import { CloudDbProvider, CloudHostConfig } from "../engine/execution-stack"; +import { organizations, orgMovedToV2 } from "../db/schema"; +import { + ORG_MOVED_CODE, + ORG_MOVED_MESSAGE, + ORG_MOVE_STATE_UNAVAILABLE_CODE, + ORG_MOVE_STATE_UNAVAILABLE_MESSAGE, + ORG_MOVING_CODE, + ORG_MOVING_MESSAGE, + assertOrgWritable, + cloudWorkspaceWriteGate, + frozenWriteResponse, + movedDashboardLocation, + readOrgMoveState, +} from "./move-to-v2"; + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(makeDbLayer()))); + +const fresh = () => crypto.randomUUID().replaceAll("-", "").slice(0, 16); + +/** A new organization, optionally with a move row in `state`. */ +const seedOrg = ( + state?: string, + v2: { readonly id: string | null; readonly slug: string | null } = { id: null, slug: null }, +) => + run( + Effect.gen(function* () { + const { db } = yield* DbService; + const suffix = fresh(); + const id = `org_${suffix}`; + const slug = `moving-${suffix}`; + yield* Effect.promise(() => + db + .insert(organizations) + .values({ id, name: "Synthetic Org", slug, backfilledAt: new Date() }), + ); + if (state !== undefined) { + yield* Effect.promise(() => + db.insert(orgMovedToV2).values({ orgId: id, state, v2OrgId: v2.id, v2OrgSlug: v2.slug }), + ); + } + return { id, slug }; + }), + ); + +class SimulatedOutage extends Error {} + +/** A database whose every query rejects, the way an unreachable one does. */ +const brokenDb = Layer.succeed(DbService)({ + db: new Proxy({} as DbService["Service"]["db"], { + get: () => () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: drizzle's query builder throws synchronously when the connection is gone + throw new SimulatedOutage("database unavailable"); + }, + }), +}); + +describe("move state on write paths", () => { + it("accepts writes with no row or a rolled-back move", async () => { + const none = await seedOrg(); + const rolledBack = await seedOrg("rolled_back", { id: "v2-org", slug: "acme" }); + await run(assertOrgWritable(none.id)); + await run(assertOrgWritable(rolledBack.id)); + expect(await run(readOrgMoveState(rolledBack.id))).toBeNull(); + }); + + it("refuses writes while moving and after moving", async () => { + const moving = await seedOrg("moving"); + const moved = await seedOrg("moved", { id: "v2-org-moved", slug: "acme" }); + const movingError = await run(Effect.flip(assertOrgWritable(moving.id))); + const movedError = await run(Effect.flip(assertOrgWritable(moved.id))); + expect(movingError).toMatchObject({ _tag: "OrgFrozenError", state: "moving" }); + expect(movingError.message).toBe(ORG_MOVING_MESSAGE); + expect(movedError).toMatchObject({ _tag: "OrgFrozenError", state: "moved" }); + expect(movedError.message).toBe(ORG_MOVED_MESSAGE); + expect(await run(readOrgMoveState(moved.id))).toEqual({ + state: "moved", + v2OrganizationId: "v2-org-moved", + v2OrganizationSlug: "acme", + }); + }); + + it("fails closed when the state cannot be read", async () => { + const error = await Effect.runPromise( + Effect.flip(assertOrgWritable("org_unreadable")).pipe(Effect.provide(brokenDb)), + ); + expect(error).toMatchObject({ _tag: "OrgMoveStateReadError" }); + }); + + it("renders the refusal the account, domain and billing planes send", async () => { + const moving = await seedOrg("moving"); + const open = await seedOrg(); + const refused = await run(frozenWriteResponse(moving.id)); + expect(refused?.status).toBe(423); + expect(refused?.headers["retry-after"]).toBe("60"); + expect(await run(frozenWriteResponse(open.id))).toBeNull(); + const unreadable = await Effect.runPromise( + frozenWriteResponse("org_unreadable").pipe(Effect.provide(brokenDb)), + ); + expect(unreadable?.status).toBe(503); + }); +}); + +describe("the executor's workspace gate", () => { + it("refuses a frozen organization with the message the tool call shows", async () => { + const moving = await seedOrg("moving"); + const error = await Effect.runPromise(Effect.flip(cloudWorkspaceWriteGate(moving.id))); + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError", reason: ORG_MOVING_MESSAGE }); + }); + + it("lets an organization that is not moving write", async () => { + const open = await seedOrg(); + const rolledBack = await seedOrg("rolled_back"); + await Effect.runPromise(cloudWorkspaceWriteGate(open.id)); + await Effect.runPromise(cloudWorkspaceWriteGate(rolledBack.id)); + }); + + it("follows the state as v2 changes it, with no cache", async () => { + const org = await seedOrg("moving"); + await Effect.runPromise(Effect.flip(cloudWorkspaceWriteGate(org.id))); + await run( + Effect.gen(function* () { + const { db } = yield* DbService; + yield* Effect.promise(() => + db + .update(orgMovedToV2) + .set({ state: "rolled_back" }) + .where(eq(orgMovedToV2.orgId, org.id)), + ); + }), + ); + await Effect.runPromise(cloudWorkspaceWriteGate(org.id)); + }); +}); + +// --------------------------------------------------------------------------- +// The gate inside cloud's own executor stack. The test database is the +// single-session PGlite the dev stack runs: a second connection is served only +// once the first one's open transaction commits, so a gate asked from inside a +// write transaction would wait on that transaction until its connect timeout +// and then refuse a writable organization. +// --------------------------------------------------------------------------- + +const noPlugins = Layer.succeed(PluginsProvider)({ plugins: () => [] }); + +const createOrgPolicy = (organizationId: string) => + makeScopedExecutor("user_writer", organizationId, "Synthetic Org").pipe( + Effect.flatMap((executor) => + executor.policies.create({ owner: "org", pattern: "*", action: "require_approval" }), + ), + Effect.provide( + Layer.mergeAll(CloudDbProvider, noPlugins, CloudHostConfig).pipe( + Layer.provide(makeDbLayer()), + ), + ), + Effect.scoped, + ); + +describe("a write through cloud's executor", () => { + it("commits for an organization that is not moving", async () => { + const open = await seedOrg(); + const policy = await Effect.runPromise(createOrgPolicy(open.id)); + expect(policy).toMatchObject({ pattern: "*", action: "require_approval" }); + }); + + it("is refused for a moving organization with the move's message", async () => { + const moving = await seedOrg("moving"); + const error = await Effect.runPromise(Effect.flip(createOrgPolicy(moving.id))); + expect(error).toMatchObject({ _tag: "OrgWriteDeniedError", reason: ORG_MOVING_MESSAGE }); + }); +}); + +// --------------------------------------------------------------------------- +// The protected plane: the identity provider every `/api/*` executor route +// authenticates through. +// --------------------------------------------------------------------------- + +const MEMBER = "user_synthetic"; + +const stubApiKeys = (organizationId: string) => + Layer.succeed(ApiKeyService)({ + validate: (value: string) => + Effect.succeed( + value === "user_key" + ? { scope: "user" as const, accountId: MEMBER, organizationId, keyId: "key_1" } + : value === "org_key" + ? { scope: "org" as const, accountId: null, organizationId, keyId: "key_2" } + : null, + ), + listUserKeys: () => Effect.die("not listed here"), + createUserKey: () => Effect.die("not created here"), + revokeUserKey: () => Effect.die("not revoked here"), + listOrgKeys: () => Effect.die("not listed here"), + createOrgKey: () => Effect.die("not created here"), + revokeOrgKey: () => Effect.die("not revoked here"), + }); + +const stubWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), +); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + accountId === MEMBER + ? { + accountId, + membershipId: `om_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "admin", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("not read here"), + membershipsOf: () => Effect.die("not read here"), + members: () => Effect.die("not read here"), + membersById: () => Effect.die("not read here"), + findByEmail: () => Effect.die("not read here"), +}); + +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + +const authenticate = (organizationId: string, request: Request) => { + const dbLive = makeDbLayer(); + const identity = workosIdentityLayer.pipe( + Layer.provide( + Layer.mergeAll( + stubApiKeys(organizationId), + stubWorkOS, + stubDirectory, + stubMirror, + UserStoreService.Live.pipe(Layer.provide(dbLive)), + dbLive, + ), + ), + ); + return Effect.runPromise( + IdentityProvider.asEffect().pipe( + Effect.flatMap((provider) => provider.authenticate(request)), + Effect.provide(identity), + Effect.flip, + Effect.option, + ), + ); +}; + +const apiRequest = (method: string, path: string, key = "user_key") => + new Request(`https://executor.test/api${path}`, { + method, + headers: { authorization: `Bearer ${key}` }, + }); + +describe("the protected plane's freeze", () => { + it("refuses a member's write to a moving organization", async () => { + const moving = await seedOrg("moving"); + for (const [method, path] of [ + ["POST", "/connections"], + ["PATCH", "/integrations/github"], + ["DELETE", "/artifacts/art_1"], + ["POST", "/oauth/start"], + ] as const) { + const refused = await authenticate(moving.id, apiRequest(method, path)); + expect(Option.isSome(refused)).toBe(true); + expect(refused).toMatchObject({ + value: { _tag: "Unavailable", code: ORG_MOVING_CODE, message: ORG_MOVING_MESSAGE }, + }); + } + }); + + it("refuses a write to a moved organization as moved", async () => { + const moved = await seedOrg("moved", { id: "v2-org", slug: "acme" }); + expect(await authenticate(moved.id, apiRequest("POST", "/policies"))).toMatchObject({ + value: { code: ORG_MOVED_CODE, message: ORG_MOVED_MESSAGE }, + }); + }); + + it("serves reads, executions and the OAuth callback page while moving", async () => { + const moving = await seedOrg("moving"); + for (const [method, path] of [ + ["GET", "/connections"], + ["POST", "/executions"], + ["POST", "/executions/exec_1/resume"], + ["GET", "/oauth/callback"], + ] as const) { + expect(Option.isNone(await authenticate(moving.id, apiRequest(method, path)))).toBe(true); + } + }); + + it("serves writes to an organization that is not moving, or was given back", async () => { + const open = await seedOrg(); + const rolledBack = await seedOrg("rolled_back"); + for (const org of [open, rolledBack]) { + expect(Option.isNone(await authenticate(org.id, apiRequest("POST", "/connections")))).toBe( + true, + ); + } + }); + + it("leaves an org API key's write to the read-only refusal", async () => { + const moving = await seedOrg("moving"); + expect( + Option.isNone(await authenticate(moving.id, apiRequest("POST", "/connections", "org_key"))), + ).toBe(true); + }); + + it("renders the refusal with the freeze status, and other outages as before", async () => { + const asResponse = async (failure: Unavailable) => { + const result = await Effect.runPromise( + cloudIdentityFailureStrategy + .renderFailure(Effect.fail(failure)) + .pipe( + Effect.map((value) => (HttpServerResponse.isHttpServerResponse(value) ? value : null)), + ), + ); + if (result === null) return null; + return { + status: result.status, + retryAfter: result.headers["retry-after"], + body: await HttpServerResponse.toWeb(result).json(), + }; + }; + expect(await asResponse(new Unavailable({}))).toMatchObject({ + status: 503, + retryAfter: undefined, + body: { code: "service_unavailable" }, + }); + expect( + await asResponse(new Unavailable({ code: ORG_MOVING_CODE, message: ORG_MOVING_MESSAGE })), + ).toEqual({ + status: 423, + retryAfter: "60", + body: { error: ORG_MOVING_MESSAGE, code: ORG_MOVING_CODE }, + }); + expect( + await asResponse( + new Unavailable({ + code: ORG_MOVE_STATE_UNAVAILABLE_CODE, + message: ORG_MOVE_STATE_UNAVAILABLE_MESSAGE, + }), + ), + ).toMatchObject({ status: 503, retryAfter: "2" }); + expect( + await asResponse( + new Unavailable({ code: "api_key_validation_unavailable", message: "unavailable" }), + ), + ).toEqual({ + status: 503, + retryAfter: undefined, + body: { error: "unavailable", code: "api_key_validation_unavailable" }, + }); + }); +}); + +// --------------------------------------------------------------------------- +// The dashboard redirect decision (`auth/doc-gate.ts` runs it per page load). +// --------------------------------------------------------------------------- + +describe("moved dashboard location", () => { + const v2Origin = new URL("https://app.example.test"); + const member = () => Effect.succeed(true); + const stranger = () => Effect.succeed(false); + const locate = ( + target: { readonly slug: string } | { readonly organizationId: string }, + isMember: (organizationId: string) => Effect.Effect = member, + ) => run(movedDashboardLocation({ target, v2Origin, isMember })); + + it("sends a member of a moved organization to its v2 page, by URL slug or session org", async () => { + const moved = await seedOrg("moved", { id: "v2-org", slug: "acme-team" }); + expect(await locate({ slug: moved.slug })).toBe("https://app.example.test/org/acme-team"); + expect(await locate({ organizationId: moved.id })).toBe( + "https://app.example.test/org/acme-team", + ); + }); + + it("sends a moved organization without a v2 slug to v2's root", async () => { + const moved = await seedOrg("moved", { id: "v2-org", slug: null }); + expect(await locate({ slug: moved.slug })).toBe("https://app.example.test/"); + }); + + it("serves the v1 dashboard to everyone else", async () => { + const moved = await seedOrg("moved", { id: "v2-org", slug: "acme-team" }); + const moving = await seedOrg("moving"); + const rolledBack = await seedOrg("rolled_back", { id: "v2-org", slug: "acme-team" }); + const open = await seedOrg(); + expect(await locate({ slug: moved.slug }, stranger)).toBeNull(); + expect(await locate({ slug: moving.slug })).toBeNull(); + expect(await locate({ slug: rolledBack.slug })).toBeNull(); + expect(await locate({ slug: open.slug })).toBeNull(); + expect(await locate({ slug: "no-such-org" })).toBeNull(); + expect(await locate({ organizationId: open.id })).toBeNull(); + }); + + it("checks membership only for a moved organization", async () => { + const moving = await seedOrg("moving"); + let checked = 0; + const counting = () => + Effect.sync(() => { + checked += 1; + return true; + }); + await locate({ slug: moving.slug }, counting); + expect(checked).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// The session handoff the redirect carries when V2_SESSION_HANDOFF_SECRET is set. +// --------------------------------------------------------------------------- + +const decodeClaims = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +describe("moved dashboard session handoff", () => { + const v2Origin = new URL("https://app.example.test"); + const secret = "synthetic-handoff-secret-0123456789abcdef"; + const handoff = { secret, user: "user_synthetic_member" }; + const member = () => Effect.succeed(true); + const stranger = () => Effect.succeed(false); + const locate = ( + target: { readonly slug: string } | { readonly organizationId: string }, + options: { + readonly isMember?: (organizationId: string) => Effect.Effect; + readonly handoff?: typeof handoff; + } = { handoff }, + ) => + run( + movedDashboardLocation({ + target, + v2Origin, + isMember: options.isMember ?? member, + handoff: options.handoff, + }), + ); + + /** The claims of a handoff URL's token, once its signature checks out with `secret`. */ + const verifiedClaims = (location: string | null) => { + expect(location).not.toBeNull(); + const url = new URL(location ?? ""); + expect(`${url.origin}${url.pathname}`).toBe("https://app.example.test/api/v1-handoff"); + expect([...url.searchParams.keys()]).toEqual(["token"]); + const [prefix, payload, signature, ...rest] = (url.searchParams.get("token") ?? "").split("."); + expect(prefix).toBe("v1hnd"); + expect(rest).toEqual([]); + const expected = createHmac("sha256", secret).update(`v1hnd.${payload}`).digest("base64url"); + expect(signature).toBe(expected); + return decodeClaims(Buffer.from(payload ?? "", "base64url").toString("utf8")); + }; + + it("sends a member of a moved organization to v2's handoff with a signed, minute-long token", async () => { + const moved = await seedOrg("moved", { id: "v2-org", slug: "acme-team" }); + const before = Math.floor(Date.now() / 1000); + const bySlug = verifiedClaims(await locate({ slug: moved.slug })); + const bySession = verifiedClaims(await locate({ organizationId: moved.id })); + expect(bySlug).toMatchObject({ + v: 1, + aud: "v2-session-handoff", + user: "user_synthetic_member", + organization: moved.id, + slug: "acme-team", + }); + expect(Object.keys(bySlug).sort()).toEqual( + ["aud", "exp", "iat", "jti", "organization", "slug", "user", "v"].sort(), + ); + expect(bySlug.iat).toBeGreaterThanOrEqual(before); + expect(bySlug.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)); + expect(Number(bySlug.exp) - Number(bySlug.iat)).toBe(60); + expect(typeof bySlug.jti === "string" && bySlug.jti.length >= 16).toBe(true); + // Every redirect carries its own token. + expect(bySession).toMatchObject({ organization: moved.id, slug: "acme-team" }); + expect(bySession.jti).not.toBe(bySlug.jti); + }); + + it("gives no token to anyone who is not a member, or for an organization that has not moved", async () => { + const moved = await seedOrg("moved", { id: "v2-org", slug: "acme-team" }); + const moving = await seedOrg("moving"); + expect(await locate({ slug: moved.slug }, { isMember: stranger, handoff })).toBeNull(); + expect(await locate({ organizationId: moved.id }, { isMember: stranger, handoff })).toBeNull(); + expect(await locate({ slug: moving.slug })).toBeNull(); + }); + + it("keeps the plain redirect without the secret, or without a v2 slug to open", async () => { + const moved = await seedOrg("moved", { id: "v2-org", slug: "acme-team" }); + const slugless = await seedOrg("moved", { id: "v2-org", slug: null }); + expect(await locate({ slug: moved.slug }, {})).toBe("https://app.example.test/org/acme-team"); + expect(await locate({ slug: slugless.slug })).toBe("https://app.example.test/"); + }); +}); + +// --------------------------------------------------------------------------- +// Migration 0022 and the role v2 writes the table with. +// --------------------------------------------------------------------------- + +const MIGRATION = readFileSync( + new URL("../../drizzle/0022_org_moved_to_v2_slug.sql", import.meta.url), + "utf8", +); +const GRANT_BLOCK = MIGRATION.slice(MIGRATION.indexOf("DO $$"), MIGRATION.indexOf("END $$;") + 7); + +/** Run `statement` as `v1_import_writer`, in a transaction of its own. */ +const asWriter = (statement: string) => + run( + Effect.gen(function* () { + const { sql } = yield* DbService; + if (sql === undefined) return yield* Effect.die("the test database exposes no sql handle"); + return yield* Effect.tryPromise({ + try: () => + sql.begin(async (tx) => { + await tx.unsafe("set local role v1_import_writer"); + return [...(await tx.unsafe(statement))]; + }), + catch: (cause) => cause, + }); + }), + ); + +const upsert = (organizationId: string, state: string, v2OrgId: string, v2OrgSlug: string) => + `insert into org_moved_to_v2 (org_id, state, v2_org_id, v2_org_slug) + values ('${organizationId}', '${state}', '${v2OrgId}', '${v2OrgSlug}') + on conflict (org_id) do update set state = excluded.state, + v2_org_id = excluded.v2_org_id, v2_org_slug = excluded.v2_org_slug`; + +describe("migration 0022", () => { + it("accepts exactly the three states", async () => { + for (const state of ["moving", "moved", "rolled_back"]) { + await seedOrg(state, { id: "v2-org", slug: "acme" }); + } + await expect(seedOrg("failed")).rejects.toThrow(); + await expect(seedOrg("paused")).rejects.toThrow(); + }); + + it("documents the grants it runs, for a role created after it", () => { + expect(MIGRATION).toContain( + "-- GRANT INSERT, UPDATE ON org_moved_to_v2 TO v1_import_writer;", + ); + expect(MIGRATION).toContain( + "-- GRANT SELECT (org_id, state, v2_org_id, v2_org_slug) ON org_moved_to_v2 TO v1_import_writer;", + ); + expect(GRANT_BLOCK).toContain("GRANT INSERT, UPDATE ON org_moved_to_v2 TO v1_import_writer;"); + expect(GRANT_BLOCK).toContain( + "GRANT SELECT (org_id, state, v2_org_id, v2_org_slug) ON org_moved_to_v2 TO v1_import_writer;", + ); + }); + + it("lets v1_import_writer upsert every state, and nothing more", async () => { + // The test database ran the migration before the role existed, as a + // deployment that creates the role later would; the migration's own + // grant block, run once the role exists, grants it. + await run( + Effect.gen(function* () { + const { sql } = yield* DbService; + if (sql === undefined) return yield* Effect.die("the test database exposes no sql handle"); + yield* Effect.promise(() => + sql.unsafe(`DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'v1_import_writer') THEN + CREATE ROLE v1_import_writer; + END IF; + END $$`), + ); + yield* Effect.promise(() => sql.unsafe(GRANT_BLOCK)); + }), + ); + + const org = await seedOrg(); + await asWriter(upsert(org.id, "moving", "v2-org", "acme")); + expect(await run(readOrgMoveState(org.id))).toEqual({ state: "moving" }); + await asWriter(upsert(org.id, "moved", "v2-org", "acme")); + expect(await run(readOrgMoveState(org.id))).toEqual({ + state: "moved", + v2OrganizationId: "v2-org", + v2OrganizationSlug: "acme", + }); + await asWriter(upsert(org.id, "rolled_back", "v2-org", "acme")); + expect(await run(readOrgMoveState(org.id))).toBeNull(); + + await expect(asWriter(upsert(org.id, "paused", "v2-org", "acme"))).rejects.toMatchObject({ + message: expect.stringContaining("check constraint"), + }); + for (const refused of [ + "select updated_at from org_moved_to_v2", + "select * from org_moved_to_v2", + `delete from org_moved_to_v2 where org_id = '${org.id}'`, + "select id from organizations", + "insert into organizations (id, name, slug) values ('org_x', 'x', 'x')", + ]) { + await expect(asWriter(refused)).rejects.toMatchObject({ + message: expect.stringContaining("permission denied"), + }); + } + expect( + await asWriter("select org_id, state, v2_org_id, v2_org_slug from org_moved_to_v2"), + ).not.toHaveLength(0); + }); +}); diff --git a/apps/cloud/src/org/move-to-v2.test.ts b/apps/cloud/src/org/move-to-v2.test.ts new file mode 100644 index 0000000000..764f489cea --- /dev/null +++ b/apps/cloud/src/org/move-to-v2.test.ts @@ -0,0 +1,160 @@ +// --------------------------------------------------------------------------- +// The move-to-v2 state, the freeze's request classification and its HTTP +// refusals: the pure parts of `./move-to-v2`. The database-backed parts are in +// `./move-to-v2.node.test.ts`. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; + +import { CLOUD_MOUNT_PREFIX } from "../engine/execution-stack"; +import { + ORG_MOVED_CODE, + ORG_MOVED_MESSAGE, + ORG_MOVE_STATE_UNAVAILABLE_CODE, + ORG_MOVE_STATE_UNAVAILABLE_MESSAGE, + ORG_MOVING_CODE, + ORG_MOVING_MESSAGE, + OrgFrozenError, + OrgMoveStateReadError, + isFrozenWriteRequest, + movedDashboardUrl, + orgFreezeRefusal, + orgFreezeRefusalForCode, + orgFreezeServerResponse, + orgMoveStateFromRow, +} from "./move-to-v2"; + +const row = (state: string, v2OrgId: string | null = null, v2OrgSlug: string | null = null) => ({ + state, + v2OrgId, + v2OrgSlug, +}); + +describe("move state", () => { + it("serves an organization as usual with no row or a rolled-back move", () => { + expect(orgMoveStateFromRow(undefined)).toBeNull(); + expect(orgMoveStateFromRow(row("rolled_back", "v2-org", "acme"))).toBeNull(); + }); + + it("reads moving and moved, with the v2 organization once it exists", () => { + expect(orgMoveStateFromRow(row("moving"))).toEqual({ state: "moving" }); + expect(orgMoveStateFromRow(row("moved"))).toEqual({ + state: "moved", + v2OrganizationId: null, + v2OrganizationSlug: null, + }); + expect(orgMoveStateFromRow(row("moved", "v2-org", "acme"))).toEqual({ + state: "moved", + v2OrganizationId: "v2-org", + v2OrganizationSlug: "acme", + }); + }); + + it("reads a state this build does not know as moving", () => { + expect(orgMoveStateFromRow(row("failed"))).toEqual({ state: "moving" }); + }); +}); + +describe("frozen write requests", () => { + const api = (path: string) => `${CLOUD_MOUNT_PREFIX}${path}`; + + it("lets reads through", () => { + for (const method of ["GET", "HEAD", "OPTIONS"]) { + expect(isFrozenWriteRequest(method, api("/connections"))).toBe(false); + } + }); + + it("refuses every other method on the organization's routes", () => { + for (const [method, path] of [ + ["POST", "/connections"], + ["PATCH", "/connections/user/github/main"], + ["DELETE", "/integrations/github"], + ["PUT", "/artifacts/art_1/preview"], + ["POST", "/oauth/complete"], + ["POST", "/oauth/start"], + ["POST", "/connections/org/github/main/refresh"], + ["POST", "/policies"], + ["POST", "/toolkits"], + ] as const) { + expect(isFrozenWriteRequest(method, api(path))).toBe(true); + } + }); + + it("keeps executions running", () => { + expect(isFrozenWriteRequest("POST", api("/executions"))).toBe(false); + expect(isFrozenWriteRequest("POST", api("/executions/exec_1/resume"))).toBe(false); + expect(isFrozenWriteRequest("POST", "/executions")).toBe(false); + expect(isFrozenWriteRequest("DELETE", api("/executions"))).toBe(true); + expect(isFrozenWriteRequest("POST", api("/executions/exec_1/other"))).toBe(true); + }); + + it("leaves the OAuth callback to the executor's gate, which the popup can render", () => { + expect(isFrozenWriteRequest("GET", api("/oauth/callback"))).toBe(false); + }); +}); + +describe("refusals", () => { + it("locks a moving organization, temporarily", () => { + const refusal = orgFreezeRefusal(new OrgFrozenError({ state: "moving" })); + expect(refusal).toEqual({ + status: 423, + code: ORG_MOVING_CODE, + message: ORG_MOVING_MESSAGE, + retryAfterSeconds: 60, + }); + expect(ORG_MOVING_MESSAGE).toBe( + "This workspace is moving to Executor v2. Changes are paused until the move finishes.", + ); + }); + + it("locks a moved organization for good", () => { + expect(orgFreezeRefusal(new OrgFrozenError({ state: "moved" }))).toEqual({ + status: 423, + code: ORG_MOVED_CODE, + message: ORG_MOVED_MESSAGE, + }); + }); + + it("answers a failed state read with a retryable 503", () => { + expect(orgFreezeRefusal(new OrgMoveStateReadError({ cause: "down" }))).toEqual({ + status: 503, + code: ORG_MOVE_STATE_UNAVAILABLE_CODE, + message: ORG_MOVE_STATE_UNAVAILABLE_MESSAGE, + retryAfterSeconds: 2, + }); + }); + + it("finds a refusal by its code, and nothing else", () => { + expect(orgFreezeRefusalForCode(ORG_MOVING_CODE)?.status).toBe(423); + expect(orgFreezeRefusalForCode(ORG_MOVE_STATE_UNAVAILABLE_CODE)?.status).toBe(503); + expect(orgFreezeRefusalForCode("api_key_validation_unavailable")).toBeNull(); + expect(orgFreezeRefusalForCode("toString")).toBeNull(); + }); + + it("renders cloud's { error, code } body with Retry-After", async () => { + const response = orgFreezeServerResponse(new OrgFrozenError({ state: "moving" })); + expect(response.status).toBe(423); + expect(response.headers["retry-after"]).toBe("60"); + expect(response.headers["cache-control"]).toBe("no-store"); + const moved = orgFreezeServerResponse(new OrgFrozenError({ state: "moved" })); + expect(moved.headers["retry-after"]).toBeUndefined(); + }); +}); + +describe("moved dashboard URL", () => { + const origin = new URL("https://app.example.test"); + const moved = (slug: string | null) => + ({ state: "moved", v2OrganizationId: "v2-org", v2OrganizationSlug: slug }) as const; + + it("opens the organization's page on v2", () => { + expect(movedDashboardUrl(moved("acme-team"), origin)).toBe( + "https://app.example.test/org/acme-team", + ); + }); + + it("opens v2's root without a well-formed v2 slug", () => { + expect(movedDashboardUrl(moved(null), origin)).toBe("https://app.example.test/"); + expect(movedDashboardUrl(moved("../admin"), origin)).toBe("https://app.example.test/"); + expect(movedDashboardUrl(moved("Acme"), origin)).toBe("https://app.example.test/"); + }); +}); diff --git a/apps/cloud/src/org/move-to-v2.ts b/apps/cloud/src/org/move-to-v2.ts new file mode 100644 index 0000000000..1ba89947ce --- /dev/null +++ b/apps/cloud/src/org/move-to-v2.ts @@ -0,0 +1,347 @@ +// --------------------------------------------------------------------------- +// Where an organization stands in its move to Executor v2, and the freeze that +// follows from it. +// +// v2 writes `org_moved_to_v2`; v1 only reads it, once per request that needs +// it (never cached: v2 waits about a minute after writing `moving` before it +// starts copying, and every write v1 accepts after that is lost). +// +// no row, rolled_back v1 serves the organization as it always has +// moving frozen: writes, OAuth callbacks and OAuth refresh are +// refused; reads and tool calls that need neither work +// moved writes are refused for good (v2 is the copy that +// counts); MCP is forwarded and the dashboard redirects +// +// A state this build does not know is read as `moving`, and a failed read is +// an error, never "not moving": a write path that cannot read the state +// refuses with a retryable error rather than guess. +// --------------------------------------------------------------------------- + +import { eq } from "drizzle-orm"; +import { Data, Effect, Predicate } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; + +import { OrgWriteDeniedError } from "@executor-js/sdk"; + +import { isV2OrgSlug } from "../auth/v2-moved"; +import { DbService, makeDbLayer } from "../db/db"; +import { organizations, orgMovedToV2 } from "../db/schema"; +import { mintSessionHandoffToken, sessionHandoffUrl } from "./v2-session-handoff"; + +/** Shown wherever a write is refused while the organization is moving. */ +export const ORG_MOVING_MESSAGE = + "This workspace is moving to Executor v2. Changes are paused until the move finishes."; + +/** Shown wherever a write is refused after the organization has moved. */ +export const ORG_MOVED_MESSAGE = + "This workspace has moved to Executor v2. Make changes in Executor v2 instead."; + +/** Shown when the move state could not be read on a write path. */ +export const ORG_MOVE_STATE_UNAVAILABLE_MESSAGE = + "Executor could not confirm this workspace accepts changes right now - please retry"; + +/** How long a client should wait before retrying a write refused while moving. */ +export const ORG_MOVING_RETRY_AFTER_SECONDS = 60; +/** How long a client should wait before retrying after a failed state read. */ +export const ORG_MOVE_STATE_RETRY_AFTER_SECONDS = 2; + +export type OrgMoveState = + | { readonly state: "moving" } + | { + readonly state: "moved"; + readonly v2OrganizationId: string | null; + readonly v2OrganizationSlug: string | null; + }; + +export class OrgMoveStateReadError extends Data.TaggedError("OrgMoveStateReadError")<{ + readonly cause: unknown; +}> { + override get message(): string { + return ORG_MOVE_STATE_UNAVAILABLE_MESSAGE; + } +} + +/** A write refused because the organization is moving to, or has moved to, v2. */ +export class OrgFrozenError extends Data.TaggedError("OrgFrozenError")<{ + readonly state: OrgMoveState["state"]; +}> { + override get message(): string { + return this.state === "moving" ? ORG_MOVING_MESSAGE : ORG_MOVED_MESSAGE; + } +} + +export interface OrgMoveRow { + readonly state: string; + readonly v2OrgId: string | null; + readonly v2OrgSlug: string | null; +} + +/** The move state a stored row means; `null` when v1 serves the org as usual. */ +export const orgMoveStateFromRow = (row: OrgMoveRow | undefined): OrgMoveState | null => { + if (row === undefined || row.state === "rolled_back") return null; + if (row.state === "moved") { + return { + state: "moved", + v2OrganizationId: row.v2OrgId, + v2OrganizationSlug: row.v2OrgSlug, + }; + } + return { state: "moving" }; +}; + +export const readOrgMoveState = ( + 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, + v2OrgSlug: orgMovedToV2.v2OrgSlug, + }) + .from(orgMovedToV2) + .where(eq(orgMovedToV2.orgId, organizationId)), + catch: (cause) => new OrgMoveStateReadError({ cause }), + }); + return orgMoveStateFromRow(rows[0]); + }).pipe(Effect.withSpan("org.read_move_state")); + +/** + * The move state of the organization a dashboard URL names, by its v1 slug, + * with the organization's id: one read joining the slug to the move row. + * `null` when no organization has the slug or v1 serves it as usual. + */ +export const readOrgMoveStateBySlug = ( + slug: string, +): Effect.Effect< + { readonly organizationId: string; readonly moveState: OrgMoveState } | null, + OrgMoveStateReadError, + DbService +> => + Effect.gen(function* () { + const { db } = yield* DbService; + const rows = yield* Effect.tryPromise({ + try: () => + db + .select({ + organizationId: orgMovedToV2.orgId, + state: orgMovedToV2.state, + v2OrgId: orgMovedToV2.v2OrgId, + v2OrgSlug: orgMovedToV2.v2OrgSlug, + }) + .from(orgMovedToV2) + .innerJoin(organizations, eq(organizations.id, orgMovedToV2.orgId)) + .where(eq(organizations.slug, slug)), + catch: (cause) => new OrgMoveStateReadError({ cause }), + }); + const row = rows[0]; + const moveState = orgMoveStateFromRow(row); + return row && moveState ? { organizationId: row.organizationId, moveState } : null; + }).pipe(Effect.withSpan("org.read_move_state_by_slug")); + +/** + * Where v1's dashboard sends a moved organization: its page on v2's app host. + * Without a well-formed v2 slug (v2 writes one with `moved`, so this is only a + * guard) it is v2's app root, which opens the member's own v2 organizations. + */ +export const movedDashboardUrl = ( + moved: Extract, + v2Origin: URL, +): string => + isV2OrgSlug(moved.v2OrganizationSlug) + ? new URL(`/org/${moved.v2OrganizationSlug}`, v2Origin).href + : new URL("/", v2Origin).href; + +/** + * Where v1's dashboard sends a page load for the organization `target` names + * (by URL slug or id), or `null` to serve it in v1: only an organization that + * has `moved`, and only for a member of it, so the redirect says nothing about + * an organization the caller cannot open. The move row is read first: the + * membership read only runs for a moved organization. + * + * With `handoff` (the signed-in WorkOS user and the shared secret), the member + * goes to v2's session handoff with a token vouching for that user, so v2 opens + * the organization without a second sign-in (`v2-session-handoff.ts`). Without + * it, or without a v2 slug to open, the redirect is the plain one. + */ +export const movedDashboardLocation = (input: { + readonly target: { readonly slug: string } | { readonly organizationId: string }; + readonly v2Origin: URL; + readonly isMember: (organizationId: string) => Effect.Effect; + readonly handoff?: { readonly secret: string; readonly user: string } | undefined; +}): Effect.Effect => + Effect.gen(function* () { + const { target, handoff } = input; + const found = + "slug" in target + ? yield* readOrgMoveStateBySlug(target.slug) + : yield* readOrgMoveState(target.organizationId).pipe( + Effect.map((moveState) => + moveState === null ? null : { organizationId: target.organizationId, moveState }, + ), + ); + if (found === null || found.moveState.state !== "moved") return null; + if (!(yield* input.isMember(found.organizationId))) return null; + const slug = found.moveState.v2OrganizationSlug; + if (handoff === undefined || !isV2OrgSlug(slug)) + return movedDashboardUrl(found.moveState, input.v2Origin); + const token = yield* Effect.promise(() => + mintSessionHandoffToken(handoff.secret, { + user: handoff.user, + organization: found.organizationId, + slug, + }), + ); + return sessionHandoffUrl(input.v2Origin, token); + }); + +/** Succeeds only when the organization accepts writes in v1. */ +export const assertOrgWritable = ( + organizationId: string, +): Effect.Effect => + readOrgMoveState(organizationId).pipe( + Effect.flatMap((moveState) => + moveState === null + ? Effect.void + : Effect.fail(new OrgFrozenError({ state: moveState.state })), + ), + ); + +/** + * Cloud's `workspaceWriteGate` for the executor (see `ExecutorConfig`): the + * freeze as both request planes see it from inside the SDK — connection and + * OAuth writes, OAuth completion and OAuth token refresh, whether a dashboard + * request, an API key, the CLI or an MCP tool call asked for them. It runs on + * its own short-lived database connection, so the MCP session Durable Object, + * whose executor outlives any one request, still reads the state fresh on each + * guarded operation. That second connection is safe because the executor never + * asks the gate while one of its storage transactions is open: a write + * transaction asks it just before it opens. (On the single-session PGlite the + * dev stack and tests run, a connection waiting on an open transaction is + * never served, so asking from inside one would hang until connect timeout.) + */ +export const cloudWorkspaceWriteGate = (tenant: string): Effect.Effect => + assertOrgWritable(tenant).pipe( + Effect.provide(makeDbLayer()), + Effect.mapError( + (error) => new OrgWriteDeniedError({ reason: orgFreezeRefusal(error).message }), + ), + Effect.withSpan("org.workspace_write_gate"), + ); + +// --------------------------------------------------------------------------- +// HTTP rendering, shared by every plane that refuses a write. +// +// moving 423 Locked + Retry-After. The org is locked, not the server +// failing: a 4xx keeps the freeze out of 5xx error rates and stops +// generic clients from retrying a write in a loop for the minutes a +// move takes; Retry-After and the message say it is temporary. +// moved 423 Locked, no Retry-After: v1's copy stays locked for good. +// read 503 + Retry-After: a genuine transient failure on our side. +// --------------------------------------------------------------------------- + +export const ORG_MOVING_CODE = "organization_moving"; +export const ORG_MOVED_CODE = "organization_moved"; +export const ORG_MOVE_STATE_UNAVAILABLE_CODE = "organization_state_unavailable"; + +export interface OrgFreezeRefusal { + readonly status: number; + readonly code: string; + readonly message: string; + readonly retryAfterSeconds?: number; +} + +const MOVING_REFUSAL: OrgFreezeRefusal = { + status: 423, + code: ORG_MOVING_CODE, + message: ORG_MOVING_MESSAGE, + retryAfterSeconds: ORG_MOVING_RETRY_AFTER_SECONDS, +}; +const MOVED_REFUSAL: OrgFreezeRefusal = { + status: 423, + code: ORG_MOVED_CODE, + message: ORG_MOVED_MESSAGE, +}; +const STATE_UNAVAILABLE_REFUSAL: OrgFreezeRefusal = { + status: 503, + code: ORG_MOVE_STATE_UNAVAILABLE_CODE, + message: ORG_MOVE_STATE_UNAVAILABLE_MESSAGE, + retryAfterSeconds: ORG_MOVE_STATE_RETRY_AFTER_SECONDS, +}; +const REFUSALS: ReadonlyMap = new Map( + [MOVING_REFUSAL, MOVED_REFUSAL, STATE_UNAVAILABLE_REFUSAL].map((refusal) => [ + refusal.code, + refusal, + ]), +); + +/** The refusal a freeze code stands for, or `null` for any other code. */ +export const orgFreezeRefusalForCode = (code: string): OrgFreezeRefusal | null => + REFUSALS.get(code) ?? null; + +export const orgFreezeRefusal = (error: OrgFrozenError | OrgMoveStateReadError): OrgFreezeRefusal => + Predicate.isTagged(error, "OrgFrozenError") + ? error.state === "moving" + ? MOVING_REFUSAL + : MOVED_REFUSAL + : STATE_UNAVAILABLE_REFUSAL; + +/** A refusal as cloud's `{ error, code }` JSON API failure. */ +export const orgFreezeServerResponse = ( + error: OrgFrozenError | OrgMoveStateReadError, +): HttpServerResponse.HttpServerResponse => { + const refusal = orgFreezeRefusal(error); + return HttpServerResponse.jsonUnsafe( + { error: refusal.message, code: refusal.code }, + { + status: refusal.status, + headers: { + "cache-control": "no-store", + ...(refusal.retryAfterSeconds === undefined + ? {} + : { "retry-after": String(refusal.retryAfterSeconds) }), + }, + }, + ); +}; + +/** + * The refusal to send instead of serving a write to `organizationId`, or + * `null` when v1 accepts writes for it. For the planes that answer with a + * plain response (account, domains, billing). + */ +export const frozenWriteResponse = ( + organizationId: string, +): Effect.Effect => + assertOrgWritable(organizationId).pipe( + Effect.as(null), + Effect.catch((error) => Effect.succeed(orgFreezeServerResponse(error))), + ); + +// --------------------------------------------------------------------------- +// Which requests the freeze refuses. +// --------------------------------------------------------------------------- + +/** Running code is not a change to the organization: the executor's own gate + * refuses the writes and refreshes inside an execution (see + * `cloudWorkspaceWriteGate`), and everything else it does keeps working, as + * MCP tool calls do. The paths are the protected API's, with or without + * cloud's `/api` mount. */ +const EXECUTION_PATH = /^(?:\/api)?\/executions(?:\/[^/]+\/resume)?$/; + +/** + * Whether a protected-API request would change the organization: any method + * but GET/HEAD/OPTIONS. Running and resuming executions is exempt. + * + * The OAuth callback is a GET that completes a connection, and is let through + * on purpose: it is a page in the user's popup, so it is refused by the + * executor's gate at the top of `oauth.complete` (before the authorization + * code is exchanged), which the popup renders with the freeze message. + */ +export const isFrozenWriteRequest = (method: string, pathname: string): boolean => { + if (method === "GET" || method === "HEAD" || method === "OPTIONS") return false; + return !(method === "POST" && EXECUTION_PATH.test(pathname)); +}; diff --git a/apps/cloud/src/org/v2-session-handoff.ts b/apps/cloud/src/org/v2-session-handoff.ts new file mode 100644 index 0000000000..63be6062ee --- /dev/null +++ b/apps/cloud/src/org/v2-session-handoff.ts @@ -0,0 +1,86 @@ +// --------------------------------------------------------------------------- +// Handing a member's verified v1 session to Executor v2. +// +// v2 never signs anyone in with WorkOS. When the dashboard redirects a +// signed-in member of an organization that has moved, it sends them to v2's +// `/api/v1-handoff` with a token vouching for the WorkOS session it has just +// verified, and v2 starts its own session for the user its import mapped: +// +// v1hnd.. +// +// payload base64url (no padding) of the UTF-8 JSON +// {"v":1,"aud":"v2-session-handoff","user","organization","slug", +// "iat","exp","jti"} +// sig base64url (no padding) of HMAC-SHA256 over "v1hnd." + payload, +// keyed by the UTF-8 bytes of V2_SESSION_HANDOFF_SECRET +// +// `user` is the WorkOS user, `organization` the v1 organization, `slug` the +// v2 organization to open. A token lives a minute and v2 accepts each `jti` +// once. The secret is its own: it is never the MCP forward secret. +// --------------------------------------------------------------------------- + +const TOKEN_PREFIX = "v1hnd"; +const TOKEN_VERSION = 1; +export const SESSION_HANDOFF_AUDIENCE = "v2-session-handoff"; +export const SESSION_HANDOFF_TTL_SECONDS = 60; + +/** v2's route that turns a token into a v2 session. */ +export const SESSION_HANDOFF_PATH = "/api/v1-handoff"; + +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(/=+$/, ""); +}; + +export interface SessionHandoffClaims { + /** The WorkOS user whose session v1 verified. */ + readonly user: string; + /** The v1 organization that moved. */ + readonly organization: string; + /** The v2 organization to open. */ + readonly slug: string; +} + +export const mintSessionHandoffToken = async ( + secret: string, + claims: SessionHandoffClaims, + nowSeconds: number = Math.floor(Date.now() / 1000), +): Promise => { + const payload = base64Url( + encoder.encode( + JSON.stringify({ + v: TOKEN_VERSION, + aud: SESSION_HANDOFF_AUDIENCE, + user: claims.user, + organization: claims.organization, + slug: claims.slug, + iat: nowSeconds, + exp: nowSeconds + SESSION_HANDOFF_TTL_SECONDS, + jti: base64Url(crypto.getRandomValues(new Uint8Array(18))), + }), + ), + ); + 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))}`; +}; + +/** Where v1 sends the member: v2's handoff route on its app host. */ +export const sessionHandoffUrl = (v2Origin: URL, token: string): string => { + const url = new URL(SESSION_HANDOFF_PATH, v2Origin); + url.searchParams.set("token", token); + return url.href; +}; 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. + + + +
+ +