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 | 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"; |
| 28 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 28 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; | 29 | import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; |
| 29 | import { FieldLabel } from "@executor-js/react/components/field"; | 30 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 30 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 31 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| @@ -170,6 +171,8 @@ 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; | ||
| 173 | }) { | 176 | }) { |
| 174 | const integrationPlugins = useIntegrationPlugins(); | 177 | const integrationPlugins = useIntegrationPlugins(); |
| 175 | const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); | 178 | const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); |
| @@ -297,14 +300,26 @@ export default function AddOpenApiIntegration(props: { · 121 unmodified lines | |||
| 297 | ...(preview?.headerPresets ?? []).map((preset) => preset.label), | 300 | ...(preview?.headerPresets ?? []).map((preset) => preset.label), |
| 298 | ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), | 301 | ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), |
| 299 | ]; | 302 | ]; |
| 300 | return authenticationTemplate.map( | 303 | const detected = authenticationTemplate.map( |
| 301 | (template: Authentication, index: number): AuthMethodSeed => ({ | 304 | (template: Authentication, index: number): AuthMethodSeed => ({ |
| 302 | value: editorValueFromAuthentication(template), | 305 | value: editorValueFromAuthentication(template), |
| 303 | slug: String(template.slug), | 306 | slug: String(template.slug), |
| 304 | ...(labels[index] !== undefined ? { label: labels[index] } : {}), | 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 | const authMethodList = useAuthMethodList(authMethodSeeds); | 323 | const authMethodList = useAuthMethodList(authMethodSeeds); |
| 309 | 324 | ||
| 310 | // The methods to register, mapped back to stored `Authentication[]`. Drops | 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 | {preview ? ( | 623 | {preview ? ( |
| 605 | <AddOpenApiHealthCheckSection | 624 | <AddOpenApiHealthCheckSection |
| 606 | presetHealthCheck={activePreset?.healthCheck} | 625 | presetHealthCheck={activePreset?.healthCheck} |
| @@ -98,6 +98,16 @@ const withSurface = (name: string, surface: string): string => { · 97 unmodified lines | |||
| 98 | return already ? name : `${name} ${surface}`; | 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 | const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { | 111 | const detectionRank: Record<IntegrationDetectionResult["confidence"], number> = { |
| 102 | high: 3, | 112 | high: 3, |
| 103 | medium: 2, | 113 | medium: 2, |
| @@ -151,6 +161,8 @@ interface Row { · 47 unmodified lines | |||
| 151 | readonly key: string; | 161 | readonly key: string; |
| 152 | readonly testId: string; | 162 | readonly testId: string; |
| 153 | readonly title: string; | 163 | readonly title: string; |
| 164 | /** Surface kind for product matching (productKey); not rendered. */ | ||
| 165 | readonly kindKey: string; | ||
| 154 | /** The domain, shown beside a prettified title so near-namesakes stay | 166 | /** The domain, shown beside a prettified title so near-namesakes stay |
| 155 | * distinguishable. Omitted when the title is already the domain. */ | 167 | * distinguishable. Omitted when the title is already the domain. */ |
| 156 | readonly domain?: string; | 168 | readonly domain?: string; |
| @@ -492,8 +504,8 @@ export function IntegrationBrowsePage() { · 335 unmodified lines | |||
| 492 | // catalog row for the same product must not read as two different kinds of | 504 | // catalog row for the same product must not read as two different kinds of |
| 493 | // thing — the row shows the registry's identity (the domain) like every | 505 | // thing — the row shows the registry's identity (the domain) like every |
| 494 | // other result, and the preset only supplies the better add flow. Keyed by | 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. | 507 | // productKey, the same match the dedupe below uses. |
| 496 | const catalogIdentityByTitle = useMemo(() => { | 508 | const catalogIdentityByKey = useMemo(() => { |
| 497 | const map = new Map< | 509 | const map = new Map< |
| 498 | string, | 510 | string, |
| 499 | { readonly domain: string; readonly auth?: CatalogSurface["auth"] } | 511 | { readonly domain: string; readonly auth?: CatalogSurface["auth"] } |
| @@ -505,10 +517,9 @@ export function IntegrationBrowsePage() { · 5 unmodified lines | |||
| 505 | ? entry.surfaces | 517 | ? entry.surfaces |
| 506 | : entry.kinds.map((kind) => ({ kind })); | 518 | : entry.kinds.map((kind) => ({ kind })); |
| 507 | for (const surface of surfaces) { | 519 | for (const surface of surfaces) { |
| 508 | const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; | 520 | const key = productKey(pretty, surface.kind); |
| 509 | const title = withSurface(pretty, word).toLowerCase(); | 521 | if (!map.has(key)) { |
| 510 | if (!map.has(title)) { | 522 | map.set(key, { |
| 511 | map.set(title, { | ||
| 512 | domain: entry.domain, | 523 | domain: entry.domain, |
| 513 | ...("auth" in surface && surface.auth ? { auth: surface.auth } : {}), | 524 | ...("auth" in surface && surface.auth ? { auth: surface.auth } : {}), |
| 514 | }); | 525 | }); |
| @@ -529,11 +540,13 @@ export function IntegrationBrowsePage() { · 14 unmodified lines | |||
| 529 | } | 540 | } |
| 530 | const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel; | 541 | const surface = SURFACE_WORD[entry.pluginKey] ?? entry.pluginLabel; |
| 531 | const title = withSurface(entry.preset.name, surface); | 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 | rows.push({ | 545 | rows.push({ |
| 534 | key: `preset-${entry.pluginKey}-${entry.preset.id}`, | 546 | key: `preset-${entry.pluginKey}-${entry.preset.id}`, |
| 535 | testId: `preset-${entry.preset.id}`, | 547 | testId: `preset-${entry.preset.id}`, |
| 536 | title, | 548 | title, |
| 549 | kindKey, | ||
| 537 | ...(identity ? { domain: identity.domain } : {}), | 550 | ...(identity ? { domain: identity.domain } : {}), |
| 538 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), | 551 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), |
| 539 | ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), | 552 | ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), |
| @@ -543,7 +556,7 @@ export function IntegrationBrowsePage() { · 3 unmodified lines | |||
| 543 | }); | 556 | }); |
| 544 | } | 557 | } |
| 545 | return rows; | 558 | return rows; |
| 546 | }, [allPresets, kind, text, pickPreset, isAdded, catalogIdentityByTitle]); | 559 | }, [allPresets, kind, text, pickPreset, isAdded, catalogIdentityByKey]); |
| 547 | 560 | ||
| 548 | // --- Catalog rows: one per (service, surface) ----------------------------- | 561 | // --- Catalog rows: one per (service, surface) ----------------------------- |
| 549 | const catalogRows = useMemo<readonly Row[]>(() => { | 562 | const catalogRows = useMemo<readonly Row[]>(() => { |
| @@ -567,6 +580,7 @@ export function IntegrationBrowsePage() { · 17 unmodified lines | |||
| 567 | key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, | 580 | key: `catalog-${entry.domain}-${surface.kind}-${known?.slug ?? pretty}`, |
| 568 | testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, | 581 | testId: `catalog-${known?.slug ?? `${entry.domain}-${surface.kind}`}`, |
| 569 | title: withSurface(pretty, word), | 582 | title: withSurface(pretty, word), |
| 583 | kindKey: surface.kind, | ||
| 570 | domain: entry.domain, | 584 | domain: entry.domain, |
| 571 | ...(description ? { description } : {}), | 585 | ...(description ? { description } : {}), |
| 572 | iconUrl: catalogLogoUrl(entry.domain, 10), | 586 | iconUrl: catalogLogoUrl(entry.domain, 10), |
| @@ -577,12 +591,13 @@ export function IntegrationBrowsePage() { · 4 unmodified lines | |||
| 577 | }); | 591 | }); |
| 578 | }); | 592 | }); |
| 579 | // A registry row for something a preset already offers is a worse copy of | 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 | 594 | // it: same service, no auth template, no health check. Matched on |
| 581 | // rendered title because a preset's domain is inferred from its icon and | 595 | // productKey — normalized name plus kind — because neither titles nor |
| 582 | // often does not match the registry's (the Gmail preset's icon is Google's, | 596 | // domains line up reliably: the Gmail preset's icon is Google's (so a |
| 583 | // so a domain comparison let a second "Gmail API" through). | 597 | // domain comparison let a second "Gmail API" through), and the GitHub |
| 584 | const presetTitles = new Set(presetRows.map((row) => row.title.toLowerCase())); | 598 | // preset says "GitHub REST" where the registry row says "GitHub API". |
| 585 | rows = rows.filter((row) => !presetTitles.has(row.title.toLowerCase())); | 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 | if (text.length === 0) return rows; | 601 | if (text.length === 0) return rows; |
| 587 | // A name match beats a mention in the blurb: searching "gmail" should not | 602 | // A name match beats a mention in the blurb: searching "gmail" should not |
| 588 | // rank a CRM that merely describes itself as living inside Gmail above | 603 | // rank a CRM that merely describes itself as living inside Gmail above |