import { packageSecrets } from 'kody:runtime'
import { AwsApiError, nextStepFor, slimErrorBody } from './helpers.ts'
import { redactUnknown } from './redact.ts'
import { assertAmazonAwsHost, serviceHost, signAwsRequest } from './sign.ts'
import {
DEFAULT_REGION,
assertRegion,
assertService,
inputRecord,
optionalString,
resolveAccessKeySecretName,
resolveSecretKeySecretName,
resolveSessionTokenSecretName,
type AwsAuthOptions,
} from './validation.ts'
import { resolveRegion } from './config.ts'
type MountedSecrets = {
get?: (alias: string) => unknown
[alias: string]: unknown
} | ((alias: string) => unknown)
async function readMountedSecret(alias: string): Promise<string | null> {
try {
const mounts = packageSecrets as MountedSecrets | null
if (typeof mounts === 'function') {
const value = await (mounts as (name: string) => unknown)(alias)
if (typeof value === 'string' && value.trim()) return value.trim()
}
if (mounts && typeof mounts === 'object') {
if (typeof mounts.get === 'function') {
const value = await mounts.get(alias)
if (typeof value === 'string' && value.trim()) return value.trim()
}
const direct = mounts[alias]
if (typeof direct === 'string' && direct.trim()) return direct.trim()
if (direct && typeof direct === 'object' && 'value' in direct) {
const value = (direct as { value?: unknown }).value
if (typeof value === 'string' && value.trim()) return value.trim()
}
}
const env = (
globalThis as { process?: { env?: Record<string, string | undefined> } }
).process?.env
const envValue = env?.[alias] ?? env?.[alias.toUpperCase()]
if (typeof envValue === 'string' && envValue.trim()) return envValue.trim()
} catch {
return null
}
return null
}
export async function resolveAwsCredentials(input: AwsAuthOptions = {}) {
const accessKeySecret = resolveAccessKeySecretName(input)
const secretKeySecret = resolveSecretKeySecretName(input)
const sessionTokenSecret = resolveSessionTokenSecretName(input)
const accessKeyId = await readMountedSecret(accessKeySecret)
const secretAccessKey = await readMountedSecret(secretKeySecret)
const sessionToken = (await readMountedSecret(sessionTokenSecret)) ?? undefined
if (!accessKeyId || !secretAccessKey) {
throw new Error(
'AWS credentials are not mounted in this runtime. Save awsAccessKeyId and awsSecretAccessKey, then call this export from the package runtime (or after forking) so secret mounts resolve. Setup: ' +
'https://kody.codes/account/secrets/new?name=awsAccessKeyId&scope=user',
)
}
return {
accessKeyId,
secretAccessKey,
sessionToken,
accessKeySecret,
secretKeySecret,
sessionTokenSecret,
}
}
export async function resolveAwsRegion(
input: AwsAuthOptions = {},
): Promise<string> {
const fromInput = optionalString(inputRecord(input), 'region')
if (fromInput) return assertRegion(fromInput)
return resolveRegion()
}
export type AwsRequestInput = AwsAuthOptions & {
service: string
method?: string
path?: string
query?: Record<string, string | undefined>
headers?: Record<string, string>
body?: string
host?: string
unsignedPayload?: boolean
}
export async function awsSignedFetch(input: AwsRequestInput) {
const service = assertService(input.service)
const region = await resolveAwsRegion(input)
const credentials = await resolveAwsCredentials(input)
const host = input.host?.trim() || serviceHost(service, region)
assertAmazonAwsHost(host)
const url = new URL('https://' + host + (input.path || '/'))
for (const [key, value] of Object.entries(input.query ?? {})) {
if (value === undefined || value === '') continue
url.searchParams.set(key, value)
}
const method = (input.method ?? 'GET').toUpperCase()
const signed = await signAwsRequest({
method,
url: url.toString(),
region,
service,
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken,
headers: input.headers,
body: input.body,
unsignedPayload: input.unsignedPayload ?? service === 's3',
})
const response = await fetch(signed.url, {
method: signed.method,
headers: signed.headers,
body: signed.body,
})
const text = await response.text()
if (!response.ok) {
const details = slimErrorBody(text)
throw new AwsApiError(
response.status,
details,
nextStepFor(
response.status,
typeof details.message === 'string' ? details.message : '',
credentials.accessKeySecret,
credentials.secretKeySecret,
host,
),
)
}
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
text,
region,
host,
service,
}
}
export function parseJsonBody(text: string): unknown {
if (!text.trim()) return {}
return redactUnknown(JSON.parse(text))
}