From 8fe46f4dc2aa278e34b0cf667a08eea10ab0584e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:55:23 -0700 Subject: [PATCH 1/2] Freeze organizations moving to v2 Writes, OAuth completion and OAuth token refresh are refused for an organization whose org_moved_to_v2 row is moving or moved (423 Locked; 503 when the state cannot be read). The dashboard of a moved organization redirects to its v2 page. Toolkit connections forwarded to v2 are now named per member. The executor asks the host's write gate before a write transaction opens and carries the verdict into it, never asking from inside one: the gate reads on its own connection, which a single-session database would not serve while the transaction holds the request's. Migration 0022 adds v2_org_slug and the rolled_back state, and grants v1_import_writer its access when the role exists. For a role created after the migration has run: 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; --- .changeset/v1-move-freeze.md | 7 + .../drizzle/0022_org_moved_to_v2_slug.sql | 28 + apps/cloud/drizzle/meta/0022_snapshot.json | 1819 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/account/account-api.ts | 20 + apps/cloud/src/auth/api.ts | 18 + apps/cloud/src/auth/doc-gate.ts | 55 +- apps/cloud/src/auth/handlers.ts | 25 + apps/cloud/src/auth/v2-import.ts | 13 +- apps/cloud/src/auth/v2-moved.ts | 8 +- apps/cloud/src/auth/workos-auth-provider.ts | 61 +- apps/cloud/src/db/schema.ts | 25 +- apps/cloud/src/edge/marketing.ts | 11 + apps/cloud/src/engine/execution-stack.ts | 5 + .../src/extensions/billing/route.node.test.ts | 136 +- apps/cloud/src/extensions/billing/route.ts | 25 + apps/cloud/src/mcp/moved-to-v2.ts | 39 +- apps/cloud/src/mcp/v2-forward.test.ts | 17 +- apps/cloud/src/mcp/v2-forward.ts | 15 +- apps/cloud/src/mcp/v2-moved-flag.node.test.ts | 9 +- apps/cloud/src/org/auth-middleware.ts | 8 + apps/cloud/src/org/handlers.test.ts | 74 +- apps/cloud/src/org/move-to-v2.ts | 330 +++ packages/core/api/src/handlers/oauth.ts | 9 +- .../core/api/src/server/scoped-executor.ts | 11 + packages/core/sdk/src/errors.ts | 15 +- packages/core/sdk/src/executor.ts | 158 +- packages/core/sdk/src/oauth-service.ts | 6 + 28 files changed, 2859 insertions(+), 95 deletions(-) create mode 100644 .changeset/v1-move-freeze.md create mode 100644 apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql create mode 100644 apps/cloud/drizzle/meta/0022_snapshot.json create mode 100644 apps/cloud/src/org/move-to-v2.ts 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/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..0e172ce659 --- /dev/null +++ b/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql @@ -0,0 +1,28 @@ +-- `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. +-- +-- The grant runs only when the role already exists. When the role is created +-- after this migration has run, run these two statements once, after +-- `CREATE ROLE 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 + 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/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 7d8b7a313c..f751865445 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "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/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/auth/api.ts b/apps/cloud/src/auth/api.ts index 47ad43cfa7..533f750981 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -184,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 @@ -271,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..3b4455ee73 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,49 @@ 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. 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, + 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 +341,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 e6857d0aba..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. @@ -44,6 +46,7 @@ import { 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: "/", @@ -600,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/v2-import.ts b/apps/cloud/src/auth/v2-import.ts index 10a811dd3b..57d156c887 100644 --- a/apps/cloud/src/auth/v2-import.ts +++ b/apps/cloud/src/auth/v2-import.ts @@ -13,7 +13,7 @@ import { Effect } from "effect"; -import { parseV2Edge, type V2EdgeEnv } from "../edge/marketing"; +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"; @@ -21,17 +21,6 @@ import { WorkOSClient } from "./workos"; /** v2's import page, on v2's app host. */ const V2_IMPORT_PATH = "/v1-import"; -/** - * v2's app host, or `null` where v1 does not hand requests to v2 (local dev - * without the v2 settings, test workers, or broken settings). It is the origin - * of v2's sign-up page, the same host the edge sends v2 dashboard links to. - */ -export const v2AppOrigin = (env: V2EdgeEnv): URL | null => { - const edge = parseV2Edge(env); - if (edge === null || typeof edge === "string") return null; - return new URL(edge.signUpUrl.origin); -}; - /** The absolute URL of v2's import page, or `null` where v1 has no v2 host. */ export const v2ImportUrl = (env: V2EdgeEnv): URL | null => { const origin = v2AppOrigin(env); diff --git a/apps/cloud/src/auth/v2-moved.ts b/apps/cloud/src/auth/v2-moved.ts index 4d8da5c811..269c6c54d2 100644 --- a/apps/cloud/src/auth/v2-moved.ts +++ b/apps/cloud/src/auth/v2-moved.ts @@ -21,6 +21,10 @@ export const V2_MOVED_AT_KEY = "executor_v2_moved_at"; 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})$/; @@ -41,9 +45,7 @@ export const parseV2Moved = ( ): V2Moved | null => { const slug = metadata[V2_MOVED_SLUG_KEY]; const movedAt = metadata[V2_MOVED_AT_KEY]; - if (typeof slug !== "string" || slug.length > V2_SLUG_MAX_LENGTH || !V2_SLUG.test(slug)) { - return null; - } + if (!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; 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/schema.ts b/apps/cloud/src/db/schema.ts index d9fa135d17..174af8a6e1 100644 --- a/apps/cloud/src/db/schema.ts +++ b/apps/cloud/src/db/schema.ts @@ -124,13 +124,20 @@ export const organizations = pgTable( ); /** - * Whether an organization's MCP traffic has moved to Executor v2. v2 writes - * this table (a separate database role that may only INSERT/UPDATE it); v1 - * only reads it, on the MCP auth path. + * 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` changes nothing for MCP requests. `moved` with a `v2_org_id` means - * the organization's MCP URLs are forwarded to v2. No row means not moved. - * `v2_org_id` stays null until the v2 organization exists. + * 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", @@ -140,10 +147,14 @@ export const orgMovedToV2 = pgTable( .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')`), + stateCheck: check( + "org_moved_to_v2_state_check", + sql`${t.state} in ('moving', 'moved', 'rolled_back')`, + ), }), ); 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/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/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/mcp/moved-to-v2.ts b/apps/cloud/src/mcp/moved-to-v2.ts index b5c9ea803c..4e413b7c28 100644 --- a/apps/cloud/src/mcp/moved-to-v2.ts +++ b/apps/cloud/src/mcp/moved-to-v2.ts @@ -1,42 +1,33 @@ // --------------------------------------------------------------------------- // Whether an organization's MCP traffic has moved to Executor v2. // -// Read from `org_moved_to_v2`, which v2 writes and v1 only reads. Only a row in -// state `moved` that names the v2 organization forwards: no row, `moving`, or a -// `moved` row whose v2 organization does not exist yet all mean "serve from v1". +// 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 { eq } from "drizzle-orm"; -import { Data, Effect } from "effect"; +import { Effect } from "effect"; import { DbService } from "../db/db"; -import { orgMovedToV2 } from "../db/schema"; +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 class MovedToV2ReadError extends Data.TaggedError("MovedToV2ReadError")<{ - readonly cause: unknown; -}> {} - export const readMovedToV2 = ( organizationId: string, -): Effect.Effect => - Effect.gen(function* () { - const { db } = yield* DbService; - const rows = yield* Effect.tryPromise({ - try: () => - db - .select({ state: orgMovedToV2.state, v2OrgId: orgMovedToV2.v2OrgId }) - .from(orgMovedToV2) - .where(eq(orgMovedToV2.orgId, organizationId)), - catch: (cause) => new MovedToV2ReadError({ cause }), - }); - const row = rows[0]; - return row?.state === "moved" && row.v2OrgId ? { v2OrganizationId: row.v2OrgId } : null; - }).pipe(Effect.withSpan("mcp.auth.read_moved_to_v2")); +): 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/v2-forward.test.ts b/apps/cloud/src/mcp/v2-forward.test.ts index 665ed855ee..9230a94e5c 100644 --- a/apps/cloud/src/mcp/v2-forward.test.ts +++ b/apps/cloud/src/mcp/v2-forward.test.ts @@ -89,11 +89,21 @@ describe("forward token", () => { }); 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")).toBe( - "v1-e81c8640683211fc13e4c20d5bbb35d9bbc22935", + 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", () => { @@ -266,7 +276,8 @@ describe("forwardToV2", () => { env, makeDeps({ deploy: "tk_row_example" }), ); - const connection = "v1-e81c8640683211fc13e4c20d5bbb35d9bbc22935"; + // 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 }); diff --git a/apps/cloud/src/mcp/v2-forward.ts b/apps/cloud/src/mcp/v2-forward.ts index 54ef6fdf44..9ed1061e0a 100644 --- a/apps/cloud/src/mcp/v2-forward.ts +++ b/apps/cloud/src/mcp/v2-forward.ts @@ -87,14 +87,20 @@ export const mintForwardToken = async ( return `${TOKEN_PREFIX}.${payload}.${base64Url(new Uint8Array(signature))}`; }; -/** The v2 connection that stands for a v1 toolkit in a v2 organization. */ +/** + * 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])), + encoder.encode(JSON.stringify([v2OrganizationId, toolkitRowId, accountId])), ); return `v1-${hex(digest).slice(0, 40)}`; }; @@ -232,7 +238,10 @@ export const forwardToV2 = async ( slug, ); if (rowId === null) return jsonRpcErrorBody(404, -32001, TOOLKIT_NOT_FOUND_MESSAGE); - toolkit = { id: rowId, connection: await toolkitConnectionId(moved.v2OrganizationId, rowId) }; + toolkit = { + id: rowId, + connection: await toolkitConnectionId(moved.v2OrganizationId, rowId, principal.accountId), + }; } const nowSeconds = deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000); diff --git a/apps/cloud/src/mcp/v2-moved-flag.node.test.ts b/apps/cloud/src/mcp/v2-moved-flag.node.test.ts index cf017a1317..26ecd39669 100644 --- a/apps/cloud/src/mcp/v2-moved-flag.node.test.ts +++ b/apps/cloud/src/mcp/v2-moved-flag.node.test.ts @@ -4,7 +4,8 @@ // // What this pins: // - only a `moved` row that names its v2 organization forwards; no row, -// `moving`, or `moved` without a v2 organization all read as not moved +// `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 @@ -59,16 +60,19 @@ describe("moved-to-v2 flag", () => { 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), }; }), @@ -78,11 +82,12 @@ describe("moved-to-v2 flag", () => { moving: null, movingNoOrg: null, movedNoOrg: null, + rolledBack: null, moved: { v2OrganizationId: "v2-org-moved" }, }); }); - it("refuses a state other than moving or moved", async () => { + it("refuses a state other than moving, moved or rolled_back", async () => { await expect( run( Effect.gen(function* () { 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 07c5e38f01..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,9 +222,8 @@ 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"), @@ -236,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)), @@ -270,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"); @@ -301,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.ts b/apps/cloud/src/org/move-to-v2.ts new file mode 100644 index 0000000000..879d16fc34 --- /dev/null +++ b/apps/cloud/src/org/move-to-v2.ts @@ -0,0 +1,330 @@ +// --------------------------------------------------------------------------- +// 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"; + +/** 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. + */ +export const movedDashboardLocation = (input: { + readonly target: { readonly slug: string } | { readonly organizationId: string }; + readonly v2Origin: URL; + readonly isMember: (organizationId: string) => Effect.Effect; +}): Effect.Effect => + Effect.gen(function* () { + const { target } = 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; + return movedDashboardUrl(found.moveState, input.v2Origin); + }); + +/** 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/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index eb4b1f939b..74aa01988f 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -18,6 +18,7 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + isUserActionableError, type Connection, type ConnectResult, } from "@executor-js/sdk"; @@ -57,7 +58,8 @@ const startResultToResponse = (result: ConnectResult) => state: result.state, }; -const toPopupErrorMessage = (error: unknown): PopupErrorMessage => { +/** What the OAuth popup shows for a failed callback. Exported for its test. */ +export const toPopupErrorMessage = (error: unknown): PopupErrorMessage => { const completeError = decodeOAuthCompleteError(error); if (Option.isSome(completeError)) return { @@ -86,6 +88,11 @@ const toPopupErrorMessage = (error: unknown): PopupErrorMessage => { details: `State: ${sessionNotFound.value.state}`, }; + // A refusal the user can act on (connecting needs a workspace admin, or the + // host has paused changes to the workspace) says why. + if (isUserActionableError(error)) + return { short: "Could not complete authentication", details: error.userMessage }; + return { short: "Authentication failed" }; }; diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 749839be3e..29f64199a3 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -118,6 +118,14 @@ export interface HostConfigShape { * attempted as it was before the gate existed. */ readonly enterpriseManagedRollout?: ExecutorConfig["enterpriseManagedRollout"]; + /** + * Forwarded to `ExecutorConfig.workspaceWriteGate`: the host's veto over + * every user-intent write and OAuth token refresh in a workspace (cloud + * freezes an organization while it moves). Declared here — not per-request — + * because the gate itself reads live per-workspace state on every call, so + * one function serves both request planes. Omitted -> always writable. + */ + readonly workspaceWriteGate?: ExecutorConfig["workspaceWriteGate"]; /** * Forwarded verbatim to `ExecutorConfig.toolsSyncTtlMs`: how long a * connection's persisted remote tool catalog stays fresh. Omit to take the @@ -341,6 +349,9 @@ export const makeScopedExecutor = < oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, enterpriseManagedRollout: config.enterpriseManagedRollout, + ...(config.workspaceWriteGate === undefined + ? {} + : { workspaceWriteGate: config.workspaceWriteGate }), coreTools: { webBaseUrl, orgSlug, diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index d0f16e7d12..aa6334df9c 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -154,11 +154,18 @@ export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass()( "OrgWriteDeniedError", - {}, + { + /** The host's message when it refused writes for the whole workspace, + * shown instead of the admin-role message. */ + reason: Schema.optional(Schema.String), + }, { httpApiStatus: 403 }, ) implements UserActionableError @@ -167,7 +174,9 @@ export class OrgWriteDeniedError readonly code = "org_write_denied"; override get message(): string { - return "Adding connections or changing workspace settings requires a workspace admin."; + return ( + this.reason ?? "Adding connections or changing workspace settings requires a workspace admin." + ); } get userMessage(): string { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2a6b823a5f..ccdd37face 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,5 +1,6 @@ import { Cause, + Context, Deferred, Duration, Effect, @@ -22,6 +23,7 @@ import type { AnyColumn } from "@executor-js/fumadb/schema"; import { StorageError, CredentialWriteIncompleteError, + activeFumaDbRef, afterCommit, afterCommitRequired, isStorageFailure, @@ -272,6 +274,36 @@ const refreshGateFor = (rootDb: object): RefreshGate => { return created; }; +// --------------------------------------------------------------------------- +// The host's workspace write gate (`ExecutorConfig.workspaceWriteGate`) inside +// a storage transaction. +// +// The gate is the host's I/O (cloud reads the workspace's move state), so it is +// never run while a transaction is open: the transaction holds the request's +// connection, and on a single-session database every other connection waits +// for it to commit — a gate waiting on one would wait forever. A user-intent +// write transaction therefore consults the gate just before it opens and binds +// the verdict here for its duration; the guards inside read it. +// --------------------------------------------------------------------------- + +interface WorkspaceWriteVerdict { + readonly tenant: Tenant; + readonly exit: Exit.Exit; +} + +const WorkspaceWriteVerdictRef = Context.Reference( + "executor/WorkspaceWriteVerdict", + { defaultValue: () => null }, +); + +/** The refusal of a guarded write that ran inside a storage transaction which + * did not ask the host's write gate before it opened (one opened with + * `transaction` rather than `writeTransaction`). The gate cannot be asked + * from there, and the write is not let through unchecked: it fails closed, + * and the transaction's opener is the bug to fix. */ +const WRITE_GATE_NOT_CONSULTED_REASON = + "Executor could not check whether this workspace accepts changes."; + // --------------------------------------------------------------------------- // Elicitation handler — resolved once at `createExecutor({ onElicitation })` // and overridable per `execute`. A tool that requests user input mid-execution @@ -854,6 +886,36 @@ export interface ExecutorConfig always writable. + * + * Never called inside a storage transaction: a guarded write that runs in + * one (every user-intent write transaction, the plugin-facing + * `ctx.transaction` included) consults the gate once, just before the + * transaction opens, and its guards read that verdict. A host gate may + * therefore read its state over its own database connection: on a + * single-session database (PGlite, libSQL) a second connection waiting on + * an open transaction would never be served. + */ + readonly workspaceWriteGate?: (tenant: Tenant) => Effect.Effect; } /** Default freshness window for remote-catalog connections (see @@ -1938,8 +2000,33 @@ export const createExecutor = = + hostWriteGate === undefined + ? Effect.void + : Effect.suspend(() => hostWriteGate(config.tenant)); + const workspaceWriteGate: Effect.Effect = + hostWriteGate === undefined + ? Effect.void + : Effect.gen(function* () { + const verdict = yield* WorkspaceWriteVerdictRef; + if (verdict !== null && verdict.tenant === config.tenant) return yield* verdict.exit; + if ((yield* activeFumaDbRef) !== null) { + return yield* new OrgWriteDeniedError({ reason: WRITE_GATE_NOT_CONSULTED_REASON }); + } + return yield* askHostWriteGate; + }); const guardOrgWrite = (owner?: Owner): Effect.Effect => Effect.gen(function* () { + yield* workspaceWriteGate; const access = config.orgWrites === "request" ? yield* currentOrgWriteAccess : config.orgWrites; if (access === "denied" && (owner === undefined || owner === "org")) { @@ -2024,6 +2111,27 @@ export const createExecutor = (effect: Effect.Effect) => fuma.transaction(effect); + // A user-intent write transaction: every transaction whose body may reach + // a workspace write guard (`guardOrgWrite`, a token refresh). When it is + // the outermost one, the host's write gate is asked BEFORE it opens and + // the verdict rides into it, so no guard inside does host I/O while the + // transaction holds the connection. Nested in another transaction it + // passes through like `transaction` and inherits the outer verdict. + // Operational transactions (catalog persistence, compensation, storage + // bookkeeping) stay on `transaction` and never ask the gate. + const writeTransaction = ( + effect: Effect.Effect, + ): Effect.Effect => + hostWriteGate === undefined + ? transaction(effect) + : Effect.gen(function* () { + if ((yield* activeFumaDbRef) !== null) return yield* transaction(effect); + const exit = yield* Effect.exit(askHostWriteGate); + return yield* transaction(effect).pipe( + Effect.provideService(WorkspaceWriteVerdictRef, { tenant: config.tenant, exit }), + ); + }); + // Runtime-observed output shapes ("muscle memory"): learned on the // execute success path, served by tools.schema when a tool declares no // output schema. Backed by plugin_storage under a reserved system id. @@ -2935,7 +3043,7 @@ export const createExecutor = => + workspaceWriteGate.pipe( + Effect.catchTag("OrgWriteDeniedError", (refused) => + Effect.fail( + new CredentialResolutionError({ + owner: row.owner as Owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message: refused.message, + }), + ), + ), + Effect.andThen(runSharedRefresh(row, provider, trigger)), + ); + // Resolve every named input of a connection (`variable → value`). A // single-secret connection yields `{ token: }`; an apiKey method with // two distinct inputs yields one entry per variable. OAuth connections refresh @@ -3265,7 +3397,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { const now = new Date(); const existing = yield* findIntegrationRow(input.slug); @@ -3328,7 +3460,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(); const now = new Date(); @@ -3366,7 +3498,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(); const existing = yield* findIntegrationRow(slug); @@ -3469,7 +3601,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(); const row = yield* findIntegrationRow(slug); @@ -4787,7 +4919,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); @@ -4818,7 +4950,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(ref.owner); const row = yield* findConnectionRow(ref); @@ -6061,7 +6193,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(input.owner); if (!isValidPattern(input.pattern)) { @@ -6111,7 +6243,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(input.owner); if (input.pattern !== undefined && !isValidPattern(input.pattern)) { @@ -6147,7 +6279,7 @@ export const createExecutor = => - transaction( + writeTransaction( Effect.gen(function* () { yield* guardOrgWrite(input.owner); const where = (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)); @@ -6821,6 +6953,7 @@ export const createExecutor = ownedKeys(owner), guardOrgWrite: (owner: Owner) => guardOrgWrite(owner), + workspaceWriteGate, defaultWritableProvider, mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), integrationExists: (slug) => findIntegrationRow(slug).pipe(Effect.map((row) => row !== null)), @@ -6983,7 +7116,10 @@ export const createExecutor = execute(address, args, options), - transaction: (effect: Effect.Effect) => transaction(effect), + // Every plugin transaction is a user-intent write unit (integration + // registration, spec update/removal, auth configuration), so the + // host's write gate is asked before it opens; see `writeTransaction`. + transaction: (effect: Effect.Effect) => writeTransaction(effect), afterCommit: (effect: Effect.Effect) => afterCommit(effect), }; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 770437d908..f056211040 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -236,6 +236,9 @@ export interface OAuthServiceDeps { * (`ExecutorConfig.orgWrites`): refuses `owner: "org"` targets on the * user-intent client/connect surfaces. */ readonly guardOrgWrite: (owner: Owner) => Effect.Effect; + /** The host's workspace-wide write gate (`ExecutorConfig.workspaceWriteGate`), + * checked where a flow would spend something before `guardOrgWrite` runs. */ + readonly workspaceWriteGate?: Effect.Effect; readonly defaultWritableProvider: () => CredentialProvider | null; /** Write the connection row with OAuth lifecycle fields + produce its tools. */ readonly mintOAuthConnection: ( @@ -2161,6 +2164,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { OAuthCompleteError | OAuthSessionNotFoundError | OrgWriteDeniedError | StorageFailure > => Effect.gen(function* () { + // A frozen workspace refuses before the authorization code is exchanged: + // `mintFromToken` checks too, but only after the code is spent. + if (deps.workspaceWriteGate) yield* deps.workspaceWriteGate; const sessionRow = yield* deps.fuma.use("oauth_session.findFirst", (db) => looseDb(db).findFirst("oauth_session", { where: (b: any) => b("state", "=", String(input.state)), From 8156ec95b9ae4eb52ed2307e739e47bfc54e216b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:07:24 -0700 Subject: [PATCH 2/2] Grant the import writer USAGE on the public schema --- apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql b/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql index 0e172ce659..6f7d999111 100644 --- a/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql +++ b/apps/cloud/drizzle/0022_org_moved_to_v2_slug.sql @@ -12,16 +12,20 @@ ALTER TABLE "org_moved_to_v2" ADD CONSTRAINT "org_moved_to_v2_state_check" CHECK -- 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. +-- 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 two statements once, after +-- 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;