diff --git a/integrations/figma/README.md b/integrations/figma/README.md new file mode 100644 index 000000000..4dc91965a --- /dev/null +++ b/integrations/figma/README.md @@ -0,0 +1,65 @@ +# Figma Integration for CORE + +Connect your Figma workspace to CORE to track file updates, comments, version history, and design activity. + +## Features + +- OAuth2 Authorization Code flow (no API key required) +- Webhook-driven activity feed for real-time events +- MCP tool support for querying Figma data programmatically +- Scheduled sync to keep credentials validated + +## OAuth2 Scopes + +| Scope | Purpose | +|---|---| +| `file_content:read` | Read file document structure and metadata | +| `file_comments:read` | Read comments on files | +| `file_comments:write` | Post comments on files | +| `file_dev_resources:read` | Read dev resources attached to files | +| `webhooks:write` | Register and manage webhooks | + +## MCP Tools + +| Tool | Description | +|---|---| +| `figma_get_team_projects` | List all projects in a Figma team | +| `figma_get_project_files` | List all files in a project | +| `figma_get_file` | Fetch full document tree for a file | +| `figma_get_file_comments` | List all comments on a file | +| `figma_get_file_versions` | List version history of a file | +| `figma_create_webhook` | Register a webhook for real-time events | + +## Setup + +### Environment variables + +```bash +FIGMA_CLIENT_ID= +FIGMA_CLIENT_SECRET= +DATABASE_URL= +``` + +### Register the integration + +```bash +bun run scripts/register.ts +``` + +### Build + +```bash +bun run build +``` + +## Webhook Events + +The integration handles the following Figma webhook event types via `PROCESS`: + +- `FILE_UPDATE` - A file was edited +- `FILE_VERSION_UPDATE` - A named version was saved +- `FILE_COMMENT` - A comment was posted +- `FILE_DELETE` - A file was deleted +- `LIBRARY_PUBLISH` - A library was published + +> **TODO**: Register the webhook endpoint URL in `scripts/register.ts` once the CORE webhook receiver URL is known. diff --git a/integrations/figma/package.json b/integrations/figma/package.json new file mode 100644 index 000000000..2664a0df9 --- /dev/null +++ b/integrations/figma/package.json @@ -0,0 +1,65 @@ +{ + "name": "@core/figma", + "version": "0.1.0", + "description": "Figma extension for CORE", + "main": "./bin/index.js", + "module": "./bin/index.mjs", + "type": "module", + "files": [ + "figma", + "bin" + ], + "bin": { + "figma": "./bin/index.js" + }, + "scripts": { + "build": "rimraf dist && bun build src/index.ts --outfile dist/index.js --target node --minify", + "lint": "eslint --ext js,ts,tsx backend/ frontend/ --fix", + "prettier": "prettier --config .prettierrc --write ." + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@babel/preset-typescript": "^7.26.0", + "@types/node": "^18.0.20", + "eslint": "^9.24.0", + "eslint-config-prettier": "^10.1.2", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-unused-imports": "^2.0.0", + "prettier": "^3.4.2", + "rimraf": "^3.0.2", + "tslib": "^2.8.1", + "typescript": "^4.7.2", + "tsup": "^8.0.1", + "ncc": "0.3.6" + }, + "publishConfig": { + "access": "public" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "dependencies": { + "axios": "^1.7.9", + "commander": "^12.0.0", + "openai": "^4.0.0", + "react-query": "^3.39.3", + "@redplanethq/sdk": "0.1.10", + "zod": "^3.25.4", + "zod-to-json-schema": "^3.25.1" + } +} diff --git a/integrations/figma/scripts/register.ts b/integrations/figma/scripts/register.ts new file mode 100644 index 000000000..58ac0491a --- /dev/null +++ b/integrations/figma/scripts/register.ts @@ -0,0 +1,92 @@ +import pg from 'pg'; + +const { Client } = pg; + +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + console.error('DATABASE_URL environment variable is required'); + process.exit(1); + } + + const clientId = process.env.FIGMA_CLIENT_ID; + const clientSecret = process.env.FIGMA_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + console.error('FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET environment variables are required'); + process.exit(1); + } + + const client = new Client({ connectionString }); + + const spec = { + name: 'Figma', + key: 'figma', + description: + 'Connect your Figma workspace to track file updates, comments, version history, and design activity in CORE.', + icon: 'figma', + schedule: { + frequency: '*/15 * * * *', + }, + mcp: { + type: 'cli', + }, + auth: { + OAuth2: { + token_url: 'https://www.figma.com/api/oauth/token', + authorization_url: 'https://www.figma.com/oauth', + scopes: [ + 'file_content:read', + 'file_comments:read', + 'file_comments:write', + 'file_dev_resources:read', + 'webhooks:write', + ], + scope_separator: ',', + fields: [ + { + name: 'access_token', + label: 'Access Token', + placeholder: '', + description: 'OAuth2 access token issued by Figma after authorization.', + }, + ], + }, + }, + }; + + try { + await client.connect(); + + await client.query( + ` + INSERT INTO core."IntegrationDefinitionV2" ("id", "name", "slug", "description", "icon", "spec", "config", "version", "url", "updatedAt", "createdAt") + VALUES (gen_random_uuid(), 'Figma', 'figma', $4, 'figma', $1, $2, '0.1.0', $3, NOW(), NOW()) + ON CONFLICT (name) DO UPDATE SET + "slug" = EXCLUDED."slug", + "description" = EXCLUDED."description", + "icon" = EXCLUDED."icon", + "spec" = EXCLUDED."spec", + "config" = EXCLUDED."config", + "version" = EXCLUDED."version", + "url" = EXCLUDED."url", + "updatedAt" = NOW() + RETURNING *; + `, + [ + JSON.stringify(spec), + JSON.stringify({ clientId, clientSecret }), + '../../integrations/figma/bin/index.js', + spec.description, + ], + ); + + console.log('Figma integration registered successfully in the database.'); + } catch (error) { + console.error('Error registering Figma integration:', error); + } finally { + await client.end(); + } +} + +main().catch(console.error); diff --git a/integrations/figma/src/account-create.ts b/integrations/figma/src/account-create.ts new file mode 100644 index 000000000..f699fa4c1 --- /dev/null +++ b/integrations/figma/src/account-create.ts @@ -0,0 +1,43 @@ +import axios from 'axios'; + +/** + * Called after the OAuth2 Authorization Code flow completes. + * Fetches the authenticated Figma user and returns account + config records. + */ +export async function integrationCreate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any, +) { + const { oauthResponse } = data; + + const integrationConfiguration = { + access_token: oauthResponse.access_token, + refresh_token: oauthResponse.refresh_token, + }; + + // Fetch the authenticated user from the Figma REST API. + const userResponse = await axios.get('https://api.figma.com/v1/me', { + headers: { + Authorization: `Bearer ${integrationConfiguration.access_token}`, + }, + }); + + const user = userResponse.data; + + return [ + { + type: 'account', + data: { + settings: { + handle: user.handle, + email: user.email, + }, + accountId: user.id.toString(), + config: { + ...integrationConfiguration, + mcp: { tokens: { access_token: integrationConfiguration.access_token } }, + }, + }, + }, + ]; +} diff --git a/integrations/figma/src/create-activity.ts b/integrations/figma/src/create-activity.ts new file mode 100644 index 000000000..15bb6a7ae --- /dev/null +++ b/integrations/figma/src/create-activity.ts @@ -0,0 +1,91 @@ +interface FigmaActivityCreateParams { + text: string; + sourceURL: string; +} + +/** + * Creates a standardised activity message from Figma webhook event data. + */ +export function createActivityMessage(params: FigmaActivityCreateParams) { + return { + type: 'activity', + data: { + text: params.text, + sourceURL: params.sourceURL, + }, + }; +} + +/** + * Processes a raw Figma webhook event payload and returns zero or more + * activity messages to be persisted in CORE. + * + * TODO: Expand this handler to cover all Figma webhook event types: + * - FILE_UPDATE + * - FILE_VERSION_UPDATE + * - FILE_COMMENT + * - FILE_DELETE + * - LIBRARY_PUBLISH + */ +export function createActivityEvent( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventData: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _config: any, +): ReturnType[] { + if (!eventData || !eventData.event_type) { + return []; + } + + const { event_type, file_name, file_key, triggered_by } = eventData; + const actor: string = triggered_by?.handle ?? 'Someone'; + const fileURL = file_key ? `https://www.figma.com/file/${file_key}` : ''; + + switch (event_type) { + case 'FILE_UPDATE': + return [ + createActivityMessage({ + text: `${actor} updated Figma file "${file_name ?? file_key}"`, + sourceURL: fileURL, + }), + ]; + + case 'FILE_VERSION_UPDATE': + return [ + createActivityMessage({ + text: `${actor} saved a new version of Figma file "${file_name ?? file_key}"`, + sourceURL: fileURL, + }), + ]; + + case 'FILE_COMMENT': { + const comment: string = eventData.comment?.[0]?.text ?? ''; + return [ + createActivityMessage({ + text: `${actor} commented on Figma file "${file_name ?? file_key}": ${comment}`, + sourceURL: fileURL, + }), + ]; + } + + case 'FILE_DELETE': + return [ + createActivityMessage({ + text: `${actor} deleted Figma file "${file_name ?? file_key}"`, + sourceURL: fileURL, + }), + ]; + + case 'LIBRARY_PUBLISH': + return [ + createActivityMessage({ + text: `${actor} published a library update for "${file_name ?? file_key}"`, + sourceURL: fileURL, + }), + ]; + + default: + // TODO: Handle additional event types as Figma expands its webhook API. + return []; + } +} diff --git a/integrations/figma/src/index.ts b/integrations/figma/src/index.ts new file mode 100644 index 000000000..8ce1cb6e4 --- /dev/null +++ b/integrations/figma/src/index.ts @@ -0,0 +1,111 @@ +import { fileURLToPath } from 'url'; + +import { + IntegrationCLI, + IntegrationEventPayload, + IntegrationEventType, + Spec, +} from '@redplanethq/sdk'; + +import { integrationCreate } from './account-create'; +import { createActivityEvent } from './create-activity'; +import { handleSchedule } from './schedule'; +import { callTool, getTools } from './mcp'; + +export async function run(eventPayload: IntegrationEventPayload) { + switch (eventPayload.event) { + case IntegrationEventType.SETUP: + return await integrationCreate(eventPayload.eventBody); + + case IntegrationEventType.SYNC: + return await handleSchedule(eventPayload.config, eventPayload.state); + + case IntegrationEventType.PROCESS: + return createActivityEvent(eventPayload.eventBody.eventData, eventPayload.config); + + case IntegrationEventType.GET_TOOLS: { + try { + return getTools(); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + return { message: `Error ${message}` }; + } + } + + case IntegrationEventType.CALL_TOOL: { + const integrationDefinition = eventPayload.integrationDefinition; + + if (!integrationDefinition) { + return null; + } + + const config = eventPayload.config as any; + const { name, arguments: args } = eventPayload.eventBody; + + return await callTool(name, args, config?.access_token); + } + + default: + return [{ type: 'error', data: `The event payload type is ${eventPayload.event}` }]; + } +} + +// CLI implementation that extends the base class +class FigmaCLI extends IntegrationCLI { + constructor() { + super('figma', '1.0.0'); + } + + protected async handleEvent(eventPayload: IntegrationEventPayload): Promise { + return await run(eventPayload); + } + + protected async getSpec(): Promise { + return { + name: 'Figma', + key: 'figma', + description: + 'Connect your Figma workspace to track file updates, comments, version history, and design activity in CORE.', + icon: 'figma', + schedule: { + frequency: '*/15 * * * *', + }, + mcp: { + type: 'cli', + }, + auth: { + OAuth2: { + token_url: 'https://www.figma.com/api/oauth/token', + authorization_url: 'https://www.figma.com/oauth', + scopes: [ + 'file_content:read', + 'file_comments:read', + 'file_comments:write', + 'file_dev_resources:read', + 'webhooks:write', + ], + scope_separator: ',', + fields: [ + { + name: 'access_token', + label: 'Access Token', + placeholder: '', + description: 'OAuth2 access token issued by Figma after authorization.', + }, + ], + }, + }, + }; + } +} + +// Define a main function and invoke it directly. +// This works after bundling to JS and running with `node index.js`. +function main() { + const figmaCLI = new FigmaCLI(); + figmaCLI.parse(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/integrations/figma/src/mcp/index.ts b/integrations/figma/src/mcp/index.ts new file mode 100644 index 000000000..528c5b714 --- /dev/null +++ b/integrations/figma/src/mcp/index.ts @@ -0,0 +1,145 @@ +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { + createFigmaClient, + getTeamProjects, + getProjectFiles, + getFile, + getFileComments, + getFileVersions, + createWebhook, +} from '../utils'; + +// ============================================================================ +// SCHEMA DEFINITIONS +// ============================================================================ + +const GetTeamProjectsSchema = z.object({ + team_id: z.string().describe('Figma team ID'), +}); + +const GetProjectFilesSchema = z.object({ + project_id: z.string().describe('Figma project ID'), +}); + +const GetFileSchema = z.object({ + file_key: z.string().describe('Figma file key (from the file URL)'), +}); + +const GetFileCommentsSchema = z.object({ + file_key: z.string().describe('Figma file key'), +}); + +const GetFileVersionsSchema = z.object({ + file_key: z.string().describe('Figma file key'), +}); + +const CreateWebhookSchema = z.object({ + team_id: z.string().describe('Team ID to scope the webhook to'), + event_type: z + .enum([ + 'FILE_UPDATE', + 'FILE_VERSION_UPDATE', + 'FILE_COMMENT', + 'FILE_DELETE', + 'LIBRARY_PUBLISH', + ]) + .describe('Figma webhook event type'), + endpoint: z.string().url().describe('HTTPS endpoint that will receive the webhook payload'), + passcode: z.string().describe('Passcode included in each webhook request for verification'), + description: z.string().optional().describe('Optional human-readable description'), +}); + +// ============================================================================ +// TOOLS LIST +// ============================================================================ + +export function getTools() { + return [ + { + name: 'figma_get_team_projects', + description: 'Lists all projects inside a Figma team', + inputSchema: zodToJsonSchema(GetTeamProjectsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'figma_get_project_files', + description: 'Lists all files inside a Figma project', + inputSchema: zodToJsonSchema(GetProjectFilesSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'figma_get_file', + description: 'Fetches the document metadata and node tree for a Figma file', + inputSchema: zodToJsonSchema(GetFileSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'figma_get_file_comments', + description: 'Returns all comments on a Figma file', + inputSchema: zodToJsonSchema(GetFileCommentsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'figma_get_file_versions', + description: 'Returns the version history of a Figma file', + inputSchema: zodToJsonSchema(GetFileVersionsSchema), + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + }, + { + name: 'figma_create_webhook', + description: 'Registers a Figma webhook to receive real-time events for a team', + inputSchema: zodToJsonSchema(CreateWebhookSchema), + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + }, + ]; +} + +// ============================================================================ +// TOOL DISPATCHER +// ============================================================================ + +export async function callTool( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: Record, + accessToken: string, +) { + const client = createFigmaClient(accessToken); + + switch (name) { + case 'figma_get_team_projects': { + const { team_id } = GetTeamProjectsSchema.parse(args); + return await getTeamProjects(client, team_id); + } + + case 'figma_get_project_files': { + const { project_id } = GetProjectFilesSchema.parse(args); + return await getProjectFiles(client, project_id); + } + + case 'figma_get_file': { + const { file_key } = GetFileSchema.parse(args); + return await getFile(client, file_key); + } + + case 'figma_get_file_comments': { + const { file_key } = GetFileCommentsSchema.parse(args); + return await getFileComments(client, file_key); + } + + case 'figma_get_file_versions': { + const { file_key } = GetFileVersionsSchema.parse(args); + return await getFileVersions(client, file_key); + } + + case 'figma_create_webhook': { + const params = CreateWebhookSchema.parse(args); + // TODO: Store returned webhook ID in integration config for later cleanup. + return await createWebhook(client, params); + } + + default: + throw new Error(`Unknown tool: ${name}`); + } +} diff --git a/integrations/figma/src/schedule.ts b/integrations/figma/src/schedule.ts new file mode 100644 index 000000000..85c17be8d --- /dev/null +++ b/integrations/figma/src/schedule.ts @@ -0,0 +1,66 @@ +import { createFigmaClient } from './utils'; + +interface FigmaSettings { + lastSyncTime?: string; +} + +/** + * Returns a default sync time of 24 hours ago. + */ +function getDefaultSyncTime(): string { + return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); +} + +/** + * Scheduled sync handler for the Figma integration. + * + * Figma's REST API does not currently expose a "recent activity" feed + * comparable to GitHub's notifications or Linear's issue feed. + * Activities are primarily delivered via webhooks (see create-activity.ts). + * + * This handler keeps the sync state current so that any future polling + * logic (e.g. checking file versions) can use a reliable lastSyncTime. + * + * TODO: Add polling of file versions / comments for teams that cannot + * use webhooks (e.g. Figma Starter plan restrictions). + */ +export async function handleSchedule( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config?: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state?: Record, +): Promise { + try { + if (!config?.access_token) { + return []; + } + + // Verify credentials are still valid by fetching the authenticated user. + const client = createFigmaClient(config.access_token); + try { + await client.get('/v1/me'); + } catch { + return []; + } + + const settings = (state ?? {}) as FigmaSettings; + const _lastSyncTime = settings.lastSyncTime ?? getDefaultSyncTime(); // eslint-disable-line @typescript-eslint/no-unused-vars + + const messages: any[] = []; + + // TODO: Implement polling of file versions / comments here when needed. + + const newSyncTime = new Date().toISOString(); + messages.push({ + type: 'state', + data: { + ...settings, + lastSyncTime: newSyncTime, + }, + }); + + return messages; + } catch { + return []; + } +} diff --git a/integrations/figma/src/utils.ts b/integrations/figma/src/utils.ts new file mode 100644 index 000000000..009bfc632 --- /dev/null +++ b/integrations/figma/src/utils.ts @@ -0,0 +1,136 @@ +import axios, { AxiosInstance } from 'axios'; + +export const FIGMA_BASE_URL = 'https://api.figma.com'; + +/** + * Creates an Axios instance pre-configured for the Figma REST API. + */ +export function createFigmaClient(accessToken: string): AxiosInstance { + return axios.create({ + baseURL: FIGMA_BASE_URL, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); +} + +// --------------------------------------------------------------------------- +// Typed API wrapper stubs +// --------------------------------------------------------------------------- + +export interface FigmaProject { + id: string; + name: string; +} + +export interface FigmaFile { + key: string; + name: string; + thumbnail_url: string; + last_modified: string; +} + +export interface FigmaComment { + id: string; + message: string; + created_at: string; + user: { handle: string; img_url: string }; +} + +export interface FigmaVersion { + id: string; + created_at: string; + label: string; + description: string; + user: { handle: string }; +} + +export interface FigmaWebhook { + id: string; + team_id: string; + event_type: string; + client_id: string; + endpoint: string; + passcode: string; + status: string; +} + +/** + * GET /v1/teams/:team_id/projects + * Returns all projects for the given Figma team. + */ +export async function getTeamProjects( + client: AxiosInstance, + teamId: string, +): Promise { + const response = await client.get(`/v1/teams/${teamId}/projects`); + return response.data.projects as FigmaProject[]; +} + +/** + * GET /v1/projects/:project_id/files + * Returns all files inside a Figma project. + */ +export async function getProjectFiles( + client: AxiosInstance, + projectId: string, +): Promise { + const response = await client.get(`/v1/projects/${projectId}/files`); + return response.data.files as FigmaFile[]; +} + +/** + * GET /v1/files/:file_key + * Returns document metadata for a specific Figma file. + */ +export async function getFile( + client: AxiosInstance, + fileKey: string, +): Promise> { + const response = await client.get(`/v1/files/${fileKey}`); + return response.data as Record; +} + +/** + * GET /v1/files/:file_key/comments + * Returns all comments on a Figma file. + */ +export async function getFileComments( + client: AxiosInstance, + fileKey: string, +): Promise { + const response = await client.get(`/v1/files/${fileKey}/comments`); + return response.data.comments as FigmaComment[]; +} + +/** + * GET /v1/files/:file_key/versions + * Returns all version history entries for a Figma file. + */ +export async function getFileVersions( + client: AxiosInstance, + fileKey: string, +): Promise { + const response = await client.get(`/v1/files/${fileKey}/versions`); + return response.data.versions as FigmaVersion[]; +} + +/** + * POST /v2/webhooks + * Registers a Figma webhook for the given team and event type. + */ +export async function createWebhook( + client: AxiosInstance, + params: { + event_type: string; + team_id: string; + endpoint: string; + passcode: string; + description?: string; + }, +): Promise { + // TODO: Implement full webhook handler to receive and process Figma events. + const response = await client.post('/v2/webhooks', params); + return response.data as FigmaWebhook; +} diff --git a/integrations/figma/tsconfig.json b/integrations/figma/tsconfig.json new file mode 100644 index 000000000..4f28056ae --- /dev/null +++ b/integrations/figma/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["dom", "dom.iterable", "esnext"], + "baseUrl": "frontend", + "allowJs": false, + "allowSyntheticDefaultImports": true, + "module": "esnext", + "moduleResolution": "node", + "isolatedModules": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"] +}