diff --git a/companion/src/routes.ts b/companion/src/routes.ts index a7fa7cb6a3..385d726b30 100644 --- a/companion/src/routes.ts +++ b/companion/src/routes.ts @@ -168,12 +168,16 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ { method: "POST", path: /^\/api\/routine-runs\/[\w-]+\/(?:cancel|seen)$/ }, // Multi-account Composio management exposes opaque ids and aliases only. - // Revocation stays on the host: the account DELETE route is deliberately - // absent — a paired client can see and add accounts, never remove one. + // Account-level removal is allowed: the handler still proves the account + // belongs to the host's own user before revoking, and a paired client can + // already add accounts — connectable but not disconnectable is the bug + // being fixed here. The whole-service DELETE stays denied: it belongs to + // the host. { method: "GET", path: /^\/api\/connectors\/catalog$/ }, { method: "GET", path: /^\/api\/connectors\/connected$/ }, { method: "GET", path: /^\/api\/connectors$/ }, { method: "POST", path: /^\/api\/connectors\/[\w-]+\/authorize$/ }, + { method: "DELETE", path: /^\/api\/connectors\/[\w-]+\/accounts\/[\w-]+$/ }, // Inline connector cards are scoped by bot, transcript message, and // thread. They expose the same opaque OAuth authorization already allowed // above, then only poll, resume, or dismiss that exact pending card. diff --git a/companion/test/routes.test.ts b/companion/test/routes.test.ts index 5f5208067c..d4923845fd 100644 --- a/companion/test/routes.test.ts +++ b/companion/test/routes.test.ts @@ -99,6 +99,7 @@ describe("what the app may do", () => { ["GET", "/api/connectors/connected"], ["GET", "/api/connectors"], ["POST", "/api/connectors/slack/authorize"], + ["DELETE", "/api/connectors/slack/accounts/ca_123"], ["GET", "/api/bots/bot_123/connector-cards/msg_2/status"], ["POST", "/api/bots/bot_123/connector-cards/msg_2/authorize"], ["POST", "/api/bots/bot_123/connector-cards/msg_2/resume"], @@ -219,9 +220,10 @@ describe("what it may not", () => { expect(allowed("POST", "/api/routine-runs/run_1/retry")).toBe(false); expect(allowed("DELETE", "/api/connectors/slack")).toBe(false); expect(allowed("GET", "/api/connectors/connected/all")).toBe(false); - // revocation is a host-only affordance: a paired client can list and add - // accounts but the account DELETE route is deliberately not allowed - expect(allowed("DELETE", "/api/connectors/slack/accounts/ca_123")).toBe(false); + // per-account removal is allowed (the server proves ownership before + // revoking); removing the whole service binding stays host-only + expect(allowed("DELETE", "/api/connectors/slack/accounts/ca_123")).toBe(true); + expect(allowed("DELETE", "/api/connectors/slack/accounts/../gmail")).toBe(false); expect(allowed("POST", "/api/bots/bot_123/secret-cards/msg_2/provided")).toBe(false); expect(allowed("PATCH", "/api/groups/room-1")).toBe(false); }); diff --git a/src/components/PluginsPanel.test.ts b/src/components/PluginsPanel.test.ts index 2ad546b286..1311982bd6 100644 --- a/src/components/PluginsPanel.test.ts +++ b/src/components/PluginsPanel.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { - connectedAppsMayDisconnect, connectedInventoryCopy, connectorActionLabel, disconnectAccountConfirmation, @@ -13,13 +12,6 @@ import { } from "./PluginsPanel"; import { managedConnectorUnavailableReason } from "../../shared/connector-availability"; -describe("connected-app remote permissions", () => { - it("allows pairing and status remotely but keeps revocation on the host", () => { - expect(connectedAppsMayDisconnect(false)).toBe(true); - expect(connectedAppsMayDisconnect(true)).toBe(false); - }); -}); - describe("connected-app status races", () => { it("offers status recovery for a pending authorization whose URL was lost on remount", () => { for (const hasAccounts of [false, true]) { diff --git a/src/components/PluginsPanel.tsx b/src/components/PluginsPanel.tsx index 1662515ff0..b848df35ad 100644 --- a/src/components/PluginsPanel.tsx +++ b/src/components/PluginsPanel.tsx @@ -87,10 +87,6 @@ export function disconnectAccountConfirmation( return t("connectors.disconnectConfirm", { identity, service }); } -export function connectedAppsMayDisconnect(remoteClient: boolean): boolean { - return !remoteClient; -} - export function requiresAccountAlias(message: string) { return /account alias.*existing connection.*not replaced/i.test(message); } @@ -213,7 +209,6 @@ function ServiceIcon({ card }: { card: ToolkitCard }) { export function PluginsPanel() { const { state, dispatch } = useStore(); const remoteClient = window.ogb?.remoteClient?.active === true; - const mayDisconnect = connectedAppsMayDisconnect(remoteClient); const dialogRef = useRef(null); const surface = state.pluginsSurface; const [cards, setCards] = useState(null); @@ -743,23 +738,21 @@ export function PluginsPanel() { {account.alias ? `${account.id} · ` : ""}{account.status.toLowerCase()} - {mayDisconnect && ( - - )} + ); })}