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
}