diff --git a/apps/landing/src/shared/icons/brands.tsx b/apps/landing/src/shared/icons/brands.tsx index e4040ce8..f11b7fc7 100644 --- a/apps/landing/src/shared/icons/brands.tsx +++ b/apps/landing/src/shared/icons/brands.tsx @@ -1,5 +1,6 @@ import { CodexIcon } from "@onequery/ui/icons/codex-icon"; import { E2BIcon } from "@onequery/ui/icons/e2b-icon"; +import { FigmaIcon } from "@onequery/ui/icons/figma-icon"; import { GoogleSearchConsoleIcon } from "@onequery/ui/icons/google-search-console-icon"; import { GranolaIcon } from "@onequery/ui/icons/granola-icon"; import { HermesAgentIcon } from "@onequery/ui/icons/hermes-agent-icon"; @@ -208,6 +209,7 @@ const BRAND_ICONS = { curl: createSimpleBrandIcon(siCurl), discord: createSimpleBrandIcon(siDiscord), e2b: E2BIcon, + figma: FigmaIcon, ga: createSimpleBrandIcon(siGoogleanalytics), github: createSimpleBrandIcon(siGithub), googledrive: createSimpleBrandIcon(siGoogledrive), diff --git a/packages/db/src/__snapshots__/credentials.test.ts.snap b/packages/db/src/__snapshots__/credentials.test.ts.snap index 0a9a81ba..26a464b4 100644 --- a/packages/db/src/__snapshots__/credentials.test.ts.snap +++ b/packages/db/src/__snapshots__/credentials.test.ts.snap @@ -16,6 +16,7 @@ exports[`credentials schemas > credentialSchemaMap > matches supported provider "confluence", "discord", "e2b", + "figma", "ga", "github", "google_search_console", diff --git a/packages/db/src/credentials.ts b/packages/db/src/credentials.ts index c83bd2ff..a74c46ab 100644 --- a/packages/db/src/credentials.ts +++ b/packages/db/src/credentials.ts @@ -265,6 +265,15 @@ export const GitHubCredentialsSchema = z.object({ export type GitHubCredentials = z.infer; +export const FigmaCredentialsSchema = z.object({ + personalAccessToken: requiredOpaqueString( + "Personal access token is required" + ), + type: z.literal("figma"), +}); + +export type FigmaCredentials = z.infer; + export const AirtableCredentialsSchema = z.object({ apiBaseUrl: optionalTrimmedUrl("API base URL must be a valid URL"), baseId: optionalTrimmedString("Base ID is required"), @@ -704,6 +713,7 @@ export const CredentialsSchema = z.union([ PostHogCredentialsSchema, SentryCredentialsSchema, GitHubCredentialsSchema, + FigmaCredentialsSchema, AirtableCredentialsSchema, DiscordCredentialsSchema, SlackCredentialsSchema, @@ -779,6 +789,7 @@ export const credentialSchemaMap = { confluence: ConfluenceCredentialsSchema, discord: DiscordCredentialsSchema, e2b: E2BCredentialsSchema, + figma: FigmaCredentialsSchema, ga: GoogleAnalyticsCredentialsSchema, github: GitHubCredentialsSchema, google_search_console: GoogleSearchConsoleCredentialsSchema, diff --git a/packages/db/src/figma-credentials.test.ts b/packages/db/src/figma-credentials.test.ts new file mode 100644 index 00000000..da8a5633 --- /dev/null +++ b/packages/db/src/figma-credentials.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { FigmaCredentialsSchema } from "./credentials"; +import { safeParseSourceProviderCredentials } from "./source-providers"; + +describe("Figma credentials", () => { + it("accepts a personal access token", () => { + expect( + FigmaCredentialsSchema.safeParse({ + personalAccessToken: "figd_test_token", + type: "figma", + }).success + ).toBe(true); + }); + + it("rejects blank personal access tokens", () => { + expect( + FigmaCredentialsSchema.safeParse({ + personalAccessToken: " ", + type: "figma", + }).success + ).toBe(false); + }); + + it("injects the credential discriminator from the provider", () => { + const parsed = safeParseSourceProviderCredentials({ + credentials: { personalAccessToken: "figd_test_token" }, + provider: "figma", + }); + + expect(parsed).toMatchObject({ + data: { + credentials: { + personalAccessToken: "figd_test_token", + type: "figma", + }, + provider: "figma", + }, + success: true, + }); + }); +}); diff --git a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap index 4f2671b7..628e1b8b 100644 --- a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap +++ b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap @@ -22,6 +22,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = ` "posthog", "sentry", "github", + "figma", "airtable", "discord", "slack", @@ -64,6 +65,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = ` "posthog", "sentry", "github", + "figma", "airtable", "discord", "slack", diff --git a/packages/db/src/source-providers.ts b/packages/db/src/source-providers.ts index 1c68e506..bf8a1a39 100644 --- a/packages/db/src/source-providers.ts +++ b/packages/db/src/source-providers.ts @@ -15,6 +15,7 @@ import { ConnectorCredentialsSchema, DiscordCredentialsSchema, E2BCredentialsSchema, + FigmaCredentialsSchema, GitHubCredentialsSchema, GoogleSearchConsoleCredentialsSchema, GoogleAnalyticsCredentialsSchema, @@ -703,6 +704,35 @@ export const SOURCE_PROVIDER_REGISTRY = { }, }, }, + figma: { + label: "Figma", + credentialSchema: FigmaCredentialsSchema, + credentialType: "figma", + connectable: true, + analysisSource: true, + queryInterface: false, + sourceApiInterface: true, + testable: false, + dashboardConnectable: true, + dashboardCredentialForm: "json", + publicCategory: "Developer workflow", + guide: { + summary: + "Connect Figma with a personal access token for read-only REST API and grouped design-context access.", + steps: [ + "Create a Figma personal access token with the `file_content:read` scope.", + "Add `file_variables:read` when grouped design-context requests should include local variable definitions.", + "Copy the token into `credentials.personalAccessToken`.", + "Use `fetch_api` for read-only REST paths or `prepare_design_context` to bundle nodes, renders, image fills, and optional variables for selected frames.", + ], + exampleInput: { + sourceKey: "figma_product", + credentials: { + personalAccessToken: "figd_personal_access_token", + }, + }, + }, + }, airtable: { label: "Airtable", credentialSchema: AirtableCredentialsSchema, diff --git a/packages/server/src/source-api/adapters/figma-api.ts b/packages/server/src/source-api/adapters/figma-api.ts new file mode 100644 index 00000000..2fb89db9 --- /dev/null +++ b/packages/server/src/source-api/adapters/figma-api.ts @@ -0,0 +1,114 @@ +import type { JsonValue } from "@bufbuild/protobuf"; +import type { FigmaCredentials } from "@onequery/db/server"; +import { z } from "zod"; + +import { MAX_PROVIDER_ERROR_DETAIL_LENGTH } from "../../services/provider-http"; +import { ProviderHttpClient } from "../../services/provider-http-client"; + +const FIGMA_API_BASE_URL = "https://api.figma.com"; + +type FigmaDesignContextApiRequest = { + depth?: number; + fileKey: string; + includeImageFills: boolean; + includeVariables: boolean; + nodeIds: readonly string[]; + renderFormat: "jpg" | "png" | "svg"; + renderScale: number; + timeoutMs?: number; +}; + +export async function requestFigmaDesignContext( + credentials: FigmaCredentials, + request: FigmaDesignContextApiRequest +): Promise { + const client = createFigmaHttpClient(credentials); + const fileKey = encodeURIComponent(request.fileKey); + const ids = request.nodeIds.join(","); + const commonParams = { + ...(request.depth === undefined ? {} : { depth: request.depth }), + ids, + }; + const [nodes, renders, imageFillsResult, variablesResult] = await Promise.all( + [ + client.get(`/v1/files/${fileKey}/nodes`, commonParams, request.timeoutMs), + client.get( + `/v1/images/${fileKey}`, + { + format: request.renderFormat, + ids, + scale: request.renderScale, + }, + request.timeoutMs + ), + request.includeImageFills + ? requestOptionalFigmaJson( + client.get( + `/v1/files/${fileKey}/images`, + undefined, + request.timeoutMs + ), + "Figma image fills are unavailable" + ) + : Promise.resolve({ value: null, warning: null }), + request.includeVariables + ? requestOptionalFigmaJson( + client.get( + `/v1/files/${fileKey}/variables/local`, + undefined, + request.timeoutMs + ), + "Figma local variables are unavailable" + ) + : Promise.resolve({ value: null, warning: null }), + ] + ); + const warnings = [imageFillsResult.warning, variablesResult.warning].filter( + (warning): warning is string => warning !== null + ); + + return z.json().parse({ + fileKey: request.fileKey, + imageFills: imageFillsResult.value, + localVariables: variablesResult.value, + nodeIds: request.nodeIds, + nodes, + renders, + warnings, + }); +} + +function createFigmaHttpClient( + credentials: FigmaCredentials +): ProviderHttpClient { + return new ProviderHttpClient({ + auth: { + type: "raw", + value: credentials.personalAccessToken, + }, + authHeaderName: "X-Figma-Token", + baseUrl: FIGMA_API_BASE_URL, + defaultHeaders: { Accept: "application/json" }, + providerName: "Figma", + sanitize: (text) => + text + .split(credentials.personalAccessToken) + .join("***") + .slice(0, MAX_PROVIDER_ERROR_DETAIL_LENGTH), + }); +} + +async function requestOptionalFigmaJson( + request: Promise, + warningPrefix: string +): Promise<{ value: unknown; warning: string | null }> { + try { + return { value: await request, warning: null }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + value: null, + warning: `${warningPrefix}: ${detail}`, + }; + } +} diff --git a/packages/server/src/source-api/adapters/figma-design-context-request.ts b/packages/server/src/source-api/adapters/figma-design-context-request.ts new file mode 100644 index 00000000..9b7de0ea --- /dev/null +++ b/packages/server/src/source-api/adapters/figma-design-context-request.ts @@ -0,0 +1,96 @@ +import type { JsonObject } from "@bufbuild/protobuf"; +import { isRecord } from "@onequery/base"; +import { z } from "zod"; + +import { MAX_PROVIDER_REQUEST_TIMEOUT_MS } from "../../services/provider-http"; +import { SourceApiInvalidRequestError } from "../errors"; +import type { SourceApiRequestBody } from "../types"; +import { + figmaFileKeySchema, + figmaNodeIdSchema, + parseFigmaDesignUrl, +} from "./figma-url"; + +const figmaDesignContextInputSchema = z + .object({ + depth: z.number().int().min(1).max(20).optional(), + fileKey: figmaFileKeySchema.optional(), + includeImageFills: z.boolean().default(true), + includeVariables: z.boolean().default(false), + nodeIds: z.array(figmaNodeIdSchema).min(1).max(20).optional(), + renderFormat: z.enum(["jpg", "png", "svg"]).default("png"), + renderScale: z.number().min(0.01).max(4).default(2), + timeoutMs: z + .number() + .int() + .min(1) + .max(MAX_PROVIDER_REQUEST_TIMEOUT_MS) + .optional(), + url: z.url().optional(), + }) + .strict(); + +type FigmaDesignContextInput = z.infer; + +export class FigmaDesignContextInvalidRequestError extends SourceApiInvalidRequestError {} + +export function parseFigmaDesignContextRequest(value: JsonObject): JsonObject & + Omit & { + fileKey: string; + nodeIds: string[]; + } { + const parsed = figmaDesignContextInputSchema.safeParse(value); + if (!parsed.success) { + throwInvalidRequest(); + } + + const reference = parsed.data.url + ? parseFigmaReference(parsed.data.url) + : null; + const fileKey = parsed.data.fileKey ?? reference?.fileKey; + const nodeIds = parsed.data.nodeIds ?? (reference ? [reference.nodeId] : []); + if (!fileKey || nodeIds.length === 0) { + throwInvalidRequest(); + } + + return { + ...(parsed.data.depth === undefined ? {} : { depth: parsed.data.depth }), + fileKey, + includeImageFills: parsed.data.includeImageFills, + includeVariables: parsed.data.includeVariables, + nodeIds, + renderFormat: parsed.data.renderFormat, + renderScale: parsed.data.renderScale, + ...(parsed.data.timeoutMs === undefined + ? {} + : { timeoutMs: parsed.data.timeoutMs }), + }; +} + +export function parseFigmaDesignContextBody( + body: SourceApiRequestBody +): JsonObject { + if (body.kind === "none") { + return {}; + } + if (body.kind === "json" && isRecord(body.value)) { + return body.value; + } + throw new FigmaDesignContextInvalidRequestError( + "Figma design context requests must be JSON objects" + ); +} + +function parseFigmaReference(url: string) { + try { + return parseFigmaDesignUrl(url); + } catch { + throwInvalidRequest(); + } +} + +function throwInvalidRequest(): never { + throw new FigmaDesignContextInvalidRequestError( + "Invalid Figma design context request" + ); +} diff --git a/packages/server/src/source-api/adapters/figma-design-context.test.ts b/packages/server/src/source-api/adapters/figma-design-context.test.ts new file mode 100644 index 00000000..e76c099d --- /dev/null +++ b/packages/server/src/source-api/adapters/figma-design-context.test.ts @@ -0,0 +1,158 @@ +import type { JsonObject } from "@bufbuild/protobuf"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { finalizePreparedSourceApi } from "../normalize"; +import type { PreparedSourceConnection, SourceApiActorContext } from "../types"; +import { figmaSourceApiAdapter } from "./figma"; + +const originalFetch = globalThis.fetch; + +const actor: SourceApiActorContext = { + capabilities: ["source_api.describe", "source_api.execute"], + membershipRoles: ["owner"], + organizationId: "org_1", + organizationSlug: "acme", + userId: "user_1", +}; + +const source: PreparedSourceConnection = { + credentials: { + personalAccessToken: "figd_test_token", + type: "figma", + }, + displayName: "Product design", + id: "source_1", + provider: "figma", + sourceKey: "figma-design", +}; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("Figma grouped design context", () => { + it("fetches nodes, renders, image fills, and optional variables together", async () => { + const fetchMock = vi.fn( + async (input: RequestInfo | URL, _init?: RequestInit) => { + const url = String(input); + if (url.includes("/variables/local")) { + return Response.json({ meta: { variables: { color: "blue" } } }); + } + if (url.includes("/files/") && url.endsWith("/images")) { + return Response.json({ + images: { image_ref: "https://cdn.example.com/fill.png" }, + }); + } + if (url.includes("/v1/images/")) { + return Response.json({ + images: { + "2578:39032": "https://cdn.example.com/reference.png", + }, + }); + } + return Response.json({ + nodes: { + "2578:39032": { + document: { id: "2578:39032", name: "Desktop" }, + }, + }, + }); + } + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const response = await executeDesignContext({ + fileKey: "wwSVz2qdnWZ2U8ZBwQE9QN", + includeVariables: true, + nodeIds: ["2578:39032"], + }); + + expect(fetchMock).toHaveBeenCalledTimes(4); + for (const [, init] of fetchMock.mock.calls) { + expect(init).toMatchObject({ + headers: expect.objectContaining({ + "X-Figma-Token": "figd_test_token", + }), + method: "GET", + }); + } + expect(response).toMatchObject({ + body: { + kind: "json", + value: { + fileKey: "wwSVz2qdnWZ2U8ZBwQE9QN", + imageFills: { + images: { image_ref: "https://cdn.example.com/fill.png" }, + }, + localVariables: { + meta: { variables: { color: "blue" } }, + }, + nodeIds: ["2578:39032"], + nodes: { + nodes: { + "2578:39032": { + document: { id: "2578:39032", name: "Desktop" }, + }, + }, + }, + renders: { + images: { + "2578:39032": "https://cdn.example.com/reference.png", + }, + }, + warnings: [], + }, + }, + operation: "prepare_design_context", + status: 200, + }); + }); + + it("returns partial context when optional variable access is unavailable", async () => { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes("/variables/local")) { + return new Response("Limited by Figma plan", { status: 403 }); + } + return Response.json({}); + }) as unknown as typeof fetch; + + const response = await executeDesignContext({ + fileKey: "wwSVz2qdnWZ2U8ZBwQE9QN", + includeImageFills: false, + includeVariables: true, + nodeIds: ["2578:39032"], + }); + + expect(response.body).toMatchObject({ + kind: "json", + value: { + imageFills: null, + localVariables: null, + warnings: [ + expect.stringContaining("Figma local variables are unavailable"), + ], + }, + }); + }); +}); + +async function executeDesignContext(value: JsonObject) { + const descriptor = await figmaSourceApiAdapter.describe({ actor, source }); + const plan = await figmaSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "json", value }, + headers: [], + operation: "prepare_design_context", + }, + source, + }); + + return figmaSourceApiAdapter.execute({ + actor, + prepared: finalizePreparedSourceApi(plan), + source, + }); +} diff --git a/packages/server/src/source-api/adapters/figma-design-context.ts b/packages/server/src/source-api/adapters/figma-design-context.ts new file mode 100644 index 00000000..373bb4c8 --- /dev/null +++ b/packages/server/src/source-api/adapters/figma-design-context.ts @@ -0,0 +1,148 @@ +import type { JsonObject } from "@bufbuild/protobuf"; +import type { FigmaCredentials } from "@onequery/db/server"; + +import { SourceApiUnsupportedOperationError } from "../errors"; +import { normalizeAllowedHeaders } from "../helpers/http-rest"; +import { + createStructuredRequestOperation, + mergeStructuredFieldPatch, +} from "../helpers/structured"; +import type { + PreparedSourceApi, + PreparedSourceConnection, + SourceApiDescriptor, + SourceApiExample, + SourceApiExecutionResult, + SourceApiOperation, + SourceApiRequestBody, + UnboundPreparedSourceApi, +} from "../types"; +import { requestFigmaDesignContext } from "./figma-api"; +import { + FigmaDesignContextInvalidRequestError, + parseFigmaDesignContextBody, + parseFigmaDesignContextRequest, +} from "./figma-design-context-request"; + +export const FIGMA_DESIGN_CONTEXT_OPERATION = "prepare_design_context"; + +export function createFigmaDesignContextOperation( + examples: readonly SourceApiExample[] +): SourceApiOperation { + return createStructuredRequestOperation({ + allowedResponseHeaders: ["content-type"], + description: + "Bundle selected Figma nodes, reference renders, image fills, and optional local variables for implementation.", + examples, + name: FIGMA_DESIGN_CONTEXT_OPERATION, + notes: [ + "Pass either a Figma frame URL or a fileKey with nodeIds.", + "Local variables require the Figma file_variables:read scope and may be unavailable on some plans.", + "This Velen context bundle is not the Figma MCP get_design_context response format.", + ], + summary: "Prepare implementation context for selected Figma frames.", + }); +} + +export function normalizeFigmaDesignContext(input: { + body: SourceApiRequestBody; + descriptor: SourceApiDescriptor; + fieldPatch?: JsonObject; + headers: readonly { name: string; value: string }[]; + methodOverride?: string; + operationName: string; + selector?: string; + source: PreparedSourceConnection; +}): UnboundPreparedSourceApi & { paginationPolicy: "none" } { + const operation = requireFigmaDesignContextOperation(input); + rejectUnsupportedRequestControls(input); + const headers = normalizeAllowedHeaders({ + allowedNames: operation.headerPolicy.allowedRequestHeaders, + headers: input.headers, + }); + const request = parseFigmaDesignContextRequest( + mergeStructuredFieldPatch({ + base: parseFigmaDesignContextBody(input.body), + patch: input.fieldPatch, + }) + ); + + return { + body: input.body, + descriptorVersion: input.descriptor.descriptorVersion, + headers, + kind: "structured_request", + method: "GET", + operation: operation.name, + paginationPolicy: "none", + provider: input.source.provider, + request, + selectorTemplate: "/v1/files/{fileKey}/nodes", + sourceId: input.source.id, + sourceKey: input.source.sourceKey, + }; +} + +export async function executeFigmaDesignContext(input: { + prepared: PreparedSourceApi; + source: PreparedSourceConnection; +}): Promise { + if (input.prepared.kind !== "structured_request") { + throw new Error( + `Figma operation "${input.prepared.operation}" requires a structured plan` + ); + } + + const request = parseFigmaDesignContextRequest(input.prepared.request); + const value = await requestFigmaDesignContext( + requireFigmaCredentials(input.source), + request + ); + + return { + body: { kind: "json", value }, + contentType: "application/json", + headers: [{ name: "content-type", value: "application/json" }], + operation: input.prepared.operation, + source: { + displayName: input.source.displayName, + provider: input.source.provider, + sourceKey: input.source.sourceKey, + }, + status: 200, + }; +} + +function requireFigmaDesignContextOperation(input: { + descriptor: SourceApiDescriptor; + operationName: string; +}): SourceApiOperation { + const operation = input.descriptor.operations.find( + (candidate) => candidate.name === input.operationName.trim() + ); + if (operation?.name === FIGMA_DESIGN_CONTEXT_OPERATION) { + return operation; + } + throw new SourceApiUnsupportedOperationError(input.operationName); +} + +function rejectUnsupportedRequestControls(input: { + methodOverride?: string; + operationName: string; + selector?: string; +}): void { + if (input.selector?.trim() || input.methodOverride?.trim()) { + throw new FigmaDesignContextInvalidRequestError( + `Figma operation "${input.operationName}" does not accept selectors or method overrides` + ); + } +} + +function requireFigmaCredentials( + source: PreparedSourceConnection +): FigmaCredentials { + if (source.credentials.type === "figma") { + return source.credentials; + } + throw new Error("Figma source credentials are invalid"); +} diff --git a/packages/server/src/source-api/adapters/figma-url.ts b/packages/server/src/source-api/adapters/figma-url.ts new file mode 100644 index 00000000..fd47db45 --- /dev/null +++ b/packages/server/src/source-api/adapters/figma-url.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +const FIGMA_FILE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/u; +const FIGMA_NODE_ID_PATTERN = /^\d+:\d+$/u; +const FIGMA_FILE_PATH_TYPES = new Set([ + "board", + "design", + "file", + "make", + "proto", + "slides", +]); + +export const figmaFileKeySchema = z + .string() + .trim() + .min(1) + .regex(FIGMA_FILE_KEY_PATTERN, "Invalid Figma file key"); + +export const figmaNodeIdSchema = z + .string() + .trim() + .transform(normalizeFigmaNodeId) + .pipe(z.string().regex(FIGMA_NODE_ID_PATTERN, "Invalid Figma node ID")); + +type FigmaDesignReference = { + fileKey: string; + nodeId: string; +}; + +export function parseFigmaDesignUrl(value: string): FigmaDesignReference { + const url = new URL(value); + if (url.hostname !== "figma.com" && url.hostname !== "www.figma.com") { + throw new Error("Figma design URLs must use figma.com"); + } + + const [fileType, rawFileKey] = url.pathname.split("/").filter(Boolean); + if (!fileType || !FIGMA_FILE_PATH_TYPES.has(fileType) || !rawFileKey) { + throw new Error("Figma design URL does not contain a file key"); + } + + const rawNodeId = url.searchParams.get("node-id"); + if (!rawNodeId) { + throw new Error("Figma design URL does not contain a node-id"); + } + + return { + fileKey: figmaFileKeySchema.parse(rawFileKey), + nodeId: figmaNodeIdSchema.parse(rawNodeId), + }; +} + +function normalizeFigmaNodeId(value: string): string { + if (value.includes(":")) { + return value; + } + + const separatorIndex = value.indexOf("-"); + if (separatorIndex < 0) { + return value; + } + + return `${value.slice(0, separatorIndex)}:${value.slice(separatorIndex + 1)}`; +} diff --git a/packages/server/src/source-api/adapters/figma.test.ts b/packages/server/src/source-api/adapters/figma.test.ts new file mode 100644 index 00000000..4b8d1ba2 --- /dev/null +++ b/packages/server/src/source-api/adapters/figma.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; + +import type { PreparedSourceConnection, SourceApiActorContext } from "../types"; +import { figmaSourceApiAdapter } from "./figma"; + +const actor: SourceApiActorContext = { + capabilities: ["source_api.describe", "source_api.execute"], + membershipRoles: ["owner"], + organizationId: "org_1", + organizationSlug: "acme", + userId: "user_1", +}; + +const source: PreparedSourceConnection = { + credentials: { + personalAccessToken: "figd_test_token", + type: "figma", + }, + displayName: "Product design", + id: "source_1", + provider: "figma", + sourceKey: "figma-design", +}; + +describe("Figma source API adapter", () => { + it("describes raw REST and grouped design-context operations", async () => { + const descriptor = await figmaSourceApiAdapter.describe({ actor, source }); + + expect(descriptor.operations.map((operation) => operation.name)).toEqual([ + "fetch_api", + "prepare_design_context", + ]); + expect(descriptor.defaultPathOperation).toBe("fetch_api"); + }); + + it("normalizes Figma frame URLs into a grouped request", async () => { + const descriptor = await figmaSourceApiAdapter.describe({ actor, source }); + const plan = await figmaSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { + kind: "json", + value: { + includeVariables: true, + url: "https://www.figma.com/design/wwSVz2qdnWZ2U8ZBwQE9QN/NEW-GetGPT?node-id=2578-39032", + }, + }, + headers: [], + operation: "prepare_design_context", + }, + source, + }); + + expect(plan).toMatchObject({ + kind: "structured_request", + operation: "prepare_design_context", + provider: "figma", + request: { + fileKey: "wwSVz2qdnWZ2U8ZBwQE9QN", + includeImageFills: true, + includeVariables: true, + nodeIds: ["2578:39032"], + renderFormat: "png", + renderScale: 2, + }, + selectorTemplate: "/v1/files/{fileKey}/nodes", + }); + }); + + it("normalizes raw read-only Figma REST requests", async () => { + const descriptor = await figmaSourceApiAdapter.describe({ actor, source }); + const plan = await figmaSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { kind: "none" }, + fieldPatch: { + params: { ids: "2578:39032" }, + }, + headers: [], + operation: "fetch_api", + selector: "/v1/files/wwSVz2qdnWZ2U8ZBwQE9QN/nodes", + }, + source, + }); + + expect(plan).toMatchObject({ + kind: "http_request", + method: "GET", + operation: "fetch_api", + url: "https://api.figma.com/v1/files/wwSVz2qdnWZ2U8ZBwQE9QN/nodes?ids=2578%3A39032", + }); + }); + + it("rejects non-Figma design URLs", async () => { + const descriptor = await figmaSourceApiAdapter.describe({ actor, source }); + + await expect( + figmaSourceApiAdapter.normalize({ + actor, + descriptor, + request: { + body: { + kind: "json", + value: { + url: "https://example.com/design/file/frame?node-id=1-2", + }, + }, + headers: [], + operation: "prepare_design_context", + }, + source, + }) + ).rejects.toThrow("Invalid Figma design context request"); + }); +}); diff --git a/packages/server/src/source-api/adapters/figma.ts b/packages/server/src/source-api/adapters/figma.ts new file mode 100644 index 00000000..f6535ddd --- /dev/null +++ b/packages/server/src/source-api/adapters/figma.ts @@ -0,0 +1,117 @@ +import type { FigmaCredentials } from "@onequery/db/server"; + +import type { SourceApiAdapter, SourceApiExample } from "../types"; +import { + FIGMA_DESIGN_CONTEXT_OPERATION, + createFigmaDesignContextOperation, + executeFigmaDesignContext, + normalizeFigmaDesignContext, +} from "./figma-design-context"; +import { createSimpleRestSourceApiAdapter } from "./simple-rest"; + +const FIGMA_API_BASE_URL = "https://api.figma.com"; +const FIGMA_DESCRIPTOR_VERSION = "figma.v1"; +const FIGMA_ALLOWED_RESPONSE_HEADERS = [ + "content-type", + "retry-after", + "x-figma-plan-tier", + "x-figma-rate-limit-type", + "x-figma-upgrade-link", +] as const; + +const rawFigmaSourceApiAdapter = + createSimpleRestSourceApiAdapter({ + allowedMethods: ["GET"], + allowedResponseHeaders: FIGMA_ALLOWED_RESPONSE_HEADERS, + apiBaseUrl: () => FIGMA_API_BASE_URL, + auth: (credentials) => ({ + type: "raw", + value: credentials.personalAccessToken, + }), + authHeaderName: "X-Figma-Token", + buildExamples: buildFigmaRawExamples, + descriptorVersion: FIGMA_DESCRIPTOR_VERSION, + notes: [ + "Figma personal access tokens are sent only to https://api.figma.com.", + "This adapter exposes read-only Figma REST API requests.", + ], + operationNotes: [ + "Selectors should use Figma REST paths under /v1.", + "Use prepare_design_context to bundle implementation context for one or more frames.", + ], + provider: "figma", + providerLabel: "Figma", + }); + +export const figmaSourceApiAdapter: SourceApiAdapter = { + provider: "figma", + async describe(input) { + const rawDescriptor = await rawFigmaSourceApiAdapter.describe(input); + const groupedExamples = buildFigmaDesignContextExamples( + input.source.sourceKey + ); + + return { + ...rawDescriptor, + examples: [...rawDescriptor.examples, ...groupedExamples], + operations: [ + ...rawDescriptor.operations, + createFigmaDesignContextOperation(groupedExamples), + ], + }; + }, + async normalize(input) { + if (input.request.operation !== FIGMA_DESIGN_CONTEXT_OPERATION) { + return rawFigmaSourceApiAdapter.normalize(input); + } + + return normalizeFigmaDesignContext({ + body: input.request.body, + descriptor: input.descriptor, + fieldPatch: input.request.fieldPatch, + headers: input.request.headers, + methodOverride: input.request.methodOverride, + operationName: input.request.operation, + selector: input.request.selector, + source: input.source, + }); + }, + async execute(input) { + if (input.prepared.operation !== FIGMA_DESIGN_CONTEXT_OPERATION) { + return rawFigmaSourceApiAdapter.execute(input); + } + + return executeFigmaDesignContext({ + prepared: input.prepared, + source: input.source, + }); + }, +}; + +function buildFigmaRawExamples(sourceKey: string): SourceApiExample[] { + return [ + { + command: `onequery api --source ${sourceKey} /v1/files//nodes -f params[ids]=2578:39032`, + description: "Read selected nodes and their component metadata.", + label: "Get file nodes", + }, + { + command: `onequery api --source ${sourceKey} /v1/images/ -f params[ids]=2578:39032 -f params[format]=png`, + description: "Render selected nodes as reference images.", + label: "Render nodes", + }, + ]; +} + +function buildFigmaDesignContextExamples( + sourceKey: string +): SourceApiExample[] { + return [ + { + command: `onequery api --source ${sourceKey} --op prepare_design_context --input '{"url":"https://www.figma.com/design//?node-id=2578-39032"}'`, + description: + "Bundle nodes, renders, and image fills for a Figma frame URL.", + label: "Prepare design context", + }, + ]; +} diff --git a/packages/server/src/source-api/registry.ts b/packages/server/src/source-api/registry.ts index c18ea62c..acc786ae 100644 --- a/packages/server/src/source-api/registry.ts +++ b/packages/server/src/source-api/registry.ts @@ -11,6 +11,7 @@ import { codexAppServerApiSourceApiAdapter } from "./adapters/codex-app-server-a import { confluenceSourceApiAdapter } from "./adapters/confluence"; import { discordSourceApiAdapter } from "./adapters/discord"; import { e2bSourceApiAdapter } from "./adapters/e2b"; +import { figmaSourceApiAdapter } from "./adapters/figma"; import { googleAnalyticsSourceApiAdapter } from "./adapters/ga"; import { githubSourceApiAdapter } from "./adapters/github"; import { googleSearchConsoleSourceApiAdapter } from "./adapters/google-search-console"; @@ -86,6 +87,7 @@ export const sourceApiRegistry = createSourceApiRegistry([ confluenceSourceApiAdapter, discordSourceApiAdapter, e2bSourceApiAdapter, + figmaSourceApiAdapter, googleAnalyticsSourceApiAdapter, githubSourceApiAdapter, googleSearchConsoleSourceApiAdapter, diff --git a/packages/ui/package.json b/packages/ui/package.json index 50c2f038..1e530e53 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,6 +13,7 @@ "./components/*": "./src/components/ui/*.tsx", "./icons/codex-icon": "./src/icons/codex-icon.tsx", "./icons/e2b-icon": "./src/icons/e2b-icon.tsx", + "./icons/figma-icon": "./src/icons/figma-icon.tsx", "./icons/google-search-console-icon": "./src/icons/google-search-console-icon.tsx", "./icons/granola-icon": "./src/icons/granola-icon.tsx", "./icons/hermes-agent-icon": "./src/icons/hermes-agent-icon.tsx", diff --git a/packages/ui/src/data-sources/provider-icons.test.tsx b/packages/ui/src/data-sources/provider-icons.test.tsx index 5a68016a..e005c4c7 100644 --- a/packages/ui/src/data-sources/provider-icons.test.tsx +++ b/packages/ui/src/data-sources/provider-icons.test.tsx @@ -69,6 +69,20 @@ describe("shared provider icon accessibility", () => { expect(markup).toContain("Google Search Console provider"); }); + it("renders Figma with the official full-color brand mark", () => { + const markup = renderProviderIcon("figma", { + title: "Figma provider", + }); + + expect(markup).toContain('viewBox="0 0 1024 1280"'); + expect(markup).toContain('fill="#24CB71"'); + expect(markup).toContain('fill="#FF7237"'); + expect(markup).toContain('fill="#00B6FF"'); + expect(markup).toContain('fill="#FF3737"'); + expect(markup).toContain('fill="#874FFF"'); + expect(markup).toContain("Figma provider"); + }); + it("renders SendGrid with the current multicolor brand mark", () => { const markup = renderProviderIcon("sendgrid", { title: "SendGrid provider", diff --git a/packages/ui/src/data-sources/provider-icons.tsx b/packages/ui/src/data-sources/provider-icons.tsx index e3e6f654..e18cdc83 100644 --- a/packages/ui/src/data-sources/provider-icons.tsx +++ b/packages/ui/src/data-sources/provider-icons.tsx @@ -29,6 +29,7 @@ import { import { CodexIcon } from "../icons/codex-icon"; import { E2BIcon } from "../icons/e2b-icon"; +import { FigmaIcon } from "../icons/figma-icon"; import { GoogleSearchConsoleIcon } from "../icons/google-search-console-icon"; import { GranolaIcon } from "../icons/granola-icon"; import { HermesAgentIcon } from "../icons/hermes-agent-icon"; @@ -252,6 +253,7 @@ export const ProviderIcons = { confluence: createSimpleProviderIcon(siConfluence), discord: createSimpleProviderIcon(siDiscord), e2b: E2BIcon, + figma: FigmaIcon, ga: createSimpleProviderIcon(siGoogleanalytics), github: GitHubIcon, google_docs: createSimpleProviderIcon(siGoogledocs), diff --git a/packages/ui/src/icons/figma-icon.tsx b/packages/ui/src/icons/figma-icon.tsx new file mode 100644 index 00000000..9dcbd870 --- /dev/null +++ b/packages/ui/src/icons/figma-icon.tsx @@ -0,0 +1,37 @@ +import { SvgIcon } from "./svg-icon"; +import type { IconSvgProps } from "./svg-icon"; + +// Source: Figma's official full-color brand asset. +// https://www.figma.com/using-the-figma-brand/ +export function FigmaIcon({ + defaultLabel = "Figma", + size = 24, + ...props +}: IconSvgProps & { defaultLabel?: string }) { + return ( + + + + + + + + ); +}