Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@kentcdodds/onepassword

Resolve 1Password Connect item fields for secret-aware fetch with website host allowlisting.

connect/deploy-fly.ts

468 lines · 15.1 KB · TypeScript
import { kody } from 'kody:runtime'
import source from './source.ts'

const appNameDefault = 'kody-onepassword-connect'
const organizationIdDefault = 'G1B7JG6wPDBq0I8b8yRl4q9RlQUDnK'
const regionDefault = 'ams'
const apiImageDefault = '1password/connect-api:latest'
const syncImageDefault = '1password/connect-sync:latest'

const FLY_API_HOSTS = ['api.machines.dev', 'api.fly.io'] as const

const OP_SESSION_SECRET = 'ONEPASSWORD_CONNECT_OP_SESSION'
const CREDENTIALS_SECRET = 'ONEPASSWORD_CONNECT_CREDENTIALS_JSON'
const FLY_TOKEN_SECRET = 'flyApiToken'

const opSessionPrefill =
	'https://kody.codes/account/secrets/new?name=ONEPASSWORD_CONNECT_OP_SESSION&description=Base64%20of%201password-credentials.json%20(no%20newlines).%20deploy-fly%20writes%20it%20via%20Machines%20config.files%20raw_value%3B%20OP_SESSION%20env%20is%20the%20credentials%20file%20path.&allowedHosts=api.machines.dev%2Capi.fly.io&scope=user'

const flyTokenHostsPrefill =
	'https://kody.codes/account/secrets/new?name=flyApiToken&description=Fly.io%20API%20token%20for%20Machines%20API%20and%20GraphQL%20(api.machines.dev%20%2B%20api.fly.io)&allowedHosts=api.machines.dev%2Capi.fly.io%2Cdocs.machines.dev%2Cfly.io&scope=user'

type SecretMeta = {
	name: string
	allowed_hosts?: string[]
	allowed_packages?: string[]
}

function missingHostApprovals(secret: SecretMeta | undefined, required: readonly string[]) {
	const hosts = new Set((secret?.allowed_hosts || []).map((h) => h.toLowerCase()))
	return required.filter((h) => !hosts.has(h.toLowerCase()))
}

async function requireDeploySecrets(packageId: string) {
	const listed = await kody.secretList({ scope: 'user' })
	const secrets: SecretMeta[] = listed?.secrets || []
	const byName = new Map(secrets.map((s) => [s.name, s]))

	const opSession = byName.get(OP_SESSION_SECRET)
	const credentials = byName.get(CREDENTIALS_SECRET)
	const flyToken = byName.get(FLY_TOKEN_SECRET)

	if (!opSession) {
		const hint = credentials
			? ` Found ${CREDENTIALS_SECRET}, but deploy-fly will not send raw credentials JSON as Fly OP_SESSION. Re-save Base64(credentials.json) as ${OP_SESSION_SECRET} (or update that secret to Base64 and use the OP_SESSION name).`
			: ''
		throw new Error(
			`${OP_SESSION_SECRET} is missing.${hint} Save Base64 of 1password-credentials.json (never paste the value into chat): ${opSessionPrefill}`,
		)
	}

	const opSessionHostGaps = missingHostApprovals(opSession, FLY_API_HOSTS)
	if (opSessionHostGaps.length > 0) {
		throw new Error(
			`${OP_SESSION_SECRET} is missing host approval for: ${opSessionHostGaps.join(', ')}. Edit the secret and add those hosts (or recreate via): ${opSessionPrefill}`,
		)
	}

	if (!flyToken) {
		throw new Error(
			`${FLY_TOKEN_SECRET} is missing. Save it with Fly API hosts approved (never paste the value into chat): ${flyTokenHostsPrefill}`,
		)
	}

	const flyHostGaps = missingHostApprovals(flyToken, FLY_API_HOSTS)
	if (flyHostGaps.length > 0) {
		throw new Error(
			`${FLY_TOKEN_SECRET} is missing host approval for: ${flyHostGaps.join(', ')}. Update allowedHosts via: ${flyTokenHostsPrefill}`,
		)
	}

	const packages = new Set(flyToken.allowed_packages || [])
	if (packages.size > 0 && !packages.has(packageId)) {
		throw new Error(
			`${FLY_TOKEN_SECRET} is not granted to package ${packageId}. Add a package grant for @kentcdodds/onepassword, then retry deploy-fly.`,
		)
	}

	const opPackages = new Set(opSession.allowed_packages || [])
	if (opPackages.size > 0 && !opPackages.has(packageId)) {
		throw new Error(
			`${OP_SESSION_SECRET} is not granted to package ${packageId}. Add a package grant for @kentcdodds/onepassword, then retry deploy-fly.`,
		)
	}

	return { opSession, flyToken }
}

async function flyGraphql(query: string, variables: Record<string, unknown> = {}) {
	// Placeholders must appear inside secret-aware fetch headers/body (not transformed).
	const response = await fetch('https://api.fly.io/graphql', {
		method: 'POST',
		headers: {
			authorization: 'Bearer {{secret:flyApiToken|scope=user}}',
			'content-type': 'application/json',
		},
		body: JSON.stringify({ query, variables }),
	})
	const body = await response.json().catch(() => null)
	if (!response.ok || body?.errors) {
		throw new Error('Fly GraphQL failed ' + response.status + ': ' + JSON.stringify(body))
	}
	return body.data
}

async function flyMachines(path: string, init: RequestInit = {}) {
	const response = await fetch('https://api.machines.dev/v1' + path, {
		...init,
		headers: {
			authorization: 'Bearer {{secret:flyApiToken|scope=user}}',
			'content-type': 'application/json',
			...(init.headers || {}),
		},
	})
	const text = await response.text()
	let body: unknown = null
	try {
		body = text ? JSON.parse(text) : null
	} catch {
		body = text
	}
	if (!response.ok) {
		throw new Error('Fly Machines failed ' + response.status + ': ' + JSON.stringify(body))
	}
	return body
}

async function ensureApp(appName: string, organizationId: string) {
	try {
		const data = await flyGraphql('query($name:String!){ app(name:$name){ id name } }', {
			name: appName,
		})
		if (data?.app?.name) return { created: false, app: data.app }
	} catch (error) {
		if (!String(error).includes('Could not find App') && !String(error).includes('NOT_FOUND')) {
			throw error
		}
	}
	const data = await flyGraphql(
		'mutation($input:CreateAppInput!){ createApp(input:$input){ app { id name } } }',
		{ input: { name: appName, organizationId } },
	)
	return { created: true, app: data.createApp.app }
}

async function ensureSharedIpv4(appName: string) {
	const data = await flyGraphql(
		'query($name:String!){ app(name:$name){ id sharedIpAddress ipAddresses { nodes { id address type } } } }',
		{ name: appName },
	)
	const nodes = data?.app?.ipAddresses?.nodes || []
	const hasShared = nodes.some(
		(ip: { type?: string }) => ip.type === 'shared_v4' || ip.type === 'v4',
	)
	const hasV6 = nodes.some((ip: { type?: string }) => ip.type === 'v6')
	const allocated: Array<{ type: string; address?: string }> = []
	if (!hasShared) {
		const created = await flyGraphql(
			'mutation($input:AllocateIPAddressInput!){ allocateIpAddress(input:$input){ ipAddress { id address type } } }',
			{ input: { appId: appName, type: 'shared_v4' } },
		)
		allocated.push({
			type: 'shared_v4',
			address: created?.allocateIpAddress?.ipAddress?.address,
		})
	}
	if (!hasV6) {
		const created = await flyGraphql(
			'mutation($input:AllocateIPAddressInput!){ allocateIpAddress(input:$input){ ipAddress { id address type } } }',
			{ input: { appId: appName, type: 'v6' } },
		)
		allocated.push({
			type: 'v6',
			address: created?.allocateIpAddress?.ipAddress?.address,
		})
	}
	return {
		existing: nodes.map((ip: { id?: string; address?: string; type?: string }) => ({
			id: ip.id,
			address: ip.address,
			type: ip.type,
		})),
		allocated,
	}
}

const CREDENTIALS_GUEST_PATH = '/home/opuser/.op/1password-credentials.json'
const DATA_MOUNT_PATH = '/opdata'
const VOLUME_NAME = 'opdata'

async function unsetFlyOpSessionEnv(appName: string) {
	// Official OP_SESSION is a *file path*. A leftover Fly app secret named OP_SESSION
	// would inject Base64 into the container env and override the path — remove it.
	try {
		const data = await flyGraphql(
			'mutation($input:UnsetSecretsInput!){ unsetSecrets(input:$input){ release { id version reason description createdAt } } }',
			{ input: { appId: appName, keys: ['OP_SESSION'] } },
		)
		return data?.unsetSecrets?.release ?? null
	} catch (error) {
		const msg = String(error)
		if (msg.includes('not found') || msg.includes('NO_SUCH') || msg.includes('Unknown secret')) {
			return null
		}
		throw error
	}
}

function credentialsFiles() {
	// Fly files.raw_value expects Base64 file bytes. ONEPASSWORD_CONNECT_OP_SESSION is already that.
	return [
		{
			guest_path: CREDENTIALS_GUEST_PATH,
			raw_value: '{{secret:ONEPASSWORD_CONNECT_OP_SESSION|scope=user}}',
			mode: 0o440,
		},
	]
}

function sharedConnectEnv() {
	return {
		OP_SESSION: CREDENTIALS_GUEST_PATH,
		// Relocate SQLite so the Fly volume does not root-own /home/opuser/.op (ConfigDir).
		XDG_DATA_HOME: DATA_MOUNT_PATH,
	}
}

function buildMachineConfig(params: {
	apiImage: string
	syncImage: string
	memoryMb: number
}) {
	// Fly cannot attach one persistent volume into two containers on a multi-container
	// Machine. Use a named temp_dir volume shared by both (Connect re-syncs from cloud).
	// Mount at /opdata + XDG_DATA_HOME so /home/opuser/.op stays opuser-owned.
	const volumeMount = { name: VOLUME_NAME, path: DATA_MOUNT_PATH }
	const files = credentialsFiles()
	return {
		guest: { cpu_kind: 'shared', cpus: 1, memory_mb: params.memoryMb },
		files,
		volumes: [{ name: VOLUME_NAME, temp_dir: { size_mb: 1024 } }],
		// Sync first. Helm: sync peers api on the bus; api waits via OP_SYNC_TIMEOUT.
		// Do NOT make sync depend_on api (that deadlock left api unbound on :8080).
		containers: [
			{
				name: 'connect-sync',
				image: params.syncImage,
				env: {
					...sharedConnectEnv(),
					OP_HTTP_PORT: '8081',
					OP_BUS_PORT: '11221',
					OP_BUS_PEERS: 'localhost:11220',
				},
				mounts: [volumeMount],
				files,
			},
			{
				name: 'connect-api',
				image: params.apiImage,
				env: {
					...sharedConnectEnv(),
					OP_HTTP_PORT: '8080',
					OP_BUS_PORT: '11220',
					OP_BUS_PEERS: 'localhost:11221',
					OP_SYNC_TIMEOUT: '60s',
				},
				mounts: [volumeMount],
				files,
				healthchecks: [
					{
						name: 'api-heartbeat',
						http: {
							port: 8080,
							method: 'GET',
							path: '/heartbeat',
							scheme: 'http',
						},
						grace_period: 90,
						interval: 15,
						timeout: 5,
						success_threshold: 1,
						failure_threshold: 5,
					},
				],
			},
		],
		services: [
			{
				protocol: 'tcp',
				internal_port: 8080,
				autostart: true,
				autostop: false,
				ports: [
					{ port: 80, handlers: ['http'], force_https: true },
					{ port: 443, handlers: ['tls', 'http'] },
				],
			},
		],
		restart: { policy: 'on-failure', max_retries: 10 },
	}
}

async function recreateMachine(
	appName: string,
	params: {
		region: string
		machineName: string
		apiImage: string
		syncImage: string
		memoryMb: number
		replaceExisting: boolean
	},
) {
	const existing = (await flyMachines('/apps/' + appName + '/machines').catch(() => [])) as Array<{
		id: string
	}>
	const deleted: unknown[] = []
	if (params.replaceExisting) {
		for (const machine of existing) {
			// Stop before delete so the persistent volume can detach cleanly.
			await flyMachines('/apps/' + appName + '/machines/' + machine.id + '/stop', {
				method: 'POST',
				body: JSON.stringify({}),
			}).catch(() => null)
			deleted.push(
				await flyMachines('/apps/' + appName + '/machines/' + machine.id + '?force=true', {
					method: 'DELETE',
				}).catch((error) => ({ id: machine.id, error: String(error) })),
			)
		}
		// Brief pause for volume detach.
		await new Promise((resolve) => setTimeout(resolve, 3000))
	}
	const created = await flyMachines('/apps/' + appName + '/machines', {
		method: 'POST',
		body: JSON.stringify({
			name: params.machineName,
			region: params.region,
			config: buildMachineConfig({
				apiImage: params.apiImage,
				syncImage: params.syncImage,
				memoryMb: params.memoryMb,
			}),
			skip_launch: false,
		}),
	})
	return { deleted, created }
}

/**
 * Default callable for `kody:@kentcdodds/onepassword/connect/deploy-fly`.
 *
 * Creates/updates the Fly app, mounts Base64 credentials from Kody secret
 * ONEPASSWORD_CONNECT_OP_SESSION via config.files, sets OP_SESSION to the file
 * path, attaches shared temp_dir opdata at /opdata (XDG_DATA_HOME; Fly multi-container limit), deploys a
 * multi-container Connect Machine, and returns metadata without secret values.
 */
export default async function deployFly(params: Record<string, unknown> = {}) {
	const appName = String(params.appName || appNameDefault)
	const organizationId = String(params.organizationId || organizationIdDefault)
	const region = String(params.region || regionDefault)
	const machineName = String(params.machineName || appName + '-1')
	const apiImage = String(params.apiImage || apiImageDefault)
	const syncImage = String(params.syncImage || syncImageDefault)
	const memoryMb = Number(params.memoryMb || 1024)
	const replaceExisting = params.replaceExisting !== false
	const settleMs = Number(params.settleMs || 45000)
	const packageId = '7603a38f-6d3f-44e4-beca-b10196b38008'

	await requireDeploySecrets(packageId)
	await source()
	const app = await ensureApp(appName, organizationId)
	const ips = await ensureSharedIpv4(appName)
	const secretsRelease = await unsetFlyOpSessionEnv(appName)
	const machine = await recreateMachine(appName, {
		region,
		machineName,
		apiImage,
		syncImage,
		memoryMb,
		replaceExisting,
	})

	await new Promise((resolve) => setTimeout(resolve, settleMs))
	const machines = (await flyMachines('/apps/' + appName + '/machines')) as Array<{
		id: string
		name?: string
		state?: string
		region?: string
		updated_at?: string
		events?: unknown[]
		config?: { containers?: Array<{ name?: string; image?: string }> }
	}>

	const url = 'https://' + appName + '.fly.dev'
	return {
		ok: true,
		appName,
		url,
		region,
		heartbeatUrl: url + '/heartbeat',
		vaultsUrl: url + '/v1/vaults',
		app,
		ips: {
			allocated: ips.allocated,
			existingCount: ips.existing.length,
		},
		secrets: {
			flyOpSessionEnvUnset: Boolean(secretsRelease),
			unsetReleaseId: secretsRelease?.id,
			credentialsFile: CREDENTIALS_GUEST_PATH,
			sourceSecret: OP_SESSION_SECRET,
			keysSet: [] as string[],
		},
		volume: {
			kind: 'temp_dir',
			name: VOLUME_NAME,
			mountPath: DATA_MOUNT_PATH,
			note:
				'Fly cannot share one persistent volume across two containers; named temp_dir is shared. Connect re-syncs from 1Password after replace.',
		},
		machine: {
			created: {
				id: (machine.created as { id?: string }).id,
				state: (machine.created as { state?: string }).state,
				containers: (
					(machine.created as { config?: { containers?: Array<{ name?: string; image?: string }> } })
						.config?.containers || []
				).map((c) => ({ name: c.name, image: c.image })),
			},
			deleted: machine.deleted,
		},
		machines: machines.map((m) => ({
			id: m.id,
			name: m.name,
			state: m.state,
			region: m.region,
			updated_at: m.updated_at,
			containers: (m.config?.containers || []).map((c) => ({ name: c.name, image: c.image })),
			events: m.events?.slice?.(0, 5),
		})),
		bind: {
			provider: '1password',
			door_secret_name: 'ONEPASSWORD_CONNECT_TOKEN',
			config: { connectHost: url },
		},
		notes: [
			'Credentials mounted via Machines config.files raw_value from ' +
				OP_SESSION_SECRET +
				' (Base64) to ' +
				CREDENTIALS_GUEST_PATH +
				'; container env OP_SESSION is that path (not Base64).',
			'Shared temp_dir volume ' +
				VOLUME_NAME +
				' mounted at ' +
				DATA_MOUNT_PATH +
				' with XDG_DATA_HOME so /home/opuser/.op stays opuser-owned (Fly cannot share persistent volumes across multi-container mounts; Connect re-syncs).',
			'Bus: api 11220 <-> sync 11221; sync starts first; OP_SYNC_TIMEOUT=60s on api.',
			'Secret placeholders expand only inside fetch to api.fly.io / api.machines.dev.',
			'Smoke GET ' + url + '/heartbeat then /v1/vaults with ONEPASSWORD_CONNECT_TOKEN.',
			'secretProviderBind with connectHost ' + url,
		],
		nextStepsIfBlocked: {
			opSessionPrefill,
			flyTokenHostsPrefill,
			requiredHosts: [...FLY_API_HOSTS],
		},
	}
}