From 042740323f9194296411158a630cd14efa23318e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:21:53 -0700 Subject: [PATCH 1/5] Add organization deletion to executor cloud Admins can permanently delete their organization from Organization settings. Deletion tears down WorkOS, cancels the Autumn customer, and purges all tenant data and secrets in one transaction, then clears the caller's session. Requires re-typing the org name to confirm. --- .../api/protected-api-key-auth.node.test.ts | 1 + .../src/api/protected-jwt-auth.node.test.ts | 16 +- apps/cloud/src/auth/api.ts | 32 ++- apps/cloud/src/auth/handlers.ts | 75 +++++- .../src/auth/org-selector-auth.node.test.ts | 19 +- apps/cloud/src/auth/user-store.ts | 6 + apps/cloud/src/auth/workos.ts | 26 +- apps/cloud/src/db/org-deletion.test.ts | 251 ++++++++++++++++++ apps/cloud/src/db/org-deletion.ts | 68 +++++ .../src/extensions/billing/route.node.test.ts | 8 +- apps/cloud/src/routes/app/org.tsx | 147 +++++++++- apps/cloud/src/web/auth.tsx | 7 +- e2e/cloud/org-delete.test.ts | 74 ++++++ packages/react/src/api/analytics.tsx | 25 +- packages/react/src/pages/org.tsx | 25 +- .../no-direct-cloud-executor-schema-import.js | 12 +- 16 files changed, 766 insertions(+), 26 deletions(-) create mode 100644 apps/cloud/src/db/org-deletion.test.ts create mode 100644 apps/cloud/src/db/org-deletion.ts create mode 100644 e2e/cloud/org-delete.test.ts 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..083860948 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,66 @@ 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. + yield* users.use((s) => s.deleteOrganizationCascade(organizationId)); + + // 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..26382ae9e 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,142 @@ 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.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/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts new file mode 100644 index 000000000..27233086f --- /dev/null +++ b/e2e/cloud/org-delete.test.ts @@ -0,0 +1,74 @@ +// 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" }); + await expect(confirm, "cannot delete before typing the name").toBeDisabled(); + + // A wrong value keeps it disabled; the exact org name enables it. + await page.getByLabel(/to confirm/i).fill("not the name"); + await expect(confirm).toBeDisabled(); + await page.getByLabel(/to confirm/i).fill(ORG); + await expect(confirm, "the exact org name unlocks deletion").toBeEnabled(); + }); + + await step("Confirming deletes the org and drops the user out of it", async () => { + await page.getByRole("dialog").getByRole("button", { name: "Delete organization" }).click(); + + // Deletion clears the session and navigates to "/". The org console is + // gone: the deleted slug no longer resolves for this browser, so it + // never lands back on the org settings page. + await page.waitForURL((url) => !url.pathname.startsWith(`/${slug}/org`), { + timeout: 30_000, + }); + await expect(page.getByText("Permanently delete this organization")).toHaveCount(0); + }); + }); + }), +); 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/"); From bc9a7dd9e28f324f6da86f87d2f9387490cb6a80 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:36:52 -0700 Subject: [PATCH 2/5] Address review: gate delete UI on active admin, alert on orphaned purge The danger-zone admin check now also requires an active membership (a pending admin invite no longer surfaces the delete control). If the local purge fails after the WorkOS org delete already succeeded, log an error so the orphaned tenant data and secrets get swept. --- apps/cloud/src/auth/handlers.ts | 16 ++++++++++++++-- apps/cloud/src/routes/app/org.tsx | 3 ++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 083860948..db249f3e5 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -454,8 +454,20 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( yield* workos.deleteOrganization(organizationId); // Purge all local tenant data, secrets, and the identity mirror - // (cascades local memberships) in one transaction. - yield* users.use((s) => s.deleteOrganizationCascade(organizationId)); + // (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 diff --git a/apps/cloud/src/routes/app/org.tsx b/apps/cloud/src/routes/app/org.tsx index 26382ae9e..a9ded0b7c 100644 --- a/apps/cloud/src/routes/app/org.tsx +++ b/apps/cloud/src/routes/app/org.tsx @@ -92,7 +92,8 @@ function DangerZoneSection() { const isAdmin = AsyncResult.match(membersResult, { onInitial: () => false, onFailure: () => false, - onSuccess: ({ value }) => value.members.some((m) => m.isCurrentUser && m.role === "admin"), + onSuccess: ({ value }) => + value.members.some((m) => m.isCurrentUser && m.status === "active" && m.role === "admin"), }); if (!isAdmin || !organizationName) return null; From 66d38fd9562d1c7ceb5d20332c414cd9563f2fe5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:39:24 -0700 Subject: [PATCH 3/5] Use isDisabled/isEnabled locator checks in delete-org e2e @effect/vitest's expect has no Playwright toBeDisabled/toBeEnabled matchers. --- e2e/cloud/org-delete.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index 27233086f..2e373da42 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -49,13 +49,13 @@ scenario( const dialog = page.getByRole("dialog"); await dialog.waitFor(); const confirm = dialog.getByRole("button", { name: "Delete organization" }); - await expect(confirm, "cannot delete before typing the name").toBeDisabled(); + 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"); - await expect(confirm).toBeDisabled(); + expect(await confirm.isDisabled()).toBe(true); await page.getByLabel(/to confirm/i).fill(ORG); - await expect(confirm, "the exact org name unlocks deletion").toBeEnabled(); + 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 () => { @@ -67,7 +67,7 @@ scenario( await page.waitForURL((url) => !url.pathname.startsWith(`/${slug}/org`), { timeout: 30_000, }); - await expect(page.getByText("Permanently delete this organization")).toHaveCount(0); + expect(await page.getByText("Permanently delete this organization").count()).toBe(0); }); }); }), From aa49065304a93e9946142c663b8b4e5c003b7fbb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:03:14 -0700 Subject: [PATCH 4/5] Assert org console teardown in delete-org e2e The success path hard-navigates and the SSR gate redirects, which aborts waitForURL; wait for the danger zone to detach instead. --- e2e/cloud/org-delete.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/e2e/cloud/org-delete.test.ts b/e2e/cloud/org-delete.test.ts index 2e373da42..f18a6fc8c 100644 --- a/e2e/cloud/org-delete.test.ts +++ b/e2e/cloud/org-delete.test.ts @@ -61,13 +61,17 @@ scenario( await step("Confirming deletes the org and drops the user out of it", async () => { await page.getByRole("dialog").getByRole("button", { name: "Delete organization" }).click(); - // Deletion clears the session and navigates to "/". The org console is - // gone: the deleted slug no longer resolves for this browser, so it - // never lands back on the org settings page. - await page.waitForURL((url) => !url.pathname.startsWith(`/${slug}/org`), { - timeout: 30_000, - }); - expect(await page.getByText("Permanently delete this organization").count()).toBe(0); + // 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); }); }); }), From d6e890dd7785ce8da6a2a7d67923f33528d555ed Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:09:53 -0700 Subject: [PATCH 5/5] Require @executor-js/emulate 0.13.3 for the org-delete e2e The WorkOS org delete route lands in emulate 0.13.3. --- bun.lock | 6 +++--- e2e/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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:*",