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
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 — plugin208 /** 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`. */
packages/plugins/graphql/src/react/AddGraphqlIntegration.tsx+18−1
@@ -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";
18import { placementFromHeaderPattern } from "@executor-js/react/lib/auth-placements";
18import { FloatActions } from "@executor-js/react/components/float-actions";19import { FloatActions } from "@executor-js/react/components/float-actions";
19import {20import {
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} />}
packages/react/src/components/auth-method-list-editor.tsx+5−2
@@ -258,9 +258,12 @@ function DetectedMethodSummary(props: { · 257 unmodified lines
258 from its own page. Saying so is the difference between a confident258 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 );
packages/react/src/lib/auth-placements.test.ts+23−1
@@ -1,7 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";2import type { AuthMethodDescriptor } from "@executor-js/sdk/shared";
3 3
4import { authMethodsFromDescriptors } from "./auth-placements";4import { authMethodsFromDescriptors, placementFromHeaderPattern } from "./auth-placements";
5 5
6describe("authMethodsFromDescriptors", () => {6describe("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
105describe("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
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. */
41export const emptyPlacement = (): Placement => ({ carrier: "header", name: "", prefix: "" });41export 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. */
47export 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 no60 * `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`.
packages/react/src/lib/integrations-sh-catalog.ts+17−0
@@ -45,6 +45,15 @@ export interface CatalogSurface { · 44 unmodified lines
45 * machine-readable locator, in which case the surface document still has to45 * 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
50export interface CatalogSearchEntry {59export 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 : [],
packages/react/src/pages/integration-add.tsx+5−1
@@ -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,
packages/react/src/pages/integration-browse.tsx+25−5
@@ -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 by494 // 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 });
packages/react/src/routes/integrations.add.$pluginKey.tsx+14−2
@@ -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});

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