Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/aws

src/helpers.ts

157 lines · 3.5 KB · TypeScript
import { redactUnknown } from './redact.ts'
import {
	accessKeySetupUrl,
	isDryRun,
	resolveAccessKeySecretName,
	resolveSecretKeySecretName,
	secretKeySetupUrl,
	type AwsAuthOptions,
} from './validation.ts'

export class AwsApiError extends Error {
	readonly status: number
	readonly code: string | null
	readonly nextStep: string
	readonly details: Record<string, unknown>

	constructor(
		status: number,
		details: Record<string, unknown>,
		nextStep: string,
	) {
		const code = typeof details.code === 'string' ? details.code : null
		const message =
			typeof details.message === 'string' && details.message.length > 0
				? details.message
				: 'AWS API request failed'
		super(
			'AWS API failed (' +
				status +
				(code ? '/' + code : '') +
				'): ' +
				message +
				'. Next step: ' +
				nextStep,
		)
		this.name = 'AwsApiError'
		this.status = status
		this.code = code
		this.nextStep = nextStep
		this.details = details
	}
}

export function nextStepFor(
	status: number,
	message: string,
	accessKeySecret: string,
	secretKeySecret: string,
	host: string,
): string {
	const accessUrl = accessKeySetupUrl(accessKeySecret)
	const secretUrl = secretKeySetupUrl(secretKeySecret)
	const lower = message.toLowerCase()

	if (
		status === 401 ||
		status === 403 ||
		lower.includes('invalidclienttokenid') ||
		lower.includes('signaturedoesnotmatch') ||
		lower.includes('incomplete') ||
		lower.includes('expired')
	) {
		return (
			'Save ' +
			accessKeySecret +
			' and ' +
			secretKeySecret +
			' (optional awsSessionToken for temporary credentials) and approve host ' +
			host +
			' plus the regional service hosts you call. Access key: ' +
			accessUrl +
			' Secret key: ' +
			secretUrl
		)
	}

	if (status === 404) {
		return (
			'AWS returned 404. Confirm the region, bucket, and resource exist, and that host ' +
			host +
			' is approved for both secrets.'
		)
	}

	return (
		'Confirm both secrets are saved and host ' +
		host +
		' is approved. Access key: ' +
		accessUrl +
		' Secret key: ' +
		secretUrl
	)
}

export type MutationPreview = {
	dryRun: true
	method: string
	service: string
	region: string
	host: string
	path: string
	body: Record<string, unknown> | null
	accessKeySecret: string
	secretKeySecret: string
}

export function mutationPreview(
	input: AwsAuthOptions & { dryRun?: unknown; confirm?: unknown },
	spec: {
		method: string
		service: string
		region: string
		host: string
		path: string
		body?: Record<string, unknown> | null
	},
): MutationPreview | null {
	if (!isDryRun(input)) return null
	return {
		dryRun: true,
		method: spec.method,
		service: spec.service,
		region: spec.region,
		host: spec.host,
		path: spec.path,
		body: spec.body ?? null,
		accessKeySecret: resolveAccessKeySecretName(input),
		secretKeySecret: resolveSecretKeySecretName(input),
	}
}

export function slimErrorBody(text: string): Record<string, unknown> {
	const redacted = redactUnknown(text)
	if (typeof redacted !== 'string') return { message: 'AWS request failed' }
	const codeMatch = redacted.match(/<Code>([\s\S]*?)<\/Code>/i)
	const messageMatch = redacted.match(/<Message>([\s\S]*?)<\/Message>/i)
	try {
		const parsed = JSON.parse(redacted) as Record<string, unknown>
		return {
			code:
				typeof parsed.__type === 'string'
					? parsed.__type
					: typeof parsed.code === 'string'
						? parsed.code
						: null,
			message:
				typeof parsed.message === 'string'
					? parsed.message
					: redacted.slice(0, 300),
		}
	} catch {
		return {
			code: codeMatch?.[1] ?? null,
			message: messageMatch?.[1] ?? redacted.slice(0, 300),
		}
	}
}