diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 87521544e..9d9334bf9 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -65,6 +65,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ slug, createdAt, }), + deleteOrganizationCascade: async () => {}, }), ), }); diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index 9b317a5b8..b8d81b334 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -83,6 +83,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ slug, createdAt, }), + deleteOrganizationCascade: async () => {}, }), ), }); @@ -143,7 +144,10 @@ describe("protected JWT (device-login) auth", () => { const error = yield* Effect.flip(run(request(token), config)); - expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" }); + expect(error).toMatchObject({ + _tag: "Unauthorized", + code: "invalid_access_token", + }); }), ); @@ -154,7 +158,10 @@ describe("protected JWT (device-login) auth", () => { const error = yield* Effect.flip(run(request(token), config)); - expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" }); + expect(error).toMatchObject({ + _tag: "NoOrganization", + code: "no_organization", + }); }), ); @@ -166,7 +173,10 @@ describe("protected JWT (device-login) auth", () => { const error = yield* Effect.flip(run(request(token), config)); - expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" }); + expect(error).toMatchObject({ + _tag: "NoOrganization", + code: "no_organization", + }); }), ); }); diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 6c73838b7..9d14c8d64 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -44,6 +44,16 @@ const CreateOrganizationResponse = Schema.Struct({ slug: Schema.String, }); +// Deleting an org requires re-typing its name (the label the UI shows) as a +// deliberate, non-automatable confirmation. Verified server-side too. +const DeleteOrganizationBody = Schema.Struct({ + confirmName: Schema.String, +}); + +const DeleteOrganizationResponse = Schema.Struct({ + success: Schema.Boolean, +}); + // CLI device-login discovery (`executor login`). Tells the CLI where to run // the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client // to use. The CLI hits these provider endpoints directly, gets a WorkOS access @@ -146,6 +156,15 @@ export class McpSessionForbiddenError extends Schema.TaggedErrorClass()( + "OrganizationDeletionForbidden", + {}, + { httpApiStatus: 403 }, +) {} + export const AUTH_PATHS = { login: "/api/auth/login", logout: "/api/auth/logout", @@ -168,7 +187,11 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") error: AuthErrors, }), ) - .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {} + .add( + HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { + success: CliLoginResponse, + }), + ) {} /** Session auth endpoints — require a logged-in user, may not have an org */ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") @@ -192,6 +215,13 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") error: AuthErrors, }), ) + .add( + HttpApiEndpoint.post("deleteOrganization", "/auth/delete-organization", { + payload: DeleteOrganizationBody, + success: DeleteOrganizationResponse, + error: [...AuthErrors, NoOrganization, OrganizationDeletionForbidden], + }), + ) .add( HttpApiEndpoint.get("pendingInvitations", "/auth/pending-invitations", { success: PendingInvitationsResponse, diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 108f96bba..db249f3e5 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -9,6 +9,7 @@ import { CloudAuthPublicApi, McpExecutionNotFoundError, McpSessionForbiddenError, + OrganizationDeletionForbidden, } from "./api"; import { NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. @@ -242,7 +243,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( } if (!sealedSession) { - return HttpServerResponse.text("Failed to create session", { status: 500 }); + return HttpServerResponse.text("Failed to create session", { + status: 500, + }); } return deleteResponseCookie( @@ -323,7 +326,11 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const organizations = yield* Effect.all( memberships.data.map((m) => resolveOrganization(m.organizationId).pipe( - Effect.map((org) => ({ id: org.id, name: org.name, slug: org.slug })), + Effect.map((org) => ({ + id: org.id, + name: org.name, + slug: org.slug, + })), Effect.orElseSucceed(() => null), ), ), @@ -354,7 +361,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( activeMemberships.map((membership) => autumn .use((client) => - client.customers.getOrCreate({ customerId: membership.organizationId }), + client.customers.getOrCreate({ + customerId: membership.organizationId, + }), ) .pipe( Effect.map((customer) => @@ -410,6 +419,78 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( return { id: org.id, name: org.name, slug: mirrored.slug }; }), ) + .handle("deleteOrganization", ({ payload }) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + const users = yield* UserStoreService; + const autumn = yield* AutumnService; + + // Target the caller's currently-selected org (honors the org-selector + // header, same as the other org-scoped auth handlers). NoOrganization + // when the session has no org to act on. + const session = yield* requireSelectedOrganization; + const organizationId = session.organizationId; + + // Admin-only. Live WorkOS check so a member removed/demoted moments + // ago can't delete the workspace. A pending admin invite is not an + // active admin, so require active status too. + const membership = yield* workos.getUserOrgMembership(organizationId, session.accountId); + if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") { + return yield* new OrganizationDeletionForbidden(); + } + + // The typed confirmation must match the org's current name — the same + // label the settings page shows. Trimmed on both sides. + const org = yield* users.use((s) => s.getOrganization(organizationId)); + if (!org || payload.confirmName.trim() !== org.name.trim()) { + return yield* new OrganizationDeletionForbidden(); + } + + // WorkOS FIRST. Once the org is gone there, membership authorization + // fails for every member, so the workspace is truly deleted even if a + // later local step lags (leftover local rows become unreachable, not + // user-visible). The reverse order risks the org resurrecting as an + // empty workspace when a later request re-mirrors it with a new slug. + yield* workos.deleteOrganization(organizationId); + + // Purge all local tenant data, secrets, and the identity mirror + // (cascades local memberships) in one transaction. If this fails + // after the WorkOS delete already succeeded, the org is gone for + // everyone (unreachable) but its secrets/tenant rows linger orphaned — + // alert loudly so that window gets swept, then surface the failure. + yield* users + .use((s) => s.deleteOrganizationCascade(organizationId)) + .pipe( + Effect.tapError((error) => + Effect.logError( + "deleteOrganization: WorkOS org deleted but local purge failed, tenant data and secrets orphaned", + { organizationId, error }, + ), + ), + ); + + // Cancel billing. Best-effort: the org is already deleted, so a + // lingering Autumn customer is a billing loose end (log loudly) rather + // than a correctness failure that should 500 the caller. + yield* autumn + .use((client) => client.customers.delete({ customerId: organizationId })) + .pipe( + Effect.catchTag("AutumnError", (error) => + Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { + organizationId, + error, + }), + ), + ); + + // The caller's session is pinned to the now-deleted org — clear it so + // the browser bounces to login and rehydrates to another membership + // (or the create-org screen when they have none left). + (yield* SessionCookies).set("wos-session", "", DELETE_COOKIE_OPTIONS); + + return { success: true }; + }), + ) .handle("pendingInvitations", () => Effect.gen(function* () { const workos = yield* WorkOSClient; diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/apps/cloud/src/auth/org-selector-auth.node.test.ts index 8199bd63b..c469780a7 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -35,7 +35,11 @@ const stubWorkOS = Layer.succeed( get: (_t, prop) => { if (prop === "authenticateRequest") { return () => - Effect.succeed({ userId: MEMBER, email: "u@e2e.test", organizationId: SESSION_ORG }); + Effect.succeed({ + userId: MEMBER, + email: "u@e2e.test", + organizationId: SESSION_ORG, + }); } if (prop === "listUserMemberships") { return (userId: string) => @@ -66,7 +70,12 @@ const stubUsers = Layer.succeed(UserStoreService)({ slug: org.id, createdAt, }), - getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + slug: id, + createdAt, + }), // The URL slug maps to URL_ORG (the member's other org); any other slug // maps to an org the caller is NOT a member of, so membership rejects it. getOrganizationBySlug: async (slug: string) => ({ @@ -75,6 +84,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ slug, createdAt, }), + deleteOrganizationCascade: async () => {}, }), ), }); @@ -117,7 +127,10 @@ describe("resolveSessionPrincipal · URL org selector", () => { // The slug resolves to a real org id, but membership is re-checked — a // slug is a selector, not a trust boundary, so a non-member is rejected. const error = yield* Effect.flip( - run({ cookie: "wos-session=x", "x-executor-organization": "outsider-slug" }), + run({ + cookie: "wos-session=x", + "x-executor-organization": "outsider-slug", + }), ); expect(error).toMatchObject({ _tag: "NoOrganization" }); }), diff --git a/apps/cloud/src/auth/user-store.ts b/apps/cloud/src/auth/user-store.ts index 76929bc06..997d6ebc8 100644 --- a/apps/cloud/src/auth/user-store.ts +++ b/apps/cloud/src/auth/user-store.ts @@ -13,6 +13,7 @@ import { generateOrgSlug } from "@executor-js/api"; import { accounts, organizations } from "../db/schema"; import type { DrizzleDb } from "../db/db"; +import { purgeOrganizationData } from "../db/org-deletion"; export type Account = typeof accounts.$inferSelect; export type Organization = typeof organizations.$inferSelect; @@ -96,5 +97,10 @@ export const makeUserStore = (db: DrizzleDb) => { const rows = await db.select().from(organizations).where(eq(organizations.slug, slug)); return rows[0] ?? null; }, + + // Permanently delete an org and everything it owns (tenant data, secrets, + // identity mirror + cascaded memberships) in a single transaction. Callers + // sequence the external WorkOS/Autumn deletions around this. + deleteOrganizationCascade: (id: string) => purgeOrganizationData(db, id), }; }; diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index b29bad2bd..2e1655c69 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -145,7 +145,11 @@ const make = Effect.gen(function* () { }); } - const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) }); + const workos = new WorkOS({ + apiKey, + clientId, + ...workosApiUrlOptions(env.WORKOS_API_URL), + }); const use = (fn: (wos: WorkOS) => Promise) => withServiceLogging( @@ -389,7 +393,11 @@ const make = Effect.gen(function* () { /** Update a membership's role. */ updateOrgMembershipRole: (membershipId: string, roleSlug: string) => - use((wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug })), + use((wos) => + wos.userManagement.updateOrganizationMembership(membershipId, { + roleSlug, + }), + ), /** List available roles for an organization. */ listOrgRoles: (organizationId: string) => @@ -401,7 +409,19 @@ const make = Effect.gen(function* () { /** Update an organization. */ updateOrganization: (organizationId: string, name: string) => - use((wos) => wos.organizations.updateOrganization({ organization: organizationId, name })), + use((wos) => + wos.organizations.updateOrganization({ + organization: organizationId, + name, + }), + ), + + /** + * Delete an organization. Cascades in WorkOS: the org's memberships, + * invitations, and domains go with it, so every member loses access. + */ + deleteOrganization: (organizationId: string) => + use((wos) => wos.organizations.deleteOrganization(organizationId)), /** Generate an Admin Portal link for domain verification. */ generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts new file mode 100644 index 000000000..f05f5234e --- /dev/null +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -0,0 +1,251 @@ +// --------------------------------------------------------------------------- +// purgeOrganizationData — org deletion cascade +// --------------------------------------------------------------------------- +// +// Runs inside the Cloudflare Workers runtime against a real PGlite Postgres +// (scripts/test-globalsetup.ts), the same path api.ts uses per request. Seeds +// two orgs across every tenant table + blob namespace, purges one, and asserts: +// - every executor tenant table row for the target org is gone +// - org- and user-scoped secret blobs for the target org are gone +// - the identity row is gone and its memberships cascade with it +// - a second org's data is completely untouched +// - the blob prefix match escapes LIKE wildcards (a `_` in the org id must +// not widen the match to a look-alike namespace) + +import { describe, it, expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { eq, inArray } from "drizzle-orm"; + +import { DbService } from "./db"; +import type { DrizzleDb } from "./db"; +import { makeUserStore } from "../auth/user-store"; +import { memberships, accounts } from "./schema"; +import { + blob, + connection, + definition, + integration, + oauth_client, + oauth_session, + plugin_storage, + tool, + tool_policy, +} from "./executor-schema"; + +const program = (body: Effect.Effect) => + Effect.runPromise( + body.pipe(Effect.provide(DbService.Live), Effect.scoped) as Effect.Effect, + ); + +// Insert one row into every tenant table, plus an org- and a user-scoped blob, +// all owned by `tenant`. `tag` keeps unique indexes from colliding across orgs. +const seedTenant = async (db: DrizzleDb, tenant: string, tag: string) => { + const now = new Date(); + await db.insert(integration).values({ + slug: `int-${tag}`, + plugin_id: "p", + created_at: now, + updated_at: now, + tenant, + }); + await db.insert(connection).values({ + integration: "int", + name: `conn-${tag}`, + template: "t", + provider: "pr", + item_ids: [], + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(oauth_client).values({ + slug: `oc-${tag}`, + authorization_url: "u", + token_url: "u", + grant: "g", + client_id: "c", + created_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(oauth_session).values({ + state: `state-${tag}`, + client_slug: "cs", + integration: "int", + name: "n", + template: "t", + redirect_url: "r", + payload: {}, + expires_at: 0n, + created_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(tool).values({ + integration: "int", + connection: "conn", + plugin_id: "p", + name: `tool-${tag}`, + description: "d", + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(definition).values({ + integration: "int", + connection: "conn", + plugin_id: "p", + name: `def-${tag}`, + schema: {}, + created_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(tool_policy).values({ + id: `tp-${tag}`, + pattern: "*", + action: "allow", + position: "1", + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + await db.insert(plugin_storage).values({ + plugin_id: "p", + collection: "col", + key: `k-${tag}`, + data: {}, + created_at: now, + updated_at: now, + tenant, + owner: "o", + subject: "s", + }); + + const orgNs = `o:${tenant}/plugin`; + const userNs = `u:${tenant}:subject/plugin`; + await db.insert(blob).values({ + namespace: orgNs, + key: "k", + value: "v", + id: JSON.stringify([orgNs, "k"]), + }); + await db.insert(blob).values({ + namespace: userNs, + key: "k", + value: "v", + id: JSON.stringify([userNs, "k"]), + }); +}; + +const TENANT_TABLES = [ + integration, + connection, + oauth_client, + oauth_session, + tool, + definition, + tool_policy, + plugin_storage, +] as const; + +const countTenantRows = async (db: DrizzleDb, tenant: string): Promise => { + let total = 0; + for (const table of TENANT_TABLES) { + const rows = await db.select().from(table).where(eq(table.tenant, tenant)); + total += rows.length; + } + // Count by the exact namespaces `seedTenant` inserts. Matching on a `%tenant%` + // LIKE here would reintroduce the very wildcard hazard under test. + const blobs = await db + .select() + .from(blob) + .where(inArray(blob.namespace, [`o:${tenant}/plugin`, `u:${tenant}:subject/plugin`])); + return total + blobs.length; +}; + +describe("purgeOrganizationData", () => { + it("removes all of one org's data and leaves other orgs untouched", async () => { + // Underscore in the id is deliberate: an unescaped `_` in a LIKE pattern is + // a single-char wildcard, so the escaping has to hold for this to be safe. + const orgA = `org_del_${crypto.randomUUID().slice(0, 8)}`; + const orgB = `org_keep_${crypto.randomUUID().slice(0, 8)}`; + const accountId = `user_${crypto.randomUUID().slice(0, 8)}`; + + // A look-alike blob that only an UNescaped `_` wildcard would match: + // `o:/…` with the underscore replaced by another char. + const trapNs = `o:${orgA.replace("_", "X")}/plugin`; + + await program( + Effect.gen(function* () { + const { db } = yield* DbService; + yield* Effect.promise(async () => { + const store = makeUserStore(db); + await store.upsertOrganization({ id: orgA, name: "Delete Me" }); + await store.upsertOrganization({ id: orgB, name: "Keep Me" }); + await store.ensureAccount(accountId); + await db.insert(memberships).values({ accountId, organizationId: orgA }); + await db.insert(memberships).values({ accountId, organizationId: orgB }); + await seedTenant(db, orgA, "a"); + await seedTenant(db, orgB, "b"); + await db.insert(blob).values({ + namespace: trapNs, + key: "k", + value: "v", + id: JSON.stringify([trapNs, "k"]), + }); + }); + }), + ); + + await program( + Effect.gen(function* () { + const { db } = yield* DbService; + yield* Effect.promise(() => makeUserStore(db).deleteOrganizationCascade(orgA)); + }), + ); + + await program( + Effect.gen(function* () { + const { db } = yield* DbService; + yield* Effect.promise(async () => { + const store = makeUserStore(db); + + // Target org: every tenant row + blob gone, identity gone, membership + // cascaded, but the shared account survives (it may join other orgs). + expect(await countTenantRows(db, orgA)).toBe(0); + expect(await store.getOrganization(orgA)).toBeNull(); + const orgAMemberships = await db + .select() + .from(memberships) + .where(eq(memberships.organizationId, orgA)); + expect(orgAMemberships).toHaveLength(0); + const account = await db.select().from(accounts).where(eq(accounts.id, accountId)); + expect(account, "the account outlives the org").toHaveLength(1); + + // The look-alike blob must survive: escaping keeps `_` literal. + const trap = await db.select().from(blob).where(eq(blob.namespace, trapNs)); + expect(trap, "escaped LIKE must not match a look-alike namespace").toHaveLength(1); + + // Second org: fully intact. + expect(await countTenantRows(db, orgB)).toBeGreaterThan(0); + expect(await store.getOrganization(orgB)).not.toBeNull(); + const orgBMemberships = await db + .select() + .from(memberships) + .where(eq(memberships.organizationId, orgB)); + expect(orgBMemberships).toHaveLength(1); + }); + }), + ); + }, 20_000); +}); diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts new file mode 100644 index 000000000..c86afd63e --- /dev/null +++ b/apps/cloud/src/db/org-deletion.ts @@ -0,0 +1,68 @@ +// --------------------------------------------------------------------------- +// Organization data purge — removes every local trace of an org in one txn +// --------------------------------------------------------------------------- +// +// Identity (accounts/organizations/memberships) and executor tenant data +// (integrations, connections, tools, secrets, ...) share ONE Postgres database +// (`combinedSchema` in `db.ts`), so deleting an org is a single transaction +// here rather than a fan-out across stores. Tenant rows carry the org id in a +// `tenant` column; secrets/tokens live in `blob`, namespaced by owner. +// +// External side effects (the WorkOS org, the Autumn customer) are NOT touched +// here — the caller (auth handler) sequences those around this purge. + +import { eq, or, sql } from "drizzle-orm"; + +import type { DrizzleDb } from "./db"; +import { organizations } from "./schema"; +import { + blob, + connection, + definition, + integration, + oauth_client, + oauth_session, + plugin_storage, + tool, + tool_policy, +} from "./executor-schema"; + +// Escape LIKE wildcards (`\`, `%`, `_`) in the org id before using it as a +// prefix. WorkOS org ids contain underscores, and a bare `_` in a LIKE pattern +// matches any character, which would widen the match to unrelated tenants. +const escapeLike = (value: string): string => value.replace(/[\\%_]/g, "\\$&"); + +/** + * Delete all rows owned by `organizationId`: every executor tenant table, the + * org's secret blobs (org- and user-scoped), and the identity mirror row (which + * cascades to local `memberships`). Idempotent — a second run deletes nothing. + */ +export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Promise => + db.transaction(async (tx) => { + // Executor tenant tables — every row is scoped by `tenant = organizationId`. + await tx.delete(tool).where(eq(tool.tenant, organizationId)); + await tx.delete(definition).where(eq(definition.tenant, organizationId)); + await tx.delete(connection).where(eq(connection.tenant, organizationId)); + await tx.delete(integration).where(eq(integration.tenant, organizationId)); + await tx.delete(oauth_client).where(eq(oauth_client.tenant, organizationId)); + await tx.delete(oauth_session).where(eq(oauth_session.tenant, organizationId)); + await tx.delete(tool_policy).where(eq(tool_policy.tenant, organizationId)); + await tx.delete(plugin_storage).where(eq(plugin_storage.tenant, organizationId)); + + // Secrets, OAuth tokens, and cached specs live in `blob`, namespaced by + // owner: `o:/` (org scope) and `u::/` + // (per-user scope). Match both prefixes for this org. + const esc = escapeLike(organizationId); + await tx + .delete(blob) + .where( + or( + sql`${blob.namespace} LIKE ${`o:${esc}/%`} ESCAPE '\\'`, + sql`${blob.namespace} LIKE ${`u:${esc}:%`} ESCAPE '\\'`, + ), + ); + + // Identity mirror — FK `ON DELETE CASCADE` removes local memberships too. + // `accounts` are intentionally left: a user may belong to other orgs. + await tx.delete(organizations).where(eq(organizations.id, organizationId)); + }); diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index d2966e4dc..52e5d7c03 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -44,13 +44,19 @@ const stubUsers = Layer.succeed(UserStoreService)({ slug: org.id, createdAt, }), - getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: id, createdAt }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + slug: id, + createdAt, + }), getOrganizationBySlug: async (slug: string) => ({ id: slug === URL_SLUG ? URL_ORG : "org_outsider", name: `Org ${slug}`, slug, createdAt, }), + deleteOrganizationCascade: async () => {}, }), ), }); diff --git a/apps/cloud/src/routes/app/org.tsx b/apps/cloud/src/routes/app/org.tsx index fa44a316d..a9ded0b7c 100644 --- a/apps/cloud/src/routes/app/org.tsx +++ b/apps/cloud/src/routes/app/org.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { Exit } from "effect"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; @@ -5,18 +6,31 @@ import { trackEvent } from "@executor-js/react/api/analytics"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { useCustomer } from "autumn-js/react"; import { toast } from "sonner"; -import { orgDomainWriteKeys } from "@executor-js/react/api/reactivity-keys"; +import { orgDomainWriteKeys, authWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { Button } from "@executor-js/react/components/button"; import { Badge } from "@executor-js/react/components/badge"; +import { Input } from "@executor-js/react/components/input"; +import { Label } from "@executor-js/react/components/label"; import { CopyButton } from "@executor-js/react/components/copy-button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from "@executor-js/react/components/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@executor-js/react/components/dropdown-menu"; +import { orgMembersAtom } from "@executor-js/react/api/account-atoms"; import { OrgPage as SharedOrgPage } from "@executor-js/react/pages/org"; import { orgDomainsAtom, getDomainVerificationLink, deleteDomain } from "../../web/org-atoms"; +import { deleteOrganization, useAuth } from "../../web/auth"; // --------------------------------------------------------------------------- // Cloud organization page. The members / roles / invite / org-name surface is @@ -52,11 +66,143 @@ function OrgPage() { } + dangerZoneSection={} /> ); } +// Destructive org teardown, admin-only. Hidden entirely for non-admins (the +// backend enforces admin + name-confirmation regardless). Deleting the org +// removes the workspace and all of its data for every member, cancels billing, +// and logs the caller out. +function DangerZoneSection() { + const auth = useAuth(); + const membersResult = useAtomValue(orgMembersAtom); + const doDelete = useAtomSet(deleteOrganization, { mode: "promiseExit" }); + const [open, setOpen] = useState(false); + const [confirmText, setConfirmText] = useState(""); + const [deleting, setDeleting] = useState(false); + + const organizationName = auth.status === "authenticated" ? auth.organization?.name : undefined; + + // Only admins may delete. Derive the caller's role from the members list + // (already loaded for this page); render nothing while it loads or for + // members, so a delete control never flashes for someone who can't use it. + const isAdmin = AsyncResult.match(membersResult, { + onInitial: () => false, + onFailure: () => false, + onSuccess: ({ value }) => + value.members.some((m) => m.isCurrentUser && m.status === "active" && m.role === "admin"), + }); + + if (!isAdmin || !organizationName) return null; + + const confirmed = confirmText.trim() === organizationName.trim(); + + const handleDelete = async () => { + if (!confirmed || deleting) return; + setDeleting(true); + const exit = await doDelete({ + payload: { confirmName: confirmText.trim() }, + reactivityKeys: authWriteKeys, + }); + trackEvent("org_deleted", { success: Exit.isSuccess(exit) }); + if (Exit.isSuccess(exit)) { + // The org (and the caller's session) are gone. A full navigation resets + // all app state and lets the auth gate rehydrate to another membership or + // the create-org screen. + toast.success(`Deleted ${organizationName}`); + window.location.href = "/"; + return; + } + setDeleting(false); + toast.error("Failed to delete organization"); + }; + + return ( +
+
+
+

Delete organization

+

+ Permanently delete this organization and all of its data for every member. This cannot + be undone. +

+
+ +
+ + { + if (deleting) return; + if (!v) setConfirmText(""); + setOpen(v); + }} + > + + + Delete organization + + This permanently deletes{" "} + {organizationName}, including + every integration, connection, credential, and policy, for all members. Billing is + canceled and everyone loses access immediately. This cannot be undone. + + + +
+ + setConfirmText((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleDelete(); + }} + className="h-9 text-sm" + /> +
+ + + + + + + +
+
+
+ ); +} + function DomainsSection() { const domainsResult = useAtomValue(orgDomainsAtom); const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" }); diff --git a/apps/cloud/src/web/auth.tsx b/apps/cloud/src/web/auth.tsx index 1652059a5..98512e16b 100644 --- a/apps/cloud/src/web/auth.tsx +++ b/apps/cloud/src/web/auth.tsx @@ -35,6 +35,8 @@ export const organizationsAtom = Atom.refreshOnWindowFocus( export const createOrganization = CloudApiClient.mutation("cloudAuth", "createOrganization"); +export const deleteOrganization = CloudApiClient.mutation("cloudAuth", "deleteOrganization"); + export const pendingInvitationsAtom = CloudApiClient.query("cloudAuth", "pendingInvitations", { timeToLive: "1 minute", reactivityKeys: [ReactivityKey.auth], @@ -57,7 +59,10 @@ export const AuthProvider = ({ (state) => { if (!posthog) return; if (state.status === "authenticated") { - posthog.identify(state.user.id, { email: state.user.email, name: state.user.name }); + posthog.identify(state.user.id, { + email: state.user.email, + name: state.user.name, + }); if (state.organization) { posthog.group("organization", state.organization.id, { name: state.organization.name, diff --git a/bun.lock b/bun.lock index e1f7250b2..bfddcafc8 100644 --- a/bun.lock +++ b/bun.lock @@ -344,7 +344,7 @@ "version": "0.0.25", "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.2", + "@executor-js/emulate": "^0.13.3", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -1215,9 +1215,9 @@ }, }, "patchedDependencies": { - "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", + "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "agents@0.17.3": "patches/agents@0.17.3.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", }, @@ -1746,7 +1746,7 @@ "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], - "@executor-js/emulate": ["@executor-js/emulate@0.13.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-ikmzZgBrRnmJgKKOW9TE2KCCdenKtihx2nWW/mMMmugn3UlVJsxuIxp6jv0BEo4GZcY8Ve2oka6UEKl+gBKRZg=="], + "@executor-js/emulate": ["@executor-js/emulate@0.13.3", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-2d2Ww1FVLY4q66ZHxzanKVVOA9oJSjk+pzUJqrw6AWa/34P+HxODXor1hqWmxEER2WNs3fcrJT2c0JGDyM9kaQ=="], "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts new file mode 100644 index 000000000..f18a6fc8c --- /dev/null +++ b/e2e/cloud/org-delete.test.ts @@ -0,0 +1,78 @@ +// Cloud-specific (browser): an admin permanently deletes their organization. +// A fresh user creates an org through onboarding, opens Organization settings, +// and uses the danger-zone "Delete organization" flow — which requires +// re-typing the org name to confirm. Deleting tears the org down in WorkOS + +// billing + the tenant database and clears the session, so the app drops the +// user out of the (now gone) workspace. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +scenario( + "Organizations · an admin deletes the organization from settings", + {}, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity({ org: false }); + + yield* browser.session(identity, async ({ page, step }) => { + const ORG = "Doomed Org"; + + await step("Fresh user creates an org via onboarding", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByPlaceholder("Northwind Labs").fill(ORG); + await page.getByRole("button", { name: "Create organization" }).click(); + await page.getByText("Connect your MCP client").waitFor(); + await page.getByRole("button", { name: "Continue to app" }).click(); + await page.getByText("Integrations").first().waitFor(); + // The console canonicalizes onto the org's URL slug (/doomed-org). + await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), { + timeout: 30_000, + }); + await page.waitForLoadState("networkidle"); + }); + + const slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0]!; + + await step("Open Organization settings and find the danger zone", async () => { + await page.goto(`/${slug}/org`, { waitUntil: "networkidle" }); + // The admin-only danger zone renders (a member would not see it). + await page.getByText("Permanently delete this organization").waitFor(); + }); + + await step("The confirm button stays disabled until the name matches", async () => { + // Open the confirmation dialog from the danger-zone Delete button. + await page.getByRole("button", { name: "Delete", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await dialog.waitFor(); + const confirm = dialog.getByRole("button", { name: "Delete organization" }); + expect(await confirm.isDisabled(), "cannot delete before typing the name").toBe(true); + + // A wrong value keeps it disabled; the exact org name enables it. + await page.getByLabel(/to confirm/i).fill("not the name"); + expect(await confirm.isDisabled()).toBe(true); + await page.getByLabel(/to confirm/i).fill(ORG); + expect(await confirm.isEnabled(), "the exact org name unlocks deletion").toBe(true); + }); + + await step("Confirming deletes the org and drops the user out of it", async () => { + await page.getByRole("dialog").getByRole("button", { name: "Delete organization" }).click(); + + // Success clears the session and hard-navigates to "/", which the SSR + // auth gate then redirects (the org and session are gone). That nav + // chain aborts any waitForURL, so instead assert the org console itself + // is torn down: its danger zone detaches and never comes back. + await page + .getByText("Permanently delete this organization") + .waitFor({ state: "detached", timeout: 30_000 }); + expect( + new URL(page.url()).pathname.startsWith(`/${slug}/org`), + "left the org console", + ).toBe(false); + }); + }); + }), +); diff --git a/e2e/package.json b/e2e/package.json index 2b39555ff..dd8829e7c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@executor-js/api": "workspace:*", - "@executor-js/emulate": "^0.13.2", + "@executor-js/emulate": "^0.13.3", "@executor-js/mcporter": "^0.11.4", "@executor-js/plugin-google": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index 731f9445b..6addc8482 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -71,8 +71,16 @@ export interface AnalyticsEvents { success: boolean; dcr_fallback?: boolean; }; - connection_reconnected: { integration_slug: string; owner: Owner; success: boolean }; - connection_removed: { integration_slug: string; owner: Owner; success: boolean }; + connection_reconnected: { + integration_slug: string; + owner: Owner; + success: boolean; + }; + connection_removed: { + integration_slug: string; + owner: Owner; + success: boolean; + }; oauth_completed: { success: boolean }; oauth_popup_blocked: {}; oauth_client_registered: { @@ -95,14 +103,22 @@ export interface AnalyticsEvents { is_error?: boolean; }; tool_id_copied: { integration_slug: string; tool_name: string }; - tool_policy_set: { action: string; pattern_kind: "exact" | "group"; owner: Owner }; + tool_policy_set: { + action: string; + pattern_kind: "exact" | "group"; + owner: Owner; + }; tool_policy_cleared: { pattern_kind: "exact" | "group"; owner: Owner }; // ── Policies page ──────────────────────────────────────────────────────── policy_created: { action: string; owner: Owner; success: boolean }; policy_action_changed: { action: string; owner: Owner; success: boolean }; policy_removed: { owner: Owner; success: boolean }; - policy_reordered: { owner: Owner; direction: "up" | "down"; success: boolean }; + policy_reordered: { + owner: Owner; + direction: "up" | "down"; + success: boolean; + }; // ── API keys ───────────────────────────────────────────────────────────── api_key_created: { success: boolean }; @@ -146,6 +162,7 @@ export interface AnalyticsEvents { login_cta_clicked: {}; signed_out: {}; org_created: { success: boolean }; + org_deleted: { success: boolean }; org_switched: { success: boolean }; org_invitation_accepted: { success: boolean }; setup_mcp_completed: {}; diff --git a/packages/react/src/pages/org.tsx b/packages/react/src/pages/org.tsx index ce39e6ce2..8797533af 100644 --- a/packages/react/src/pages/org.tsx +++ b/packages/react/src/pages/org.tsx @@ -140,6 +140,10 @@ export function OrgPage(props: { // an upgrade prompt instead of the invite form. Self-host has no seat limit, // so it never reaches this and can omit it. upgradeAction?: React.ReactNode; + // Cloud injects a destructive "delete organization" section here. It hangs + // off the cloud-only `/auth/delete-organization` endpoint (WorkOS + billing + // teardown), so self-host omits it. Rendered last, below members. + dangerZoneSection?: React.ReactNode; }) { useExecutorDocumentTitle("Organization"); const auth = useAuth(); @@ -191,7 +195,10 @@ export function OrgPage(props: { payload: { roleSlug }, reactivityKeys: orgMemberWriteKeys, }); - trackEvent("org_member_role_changed", { role: roleSlug, success: Exit.isSuccess(exit) }); + trackEvent("org_member_role_changed", { + role: roleSlug, + success: Exit.isSuccess(exit), + }); toast[Exit.isSuccess(exit) ? "success" : "error"]( Exit.isSuccess(exit) ? `Role changed to ${roleName}` : "Failed to change role", ); @@ -417,6 +424,8 @@ export function OrgPage(props: { )} + {props.dangerZoneSection} + You are at your member limit - Your plan includes {props.granted} member{props.granted === 1 ? "" : "s"}. Upgrade your - plan to invite more. + Your plan includes {props.granted} member + {props.granted === 1 ? "" : "s"}. Upgrade your plan to invite more. @@ -477,7 +486,10 @@ function InviteDialog(props: { }, reactivityKeys: orgMemberWriteKeys, }); - trackEvent("org_member_invited", { role: state.roleSlug, success: Exit.isSuccess(exit) }); + trackEvent("org_member_invited", { + role: state.roleSlug, + success: Exit.isSuccess(exit), + }); if (Exit.isSuccess(exit)) { toast.success(`Invitation sent to ${state.email.trim()}`); dispatch({ type: "reset" }); @@ -488,7 +500,10 @@ function InviteDialog(props: { // 403 AccountForbidden carrying a message) so the admin knows WHY and that // retrying will not help. Transient/untyped failures fall back to the // generic retry copy. - dispatch({ type: "error", message: messageFromExit(exit, GENERIC_INVITE_ERROR) }); + dispatch({ + type: "error", + message: messageFromExit(exit, GENERIC_INVITE_ERROR), + }); }; return ( diff --git a/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js b/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js index 75ca1db1e..5891c77b9 100644 --- a/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js +++ b/scripts/oxlint-plugin-executor/rules/no-direct-cloud-executor-schema-import.js @@ -3,7 +3,17 @@ import { getPropertyName, isIdentifier, toRepoRelative, unwrapExpression } from const message = "Do not access cloud executor tables directly outside DB schema wiring. Executor-domain table access must go through the scoped SDK adapter so scope_id filtering cannot be skipped."; -const allowedFiles = new Set(["apps/cloud/src/db/db.ts", "apps/cloud/src/db/db.schema.test.ts"]); +// Sanctioned DB-wiring files that legitimately touch executor tables directly. +// `org-deletion` is a cross-tenant purge (org deletion) that must span every +// tenant table at the DB layer; it scopes every statement by `tenant`, which is +// exactly the isolation this rule protects, so a scoped per-request SDK adapter +// is neither available nor the right seam for it. +const allowedFiles = new Set([ + "apps/cloud/src/db/db.ts", + "apps/cloud/src/db/db.schema.test.ts", + "apps/cloud/src/db/org-deletion.ts", + "apps/cloud/src/db/org-deletion.test.ts", +]); const isCloudSource = (filename) => toRepoRelative(filename).startsWith("apps/cloud/src/");