Skip to content

Commit c9bb8ea

Browse files
committed
Make spec-detected auth methods immutable in the add flow
A method seeded from spec/probe detection rendered the full None/API key/ OAuth selector and editable fields, so a user could silently switch a detected method (e.g. a Bearer-token API) to OAuth with empty endpoints and end up with a broken integration. Detected methods now render read-only in a disabled state (lock icon, muted non-interactive block, "Pulled from spec. Remove to override."). The only action is to remove the row and add a custom one; hand-added methods stay fully editable. This lives in the shared AuthMethodListEditor, so the OpenAPI and MCP add flows both get it. Detection is keyed off an explicit row flag rather than the seed slug, since MCP seeds a detected method without one.
1 parent f173b2a commit c9bb8ea

2 files changed

Lines changed: 298 additions & 31 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Selfhost-only (browser): a spec/probe-DETECTED auth method is immutable in
2+
// the add flow. The shared AuthMethodListEditor renders detected methods as a
3+
// disabled, read-only summary ("Pulled from spec. Remove to override.") with no
4+
// kind selector, so a user can't silently retype the spec's method into a kind
5+
// nothing backs. A method the user adds by hand stays fully editable. Both the
6+
// MCP and OpenAPI add flows compose the same editor, so one behavior, two
7+
// surfaces. Selfhost runs with EXECUTOR_ALLOW_LOCAL_NETWORK so the probe/analyze
8+
// can reach the loopback fixtures. Video is the artifact.
9+
import { randomBytes } from "node:crypto";
10+
import { createServer } from "node:http";
11+
12+
import { expect } from "@effect/vitest";
13+
import { Effect } from "effect";
14+
import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing";
15+
import { OAuthTestServer } from "@executor-js/sdk/testing";
16+
17+
import { scenario } from "../src/scenario";
18+
import { Browser, Target } from "../src/services";
19+
20+
const REMOVE_HINT = "Pulled from spec. Remove to override.";
21+
22+
scenario(
23+
"Detected auth · an MCP probe's OAuth method is immutable in the add flow",
24+
{},
25+
Effect.scoped(
26+
Effect.gen(function* () {
27+
const target = yield* Target;
28+
const browser = yield* Browser;
29+
// OAuth-protected server: the probe 401s with resource metadata, so the
30+
// method list seeds a single detected OAuth row (discovered metadata).
31+
const server = yield* serveMcpServerWithOAuth(
32+
() => makeGreetingMcpServer({ name: `oauth-mcp-${randomBytes(3).toString("hex")}` }),
33+
{ path: "/mcp" },
34+
);
35+
const identity = yield* target.newIdentity();
36+
37+
yield* browser.session(identity, async ({ page, step }) => {
38+
await step("Open the add-MCP flow pointed at the OAuth server", async () => {
39+
await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, {
40+
waitUntil: "networkidle",
41+
});
42+
await page.getByText("How does this server authenticate?").waitFor();
43+
await page.getByText("Method 1 · Detected").waitFor();
44+
});
45+
46+
await step("The detected method is locked: read-only, no kind selector", async () => {
47+
// Discovered-OAuth summary + the override hint, both inside a disabled
48+
// block — and crucially NO editable kind tabs anywhere yet.
49+
await page.getByText("OAuth metadata is discovered from this server").waitFor();
50+
await page.getByText(REMOVE_HINT).waitFor();
51+
expect(
52+
await page.locator("[aria-disabled]").count(),
53+
"the detected method renders a disabled (non-interactive) block",
54+
).toBeGreaterThan(0);
55+
expect(
56+
await page.getByText("API key", { exact: true }).count(),
57+
"no editable kind selector is shown for the detected method",
58+
).toBe(0);
59+
});
60+
61+
await step("A hand-added method keeps the full editable selector", async () => {
62+
await page.getByRole("button", { name: "Add method" }).click();
63+
await page.getByText("Method 2").waitFor();
64+
// The added row defaults to API key and exposes the None/API key/OAuth
65+
// kind tabs — the detected row above still shows none.
66+
expect(
67+
await page.getByText("API key", { exact: true }).count(),
68+
"the added method exposes the kind selector",
69+
).toBeGreaterThan(0);
70+
await page.getByText(REMOVE_HINT).waitFor();
71+
});
72+
});
73+
}),
74+
).pipe(Effect.provide(OAuthTestServer.layer())),
75+
);
76+
77+
/** A real 127.0.0.1 server that serves a static OpenAPI spec for the add flow. */
78+
const serveSpec = (body: string) =>
79+
Effect.acquireRelease(
80+
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
81+
const server = createServer((_request, response) => {
82+
response.writeHead(200, { "content-type": "application/json" });
83+
response.end(body);
84+
});
85+
server.listen(0, "127.0.0.1", () => {
86+
const address = server.address();
87+
const port = typeof address === "object" && address ? address.port : 0;
88+
resume(
89+
Effect.succeed({
90+
url: `http://127.0.0.1:${port}/spec.json`,
91+
close: () => {
92+
server.close();
93+
server.closeAllConnections();
94+
},
95+
}),
96+
);
97+
});
98+
}),
99+
(server) => Effect.sync(server.close),
100+
);
101+
102+
const apiKeyAndOAuthSpec = (): string =>
103+
JSON.stringify({
104+
openapi: "3.0.3",
105+
info: { title: "Acme Immutable Auth Fixture", version: "1.0.0" },
106+
servers: [{ url: "https://api.acme.test" }],
107+
security: [{ bearerAuth: [] }, { acmeOAuth: ["read"] }],
108+
components: {
109+
securitySchemes: {
110+
bearerAuth: { type: "http", scheme: "bearer" },
111+
acmeOAuth: {
112+
type: "oauth2",
113+
flows: {
114+
authorizationCode: {
115+
authorizationUrl: "https://api.acme.test/oauth/authorize",
116+
tokenUrl: "https://api.acme.test/oauth/token",
117+
scopes: { read: "Read access" },
118+
},
119+
},
120+
},
121+
},
122+
},
123+
paths: {
124+
"/widgets": {
125+
get: {
126+
operationId: "listWidgets",
127+
summary: "List widgets",
128+
responses: { "200": { description: "ok" } },
129+
},
130+
},
131+
},
132+
});
133+
134+
scenario(
135+
"Detected auth · OpenAPI spec-detected methods are immutable in the add flow",
136+
{},
137+
Effect.scoped(
138+
Effect.gen(function* () {
139+
const target = yield* Target;
140+
const browser = yield* Browser;
141+
const spec = yield* serveSpec(apiKeyAndOAuthSpec());
142+
const identity = yield* target.newIdentity();
143+
144+
yield* browser.session(identity, async ({ page, step }) => {
145+
await step("Analyze a spec that declares both API key and OAuth", async () => {
146+
await page.goto(`/integrations/add/openapi`, { waitUntil: "networkidle" });
147+
await page.getByPlaceholder(/openapi\.json/i).first().fill(spec.url);
148+
await page.getByText("How does this API authenticate?").waitFor();
149+
await page.getByText("Method 2").waitFor();
150+
});
151+
152+
await step("Both detected methods are locked, read-only, no kind selector", async () => {
153+
// Two detected methods, each with the override hint; the OAuth one
154+
// shows the spec's real endpoints read-only.
155+
expect(
156+
await page.getByText(REMOVE_HINT).count(),
157+
"both detected methods show the remove-to-override hint",
158+
).toBe(2);
159+
await page.getByText("https://api.acme.test/oauth/authorize").waitFor();
160+
expect(
161+
await page.getByText("API key", { exact: true }).count(),
162+
"no editable kind selector is shown for the detected methods",
163+
).toBe(0);
164+
});
165+
166+
await step("A hand-added method keeps the full editable selector", async () => {
167+
await page.getByRole("button", { name: "Add method" }).click();
168+
await page.getByText("Method 3").waitFor();
169+
expect(
170+
await page.getByText("API key", { exact: true }).count(),
171+
"the added method exposes the kind selector",
172+
).toBeGreaterThan(0);
173+
});
174+
});
175+
}),
176+
),
177+
);

packages/react/src/components/auth-method-list-editor.tsx

Lines changed: 121 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
// ---------------------------------------------------------------------------
1313

1414
import { useCallback, useEffect, useRef, useState } from "react";
15-
import { PlusIcon, XIcon } from "lucide-react";
15+
import { LockIcon, PlusIcon, XIcon } from "lucide-react";
1616

17+
import { PlacementLine } from "../lib/auth-placements";
1718
import { Button } from "./button";
1819
import { FieldLabel } from "./field";
1920
import {
@@ -35,6 +36,11 @@ export interface AuthMethodSeed {
3536

3637
export interface AuthMethodRow {
3738
readonly value: AuthTemplateEditorValue;
39+
/** True when this row came from detection (a seed), false when the user added
40+
* it. Detected rows are immutable — the spec/probe declared them — so the
41+
* editor renders them read-only. Not inferred from `seedSlug`: some plugins
42+
* (MCP) seed a detected method with a label but no slug. */
43+
readonly seeded: boolean;
3844
readonly seedSlug?: string;
3945
readonly seedLabel?: string;
4046
}
@@ -59,6 +65,7 @@ export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodL
5965
seeds.map(
6066
(seed: AuthMethodSeed): AuthMethodRow => ({
6167
value: seed.value,
68+
seeded: true,
6269
...(seed.slug !== undefined ? { seedSlug: seed.slug } : {}),
6370
...(seed.label !== undefined ? { seedLabel: seed.label } : {}),
6471
}),
@@ -79,7 +86,10 @@ export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodL
7986
}, []);
8087

8188
const addRow = useCallback(() => {
82-
setRows((current: readonly AuthMethodRow[]) => [...current, { value: emptyApiKeyValue() }]);
89+
setRows((current: readonly AuthMethodRow[]) => [
90+
...current,
91+
{ value: emptyApiKeyValue(), seeded: false },
92+
]);
8393
}, []);
8494

8595
return { rows, setRowAt, removeRowAt, addRow };
@@ -115,36 +125,53 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) {
115125
) : null
116126
) : (
117127
<div className="flex flex-col gap-3">
118-
{list.rows.map((row: AuthMethodRow, index: number) => (
119-
<div
120-
key={index}
121-
className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"
122-
>
123-
<div className="flex items-center justify-between">
124-
<span className="text-xs font-medium text-muted-foreground">
125-
Method {index + 1}
126-
{row.seedLabel ? ` · ${row.seedLabel}` : ""}
127-
</span>
128-
<Button
129-
type="button"
130-
variant="ghost"
131-
size="icon-sm"
132-
aria-label="Remove method"
133-
className="text-muted-foreground hover:text-foreground"
134-
onClick={() => list.removeRowAt(index)}
135-
>
136-
<XIcon />
137-
</Button>
128+
{list.rows.map((row: AuthMethodRow, index: number) => {
129+
// A row seeded from detection is the spec's own auth declaration:
130+
// it's IMMUTABLE here. We render it read-only (no kind selector, no
131+
// editable fields) so a user can't silently retype the spec's
132+
// method into something nothing backs (e.g. flipping a Bearer-token
133+
// API to OAuth with empty endpoints). The escape hatch is to remove
134+
// the row and add a custom one. Manually added rows (no seed) get
135+
// the full editor.
136+
const detected = row.seeded;
137+
return (
138+
<div
139+
key={index}
140+
className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"
141+
>
142+
<div className="flex items-center justify-between">
143+
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
144+
{detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}
145+
<span>
146+
Method {index + 1}
147+
{row.seedLabel ? ` · ${row.seedLabel}` : ""}
148+
</span>
149+
</span>
150+
<Button
151+
type="button"
152+
variant="ghost"
153+
size="icon-sm"
154+
aria-label="Remove method"
155+
className="text-muted-foreground hover:text-foreground"
156+
onClick={() => list.removeRowAt(index)}
157+
>
158+
<XIcon />
159+
</Button>
160+
</div>
161+
{detected ? (
162+
<DetectedMethodSummary value={row.value} oauthMetadata={oauthMetadata} />
163+
) : (
164+
<AuthTemplateEditor
165+
value={row.value}
166+
onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}
167+
{...(allowedKinds ? { allowedKinds } : {})}
168+
{...(presets ? { presets } : {})}
169+
{...(oauthMetadata ? { oauthMetadata } : {})}
170+
/>
171+
)}
138172
</div>
139-
<AuthTemplateEditor
140-
value={row.value}
141-
onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}
142-
{...(allowedKinds ? { allowedKinds } : {})}
143-
{...(presets ? { presets } : {})}
144-
{...(oauthMetadata ? { oauthMetadata } : {})}
145-
/>
146-
</div>
147-
))}
173+
);
174+
})}
148175
</div>
149176
)}
150177
{list.rows.length > 0 && props.footerHint ? (
@@ -153,3 +180,66 @@ export function AuthMethodListEditor(props: AuthMethodListEditorProps) {
153180
</section>
154181
);
155182
}
183+
184+
/** One read-only `label value` line, mono value, for the detected summary. */
185+
function SpecField(props: { readonly label: string; readonly value: string }) {
186+
return (
187+
<div className="flex gap-2 text-xs">
188+
<span className="w-20 shrink-0 text-muted-foreground">{props.label}</span>
189+
<span className="break-all font-mono text-foreground/80">{props.value}</span>
190+
</div>
191+
);
192+
}
193+
194+
/** Read-only view of a spec-detected method: shows what the spec declared
195+
* (placements / OAuth endpoints) as a DISABLED, non-interactive block. The
196+
* detected method is immutable here, so the summary is styled like a disabled
197+
* field (muted, not-allowed cursor, text not selectable) to communicate that
198+
* plainly. The only action is to remove the row (the header's X) and add a
199+
* custom method to override. */
200+
function DetectedMethodSummary(props: {
201+
readonly value: AuthTemplateEditorValue;
202+
readonly oauthMetadata?: "editable" | "discovered";
203+
}) {
204+
const { value, oauthMetadata } = props;
205+
return (
206+
<div className="space-y-2">
207+
<div
208+
aria-disabled
209+
className="cursor-not-allowed select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"
210+
>
211+
{value.kind === "none" && (
212+
<p className="text-xs">No credential — tools are callable without an account.</p>
213+
)}
214+
215+
{value.kind === "apikey" &&
216+
(value.placements.length > 0 ? (
217+
<div className="flex flex-wrap gap-x-4 gap-y-1">
218+
{value.placements.map((placement, i: number) => (
219+
<PlacementLine key={i} placement={placement} />
220+
))}
221+
</div>
222+
) : null)}
223+
224+
{value.kind === "oauth" &&
225+
(oauthMetadata === "discovered" ? (
226+
<p className="text-xs">
227+
OAuth metadata is discovered from this server when you connect an account.
228+
</p>
229+
) : (
230+
<div className="space-y-1">
231+
{value.authorizationUrl ? (
232+
<SpecField label="Authorize" value={value.authorizationUrl} />
233+
) : null}
234+
{value.tokenUrl ? <SpecField label="Token" value={value.tokenUrl} /> : null}
235+
{value.scopes.length > 0 ? (
236+
<SpecField label="Scopes" value={value.scopes.join(", ")} />
237+
) : null}
238+
</div>
239+
))}
240+
</div>
241+
242+
<p className="text-[11px] text-muted-foreground">Pulled from spec. Remove to override.</p>
243+
</div>
244+
);
245+
}

0 commit comments

Comments
 (0)