History

Merge remote-tracking branch 'origin/main' into fix/database-connection-safeguards

Rhys Sullivan committed 1 month ago

Commit 66a82a12 parents 4fb6ddd 2e5aa1618 files · +551 −68Browse files

Unified · Split

18 files
  1. .changeset/fair-admin-integrations.md +5 −0
  2. .changeset/oauth-discovered-scope-budget.md +5 −0
  3. e2e/cloud/integration-creation-permissions.test.ts +17 −0
  4. e2e/selfhost/integration-creation-permissions.test.ts +21 −0
  5. e2e/src/integration-creation-permissions.ts +164 −0
  6. packages/core/sdk/src/executor.ts +25 −2
  7. packages/core/sdk/src/health-check.ts +1 −0
  8. packages/core/sdk/src/oauth-flow.test.ts +67 −0
  9. packages/core/sdk/src/oauth-scope-union.test.ts +38 −7
  10. packages/core/sdk/src/oauth-service.ts +20 −3
  11. packages/react/src/components/command-palette.tsx +8 −1
  12. packages/react/src/components/integration-creation-gate.tsx +36 −0
  13. packages/react/src/components/workspace-admin-hint.tsx +29 −0
  14. packages/react/src/multiplayer/shell.tsx +17 −11
  15. packages/react/src/pages/integration-add.tsx +13 −3
  16. packages/react/src/pages/integration-browse.tsx +15 −5
  17. packages/react/src/pages/integration-detail.tsx +26 −15
  18. packages/react/src/pages/integrations.tsx +44 −21
.changeset/fair-admin-integrations.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/react": patch
3+---
4+
5+Show restricted integration actions as disabled controls with an admin explanation. Members can browse the catalog and add personal connections to existing integrations.
.changeset/oauth-discovered-scope-budget.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"@executor-js/sdk": patch
3+---
4+
5+Request every scope a resource advertises during OAuth scope discovery, bounded by an 8 KiB scope-string budget instead of a 100-scope count. Resources with many fine-grained scopes previously received a token missing the ones it needed. Health checks without a probe no longer replace a tool-sync failure verdict with "healthy".
e2e/cloud/integration-creation-permissions.test.ts+17−0
@@ -0,0 +1,17 @@
1+import { Effect } from "effect";
2+import { scenario } from "../src/scenario";
3+import { Target } from "../src/services";
4+import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5+import { forBrowser, joinOrg } from "./support/session";
6+
7+scenario(
8+ "Integration creation · cloud members see admin guidance and admins can add",
9+ { timeout: 180_000 },
10+ Effect.gen(function* () {
11+ const target = yield* Target;
12+ const admin = yield* target.newIdentity();
13+ const invitee = yield* target.newIdentity({ org: false });
14+ const member = yield* joinOrg(target, admin, invitee);
15+ yield* integrationCreationPermissions(forBrowser(admin), forBrowser(member));
16+ }),
17+);
e2e/selfhost/integration-creation-permissions.test.ts+21−0
@@ -0,0 +1,21 @@
1+import { Effect } from "effect";
2+import { scenario } from "../src/scenario";
3+import { Target } from "../src/services";
4+import { integrationCreationPermissions } from "../src/integration-creation-permissions";
5+import { createInvitedIdentity } from "../targets/selfhost";
6+
7+scenario(
8+ "Integration creation · self-host members see admin guidance and owners can add",
9+ { timeout: 180_000 },
10+ Effect.gen(function* () {
11+ const target = yield* Target;
12+ const admin = yield* target.newIdentity();
13+ const member = yield* Effect.promise(() =>
14+ createInvitedIdentity(target.baseUrl, admin, {
15+ role: "member",
16+ emailPrefix: "integration-permissions",
17+ }),
18+ );
19+ yield* integrationCreationPermissions(admin, member);
20+ }),
21+);
e2e/src/integration-creation-permissions.ts+164−0
@@ -0,0 +1,164 @@
1+import { randomBytes } from "node:crypto";
2+import { expect } from "@effect/vitest";
3+import { Effect } from "effect";
4+import { composePluginApi } from "@executor-js/api/server";
5+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
6+import { IntegrationSlug } from "@executor-js/sdk/shared";
7+
8+import { Api, Browser } from "./services";
9+import type { Identity } from "./target";
10+import { visit } from "./surfaces/browser";
11+
12+const api = composePluginApi([openApiHttpPlugin()] as const);
13+
14+/** Exercise integration creation and member restrictions through the shared console. */
15+export const integrationCreationPermissions = (admin: Identity, member: Identity) =>
16+ Effect.gen(function* () {
17+ const browser = yield* Browser;
18+ const { client } = yield* Api;
19+ const adminClient = yield* client(api, admin);
20+ const title = `Permissions API ${randomBytes(4).toString("hex")}`;
21+ const slug = IntegrationSlug.make(title.toLowerCase().replaceAll(" ", "_"));
22+ const spec = JSON.stringify({
23+ openapi: "3.0.3",
24+ info: { title, version: "1.0.0" },
25+ servers: [{ url: "https://api.example.com" }],
26+ paths: {},
27+ components: {
28+ securitySchemes: { apiKey: { type: "apiKey", in: "header", name: "X-API-Key" } },
29+ },
30+ security: [{ apiKey: [] }],
31+ });
32+
33+ yield* Effect.ensuring(
34+ Effect.gen(function* () {
35+ yield* browser.session(admin, async ({ page, step }) => {
36+ await step("Admin opens the integration catalog", async () => {
37+ await visit(page, "/");
38+ await page.getByRole("button", { name: "Browse integrations", exact: true }).waitFor();
39+ await page.keyboard.press("ControlOrMeta+k");
40+ await page.getByRole("option", { name: /^Add OpenAPI/ }).waitFor();
41+ await page.keyboard.press("Escape");
42+ await page.getByRole("link", { name: "Add integration", exact: true }).click();
43+ await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor();
44+ await page
45+ .getByRole("textbox", { name: "Search integrations, or paste a URL" })
46+ .waitFor();
47+ });
48+ await step("Admin creates an integration from the setup form", async () => {
49+ await visit(page, "/integrations/add/openapi");
50+ await page.getByPlaceholder("https://api.example.com/openapi.json").fill(spec);
51+ await page.getByRole("button", { name: "Add integration", exact: true }).click();
52+ await page.waitForURL((url) => url.pathname.endsWith(`/integrations/${slug}`), {
53+ timeout: 30_000,
54+ });
55+ await page.getByRole("button", { name: "Edit", exact: true }).waitFor();
56+ await page.getByRole("button", { name: "Delete", exact: true }).waitFor();
57+ });
58+ });
59+ expect(yield* adminClient.integrations.get({ params: { slug } })).toMatchObject({
60+ name: title,
61+ });
62+
63+ yield* browser.session(member, async ({ page, step }) => {
64+ await step(
65+ "Member sees disabled creation controls with an admin explanation",
66+ async () => {
67+ await visit(page, "/");
68+ await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
69+ await page.getByTestId(`integration-entry-${slug}`).waitFor();
70+ const add = page.getByRole("button", { name: "Add integration", exact: true });
71+ await add.waitFor();
72+ expect(await add.isDisabled()).toBe(true);
73+ expect(
74+ await page
75+ .getByRole("button", { name: "Browse integrations", exact: true })
76+ .isDisabled(),
77+ ).toBe(true);
78+ const hint = page
79+ .getByRole("group", { name: "Requires a workspace admin" })
80+ .filter({ has: add });
81+ await hint.hover();
82+ await page.getByRole("tooltip", { name: "Requires a workspace admin" }).waitFor();
83+ await hint.focus();
84+ const before = page.url();
85+ await page.keyboard.press("Enter");
86+ expect(page.url()).toBe(before);
87+ },
88+ );
89+ await step(
90+ "Member sees disabled add commands and can still find existing integrations",
91+ async () => {
92+ await page.keyboard.press("ControlOrMeta+k");
93+ const palette = page.getByRole("dialog");
94+ await palette.getByRole("option", { name: new RegExp(title) }).waitFor();
95+ const addCommand = palette.getByRole("option", { name: /^Add OpenAPI/ });
96+ await addCommand.waitFor();
97+ expect(await addCommand.getAttribute("aria-disabled")).toBe("true");
98+ expect(await addCommand.textContent()).toContain("Admin only");
99+ await page.keyboard.press("Escape");
100+ },
101+ );
102+ await step("Member sees disabled Edit and Delete actions", async () => {
103+ await page.getByTestId(`integration-entry-${slug}`).click();
104+ await page.getByRole("button", { name: "Add connection", exact: true }).waitFor();
105+ for (const name of ["Edit", "Delete"]) {
106+ const action = page.getByRole("button", { name, exact: true });
107+ await action.waitFor();
108+ expect(await action.isDisabled()).toBe(true);
109+ }
110+ });
111+ await step("Member can still add a personal connection", async () => {
112+ await page.getByRole("button", { name: "Add connection", exact: true }).click();
113+ const dialog = page.getByRole("dialog");
114+ await dialog.waitFor();
115+ expect(await dialog.getByText("Workspace", { exact: true }).count()).toBe(0);
116+ });
117+ await step("Member browses the catalog with disabled Add buttons", async () => {
118+ await visit(page, "/integrations/browse");
119+ await page.getByRole("heading", { name: "Add an integration", exact: true }).waitFor();
120+ await page
121+ .getByText("Requires a workspace admin to add integrations.", { exact: true })
122+ .waitFor();
123+ const addButtons = page.getByRole("button", { name: /^Add / });
124+ await addButtons.first().waitFor();
125+ for (const button of await addButtons.all())
126+ expect(await button.isDisabled()).toBe(true);
127+ const scratch = page.getByRole("button", {
128+ name: "New OpenAPI integration from scratch",
129+ exact: true,
130+ });
131+ expect(await scratch.isDisabled()).toBe(true);
132+ const view = page.getByRole("link", { name: `View ${title}`, exact: true });
133+ await view.waitFor();
134+ expect(await view.isEnabled()).toBe(true);
135+ });
136+ await step("Member cannot add a URL with the button or Enter key", async () => {
137+ const input = page.getByRole("textbox", {
138+ name: "Search integrations, or paste a URL",
139+ });
140+ await input.fill("https://api.example.com/openapi.json");
141+ expect(
142+ await page.getByRole("button", { name: "Add this URL", exact: true }).isDisabled(),
143+ ).toBe(true);
144+ const before = page.url();
145+ await input.press("Enter");
146+ expect(page.url()).toBe(before);
147+ });
148+ for (const path of ["/integrations/add/openapi", "/integrations/add/mcp"]) {
149+ await step(`Member follows ${path} and sees the admin explanation`, async () => {
150+ await visit(page, path);
151+ await page.getByRole("heading", { name: "An admin must add integrations" }).waitFor();
152+ expect(await page.getByRole("textbox").count()).toBe(0);
153+ expect(await page.getByRole("button", { name: /^Add/ }).count()).toBe(0);
154+ });
155+ }
156+ await step("Member returns to their existing integrations", async () => {
157+ await page.getByRole("link", { name: "Back to integrations" }).click();
158+ await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor();
159+ });
160+ });
161+ }),
162+ adminClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore),
163+ );
164+ });
packages/core/sdk/src/executor.ts+25−2
@@ -3605,7 +3605,15 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3604 unmodified lines
36053605 ? { tools_synced_at: Date.now() }
36063606 : {
36073607 tools_synced_at: Date.now(),
3608− last_health: health ?? toolSyncHealth(reason),
3608+ // A plugin-supplied verdict (e.g. the MCP server
3609+ // rejecting the token during discovery) is still
3610+ // sync-stamped: mark it so credential-only health
3611+ // checks cannot bury it under "healthy", and a
3612+ // later successful sync clears it.
3613+ last_health:
3614+ health === undefined
3615+ ? toolSyncHealth(reason)
3616+ : { ...health, reason: health.reason ?? "tool_sync_failed" },
36093617 updated_at: new Date(),
36103618 },
36113619 })
@@ -5138,7 +5146,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1526 unmodified lines
51385146 ): Effect.Effect<void> =>
51395147 findConnectionRow(ref).pipe(
51405148 Effect.flatMap((fresh) =>
5141− fresh === null || oauthReauthRequiredFromProviderState(fresh.provider_state) !== null
5149+ fresh === null ||
5150+ oauthReauthRequiredFromProviderState(fresh.provider_state) !== null ||
5151+ // A credential verdict cannot refute a failed tool sync; only a
5152+ // successful sync clears that record (see `isToolSyncHealth`).
5153+ isToolSyncHealth(Option.getOrNull(decodeLastHealth(fresh.last_health)))
51425154 ? Effect.void
51435155 : persistHealthResult(ref, fresh, result),
51445156 ),
@@ -5241,6 +5253,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 96 unmodified lines
52415253 // failure is the one real signal this path can produce, and it
52425254 // must not hide inside a green span.
52435255 oauthCredentialHealthWithoutProbe(connectionRow).pipe(
5256+ // A resolvable token says nothing about whether the
5257+ // upstream accepts it. When tool sync has already recorded
5258+ // that it does not (a rejected discovery handshake, an
5259+ // unreachable server), that verdict stands until a sync
5260+ // succeeds — serving "healthy" here would hide a connection
5261+ // that has no tools behind a green badge.
5262+ Effect.map((result) =>
5263+ result.status === "healthy" && previous !== null && isToolSyncHealth(previous)
5264+ ? previous
5265+ : result,
5266+ ),
52445267 Effect.tap((result) => persistProbeHealthResult(ref, result)),
52455268 Effect.map((result) => ({
52465269 source: "credential_only" as const,
packages/core/sdk/src/health-check.ts+1−0
@@ -145,6 +145,7 @@ export type HealthCheckResult = typeof HealthCheckResult.Type; · 144 unmodified lines
145145 export const toolSyncHealthDetailPrefix = "Tool sync failing";
146146
147147 export const isToolSyncHealth = (result: HealthCheckResult | null | undefined): boolean =>
148+ result?.reason === "tool_sync_failed" ||
148149 result?.detail?.startsWith(toolSyncHealthDetailPrefix) === true;
149150
150151 // ---------------------------------------------------------------------------
packages/core/sdk/src/oauth-flow.test.ts+67−0
@@ -2033,6 +2033,73 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 2032 unmodified lines
20332033 ),
20342034 );
20352035
2036+ it.effect(
2037+ "checkHealth without a probe serves a sync-stamped verdict instead of burying it under healthy",
2038+ () =>
2039+ Effect.scoped(
2040+ Effect.gen(function* () {
2041+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2042+ const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2043+ yield* executor.acme.seed();
2044+
2045+ yield* executor.oauth.createClient({
2046+ owner: "org",
2047+ slug: CLIENT,
2048+ authorizationUrl: server.authorizationEndpoint,
2049+ tokenUrl: server.tokenEndpoint,
2050+ grant: "authorization_code",
2051+ clientId: "test-client",
2052+ clientSecret: "test-secret",
2053+ resource: server.mcpResourceUrl,
2054+ });
2055+
2056+ const started = yield* executor.oauth.start({
2057+ owner: "org",
2058+ client: CLIENT,
2059+ clientOwner: "org",
2060+ name: ConnectionName.make("main"),
2061+ integration: INTEG,
2062+ template: TEMPLATE,
2063+ });
2064+ expect(started.status).toBe("redirect");
2065+ if (started.status !== "redirect") return;
2066+ const callback = yield* server.completeAuthorizationCodeFlow({
2067+ authorizationUrl: started.authorizationUrl,
2068+ });
2069+ yield* executor.oauth.complete({ state: started.state, code: callback.code });
2070+
2071+ // Tool sync found the upstream rejecting the freshly minted token
2072+ // (e.g. an MCP discovery handshake answering 401) and stamped it.
2073+ // The token itself still resolves, so a credential-only check would
2074+ // otherwise report healthy and hide a connection that has no tools.
2075+ const stamped = {
2076+ status: "expired",
2077+ checkedAt: Date.now(),
2078+ detail: "MCP OAuth reauthorization required",
2079+ reason: "tool_sync_failed",
2080+ };
2081+ yield* Effect.promise(() =>
2082+ config.db.updateMany("connection", {
2083+ where: (b) => b("name", "=", "main"),
2084+ set: { last_health: stamped },
2085+ }),
2086+ );
2087+
2088+ const result = yield* executor.connections.checkHealth({
2089+ owner: "org",
2090+ integration: INTEG,
2091+ name: ConnectionName.make("main"),
2092+ });
2093+ expect(result).toMatchObject(stamped);
2094+
2095+ const row = yield* Effect.promise(() =>
2096+ config.db.findFirst("connection", { where: (b) => b("name", "=", "main") }),
2097+ );
2098+ expect(row?.last_health).toMatchObject(stamped);
2099+ }),
2100+ ),
2101+ );
2102+
20362103 it.effect("records missing authorization-code scopes without blocking the connection", () =>
20372104 Effect.scoped(
20382105 Effect.gen(function* () {
packages/core/sdk/src/oauth-scope-union.test.ts+38−7
@@ -713,13 +713,42 @@ describe("oauth.start integration-driven scopes", () => { · 712 unmodified lines
713713 ),
714714 );
715715
716− it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
716+ it.effect("(j) requests every advertised scope of a large but realistic resource list", () =>
717717 Effect.scoped(
718718 Effect.gen(function* () {
719− // A hostile/buggy server advertises far more scopes than any real
720− // template. Discovery caps the request at 100 so the authorize URL
721− // cannot be blown up.
722− const manyScopes = Array.from({ length: 200 }, (_, i) => `scope:${i}`);
719+ // A fine-grained resource can legitimately advertise well over a
720+ // hundred scopes (PostHog lists 150). Dropping any of them mints a
721+ // token the resource rejects, so the whole list must be requested.
722+ const manyScopes = Array.from(
723+ { length: 150 },
724+ (_, i) => `resource_${i}:${i % 2 === 0 ? "read" : "write"}`,
725+ );
726+ const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });
727+ const executor = yield* setupMcpScopeClient(server);
728+
729+ const started = yield* executor.oauth.start({
730+ owner: "org",
731+ client: CLIENT,
732+ clientOwner: "org",
733+ name: ConnectionName.make("main"),
734+ integration: INTEG,
735+ template: TEMPLATE,
736+ });
737+ expect(started.status).toBe("redirect");
738+ if (started.status !== "redirect") return;
739+
740+ expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(manyScopes);
741+ }),
742+ ),
743+ );
744+
745+ it.effect("(j2) caps server-advertised resource scopes so the authorize URL stays bounded", () =>
746+ Effect.scoped(
747+ Effect.gen(function* () {
748+ // A hostile/buggy server advertises an absurd list. Discovery keeps
749+ // the longest leading prefix whose joined `scope` value fits the
750+ // 8 KiB budget so the authorize URL cannot be blown up.
751+ const manyScopes = Array.from({ length: 2000 }, (_, i) => `scope:${i}`);
723752 const server = yield* serveMetadataServer({ prm: { scopesSupported: manyScopes } });
724753 const executor = yield* setupMcpScopeClient(server);
725754
@@ -735,8 +764,10 @@ describe("oauth.start integration-driven scopes", () => { · 9 unmodified lines
735764 if (started.status !== "redirect") return;
736765
737766 const requested = scopesFromAuthorizeUrl(started.authorizationUrl);
738− expect(requested.length).toBe(100);
739− expect(requested).toEqual(manyScopes.slice(0, 100));
767+ expect(requested.length).toBeLessThan(manyScopes.length);
768+ expect(requested).toEqual(manyScopes.slice(0, requested.length));
769+ expect(requested.join(" ").length).toBeLessThanOrEqual(8192);
770+ expect([...requested, manyScopes[requested.length]].join(" ").length).toBeGreaterThan(8192);
740771 }),
741772 ),
742773 );
packages/core/sdk/src/oauth-service.ts+20−3
@@ -782,9 +782,26 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 781 unmodified lines
782782 // Caps on server-controlled discovery input — a hostile or buggy server must
783783 // not be able to hang `oauth.start` or overflow the authorize URL.
784784 const MAX_DISCOVERY_AUTH_SERVERS = 3; // AS-failover lists are tiny in practice
785− const MAX_DISCOVERED_SCOPES = 100; // far beyond any realistic authorization template
786− const capScopes = (scopes: readonly string[]): readonly string[] =>
787− dedupeScopes(scopes).slice(0, MAX_DISCOVERED_SCOPES);
785+ // The cap is on the encoded `scope` parameter's length, not the scope
786+ // count: the URL is what overflows, and a real resource can legitimately
787+ // advertise well over a hundred fine-grained scopes (PostHog lists 150).
788+ // Dropping any advertised scope silently mints a token the resource then
789+ // rejects, so the budget is generous — 8 KiB leaves room for the rest of the
790+ // authorize URL under the common 8-16 KiB request-line limits — and only an
791+ // absurd list is truncated.
792+ const MAX_DISCOVERED_SCOPE_CHARS = 8192;
793+ const capScopes = (scopes: readonly string[]): readonly string[] => {
794+ const unique = dedupeScopes(scopes);
795+ let length = 0;
796+ let count = 0;
797+ for (const scope of unique) {
798+ const next = length + scope.length + (count > 0 ? 1 : 0);
799+ if (next > MAX_DISCOVERED_SCOPE_CHARS) break;
800+ length = next;
801+ count += 1;
802+ }
803+ return unique.slice(0, count);
804+ };
788805
789806 // Bound a whole discovery sequence (PRM + up to MAX_DISCOVERY_AUTH_SERVERS AS
790807 // fetches, each with its own request timeout). 30s is larger than a single
packages/react/src/components/command-palette.tsx+8−1
@@ -9,6 +9,7 @@ import { IntegrationFavicon, integrationPresetIconUrl } from "./integration-favi · 8 unmodified lines
99 import { PresetIcon } from "./preset-icon";
1010 import { integrationsOptimisticAtom } from "../api/atoms";
1111 import { useIntegrationPlugins } from "@executor-js/sdk/client";
12+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
1213 import {
1314 CommandDialog,
1415 CommandEmpty,
@@ -34,6 +35,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 19 unmodified lines
3435 const integrationPlugins = useIntegrationPlugins();
3536 const navigate = useNavigate();
3637 const integrationsResult = useAtomValue(integrationsOptimisticAtom);
38+ const canCreateIntegration = useCanCreateWorkspaceConnections();
3739
3840 // Toggle with ⌘K / Ctrl+K
3941 useEffect(() => {
@@ -176,11 +178,13 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 136 unmodified lines
176178 {integrationPlugins.map((plugin) => (
177179 <CommandItem
178180 key={`add-${plugin.key}`}
181+ disabled={!canCreateIntegration}
179182 value={`add ${plugin.label} ${plugin.key}`}
180183 onSelect={() => goToAdd(plugin.key)}
181184 >
182185 <PlusIcon />
183186 <span className="flex-1 truncate">Add {plugin.label}</span>
187+ {!canCreateIntegration && <CommandShortcut>Admin only</CommandShortcut>}
184188 </CommandItem>
185189 ))}
186190 </CommandGroup>
@@ -193,6 +197,7 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 6 unmodified lines
193197 {presetEntries.map((e) => (
194198 <CommandItem
195199 key={`preset-${e.pluginKey}-${e.presetId}`}
200+ disabled={!canCreateIntegration}
196201 value={`preset ${e.presetName} ${e.presetSummary ?? ""} ${e.pluginLabel}`}
197202 onSelect={() => goToPreset(e.pluginKey, e.presetId, e.presetUrl)}
198203 >
@@ -208,7 +213,9 @@ export function CommandPalette(props: { open: boolean; onOpenChange: (open: bool · 9 unmodified lines
208213 }
209214 />
210215 <span className="flex-1 truncate">{e.presetName}</span>
211− <CommandShortcut>{e.pluginLabel}</CommandShortcut>
216+ <CommandShortcut>
217+ {canCreateIntegration ? e.pluginLabel : "Admin only"}
218+ </CommandShortcut>
212219 </CommandItem>
213220 ))}
214221 </CommandGroup>
packages/react/src/components/integration-creation-gate.tsx+36−0
@@ -0,0 +1,36 @@
1+import type { ReactNode } from "react";
2+import { Link } from "@tanstack/react-router";
3+import { useAtomValue } from "@effect/atom-react";
4+
5+import { orgMembersAtom } from "../api/account-atoms";
6+import { isAsyncResultLoading } from "../lib/async-result";
7+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
8+import { Button } from "./button";
9+import { PageContainer, PageHeader } from "./page";
10+import { Skeleton } from "./skeleton";
11+
12+/** Keep integration creation flows behind the same role gate as edit and delete. */
13+export function IntegrationCreationGate({ children }: { readonly children: ReactNode }) {
14+ const canCreate = useCanCreateWorkspaceConnections();
15+ const members = useAtomValue(orgMembersAtom);
16+ if (canCreate) return children;
17+ if (isAsyncResultLoading(members)) {
18+ return (
19+ <PageContainer>
20+ <Skeleton className="h-8 w-64" />
21+ </PageContainer>
22+ );
23+ }
24+
25+ return (
26+ <PageContainer>
27+ <PageHeader
28+ title="An admin must add integrations"
29+ description="Ask a workspace admin to add the integration. You can add personal connections to existing integrations."
30+ />
31+ <Button asChild variant="outline">
32+ <Link to="/{-$orgSlug}">Back to integrations</Link>
33+ </Button>
34+ </PageContainer>
35+ );
36+}
packages/react/src/components/workspace-admin-hint.tsx+29−0
@@ -0,0 +1,29 @@
1+import type { ReactNode } from "react";
2+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
3+
4+/** Explain a disabled workspace action on hover or keyboard focus. */
5+export function WorkspaceAdminHint(props: {
6+ readonly allowed: boolean;
7+ readonly children: ReactNode;
8+}) {
9+ if (props.allowed) return props.children;
10+ return (
11+ <TooltipProvider>
12+ <Tooltip>
13+ <TooltipTrigger asChild>
14+ <span
15+ tabIndex={0}
16+ role="group"
17+ aria-label="Requires a workspace admin"
18+ className="inline-flex"
19+ >
20+ {props.children}
21+ </span>
22+ </TooltipTrigger>
23+ <TooltipContent side="bottom" sideOffset={6}>
24+ Requires a workspace admin
25+ </TooltipContent>
26+ </Tooltip>
27+ </TooltipProvider>
28+ );
29+}
packages/react/src/multiplayer/shell.tsx+17−11
@@ -6,6 +6,7 @@ import { BookOpen, Command, ExternalLink, PlusIcon } from "lucide-react"; · 5 unmodified lines
66 import type { Integration } from "@executor-js/sdk/shared";
77 import { integrationsOptimisticAtom } from "../api/atoms";
88 import { trackEvent } from "../api/analytics";
9+import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
910 import { Button } from "../components/button";
1011 import { Skeleton } from "../components/skeleton";
1112 import { SidebarUpdateCard } from "../components/update-card";
@@ -25,6 +26,7 @@ import { CommandPalette } from "../components/command-palette"; · 13 unmodified lines
2526 import { Wordmark } from "../components/wordmark";
2627 import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client";
2728 import { useAuth } from "./auth-context";
29+import { useCanCreateWorkspaceConnections } from "./use-admin-nav";
2830
2931 // ---------------------------------------------------------------------------
3032 // Shared multiplayer shell (cloud + self-host).
@@ -351,6 +353,7 @@ function SidebarContent( · 320 unmodified lines
351353 },
352354 ) {
353355 const plugins = useClientPlugins();
356+ const canCreateIntegration = useCanCreateWorkspaceConnections();
354357 const pluginNavItems = plugins.flatMap((plugin) =>
355358 (plugin.pages ?? []).flatMap((page) =>
356359 page.nav
@@ -385,17 +388,20 @@ function SidebarContent( · 28 unmodified lines
385388
386389 <div className="mt-5 mb-1 flex items-center justify-between px-2.5 text-xs font-medium uppercase tracking-widest text-muted-foreground">
387390 <span>Integrations</span>
388− <Button
389− type="button"
390− variant="ghost"
391− size="icon-xs"
392− aria-label="Browse integrations"
393− title="Browse integrations"
394− onClick={props.onOpenIntegrationConnect}
395− className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
396− >
397− <PlusIcon className="size-3.5" />
398− </Button>
391+ <WorkspaceAdminHint allowed={canCreateIntegration}>
392+ <Button
393+ type="button"
394+ variant="ghost"
395+ size="icon-xs"
396+ disabled={!canCreateIntegration}
397+ aria-label="Browse integrations"
398+ title="Browse integrations"
399+ onClick={props.onOpenIntegrationConnect}
400+ className="-my-1 text-muted-foreground hover:bg-sidebar-active/60 hover:text-foreground"
401+ >
402+ <PlusIcon className="size-3.5" />
403+ </Button>
404+ </WorkspaceAdminHint>
399405 </div>
400406
401407 <IntegrationList pathname={props.pathname} onNavigate={props.onNavigate} />
packages/react/src/pages/integration-add.tsx+13−3
@@ -1,16 +1,27 @@
1−import { Suspense } from "react";
1+import { Suspense, type ComponentProps } from "react";
22 import { useAtomRefresh } from "@effect/atom-react";
33 import { Link, useNavigate } from "@tanstack/react-router";
44 import { useIntegrationPlugins } from "@executor-js/sdk/client";
55 import { integrationsOptimisticAtom } from "../api/atoms";
66 import { trackEvent } from "../api/analytics";
77 import { useExecutorDocumentTitle } from "../lib/document-title";
8+import { IntegrationCreationGate } from "../components/integration-creation-gate";
89
910 // ---------------------------------------------------------------------------
1011 // Page
1112 // ---------------------------------------------------------------------------
1213
13−export function AddIntegrationPage(props: {
14+/** Render an integration setup flow only when the workspace role permits creation. */
15+export function AddIntegrationPage(props: ComponentProps<typeof AddIntegrationContent>) {
16+ useExecutorDocumentTitle("Add integration");
17+ return (
18+ <IntegrationCreationGate>
19+ <AddIntegrationContent {...props} />
20+ </IntegrationCreationGate>
21+ );
22+}
23+
24+function AddIntegrationContent(props: {
1425 pluginKey: string;
1526 url?: string;
1627 preset?: string;
@@ -20,7 +31,6 @@ export function AddIntegrationPage(props: { · 3 unmodified lines
2031 authKind?: string;
2132 specOverrides?: string;
2233 }) {
23− useExecutorDocumentTitle("Add integration");
2434 const { pluginKey, url, preset, namespace, authHeader, authNote, authKind, specOverrides } =
2535 props;
2636 const navigate = useNavigate();
packages/react/src/pages/integration-browse.tsx+15−5
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
2727 } from "../components/integration-favicon";
2828 import { Skeleton } from "../components/skeleton";
2929 import { useExecutorDocumentTitle } from "../lib/document-title";
30+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
3031 import {
3132 availableCatalogKinds,
3233 catalogLogoUrl,
@@ -244,7 +245,7 @@ function RowIcon(props: { readonly src?: string; readonly alt: string }) { · 211 unmodified lines
244245 );
245246 }
246247
247−function ResultCard(props: { readonly row: Row }) {
248+function ResultCard(props: { readonly row: Row; readonly canCreate: boolean }) {
248249 const { row } = props;
249250 return (
250251 <div
@@ -275,6 +276,7 @@ function ResultCard(props: { readonly row: Row }) { · 24 unmodified lines
275276 variant="outline"
276277 size="sm"
277278 onClick={row.onSelect}
279+ disabled={!props.canCreate}
278280 // Every card's button reads "Add", so the visible label alone is
279281 // useless to a screen reader; the accessible name carries the card.
280282 aria-label={`Add ${row.title}`}
@@ -362,8 +364,10 @@ function quickAddCapablePlugins(plugins: readonly IntegrationPlugin[]) { · 81 unmodified lines
362364 // Page
363365 // ---------------------------------------------------------------------------
364366
367+/** Let members browse integrations while reserving creation for workspace admins. */
365368 export function IntegrationBrowsePage() {
366369 useExecutorDocumentTitle("Add an integration");
370+ const canCreate = useCanCreateWorkspaceConnections();
367371 const navigate = useNavigate();
368372 const integrationPlugins = useIntegrationPlugins();
369373 const doDetect = useAtomSet(detectIntegration, { mode: "promiseExit" });
@@ -923,6 +927,11 @@ export function IntegrationBrowsePage() { · 553 unmodified lines
923927 description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint."
924928 />
925929
930+ {!canCreate && (
931+ <p className="mb-4 text-sm text-muted-foreground">
932+ Requires a workspace admin to add integrations.
933+ </p>
934+ )}
926935 <div className="mb-4 flex gap-2">
927936 <div className="relative min-w-0 flex-1">
928937 <SearchIcon
@@ -937,7 +946,7 @@ export function IntegrationBrowsePage() { · 8 unmodified lines
937946 setError(null);
938947 }}
939948 onKeyDown={(event) => {
940− if (event.key === "Enter" && isUrl) void handleDetect();
949+ if (event.key === "Enter" && isUrl && canCreate) void handleDetect();
941950 }}
942951 placeholder="Search integrations, or paste a URL…"
943952 aria-label="Search integrations, or paste a URL"
@@ -951,7 +960,7 @@ export function IntegrationBrowsePage() { · 7 unmodified lines
951960 <Button
952961 className="h-11 shrink-0"
953962 onClick={() => void handleDetect()}
954− disabled={detecting || query.trim().length === 0}
963+ disabled={!canCreate || detecting || query.trim().length === 0}
955964 loading={detecting}
956965 >
957966 Add this URL
@@ -973,6 +982,7 @@ export function IntegrationBrowsePage() { · 15 unmodified lines
973982 key={plugin.key}
974983 type="button"
975984 aria-label={`New ${plugin.label} integration from scratch`}
985+ disabled={!canCreate}
976986 onClick={() => {
977987 trackEvent("integration_add_started", {
978988 plugin_key: plugin.key,
@@ -983,7 +993,7 @@ export function IntegrationBrowsePage() { · 4 unmodified lines
983993 params: { pluginKey: plugin.key },
984994 });
985995 }}
986− className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
996+ className="disabled:opacity-50 disabled:pointer-events-none inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
987997 >
988998 <PlusIcon className="size-3" aria-hidden />
989999 {plugin.label}
@@ -1020,7 +1030,7 @@ export function IntegrationBrowsePage() { · 30 unmodified lines
10201030 ) : (
10211031 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
10221032 {results.map((row) => (
1023− <ResultCard key={row.key} row={row} />
1033+ <ResultCard key={row.key} row={row} canCreate={canCreate} />
10241034 ))}
10251035 {catalog.loadingMore
10261036 ? Array.from({ length: 3 }, (_, index) => (
packages/react/src/pages/integration-detail.tsx+26−15
@@ -37,6 +37,7 @@ import { IntegrationEditSheet } from "../components/metadata-edit-sheet"; · 36 unmodified lines
3737 import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/tabs";
3838 import { authMethodsFromDescriptors, type AuthMethod } from "../lib/auth-placements";
3939 import { usePolicyActions } from "../hooks/use-policy-actions";
40+import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
4041 import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
4142 import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor-js/sdk/client";
4243 import { Button } from "../components/button";
@@ -139,11 +140,11 @@ export function IntegrationDetailPage(props: { · 96 unmodified lines
139140 const isBuiltInIntegration = namespace === "executor" || integrationData?.kind === "built-in";
140141 const currentTab = isBuiltInIntegration ? "tools" : activeTab;
141142 // Integrations are workspace-owned; the server refuses catalog mutations
142− // (update/remove) from non-admin members, so hide the controls for them.
143+ // (update/remove) from non-admin members, so disable the controls for them.
143144 const canMutateIntegration = useCanCreateWorkspaceConnections();
144− const canEdit = canMutateIntegration && !isBuiltInIntegration && integrationData !== null;
145+ const canEdit = !isBuiltInIntegration && integrationData !== null;
145146 const canRefresh = integrationData?.canRefresh ?? false;
146− const canRemove = canMutateIntegration && (integrationData?.canRemove ?? false);
147+ const canRemove = integrationData?.canRemove ?? false;
147148 const urlAccountHandoff = useMemo<IntegrationAccountHandoff | null>(() => {
148149 const search = new URLSearchParams(locationSearch);
149150 // The route-validated flag and the raw `addAccount=1` are the same request;
@@ -499,9 +500,16 @@ export function IntegrationDetailPage(props: { · 349 unmodified lines
499500
500501 <div className="flex shrink-0 items-center gap-2">
501502 {!confirmDelete && canEdit && (
502− <Button variant="outline" size="sm" onClick={() => setEditSheetOpen(true)}>
503− Edit
504− </Button>
503+ <WorkspaceAdminHint allowed={canMutateIntegration}>
504+ <Button
505+ disabled={!canMutateIntegration}
506+ variant="outline"
507+ size="sm"
508+ onClick={() => setEditSheetOpen(true)}
509+ >
510+ Edit
511+ </Button>
512+ </WorkspaceAdminHint>
505513 )}
506514
507515 {canRefresh && (
@@ -530,20 +538,23 @@ export function IntegrationDetailPage(props: { · 22 unmodified lines
530538 variant="destructive"
531539 size="sm"
532540 onClick={() => void handleDelete()}
533− disabled={deleting}
541+ disabled={deleting || !canMutateIntegration}
534542 >
535543 {deleting ? "Deleting..." : "Confirm Delete"}
536544 </Button>
537545 </div>
538546 ) : (
539− <Button
540− variant="outline"
541− size="sm"
542− onClick={() => setConfirmDelete(true)}
543− className="border-destructive/30 text-destructive hover:bg-destructive/10"
544− >
545− Delete
546− </Button>
547+ <WorkspaceAdminHint allowed={canMutateIntegration}>
548+ <Button
549+ disabled={!canMutateIntegration}
550+ variant="outline"
551+ size="sm"
552+ onClick={() => setConfirmDelete(true)}
553+ className="border-destructive/30 text-destructive hover:bg-destructive/10"
554+ >
555+ Delete
556+ </Button>
557+ </WorkspaceAdminHint>
547558 ))}
548559 </div>
549560 </div>
packages/react/src/pages/integrations.tsx+44−21
@@ -8,6 +8,7 @@ import { useIntegrationPlugins, type IntegrationPlugin } from "@executor-js/sdk/ · 7 unmodified lines
88 import { integrationsOptimisticAtom } from "../api/atoms";
99 import { trackEvent } from "../api/analytics";
1010 import { McpInstallCard } from "../components/mcp-install-card";
11+import { WorkspaceAdminHint } from "../components/workspace-admin-hint";
1112 import { Button } from "../components/button";
1213 import { PageContainer, PageHeader } from "../components/page";
1314 import {
@@ -32,6 +33,7 @@ import { Skeleton } from "../components/skeleton"; · 18 unmodified lines
3233 import { useExecutorDocumentTitle } from "../lib/document-title";
3334 import { ErrorState } from "../components/error-state";
3435 import { isAsyncResultLoading } from "../lib/async-result";
36+import { useCanCreateWorkspaceConnections } from "../multiplayer/use-admin-nav";
3537
3638 const KIND_TO_PLUGIN_KEY: Record<string, string> = {
3739 openapi: "openapi",
@@ -48,6 +50,7 @@ export function IntegrationsPage() { · 10 unmodified lines
4850 useExecutorDocumentTitle("Integrations");
4951 const integrations = useAtomValue(integrationsOptimisticAtom);
5052 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
53+ const canCreate = useCanCreateWorkspaceConnections();
5154
5255 return (
5356 <PageContainer>
@@ -55,15 +58,24 @@ export function IntegrationsPage() { · 1 unmodified line
5558 title="Integrations"
5659 description="Tool providers available in this workspace."
5760 actions={
58− <Button asChild size="sm" className="gap-1.5">
59− <Link
60− to="/{-$orgSlug}/integrations/browse"
61− onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
62− >
63− <PlusIcon className="size-4" />
64− Add integration
65− </Link>
66− </Button>
61+ canCreate ? (
62+ <Button asChild size="sm" className="gap-1.5">
63+ <Link
64+ to="/{-$orgSlug}/integrations/browse"
65+ onClick={() => trackEvent("integration_browse_opened", { via: "header" })}
66+ >
67+ <PlusIcon className="size-4" />
68+ Add integration
69+ </Link>
70+ </Button>
71+ ) : (
72+ <WorkspaceAdminHint allowed={false}>
73+ <Button disabled size="sm">
74+ <PlusIcon className="size-4" />
75+ Add integration
76+ </Button>
77+ </WorkspaceAdminHint>
78+ )
6779 }
6880 />
6981
@@ -83,7 +95,7 @@ export function IntegrationsPage() { · 13 unmodified lines
8395 ),
8496 onSuccess: ({ value }) => {
8597 if (value.length === 0) {
86− return <EmptyIntegrations />;
98+ return <EmptyIntegrations canCreate={canCreate} />;
8799 }
88100
89101 return (
@@ -102,7 +114,7 @@ export function IntegrationsPage() { · 12 unmodified lines
102114 // Empty state
103115 // ---------------------------------------------------------------------------
104116
105−function EmptyIntegrations() {
117+function EmptyIntegrations({ canCreate }: { readonly canCreate: boolean }) {
106118 return (
107119 <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16">
108120 <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
@@ -110,17 +122,28 @@ function EmptyIntegrations() { · 1 unmodified line
110122 </div>
111123 <p className="mb-1 text-[14px] font-medium text-foreground/70">No integrations yet</p>
112124 <p className="mb-5 text-[13px] text-muted-foreground/60">
113− Connect an integration to start curating tools.
125+ {canCreate
126+ ? "Connect an integration to start curating tools."
127+ : "Ask a workspace admin to add an integration."}
114128 </p>
115− <Button asChild size="sm" className="gap-1.5">
116− <Link
117− to="/{-$orgSlug}/integrations/browse"
118− onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
119− >
120− <PlusIcon className="size-4" />
121− Add an integration
122− </Link>
123− </Button>
129+ {canCreate ? (
130+ <Button asChild size="sm" className="gap-1.5">
131+ <Link
132+ to="/{-$orgSlug}/integrations/browse"
133+ onClick={() => trackEvent("integration_browse_opened", { via: "empty-state" })}
134+ >
135+ <PlusIcon className="size-4" />
136+ Add an integration
137+ </Link>
138+ </Button>
139+ ) : (
140+ <WorkspaceAdminHint allowed={false}>
141+ <Button disabled size="sm">
142+ <PlusIcon className="size-4" />
143+ Add an integration
144+ </Button>
145+ </WorkspaceAdminHint>
146+ )}
124147 </div>
125148 );
126149 }