Skip to content
← Public packages

@kentcdodds/agent-files

Mint short-lived R2 PUT URLs for agent file handoffs; public download via managed r2.dev.

src/lib/r2.ts

435 lines · 12.9 KB · TypeScript
import { packageSecrets, packageStorage } from 'kody:runtime'
import {
	cloudflareAccountId,
	lifecycleMaxAgeSeconds,
	r2BucketName,
	r2LifecycleAppliedStorageKey,
	r2ParentAccessKeyIdStorageKey,
	r2PublicDomainFallback,
	r2PublicDomainStorageKey,
} from './constants.ts'
import {
	bytesToHex,
	encodeObjectPath,
	hmac,
	sha256Hex,
} from './util.ts'

type CloudflareConfig = {
	accountId: string
	token: string
	parentAccessKeyId: string
}

async function readCachedString(key: string): Promise<string | null> {
	const stored = await packageStorage().get(key)
	if (typeof stored === 'string' && stored.trim()) return stored.trim()
	return null
}

async function writeCachedString(key: string, value: string): Promise<void> {
	await packageStorage().set(key, value)
}

export async function getCloudflareConfig(): Promise<CloudflareConfig> {
	const [token, savedAccessKeyId] = await Promise.all([
		packageSecrets
			? packageSecrets.get('cloudflareApiToken')
			: Promise.resolve('{{secret:cloudflareApiToken|scope=user}}'),
		readCachedString(r2ParentAccessKeyIdStorageKey),
	])
	const tokenVerify = await fetch(
		'https://api.cloudflare.com/client/v4/user/tokens/verify',
		{ headers: { authorization: `Bearer ${token}` } },
	)
	const tokenText = await tokenVerify.text().catch(() => '')
	let tokenPayload: { result?: { id?: string } } | null = null
	try {
		tokenPayload = tokenText ? JSON.parse(tokenText) : null
	} catch {
		// ignore
	}
	const verifiedAccessKeyId = tokenPayload?.result?.id?.trim()
	const accessKeyId = savedAccessKeyId || verifiedAccessKeyId
	if (!savedAccessKeyId && verifiedAccessKeyId) {
		await writeCachedString(r2ParentAccessKeyIdStorageKey, verifiedAccessKeyId)
	}
	if (!tokenVerify.ok || !accessKeyId) {
		throw new Error(
			`Unable to resolve R2 parent access key id: verify HTTP ${tokenVerify.status}; verifyBody=${tokenText.slice(0, 500) || '<empty>'}; savedValue=${savedAccessKeyId ? 'present' : 'missing'}. Ensure user secret cloudflareApiToken exists and can call api.cloudflare.com (Account → R2 → Edit + Tokens: Verify). Prefill: https://dash.cloudflare.com/profile/api-tokens`,
		)
	}
	return {
		accountId: cloudflareAccountId,
		token,
		parentAccessKeyId: accessKeyId,
	}
}

export async function ensureR2Bucket(config: CloudflareConfig): Promise<void> {
	const getResponse = await fetch(
		`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets/${r2BucketName}`,
		{ headers: { authorization: `Bearer ${config.token}` } },
	)
	if (getResponse.ok) return
	if (getResponse.status !== 404) {
		const body = await getResponse.text().catch(() => '')
		throw new Error(
			`Unable to inspect R2 bucket ${r2BucketName}: HTTP ${getResponse.status} ${body}`,
		)
	}
	const createResponse = await fetch(
		`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets`,
		{
			method: 'POST',
			headers: {
				authorization: `Bearer ${config.token}`,
				'content-type': 'application/json',
			},
			body: JSON.stringify({ name: r2BucketName }),
		},
	)
	if (createResponse.ok || createResponse.status === 409) return
	const body = await createResponse.text().catch(() => '')
	throw new Error(
		`Unable to create R2 bucket ${r2BucketName}: HTTP ${createResponse.status} ${body}. Kent: create the bucket on KCD account ${cloudflareAccountId} if the token lacks R2 write. Dashboard: https://dash.cloudflare.com/${cloudflareAccountId}/r2/overview`,
	)
}

/**
 * Prefer ~7-day object delete lifecycle. Best-effort; document manual setup on failure.
 */
export async function ensureR2Lifecycle(config: CloudflareConfig): Promise<{
	ok: boolean
	message?: string
}> {
	const cached = await readCachedString(r2LifecycleAppliedStorageKey)
	if (cached === '7d') return { ok: true }

	const response = await fetch(
		`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets/${r2BucketName}/lifecycle`,
		{
			method: 'PUT',
			headers: {
				authorization: `Bearer ${config.token}`,
				'content-type': 'application/json',
			},
			body: JSON.stringify({
				rules: [
					{
						id: 'agent-files-delete-7d',
						enabled: true,
						conditions: { prefix: '' },
						deleteObjectsTransition: {
							condition: {
								type: 'Age',
								maxAge: lifecycleMaxAgeSeconds,
							},
						},
					},
				],
			}),
		},
	)
	if (!response.ok) {
		const body = await response.text().catch(() => '')
		return {
			ok: false,
			message: `R2 lifecycle not applied (HTTP ${response.status}): ${body.slice(0, 400)}. Manual: Dashboard → R2 → ${r2BucketName} → Settings → Object lifecycle → delete after 7 days.`,
		}
	}
	await writeCachedString(r2LifecycleAppliedStorageKey, '7d')
	return { ok: true }
}

const browserCorsOrigins = [
	'https://kentcdodds.kody.run',
	'https://kody.codes',
	'https://kodyapps.dev',
	'https://www.kodyapps.dev',
	'http://localhost:5173',
	'http://127.0.0.1:5173',
]

export async function ensureR2Cors(config: CloudflareConfig): Promise<void> {
	const response = await fetch(
		`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets/${r2BucketName}/cors`,
		{
			method: 'PUT',
			headers: {
				authorization: `Bearer ${config.token}`,
				'content-type': 'application/json',
			},
			body: JSON.stringify({
				rules: [
					{
						allowed: {
							origins: browserCorsOrigins,
							methods: ['GET', 'PUT', 'HEAD'],
							headers: ['*'],
						},
						exposeHeaders: ['ETag', 'Content-Length', 'Content-Type'],
						maxAgeSeconds: 3600,
					},
				],
			}),
		},
	)
	if (!response.ok) {
		throw new Error(
			`Unable to configure R2 CORS: HTTP ${response.status}: ${await response.text().catch(() => '')}`,
		)
	}
}

/**
 * Ensure managed r2.dev public access for durable download URLs.
 * @returns Public hostname (without scheme), e.g. pub-….r2.dev
 */
export async function ensureR2PublicDomain(
	config: CloudflareConfig,
): Promise<string> {
	const cached = await readCachedString(r2PublicDomainStorageKey)
	if (cached) return cached

	const getResponse = await fetch(
		`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets/${r2BucketName}/domains/managed`,
		{ headers: { authorization: `Bearer ${config.token}` } },
	)
	const getPayload = (await getResponse.json().catch(() => null)) as {
		result?: { enabled?: boolean; domain?: string }
	} | null
	let domain = getPayload?.result?.domain?.trim()
	const enabled = getPayload?.result?.enabled === true

	if (!enabled || !domain) {
		const putResponse = await fetch(
			`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets/${r2BucketName}/domains/managed`,
			{
				method: 'PUT',
				headers: {
					authorization: `Bearer ${config.token}`,
					'content-type': 'application/json',
				},
				body: JSON.stringify({ enabled: true }),
			},
		)
		const putPayload = (await putResponse.json().catch(() => null)) as {
			result?: { domain?: string }
			errors?: unknown
		} | null
		domain = putPayload?.result?.domain?.trim() || domain
		if (!putResponse.ok || !domain) {
			domain = domain || r2PublicDomainFallback
		}
	}

	if (!domain) {
		throw new Error(
			`Unable to enable managed r2.dev public access on bucket ${r2BucketName}. Enable Public access (r2.dev) in the Cloudflare dashboard for account ${cloudflareAccountId}.`,
		)
	}

	await writeCachedString(r2PublicDomainStorageKey, domain)
	return domain
}

export function publicObjectUrl(publicDomain: string, key: string): string {
	const path = key
		.split('/')
		.map((segment) => encodeURIComponent(segment))
		.join('/')
	return `https://${publicDomain}/${path}`
}

async function createTemporaryCredentials(
	config: CloudflareConfig,
	key: string,
	ttlSeconds: number,
): Promise<{
	accessKeyId: string
	secretAccessKey: string
	sessionToken: string
}> {
	const response = await fetch(
		`https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/temp-access-credentials`,
		{
			method: 'POST',
			headers: {
				authorization: `Bearer ${config.token}`,
				'content-type': 'application/json',
			},
			body: JSON.stringify({
				bucket: r2BucketName,
				parentAccessKeyId: config.parentAccessKeyId,
				permission: 'object-read-write',
				ttlSeconds: Math.max(60, Math.min(604_800, Math.trunc(ttlSeconds))),
				objects: [key],
			}),
		},
	)
	const payload = (await response.json().catch(() => null)) as {
		result?: {
			accessKeyId?: string
			secretAccessKey?: string
			sessionToken?: string
		}
		errors?: unknown
	} | null
	const result = payload?.result
	if (
		!response.ok ||
		!result?.accessKeyId ||
		!result.secretAccessKey ||
		!result.sessionToken
	) {
		throw new Error(
			`Unable to create R2 temporary credentials: HTTP ${response.status} ${JSON.stringify(payload?.errors ?? payload)}`,
		)
	}
	return {
		accessKeyId: result.accessKeyId,
		secretAccessKey: result.secretAccessKey,
		sessionToken: result.sessionToken,
	}
}

async function signAwsHeaders(input: {
	method: string
	key: string
	contentType?: string
	browser?: boolean
	ttlSeconds?: number
	query?: Record<string, string>
}): Promise<{ url: string; headers: Record<string, string> }> {
	const config = await getCloudflareConfig()
	await ensureR2Bucket(config)
	await ensureR2PublicDomain(config)
	await ensureR2Lifecycle(config)
	if (input.browser) await ensureR2Cors(config)
	const credentials = await createTemporaryCredentials(
		config,
		input.key,
		input.ttlSeconds ?? 3600,
	)
	const host = `${config.accountId}.r2.cloudflarestorage.com`
	const now = new Date()
	const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
	const dateStamp = amzDate.slice(0, 8)
	const region = 'auto'
	const serviceName = 's3'
	const payloadHash = 'UNSIGNED-PAYLOAD'
	const path = encodeObjectPath(r2BucketName, input.key)
	const headers: Record<string, string> = {
		host,
		'x-amz-content-sha256': payloadHash,
		'x-amz-date': amzDate,
		'x-amz-security-token': credentials.sessionToken,
	}
	if (input.contentType) headers['content-type'] = input.contentType
	const signedHeaderNames = Object.keys(headers).sort()
	const canonicalHeaders = signedHeaderNames
		.map((name) => `${name}:${headers[name]}\n`)
		.join('')
	const signedHeaders = signedHeaderNames.join(';')
	const canonicalQuery = Object.keys(input.query ?? {})
		.sort()
		.map(
			(key) =>
				`${encodeURIComponent(key)}=${encodeURIComponent(input.query![key]!)}`,
		)
		.join('&')
	const canonicalRequest = [
		input.method,
		path,
		canonicalQuery,
		canonicalHeaders,
		signedHeaders,
		payloadHash,
	].join('\n')
	const credentialScope = `${dateStamp}/${region}/${serviceName}/aws4_request`
	const stringToSign = [
		'AWS4-HMAC-SHA256',
		amzDate,
		credentialScope,
		await sha256Hex(canonicalRequest),
	].join('\n')
	const dateKey = await hmac(
		new TextEncoder().encode('AWS4' + credentials.secretAccessKey),
		dateStamp,
	)
	const regionKey = await hmac(dateKey, region)
	const serviceKey = await hmac(regionKey, serviceName)
	const signingKey = await hmac(serviceKey, 'aws4_request')
	const signature = bytesToHex(await hmac(signingKey, stringToSign))
	const url = `https://${host}${path}${canonicalQuery ? `?${canonicalQuery}` : ''}`
	return {
		url,
		headers: {
			...headers,
			authorization: `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
		},
	}
}

export async function createSignedR2Request(input: {
	method: string
	key: string
	contentType?: string
	browser?: boolean
	ttlSeconds?: number
}): Promise<{ url: string; headers: Record<string, string>; method: string }> {
	const signed = await signAwsHeaders(input)
	return { method: input.method, ...signed }
}

export async function signedR2Fetch(input: {
	method: string
	key: string
	body?: ArrayBuffer | Uint8Array
	contentType?: string
	ttlSeconds?: number
}): Promise<Response> {
	const signed = await signAwsHeaders({
		method: input.method,
		key: input.key,
		contentType: input.contentType,
		ttlSeconds: input.ttlSeconds,
	})
	const payloadBytes = input.body ? new Uint8Array(input.body) : new Uint8Array()
	const payloadBody = payloadBytes.buffer.slice(
		payloadBytes.byteOffset,
		payloadBytes.byteOffset + payloadBytes.byteLength,
	)
	const requestHeaders = new Headers(signed.headers)
	const response = await fetch(signed.url, {
		method: input.method,
		headers: requestHeaders,
		body:
			input.method === 'PUT' || input.method === 'POST' ? payloadBody : undefined,
	})
	if (!response.ok && response.status !== 404) {
		throw new Error(
			`R2 ${input.method} failed with HTTP ${response.status}: ${await response.text().catch(() => '')}`,
		)
	}
	return response
}

export async function resolvePublicUrl(key: string): Promise<string> {
	const config = await getCloudflareConfig()
	await ensureR2Bucket(config)
	const domain = await ensureR2PublicDomain(config)
	return publicObjectUrl(domain, key)
}

export async function ensureBucketReady(): Promise<{
	publicDomain: string
	lifecycle: { ok: boolean; message?: string }
}> {
	const config = await getCloudflareConfig()
	await ensureR2Bucket(config)
	const publicDomain = await ensureR2PublicDomain(config)
	const lifecycle = await ensureR2Lifecycle(config)
	return { publicDomain, lifecycle }
}