Skip to content

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

Package listing

@kody/fly

src/fly-core.ts

468 lines · 15.2 KB · TypeScript
/**
 * Shared Fly.io transport: token secret resolution, Machines REST, GraphQL,
 * Prometheus / logs on api.fly.io, organization resolution, and dry-run
 * mutation previews.
 *
 * Auth is secret-backed (not OAuth). The default user secret is `flyApiToken`.
 * Extra Fly accounts use `account: "work"` → secret `flyApiToken-work`.
 * Placeholders resolve on approved hosts only.
 */

export const MACHINES_API_BASE_URL = 'https://api.machines.dev/v1'
export const FLY_API_BASE_URL = 'https://api.fly.io'
export const FLY_GRAPHQL_URL = 'https://api.fly.io/graphql'
export const MACHINES_HOST = 'api.machines.dev'
export const FLY_API_HOST = 'api.fly.io'
export const DEFAULT_API_TOKEN_SECRET = 'flyApiToken'
export const MACHINES_OPENAPI_SPEC = 'https://docs.machines.dev/spec/openapi3.json'

export const API_TOKEN_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=flyApiToken&description=Fly.io%20API%20token%20for%20Machines%2C%20GraphQL%2C%20logs%2C%20and%20Prometheus&allowedHosts=api.machines.dev,api.fly.io&scope=user'

const DEFAULT_API_TOKEN_PLACEHOLDER = '{{secret:flyApiToken|scope=user}}'
const SECRET_NAME_PATTERN = /^flyApiToken(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type FlyAuthOptions = {
	/**
	 * Extra Fly account label. `work` reads secret `flyApiToken-work`.
	 * Omit (or pass `default`) for `flyApiToken`.
	 */
	account?: string
	/** Override the API token secret name. Must be `flyApiToken` or `flyApiToken-<label>`. */
	secretName?: string
}

export type FlyJson = Record<string, any>

export class FlyApiError extends Error {
	status: number
	label: string
	details: unknown
	setupUrl: string

	constructor(
		message: string,
		input: { status: number; label?: string; details?: unknown; setupUrl?: string },
	) {
		super(message)
		this.name = 'FlyApiError'
		this.status = input.status
		this.label = input.label ?? 'Fly API'
		this.details = input.details ?? null
		this.setupUrl = input.setupUrl ?? API_TOKEN_SETUP_URL
	}
}

export function assertNever(value: never, message: string): never {
	throw new Error(message + String(value))
}

export function parseAction<T extends string>(
	value: unknown,
	allowed: readonly T[],
	fallback: T,
	label: string,
): T {
	const action = (value == null || value === '' ? fallback : value) as unknown
	if (typeof action === 'string' && (allowed as readonly string[]).includes(action)) {
		return action as T
	}
	throw new Error(
		'Unknown ' + label + ' action: ' + String(action) + '. Valid actions: ' + allowed.join(', '),
	)
}

export function clean(value: unknown): string {
	return String(value ?? '').trim()
}

export function positiveInt(value: unknown, fallback: number, max = 100): number {
	const number = Math.floor(Number(value))
	if (!Number.isFinite(number) || number <= 0) return fallback
	return Math.min(number, max)
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "live" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

/** Resolve the user-scoped Fly API token secret name for this call. */
export function resolveApiTokenSecretName(input: FlyAuthOptions = {}): string {
	if (input.secretName != null && String(input.secretName).trim() !== '') {
		const name = String(input.secretName).trim()
		if (!SECRET_NAME_PATTERN.test(name)) {
			throw new Error(
				'secretName must be flyApiToken or flyApiToken-<account>. Got ' +
					name +
					'. Save it at ' +
					secretSetupUrl(name) +
					'.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account ? DEFAULT_API_TOKEN_SECRET + '-' + account : DEFAULT_API_TOKEN_SECRET
}

export function secretSetupUrl(secretName: string = DEFAULT_API_TOKEN_SECRET): string {
	if (secretName === DEFAULT_API_TOKEN_SECRET) return API_TOKEN_SETUP_URL
	const encoded = encodeURIComponent(secretName)
	return API_TOKEN_SETUP_URL.replace('name=flyApiToken', 'name=' + encoded)
}

function tokenPlaceholder(input: FlyAuthOptions = {}): string {
	const name = resolveApiTokenSecretName(input)
	if (name === DEFAULT_API_TOKEN_SECRET) return DEFAULT_API_TOKEN_PLACEHOLDER
	return '{{secret:' + name + '|scope=user}}'
}

export function machinesAuthHeaders(input: FlyAuthOptions = {}): Record<string, string> {
	return { Authorization: 'Bearer ' + tokenPlaceholder(input) }
}

export function flyApiAuthHeaders(input: FlyAuthOptions = {}): Record<string, string> {
	return { Authorization: 'FlyV1 ' + tokenPlaceholder(input) }
}

export function graphqlAuthHeaders(input: FlyAuthOptions = {}): Record<string, string> {
	return { Authorization: 'Bearer ' + tokenPlaceholder(input) }
}

export async function parseJsonResponse(response: Response, label = 'Fly API'): Promise<any> {
	const text = await response.text()
	let body: any = null
	try {
		body = text ? JSON.parse(text) : null
	} catch {
		body = { raw: text }
	}
	if (!response.ok) {
		const hint =
			response.status === 401 || response.status === 403
				? ' Save a valid token at ' + API_TOKEN_SETUP_URL + '.'
				: ''
		throw new FlyApiError(label + ' HTTP ' + response.status + '.' + hint, {
			status: response.status,
			label,
			details: body,
		})
	}
	return body
}

export async function requestGraphql(
	input: FlyAuthOptions & { query?: string; variables?: Record<string, unknown> } = {},
): Promise<any> {
	if (!clean(input.query)) throw new Error('query is required.')
	const response = await fetch(FLY_GRAPHQL_URL, {
		method: 'POST',
		headers: {
			...graphqlAuthHeaders(input),
			'Content-Type': 'application/json',
		},
		body: JSON.stringify({ query: input.query, variables: input.variables ?? {} }),
	})
	const body = await parseJsonResponse(response, 'Fly GraphQL')
	if (Array.isArray(body?.errors) && body.errors.length > 0) {
		const message = body.errors.map((error: any) => error.message || JSON.stringify(error)).join('; ')
		throw new FlyApiError('Fly GraphQL error: ' + message, {
			status: response.status,
			label: 'Fly GraphQL',
			details: body.errors,
		})
	}
	return body?.data ?? body
}

/** Prometheus / app-logs helper against api.fly.io (not Machines OpenAPI). */
export async function requestFlyApi(
	path: string,
	input: FlyAuthOptions & {
		method?: string
		search?: Record<string, unknown>
		headers?: Record<string, string>
		body?: BodyInit | null
	} = {},
): Promise<any> {
	const url = new URL(path, FLY_API_BASE_URL)
	for (const [key, value] of Object.entries(input.search ?? {})) {
		if (value !== undefined && value !== null && clean(value)) {
			url.searchParams.set(key, String(value))
		}
	}
	const response = await fetch(url, {
		method: input.method ?? 'GET',
		headers: {
			...flyApiAuthHeaders(input),
			Accept: 'application/json',
			...input.headers,
		},
		body: input.body,
	})
	return await parseJsonResponse(response, 'Fly API')
}

export async function machinesRequest(
	path: string,
	input: FlyAuthOptions & {
		method?: string
		query?: Record<string, unknown>
		body?: unknown
	} = {},
): Promise<any> {
	const suffix = path.startsWith('/') ? path : '/' + path
	const url = new URL(MACHINES_API_BASE_URL + suffix)
	for (const [key, value] of Object.entries(input.query ?? {})) {
		if (value === undefined || value === null) continue
		url.searchParams.set(key, String(value))
	}
	const method = (input.method ?? 'GET').toUpperCase()
	const headers: Record<string, string> = {
		...machinesAuthHeaders(input),
		Accept: 'application/json',
	}
	let body: string | undefined
	if (input.body !== undefined && method !== 'GET') {
		body = JSON.stringify(input.body)
		headers['content-type'] = 'application/json'
	}
	const response = await fetch(url, { method, headers, body })
	return await parseJsonResponse(response, 'Fly Machines ' + method + ' ' + path)
}

export function pageInfo(connection: any): Record<string, any> {
	return connection && typeof connection === 'object' ? connection.pageInfo || {} : {}
}

export function nodes(connection: any): any[] {
	return Array.isArray(connection?.nodes) ? connection.nodes : []
}

export async function collectConnection(
	fetchPage: (after: string | null) => Promise<any>,
	{ maxPages = 20 }: { maxPages?: unknown } = {},
): Promise<any[]> {
	const all: any[] = []
	let after: string | null = null
	for (let page = 0; page < positiveInt(maxPages, 20, 100); page += 1) {
		const connection = await fetchPage(after)
		all.push(...nodes(connection))
		const info = pageInfo(connection)
		if (!info.hasNextPage || !info.endCursor) break
		after = info.endCursor
	}
	return all
}

export function explicitOrgSlug(params: Record<string, unknown> = {}): string {
	return (
		clean(params.organizationSlug) ||
		clean(params.orgSlug) ||
		clean(params.slug) ||
		clean(params.org_slug)
	)
}

/**
 * Resolve a Fly org slug from the caller. Never defaults to a baked-in org
 * (including Fly's common `personal` slug). When omitted, uses the only org
 * visible to the token, or asks the caller to pick among several.
 */
export async function resolveOrganizationSlug(
	params: FlyAuthOptions & Record<string, unknown> = {},
): Promise<string> {
	const explicit = explicitOrgSlug(params)
	if (explicit) return explicit
	const data = await requestGraphql({
		...params,
		query: 'query { organizations(first: 100) { nodes { id name slug } } }',
	})
	const organizations = Array.isArray(data?.organizations?.nodes) ? data.organizations.nodes : []
	if (organizations.length === 1 && clean(organizations[0]?.slug)) {
		return clean(organizations[0].slug)
	}
	if (organizations.length === 0) {
		throw new Error(
			'No Fly organizations are visible to this token. Pass organizationSlug, or check the token at ' +
				secretSetupUrl(resolveApiTokenSecretName(params)) +
				'.',
		)
	}
	const slugs = organizations.map((org: any) => clean(org.slug) || clean(org.name)).filter(Boolean)
	throw new Error(
		'Multiple Fly organizations are visible. Pass organizationSlug. Available: ' + slugs.join(', '),
	)
}

export function summarizeMachine(machine: any, appHint: { id?: string | null; name?: string | null } | null = null) {
	const appName = machine?.app_name || machine?.app?.name || appHint?.name || null
	const appId = machine?.app?.id || appHint?.id || null
	return {
		id: machine?.id ?? null,
		name: machine?.name ?? null,
		state: machine?.state ?? null,
		region: machine?.region ?? null,
		app: appName || appId ? { id: appId, name: appName } : null,
		image: machine?.config?.image ?? machine?.image ?? null,
		createdAt: machine?.created_at ?? machine?.createdAt ?? null,
		updatedAt: machine?.updated_at ?? machine?.updatedAt ?? null,
	}
}

export function summarizeApp(app: any) {
	return {
		id: app?.id ?? null,
		name: app?.name ?? null,
		status: app?.status ?? null,
		deployed: app?.status === 'deployed' ? true : (app?.deployed ?? null),
		organization: app?.organization
			? {
					id: app.organization.id ?? null,
					slug: app.organization.slug ?? null,
					name: app.organization.name ?? null,
				}
			: null,
		machineCount: app?.machine_count ?? null,
		volumeCount: app?.volume_count ?? null,
	}
}

export async function machinesAppsList(params: FlyAuthOptions & Record<string, unknown> = {}) {
	const orgSlug = await resolveOrganizationSlug(params)
	const query: Record<string, unknown> = { org_slug: orgSlug }
	if (clean(params.appRole) || clean(params.app_role)) {
		query.app_role = clean(params.appRole || params.app_role)
	}
	const body = await machinesRequest('/apps', { ...params, query })
	const apps = Array.isArray(body?.apps) ? body.apps : Array.isArray(body) ? body : []
	return {
		organization: { slug: orgSlug },
		totalApps: body?.total_apps ?? apps.length,
		apps,
	}
}

export async function machinesAppShow(appName: string, auth: FlyAuthOptions = {}) {
	const name = clean(appName)
	if (!name) throw new Error('appName is required.')
	return await machinesRequest('/apps/' + encodeURIComponent(name), auth)
}

export async function machinesListForApp(
	appName: string,
	params: FlyAuthOptions & Record<string, unknown> = {},
) {
	const name = clean(appName)
	if (!name) throw new Error('appName is required.')
	const query: Record<string, unknown> = {}
	if (clean(params.state)) query.state = clean(params.state)
	if (clean(params.region)) query.region = clean(params.region)
	if (params.includeDeleted === true || params.include_deleted === true) query.include_deleted = true
	if (params.summary !== false) query.summary = true
	const body = await machinesRequest('/apps/' + encodeURIComponent(name) + '/machines', {
		...params,
		query,
	})
	return Array.isArray(body) ? body : Array.isArray(body?.machines) ? body.machines : []
}

export async function machinesGet(appName: string, machineId: string, auth: FlyAuthOptions = {}) {
	const name = clean(appName)
	const id = clean(machineId)
	if (!name) throw new Error('appName is required.')
	if (!id) throw new Error('machineId is required.')
	return await machinesRequest(
		'/apps/' + encodeURIComponent(name) + '/machines/' + encodeURIComponent(id),
		auth,
	)
}

export async function machinesListForOrg(params: FlyAuthOptions & Record<string, unknown> = {}) {
	const orgSlug = await resolveOrganizationSlug(params)
	const query: Record<string, unknown> = {}
	if (clean(params.state)) query.state = clean(params.state)
	if (clean(params.region)) query.region = clean(params.region)
	if (params.includeDeleted === true || params.include_deleted === true) query.include_deleted = true
	if (params.summary !== false) query.summary = true
	if (clean(params.updatedAfter) || clean(params.updated_after)) {
		query.updated_after = clean(params.updatedAfter || params.updated_after)
	}
	if (clean(params.cursor)) query.cursor = clean(params.cursor)
	const body = await machinesRequest('/orgs/' + encodeURIComponent(orgSlug) + '/machines', {
		...params,
		query,
	})
	const machines = Array.isArray(body?.machines) ? body.machines : Array.isArray(body) ? body : []
	return {
		organization: { slug: orgSlug },
		machines,
		lastMachineId: body?.last_machine_id ?? null,
		lastUpdatedAt: body?.last_updated_at ?? null,
	}
}

export async function machinesCurrentToken(auth: FlyAuthOptions = {}) {
	return await machinesRequest('/tokens/current', auth)
}

export function requireConfirm(input: { confirm?: boolean }, action: string) {
	if (input.confirm !== true) {
		throw new Error(
			'Refusing to ' +
				action +
				' without confirm: true. This action changes live Fly Machines state; pass dryRun: true to preview, or confirm: true to proceed.',
		)
	}
}

export type FlyDryRun = {
	dryRun: true
	action: string
	method: 'POST' | 'DELETE'
	path: string
	query?: Record<string, unknown>
	body?: Record<string, unknown>
}

export type MutationGuardInput = FlyAuthOptions & {
	dryRun?: boolean
	confirm?: boolean
}

/**
 * Preview a mutation when `dryRun: true`. Destructive helpers also require
 * `confirm: true` before they contact Fly.
 */
export function mutationPreview(
	input: MutationGuardInput,
	options: {
		action: string
		method: 'POST' | 'DELETE'
		path: string
		query?: Record<string, unknown>
		body?: Record<string, unknown>
		requireConfirm?: boolean
	},
): FlyDryRun | null {
	if (input.dryRun === true) {
		return {
			dryRun: true,
			action: options.action,
			method: options.method,
			path: options.path,
			query: options.query,
			body: options.body,
		}
	}
	if (options.requireConfirm) requireConfirm(input, options.action)
	return null
}