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 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { IntegrationSlug } from "@executor-js/sdk/shared"; |
| 8 | AuthTemplateSlug, | ||
| 9 | IntegrationSlug, | ||
| 10 | type OAuthAuthentication, | ||
| 11 | } from "@executor-js/sdk/shared"; | ||
| 12 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 8 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | import { | 9 | import { |
| 14 | slugifyNamespace, | 10 | slugifyNamespace, |
| @@ -48,9 +44,10 @@ import { · 33 unmodified lines | |||
| 48 | googleOpenApiPresets, | 44 | googleOpenApiPresets, |
| 49 | type GoogleOpenApiPreset, | 45 | type GoogleOpenApiPreset, |
| 50 | } from "../sdk/google-presets"; | 46 | } from "../sdk/google-presets"; |
| 51 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; | 47 | import type { SpecPreview } from "../sdk/preview"; |
| 52 | import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types"; | 48 | import { type Authentication, type ServerInfo } from "../sdk/types"; |
| 53 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; | 49 | import { expandServerUrlOptions } from "../sdk/openapi-utils"; |
| 50 | import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth"; | ||
| 54 | 51 | ||
| 55 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; | 52 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; |
| 56 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | 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 | return [...new Set([...fromPresets, ...customUrls])]; | 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 | const isGoogleDiscoveryUrl = (url: string): boolean => { | 73 | const isGoogleDiscoveryUrl = (url: string): boolean => { |
| 117 | const trimmed = url.trim(); | 74 | const trimmed = url.trim(); |
| 118 | if (!URL.canParse(trimmed)) return false; | 75 | if (!URL.canParse(trimmed)) return false; |
| @@ -146,100 +103,9 @@ const specInputForAdd = (input: string) => { · 27 unmodified lines | |||
| 146 | : { kind: "blob" as const, value }; | 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 | const expandServerOptions = (server: ServerInfo) => | 106 | const expandServerOptions = (server: ServerInfo) => |
| 201 | expandServerUrlOptions(server).map((value) => ({ value, label: value })); | 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 | // Component — single progressive form. Post-redesign: preview → addSpec | 110 | // Component — single progressive form. Post-redesign: preview → addSpec |
| 245 | // (register the integration catalog entry with ALL detected auth methods) → | 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 | it.effect("removeSpec cleans up the integration and its tools", () => | 562 | it.effect("removeSpec cleans up the integration and its tools", () => |
| 510 | Effect.scoped( | 563 | Effect.scoped( |
| 511 | Effect.gen(function* () { | 564 | Effect.gen(function* () { |
| @@ -41,7 +41,8 @@ import { · 40 unmodified lines | |||
| 41 | import { extract } from "./extract"; | 41 | import { extract } from "./extract"; |
| 42 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; | 42 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; |
| 43 | import { annotationsForOperation, invokeWithLayer } from "./invoke"; | 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 | import { openApiPresets } from "./presets"; | 46 | import { openApiPresets } from "./presets"; |
| 46 | import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; | 47 | import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; |
| 47 | import type { Authentication } from "./types"; | 48 | import type { Authentication } from "./types"; |
| @@ -713,6 +714,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 665 unmodified lines | |||
| 713 | const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); | 714 | const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); |
| 714 | const compiled = yield* compileSpec(resolved.specText); | 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 | const slug = IntegrationSlug.make(config.slug); | 730 | const slug = IntegrationSlug.make(config.slug); |
| 717 | 731 | ||
| 718 | // Block re-adding an existing slug. The core `integrations.register` | 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 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), | 756 | ...(config.queryParams ? { queryParams: config.queryParams } : {}), |
| 743 | // Prefer the caller's explicit template; otherwise adopt the one the | 757 | // Prefer the caller's explicit template; otherwise adopt the one the |
| 744 | // Google Discovery converter derived from the spec (the bundle add | 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 | ...(config.authenticationTemplate | 761 | ...(config.authenticationTemplate |
| 747 | ? { | 762 | ? { |
| 748 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), | 763 | authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), |
| 749 | } | 764 | } |
| 750 | : resolved.authenticationTemplate | 765 | : resolved.authenticationTemplate |
| 751 | ? { authenticationTemplate: resolved.authenticationTemplate } | 766 | ? { authenticationTemplate: resolved.authenticationTemplate } |
| 752 | : {}), | 767 | : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 |
| 768 | ? { authenticationTemplate: derivedAuthenticationTemplate } | ||
| 769 | : {}), | ||
| 753 | }; | 770 | }; |
| 754 | 771 | ||
| 755 | // The spec blob is written OUTSIDE the transaction: it's | 772 | // The spec blob is written OUTSIDE the transaction: it's |
| @@ -374,10 +374,9 @@ const collectTags = (result: ExtractionResult): string[] => { · 373 unmodified lines | |||
| 374 | // Public API | 374 | // Public API |
| 375 | // --------------------------------------------------------------------------- | 375 | // --------------------------------------------------------------------------- |
| 376 | 376 | ||
| 377 | /** Preview an OpenAPI spec — extract metadata without registering anything. | 377 | /** Preview already-resolved spec text — extract metadata without registering |
| 378 | * Accepts either a URL or raw JSON/YAML text. */ | 378 | * anything and without any HTTP dependency. */ |
| 379 | export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) { | 379 | export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (specText: string) { |
| 380 | const specText = yield* resolveSpecText(input); | ||
| 381 | const doc: ParsedDocument = yield* parse(specText); | 380 | const doc: ParsedDocument = yield* parse(specText); |
| 382 | const result = yield* extract(doc); | 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 | oauth2Presets: buildOAuth2Presets(securitySchemes), | 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 | }); | ||