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 | readonly initialUrl?: string; | 198 | readonly initialUrl?: string; |
| 199 | readonly initialPreset?: string; | 199 | readonly initialPreset?: string; |
| 200 | readonly initialNamespace?: string; | 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 | /** Legacy full-page edit surface. No host renders this anymore — plugin | 208 | /** Legacy full-page edit surface. No host renders this anymore — plugin |
| 203 | * configuration lives in the integration Edit sheet via `editSheet`. */ | 209 | * configuration lives in the integration Edit sheet via `editSheet`. */ |
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | |||
| 15 | type AuthMethodRow, | 15 | type AuthMethodRow, |
| 16 | type AuthMethodSeed, | 16 | type AuthMethodSeed, |
| 17 | } from "@executor-js/react/components/auth-method-list-editor"; | 17 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 18 | import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements"; | ||
| 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 19 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | import { | 20 | import { |
| 20 | addIntegrationErrorMessage, | 21 | addIntegrationErrorMessage, |
| @@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines | |||
| 42 | onComplete: (slug?: string) => void; | 43 | onComplete: (slug?: string) => void; |
| 43 | onCancel: () => void; | 44 | onCancel: () => void; |
| 44 | initialUrl?: string; | 45 | initialUrl?: string; |
| 46 | initialAuthHeader?: string; | ||
| 47 | initialAuthNote?: string; | ||
| 45 | }) { | 48 | }) { |
| 46 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); | 49 | const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); |
| 47 | const [description, setDescription] = useState(""); | 50 | const [description, setDescription] = useState(""); |
| 48 | const identity = useIntegrationIdentity({ | 51 | const identity = useIntegrationIdentity({ |
| 49 | fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "", | 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 | const [adding, setAdding] = useState(false); | 65 | const [adding, setAdding] = useState(false); |
| 53 | const [addError, setAddError] = useState<string | null>(null); | 66 | const [addError, setAddError] = useState<string | null>(null); |
| 54 | 67 | ||
| @@ -147,6 +160,10 @@ export default function AddGraphqlIntegration(props: { · 92 unmodified lines | |||
| 147 | footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page." | 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 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />} | 167 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />} |
| 151 | 168 | ||
| 152 | {addError && <FormErrorAlert message={addError} />} | 169 | {addError && <FormErrorAlert message={addError} />} |
| @@ -258,9 +258,12 @@ function DetectedMethodSummary(props: { · 257 unmodified lines | |||
| 258 | from its own page. Saying so is the difference between a confident | 258 | from its own page. Saying so is the difference between a confident |
| 259 | next click and hunting for a field that is not here. */} | 259 | next click and hunting for a field that is not here. */} |
| 260 | <p className="text-[11px] text-muted-foreground"> | 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 | {value.kind === "oauth" | 264 | {value.kind === "oauth" |
| 262 | ? "Read from the spec. You'll sign in after adding this integration." | 265 | ? "Declared by this API. You'll sign in after adding this integration." |
| 263 | : "Read from the spec. You'll enter your key after adding this integration."} | 266 | : "Declared by this API. You'll enter your key after adding this integration."} |
| 264 | </p> | 267 | </p> |
| 265 | </div> | 268 | </div> |
| 266 | ); | 269 | ); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; | 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 | describe("authMethodsFromDescriptors", () => { | 6 | describe("authMethodsFromDescriptors", () => { |
| 7 | it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => { | 7 | it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => { |
| @@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines | |||
| 101 | expect(authMethodsFromDescriptors([])).toEqual([]); | 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 | /** A fresh, empty header placement — the default first row in an editor. */ | 40 | /** A fresh, empty header placement — the default first row in an editor. */ |
| 41 | export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" }); | 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 | /** What an auth method is, presentationally. `kind` drives the credential UI: | 59 | /** What an auth method is, presentationally. `kind` drives the credential UI: |
| 44 | * `oauth` shows a Connect button, `none` creates a connection with no | 60 | * `oauth` shows a Connect button, `none` creates a connection with no |
| 45 | * credential inputs, and apikey/custom fill secrets across `placements`. | 61 | * credential inputs, and apikey/custom fill secrets across `placements`. |
| @@ -45,6 +45,15 @@ export interface CatalogSurface { · 44 unmodified lines | |||
| 45 | * machine-readable locator, in which case the surface document still has to | 45 | * machine-readable locator, in which case the surface document still has to |
| 46 | * be fetched on click. */ | 46 | * be fetched on click. */ |
| 47 | readonly url?: string; | 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 | export interface CatalogSearchEntry { | 59 | export interface CatalogSearchEntry { |
| @@ -107,6 +116,13 @@ const SearchResponse = Schema.Struct({ · 56 unmodified lines | |||
| 107 | kind: Schema.String, | 116 | kind: Schema.String, |
| 108 | slug: Schema.String, | 117 | slug: Schema.String, |
| 109 | url: Schema.optional(Schema.String), | 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 | kind: surface.kind, | 144 | kind: surface.kind, |
| 129 | slug: surface.slug, | 145 | slug: surface.slug, |
| 130 | ...(surface.url ? { url: surface.url } : {}), | 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 | url?: string; | 15 | url?: string; |
| 16 | preset?: string; | 16 | preset?: string; |
| 17 | namespace?: string; | 17 | namespace?: string; |
| 18 | authHeader?: string; | ||
| 19 | authNote?: string; | ||
| 18 | }) { | 20 | }) { |
| 19 | useExecutorDocumentTitle("Add integration"); | 21 | useExecutorDocumentTitle("Add integration"); |
| 20 | const { pluginKey, url, preset, namespace } = props; | 22 | const { pluginKey, url, preset, namespace, authHeader, authNote } = props; |
| 21 | const navigate = useNavigate(); | 23 | const navigate = useNavigate(); |
| 22 | const integrationPlugins = useIntegrationPlugins(); | 24 | const integrationPlugins = useIntegrationPlugins(); |
| 23 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); | 25 | const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom); |
| @@ -57,6 +59,8 @@ export function AddIntegrationPage(props: { · 33 unmodified lines | |||
| 57 | initialUrl={url} | 59 | initialUrl={url} |
| 58 | initialPreset={preset} | 60 | initialPreset={preset} |
| 59 | initialNamespace={namespace} | 61 | initialNamespace={namespace} |
| 62 | initialAuthHeader={authHeader} | ||
| 63 | initialAuthNote={authNote} | ||
| 60 | onComplete={(slug?: string) => { | 64 | onComplete={(slug?: string) => { |
| 61 | trackEvent("integration_added", { | 65 | trackEvent("integration_added", { |
| 62 | plugin_key: pluginKey, | 66 | plugin_key: pluginKey, |
| @@ -407,6 +407,7 @@ export function IntegrationBrowsePage() { · 406 unmodified lines | |||
| 407 | readonly url: string; | 407 | readonly url: string; |
| 408 | readonly slug?: string; | 408 | readonly slug?: string; |
| 409 | readonly domain: string; | 409 | readonly domain: string; |
| 410 | readonly auth?: CatalogSurface["auth"]; | ||
| 410 | }) => { | 411 | }) => { |
| 411 | trackEvent("integration_add_started", { | 412 | trackEvent("integration_add_started", { |
| 412 | plugin_key: input.kind, | 413 | plugin_key: input.kind, |
| @@ -416,7 +417,12 @@ export function IntegrationBrowsePage() { · 3 unmodified lines | |||
| 416 | void navigate({ | 417 | void navigate({ |
| 417 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | 418 | to: "/{-$orgSlug}/integrations/add/$pluginKey", |
| 418 | params: { pluginKey: input.kind }, | 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 | [navigate], | 428 | [navigate], |
| @@ -433,6 +439,7 @@ export function IntegrationBrowsePage() { · 10 unmodified lines | |||
| 433 | url: knownUrl, | 439 | url: knownUrl, |
| 434 | domain: entry.domain, | 440 | domain: entry.domain, |
| 435 | ...(surface ? { slug: surface.slug } : {}), | 441 | ...(surface ? { slug: surface.slug } : {}), |
| 442 | ...(surface?.auth ? { auth: surface.auth } : {}), | ||
| 436 | }); | 443 | }); |
| 437 | return; | 444 | return; |
| 438 | } | 445 | } |
| @@ -457,7 +464,7 @@ export function IntegrationBrowsePage() { · 18 unmodified lines | |||
| 457 | ); | 464 | ); |
| 458 | 465 | ||
| 459 | const pickPreset = useCallback( | 466 | const pickPreset = useCallback( |
| 460 | (entry: PresetEntry) => { | 467 | (entry: PresetEntry, auth?: CatalogSurface["auth"]) => { |
| 461 | trackEvent("integration_add_started", { | 468 | trackEvent("integration_add_started", { |
| 462 | plugin_key: entry.pluginKey, | 469 | plugin_key: entry.pluginKey, |
| 463 | via: "preset", | 470 | via: "preset", |
| @@ -465,6 +472,11 @@ export function IntegrationBrowsePage() { · 1 unmodified line | |||
| 465 | }); | 472 | }); |
| 466 | const search: Record<string, string> = { preset: entry.preset.id }; | 473 | const search: Record<string, string> = { preset: entry.preset.id }; |
| 467 | if (entry.preset.url) search.url = entry.preset.url; | 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 | void navigate({ | 480 | void navigate({ |
| 469 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | 481 | to: "/{-$orgSlug}/integrations/add/$pluginKey", |
| 470 | params: { pluginKey: entry.pluginKey }, | 482 | params: { pluginKey: entry.pluginKey }, |
| @@ -482,7 +494,10 @@ export function IntegrationBrowsePage() { · 11 unmodified lines | |||
| 482 | // other result, and the preset only supplies the better add flow. Keyed by | 494 | // other result, and the preset only supplies the better add flow. Keyed by |
| 483 | // the rendered title, the same match the dedupe below uses. | 495 | // the rendered title, the same match the dedupe below uses. |
| 484 | const catalogIdentityByTitle = useMemo(() => { | 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 | for (const entry of catalogEntries) { | 501 | for (const entry of catalogEntries) { |
| 487 | const pretty = entry.name ?? domainDisplayName(entry.domain); | 502 | const pretty = entry.name ?? domainDisplayName(entry.domain); |
| 488 | const surfaces = | 503 | const surfaces = |
| @@ -492,7 +507,12 @@ export function IntegrationBrowsePage() { · 3 unmodified lines | |||
| 492 | for (const surface of surfaces) { | 507 | for (const surface of surfaces) { |
| 493 | const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; | 508 | const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind]; |
| 494 | const title = withSurface(pretty, word).toLowerCase(); | 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 | return map; | 518 | return map; |
| @@ -517,7 +537,7 @@ export function IntegrationBrowsePage() { · 18 unmodified lines | |||
| 517 | ...(identity ? { domain: identity.domain } : {}), | 537 | ...(identity ? { domain: identity.domain } : {}), |
| 518 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), | 538 | ...(entry.preset.summary ? { description: entry.preset.summary } : {}), |
| 519 | ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), | 539 | ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}), |
| 520 | onSelect: () => pickPreset(entry), | 540 | onSelect: () => pickPreset(entry, identity?.auth), |
| 521 | added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name), | 541 | added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name), |
| 522 | busy: false, | 542 | busy: false, |
| 523 | }); | 543 | }); |
| @@ -8,6 +8,11 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines | |||
| 8 | url: Schema.optional(Schema.String), | 8 | url: Schema.optional(Schema.String), |
| 9 | preset: Schema.optional(Schema.String), | 9 | preset: Schema.optional(Schema.String), |
| 10 | namespace: Schema.optional(Schema.String), | 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 | validateSearch: SearchParams, | 20 | validateSearch: SearchParams, |
| 16 | component: () => { | 21 | component: () => { |
| 17 | const { pluginKey } = Route.useParams(); | 22 | const { pluginKey } = Route.useParams(); |
| 18 | const { url, preset, namespace } = Route.useSearch(); | 23 | const { url, preset, namespace, authHeader, authNote } = Route.useSearch(); |
| 19 | return ( | 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 | }); |