History

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.

Rhys Sullivan committed 2 months ago

Commit d3acfe31 parent 325389c9 files · +129 −12Browse files

Unified · Split

9 files
  1. packages/core/sdk/src/client.ts +6 −0
  2. packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx +18 −1
  3. packages/react/src/components/auth-method-list-editor.tsx +5 −2
  4. packages/react/src/lib/auth-placements.test.ts +23 −1
  5. packages/react/src/lib/auth-placements.tsx +16 −0
  6. packages/react/src/lib/integrations-sh-catalog.ts +17 −0
  7. packages/react/src/pages/integration-add.tsx +5 −1
  8. packages/react/src/pages/integration-browse.tsx +25 −5
  9. packages/react/src/routes/integrations.add.$pluginKey.tsx +14 −2
packages/core/sdk/src/client.ts+6−0
@@ -198,6 +198,12 @@ export interface IntegrationPlugin { · 197 unmodified lines
198198 readonly initialUrl?: string;
199199 readonly initialPreset?: string;
200200 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;
201207 }>;
202208 /** Legacy full-page edit surface. No host renders this anymore — plugin
203209 * configuration lives in the integration Edit sheet via `editSheet`. */
packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx+18−1
@@ -15,6 +15,7 @@ import { · 14 unmodified lines
1515 type AuthMethodRow,
1616 type AuthMethodSeed,
1717 } from "@executor-js/react/components/auth-method-list-editor";
18+import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
1819 import { FloatActions } from "@executor-js/react/components/float-actions";
1920 import {
2021 addIntegrationErrorMessage,
@@ -42,13 +43,25 @@ export default function AddGraphqlIntegration(props: { · 21 unmodified lines
4243 onComplete: (slug?: string) => void;
4344 onCancel: () => void;
4445 initialUrl?: string;
46+ initialAuthHeader?: string;
47+ initialAuthNote?: string;
4548 }) {
4649 const [endpoint, setEndpoint] = useState(props.initialUrl ?? "");
4750 const [description, setDescription] = useState("");
4851 const identity = useIntegrationIdentity({
4952 fallbackName: integrationDisplayNameFromUrl(endpoint, "GraphQL") ?? "",
5053 });
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);
5265 const [adding, setAdding] = useState(false);
5366 const [addError, setAddError] = useState<string | null>(null);
5467
@@ -147,6 +160,10 @@ export default function AddGraphqlIntegration(props: { · 92 unmodified lines
147160 footerHint="Nothing here takes your credential. Add the integration first, then connect an account on its page."
148161 />
149162
163+ {props.initialAuthNote ? (
164+ <p className="text-xs leading-relaxed text-muted-foreground">{props.initialAuthNote}</p>
165+ ) : null}
166+
150167 {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSlug} />}
151168
152169 {addError && <FormErrorAlert message={addError} />}
packages/react/src/components/auth-method-list-editor.tsx+5−2
@@ -258,9 +258,12 @@ function DetectedMethodSummary(props: { · 257 unmodified lines
258258 from its own page. Saying so is the difference between a confident
259259 next click and hunting for a field that is not here. */}
260260 <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. */}
261264 {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."}
264267 </p>
265268 </div>
266269 );
packages/react/src/lib/auth-placements.test.ts+23−1
@@ -1,7 +1,7 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";
33
4−import { authMethodsFromDescriptors } from "./auth-placements";
4+import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements";
55
66 describe("authMethodsFromDescriptors", () => {
77 it("maps an oauth descriptor and carries discoveryUrl + supportsDynamicRegistration", () => {
@@ -101,3 +101,25 @@ describe("authMethodsFromDescriptors", () => { · 93 unmodified lines
101101 expect(authMethodsFromDescriptors([])).toEqual([]);
102102 });
103103 });
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+});
packages/react/src/lib/auth-placements.tsx+16−0
@@ -40,6 +40,22 @@ export interface Placement { · 39 unmodified lines
4040 /** A fresh, empty header placement — the default first row in an editor. */
4141 export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" });
4242
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+
4359 /** What an auth method is, presentationally. `kind` drives the credential UI:
4460 * `oauth` shows a Connect button, `none` creates a connection with no
4561 * credential inputs, and apikey/custom fill secrets across `placements`.
packages/react/src/lib/integrations-sh-catalog.ts+17−0
@@ -45,6 +45,15 @@ export interface CatalogSurface { · 44 unmodified lines
4545 * machine-readable locator, in which case the surface document still has to
4646 * be fetched on click. */
4747 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+ };
4857 }
4958
5059 export interface CatalogSearchEntry {
@@ -107,6 +116,13 @@ const SearchResponse = Schema.Struct({ · 56 unmodified lines
107116 kind: Schema.String,
108117 slug: Schema.String,
109118 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+ ),
110126 }),
111127 ),
112128 ),
@@ -128,6 +144,7 @@ export const parseCatalogSearch = (payload: unknown): readonly CatalogSearchEntr · 15 unmodified lines
128144 kind: surface.kind,
129145 slug: surface.slug,
130146 ...(surface.url ? { url: surface.url } : {}),
147+ ...(surface.auth ? { auth: surface.auth } : {}),
131148 },
132149 ]
133150 : [],
packages/react/src/pages/integration-add.tsx+5−1
@@ -15,9 +15,11 @@ export function AddIntegrationPage(props: { · 14 unmodified lines
1515 url?: string;
1616 preset?: string;
1717 namespace?: string;
18+ authHeader?: string;
19+ authNote?: string;
1820 }) {
1921 useExecutorDocumentTitle("Add integration");
20− const { pluginKey, url, preset, namespace } = props;
22+ const { pluginKey, url, preset, namespace, authHeader, authNote } = props;
2123 const navigate = useNavigate();
2224 const integrationPlugins = useIntegrationPlugins();
2325 const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
@@ -57,6 +59,8 @@ export function AddIntegrationPage(props: { · 33 unmodified lines
5759 initialUrl={url}
5860 initialPreset={preset}
5961 initialNamespace={namespace}
62+ initialAuthHeader={authHeader}
63+ initialAuthNote={authNote}
6064 onComplete={(slug?: string) => {
6165 trackEvent("integration_added", {
6266 plugin_key: pluginKey,
packages/react/src/pages/integration-browse.tsx+25−5
@@ -407,6 +407,7 @@ export function IntegrationBrowsePage() { · 406 unmodified lines
407407 readonly url: string;
408408 readonly slug?: string;
409409 readonly domain: string;
410+ readonly auth?: CatalogSurface["auth"];
410411 }) => {
411412 trackEvent("integration_add_started", {
412413 plugin_key: input.kind,
@@ -416,7 +417,12 @@ export function IntegrationBrowsePage() { · 3 unmodified lines
416417 void navigate({
417418 to: "/{-$orgSlug}/integrations/add/$pluginKey",
418419 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+ },
420426 });
421427 },
422428 [navigate],
@@ -433,6 +439,7 @@ export function IntegrationBrowsePage() { · 10 unmodified lines
433439 url: knownUrl,
434440 domain: entry.domain,
435441 ...(surface ? { slug: surface.slug } : {}),
442+ ...(surface?.auth ? { auth: surface.auth } : {}),
436443 });
437444 return;
438445 }
@@ -457,7 +464,7 @@ export function IntegrationBrowsePage() { · 18 unmodified lines
457464 );
458465
459466 const pickPreset = useCallback(
460− (entry: PresetEntry) => {
467+ (entry: PresetEntry, auth?: CatalogSurface["auth"]) => {
461468 trackEvent("integration_add_started", {
462469 plugin_key: entry.pluginKey,
463470 via: "preset",
@@ -465,6 +472,11 @@ export function IntegrationBrowsePage() { · 1 unmodified line
465472 });
466473 const search: Record<string, string> = { preset: entry.preset.id };
467474 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;
468480 void navigate({
469481 to: "/{-$orgSlug}/integrations/add/$pluginKey",
470482 params: { pluginKey: entry.pluginKey },
@@ -482,7 +494,10 @@ export function IntegrationBrowsePage() { · 11 unmodified lines
482494 // other result, and the preset only supplies the better add flow. Keyed by
483495 // the rendered title, the same match the dedupe below uses.
484496 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+ >();
486501 for (const entry of catalogEntries) {
487502 const pretty = entry.name ?? domainDisplayName(entry.domain);
488503 const surfaces =
@@ -492,7 +507,12 @@ export function IntegrationBrowsePage() { · 3 unmodified lines
492507 for (const surface of surfaces) {
493508 const word = SURFACE_WORD[surface.kind] ?? CATALOG_KIND_LABEL[surface.kind];
494509 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+ }
496516 }
497517 }
498518 return map;
@@ -517,7 +537,7 @@ export function IntegrationBrowsePage() { · 18 unmodified lines
517537 ...(identity ? { domain: identity.domain } : {}),
518538 ...(entry.preset.summary ? { description: entry.preset.summary } : {}),
519539 ...(entry.preset.icon ? { iconUrl: entry.preset.icon } : {}),
520− onSelect: () => pickPreset(entry),
540+ onSelect: () => pickPreset(entry, identity?.auth),
521541 added: isAdded(entry.pluginKey, entry.preset.defaultSlug, entry.preset.name),
522542 busy: false,
523543 });
packages/react/src/routes/integrations.add.$pluginKey.tsx+14−2
@@ -8,6 +8,11 @@ const SearchParams = Schema.toStandardSchemaV1( · 7 unmodified lines
88 url: Schema.optional(Schema.String),
99 preset: Schema.optional(Schema.String),
1010 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),
1116 }),
1217 );
1318
@@ -15,9 +20,16 @@ export const Route = createFileRoute("/{-$orgSlug}/integrations/add/$pluginKey") · 1 unmodified line
1520 validateSearch: SearchParams,
1621 component: () => {
1722 const { pluginKey } = Route.useParams();
18− const { url, preset, namespace } = Route.useSearch();
23+ const { url, preset, namespace, authHeader, authNote } = Route.useSearch();
1924 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+ />
2133 );
2234 },
2335 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t