import type { FigmaAuthMode } from './types.ts' import { requireString } from './types.ts' export const FIGMA_API_BASE_URL = 'https://api.figma.com' export const FIGMA_AUTHORIZE_URL = 'https://www.figma.com/oauth' export const FIGMA_TOKEN_URL = 'https://api.figma.com/v1/oauth/token' export const FIGMA_REQUIRED_HOST = 'api.figma.com' export const FIGMA_OAUTH_APP_URL = 'https://www.figma.com/developers/apps' export const FIGMA_PAT_URL = 'https://www.figma.com/settings' export const FIGMA_PAT_DOCS_URL = 'https://developers.figma.com/docs/rest-api/personal-access-tokens/' export const FIGMA_CALLBACK_URL = 'https://kody.codes/connect/oauth' export const DEFAULT_INTEGRATION_NAME = 'figma' export const DEFAULT_PAT_SECRET = 'figmaPat' export const DEFAULT_OAUTH_SCOPES = [ 'current_user:read', 'file_content:read', 'file_metadata:read', 'file_comments:read', 'file_comments:write', 'file_versions:read', 'library_content:read', 'library_assets:read', 'folders:read', ] as const export const FIGMA_SCOPES = { 'current_user:read': 'Read the authorizing user handle (used by viewer / smoke-test).', 'file_content:read': 'Read file JSON, nodes, and rendered images.', 'file_metadata:read': 'Read file metadata without full document JSON.', 'file_comments:read': 'List comments on a file.', 'file_comments:write': 'Create or delete comments.', 'file_versions:read': 'Read file version history.', 'library_content:read': 'Read published components and styles.', 'library_assets:read': 'Read library image assets and fills.', 'folders:read': 'List folders and files in folders.', } as const export type FigmaScope = keyof typeof FIGMA_SCOPES export type FigmaOperation = | 'users.read' | 'files.read' | 'files.meta' | 'comments.read' | 'comments.write' | 'library.read' | 'folders.read' | 'unknownRead' | 'unknownMutation' const FILE_KEY_IN_URL = /\/(?:file|design|board|proto|slides|deck|figjam)\/([A-Za-z0-9]+)/i export function parseFileKey(value: string, label = 'fileKey'): string { const raw = requireString(value, label) if (!raw.includes('/') && !raw.includes('?')) return raw try { const url = new URL(raw) const match = url.pathname.match(FILE_KEY_IN_URL) if (match?.[1]) return match[1] } catch { const match = raw.match(FILE_KEY_IN_URL) if (match?.[1]) return match[1] } throw new Error( `${label} must be a Figma file key or a figma.com file/design/board URL.`, ) } export function normalizeNodeId(value: string): string { return requireString(value, 'nodeId').replace(/-/g, ':') } export function normalizeNodeIds(values: Array): Array { return values.map((value) => normalizeNodeId(value)) } export function oauthConnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string { const name = requireString(provider, 'provider') const params = new URLSearchParams({ provider: name, authorizeUrl: FIGMA_AUTHORIZE_URL, tokenUrl: FIGMA_TOKEN_URL, apiBaseUrl: FIGMA_API_BASE_URL, scopes: DEFAULT_OAUTH_SCOPES.join(' '), flow: 'confidential', pkce: 'true', allowedHosts: FIGMA_REQUIRED_HOST, dashboardUrl: FIGMA_OAUTH_APP_URL, }) return `https://kody.codes/connect/oauth?${params.toString()}` } export function patSetupUrl(secretName: string = DEFAULT_PAT_SECRET): string { const name = requireString(secretName, 'secretName') const params = new URLSearchParams({ name, description: 'Figma personal or plan access token', allowedHosts: FIGMA_REQUIRED_HOST, scope: 'user', }) return `https://kody.codes/account/secrets/new?${params.toString()}` } export function reconnectUrl(provider: string = DEFAULT_INTEGRATION_NAME): string { return `https://kody.codes/connect/oauth?provider=${encodeURIComponent(provider)}` } export function scopeForOperation(operation: FigmaOperation): FigmaScope { switch (operation) { case 'users.read': return 'current_user:read' case 'files.read': return 'file_content:read' case 'files.meta': return 'file_metadata:read' case 'comments.read': return 'file_comments:read' case 'comments.write': return 'file_comments:write' case 'library.read': return 'library_content:read' case 'folders.read': return 'folders:read' case 'unknownRead': return 'file_content:read' case 'unknownMutation': return 'file_comments:write' default: { const exhaustive: never = operation throw new Error(`Unsupported Figma operation: ${String(exhaustive)}`) } } } export function nextStepForScope( scope: FigmaScope, options: { authMode: FigmaAuthMode integrationName: string secretName: string }, ): string { switch (options.authMode) { case 'oauth': return [ `Reconnect the "${options.integrationName}" OAuth integration and include the "${scope}" scope.`, reconnectUrl(options.integrationName), `Full BYO connect URL (scopes ${DEFAULT_OAUTH_SCOPES.join(' ')}): ${oauthConnectUrl(options.integrationName)}`, ].join(' ') case 'pat': return [ `Create a Figma personal or plan access token with the "${scope}" scope, then save it as ${options.secretName}.`, FIGMA_PAT_DOCS_URL, patSetupUrl(options.secretName), ].join(' ') default: { const exhaustive: never = options.authMode throw new Error(`Unsupported Figma auth mode: ${String(exhaustive)}`) } } } export function missingCredentialsMessage(options: { integrationName: string secretName: string }): string { return [ 'Figma credentials are missing.', 'OAuth (recommended for shared/multi-account use): create an OAuth app at', FIGMA_OAUTH_APP_URL, `with redirect URI ${FIGMA_CALLBACK_URL}, then connect:`, oauthConnectUrl(options.integrationName), `Personal or plan access token (fastest for one account): create a token at ${FIGMA_PAT_URL}`, `(${FIGMA_PAT_DOCS_URL}) then save it (do not paste the value in chat):`, patSetupUrl(options.secretName), `Required API host: ${FIGMA_REQUIRED_HOST}.`, ].join(' ') } export function isMutatingMethod(method: string): boolean { const normalized = method.toUpperCase() return ( normalized === 'POST' || normalized === 'PUT' || normalized === 'PATCH' || normalized === 'DELETE' ) } export function inferOperationFromPath( method: string, path: string, explicit?: FigmaOperation, ): FigmaOperation { if (explicit) return explicit const mutating = isMutatingMethod(method) if (/\/comments(?:\/|$)/.test(path)) { return mutating ? 'comments.write' : 'comments.read' } if (/\/(?:components|component_sets|styles)(?:\/|$)/.test(path)) { return mutating ? 'unknownMutation' : 'library.read' } if (/\/(?:teams|projects|folders)(?:\/|$)/.test(path)) { return mutating ? 'unknownMutation' : 'folders.read' } if (/\/files\/[^/]+\/meta(?:\/|$)/.test(path)) { return mutating ? 'unknownMutation' : 'files.meta' } if (/\/(?:files|images)(?:\/|$)/.test(path)) { return mutating ? 'unknownMutation' : 'files.read' } if (/\/me(?:\/|$)/.test(path)) { return mutating ? 'unknownMutation' : 'users.read' } return mutating ? 'unknownMutation' : 'unknownRead' }