Skip to content

Commit bdcc62c

Browse files
committed
Default Figma OAuth scopes
1 parent 8e1f840 commit bdcc62c

10 files changed

Lines changed: 183 additions & 43 deletions

File tree

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
"@executor-js/plugin-openapi": patch
3+
"@executor-js/sdk": patch
34
---
45

5-
Apply persisted RFC 6902 overrides to OpenAPI specifications during preview, import, and refresh so upstream documents can be corrected without maintaining a fork.
6+
Apply persisted RFC 6902 overrides to OpenAPI specifications during preview, import, and refresh so upstream documents can be corrected without maintaining a fork. Figma imports automatically narrow OAuth to the scopes supported by its OAuth app configuration.

‎packages/core/sdk/src/client.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export interface IntegrationPreset {
116116
readonly family?: string;
117117
readonly specFormat?: string;
118118
readonly defaultSlug?: string;
119+
/** Plugin-specific RFC 6902 operations applied to a fetched specification. */
120+
readonly specOverrides?: readonly unknown[];
119121
readonly authTemplate?: readonly IntegrationPresetAuthentication[];
120122
readonly healthCheck?: HealthCheckSpec;
121123
}

‎packages/core/sdk/src/plugin.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,8 @@ export interface IntegrationPreset {
568568
readonly family?: string;
569569
readonly specFormat?: string;
570570
readonly defaultSlug?: string;
571+
/** Plugin-specific RFC 6902 operations applied to a fetched specification. */
572+
readonly specOverrides?: readonly unknown[];
571573
readonly authTemplate?: readonly IntegrationPresetAuthentication[];
572574
readonly healthCheck?: HealthCheckSpec;
573575
readonly transport?: "remote" | "stdio";

‎packages/plugins/openapi/src/react/AddOpenApiIntegration.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
AddOpenApiHealthCheckSection,
66
baseUrlFromSpecInput,
77
openApiPreviewFailureMessage,
8+
resolveOpenApiPreset,
89
} from "./AddOpenApiIntegration";
10+
import { openApiPresets } from "../sdk/presets";
911

1012
const visibleText = (node: React.ReactNode): string => {
1113
if (node === null || node === undefined || typeof node === "boolean") return "";
@@ -42,6 +44,29 @@ describe("openApiPreviewFailureMessage", () => {
4244
});
4345
});
4446

47+
describe("resolveOpenApiPreset", () => {
48+
it("matches Figma defaults from its canonical spec URL", () => {
49+
const preset = resolveOpenApiPreset(
50+
openApiPresets,
51+
undefined,
52+
"https://raw.githubusercontent.com/figma/rest-api-spec/refs/heads/main/openapi/openapi.yaml#ignored",
53+
);
54+
55+
expect(preset?.id).toBe("figma");
56+
expect(preset?.specOverrides).toBeTruthy();
57+
});
58+
59+
it("prefers an explicitly selected preset over URL matching", () => {
60+
const preset = resolveOpenApiPreset(
61+
openApiPresets,
62+
"stripe",
63+
"https://raw.githubusercontent.com/figma/rest-api-spec/refs/heads/main/openapi/openapi.yaml",
64+
);
65+
66+
expect(preset?.id).toBe("stripe");
67+
});
68+
});
69+
4570
describe("AddOpenApiHealthCheckSection", () => {
4671
const candidate = {
4772
operation: "getCurrentUser",

‎packages/plugins/openapi/src/react/AddOpenApiIntegration.tsx‎

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type HealthCheckCandidate,
1111
type HealthCheckSpec,
1212
} from "@executor-js/sdk/shared";
13-
import { useIntegrationPlugins } from "@executor-js/sdk/client";
13+
import { useIntegrationPlugins, type IntegrationPreset } from "@executor-js/sdk/client";
1414
import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys";
1515
import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms";
1616
import {
@@ -49,7 +49,11 @@ import type { SpecPreviewSummary } from "../sdk/preview";
4949
import { type Authentication } from "../sdk/types";
5050
import { resolveServerUrl } from "../sdk/openapi-utils";
5151
import { detectedAuthenticationTemplates } from "../sdk/derive-auth";
52-
import { parseSpecOverridesText } from "../sdk/spec-overrides";
52+
import {
53+
decodeOpenApiSpecOverrides,
54+
formatSpecOverridesText,
55+
parseSpecOverridesText,
56+
} from "../sdk/spec-overrides";
5357
import { SpecOverridesEditor } from "./SpecOverridesEditor";
5458

5559
const normalizePresetUrl = (url: string): string => {
@@ -61,6 +65,22 @@ const normalizePresetUrl = (url: string): string => {
6165
return parsed.toString().replace(/\/$/, "");
6266
};
6367

68+
/** Resolve catalog defaults from an explicit preset id or a matching spec URL. */
69+
export const resolveOpenApiPreset = (
70+
presets: readonly IntegrationPreset[] | undefined,
71+
initialPresetId: string | undefined,
72+
specUrl: string,
73+
): IntegrationPreset | null => {
74+
const explicitPreset = presets?.find((preset) => preset.id === initialPresetId);
75+
if (explicitPreset) return explicitPreset;
76+
if (specUrl.trim().length === 0) return null;
77+
const normalizedSpecUrl = normalizePresetUrl(specUrl);
78+
return (
79+
presets?.find((preset) => preset.url && normalizePresetUrl(preset.url) === normalizedSpecUrl) ??
80+
null
81+
);
82+
};
83+
6484
const specInputForAdd = (input: string) => {
6585
const value = input.trim();
6686
const parsed = Effect.runSyncExit(
@@ -154,20 +174,23 @@ export default function AddOpenApiIntegration(props: {
154174
const integrationPlugins = useIntegrationPlugins();
155175
const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
156176
const openApiPresets = openApiPlugin?.presets;
157-
const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null;
158177
const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
159-
const [specOverridesText, setSpecOverridesText] = useState("");
178+
const [specOverridesDraft, setSpecOverridesDraft] = useState<string | null>(null);
160179
const [analyzing, setAnalyzing] = useState(false);
161180
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
162181

182+
const activePreset = resolveOpenApiPreset(openApiPresets, props.initialPreset, specUrl);
183+
const presetSpecOverrides = decodeOpenApiSpecOverrides(activePreset?.specOverrides);
184+
const specOverridesText = specOverridesDraft ?? formatSpecOverridesText(presetSpecOverrides);
185+
163186
// After analysis
164187
const [preview, setPreview] = useState<SpecPreviewSummary | null>(null);
165188
const [baseUrl, setBaseUrl] = useState("");
166189
// Agent-visible description: prefilled from the spec's `info.description`
167190
// until the user types (null = untouched, keep deriving from the preview).
168191
const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
169192
const identityFallbackName =
170-
initialPreset?.name ?? (preview ? Option.getOrElse(preview.title, () => "") : "");
193+
activePreset?.name ?? (preview ? Option.getOrElse(preview.title, () => "") : "");
171194
const identity = useIntegrationIdentity({
172195
fallbackName: identityFallbackName,
173196
fallbackNamespace: props.initialNamespace,
@@ -219,14 +242,11 @@ export default function AddOpenApiIntegration(props: {
219242
const firstServerUrl = firstServer
220243
? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
221244
: "";
222-
const previewPresetIcon =
223-
openApiPresets?.find(
224-
(preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
225-
)?.icon ?? null;
245+
const previewPresetIcon = activePreset?.icon ?? null;
226246

227247
const resolvedBaseUrl = baseUrl.trim();
228248
const resolvedIntegrationId =
229-
initialPreset?.defaultSlug ||
249+
activePreset?.defaultSlug ||
230250
slugifyNamespace(identity.namespace) ||
231251
(preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi");
232252
const resolvedDisplayName =
@@ -245,8 +265,8 @@ export default function AddOpenApiIntegration(props: {
245265
// Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the
246266
// freshly-allocated `?? []` fallback arrays.
247267
const authenticationTemplate: readonly Authentication[] = useMemo(() => {
248-
if (initialPreset?.authTemplate) {
249-
return initialPreset.authTemplate.flatMap((template): readonly Authentication[] =>
268+
if (activePreset?.authTemplate) {
269+
return activePreset.authTemplate.flatMap((template): readonly Authentication[] =>
250270
template.kind === "oauth2"
251271
? [
252272
{
@@ -263,7 +283,7 @@ export default function AddOpenApiIntegration(props: {
263283
preview?.oauth2Presets ?? [],
264284
resolvedBaseUrl,
265285
);
266-
}, [initialPreset?.authTemplate, preview, resolvedBaseUrl]);
286+
}, [activePreset?.authTemplate, preview, resolvedBaseUrl]);
267287

268288
// Editable auth methods, seeded from the spec-detected templates. The add flow
269289
// registers EVERY method (P6), so this is a LIST, preserving multi-method
@@ -359,7 +379,7 @@ export default function AddOpenApiIntegration(props: {
359379
const exit = await doPreview({
360380
payload: {
361381
spec: specUrl,
362-
specFormat: initialPreset?.specFormat,
382+
specFormat: activePreset?.specFormat,
363383
...(parsedSpecOverrides.value.length > 0
364384
? { specOverrides: parsedSpecOverrides.value }
365385
: {}),
@@ -391,9 +411,9 @@ export default function AddOpenApiIntegration(props: {
391411
? { description: resolvedDescription.trim() }
392412
: {}),
393413
baseUrl: resolvedBaseUrl,
394-
specFormat: initialPreset?.specFormat,
395-
family: initialPreset?.family,
396-
healthCheck: initialPreset?.healthCheck,
414+
specFormat: activePreset?.specFormat,
415+
family: activePreset?.family,
416+
healthCheck: activePreset?.healthCheck,
397417
...(parsedSpecOverrides.ok && parsedSpecOverrides.value.length > 0
398418
? { specOverrides: parsedSpecOverrides.value }
399419
: {}),
@@ -422,9 +442,9 @@ export default function AddOpenApiIntegration(props: {
422442
resolvedBaseUrl,
423443
editedAuthenticationTemplate,
424444
parsedSpecOverrides,
425-
initialPreset?.family,
426-
initialPreset?.healthCheck,
427-
initialPreset?.specFormat,
445+
activePreset?.family,
446+
activePreset?.healthCheck,
447+
activePreset?.specFormat,
428448
]);
429449

430450
const handleAdd = async () => {
@@ -565,7 +585,7 @@ export default function AddOpenApiIntegration(props: {
565585
<SpecOverridesEditor
566586
value={specOverridesText}
567587
onChange={(value) => {
568-
setSpecOverridesText(value);
588+
setSpecOverridesDraft(value);
569589
setAnalyzeError(null);
570590
setPreview(null);
571591
setBaseUrl("");
@@ -583,7 +603,7 @@ export default function AddOpenApiIntegration(props: {
583603

584604
{preview ? (
585605
<AddOpenApiHealthCheckSection
586-
presetHealthCheck={initialPreset?.healthCheck}
606+
presetHealthCheck={activePreset?.healthCheck}
587607
candidates={healthCheckCandidates}
588608
selected={hcSelected}
589609
operation={hcOperation}

‎packages/plugins/openapi/src/sdk/plugin.ts‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type AuthMethodDescriptor,
1717
type Integration,
1818
type IntegrationConfig,
19+
type IntegrationPreset,
1920
type IntegrationRecord,
2021
type PluginCtx,
2122
type StorageFailure,
@@ -39,7 +40,7 @@ import {
3940
type SpecPreview,
4041
} from "./preview";
4142
import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
42-
import { openApiPresets, type OpenApiPreset } from "./presets";
43+
import { openApiPresets } from "./presets";
4344
import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";
4445
import {
4546
resolveSpecFormatAdapter,
@@ -62,7 +63,12 @@ import {
6263
} from "./backing";
6364
import type { InvokeOptions } from "./invoke";
6465
import { resolveServerUrl } from "./openapi-utils";
65-
import { applySpecOverrides, SpecOverridesSchema, type SpecOverrides } from "./spec-overrides";
66+
import {
67+
applySpecOverrides,
68+
decodeOpenApiSpecOverrides,
69+
SpecOverridesSchema,
70+
type SpecOverrides,
71+
} from "./spec-overrides";
6672

6773
const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
6874

@@ -608,7 +614,7 @@ export interface OpenApiPluginOptions {
608614
readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
609615
readonly invokeOptions?: InvokeOptions;
610616
readonly specFormats?: readonly SpecFormatAdapter[];
611-
readonly presets?: readonly OpenApiPreset[];
617+
readonly presets?: readonly IntegrationPreset[];
612618
}
613619

614620
export const openApiPlugin = definePlugin<
@@ -687,19 +693,23 @@ export const openApiPlugin = definePlugin<
687693
id: "openapi" as const,
688694
packageName: "@executor-js/plugin-openapi",
689695
clientConfig: options?.presets ? { presets: options.presets } : undefined,
690-
integrationPresets: [...openApiPresets, ...(options?.presets ?? [])].map((preset) => ({
691-
id: preset.id,
692-
name: preset.name,
693-
summary: preset.summary,
694-
...(preset.url ? { url: preset.url } : {}),
695-
...(preset.icon ? { icon: preset.icon } : {}),
696-
...(preset.featured ? { featured: preset.featured } : {}),
697-
...(preset.family ? { family: preset.family } : {}),
698-
...(preset.specFormat ? { specFormat: preset.specFormat } : {}),
699-
...(preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}),
700-
...(preset.authTemplate ? { authTemplate: preset.authTemplate } : {}),
701-
...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}),
702-
})),
696+
integrationPresets: [...openApiPresets, ...(options?.presets ?? [])].map((preset) => {
697+
const specOverrides = decodeOpenApiSpecOverrides(preset.specOverrides);
698+
return {
699+
id: preset.id,
700+
name: preset.name,
701+
summary: preset.summary,
702+
...(preset.url ? { url: preset.url } : {}),
703+
...(preset.icon ? { icon: preset.icon } : {}),
704+
...(preset.featured ? { featured: preset.featured } : {}),
705+
...(preset.family ? { family: preset.family } : {}),
706+
...(preset.specFormat ? { specFormat: preset.specFormat } : {}),
707+
...(preset.defaultSlug ? { defaultSlug: preset.defaultSlug } : {}),
708+
...(specOverrides ? { specOverrides } : {}),
709+
...(preset.authTemplate ? { authTemplate: preset.authTemplate } : {}),
710+
...(preset.healthCheck ? { healthCheck: preset.healthCheck } : {}),
711+
};
712+
}),
703713
storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
704714

705715
extension: (ctx: PluginCtx<OpenapiStore>) => {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { openApiPlugin } from "./plugin";
4+
import { FIGMA_SPEC_OVERRIDES, FIGMA_SUPPORTED_OAUTH_SCOPES, openApiPresets } from "./presets";
5+
6+
const unsupportedFigmaOAuthScopes = [
7+
"file_variables:read",
8+
"file_variables:write",
9+
"files:read",
10+
"library_analytics:read",
11+
] as const;
12+
13+
describe("OpenAPI presets", () => {
14+
it("narrows Figma OAuth to the supported scope set", () => {
15+
expect(FIGMA_SUPPORTED_OAUTH_SCOPES).toHaveLength(15);
16+
for (const scope of unsupportedFigmaOAuthScopes) {
17+
expect(FIGMA_SUPPORTED_OAUTH_SCOPES).not.toContain(scope);
18+
}
19+
20+
const operation = FIGMA_SPEC_OVERRIDES[0];
21+
expect(operation).toMatchObject({
22+
op: "replace",
23+
path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
24+
value: Object.fromEntries(FIGMA_SUPPORTED_OAUTH_SCOPES.map((scope) => [scope, ""])),
25+
});
26+
});
27+
28+
it("projects Figma spec overrides through the plugin catalog", () => {
29+
const sdkPreset = openApiPresets.find((preset) => preset.id === "figma");
30+
const catalogPreset = openApiPlugin().integrationPresets?.find(
31+
(preset) => preset.id === "figma",
32+
);
33+
34+
expect(sdkPreset?.specOverrides).toEqual(FIGMA_SPEC_OVERRIDES);
35+
expect(catalogPreset?.specOverrides).toEqual(FIGMA_SPEC_OVERRIDES);
36+
});
37+
});

‎packages/plugins/openapi/src/sdk/presets.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { HealthCheckSpec, IntegrationPresetAuthentication } from "@executor-js/sdk/core";
2+
import type { SpecOverrides } from "./spec-overrides";
23

34
export interface OpenApiPreset {
45
readonly id: string;
@@ -10,11 +11,48 @@ export interface OpenApiPreset {
1011
readonly family?: string;
1112
readonly specFormat?: string;
1213
readonly defaultSlug?: string;
14+
readonly specOverrides?: SpecOverrides;
1315
readonly authTemplate?: readonly IntegrationPresetAuthentication[];
1416
readonly healthCheck?: HealthCheckSpec;
1517
}
1618

19+
export const FIGMA_SUPPORTED_OAUTH_SCOPES = [
20+
"current_user:read",
21+
"file_comments:read",
22+
"file_comments:write",
23+
"file_content:read",
24+
"file_dev_resources:read",
25+
"file_dev_resources:write",
26+
"file_metadata:read",
27+
"file_versions:read",
28+
"library_assets:read",
29+
"library_content:read",
30+
"project_metadata:read",
31+
"projects:read",
32+
"team_library_content:read",
33+
"webhooks:read",
34+
"webhooks:write",
35+
] as const;
36+
37+
export const FIGMA_SPEC_OVERRIDES: SpecOverrides = [
38+
{
39+
op: "replace",
40+
path: "/components/securitySchemes/OAuth2/flows/authorizationCode/scopes",
41+
value: Object.fromEntries(FIGMA_SUPPORTED_OAUTH_SCOPES.map((scope) => [scope, ""])),
42+
},
43+
];
44+
1745
const openApiOnlyPresets: readonly OpenApiPreset[] = [
46+
{
47+
id: "figma",
48+
name: "Figma",
49+
summary: "Files, comments, components, variables, projects, and webhooks.",
50+
url: "https://raw.githubusercontent.com/figma/rest-api-spec/refs/heads/main/openapi/openapi.yaml",
51+
icon: "https://integrations.sh/logo/figma.com",
52+
featured: true,
53+
defaultSlug: "figma_api",
54+
specOverrides: FIGMA_SPEC_OVERRIDES,
55+
},
1856
{
1957
id: "stripe",
2058
name: "Stripe",

0 commit comments

Comments
 (0)