← 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 · TypeScriptimport { 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],
},
}
}