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.
Unified · Split
| @@ -25,6 +25,7 @@ import { · 24 unmodified lines | ||
| 25 | 25 | type AuthMethodRow, |
| 26 | 26 | type AuthMethodSeed, |
| 27 | 27 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 28 | +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | |
| 28 | 29 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; |
| 29 | 30 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 30 | 31 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| @@ -170,6 +171,8 @@ export default function AddOpenApiIntegration(props: { · 139 unmodified lines | ||
| 170 | 171 | initialUrl?: string; |
| 171 | 172 | initialPreset?: string; |
| 172 | 173 | initialNamespace?: string; |
| 174 | + initialAuthHeader?: string; | |
| 175 | + initialAuthNote?: string; | |
| 173 | 176 | }) { |
| 174 | 177 | const integrationPlugins = useIntegrationPlugins(); |
| 175 | 178 | const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); |
| @@ -297,14 +300,26 @@ export default function AddOpenApiIntegration(props: { · 121 unmodified lines | ||
| 297 | 300 | ...(preview?.headerPresets ?? []).map((preset) => preset.label), |
| 298 | 301 | ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), |
| 299 | 302 | ]; |
| 300 | − return authenticationTemplate.map( | |
| 303 | + const detected = authenticationTemplate.map( | |
| 301 | 304 | (template: Authentication, index: number): AuthMethodSeed => ({ |
| 302 | 305 | value: editorValueFromAuthentication(template), |
| 303 | 306 | slug: String(template.slug), |
| 304 | 307 | ...(labels[index] !== undefined ? { label: labels[index] } : {}), |
| 305 | 308 | }), |
| 306 | 309 | ); |
| 307 | − }, [preview, authenticationTemplate]); | |
| 310 | + if (preview === null) return detected; | |
| 311 | + // The registry's declared credential placement fills what the document | |
| 312 | + // leaves out. GitHub's official description famously declares no | |
| 313 | + // securitySchemes at all, and even with the OAuth preset template a PAT | |
| 314 | + // bearer header is how most calls actually authenticate — so the key | |
| 315 | + // method is offered alongside OAuth. A spec-declared key method wins over | |
| 316 | + // the registry's version of the same fact. | |
| 317 | + const placement = props.initialAuthHeader | |
| 318 | + ? placementFromHeaderPattern(props.initialAuthHeader) | |
| 319 | + : null; | |
| 320 | + if (!placement || detected.some((seed) => seed.value.kind === "apikey")) return detected; | |
| 321 | + return [...detected, { value: { kind: "apikey", placements: [placement] } }]; | |
| 322 | + }, [preview, authenticationTemplate, props.initialAuthHeader]); | |
| 308 | 323 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 309 | 324 | |
| 310 | 325 | // The methods to register, mapped back to stored `Authentication[]`. Drops |
| @@ -601,6 +616,10 @@ export default function AddOpenApiIntegration(props: { · 290 unmodified lines | ||
| 601 | 616 | /> |
| 602 | 617 | )} |
| 603 | 618 | |
| 619 | + {preview && props.initialAuthNote ? ( | |
| 620 | + <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | |
| 621 | + ) : null} | |
| 622 | + | |
| 604 | 623 | {preview ? ( |
| 605 | 624 | <AddOpenApiHealthCheckSection |
| 606 | 625 | presetHealthCheck={activePreset?.healthCheck} |
| @@ -98,6 +98,16 @@ const withSurface = (name: string, surface: string): string => { · 97 unmodified lines | ||
| 98 | 98 | return already ? name : `${name} ${surface}`; |
| 99 | 99 | }; |
| 100 | 100 | |
| 101 | +/** The key on which a preset and a catalog row count as the same product: | |
| 102 | + * the name with surface words and punctuation stripped, plus the kind. | |
| 103 | + * "GitHub REST" and "GitHub API" are one product; "Stripe API" and | |
| 104 | + * "Stripe MCP" are not. */ | |
| 105 | +const productKey = (name: string, kind: string): string => | |
| 106 | + `${name | |
| 107 | + .toLowerCase() | |
| 108 | + .replace(/\b(api|rest|graphql|mcp)\b/g, "") | |
| 109 | + .replace(/[^a-z0-9]/g, "")}|${kind}`; | |
| 110 | + | |
| 101 | 111 | const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { |
| 102 | 112 | high: 3, |
| 103 | 113 | medium: 2, |
| @@ -151,6 +161,8 @@ interface Row { · 47 unmodified lines | ||
| 151 | 161 | readonly key: string; |
| 152 | 162 | readonly testId: string; |
| 153 | 163 | readonly title: string; |
| 164 | + /** Surface kind for product matching (productKey); not rendered. */ | |
| 165 | + readonly kindKey: string; | |
| 154 | 166 | /** The domain, shown beside a prettified title so near-namesakes stay |
| 155 | 167 | * distinguishable. Omitted when the title is already the domain. */ |
| 156 | 168 | readonly domain?: string; |
| @@ -492,8 +504,8 @@ export function IntegrationBrowsePage() { · 335 unmodified lines | ||
| 492 | 504 | // catalog row for the same product must not read as two different kinds of |
| 493 | 505 | // thing — the row shows the registry's identity (the domain) like every |
| 494 | 506 | // other result, and the preset only supplies the better add flow. Keyed by |
| 495 | − // the rendered title, the same match the dedupe below uses. | |
| 496 | − const catalogIdentityByTitle = useMemo(() => { | |
| 507 | + // productKey, the same match the dedupe below uses. | |
| 508 | + const catalogIdentityByKey = useMemo(() => { | |
| 497 | 509 | const map = new Map< |
| 498 | 510 | string, |
| 499 | 511 | { readonly domain: string; readonly auth?: CatalogSurface["auth"] } |
| @@ -505,10 +517,9 @@ export function IntegrationBrowsePage() { · 5 unmodified lines | ||
| 505 | 517 | ? entry.surfaces |
| 506 | 518 | : entry.kinds.map((kind) => ({ kind })); |
| 507 | 519 | for (const surface of surfaces) { |
| 508 | − const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; | |
| 509 | − const title = withSurface(pretty, word).toLowerCase(); | |
| 510 | − if (!map.has(title)) { | |
| 511 | − map.set(title, { | |
| 520 | + const key = productKey(pretty, surface.kind); | |
| 521 | + if (!map.has(key)) { | |
| 522 | + map.set(key, { | |
| 512 | 523 | domain: entry.domain, |
| 513 | 524 | ...("auth" in surface && surface.auth ? { auth: surface.auth } : {}), |
| 514 | 525 | }); |
| @@ -529,11 +540,13 @@ export function IntegrationBrowsePage() { · 14 unmodified lines | ||
| 529 | 540 | } |
| 530 | 541 | const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel; |
| 531 | 542 | const title = withSurface(entry.preset.name, surface); |
| 532 | − const identity = catalogIdentityByTitle.get(title.toLowerCase()); | |
| 543 | + const kindKey = entry.pluginKey === "google" ? "openapi" : entry.pluginKey; | |
| 544 | + const identity = catalogIdentityByKey.get(productKey(entry.preset.name, kindKey)); | |
| 533 | 545 | rows.push({ |
| 534 | 546 | key: `preset-${entry.pluginKey}-${entry.preset.id}`, |
| 535 | 547 | testId: `preset-${entry.preset.id}`, |
| 536 | 548 | title, |
| 549 | + kindKey, | |
| 537 | 550 | ...(identity ? { domain: identity.domain } : {}), |
| 538 | 551 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), |
| 539 | 552 | ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), |
| @@ -543,7 +556,7 @@ export function IntegrationBrowsePage() { · 3 unmodified lines | ||
| 543 | 556 | }); |
| 544 | 557 | } |
| 545 | 558 | return rows; |
| 546 | − }, [allPresets, kind, text, pickPreset, isAdded, catalogIdentityByTitle]); | |
| 559 | + }, [allPresets, kind, text, pickPreset, isAdded, catalogIdentityByKey]); | |
| 547 | 560 | |
| 548 | 561 | // --- Catalog rows: one per (service, surface) ----------------------------- |
| 549 | 562 | const catalogRows = useMemo<readonly Row[]>(() => { |
| @@ -567,6 +580,7 @@ export function IntegrationBrowsePage() { · 17 unmodified lines | ||
| 567 | 580 | key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, |
| 568 | 581 | testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, |
| 569 | 582 | title: withSurface(pretty, word), |
| 583 | + kindKey: surface.kind, | |
| 570 | 584 | domain: entry.domain, |
| 571 | 585 | ...(description ? { description } : {}), |
| 572 | 586 | iconUrl: catalogLogoUrl(entry.domain, 10), |
| @@ -577,12 +591,13 @@ export function IntegrationBrowsePage() { · 4 unmodified lines | ||
| 577 | 591 | }); |
| 578 | 592 | }); |
| 579 | 593 | // A registry row for something a preset already offers is a worse copy of |
| 580 | − // it: same service, no auth template, no health check. Matched on the | |
| 581 | − // rendered title because a preset's domain is inferred from its icon and | |
| 582 | − // often does not match the registry's (the Gmail preset's icon is Google's, | |
| 583 | − // so a domain comparison let a second "Gmail API" through). | |
| 584 | − const presetTitles = new Set(presetRows.map((row) => row.title.toLowerCase())); | |
| 585 | − rows = rows.filter((row) => !presetTitles.has(row.title.toLowerCase())); | |
| 594 | + // it: same service, no auth template, no health check. Matched on | |
| 595 | + // productKey — normalized name plus kind — because neither titles nor | |
| 596 | + // domains line up reliably: the Gmail preset's icon is Google's (so a | |
| 597 | + // domain comparison let a second "Gmail API" through), and the GitHub | |
| 598 | + // preset says "GitHub REST" where the registry row says "GitHub API". | |
| 599 | + const presetKeys = new Set(presetRows.map((row) => productKey(row.title, row.kindKey))); | |
| 600 | + rows = rows.filter((row) => !presetKeys.has(productKey(row.title, row.kindKey))); | |
| 586 | 601 | if (text.length === 0) return rows; |
| 587 | 602 | // A name match beats a mention in the blurb: searching "gmail" should not |
| 588 | 603 | // rank a CRM that merely describes itself as living inside Gmail above |