/**
* 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
}