Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/figma

src/setup.ts

219 lines · 6.9 KB · TypeScript
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',
	'projects: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.',
	'projects:read': 'Read team projects and project files.',
} as const

export type FigmaScope = keyof typeof FIGMA_SCOPES

export type FigmaOperation =
	| 'users.read'
	| 'files.read'
	| 'files.meta'
	| 'comments.read'
	| 'comments.write'
	| 'library.read'
	| 'projects.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<string>): Array<string> {
	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 'projects.read':
			return 'projects: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)(?:\/|$)/.test(path)) {
		return mutating ? 'unknownMutation' : 'projects.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'
}