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