History

Registry-powered onboarding: full-page picker, quick add, spec-carried auth (#1851)

* Add standalone reproduction of the current add-integration flow * Add reworked add-integration flow alongside the current one * Drop the sidebar, deep-link the reworked flow, add the custom-URL path * Restore the console sidebar in the reworked flow, drop the notes panel * Full-page integration picker: one row per service, add/added, focused search * Name each row by its surface instead of grouping by service * Match added state on domain and kind, not domain alone * Identify rows by registry surface, not by domain * Source the picker from the registry only * Name the detected auth method and show the provider's key instructions * Load integration icons eagerly so they render in the console's scroll container * Give the sidebar the same icon inputs as the integrations list * Expire cached catalog searches instead of holding them for the session * Revalidate catalog requests so registry fixes are not held by a stale browser cache * Restore preset integrations in the picker; the registry does not cover them * Browse picker: named catalog rows, per-surface identity The registry now returns named product rows (many products on one vendor domain, like Microsoft Graph's workloads), so rows title by the registry name when present instead of prettifying the domain, and row keys carry the surface slug — one domain no longer collapses to one row per kind. Named rows also make the preset dedupe effective for products whose preset shares the name. The catalog origin is overridable via VITE_PUBLIC_INTEGRATIONS_SH_ORIGIN for testing against a local registry. The catalog-search scenario clicks the row's Add button, which is where the action lives since the add/added rework. * Detect Google Discovery URLs in the add flow; registry identity on preset rows Pasting a Discovery URL failed as 'not OpenAPI' while the identical URL worked through a preset, because the spec-format adapter only engaged when a preset supplied specFormat. Adapters can now recognize URLs, and resolution falls back to detection when no explicit format is given. A preset row and a catalog row for the same product no longer read as two different kinds of thing: the row shows the registry's identity (the domain) like every other result, and the preset supplies the add flow. * Seed GraphQL auth methods from the registry's declared credential The registry now says how a GraphQL surface authenticates — credential kind, header pattern, and a note — because a GraphQL endpoint has no spec document to say it itself. Adding Linear from the picker seeds the auth-method editor with 'Authorization: {api_key}' (no Bearer prefix, which is the whole point) and shows the note; the same facts ride along when a preset owns the add flow. The detected-method footer says 'Declared by this API' rather than 'Read from the spec', which was untrue for spec-less surfaces. * Registry auth for OpenAPI and preset add flows; product-key matching GitHub's official OpenAPI description declares no securitySchemes, so the add flow offered OAuth (from the preset template) and nothing else, while a PAT bearer header is how most calls actually authenticate. The registry's declared placement now seeds an API key method alongside detected OAuth; a spec-declared key method still wins over the registry's version of the same fact. Preset-to-catalog matching moves from rendered titles to a product key (normalized name plus kind), so 'GitHub REST' and the registry's 'GitHub API' count as one product — titles and domains both proved unreliable joins. MCP surfaces carry their auth facts too. * MCP add flow consumes registry auth facts The probe stays authoritative when it lands: its OAuth and open-server verdicts are unchanged, but the registry's declared header pattern now replaces the generic Bearer guess on a bare 401, rides alongside detected OAuth (GitHub's MCP takes a PAT bearer in clients without OAuth support), and stands in entirely when there is no probe result — an authless server is a fact the registry already knew. * Prefill spec overrides from the registry Registry surfaces can carry RFC 6902 JSON Patch for a spec; the picker passes it through and the OpenAPI add flow prefills its overrides editor with it, so the patch is visible and editable rather than applied invisibly. A preset's own overrides win, and a user's draft wins over both. First use: Neon's spec declares the Neon console's session cookies as security schemes — the registry's patch removes them, and the add flow now offers exactly the real Bearer method. * Detect query-located API keys; drop unrenderable auth strategies An apiKey with in=query (Viator's legacy ?apiKey=) reached the add flow as a method with a '(query)' label and zero placements — an empty, unfillable card that would register as a no-auth method. Query keys now become real query placements end to end (the invoke layer always knew how to render them). Cookie-located schemes stop producing methods entirely: placements carry header|query only, and a cookie scheme is usually the vendor console's own session, not a mintable credential. * Infinite scroll for the picker's catalog list The registry search API now pages (offset over its full ranked list), so the picker keeps appending as you scroll instead of stopping at the browse head: a sentinel near the list end fetches the next page a screen early, appended pages are keyed to the query so a keystroke discards them, and rows that slide across page boundaries while the live index moves are deduped. A failed page fetch is retried the next time the sentinel scrolls into view rather than ending the list. * Picker results as a card grid Three cards per row on desktop, two on tablet, one on mobile. Each card carries the icon, Add/Added state, name, mono domain, and a two-line description clamp; skeletons and the loading-more placeholders match the card shape, and the infinite-scroll sentinel spans the full grid width. * Only offer connectable catalog surfaces; failures render on the card The registry can know a surface exists without knowing where it lives (conjur.org's OpenAPI has no recorded spec URL); such surfaces rendered an Add card whose click could only fail — and the failure appeared as a page-top alert, three screens away from the card that was clicked. Surfaces without a connect target are no longer offered, and the remaining resolve-on-click path (kinds-only entries from older registries) reports its failure on the card itself. * Preset cards recognise adds made through them Adding Stripe MCP produced slug stripe_mcp (the add flow derives its namespace from 'Stripe' + the surface word), but the card's added check only tried the preset's bare name. The rendered title is now a candidate too, and preset test ids carry the plugin key — two plugins both naming a preset 'stripe' produced duplicate ids. * Remove the Petstore and Emulate MCP demo presets Demo fixtures are not integrations anyone ships with; both existed to demo executor, and the picker is not the place for that. * The registry is the picker; presets shrink to local-process cards Every connectable card now comes from the registry, which carries the identity, spec or endpoint, auth facts, and overrides; the spec itself declares how to authenticate, and a deployment's first-party OAuth clients bind at connect time by endpoint host. Presets with a connect URL stop being cards — only local-process servers (Chrome DevTools over stdio) remain, since they have no registry representation yet. The per-query identity borrowing, product-key preset enrichment, and its flickering domain chips all go with it. Registry-hosted spec URLs resolve against the configured catalog origin so a local registry is self-consistent before a deploy. * Update integration onboarding scenarios * Slot local-process cards alphabetically instead of pinning them first * Catalog origin is always production integrations.sh The dev-time origin override and its asset-URL rewriting existed only because the registry's spec mirrors were not deployed yet; they are. * Cards use the registry's product marks when curated A curated surface can carry a hand-picked icon (Google Calendar's own logo, Outlook's); the card prefers it over the domain favicon, which rendered every Google product as the G and every Microsoft product as the four squares. * Survive MCP servers that echo the proposed protocol revision Walmart's MCP answers any proposed protocolVersion affirmatively — including 2026-07-28 — while emitting 2024-era results, which the modern client rightly rejects (SdkError INVALID_RESULT), so the add flow reported 'couldn't discover tools' for a server that works fine. Discovery now detects that exact signature (modern-era connection, INVALID_RESULT) and retries once with legacy negotiation; the probe reports which handshake worked and the add flow pins versionNegotiation: legacy on the integration so refreshes and tool calls use the same one. Tool-discovery failures also stop swallowing the underlying SDK error message. * Local-process cards wait for the catalog Painting a lone Chrome DevTools card while the registry loads read as a one-item catalog, then reflowed; the skeleton grid now owns the loading paint. When the registry is unreachable the local cards still render, so the page degrades to what works. * Fix fast-scroll paging; move start-from-scratch above the list Two paging bugs: a fast scroll fired the sentinel before React committed loadingMore, so parallel fetches hit the same offset and corrupted the next one (now a synchronous in-flight ref); and both the offset and the exhaustion check counted FILTERED entries while the server pages by raw index — dropping CLI-only rows made full pages look short, ending the scroll mid-catalog (now keyed on raw page size). Start-from-scratch sat below an endless list, which has no reachable bottom; it is now one quiet chip row under the facets, where the label carries the action and the chips stay bare format names. * Remove the kind facet chips Kind was the only facet and it earned its row less than it cost: the surface is already in every card's name, and the search covers all kinds. The paste-URL hint moves onto the start-from-scratch line. * Stop the search results jumping Three causes. Typing toward a URL flipped the grid back to the browse head mid-keystroke ('stripe.co' parses as a URL), so the list now freezes on the last real query while only the detect hint changes. Stale results swapped wholesale with no signal when the debounced response landed; the grid now dims while a new query loads. And cards without descriptions were shorter, so grid rows resized between result sets; cards hold a uniform minimum height. Debounce drops to 150ms. * Results stay at full opacity while a new query loads * Port the multiplayer shell to the browse page Upstream gave the multiplayer shell a connect-dialog affordance while this branch replaced the dialog with the full-page picker; the sidebar action navigates there instead. * Fall back to the service-hosted Google Discovery URL Normalization canonicalizes a service-hosted Discovery URL onto the central directory, which is right for identity but not universally fetchable: the directory does not list every service. Google Ads answers only on googleads.googleapis.com, so a URL a user pasted and that works returned HTTP 404 through the adapter. The fetch now falls back to the host the caller named rather than growing an allowlist of every such service. * Point the Axiom preset at a live spec URL axiom.co/docs/restapi/versions/v2.json 404s; Axiom publishes the spec in its docs repo. Verified through the add flow: 78 tools, api key and oauth2 methods detected. * Detect Microsoft Graph sources from the URL The Graph adapter only engaged when a preset supplied specFormat, so a hand-pasted Graph URL fell to the plain OpenAPI path and died on the 43 MB monolith. It now recognizes the published monolith and executor's slice assets, with or without a #preset selector — the same URL detection the Google Discovery adapter already had. Already-sliced hosted specs stay on the plain path, since they parse there fine. * One-click add from the picker Registry rows already carry the URL and auth indicators, so clicking Add now registers in place: the card flips to a View link into the integration's hub, the user stays on the picker, and several adds can run at once. Each plugin exposes a headless useQuickAdd on its client plugin — MCP probes and declares the detected methods (sharing the add page's seed policy via mcpDetectedAuthSeeds), OpenAPI lets the spec carry auth, GraphQL declares the registry's header pattern. A quick add that cannot complete falls back to the configuration screen prefilled with the same facts. Also fixes two latent bugs this surfaced: the probe API response schema stripped versionNegotiation, so the legacy-protocol pin never survived the HTTP boundary; and per-row state keyed on domain|kind, which conflated two same-kind products on one domain (Google Photos Library and Picker). * Added integrations lead the picker list What you already have floats to the front of the results, where its View state is worth seeing. Fresh quick-adds hold their place for the rest of the session — teleporting the card the user just clicked is the jumping this page keeps having to unlearn — and float on the next visit. * Every added integration leads the picker The float now covers the whole catalog, not just loaded rows: installed integrations whose registry rows sit below the loaded page get cards synthesized from the integration record, deduplicated against loaded rows by claimed slug. Added-ness also matches the namespaces the classic add pages derive (domain, display name), not only the registry slug. The check icon goes; View carries the state. * Picker cards share the sidebar's icon cascade; only the grid scrolls Synthetic installed cards ran their own letter-mark fallback while the sidebar resolved real icons for the same integrations — two resolvers for one integration. They now run the identical cascade: preset icon by exact identity, else the logo proxy from the integration's own URL. The page also stops scrolling as a whole: the title, search, and scratch row pin, and the results grid scrolls in its own container — an endless list makes the page scrollbar meaningless and drags the search box off screen. * Stale search results stop impersonating live ones The hook held the previous query's entries while the next loaded — by design, to avoid flashing — but nothing recorded which query they belonged to, so after the loading dim was removed a calendar search showed Gmail cards at full opacity. Entries now carry their request key and the hook exposes stale; the page keeps held rows only while every token of the live text matches their name or domain, so refinements keep their rows and new words drop the old ones instantly. Also: extra pages reset when the request changes, which unwedges the sentinel after a mid-flight query switch (the in-flight guard held it until a scroll), and stops minutes-old extras reviving verbatim under a fresh first page. * Per-checkout portless name for the dev server Every checkout registered the dev server as the same portless name on the machine-shared multiplex proxy, so the proxy's duplicate-app selector listed every parallel checkout's server under one host — and its per-entry Kill button SIGTERMed whichever sibling you clicked. The name now derives from the checkout directory, scoping the selector (and its Kill button) to that checkout's own processes. * Label credential guidance as AI-generated registry data The setup text reads like the provider's own docs but is machine-written registry data; a mono provenance line names the source and links the domain's integrations.sh page so the reader weights it accordingly. * Quick add pulls every knowledge source; rate the AI guidance The GitHub registry row quick-added with no auth methods: its spec declares no security at all, the preset's OAuth endpoints only rode the preset path, and the registry's PAT header pattern was dropped. The OpenAPI quick add now pulls both across — a URL-matched preset contributes its OAuth template, spec overrides, spec format, family, and health check; the registry's header pattern becomes a declared API-key method — while spec-derived defaults still apply when neither source knows anything. The AI-generated guidance line also gains thumbs: votes land in analytics keyed by domain and credential label, so wrong registry guidance is findable instead of silently misleading. * Open the connection modal on a key method, not OAuth OAuth needs a registered app (or a DCR round-trip) before Connect does anything, so an integration declaring both greeted most users with "Register app" — a dead end — while the working method sat one tab over. The modal now opens on the first non-OAuth method; OAuth stays one click away, and a handoff-specified template still wins. * Record the product domain on quick-added OpenAPI integrations Credential guidance and favicons key on the integration's display URL, which falls back to the spec URL when no base URL is stored — and a registry quick add stores neither, so a spec hosted on a code host sent guidance lookups to raw.githubusercontent.com instead of the product. The registry row already names the product's domain; quick add now records it as displayDomain and display derivation prefers it. The field is declared at all three hops (extension input, HTTP payload, handler map) — the handler's explicit field list is where such fields silently die. * Fix the review findings on the quick-add group Selection carries the CLICKED surface instead of re-finding by kind, and the kinds-only fallback disambiguates same-kind products by the row's product name against surface slugs — two same-kind products on one domain no longer merge one card's URL with the other's slug, auth, and overrides. The OpenAPI quick add replicates the full add page's method policy: preset OAuth wins outright; otherwise every spec-detected method is preserved (one preview call, only when the registry declared a header) and the registry key is appended only when the spec has no key method. The MCP remote add carries versionNegotiation through the payload schema and handler map, with a round-trip test — the legacy pin previously died at the HTTP boundary. URL presets hide only when registry-listed (built-ins and the provider catalogs declare it), so a deployment's custom preset keeps its card, and quick add matches the plugin's complete preset list. Quick-add bridges render only for plugins with the hook and call it unconditionally; duplicate plugin keys get one bridge. * Quick add previews the same effective document it stores Auth derivation previewed the raw spec while the add stored the overridden one, so a preset's scope overrides were derived away; and registry overrides wrongly beat preset overrides. One spec plan (preset overrides first, mirroring the full page) now feeds both the preview and the add, and the composition is a pure exported function with tests pinning the contract: override-changed OAuth scopes survive alongside an appended registry key, and a spec-declared key suppresses the registry's. * Presence-based override precedence; preview and add share one builder An explicitly empty preset override list is a decision (suppress the registry's patches), not an absence — presence-based selection now mirrors the full page's ?? exactly. Both request payloads come from one builder, and the tests assert the actual regression contract: identical specFormat and specOverrides on the preview and the add, so reverting either call site fails the suite. * Quick add is a testable operation; tests observe the real payloads performQuickAdd holds the whole operation with the two mutations injected; the hook is a thin binder. The tests now capture what preview and add actually receive — identical spec plan on both, auth composed from the overridden document with the registry key appended, the derived slug — so reverting either call site fails the suite instead of slipping past helper-level assertions. * Post-merge fixes: drop deleted-section import, scope the fixture server * Update auth-method scenarios to the renamed detected rows Detected rows are titled by their kind now ('OAuth · Detected') and the summary footer names the source and next step; six scenarios in directories the local runs never covered still asserted the old 'Method 1 · Detected' copy and the removed aria-disabled block. * Port the remaining dialog-era scenarios; preset links self-seed Three more CI-only scenarios still drove the removed connect dialog: the disabled-API journey now enters through the preset deep link, and the frontend-error test keys its loaded-signal on the header's Add link. A ?preset= deep link also seeds the spec URL from the preset itself — the dialog always passed &url= alongside, which hid that the link never worked on its own.

Rhys Sullivan committed 2 months ago

Commit 30ddd791 parent 48ab9fe99 files · +7429 −876Browse files

Showing the changes to packages/plugins/openapi/src (20 of 99 files). Show all

Unified · Split

20 files
  1. packages/plugins/openapi/src/api/group.ts +3 −0
  2. packages/plugins/openapi/src/api/handlers.ts +1 −0
  3. packages/plugins/openapi/src/providers/google/discovery.test.ts +28 −0
  4. packages/plugins/openapi/src/providers/google/discovery.ts +62 −28
  5. packages/plugins/openapi/src/providers/google/presets.ts +1 −0
  6. packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +28 −0
  7. packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +2 −0
  8. packages/plugins/openapi/src/providers/microsoft/presets.ts +1 −0
  9. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts +19 −0
  10. packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts +21 −0
  11. packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx +44 −5
  12. packages/plugins/openapi/src/react/integration-plugin.test.ts +241 −0
  13. packages/plugins/openapi/src/react/integration-plugin.ts +252 −17
  14. packages/plugins/openapi/src/sdk/config.ts +5 −0
  15. packages/plugins/openapi/src/sdk/derive-auth.test.ts +42 −0
  16. packages/plugins/openapi/src/sdk/derive-auth.ts +24 −11
  17. packages/plugins/openapi/src/sdk/plugin.ts +11 −1
  18. packages/plugins/openapi/src/sdk/presets.ts +3 −8
  19. packages/plugins/openapi/src/sdk/preview.ts +17 −10
  20. packages/plugins/openapi/src/sdk/spec-format.ts +12 −1
packages/plugins/openapi/src/api/group.ts+3−0
@@ -82,6 +82,9 @@ const AddSpecPayload = Schema.Struct({ · 81 unmodified lines
82 name: Schema.optional(Schema.String),82 name: Schema.optional(Schema.String),
83 description: Schema.optional(Schema.String),83 description: Schema.optional(Schema.String),
84 baseUrl: Schema.optional(Schema.String),84 baseUrl: Schema.optional(Schema.String),
85 /** The product's domain when the caller knew it (a registry row names
86 * notion.com) — display identity when the spec lives on a code host. */
87 displayDomain: Schema.optional(Schema.String),
85 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),88 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
86 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),89 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
87 specFormat: Schema.optional(Schema.String),90 specFormat: Schema.optional(Schema.String),
packages/plugins/openapi/src/api/handlers.ts+1−0
@@ -63,6 +63,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 62 unmodified lines
63 name: payload.name,63 name: payload.name,
64 description: payload.description,64 description: payload.description,
65 baseUrl: payload.baseUrl,65 baseUrl: payload.baseUrl,
66 displayDomain: payload.displayDomain,
66 headers: payload.headers ? { ...payload.headers } : undefined,67 headers: payload.headers ? { ...payload.headers } : undefined,
67 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,68 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
68 specFormat: payload.specFormat,69 specFormat: payload.specFormat,
packages/plugins/openapi/src/providers/google/discovery.test.ts+28−0
@@ -1,5 +1,6 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import { Effect, Option, Schema } from "effect";2import { Effect, Option, Schema } from "effect";
3import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";4import { buildToolTypeScriptPreview } from "@executor-js/sdk/core";
4 5
5import {6import {
@@ -7,6 +8,7 @@ import { · 1 unmodified line
7 convertGoogleDiscoveryToOpenApi,8 convertGoogleDiscoveryToOpenApi,
8 isGoogleDiscoveryUrl,9 isGoogleDiscoveryUrl,
9 normalizeGoogleDiscoveryUrl,10 normalizeGoogleDiscoveryUrl,
11 fetchGoogleDiscoveryDocument,
10} from "./discovery";12} from "./discovery";
11import { googleOAuthConsentScopesForPreset } from "./service-policy";13import { googleOAuthConsentScopesForPreset } from "./service-policy";
12import { extract, parse } from "@executor-js/plugin-openapi";14import { extract, parse } from "@executor-js/plugin-openapi";
@@ -1669,3 +1671,29 @@ it.effect("marks People API semantic inputs as required", () => · 1656 unmodified lines
1669 });1671 });
1670 }),1672 }),
1671);1673);
1674
1675it("falls back to the service-hosted Discovery URL when the central directory has no entry", async () => {
1676 // Google Ads is not listed in the central directory: normalization maps it
1677 // onto www.googleapis.com for identity, and that URL 404s. The fetch must
1678 // still succeed by falling back to the host the caller named.
1679 const seen: string[] = [];
1680 const stub = HttpClient.make((request) => {
1681 seen.push(request.url);
1682 return Effect.succeed(
1683 request.url.includes("www.googleapis.com")
1684 ? HttpClientResponse.fromWeb(request, new Response("nope", { status: 404 }))
1685 : HttpClientResponse.fromWeb(
1686 request,
1687 new Response('{"name":"googleads"}', { status: 200 }),
1688 ),
1689 );
1690 });
1691 const text = await Effect.runPromise(
1692 fetchGoogleDiscoveryDocument(
1693 "https://googleads.googleapis.com/$discovery/rest?version=v24",
1694 ).pipe(Effect.provideService(HttpClient.HttpClient, stub)),
1695 );
1696 expect(text).toBe('{"name":"googleads"}');
1697 expect(seen[0]).toContain("www.googleapis.com");
1698 expect(seen[1]).toBe("https://googleads.googleapis.com/$discovery/rest?version=v24");
1699});
packages/plugins/openapi/src/providers/google/discovery.ts+62−28
@@ -343,6 +343,25 @@ export const isGoogleDiscoveryUrl = (url: string): boolean => { · 342 unmodified lines
343 return normalizeGoogleDiscoveryUrl(url) !== null;343 return normalizeGoogleDiscoveryUrl(url) !== null;
344};344};
345 345
346/** The service's own Discovery endpoint, for a URL that named it. Normalization
347 * canonicalizes most services onto the central directory, which is right for
348 * identity but NOT universally fetchable: services outside the directory
349 * (Google Ads) answer only on their own host. */
350const serviceHostedDiscoveryUrl = (discoveryUrl: string): string | null => {
351 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() rejects malformed input
352 try {
353 const parsed = new URL(discoveryUrl.trim());
354 const host = parsed.hostname.toLowerCase();
355 if (host === "www.googleapis.com" || !host.endsWith(".googleapis.com")) return null;
356 if (!["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) return null;
357 const version = parsed.searchParams.get("version")?.trim();
358 if (!version || !DISCOVERY_VERSION_RE.test(version)) return null;
359 return `https://${host}/$discovery/rest?version=${version}`;
360 } catch {
361 return null;
362 }
363};
364
346export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(365export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")(
347 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {366 function* (discoveryUrl: string, credentials?: SpecFetchCredentials) {
348 const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl);367 const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl);
@@ -353,35 +372,50 @@ export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscov · 4 unmodified lines
353 });372 });
354 }373 }
355 const client = yield* HttpClient.HttpClient;374 const client = yield* HttpClient.HttpClient;
356 const requestUrl = new URL(normalizedDiscoveryUrl);375
357 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {376 const attempt = (target: string) =>
358 requestUrl.searchParams.set(name, value);377 Effect.gen(function* () {
359 }378 const requestUrl = new URL(target);
360 let request = HttpClientRequest.get(requestUrl.toString()).pipe(379 for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) {
361 HttpClientRequest.setHeader("Accept", "application/json, */*"),380 requestUrl.searchParams.set(name, value);
362 );381 }
363 for (const [name, value] of Object.entries(credentials?.headers ?? {})) {382 let request = HttpClientRequest.get(requestUrl.toString()).pipe(
364 request = HttpClientRequest.setHeader(request, name, value);383 HttpClientRequest.setHeader("Accept", "application/json, */*"),
365 }384 );
366 const response = yield* client.execute(request).pipe(385 for (const [name, value] of Object.entries(credentials?.headers ?? {})) {
367 Effect.mapError(386 request = HttpClientRequest.setHeader(request, name, value);
368 () =>387 }
369 new OpenApiParseError({388 const response = yield* client
370 message: "Failed to fetch Google Discovery document",389 .execute(request)
371 }),390 .pipe(
372 ),391 Effect.mapError(
373 );392 () => new OpenApiParseError({ message: "Failed to fetch Google Discovery document" }),
374 if (response.status < 200 || response.status >= 300) {393 ),
375 return yield* new OpenApiParseError({394 );
376 message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,395 if (response.status < 200 || response.status >= 300) {
396 return yield* new OpenApiParseError({
397 message: `Failed to fetch Google Discovery document: HTTP ${response.status}`,
398 });
399 }
400 return yield* response.text.pipe(
401 Effect.mapError(
402 () =>
403 new OpenApiParseError({ message: "Failed to read Google Discovery document body" }),
404 ),
405 );
377 });406 });
378 }407
379 return yield* response.text.pipe(408 // Normalization maps a service-hosted URL onto the central directory for a
380 Effect.mapError(409 // STABLE IDENTITY, but the directory does not list every service — Google
381 () =>410 // Ads answers only on googleads.googleapis.com, so the canonical form 404s
382 new OpenApiParseError({411 // for a URL the user pasted that works. Fall back to the host they named
383 message: "Failed to read Google Discovery document body",412 // rather than maintaining an allowlist of every such service forever.
384 }),413 const serviceHosted = serviceHostedDiscoveryUrl(discoveryUrl);
414 return yield* attempt(normalizedDiscoveryUrl).pipe(
415 Effect.catch((error) =>
416 serviceHosted && serviceHosted !== normalizedDiscoveryUrl
417 ? attempt(serviceHosted)
418 : Effect.fail(error),
385 ),419 ),
386 );420 );
387 },421 },
packages/plugins/openapi/src/providers/google/presets.ts+1−0
@@ -346,6 +346,7 @@ export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets · 345 unmodified lines
346 ...(preset.featured ? { featured: preset.featured } : {}),346 ...(preset.featured ? { featured: preset.featured } : {}),
347 family: "google",347 family: "google",
348 specFormat: "google-discovery",348 specFormat: "google-discovery",
349 registryListed: true,
349 defaultSlug: googleServiceSlug(preset.id),350 defaultSlug: googleServiceSlug(preset.id),
350 authTemplate: googleCatalogAuthTemplate(preset.id),351 authTemplate: googleCatalogAuthTemplate(preset.id),
351 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),352 ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}),
packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts+28−0
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 3 unmodified lines
4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7import { resolveSpecFormatAdapter } from "../../sdk/spec-format";
7import type { AuthenticationInput } from "@executor-js/plugin-openapi";8import type { AuthenticationInput } from "@executor-js/plugin-openapi";
8 9
9import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";10import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
@@ -290,3 +291,30 @@ it.effect("preserves a Google preset's full consumer consent boundary when refre · 280 unmodified lines
290 );291 );
291 }),292 }),
292);293);
294
295it.effect("a hand-pasted Discovery URL selects the adapter without a preset", () =>
296 Effect.gen(function* () {
297 const detected = yield* resolveSpecFormatAdapter(
298 [googleDiscoveryAdapter],
299 undefined,
300 GMAIL_URL,
301 );
302 expect(detected?.id).toBe("google-discovery");
303
304 const plainOpenApi = yield* resolveSpecFormatAdapter(
305 [googleDiscoveryAdapter],
306 undefined,
307 "https://api.example.com/openapi.json",
308 );
309 expect(plainOpenApi).toBeNull();
310
311 // An explicit format id still wins over detection, and an unknown one
312 // still fails rather than silently falling back.
313 const explicit = yield* resolveSpecFormatAdapter(
314 [googleDiscoveryAdapter],
315 "google-discovery",
316 "https://api.example.com/openapi.json",
317 );
318 expect(explicit?.id).toBe("google-discovery");
319 }),
320);
packages/plugins/openapi/src/providers/google/spec-format-adapter.ts+2−0
@@ -6,6 +6,7 @@ import { type DerivedIdentity, type SpecFormatAdapter } from "../../sdk/spec-for · 5 unmodified lines
6import {6import {
7 convertGoogleDiscoveryBundleToOpenApi,7 convertGoogleDiscoveryBundleToOpenApi,
8 fetchGoogleDiscoveryDocument,8 fetchGoogleDiscoveryDocument,
9 isGoogleDiscoveryUrl,
9 normalizeGoogleDiscoveryUrl,10 normalizeGoogleDiscoveryUrl,
10} from "./discovery";11} from "./discovery";
11 12
@@ -47,6 +48,7 @@ export const deriveGoogleDiscoveryIdentity = (doc: unknown): DerivedIdentity | n · 35 unmodified lines
47 48
48export const googleDiscoveryAdapter: SpecFormatAdapter = {49export const googleDiscoveryAdapter: SpecFormatAdapter = {
49 id: "google-discovery",50 id: "google-discovery",
51 detectsUrl: isGoogleDiscoveryUrl,
50 fetch: (input) =>52 fetch: (input) =>
51 Effect.gen(function* () {53 Effect.gen(function* () {
52 const documents = yield* Effect.forEach(54 const documents = yield* Effect.forEach(
packages/plugins/openapi/src/providers/microsoft/presets.ts+1−0
@@ -569,6 +569,7 @@ export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScop · 568 unmodified lines
569 ...(preset.featured ? { featured: preset.featured } : {}),569 ...(preset.featured ? { featured: preset.featured } : {}),
570 family: "microsoft",570 family: "microsoft",
571 specFormat: "microsoft-graph",571 specFormat: "microsoft-graph",
572 registryListed: true,
572 defaultSlug: microsoftServiceSlug(preset.id),573 defaultSlug: microsoftServiceSlug(preset.id),
573 authTemplate: microsoftGraphCatalogAuthTemplate(preset),574 authTemplate: microsoftGraphCatalogAuthTemplate(preset),
574 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),575 ...(preset.id === "profile" ? { healthCheck: { operation: "me.GetUser" } } : {}),
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.test.ts+19−0
@@ -221,3 +221,22 @@ it.effect("stream-previews a Graph selection without a whole-document parse", () · 220 unmodified lines
221 ]);221 ]);
222 }),222 }),
223);223);
224
225it("detects Graph sources so a hand-pasted URL does not fall to the plain path", () => {
226 const detects = (url: string) => microsoftGraphAdapter.detectsUrl?.(url) === true;
227 // The monolith is the case that matters: on the plain path it is 43 MB and
228 // trips the whole-document parse limit.
229 expect(detects(`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=mail`)).toBe(true);
230 expect(detects(MICROSOFT_GRAPH_OPENAPI_URL)).toBe(true);
231 expect(
232 detects(
233 "https://github.com/UsefulSoftwareCo/executor/releases/download/graph-slices/mail.yaml",
234 ),
235 ).toBe(true);
236 // Already-sliced hosted specs are self-contained and parse on the plain
237 // path; claiming them would route them through the slice builder for nothing.
238 expect(detects("https://integrations.sh/specs/microsoft-graph/mail.json")).toBe(false);
239 expect(
240 detects("https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"),
241 ).toBe(false);
242});
packages/plugins/openapi/src/providers/microsoft/spec-format-adapter.ts+21−0
@@ -3,6 +3,7 @@ import { Effect } from "effect"; · 2 unmodified lines
3import type { SpecFormatAdapter } from "../../sdk/spec-format";3import type { SpecFormatAdapter } from "../../sdk/spec-format";
4 4
5import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";5import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
6import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets";
6import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices";7import { microsoftGraphPresetIdsForSliceAsset, microsoftGraphSliceAssetFromUrl } from "./slices";
7 8
8const fragmentPresetIds = (hash: string): readonly string[] =>9const fragmentPresetIds = (hash: string): readonly string[] =>
@@ -40,8 +41,28 @@ const graphCatalogSelection = ( · 31 unmodified lines
40 };41 };
41};42};
42 43
44/** Recognizes the Graph sources this adapter exists to handle: Microsoft's
45 * published monolith and executor's slice assets, with or without a
46 * `#preset=` selector. Without this, pasting one of those URLs by hand falls
47 * to the plain OpenAPI path, which then chokes on the 43 MB monolith — the
48 * adapter only ever engaged when a preset supplied `specFormat`. */
49const detectsGraphUrl = (url: string): boolean => {
50 if (!URL.canParse(url)) return false;
51 const parsed = new URL(url);
52 parsed.hash = "";
53 const bare = parsed.toString();
54 return (
55 bare === MICROSOFT_GRAPH_OPENAPI_URL ||
56 microsoftGraphSliceAssetFromUrl(bare) !== null ||
57 /^https:\/\/raw\.githubusercontent\.com\/microsoftgraph\/msgraph-metadata\/.*\/openapi\.ya?ml$/.test(
58 bare,
59 )
60 );
61};
62
43export const microsoftGraphAdapter: SpecFormatAdapter = {63export const microsoftGraphAdapter: SpecFormatAdapter = {
44 id: "microsoft-graph",64 id: "microsoft-graph",
65 detectsUrl: detectsGraphUrl,
45 fetch: (input) =>66 fetch: (input) =>
46 buildMicrosoftGraphOpenApiSpec(67 buildMicrosoftGraphOpenApiSpec(
47 graphCatalogSelection(input.urls[0]),68 graphCatalogSelection(input.urls[0]),
packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx+44−5
@@ -25,6 +25,7 @@ import { · 24 unmodified lines
25 type AuthMethodRow,25 type AuthMethodRow,
26 type AuthMethodSeed,26 type AuthMethodSeed,
27} from "@executor-js/react/components/auth-method-list-editor";27} from "@executor-js/react/components/auth-method-list-editor";
28import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
28import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";29import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";
29import { FieldLabel } from "@executor-js/react/components/field";30import { FieldLabel } from "@executor-js/react/components/field";
30import { FloatActions } from "@executor-js/react/components/float-actions";31import { FloatActions } from "@executor-js/react/components/float-actions";
@@ -170,18 +171,40 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines
170 initialUrl?: string;171 initialUrl?: string;
171 initialPreset?: string;172 initialPreset?: string;
172 initialNamespace?: string;173 initialNamespace?: string;
174 initialAuthHeader?: string;
175 initialAuthNote?: string;
176 initialSpecOverrides?: string;
173}) {177}) {
174 const integrationPlugins = useIntegrationPlugins();178 const integrationPlugins = useIntegrationPlugins();
175 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");179 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
176 const openApiPresets = openApiPlugin?.presets;180 const openApiPresets = openApiPlugin?.presets;
177 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");181 // A `?preset=` deep link is self-sufficient: the preset's own URL seeds the
182 // field. The connect dialog used to pass `&url=` alongside, which hid that
183 // this never worked on its own — preset links from agents and docs carry
184 // only the id.
185 const [specUrl, setSpecUrl] = useState(
186 () =>
187 props.initialUrl ??
188 openApiPresets?.find((preset) => preset.id === props.initialPreset)?.url ??
189 "",
190 );
178 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);191 const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);
179 const [analyzing, setAnalyzing] = useState(false);192 const [analyzing, setAnalyzing] = useState(false);
180 const [analyzeError, setAnalyzeError] = useState<string | null>(null);193 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
181 194
182 const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);195 const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);
183 const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides);196 const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides);
184 const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides);197 // Registry-declared overrides — how a vendor's published document gets
198 // improved without hosting a fork (Neon's console session cookies posing as
199 // security schemes). A preset's own overrides win; the user's draft wins
200 // over both, and the editor shows exactly what will apply.
201 const registrySpecOverrides = useMemo(() => {
202 if (!props.initialSpecOverrides) return undefined;
203 const parsed = parseSpecOverridesText(props.initialSpecOverrides);
204 return parsed.ok ? parsed.value : undefined;
205 }, [props.initialSpecOverrides]);
206 const specOverridesText =
207 specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides ?? registrySpecOverrides);
185 208
186 // After analysis209 // After analysis
187 const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);210 const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);
@@ -297,14 +320,26 @@ export default function AddOpenApiIntegration(props: { · 109 unmodified lines
297 ...(preview?.headerPresets ?? []).map((preset) => preset.label),320 ...(preview?.headerPresets ?? []).map((preset) => preset.label),
298 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),321 ...(preview?.oauth2Presets ?? []).map((preset) => preset.label),
299 ];322 ];
300 return authenticationTemplate.map(323 const detected = authenticationTemplate.map(
301 (template: Authentication, index: number): AuthMethodSeed => ({324 (template: Authentication, index: number): AuthMethodSeed => ({
302 value: editorValueFromAuthentication(template),325 value: editorValueFromAuthentication(template),
303 slug: String(template.slug),326 slug: String(template.slug),
304 ...(labels[index] !== undefined ? { label: labels[index] } : {}),327 ...(labels[index] !== undefined ? { label: labels[index] } : {}),
305 }),328 }),
306 );329 );
307 }, [preview, authenticationTemplate]);330 if (preview === null) return detected;
331 // The registry's declared credential placement fills what the document
332 // leaves out. GitHub's official description famously declares no
333 // securitySchemes at all, and even with the OAuth preset template a PAT
334 // bearer header is how most calls actually authenticate — so the key
335 // method is offered alongside OAuth. A spec-declared key method wins over
336 // the registry's version of the same fact.
337 const placement = props.initialAuthHeader
338 ? placementFromHeaderPattern(props.initialAuthHeader)
339 : null;
340 if (!placement || detected.some((seed) => seed.value.kind === "apikey")) return detected;
341 return [...detected, { value: { kind: "apikey", placements: [placement] } }];
342 }, [preview, authenticationTemplate, props.initialAuthHeader]);
308 const authMethodList = useAuthMethodList(authMethodSeeds);343 const authMethodList = useAuthMethodList(authMethodSeeds);
309 344
310 // The methods to register, mapped back to stored `Authentication[]`. Drops345 // The methods to register, mapped back to stored `Authentication[]`. Drops
@@ -597,10 +632,14 @@ export default function AddOpenApiIntegration(props: { · 286 unmodified lines
597 <AuthMethodListEditor632 <AuthMethodListEditor
598 list={authMethodList}633 list={authMethodList}
599 emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later."634 emptyHint="No authentication detected. Add a method, or add the integration without auth and connect an account from the integration page later."
600 footerHint="Every method here is registered with the integration. Connect an account from the integration page after adding."635 footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page."
601 />636 />
602 )}637 )}
603 638
639 {preview && props.initialAuthNote ? (
640 <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
641 ) : null}
642
604 {preview ? (643 {preview ? (
605 <AddOpenApiHealthCheckSection644 <AddOpenApiHealthCheckSection
606 presetHealthCheck={activePreset?.healthCheck}645 presetHealthCheck={activePreset?.healthCheck}
packages/plugins/openapi/src/react/integration-plugin.test.ts+241−0
@@ -0,0 +1,241 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Option } from "effect";
3
4import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
5import { Exit } from "effect";
6
7import {
8 composeQuickAddAuth,
9 performQuickAdd,
10 quickAddRequestPayloads,
11 quickAddSpecPlan,
12 type QuickAddDeps,
13} from "./integration-plugin";
14
15// The quick add's composition is where review found bugs twice: first it
16// suppressed spec-declared methods, then it derived auth from the unmodified
17// document while adding the overridden one. These pin the contract.
18
19const scopeOverride = decodeOpenApiSpecOverrides([
20 {
21 op: "replace",
22 path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
23 value: { "files:read": "" },
24 },
25])!;
26
27describe("quickAddSpecPlan", () => {
28 it("uses the same effective overrides for preview and add, preset first", () => {
29 const registryOverrides = decodeOpenApiSpecOverrides([
30 { op: "remove", path: "/components/securitySchemes/Cookie" },
31 ]);
32 const plan = quickAddSpecPlan(
33 {
34 id: "figma",
35 name: "Figma",
36 summary: "",
37 specFormat: "plain-ish",
38 specOverrides: [...scopeOverride],
39 },
40 registryOverrides,
41 );
42 // Preset overrides win over the registry's, mirroring the full add page.
43 expect(plan.specOverrides).toEqual(scopeOverride);
44 expect(plan.specFormat).toBe("plain-ish");
45 });
46
47 it("an explicitly EMPTY preset override list suppresses the registry's", () => {
48 // Presence-based, like the full page's `presetOverrides ?? registry`:
49 // an empty list is a decision, not an absence.
50 const registryOverrides = decodeOpenApiSpecOverrides([
51 { op: "remove", path: "/components/securitySchemes/Cookie" },
52 ]);
53 const plan = quickAddSpecPlan(
54 { id: "custom", name: "Custom", summary: "", specOverrides: [] },
55 registryOverrides,
56 );
57 expect(plan.specOverrides).toBeUndefined();
58 });
59
60 it("falls through to registry overrides when the preset has none", () => {
61 const registryOverrides = decodeOpenApiSpecOverrides([
62 { op: "remove", path: "/components/securitySchemes/Cookie" },
63 ]);
64 const plan = quickAddSpecPlan(undefined, registryOverrides);
65 expect(plan.specOverrides).toEqual(registryOverrides);
66 expect(plan.specFormat).toBeUndefined();
67 });
68});
69
70describe("composeQuickAddAuth", () => {
71 const registryPlacement = {
72 carrier: "header",
73 name: "Authorization",
74 prefix: "Bearer ",
75 } as const;
76
77 it("keeps override-derived OAuth scopes and appends the registry key", () => {
78 // The preview reflects the OVERRIDDEN document (quickAddSpecPlan sends
79 // the same overrides to preview): its oauth preset carries the replaced
80 // scopes. The composed template must retain them verbatim and add the
81 // registry header only because no key method was detected.
82 const template = composeQuickAddAuth([], registryPlacement, {
83 headerPresets: [],
84 oauth2Presets: [
85 {
86 label: "OAuth2",
87 securitySchemeName: "OAuth2",
88 flow: "authorizationCode" as const,
89 authorizationUrl: Option.some("https://example.com/authorize"),
90 tokenUrl: "https://example.com/token",
91 resource: Option.none(),
92 refreshUrl: Option.none(),
93 // The overridden document's scopes — the whole point: the preview
94 // reflects the SAME effective spec the add stores.
95 scopes: { "files:read": "" },
96 identityScopes: [] as const,
97 },
98 ],
99 servers: [{ url: "https://api.example.com" }],
100 });
101 expect(template).toHaveLength(2);
102 const oauth = template.find((method) => "kind" in method && method.kind === "oauth2");
103 expect(oauth && "scopes" in oauth ? oauth.scopes : undefined).toEqual(["files:read"]);
104 const key = template.find((method) => !("kind" in method) || method.kind !== "oauth2");
105 expect(JSON.stringify(key)).toContain("Authorization");
106 });
107
108 it("does not append the registry key when the spec already declares one", () => {
109 const template = composeQuickAddAuth([], registryPlacement, {
110 headerPresets: [
111 {
112 label: "API key",
113 headers: { "X-API-Key": null },
114 secretHeaders: ["X-API-Key"],
115 secretQueryParams: [],
116 },
117 ],
118 oauth2Presets: [],
119 servers: [{ url: "https://api.example.com" }],
120 });
121 expect(template).toHaveLength(1);
122 expect(JSON.stringify(template[0])).toContain("X-API-Key");
123 });
124});
125
126describe("quickAddRequestPayloads", () => {
127 it("the preview and the add carry the identical spec plan", () => {
128 // The regression contract itself: reverting either call site to a bare
129 // payload makes these fields diverge and this test fail.
130 const plan = quickAddSpecPlan(
131 {
132 id: "figma",
133 name: "Figma",
134 summary: "",
135 specFormat: "fmt",
136 specOverrides: [...scopeOverride],
137 },
138 undefined,
139 );
140 const requests = quickAddRequestPayloads(
141 { url: "https://example.com/spec.yaml", name: "Figma API", domain: "figma.com" },
142 "figma_api",
143 plan,
144 );
145 expect(requests.preview.specFormat).toBe("fmt");
146 expect(requests.preview.specOverrides).toEqual(scopeOverride);
147 expect(requests.add.specFormat).toBe(requests.preview.specFormat);
148 expect(requests.add.specOverrides).toEqual(requests.preview.specOverrides);
149 expect(requests.add.displayDomain).toBe("figma.com");
150 expect(requests.add.spec).toEqual({ kind: "url", url: "https://example.com/spec.yaml" });
151 });
152});
153
154describe("performQuickAdd", () => {
155 // The OPERATION under test, with the two mutations captured — the guard
156 // the review demanded twice: helper tests kept passing while a call site
157 // could quietly stop using the shared plan.
158 it("sends the identical spec plan to preview and add, and composed auth", async () => {
159 const previews: unknown[] = [];
160 const adds: unknown[] = [];
161 const deps: QuickAddDeps = {
162 presets: [
163 {
164 id: "figma",
165 name: "Figma",
166 summary: "",
167 url: "https://example.com/spec.yaml",
168 specFormat: "fmt",
169 specOverrides: [...scopeOverride],
170 },
171 ],
172 preview: (payload) => {
173 previews.push(payload);
174 return Promise.resolve(
175 Exit.succeed({
176 headerPresets: [],
177 oauth2Presets: [
178 {
179 label: "OAuth2",
180 securitySchemeName: "OAuth2",
181 flow: "authorizationCode" as const,
182 authorizationUrl: Option.some("https://example.com/authorize"),
183 tokenUrl: "https://example.com/token",
184 resource: Option.none(),
185 refreshUrl: Option.none(),
186 scopes: { "files:read": "" },
187 identityScopes: [] as const,
188 },
189 ],
190 servers: [{ url: "https://api.example.com" }],
191 }),
192 );
193 },
194 add: (payload) => {
195 adds.push(payload);
196 return Promise.resolve(Exit.succeed({ slug: "figma_api" }));
197 },
198 };
199 const result = await performQuickAdd(deps, {
200 url: "https://example.com/spec.yaml",
201 name: "Figma API",
202 slug: "figma-api",
203 domain: "figma.com",
204 authHeader: "Authorization: Bearer {token}",
205 });
206 expect(result).toEqual({ ok: true, slug: "figma_api" });
207 expect(previews).toHaveLength(1);
208 expect(adds).toHaveLength(1);
209 const preview = previews[0] as Record<string, unknown>;
210 const add = adds[0] as Record<string, unknown>;
211 // The regression contract: BOTH real payloads carry the one spec plan.
212 expect(preview.specFormat).toBe("fmt");
213 expect(preview.specOverrides).toEqual(scopeOverride);
214 expect(add.specFormat).toBe(preview.specFormat);
215 expect(add.specOverrides).toEqual(preview.specOverrides);
216 expect(add.slug).toBe("figma_api");
217 expect(add.displayDomain).toBe("figma.com");
218 // Auth derived from the overridden document, registry key appended.
219 const template = add.authenticationTemplate as readonly unknown[];
220 expect(JSON.stringify(template)).toContain("files:read");
221 expect(JSON.stringify(template)).toContain("Authorization");
222 });
223
224 it("skips the preview entirely when the registry declared no header", async () => {
225 const previews: unknown[] = [];
226 const deps: QuickAddDeps = {
227 presets: [],
228 preview: (payload) => {
229 previews.push(payload);
230 return Promise.resolve(Exit.fail("unexpected"));
231 },
232 add: () => Promise.resolve(Exit.succeed({ slug: "plain" })),
233 };
234 const result = await performQuickAdd(deps, {
235 url: "https://example.com/openapi.json",
236 name: "Plain API",
237 });
238 expect(result).toEqual({ ok: true, slug: "plain" });
239 expect(previews).toHaveLength(0);
240 });
241});
packages/plugins/openapi/src/react/integration-plugin.ts+252−17
@@ -1,6 +1,234 @@
1import { lazy } from "react";1import { lazy, useCallback } from "react";
2import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import type {
5 IntegrationPlugin,
6 IntegrationPreset,
7 IntegrationQuickAddInput,
8 IntegrationQuickAddResult,
9} from "@executor-js/sdk/client";
10import { AuthTemplateSlug } from "@executor-js/sdk/shared";
11import { slugifyNamespace } from "@executor-js/react/plugins/integration-identity";
12import { placementFromHeaderPattern, type Placement } from "@executor-js/react/lib/auth-placements";
13import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3import { openApiPresets } from "../sdk/presets";14import { openApiPresets } from "../sdk/presets";
15import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
16import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
17import { openApiWireAuthInput, templateFromPlacements } from "./auth-method-config";
18import { decodeOpenApiSpecOverrides } from "../sdk/spec-overrides";
19
20const normalizedSpecUrl = (url: string): string => {
21 if (!URL.canParse(url)) return url.trim().replace(/\/$/, "");
22 const parsed = new URL(url);
23 parsed.hash = "";
24 parsed.searchParams.sort();
25 return parsed.toString().replace(/\/$/, "");
26};
27
28/** The preset table still knows things no spec can say — GitHub's OAuth
29 * endpoints against a spec that declares NO security at all. A registry row
30 * whose URL is a preset's URL gets that knowledge pulled across. Matches the
31 * plugin's COMPLETE preset list, so a deployment's custom presets
32 * contribute too, not just the built-ins. */
33const presetForSpecUrl = (
34 presets: readonly IntegrationPreset[],
35 url: string,
36): IntegrationPreset | undefined => {
37 const target = normalizedSpecUrl(url);
38 return presets.find(
39 (preset) => preset.url !== undefined && normalizedSpecUrl(preset.url) === target,
40 );
41};
42
43/** One-click add for a registry OpenAPI row. The spec itself is the
44 * configuration: omitting `authenticationTemplate` and `baseUrl` tells the
45 * server to derive both from the document, exactly what the add page's
46 * untouched defaults submit. Registry spec overrides ride along. */
47
48/** The spec inputs a quick add must hold CONSISTENT between preview and add:
49 * deriving auth from a document other than the one being added is how a
50 * preset's scope overrides got silently dropped. Preset overrides win over
51 * the registry's, mirroring the full add page. */
52export const quickAddSpecPlan = (
53 preset: IntegrationPreset | undefined,
54 registryOverrides: ReturnType<typeof decodeOpenApiSpecOverrides>,
55): {
56 readonly specFormat?: string;
57 readonly specOverrides?: NonNullable<ReturnType<typeof decodeOpenApiSpecOverrides>>;
58} => {
59 const presetOverrides = preset?.specOverrides
60 ? decodeOpenApiSpecOverrides(preset.specOverrides)
61 : undefined;
62 // PRESENCE-based, mirroring the full page's `presetOverrides ??
63 // registryOverrides`: a preset declaring an explicitly EMPTY override list
64 // is a decision — suppress the registry's patches — not an absence.
65 const effective = presetOverrides !== undefined ? presetOverrides : registryOverrides;
66 return {
67 ...(preset?.specFormat ? { specFormat: preset.specFormat } : {}),
68 ...(effective && effective.length > 0 ? { specOverrides: effective } : {}),
69 };
70};
71
72/** The preview and add requests built from ONE plan, so they cannot diverge:
73 * deriving auth from a different effective document than the one stored is
74 * the bug this module has now had twice. The add payload takes further
75 * fields (family, health check, auth template) AFTER this base. */
76export const quickAddRequestPayloads = (
77 input: { readonly url: string; readonly name: string; readonly domain?: string },
78 slug: string,
79 specPlan: ReturnType<typeof quickAddSpecPlan>,
80): {
81 readonly preview: { readonly spec: string } & ReturnType<typeof quickAddSpecPlan>;
82 readonly add: {
83 readonly spec: { readonly kind: "url"; readonly url: string };
84 readonly slug: string;
85 readonly name: string;
86 readonly displayDomain?: string;
87 } & ReturnType<typeof quickAddSpecPlan>;
88} => ({
89 preview: { spec: input.url, ...specPlan },
90 add: {
91 spec: { kind: "url", url: input.url },
92 slug,
93 name: input.name,
94 ...(input.domain ? { displayDomain: input.domain } : {}),
95 ...specPlan,
96 },
97});
98
99/** The full add page's method policy as one pure step: preset OAuth wins
100 * outright; else every preview-detected method is preserved and the
101 * registry's key placement is appended only when the detected set has no
102 * key method (GitHub declares no security at all). `preview` MUST be the
103 * summary of the same effective document the add will store. */
104export const composeQuickAddAuth = (
105 presetMethods: readonly ReturnType<typeof openApiWireAuthInput>[],
106 registryPlacement: Placement | null,
107 preview: {
108 readonly headerPresets: Parameters<typeof detectedAuthenticationTemplates>[0];
109 readonly oauth2Presets: Parameters<typeof detectedAuthenticationTemplates>[1];
110 readonly servers: readonly { readonly url: string }[];
111 } | null,
112): readonly ReturnType<typeof openApiWireAuthInput>[] => {
113 if (presetMethods.length > 0) {
114 return registryPlacement
115 ? [...presetMethods, openApiWireAuthInput(templateFromPlacements([registryPlacement]))]
116 : presetMethods;
117 }
118 if (!registryPlacement || preview === null) return presetMethods;
119 const detected = detectedAuthenticationTemplates(
120 preview.headerPresets,
121 preview.oauth2Presets,
122 preview.servers[0]?.url ?? "",
123 );
124 const detectedHasApiKey = detected.some((template) => template.kind === "apikey");
125 return [
126 ...detected.map(openApiWireAuthInput),
127 ...(detectedHasApiKey
128 ? []
129 : [openApiWireAuthInput(templateFromPlacements([registryPlacement]))]),
130 ];
131};
132
133export interface QuickAddDeps {
134 readonly presets: readonly IntegrationPreset[];
135 /** The two mutations, injected so the OPERATION is testable against its
136 * actual outgoing payloads — helper-level tests kept passing while a call
137 * site quietly stopped using the shared plan. */
138 readonly preview: (
139 payload: ReturnType<typeof quickAddRequestPayloads>["preview"],
140 ) => Promise<Exit.Exit<NonNullable<Parameters<typeof composeQuickAddAuth>[2]>, unknown>>;
141 readonly add: (
142 payload: ReturnType<typeof quickAddRequestPayloads>["add"] & {
143 readonly family?: string;
144 readonly healthCheck?: IntegrationPreset["healthCheck"];
145 readonly authenticationTemplate?: ReturnType<typeof openApiWireAuthInput>[];
146 },
147 ) => Promise<Exit.Exit<{ readonly slug: string }, unknown>>;
148}
149
150/** The complete quick-add operation, pure of React: everything between the
151 * picker's click and the two API calls. */
152export const performQuickAdd = async (
153 deps: QuickAddDeps,
154 input: IntegrationQuickAddInput,
155): Promise<IntegrationQuickAddResult> => {
156 const { presets } = deps;
157 const slug = slugifyNamespace(input.slug ?? input.name);
158 if (!slug) return { ok: false, reason: "no derivable slug" };
159 // Registry overrides arrive as untyped JSON; a malformed patch goes to
160 // the configuration screen (its editor renders the parse error) rather
161 // than being silently dropped from a "successful" quick add.
162 const specOverrides = input.specOverrides
163 ? decodeOpenApiSpecOverrides(input.specOverrides)
164 : undefined;
165 if (input.specOverrides && input.specOverrides.length > 0 && specOverrides === undefined) {
166 return { ok: false, reason: "unparseable spec overrides" };
167 }
168 const preset = presetForSpecUrl(presets, input.url);
169 const registryPlacement = input.authHeader ? placementFromHeaderPattern(input.authHeader) : null;
170 const presetMethods = (preset?.authTemplate ?? []).flatMap((template) =>
171 template.kind === "oauth2"
172 ? [
173 openApiWireAuthInput({
174 ...template,
175 slug: AuthTemplateSlug.make(template.slug),
176 resource: template.resource ?? undefined,
177 }),
178 ]
179 : [],
180 );
181 // ONE spec plan for both preview and add: deriving auth from a
182 // different effective document than the one stored is how a preset's
183 // scope overrides got silently dropped.
184 const specPlan = quickAddSpecPlan(preset, specOverrides);
185 const requests = quickAddRequestPayloads(
186 { url: input.url, name: input.name, ...(input.domain ? { domain: input.domain } : {}) },
187 slug,
188 specPlan,
189 );
190 let preview: Parameters<typeof composeQuickAddAuth>[2] = null;
191 if (presetMethods.length === 0 && registryPlacement) {
192 // Composing with spec knowledge needs the spec: one preview call,
193 // only on this path (a plain registry row with no auth facts still
194 // adds with zero extra round trips).
195 const previewExit = await deps.preview(requests.preview);
196 if (Exit.isFailure(previewExit)) return { ok: false, reason: "preview failed" };
197 preview = previewExit.value;
198 }
199 const authenticationTemplate = composeQuickAddAuth(presetMethods, registryPlacement, preview);
200 const exit = await deps.add({
201 ...requests.add,
202 ...(preset?.family ? { family: preset.family } : {}),
203 ...(preset?.healthCheck ? { healthCheck: preset.healthCheck } : {}),
204 ...(authenticationTemplate.length > 0
205 ? { authenticationTemplate: [...authenticationTemplate] }
206 : {}),
207 });
208 if (Exit.isFailure(exit)) return { ok: false, reason: "add failed" };
209 return { ok: true, slug: String(exit.value.slug) };
210};
211
212function makeUseQuickAdd(presets: readonly IntegrationPreset[]) {
213 return function useOpenApiQuickAdd(): (
214 input: IntegrationQuickAddInput,
215 ) => Promise<IntegrationQuickAddResult> {
216 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" });
217 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" });
218 return useCallback(
219 (input) =>
220 performQuickAdd(
221 {
222 presets,
223 preview: (payload) => doPreview({ payload }),
224 add: (payload) => doAdd({ payload, reactivityKeys: integrationWriteKeys }),
225 },
226 input,
227 ),
228 [doAdd, doPreview],
229 );
230 };
231}
4 232
5const importAdd = () => import("./AddOpenApiIntegration");233const importAdd = () => import("./AddOpenApiIntegration");
6const importEditSheet = () => import("./UpdateSpecSection");234const importEditSheet = () => import("./UpdateSpecSection");
@@ -10,20 +238,27 @@ export interface OpenApiClientConfig { · 3 unmodified lines
10 readonly presets?: readonly IntegrationPreset[];238 readonly presets?: readonly IntegrationPreset[];
11}239}
12 240
13export const createOpenApiIntegrationPlugin = (241export const createOpenApiIntegrationPlugin = (config?: OpenApiClientConfig): IntegrationPlugin => {
14 config?: OpenApiClientConfig,242 // Built-ins are registry-listed (the picker shows the registry's card);
15): IntegrationPlugin => ({243 // a deployment's custom presets are not, and keep their own cards.
16 key: "openapi",244 const presets: readonly IntegrationPreset[] = [
17 label: "OpenAPI",245 ...openApiPresets.map((preset) => ({ ...preset, registryListed: true })),
18 add: lazy(importAdd),246 ...(config?.presets ?? []),
19 editSheet: lazy(importEditSheet),247 ];
20 accounts: lazy(importAccounts),248 return {
21 presets: [...openApiPresets, ...(config?.presets ?? [])],249 key: "openapi",
22 preload: () => {250 label: "OpenAPI",
23 void importAdd();251 add: lazy(importAdd),
24 void importEditSheet();252 editSheet: lazy(importEditSheet),
25 void importAccounts();253 accounts: lazy(importAccounts),
26 },254 presets,
27});255 preload: () => {
256 void importAdd();
257 void importEditSheet();
258 void importAccounts();
259 },
260 useQuickAdd: makeUseQuickAdd(presets),
261 };
262};
28 263
29export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();264export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/plugins/openapi/src/sdk/config.ts+5−0
@@ -52,6 +52,11 @@ export const OpenApiIntegrationConfigSchema = Schema.Struct({ · 51 unmodified lines
52 specUrl: Schema.optional(Schema.String),52 specUrl: Schema.optional(Schema.String),
53 /** Optional base URL override. */53 /** Optional base URL override. */
54 baseUrl: Schema.optional(Schema.String),54 baseUrl: Schema.optional(Schema.String),
55 /** The PRODUCT's domain, recorded at add time when the caller knew it (a
56 * registry row names notion.com). The spec's own host is often a code
57 * host (raw.githubusercontent.com) that says nothing about the product,
58 * which sent credential guidance and favicons to the wrong domain. */
59 displayDomain: Schema.optional(Schema.String),
55 /** Static headers applied to every request (no secret material). */60 /** Static headers applied to every request (no secret material). */
56 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),61 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
57 /** Static query params applied to every request (no secret material). */62 /** Static query params applied to every request (no secret material). */
packages/plugins/openapi/src/sdk/derive-auth.test.ts+42−0
@@ -71,3 +71,45 @@ describe("detectedAuthenticationTemplates", () => { · 70 unmodified lines
71 ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);71 ).toEqual(["OAuth2 Authorization Code · oauth_app", "OAuth2 Client Credentials · oauth_app"]);
72 });72 });
73});73});
74
75describe("query-located api keys", () => {
76 it("a query apiKey preset places the secret in the query string", () => {
77 const templates = detectedAuthenticationTemplates(
78 [
79 {
80 label: "Legacy-API-key (query)",
81 headers: {},
82 secretHeaders: [],
83 secretQueryParams: ["apiKey"],
84 },
85 ],
86 [],
87 "https://example.com",
88 );
89 expect(templates).toHaveLength(1);
90 const template = templates[0];
91 expect(template?.kind).toBe("apikey");
92 expect(template?.kind === "apikey" ? template.placements : []).toEqual([
93 { carrier: "query", name: "apiKey" },
94 ]);
95 });
96
97 it("a strategy mixing a header and a query param asks for two inputs", () => {
98 const templates = detectedAuthenticationTemplates(
99 [
100 {
101 label: "exp-api-key + apiKey (query)",
102 headers: { "exp-api-key": null },
103 secretHeaders: ["exp-api-key"],
104 secretQueryParams: ["apiKey"],
105 },
106 ],
107 [],
108 "https://example.com",
109 );
110 const template = templates[0];
111 const placements = template?.kind === "apikey" ? template.placements : [];
112 expect(placements.map((placement) => placement.carrier)).toEqual(["header", "query"]);
113 expect(new Set(placements.map((placement) => placement.variable)).size).toBe(2);
114 });
115});
packages/plugins/openapi/src/sdk/derive-auth.ts+24−11
@@ -104,20 +104,33 @@ const apiKeyTemplateFromHeaderPreset = ( · 103 unmodified lines
104 preset: HeaderPreset,104 preset: HeaderPreset,
105 slug: AuthTemplateSlug,105 slug: AuthTemplateSlug,
106): APIKeyAuthentication => {106): APIKeyAuthentication => {
107 const variables = variablesForHeaders(preset.secretHeaders);107 const secretQueryParams = preset.secretQueryParams ?? [];
108 // One variable namespace across carriers, so a strategy sending two secrets
109 // (a header and a query param) asks for two distinct inputs.
110 const variables = variablesForHeaders([...preset.secretHeaders, ...secretQueryParams]);
108 return {111 return {
109 slug,112 slug,
110 kind: "apikey",113 kind: "apikey",
111 placements: preset.secretHeaders.map((headerName) => {114 placements: [
112 const prefix = headerPrefix(preset, headerName);115 ...preset.secretHeaders.map((headerName) => {
113 const variable = variables.get(headerName);116 const prefix = headerPrefix(preset, headerName);
114 return {117 const variable = variables.get(headerName);
115 carrier: "header" as const,118 return {
116 name: headerName,119 carrier: "header" as const,
117 ...(prefix ? { prefix } : {}),120 name: headerName,
118 ...(variable ? { variable } : {}),121 ...(prefix ? { prefix } : {}),
119 };122 ...(variable ? { variable } : {}),
120 }),123 };
124 }),
125 ...secretQueryParams.map((paramName) => {
126 const variable = variables.get(paramName);
127 return {
128 carrier: "query" as const,
129 name: paramName,
130 ...(variable ? { variable } : {}),
131 };
132 }),
133 ],
121 };134 };
122};135};
123 136
packages/plugins/openapi/src/sdk/plugin.ts+11−1
@@ -98,6 +98,10 @@ export interface OpenApiSpecConfig { · 97 unmodified lines
98 * then the title). */98 * then the title). */
99 readonly description?: string;99 readonly description?: string;
100 readonly baseUrl?: string;100 readonly baseUrl?: string;
101 /** The product's domain, when the caller knows it (a registry row names
102 * notion.com); display identity for guidance/favicons when the spec's
103 * own host is a code host. */
104 readonly displayDomain?: string;
101 /** Static headers applied to every request (no secret material). */105 /** Static headers applied to every request (no secret material). */
102 readonly headers?: Record<string, string>;106 readonly headers?: Record<string, string>;
103 /** Static query params applied to every request. */107 /** Static query params applied to every request. */
@@ -307,6 +311,7 @@ const AddIntegrationInputSchema = Schema.Struct({ · 203 unmodified lines
307 name: Schema.optional(Schema.String),311 name: Schema.optional(Schema.String),
308 description: Schema.optional(Schema.String),312 description: Schema.optional(Schema.String),
309 baseUrl: Schema.optional(Schema.String),313 baseUrl: Schema.optional(Schema.String),
314 displayDomain: Schema.optional(Schema.String),
310 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),315 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
311 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),316 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
312 specFormat: Schema.optional(Schema.String),317 specFormat: Schema.optional(Schema.String),
@@ -603,7 +608,9 @@ export const describeOpenApiIntegrationDisplay = ( · 290 unmodified lines
603): { readonly url?: string; readonly family?: string } => {608): { readonly url?: string; readonly family?: string } => {
604 const config = decodeOpenApiIntegrationConfig(record.config);609 const config = decodeOpenApiIntegrationConfig(record.config);
605 return {610 return {
606 url: config?.baseUrl ?? config?.specUrl,611 url:
612 config?.baseUrl ??
613 (config?.displayDomain ? `https://${config.displayDomain}` : config?.specUrl),
607 ...(config?.family ? { family: config.family } : {}),614 ...(config?.family ? { family: config.family } : {}),
608 };615 };
609};616};
@@ -661,6 +668,7 @@ export const openApiPlugin = definePlugin< · 51 unmodified lines
661 const adapter = yield* resolveSpecFormatAdapter(668 const adapter = yield* resolveSpecFormatAdapter(
662 options?.specFormats ?? [],669 options?.specFormats ?? [],
663 config.specFormat,670 config.specFormat,
671 config.spec.kind === "url" ? config.spec.url : undefined,
664 );672 );
665 if (adapter) {673 if (adapter) {
666 if (config.spec.kind !== "url") {674 if (config.spec.kind !== "url") {
@@ -782,6 +790,7 @@ export const openApiPlugin = definePlugin< · 115 unmodified lines
782 const adapter = yield* resolveSpecFormatAdapter(790 const adapter = yield* resolveSpecFormatAdapter(
783 options?.specFormats ?? [],791 options?.specFormats ?? [],
784 config.specFormat,792 config.specFormat,
793 config.spec.kind === "url" ? config.spec.url : undefined,
785 );794 );
786 const derivedIdentity =795 const derivedIdentity =
787 adapter?.deriveIdentity && resolved.document796 adapter?.deriveIdentity && resolved.document
@@ -865,6 +874,7 @@ export const openApiPlugin = definePlugin< · 77 unmodified lines
865 // resolved per call from the operation's `servers` (extracted from874 // resolved per call from the operation's `servers` (extracted from
866 // the spec), so we never bake a derived base URL into the config.875 // the spec), so we never bake a derived base URL into the config.
867 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),876 ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
877 ...(config.displayDomain ? { displayDomain: config.displayDomain } : {}),
868 ...(config.headers ? { headers: config.headers } : {}),878 ...(config.headers ? { headers: config.headers } : {}),
869 ...(config.queryParams ? { queryParams: config.queryParams } : {}),879 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
870 ...(config.specFormat ? { specFormat: config.specFormat } : {}),880 ...(config.specFormat ? { specFormat: config.specFormat } : {}),
packages/plugins/openapi/src/sdk/presets.ts+3−8
@@ -145,7 +145,9 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 144 unmodified lines
145 id: "axiom",145 id: "axiom",
146 name: "Axiom",146 name: "Axiom",
147 summary: "Log ingestion, querying, datasets, and monitors.",147 summary: "Log ingestion, querying, datasets, and monitors.",
148 url: "https://axiom.co/docs/restapi/versions/v2.json",148 // axiom.co/docs/restapi/versions/v2.json now 404s; the docs repo is where
149 // Axiom actually publishes the spec.
150 url: "https://raw.githubusercontent.com/axiomhq/docs/main/content/docs/%28api-reference%29/restapi/versions/v2.json",
149 icon: "https://integrations.sh/logo/axiom.co",151 icon: "https://integrations.sh/logo/axiom.co",
150 },152 },
151 {153 {
@@ -169,13 +171,6 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 17 unmodified lines
169 url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",171 url: "https://raw.githubusercontent.com/digitalocean/openapi/main/specification/DigitalOcean-public.v2.yaml",
170 icon: "https://integrations.sh/logo/digitalocean.com",172 icon: "https://integrations.sh/logo/digitalocean.com",
171 },173 },
172 {
173 id: "petstore",
174 name: "Petstore",
175 summary: "Classic OpenAPI demo, no auth required.",
176 url: "https://petstore3.swagger.io/api/v3/openapi.json",
177 icon: "https://integrations.sh/logo/petstore3.swagger.io",
178 },
179 {174 {
180 id: "val-town",175 id: "val-town",
181 name: "Val Town",176 name: "Val Town",
packages/plugins/openapi/src/sdk/preview.ts+17−10
@@ -122,6 +122,9 @@ export const HeaderPreset = Schema.Struct({ · 121 unmodified lines
122 headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),122 headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)),
123 /** Which headers should be stored as secrets */123 /** Which headers should be stored as secrets */
124 secretHeaders: Schema.Array(Schema.String),124 secretHeaders: Schema.Array(Schema.String),
125 /** Query parameters the strategy sends the secret in (apiKey in=query,
126 * e.g. Viator's legacy `?apiKey=`). Absent on older stored previews. */
127 secretQueryParams: Schema.optional(Schema.Array(Schema.String)),
125});128});
126export type HeaderPreset = typeof HeaderPreset.Type;129export type HeaderPreset = typeof HeaderPreset.Type;
127 130
@@ -347,6 +350,7 @@ const buildHeaderPresets = ( · 219 unmodified lines
347 350
348 const headers: Record<string, string | null> = {};351 const headers: Record<string, string | null> = {};
349 const secretHeaders: string[] = [];352 const secretHeaders: string[] = [];
353 const secretQueryParams: string[] = [];
350 const labelParts: string[] = [];354 const labelParts: string[] = [];
351 355
352 for (const scheme of resolved) {356 for (const scheme of resolved) {
@@ -363,8 +367,16 @@ const buildHeaderPresets = ( · 10 unmodified lines
363 headers[headerName] = null;367 headers[headerName] = null;
364 secretHeaders.push(headerName);368 secretHeaders.push(headerName);
365 labelParts.push(scheme.name);369 labelParts.push(scheme.name);
370 } else if (scheme.type === "apiKey" && Option.getOrElse(scheme.in, () => "") === "query") {
371 secretQueryParams.push(Option.getOrElse(scheme.headerName, () => scheme.name));
372 labelParts.push(`${scheme.name} (query)`);
366 } else if (scheme.type === "apiKey") {373 } else if (scheme.type === "apiKey") {
367 labelParts.push(`${scheme.name} (${Option.getOrElse(scheme.in, () => "unknown")})`);374 // Cookie (and unknown) locations are not renderable as a stored
375 // method — auth placements carry header|query — and a cookie scheme
376 // is usually the vendor console's own session, not a mintable
377 // credential. Contributing a label here used to produce a method
378 // with zero placements: an empty, unfillable card in the add flow.
379 continue;
368 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {380 } else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
369 return [];381 return [];
370 } else {382 } else {
@@ -372,21 +384,16 @@ const buildHeaderPresets = ( · 1 unmodified line
372 }384 }
373 }385 }
374 386
375 if (Object.keys(headers).length === 0 && resolved.length > 0) {387 // A strategy in which nothing is renderable (cookie-only, or an exotic
376 return [388 // scheme type) yields no preset rather than an empty one.
377 HeaderPreset.make({389 if (secretHeaders.length === 0 && secretQueryParams.length === 0) return [];
378 label: labelParts.join(" + "),
379 headers: {},
380 secretHeaders: [],
381 }),
382 ];
383 }
384 390
385 return [391 return [
386 HeaderPreset.make({392 HeaderPreset.make({
387 label: labelParts.join(" + "),393 label: labelParts.join(" + "),
388 headers,394 headers,
389 secretHeaders,395 secretHeaders,
396 ...(secretQueryParams.length > 0 ? { secretQueryParams } : {}),
390 }),397 }),
391 ];398 ];
392 });399 });
packages/plugins/openapi/src/sdk/spec-format.ts+12−1
@@ -41,13 +41,24 @@ export interface SpecFormatAdapter { · 40 unmodified lines
41 readonly id: string;41 readonly id: string;
42 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;42 readonly fetch: (input: SpecFetchInput) => Effect.Effect<ConvertedSpec, OpenApiParseError>;
43 readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;43 readonly deriveIdentity?: (doc: unknown) => DerivedIdentity | null;
44 /** Recognizes URLs this adapter can convert. Lets a hand-pasted URL take
45 * the same path a preset would: without this, a Google Discovery URL only
46 * worked through a preset carrying `specFormat`, and pasting the identical
47 * URL failed as "not OpenAPI". */
48 readonly detectsUrl?: (url: string) => boolean;
44}49}
45 50
46export const resolveSpecFormatAdapter = (51export const resolveSpecFormatAdapter = (
47 adapters: readonly SpecFormatAdapter[],52 adapters: readonly SpecFormatAdapter[],
48 id: string | undefined,53 id: string | undefined,
54 url?: string,
49): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => {55): Effect.Effect<SpecFormatAdapter | null, OpenApiParseError> => {
50 if (!id) return Effect.succeed(null);56 if (!id) {
57 if (!url) return Effect.succeed(null);
58 return Effect.succeed(
59 adapters.find((candidate) => candidate.detectsUrl?.(url) === true) ?? null,
60 );
61 }
51 const adapter = adapters.find((candidate) => candidate.id === id);62 const adapter = adapters.find((candidate) => candidate.id === id);
52 if (adapter) return Effect.succeed(adapter);63 if (adapter) return Effect.succeed(adapter);
53 return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` }));64 return Effect.fail(new OpenApiParseError({ message: `Unknown OpenAPI spec format: ${id}` }));