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.
Unified · Split
| @@ -198,6 +198,12 @@ export interface IntegrationPlugin { · 197 unmodified lines | ||
| 198 | 198 | readonly initialUrl?: string; |
| 199 | 199 | readonly initialPreset?: string; |
| 200 | 200 | readonly initialNamespace?: string; |
| 201 | + /** Registry-declared credential placement for the surface, e.g. | |
| 202 | + * "Authorization: {api_key}" — the pattern Linear's no-Bearer personal | |
| 203 | + * keys need. Plugins whose surfaces can't self-describe auth (GraphQL) | |
| 204 | + * seed their auth-method editor from it. */ | |
| 205 | + readonly initialAuthHeader?: string; | |
| 206 | + readonly initialAuthNote?: string; | |
| 201 | 207 | }>; |
| 202 | 208 | /** Legacy full-page edit surface. No host renders this anymore — plugin |
| 203 | 209 | * configuration lives in the integration Edit sheet via `editSheet`. */ |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | ||
| 15 | 15 | type AuthMethodRow, |
| 16 | 16 | type AuthMethodSeed, |
| 17 | 17 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 18 | +import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | |
| 18 | 19 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | 20 | import { |
| 20 | 21 | addIntegrationErrorMessage, |
| @@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines | ||
| 42 | 43 | onComplete: (slug?: string) => void; |
| 43 | 44 | onCancel: () => void; |
| 44 | 45 | initialUrl?: string; |
| 46 | + initialAuthHeader?: string; | |
| 47 | + initialAuthNote?: string; | |
| 45 | 48 | }) { |
| 46 | 49 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 47 | 50 | const [description, setDescription] = useState(""); |
| 48 | 51 | const identity = useIntegrationIdentity({ |
| 49 | 52 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", |
| 50 | 53 | }); |
| 51 | − const authMethodList = useAuthMethodList(NO_SEEDS); | |
| 54 | + // GraphQL has no add-time detection, but the registry can declare the | |
| 55 | + // credential placement ("Authorization: {api_key}" — Linear's no-Bearer | |
| 56 | + // personal keys). A declared placement seeds the method list the same way a | |
| 57 | + // spec's security scheme would. | |
| 58 | + const registrySeeds = useMemo<readonly AuthMethodSeed[]>(() => { | |
| 59 | + const placement = props.initialAuthHeader | |
| 60 | + ? placementFromHeaderPattern(props.initialAuthHeader) | |
| 61 | + : null; | |
| 62 | + return placement ? [{ value: { kind: "apikey", placements: [placement] } }] : NO_SEEDS; | |
| 63 | + }, [props.initialAuthHeader]); | |
| 64 | + const authMethodList = useAuthMethodList(registrySeeds); | |
| 52 | 65 | const [adding, setAdding] = useState(false); |
| 53 | 66 | const [addError, setAddError] = useState<string | null>(null); |
| 54 | 67 | |
| @@ -147,6 +160,10 @@ export default function AddGraphqlIntegration(props: { · 92 unmodified lines | ||
| 147 | 160 | footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." |
| 148 | 161 | /> |
| 149 | 162 | |
| 163 | + {props.initialAuthNote ? ( | |
| 164 | + <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p> | |
| 165 | + ) : null} | |
| 166 | + | |
| 150 | 167 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />} |
| 151 | 168 | |
| 152 | 169 | {addError && <FormErrorAlert message={addError} />} |
| @@ -258,9 +258,12 @@ function DetectedMethodSummary(props: { · 257 unmodified lines | ||
| 258 | 258 | from its own page. Saying so is the difference between a confident |
| 259 | 259 | next click and hunting for a field that is not here. */} |
| 260 | 260 | <p className="text-[11px] text-muted-foreground"> |
| 261 | + {/* "Declared by this API", not "read from the spec": GraphQL surfaces | |
| 262 | + have no spec — their methods arrive from the registry record — and | |
| 263 | + the claim that matters is the same either way. */} | |
| 261 | 264 | {value.kind === "oauth" |
| 262 | − ? "Read from the spec. You'll sign in after adding this integration." | |
| 263 | − : "Read from the spec. You'll enter your key after adding this integration."} | |
| 265 | + ? "Declared by this API. You'll sign in after adding this integration." | |
| 266 | + : "Declared by this API. You'll enter your key after adding this integration."} | |
| 264 | 267 | </p> |
| 265 | 268 | </div> |
| 266 | 269 | ); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; |
| 3 | 3 | |
| 4 | −import { authMethodsFromDescriptors } from "./auth-placements"; | |
| 4 | +import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements"; | |
| 5 | 5 | |
| 6 | 6 | describe("authMethodsFromDescriptors", () => { |
| 7 | 7 | it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => { |
| @@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines | ||
| 101 | 101 | expect(authMethodsFromDescriptors([])).toEqual([]); |
| 102 | 102 | }); |
| 103 | 103 | }); |
| 104 | + | |
| 105 | +describe("placementFromHeaderPattern", () => { | |
| 106 | + it("keeps a Bearer prefix, space included", () => { | |
| 107 | + expect(placementFromHeaderPattern("Authorization: Bearer {token}")).toEqual({ | |
| 108 | + carrier: "header", | |
| 109 | + name: "Authorization", | |
| 110 | + prefix: "Bearer ", | |
| 111 | + }); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it("an empty prefix is meaningful: Linear's keys take no Bearer", () => { | |
| 115 | + expect(placementFromHeaderPattern("Authorization: {api_key}")).toEqual({ | |
| 116 | + carrier: "header", | |
| 117 | + name: "Authorization", | |
| 118 | + prefix: "", | |
| 119 | + }); | |
| 120 | + }); | |
| 121 | + | |
| 122 | + it("rejects text that is not a header pattern", () => { | |
| 123 | + expect(placementFromHeaderPattern("just some prose")).toBeNull(); | |
| 124 | + }); | |
| 125 | +}); | |
| @@ -40,6 +40,22 @@ export interface Placement { · 39 unmodified lines | ||
| 40 | 40 | /** A fresh, empty header placement — the default first row in an editor. */ |
| 41 | 41 | export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" }); |
| 42 | 42 | |
| 43 | +/** Parses a registry header pattern — `"Authorization: Bearer {token}"` — into | |
| 44 | + * a placement. The literal text between the colon and the `{variable}` is the | |
| 45 | + * prefix, and its ABSENCE is load-bearing: `"Authorization: {api_key}"` is how | |
| 46 | + * the registry says Linear's personal keys take no Bearer prefix. */ | |
| 47 | +export function placementFromHeaderPattern(pattern: string): Placement | null { | |
| 48 | + const match = /^([A-Za-z0-9-]+):\s*(.*)$/.exec(pattern.trim()); | |
| 49 | + if (!match) return null; | |
| 50 | + const rest = match[2] ?? ""; | |
| 51 | + const brace = rest.indexOf("{"); | |
| 52 | + return { | |
| 53 | + carrier: "header", | |
| 54 | + name: match[1] ?? "", | |
| 55 | + prefix: brace >= 0 ? rest.slice(0, brace) : "", | |
| 56 | + }; | |
| 57 | +} | |
| 58 | + | |
| 43 | 59 | /** What an auth method is, presentationally. `kind` drives the credential UI: |
| 44 | 60 | * `oauth` shows a Connect button, `none` creates a connection with no |
| 45 | 61 | * credential inputs, and apikey/custom fill secrets across `placements`. |
| @@ -45,6 +45,15 @@ export interface CatalogSurface { · 44 unmodified lines | ||
| 45 | 45 | * machine-readable locator, in which case the surface document still has to |
| 46 | 46 | * be fetched on click. */ |
| 47 | 47 | readonly url?: string; |
| 48 | + /** How to authenticate, for surfaces whose connect target cannot describe | |
| 49 | + * it itself — a GraphQL endpoint has no spec document, so the registry is | |
| 50 | + * the only carrier of facts like Linear's no-Bearer-prefix header. */ | |
| 51 | + readonly auth?: { | |
| 52 | + readonly kind?: string; | |
| 53 | + /** Header pattern, e.g. "Authorization: Bearer {token}". */ | |
| 54 | + readonly header?: string; | |
| 55 | + readonly note?: string; | |
| 56 | + }; | |
| 48 | 57 | } |
| 49 | 58 | |
| 50 | 59 | export interface CatalogSearchEntry { |
| @@ -107,6 +116,13 @@ const SearchResponse = Schema.Struct({ · 56 unmodified lines | ||
| 107 | 116 | kind: Schema.String, |
| 108 | 117 | slug: Schema.String, |
| 109 | 118 | url: Schema.optional(Schema.String), |
| 119 | + auth: Schema.optional( | |
| 120 | + Schema.Struct({ | |
| 121 | + kind: Schema.optional(Schema.String), | |
| 122 | + header: Schema.optional(Schema.String), | |
| 123 | + note: Schema.optional(Schema.String), | |
| 124 | + }), | |
| 125 | + ), | |
| 110 | 126 | }), |
| 111 | 127 | ), |
| 112 | 128 | ), |
| @@ -128,6 +144,7 @@ export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntr · 15 unmodified lines | ||
| 128 | 144 | kind: surface.kind, |
| 129 | 145 | slug: surface.slug, |
| 130 | 146 | ...(surface.url ? { url: surface.url } : {}), |
| 147 | + ...(surface.auth ? { auth: surface.auth } : {}), | |
| 131 | 148 | }, |
| 132 | 149 | ] |
| 133 | 150 | : [], |
| @@ -15,9 +15,11 @@ export function AddIntegrationPage(props: { · 14 unmodified lines | ||
| 15 | 15 | url?: string; |
| 16 | 16 | preset?: string; |
| 17 | 17 | namespace?: string; |
| 18 | + authHeader?: string; | |
| 19 | + authNote?: string; | |
| 18 | 20 | }) { |
| 19 | 21 | useExecutorDocumentTitle("Add integration"); |
| 20 | − const { pluginKey, url, preset, namespace } = props; | |
| 22 | + const { pluginKey, url, preset, namespace, authHeader, authNote } = props; | |
| 21 | 23 | const navigate = useNavigate(); |
| 22 | 24 | const integrationPlugins = useIntegrationPlugins(); |
| 23 | 25 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| @@ -57,6 +59,8 @@ export function AddIntegrationPage(props: { · 33 unmodified lines | ||
| 57 | 59 | initialUrl={url} |
| 58 | 60 | initialPreset={preset} |
| 59 | 61 | initialNamespace={namespace} |
| 62 | + initialAuthHeader={authHeader} | |
| 63 | + initialAuthNote={authNote} | |
| 60 | 64 | onComplete={(slug?: string) => { |
| 61 | 65 | trackEvent("integration_added", { |
| 62 | 66 | plugin_key: pluginKey, |
| @@ -407,6 +407,7 @@ export function IntegrationBrowsePage() { · 406 unmodified lines | ||
| 407 | 407 | readonly url: string; |
| 408 | 408 | readonly slug?: string; |
| 409 | 409 | readonly domain: string; |
| 410 | + readonly auth?: CatalogSurface["auth"]; | |
| 410 | 411 | }) => { |
| 411 | 412 | trackEvent("integration_add_started", { |
| 412 | 413 | plugin_key: input.kind, |
| @@ -416,7 +417,12 @@ export function IntegrationBrowsePage() { · 3 unmodified lines | ||
| 416 | 417 | void navigate({ |
| 417 | 418 | to: "/{-$orgSlug}/integrations/add/$pluginKey", |
| 418 | 419 | params: { pluginKey: input.kind }, |
| 419 | − search: { url: input.url, ...(input.slug ? { namespace: input.slug } : {}) }, | |
| 420 | + search: { | |
| 421 | + url: input.url, | |
| 422 | + ...(input.slug ? { namespace: input.slug } : {}), | |
| 423 | + ...(input.auth?.header ? { authHeader: input.auth.header } : {}), | |
| 424 | + ...(input.auth?.note ? { authNote: input.auth.note } : {}), | |
| 425 | + }, | |
| 420 | 426 | }); |
| 421 | 427 | }, |
| 422 | 428 | [navigate], |
| @@ -433,6 +439,7 @@ export function IntegrationBrowsePage() { · 10 unmodified lines | ||
| 433 | 439 | url: knownUrl, |
| 434 | 440 | domain: entry.domain, |
| 435 | 441 | ...(surface ? { slug: surface.slug } : {}), |
| 442 | + ...(surface?.auth ? { auth: surface.auth } : {}), | |
| 436 | 443 | }); |
| 437 | 444 | return; |
| 438 | 445 | } |
| @@ -457,7 +464,7 @@ export function IntegrationBrowsePage() { · 18 unmodified lines | ||
| 457 | 464 | ); |
| 458 | 465 | |
| 459 | 466 | const pickPreset = useCallback( |
| 460 | − (entry: PresetEntry) => { | |
| 467 | + (entry: PresetEntry, auth?: CatalogSurface["auth"]) => { | |
| 461 | 468 | trackEvent("integration_add_started", { |
| 462 | 469 | plugin_key: entry.pluginKey, |
| 463 | 470 | via: "preset", |
| @@ -465,6 +472,11 @@ export function IntegrationBrowsePage() { · 1 unmodified line | ||
| 465 | 472 | }); |
| 466 | 473 | const search: Record<string, string> = { preset: entry.preset.id }; |
| 467 | 474 | if (entry.preset.url) search.url = entry.preset.url; |
| 475 | + // The registry's credential facts ride along even when the preset owns | |
| 476 | + // the flow — the preset knows the endpoint, the registry knows the | |
| 477 | + // header pattern. | |
| 478 | + if (auth?.header) search.authHeader = auth.header; | |
| 479 | + if (auth?.note) search.authNote = auth.note; | |
| 468 | 480 | void navigate({ |
| 469 | 481 | to: "/{-$orgSlug}/integrations/add/$pluginKey", |
| 470 | 482 | params: { pluginKey: entry.pluginKey }, |
| @@ -482,7 +494,10 @@ export function IntegrationBrowsePage() { · 11 unmodified lines | ||
| 482 | 494 | // other result, and the preset only supplies the better add flow. Keyed by |
| 483 | 495 | // the rendered title, the same match the dedupe below uses. |
| 484 | 496 | const catalogIdentityByTitle = useMemo(() => { |
| 485 | − const map = new Map<string, { readonly domain: string }>(); | |
| 497 | + const map = new Map< | |
| 498 | + string, | |
| 499 | + { readonly domain: string; readonly auth?: CatalogSurface["auth"] } | |
| 500 | + >(); | |
| 486 | 501 | for (const entry of catalogEntries) { |
| 487 | 502 | const pretty = entry.name ?? domainDisplayName(entry.domain); |
| 488 | 503 | const surfaces = |
| @@ -492,7 +507,12 @@ export function IntegrationBrowsePage() { · 3 unmodified lines | ||
| 492 | 507 | for (const surface of surfaces) { |
| 493 | 508 | const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; |
| 494 | 509 | const title = withSurface(pretty, word).toLowerCase(); |
| 495 | − if (!map.has(title)) map.set(title, { domain: entry.domain }); | |
| 510 | + if (!map.has(title)) { | |
| 511 | + map.set(title, { | |
| 512 | + domain: entry.domain, | |
| 513 | + ...("auth" in surface && surface.auth ? { auth: surface.auth } : {}), | |
| 514 | + }); | |
| 515 | + } | |
| 496 | 516 | } |
| 497 | 517 | } |
| 498 | 518 | return map; |
| @@ -517,7 +537,7 @@ export function IntegrationBrowsePage() { · 18 unmodified lines | ||
| 517 | 537 | ...(identity ? { domain: identity.domain } : {}), |
| 518 | 538 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), |
| 519 | 539 | ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), |
| 520 | − onSelect: () => pickPreset(entry), | |
| 540 | + onSelect: () => pickPreset(entry, identity?.auth), | |
| 521 | 541 | added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name), |
| 522 | 542 | busy: false, |
| 523 | 543 | }); |
| @@ -8,6 +8,11 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines | ||
| 8 | 8 | url: Schema.optional(Schema.String), |
| 9 | 9 | preset: Schema.optional(Schema.String), |
| 10 | 10 | namespace: Schema.optional(Schema.String), |
| 11 | + // Registry-declared credential placement, e.g. "Authorization: {api_key}". | |
| 12 | + // Carried for surfaces whose connect target can't describe its own auth | |
| 13 | + // (GraphQL endpoints have no spec document). | |
| 14 | + authHeader: Schema.optional(Schema.String), | |
| 15 | + authNote: Schema.optional(Schema.String), | |
| 11 | 16 | }), |
| 12 | 17 | ); |
| 13 | 18 | |
| @@ -15,9 +20,16 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") · 1 unmodified line | ||
| 15 | 20 | validateSearch: SearchParams, |
| 16 | 21 | component: () => { |
| 17 | 22 | const { pluginKey } = Route.useParams(); |
| 18 | − const { url, preset, namespace } = Route.useSearch(); | |
| 23 | + const { url, preset, namespace, authHeader, authNote } = Route.useSearch(); | |
| 19 | 24 | return ( |
| 20 | − <AddIntegrationPage pluginKey={pluginKey} url={url} preset={preset} namespace={namespace} /> | |
| 25 | + <AddIntegrationPage | |
| 26 | + pluginKey={pluginKey} | |
| 27 | + url={url} | |
| 28 | + preset={preset} | |
| 29 | + namespace={namespace} | |
| 30 | + authHeader={authHeader} | |
| 31 | + authNote={authNote} | |
| 32 | + /> | |
| 21 | 33 | ); |
| 22 | 34 | }, |
| 23 | 35 | }); |