Skip to content

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

Package listing

@kody/aws

src/validation.ts

280 lines · 8.0 KB · TypeScript
export type InputRecord = Record<string, unknown>

export const DEFAULT_ACCESS_KEY_SECRET = 'awsAccessKeyId'
export const DEFAULT_SECRET_KEY_SECRET = 'awsSecretAccessKey'
export const DEFAULT_SESSION_TOKEN_SECRET = 'awsSessionToken'
export const DEFAULT_REGION = 'us-east-1'

const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/
const ACCESS_KEY_SECRET_PATTERN =
	/^awsAccessKeyId(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const SECRET_KEY_SECRET_PATTERN =
	/^awsSecretAccessKey(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const SESSION_TOKEN_SECRET_PATTERN =
	/^awsSessionToken(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const REGION_PATTERN = /^[a-z]{2}(?:-[a-z]+)+-\d+$/
const SERVICE_PATTERN = /^[a-z0-9][a-z0-9-]{0,47}$/
const BUCKET_PATTERN = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/

export const COMMON_AWS_HOSTS = [
	'sts.amazonaws.com',
	'sts.us-east-1.amazonaws.com',
	's3.amazonaws.com',
	's3.us-east-1.amazonaws.com',
	'logs.us-east-1.amazonaws.com',
	'monitoring.us-east-1.amazonaws.com',
] as const

export function inputRecord(value: unknown): InputRecord {
	if (value === null || typeof value !== 'object' || Array.isArray(value)) {
		throw new Error('Input must be an object.')
	}
	return value as InputRecord
}

export function optionalString(
	input: InputRecord,
	key: string,
): string | undefined {
	const value = input[key]
	if (value === undefined || value === null || value === '') return undefined
	if (typeof value !== 'string') throw new Error(key + ' must be a string.')
	return value.trim()
}

export function requiredString(input: InputRecord, key: string): string {
	const value = optionalString(input, key)
	if (!value) throw new Error(key + ' is required.')
	return value
}

export function optionalBoolean(
	input: InputRecord,
	key: string,
): boolean | undefined {
	const value = input[key]
	if (value === undefined || value === null) return undefined
	if (typeof value !== 'boolean') throw new Error(key + ' must be a boolean.')
	return value
}

export function boundedPageSize(
	value: unknown,
	fallback = 20,
	max = 1000,
): number {
	if (value === undefined || value === null || value === '') return fallback
	if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > max) {
		throw new Error(
			'pageSize must be an integer from 1 through ' + max + '.',
		)
	}
	return Number(value)
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error(
			'account must be a short label such as "work" or "live" (letters, numbers, _ or -).',
		)
	}
	return trimmed
}

export type AwsAuthOptions = {
	/** Extra AWS account label. `work` reads `awsAccessKeyId-work` / `awsSecretAccessKey-work`. */
	account?: string
	/** Override the access key secret name. Must be `awsAccessKeyId` or `awsAccessKeyId-<label>`. */
	accessKeySecret?: string
	/** Override the secret key secret name. Must be `awsSecretAccessKey` or `awsSecretAccessKey-<label>`. */
	secretKeySecret?: string
	/** Override the optional session token secret name. */
	sessionTokenSecret?: string
	/** AWS region. Defaults to us-east-1 or a fork-local packageStorage default. */
	region?: string
}

export function resolveAccessKeySecretName(
	input: AwsAuthOptions = {},
): string {
	if (input.accessKeySecret != null && String(input.accessKeySecret).trim()) {
		const name = String(input.accessKeySecret).trim()
		if (!ACCESS_KEY_SECRET_PATTERN.test(name)) {
			throw new Error(
				'accessKeySecret must be awsAccessKeyId or awsAccessKeyId-<account>.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account
		? DEFAULT_ACCESS_KEY_SECRET + '-' + account
		: DEFAULT_ACCESS_KEY_SECRET
}

export function resolveSecretKeySecretName(
	input: AwsAuthOptions = {},
): string {
	if (input.secretKeySecret != null && String(input.secretKeySecret).trim()) {
		const name = String(input.secretKeySecret).trim()
		if (!SECRET_KEY_SECRET_PATTERN.test(name)) {
			throw new Error(
				'secretKeySecret must be awsSecretAccessKey or awsSecretAccessKey-<account>.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account
		? DEFAULT_SECRET_KEY_SECRET + '-' + account
		: DEFAULT_SECRET_KEY_SECRET
}

export function resolveSessionTokenSecretName(
	input: AwsAuthOptions = {},
): string {
	if (
		input.sessionTokenSecret != null &&
		String(input.sessionTokenSecret).trim()
	) {
		const name = String(input.sessionTokenSecret).trim()
		if (!SESSION_TOKEN_SECRET_PATTERN.test(name)) {
			throw new Error(
				'sessionTokenSecret must be awsSessionToken or awsSessionToken-<account>.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account
		? DEFAULT_SESSION_TOKEN_SECRET + '-' + account
		: DEFAULT_SESSION_TOKEN_SECRET
}

export function assertRegion(value: string): string {
	if (!REGION_PATTERN.test(value)) {
		throw new Error(
			'region must look like us-east-1 or eu-west-1 (no account ids).',
		)
	}
	return value
}

export function assertService(value: string): string {
	if (!SERVICE_PATTERN.test(value)) {
		throw new Error(
			'service must be a short AWS service id such as sts, s3, logs, or monitoring.',
		)
	}
	return value
}

export function assertBucket(value: string): string {
	if (!BUCKET_PATTERN.test(value) || value.includes('..')) {
		throw new Error(
			'bucket must be a DNS-compliant S3 bucket name. Pass it at call time — this package has no baked-in buckets.',
		)
	}
	return value
}

export function assertObjectKey(value: string): string {
	if (!value || value.startsWith('/') || value.includes('//')) {
		throw new Error('key must be an S3 object key without a leading slash.')
	}
	if (value.length > 1024) {
		throw new Error('key must be at most 1024 characters.')
	}
	return value
}

export function allowedHostsQuery(): string {
	return COMMON_AWS_HOSTS.join(',')
}

export function accessKeySetupUrl(
	secretName = DEFAULT_ACCESS_KEY_SECRET,
): string {
	return (
		'https://kody.codes/account/secrets/new?name=' +
		encodeURIComponent(secretName) +
		'&description=' +
		encodeURIComponent('AWS access key id (AKIA...)') +
		'&allowedHosts=' +
		encodeURIComponent(allowedHostsQuery()) +
		'&scope=user'
	)
}

export function secretKeySetupUrl(
	secretName = DEFAULT_SECRET_KEY_SECRET,
): string {
	return (
		'https://kody.codes/account/secrets/new?name=' +
		encodeURIComponent(secretName) +
		'&description=' +
		encodeURIComponent('AWS secret access key') +
		'&allowedHosts=' +
		encodeURIComponent(allowedHostsQuery()) +
		'&scope=user'
	)
}

export function sessionTokenSetupUrl(
	secretName = DEFAULT_SESSION_TOKEN_SECRET,
): string {
	return (
		'https://kody.codes/account/secrets/new?name=' +
		encodeURIComponent(secretName) +
		'&description=' +
		encodeURIComponent('Optional AWS session token for temporary credentials') +
		'&allowedHosts=' +
		encodeURIComponent(allowedHostsQuery()) +
		'&scope=user'
	)
}

/**
 * Mutations default to dry-run. A live write requires `confirm: true` and
 * `dryRun` not set to true.
 */
export function isDryRun(input: {
	dryRun?: unknown
	confirm?: unknown
}): boolean {
	if (input.dryRun === true) return true
	if (input.confirm === true) return false
	return true
}

export function asRecord(value: unknown): Record<string, unknown> {
	if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
	return value as Record<string, unknown>
}

export function stringField(
	record: Record<string, unknown>,
	key: string,
): string | null {
	const value = record[key]
	return typeof value === 'string' && value.length > 0 ? value : null
}

export function xmlTag(xml: string, tag: string): string | null {
	const match = xml.match(
		new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>', 'i'),
	)
	return match?.[1]?.trim() ? match[1].trim() : null
}

export function xmlTags(xml: string, tag: string): Array<string> {
	const values: Array<string> = []
	const pattern = new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>', 'gi')
	for (const match of xml.matchAll(pattern)) {
		const value = match[1]?.trim()
		if (value) values.push(value)
	}
	return values
}