addSpec only persisted auth methods when the caller passed an explicit authenticationTemplate (or for Google Discovery conversions). The add page derives templates from the preview client-side before calling it, but headless callers (MCP execute, direct API) don't — so any spec added that way silently lost its declared security schemes. The resulting integration had zero auth methods and its Add-connection modal rendered a dead 'No authentication' state with nowhere to paste a credential, even though the connections.createHandoff URL pointing at it was correct. - Move the preset-to-template derivation from AddOpenApiSource into the sdk (derive-auth.ts); the React add flow now imports it, so the two paths cannot drift. - addSpec falls back to that derivation when no template is passed, via a new previewSpecText (preview minus the URL fetch, so addSpec keeps its error/context channels). - Unit test: a bearer-scheme spec added with no template persists a derived apikey method and renders a pasted token as a bearer header. - New e2e scenario walks the whole agentic handoff: addSpec over MCP, createHandoff URL contract, browser paste into the add-account modal, then a live call through the connection verified against an emulated provider's request ledger.
Unified · Split
| @@ -0,0 +1,252 @@ | ||
| 1 | +// The agentic connect handoff: an agent adds an API over MCP, asks for a | |
| 2 | +// handoff URL (`coreTools.connections.createHandoff`), and the user opens that | |
| 3 | +// URL in a browser to paste the credential. This scenario walks the WHOLE | |
| 4 | +// path — the exact flow that failed in production with a "wrong / bad" URL — | |
| 5 | +// against a real emulated provider (resend.emulators.dev) so the failure | |
| 6 | +// point is captured with trace + screenshots instead of guessed at: | |
| 7 | +// | |
| 8 | +// 1. MCP `execute` → `openapi.addSpec` registers the emulated Resend API | |
| 9 | +// 2. MCP `execute` → `connections.createHandoff` returns the browser URL | |
| 10 | +// 3. The URL's origin must be THIS deployment (not a hardcoded host) | |
| 11 | +// 4. Playwright opens it: the Add connection modal must be open with a | |
| 12 | +// credential field, the emulator-minted API key is pasted and submitted | |
| 13 | +// 5. The saved connection is proven live: `execute` sends an email through | |
| 14 | +// the new tools and the emulator's request ledger shows the call | |
| 15 | +// arriving with the pasted bearer token | |
| 16 | +import { randomBytes } from "node:crypto"; | |
| 17 | + | |
| 18 | +import { expect } from "@effect/vitest"; | |
| 19 | +import { Effect } from "effect"; | |
| 20 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 21 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 22 | + | |
| 23 | +import { scenario } from "../src/scenario"; | |
| 24 | +import { Api, Browser, Mcp, Target } from "../src/services"; | |
| 25 | +import type { Identity, Target as TargetShape } from "../src/target"; | |
| 26 | +import type { BrowserSurface } from "../src/surfaces/browser"; | |
| 27 | +import type { McpSession } from "../src/surfaces/mcp"; | |
| 28 | + | |
| 29 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 30 | + | |
| 31 | +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | |
| 32 | + | |
| 33 | +const EMULATOR_BASE = "https://resend.emulators.dev"; | |
| 34 | + | |
| 35 | +/** Minimal Resend-shaped OpenAPI subset pointed at the emulator. Bearer auth | |
| 36 | + * mirrors the real provider (and the Sentry spec that failed in prod): the | |
| 37 | + * add-account modal must render a paste-a-token flow from a bare | |
| 38 | + * `http`/`bearer` security scheme, not just from an explicit apiKey | |
| 39 | + * authenticationTemplate. */ | |
| 40 | +const resendSpec = { | |
| 41 | + openapi: "3.0.3", | |
| 42 | + info: { title: "Resend (emulated)", version: "1.0.0" }, | |
| 43 | + paths: { | |
| 44 | + "/emails": { | |
| 45 | + post: { | |
| 46 | + operationId: "sendEmail", | |
| 47 | + tags: ["emails"], | |
| 48 | + requestBody: { | |
| 49 | + required: true, | |
| 50 | + content: { | |
| 51 | + "application/json": { | |
| 52 | + schema: { | |
| 53 | + type: "object", | |
| 54 | + properties: { | |
| 55 | + from: { type: "string" }, | |
| 56 | + to: { type: "string" }, | |
| 57 | + subject: { type: "string" }, | |
| 58 | + html: { type: "string" }, | |
| 59 | + }, | |
| 60 | + required: ["from", "to", "subject"], | |
| 61 | + }, | |
| 62 | + }, | |
| 63 | + }, | |
| 64 | + }, | |
| 65 | + responses: { "200": { description: "sent" } }, | |
| 66 | + }, | |
| 67 | + }, | |
| 68 | + }, | |
| 69 | + components: { | |
| 70 | + securitySchemes: { auth_token: { type: "http", scheme: "bearer" } }, | |
| 71 | + }, | |
| 72 | + security: [{ auth_token: [] }], | |
| 73 | +} as const; | |
| 74 | + | |
| 75 | +const addSpecCode = (slug: string) => ` | |
| 76 | +const added = await tools.executor.openapi.addSpec({ | |
| 77 | + spec: { kind: "blob", value: ${JSON.stringify(JSON.stringify(resendSpec))} }, | |
| 78 | + slug: ${JSON.stringify(slug)}, | |
| 79 | + baseUrl: ${JSON.stringify(EMULATOR_BASE)}, | |
| 80 | +}); | |
| 81 | +return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error }; | |
| 82 | +`; | |
| 83 | + | |
| 84 | +const createHandoffCode = (slug: string) => ` | |
| 85 | +const handoff = await tools.executor.coreTools.connections.createHandoff({ | |
| 86 | + integration: ${JSON.stringify(slug)}, | |
| 87 | + owner: "org", | |
| 88 | + label: "Resend (emulated)", | |
| 89 | +}); | |
| 90 | +return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error }; | |
| 91 | +`; | |
| 92 | + | |
| 93 | +// Selfhost scenarios share one workspace identity — leaked connections fail | |
| 94 | +// other scenarios' zero-state assertions, so remove everything this one made. | |
| 95 | +const removeConnectionsCode = (slug: string) => ` | |
| 96 | +const list = await tools.executor.coreTools.connections.list({}); | |
| 97 | +const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)}); | |
| 98 | +for (const c of mine) { | |
| 99 | + await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name }); | |
| 100 | +} | |
| 101 | +return { removed: mine.length }; | |
| 102 | +`; | |
| 103 | + | |
| 104 | +const sendEmailCode = (slug: string, subject: string) => ` | |
| 105 | +const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 }); | |
| 106 | +const path = found.items[0]?.path; | |
| 107 | +if (!path) return { ok: false, error: "no send tool found", items: found.items }; | |
| 108 | +let t = tools; | |
| 109 | +for (const seg of path.split(".")) t = t[seg]; | |
| 110 | +const sent = await t({ | |
| 111 | + body: { | |
| 112 | + from: "onboarding@example.com", | |
| 113 | + to: "e2e@example.com", | |
| 114 | + subject: ${JSON.stringify(subject)}, | |
| 115 | + html: "<p>connect-handoff e2e</p>", | |
| 116 | + }, | |
| 117 | +}); | |
| 118 | +return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error }; | |
| 119 | +`; | |
| 120 | + | |
| 121 | +/** Run `execute`, auto-approving a paused execution (policy elicitation) once, | |
| 122 | + * and parse the sandbox's JSON return value. */ | |
| 123 | +const executeJson = (session: McpSession, code: string) => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + let result = yield* session.call("execute", { code }); | |
| 126 | + if (result.text.includes("executionId:")) { | |
| 127 | + result = yield* session.approvePaused(result.text); | |
| 128 | + } | |
| 129 | + expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); | |
| 130 | + return JSON.parse(result.text) as Record<string, unknown>; | |
| 131 | + }); | |
| 132 | + | |
| 133 | +const mintEmulatorApiKey = Effect.promise(async () => { | |
| 134 | + const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, { | |
| 135 | + method: "POST", | |
| 136 | + headers: { "content-type": "application/json" }, | |
| 137 | + body: JSON.stringify({ type: "api-key" }), | |
| 138 | + }); | |
| 139 | + const body = (await response.json()) as { credential?: { token?: string } }; | |
| 140 | + const token = body.credential?.token; | |
| 141 | + if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`); | |
| 142 | + return token; | |
| 143 | +}); | |
| 144 | + | |
| 145 | +const fetchLedgerText = Effect.promise(async () => { | |
| 146 | + const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`); | |
| 147 | + return response.text(); | |
| 148 | +}); | |
| 149 | + | |
| 150 | +scenario( | |
| 151 | + "Connect · the agentic handoff URL opens this deployment's add-account flow and the pasted key works", | |
| 152 | + { timeout: 240_000 }, | |
| 153 | + Effect.gen(function* () { | |
| 154 | + const target = yield* Target; | |
| 155 | + const mcp = yield* Mcp; | |
| 156 | + const browser = yield* Browser; | |
| 157 | + const { client: makeApiClient } = yield* Api; | |
| 158 | + | |
| 159 | + const integration = unique("resendhf"); | |
| 160 | + const emailSubject = unique("connect-handoff"); | |
| 161 | + const apiKey = yield* mintEmulatorApiKey; | |
| 162 | + | |
| 163 | + const identity = yield* target.newIdentity(); | |
| 164 | + const session = mcp.session(identity); | |
| 165 | + const client = yield* makeApiClient(api, identity); | |
| 166 | + | |
| 167 | + yield* runScenario({ | |
| 168 | + target, | |
| 169 | + browser, | |
| 170 | + session, | |
| 171 | + identity, | |
| 172 | + integration, | |
| 173 | + emailSubject, | |
| 174 | + apiKey, | |
| 175 | + }).pipe( | |
| 176 | + // Best-effort cleanup even on failure: drop the created connection(s) | |
| 177 | + // over MCP, then the integration over the API. | |
| 178 | + Effect.ensuring( | |
| 179 | + Effect.gen(function* () { | |
| 180 | + yield* session.call("execute", { code: removeConnectionsCode(integration) }); | |
| 181 | + yield* client.openapi.removeSpec({ params: { slug: integration } }); | |
| 182 | + }).pipe(Effect.ignore), | |
| 183 | + ), | |
| 184 | + ); | |
| 185 | + }), | |
| 186 | +); | |
| 187 | + | |
| 188 | +const runScenario = (input: { | |
| 189 | + readonly target: TargetShape; | |
| 190 | + readonly browser: BrowserSurface; | |
| 191 | + readonly session: McpSession; | |
| 192 | + readonly identity: Identity; | |
| 193 | + readonly integration: string; | |
| 194 | + readonly emailSubject: string; | |
| 195 | + readonly apiKey: string; | |
| 196 | +}) => | |
| 197 | + Effect.gen(function* () { | |
| 198 | + const { target, browser, session, identity, integration, emailSubject, apiKey } = input; | |
| 199 | + | |
| 200 | + // 1. Agent registers the emulated provider over MCP. | |
| 201 | + const added = yield* executeJson(session, addSpecCode(integration)); | |
| 202 | + expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true); | |
| 203 | + | |
| 204 | + // 2. Agent asks for the browser handoff URL. | |
| 205 | + const handoff = yield* executeJson(session, createHandoffCode(integration)); | |
| 206 | + expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true); | |
| 207 | + const handoffUrl = String(handoff.url); | |
| 208 | + | |
| 209 | + // 3. The URL must target THIS deployment. (Production returned a URL the | |
| 210 | + // user called "wrong/bad" — pin the contract here.) | |
| 211 | + const parsed = new URL(handoffUrl); | |
| 212 | + expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe( | |
| 213 | + new URL(target.baseUrl).origin, | |
| 214 | + ); | |
| 215 | + expect(parsed.pathname).toBe(`/integrations/${integration}`); | |
| 216 | + expect(parsed.searchParams.get("addAccount")).toBe("1"); | |
| 217 | + | |
| 218 | + // 4. The user opens the handoff URL and pastes the credential. | |
| 219 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 220 | + await step("Open the handoff URL from the agent", async () => { | |
| 221 | + await page.goto(handoffUrl, { waitUntil: "networkidle" }); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + await step("The Add connection modal is open", async () => { | |
| 225 | + await page.getByRole("heading", { name: /Add connection/ }).waitFor({ timeout: 15_000 }); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + await step("Paste the emulator API key", async () => { | |
| 229 | + const credential = page.getByPlaceholder(/paste the value \/ token/i); | |
| 230 | + await credential.waitFor({ timeout: 15_000 }); | |
| 231 | + await credential.fill(apiKey); | |
| 232 | + }); | |
| 233 | + | |
| 234 | + await step("Submit Add connection", async () => { | |
| 235 | + await page.getByRole("button", { name: "Add connection", exact: true }).click(); | |
| 236 | + await page | |
| 237 | + .getByRole("heading", { name: /Add connection/ }) | |
| 238 | + .waitFor({ state: "hidden", timeout: 20_000 }); | |
| 239 | + }); | |
| 240 | + }); | |
| 241 | + | |
| 242 | + // 5. The connection is live: send an email through the new tools and see | |
| 243 | + // it arrive at the emulator with the pasted token. | |
| 244 | + const sent = yield* executeJson(session, sendEmailCode(integration, emailSubject)); | |
| 245 | + expect(sent.ok, `email sent through the pasted connection: ${JSON.stringify(sent)}`).toBe(true); | |
| 246 | + | |
| 247 | + const ledger = yield* fetchLedgerText; | |
| 248 | + expect( | |
| 249 | + ledger.includes(emailSubject), | |
| 250 | + "the emulator's request ledger recorded the call made through Executor", | |
| 251 | + ).toBe(true); | |
| 252 | + }); | |
| @@ -4,11 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines | ||
| 4 | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | |
| 7 | −import { | |
| 8 | − AuthTemplateSlug, | |
| 9 | − IntegrationSlug, | |
| 10 | − type OAuthAuthentication, | |
| 11 | −} from "@executor-js/sdk/shared"; | |
| 7 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 12 | 8 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | 9 | import { |
| 14 | 10 | slugifyNamespace, |
| @@ -48,9 +44,10 @@ import { · 33 unmodified lines | ||
| 48 | 44 | googleOpenApiPresets, |
| 49 | 45 | type GoogleOpenApiPreset, |
| 50 | 46 | } from "../sdk/google-presets"; |
| 51 | −import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; | |
| 52 | −import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types"; | |
| 47 | +import type { SpecPreview } from "../sdk/preview"; | |
| 48 | +import { type Authentication, type ServerInfo } from "../sdk/types"; | |
| 53 | 49 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; |
| 50 | +import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth"; | |
| 54 | 51 | |
| 55 | 52 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; |
| 56 | 53 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; |
| @@ -73,46 +70,6 @@ const googleBundleUrls = ( · 16 unmodified lines | ||
| 73 | 70 | return [...new Set([...fromPresets, ...customUrls])]; |
| 74 | 71 | }; |
| 75 | 72 | |
| 76 | −// --------------------------------------------------------------------------- | |
| 77 | −// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve | |
| 78 | −// them against the chosen base URL so the stored auth template is absolute. | |
| 79 | −// --------------------------------------------------------------------------- | |
| 80 | − | |
| 81 | −export function resolveOAuthUrl(url: string, baseUrl: string): string { | |
| 82 | − if (!url) return url; | |
| 83 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs | |
| 84 | − try { | |
| 85 | − new URL(url); | |
| 86 | − return url; | |
| 87 | − } catch { | |
| 88 | − if (!baseUrl) return url; | |
| 89 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs | |
| 90 | − try { | |
| 91 | − return new URL(url, baseUrl).toString(); | |
| 92 | − } catch { | |
| 93 | − return url; | |
| 94 | − } | |
| 95 | − } | |
| 96 | −} | |
| 97 | − | |
| 98 | −const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; | |
| 99 | − | |
| 100 | −const identityScopesForPreset = ( | |
| 101 | − identityScopes: OAuth2Preset["identityScopes"], | |
| 102 | −): readonly string[] => { | |
| 103 | − if (identityScopes === false) return []; | |
| 104 | − return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes; | |
| 105 | −}; | |
| 106 | − | |
| 107 | −const resolvedOAuthScopes = ( | |
| 108 | − apiScopes: Iterable<string>, | |
| 109 | − identityScopes: OAuth2Preset["identityScopes"], | |
| 110 | −): string[] => { | |
| 111 | − const merged = new Set(apiScopes); | |
| 112 | − for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); | |
| 113 | − return [...merged]; | |
| 114 | −}; | |
| 115 | − | |
| 116 | 73 | const isGoogleDiscoveryUrl = (url: string): boolean => { |
| 117 | 74 | const trimmed = url.trim(); |
| 118 | 75 | if (!URL.canParse(trimmed)) return false; |
| @@ -146,100 +103,9 @@ const specInputForAdd = (input: string) => { · 27 unmodified lines | ||
| 146 | 103 | : { kind: "blob" as const, value }; |
| 147 | 104 | }; |
| 148 | 105 | |
| 149 | −// --------------------------------------------------------------------------- | |
| 150 | −// Auth-template builders — turn a preview preset into the integration's stored | |
| 151 | −// `Authentication` template (v2). The header preset becomes an `apiKey` template | |
| 152 | −// whose secret header value renders the resolved credential via `variable(token)`; | |
| 153 | −// the oauth2 preset becomes an `oauth` template carrying the provider endpoints. | |
| 154 | −// | |
| 155 | −// Post-redesign the add flow no longer asks the user to pick ONE method: every | |
| 156 | −// spec-detected method is registered so the integration's detail hub can list | |
| 157 | −// them and Add-account can choose among them (P6: add without auth, connect | |
| 158 | −// later). | |
| 159 | −// --------------------------------------------------------------------------- | |
| 160 | − | |
| 161 | −const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => { | |
| 162 | − const label = preset.label.toLowerCase(); | |
| 163 | − if (headerName.toLowerCase() === "authorization") { | |
| 164 | − if (label.includes("bearer")) return "Bearer "; | |
| 165 | − if (label.includes("basic")) return "Basic "; | |
| 166 | − } | |
| 167 | − return undefined; | |
| 168 | −}; | |
| 169 | − | |
| 170 | −const apiKeyTemplateFromHeaderPreset = ( | |
| 171 | − preset: HeaderPreset, | |
| 172 | − slug: AuthTemplateSlug, | |
| 173 | −): APIKeyAuthentication => ({ | |
| 174 | − slug, | |
| 175 | − kind: "apikey", | |
| 176 | − // Every secret header shares the one credential input (the canonical | |
| 177 | − // `token`, stored as an absent placement variable). | |
| 178 | − placements: preset.secretHeaders.map((headerName) => { | |
| 179 | − const prefix = headerPrefix(preset, headerName); | |
| 180 | − return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) }; | |
| 181 | − }), | |
| 182 | −}); | |
| 183 | − | |
| 184 | −const oauthTemplateFromPreset = ( | |
| 185 | − preset: OAuth2Preset, | |
| 186 | − baseUrl: string, | |
| 187 | − slug: AuthTemplateSlug, | |
| 188 | − scopes: readonly string[], | |
| 189 | −): OAuthAuthentication => ({ | |
| 190 | − slug, | |
| 191 | − kind: "oauth2", | |
| 192 | − authorizationUrl: resolveOAuthUrl( | |
| 193 | − Option.getOrElse(preset.authorizationUrl, () => ""), | |
| 194 | − baseUrl, | |
| 195 | − ), | |
| 196 | − tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl), | |
| 197 | − scopes: [...scopes], | |
| 198 | −}); | |
| 199 | − | |
| 200 | 106 | const expandServerOptions = (server: ServerInfo) => |
| 201 | 107 | expandServerUrlOptions(server).map((value) => ({ value, label: value })); |
| 202 | 108 | |
| 203 | −const firstBaseUrlForPreview = (preview: SpecPreview): string => { | |
| 204 | − const firstServer = preview.servers[0]; | |
| 205 | − return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : ""; | |
| 206 | −}; | |
| 207 | − | |
| 208 | −// --------------------------------------------------------------------------- | |
| 209 | −// All spec-detected auth methods → the union of stored `Authentication` | |
| 210 | −// templates. Header presets become apiKey templates; each oauth2 preset becomes | |
| 211 | −// an oauth template (with its declared API scopes plus, for auth-code flows, | |
| 212 | −// the standard identity scopes). Slugs stay deterministic per method so the | |
| 213 | −// stored template is stable across previews of the same spec. Adding an | |
| 214 | −// integration whose slug already exists is blocked (see the existing-slug | |
| 215 | −// guard below); to add more auth, update the existing integration instead. | |
| 216 | −// --------------------------------------------------------------------------- | |
| 217 | − | |
| 218 | −const detectedAuthenticationTemplates = ( | |
| 219 | − headerPresets: readonly HeaderPreset[], | |
| 220 | − oauth2Presets: readonly OAuth2Preset[], | |
| 221 | − baseUrl: string, | |
| 222 | −): readonly Authentication[] => { | |
| 223 | − const templates: Authentication[] = []; | |
| 224 | − headerPresets.forEach((preset, index) => { | |
| 225 | − templates.push( | |
| 226 | − apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)), | |
| 227 | − ); | |
| 228 | − }); | |
| 229 | − for (const preset of oauth2Presets) { | |
| 230 | − const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes); | |
| 231 | − templates.push( | |
| 232 | − oauthTemplateFromPreset( | |
| 233 | − preset, | |
| 234 | − baseUrl, | |
| 235 | − AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), | |
| 236 | − scopes, | |
| 237 | − ), | |
| 238 | − ); | |
| 239 | − } | |
| 240 | − return templates; | |
| 241 | −}; | |
| 242 | − | |
| 243 | 109 | // --------------------------------------------------------------------------- |
| 244 | 110 | // Component — single progressive form. Post-redesign: preview → addSpec |
| 245 | 111 | // (register the integration catalog entry with ALL detected auth methods) → |
| @@ -0,0 +1,150 @@ | ||
| 1 | +// Spec-detected auth → stored `Authentication` templates, shared by every add | |
| 2 | +// path. The React add flow derives templates from the preview before calling | |
| 3 | +// `addSpec`; `addSpec` itself falls back to the same derivation when the | |
| 4 | +// caller omits `authenticationTemplate` (the agentic/API path has no client | |
| 5 | +// to do it). One implementation so the web UI and headless callers cannot | |
| 6 | +// drift: an integration added over MCP gets the same auth methods the add | |
| 7 | +// page would have produced. | |
| 8 | +import * as Option from "effect/Option"; | |
| 9 | + | |
| 10 | +import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared"; | |
| 11 | + | |
| 12 | +import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview"; | |
| 13 | +import type { APIKeyAuthentication, Authentication } from "./types"; | |
| 14 | +import { expandServerUrlOptions } from "./openapi-utils"; | |
| 15 | + | |
| 16 | +// --------------------------------------------------------------------------- | |
| 17 | +// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve | |
| 18 | +// them against the chosen base URL so the stored auth template is absolute. | |
| 19 | +// --------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +export function resolveOAuthUrl(url: string, baseUrl: string): string { | |
| 22 | + if (!url) return url; | |
| 23 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs | |
| 24 | + try { | |
| 25 | + new URL(url); | |
| 26 | + return url; | |
| 27 | + } catch { | |
| 28 | + if (!baseUrl) return url; | |
| 29 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs | |
| 30 | + try { | |
| 31 | + return new URL(url, baseUrl).toString(); | |
| 32 | + } catch { | |
| 33 | + return url; | |
| 34 | + } | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; | |
| 39 | + | |
| 40 | +const identityScopesForPreset = ( | |
| 41 | + identityScopes: OAuth2Preset["identityScopes"], | |
| 42 | +): readonly string[] => { | |
| 43 | + if (identityScopes === false) return []; | |
| 44 | + return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +export const resolvedOAuthScopes = ( | |
| 48 | + apiScopes: Iterable<string>, | |
| 49 | + identityScopes: OAuth2Preset["identityScopes"], | |
| 50 | +): string[] => { | |
| 51 | + const merged = new Set(apiScopes); | |
| 52 | + for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); | |
| 53 | + return [...merged]; | |
| 54 | +}; | |
| 55 | + | |
| 56 | +// --------------------------------------------------------------------------- | |
| 57 | +// Auth-template builders — turn a preview preset into the integration's stored | |
| 58 | +// `Authentication` template (v2). The header preset becomes an `apiKey` template | |
| 59 | +// whose secret header value renders the resolved credential via `variable(token)`; | |
| 60 | +// the oauth2 preset becomes an `oauth` template carrying the provider endpoints. | |
| 61 | +// --------------------------------------------------------------------------- | |
| 62 | + | |
| 63 | +const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => { | |
| 64 | + const label = preset.label.toLowerCase(); | |
| 65 | + if (headerName.toLowerCase() === "authorization") { | |
| 66 | + if (label.includes("bearer")) return "Bearer "; | |
| 67 | + if (label.includes("basic")) return "Basic "; | |
| 68 | + } | |
| 69 | + return undefined; | |
| 70 | +}; | |
| 71 | + | |
| 72 | +const apiKeyTemplateFromHeaderPreset = ( | |
| 73 | + preset: HeaderPreset, | |
| 74 | + slug: AuthTemplateSlug, | |
| 75 | +): APIKeyAuthentication => ({ | |
| 76 | + slug, | |
| 77 | + kind: "apikey", | |
| 78 | + // Every secret header shares the one credential input (the canonical | |
| 79 | + // `token`, stored as an absent placement variable). | |
| 80 | + placements: preset.secretHeaders.map((headerName) => { | |
| 81 | + const prefix = headerPrefix(preset, headerName); | |
| 82 | + return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) }; | |
| 83 | + }), | |
| 84 | +}); | |
| 85 | + | |
| 86 | +const oauthTemplateFromPreset = ( | |
| 87 | + preset: OAuth2Preset, | |
| 88 | + baseUrl: string, | |
| 89 | + slug: AuthTemplateSlug, | |
| 90 | + scopes: readonly string[], | |
| 91 | +): OAuthAuthentication => ({ | |
| 92 | + slug, | |
| 93 | + kind: "oauth2", | |
| 94 | + authorizationUrl: resolveOAuthUrl( | |
| 95 | + Option.getOrElse(preset.authorizationUrl, () => ""), | |
| 96 | + baseUrl, | |
| 97 | + ), | |
| 98 | + tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl), | |
| 99 | + scopes: [...scopes], | |
| 100 | +}); | |
| 101 | + | |
| 102 | +// --------------------------------------------------------------------------- | |
| 103 | +// All spec-detected auth methods → the union of stored `Authentication` | |
| 104 | +// templates. Header presets become apiKey templates; each oauth2 preset becomes | |
| 105 | +// an oauth template (with its declared API scopes plus, for auth-code flows, | |
| 106 | +// the standard identity scopes). Slugs stay deterministic per method so the | |
| 107 | +// stored template is stable across previews of the same spec. | |
| 108 | +// --------------------------------------------------------------------------- | |
| 109 | + | |
| 110 | +export const detectedAuthenticationTemplates = ( | |
| 111 | + headerPresets: readonly HeaderPreset[], | |
| 112 | + oauth2Presets: readonly OAuth2Preset[], | |
| 113 | + baseUrl: string, | |
| 114 | +): readonly Authentication[] => { | |
| 115 | + const templates: Authentication[] = []; | |
| 116 | + headerPresets.forEach((preset, index) => { | |
| 117 | + templates.push( | |
| 118 | + apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)), | |
| 119 | + ); | |
| 120 | + }); | |
| 121 | + for (const preset of oauth2Presets) { | |
| 122 | + const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes); | |
| 123 | + templates.push( | |
| 124 | + oauthTemplateFromPreset( | |
| 125 | + preset, | |
| 126 | + baseUrl, | |
| 127 | + AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), | |
| 128 | + scopes, | |
| 129 | + ), | |
| 130 | + ); | |
| 131 | + } | |
| 132 | + return templates; | |
| 133 | +}; | |
| 134 | + | |
| 135 | +export const firstBaseUrlForPreview = (preview: SpecPreview): string => { | |
| 136 | + const firstServer = preview.servers[0]; | |
| 137 | + return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : ""; | |
| 138 | +}; | |
| 139 | + | |
| 140 | +/** The fallback `addSpec` uses when no explicit template was passed: every | |
| 141 | + * spec-detected method, resolved against the integration's base URL. */ | |
| 142 | +export const deriveAuthenticationTemplateFromPreview = ( | |
| 143 | + preview: SpecPreview, | |
| 144 | + baseUrl: string | undefined, | |
| 145 | +): readonly Authentication[] => | |
| 146 | + detectedAuthenticationTemplates( | |
| 147 | + preview.headerPresets, | |
| 148 | + preview.oauth2Presets, | |
| 149 | + baseUrl ?? firstBaseUrlForPreview(preview), | |
| 150 | + ); | |
| @@ -506,6 +506,59 @@ describe("OpenAPI Plugin", () => { · 505 unmodified lines | ||
| 506 | 506 | ), |
| 507 | 507 | ); |
| 508 | 508 | |
| 509 | + it.effect("addSpec derives auth methods from the spec's security schemes by default", () => | |
| 510 | + Effect.scoped( | |
| 511 | + Effect.gen(function* () { | |
| 512 | + const server = yield* servePluginTestApi(); | |
| 513 | + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); | |
| 514 | + | |
| 515 | + // The spec declares bearer auth; the caller passes NO template — the | |
| 516 | + // agentic add path (MCP/API) does exactly this. Without server-side | |
| 517 | + // derivation the integration is auth-less and its Add-connection | |
| 518 | + // modal is a dead end (e2e/scenarios/connect-handoff.test.ts). | |
| 519 | + // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON | |
| 520 | + const spec = JSON.parse(server.specJson) as Record<string, unknown>; | |
| 521 | + const specWithBearer = JSON.stringify({ | |
| 522 | + ...spec, | |
| 523 | + components: { | |
| 524 | + ...(spec.components as Record<string, unknown> | undefined), | |
| 525 | + securitySchemes: { auth_token: { type: "http", scheme: "bearer" } }, | |
| 526 | + }, | |
| 527 | + security: [{ auth_token: [] }], | |
| 528 | + }); | |
| 529 | + | |
| 530 | + yield* executor.openapi.addSpec({ | |
| 531 | + spec: { kind: "blob", value: specWithBearer }, | |
| 532 | + slug: "derived_auth_api", | |
| 533 | + baseUrl: server.baseUrl, | |
| 534 | + }); | |
| 535 | + | |
| 536 | + // The derived template is persisted on the integration… | |
| 537 | + const config = yield* executor.openapi.getConfig("derived_auth_api"); | |
| 538 | + const derived = config?.authenticationTemplate ?? []; | |
| 539 | + expect(derived.map((a) => ({ slug: String(a.slug), kind: a.kind }))).toEqual([ | |
| 540 | + { slug: "apikey-0", kind: "apikey" }, | |
| 541 | + ]); | |
| 542 | + | |
| 543 | + // …and it renders a pasted credential as a bearer Authorization header. | |
| 544 | + yield* executor.connections.create({ | |
| 545 | + owner: "org", | |
| 546 | + name: ConnectionName.make("main"), | |
| 547 | + integration: IntegrationSlug.make("derived_auth_api"), | |
| 548 | + template: AuthTemplateSlug.make("apikey-0"), | |
| 549 | + value: "pasted-token-xyz", | |
| 550 | + }); | |
| 551 | + const result = unwrapInvocation( | |
| 552 | + yield* executor.execute( | |
| 553 | + ToolAddress.make("tools.derived_auth_api.org.main.items.echoHeaders"), | |
| 554 | + {}, | |
| 555 | + ), | |
| 556 | + ).data as { authorization?: string }; | |
| 557 | + expect(result.authorization).toBe("Bearer pasted-token-xyz"); | |
| 558 | + }), | |
| 559 | + ), | |
| 560 | + ); | |
| 561 | + | |
| 509 | 562 | it.effect("removeSpec cleans up the integration and its tools", () => |
| 510 | 563 | Effect.scoped( |
| 511 | 564 | Effect.gen(function* () { |
| @@ -41,7 +41,8 @@ import { · 40 unmodified lines | ||
| 41 | 41 | import { extract } from "./extract"; |
| 42 | 42 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; |
| 43 | 43 | import { annotationsForOperation, invokeWithLayer } from "./invoke"; |
| 44 | −import { previewSpec, type SpecPreview } from "./preview"; | |
| 44 | +import { previewSpec, previewSpecText, type SpecPreview } from "./preview"; | |
| 45 | +import { deriveAuthenticationTemplateFromPreview } from "./derive-auth"; | |
| 45 | 46 | import { openApiPresets } from "./presets"; |
| 46 | 47 | import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; |
| 47 | 48 | import type { Authentication } from "./types"; |
| @@ -713,6 +714,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 665 unmodified lines | ||
| 713 | 714 | const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); |
| 714 | 715 | const compiled = yield* compileSpec(resolved.specText); |
| 715 | 716 | |
| 717 | + // No explicit template and nothing converter-derived → fall back to | |
| 718 | + // the spec's own declared auth (same derivation the add page runs on | |
| 719 | + // its preview). Without this, headless callers (MCP, API) silently | |
| 720 | + // produce auth-less integrations whose Add-connection modal is a | |
| 721 | + // dead end — see e2e/scenarios/connect-handoff.test.ts. | |
| 722 | + const derivedAuthenticationTemplate = | |
| 723 | + config.authenticationTemplate || resolved.authenticationTemplate | |
| 724 | + ? undefined | |
| 725 | + : deriveAuthenticationTemplateFromPreview( | |
| 726 | + yield* previewSpecText(resolved.specText), | |
| 727 | + config.baseUrl ?? resolved.baseUrl, | |
| 728 | + ); | |
| 729 | + | |
| 716 | 730 | const slug = IntegrationSlug.make(config.slug); |
| 717 | 731 | |
| 718 | 732 | // Block re-adding an existing slug. The core `integrations.register` |
| @@ -742,14 +756,17 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 23 unmodified lines | ||
| 742 | 756 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 743 | 757 | // Prefer the caller's explicit template; otherwise adopt the one the |
| 744 | 758 | // Google Discovery converter derived from the spec (the bundle add |
| 745 | − // path relies on this — it has no preview to detect auth from). | |
| 759 | + // path relies on this — it has no preview to detect auth from); | |
| 760 | + // otherwise derive from the spec's declared security schemes. | |
| 746 | 761 | ...(config.authenticationTemplate |
| 747 | 762 | ? { |
| 748 | 763 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), |
| 749 | 764 | } |
| 750 | 765 | : resolved.authenticationTemplate |
| 751 | 766 | ? { authenticationTemplate: resolved.authenticationTemplate } |
| 752 | − : {}), | |
| 767 | + : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 | |
| 768 | + ? { authenticationTemplate: derivedAuthenticationTemplate } | |
| 769 | + : {}), | |
| 753 | 770 | }; |
| 754 | 771 | |
| 755 | 772 | // The spec blob is written OUTSIDE the transaction: it's |
| @@ -374,10 +374,9 @@ const collectTags = (result: ExtractionResult): string[] => { · 373 unmodified lines | ||
| 374 | 374 | // Public API |
| 375 | 375 | // --------------------------------------------------------------------------- |
| 376 | 376 | |
| 377 | −/** Preview an OpenAPI spec — extract metadata without registering anything. | |
| 378 | − * Accepts either a URL or raw JSON/YAML text. */ | |
| 379 | −export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) { | |
| 380 | − const specText = yield* resolveSpecText(input); | |
| 377 | +/** Preview already-resolved spec text — extract metadata without registering | |
| 378 | + * anything and without any HTTP dependency. */ | |
| 379 | +export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (specText: string) { | |
| 381 | 380 | const doc: ParsedDocument = yield* parse(specText); |
| 382 | 381 | const result = yield* extract(doc); |
| 383 | 382 | |
| @@ -417,3 +416,10 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: st · 33 unmodified lines | ||
| 417 | 416 | oauth2Presets: buildOAuth2Presets(securitySchemes), |
| 418 | 417 | }); |
| 419 | 418 | }); |
| 419 | + | |
| 420 | +/** Preview an OpenAPI spec — extract metadata without registering anything. | |
| 421 | + * Accepts either a URL or raw JSON/YAML text. */ | |
| 422 | +export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) { | |
| 423 | + const specText = yield* resolveSpecText(input); | |
| 424 | + return yield* previewSpecText(specText); | |
| 425 | +}); | |