Skip to content

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

Package listing

@kody/twilio

src/validation.ts

205 lines · 6.1 KB · TypeScript
export type InputRecord = Record<string, unknown>

export const DEFAULT_ACCOUNT_SID_SECRET = 'twilioAccountSid'
export const DEFAULT_AUTH_TOKEN_SECRET = 'twilioAuthToken'
export const API_HOST = 'api.twilio.com'
export const API_ORIGIN = 'https://api.twilio.com'
export const API_ACCOUNT_PREFIX = '/2010-04-01/Accounts/'

const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/
const ACCOUNT_SID_SECRET_PATTERN =
	/^twilioAccountSid(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const AUTH_TOKEN_SECRET_PATTERN =
	/^twilioAuthToken(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const E164_PATTERN = /^\+[1-9]\d{1,14}$/
const MESSAGING_SERVICE_SID_PATTERN = /^MG[0-9a-fA-F]{32}$/

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 TwilioAuthOptions = {
	/** Extra Twilio account label. `work` reads `twilioAccountSid-work` / `twilioAuthToken-work`. */
	account?: string
	/** Override the Account SID secret name. Must be `twilioAccountSid` or `twilioAccountSid-<label>`. */
	accountSidSecret?: string
	/** Override the Auth Token secret name. Must be `twilioAuthToken` or `twilioAuthToken-<label>`. */
	authTokenSecret?: string
}

export function resolveAccountSidSecretName(
	input: TwilioAuthOptions = {},
): string {
	if (input.accountSidSecret != null && String(input.accountSidSecret).trim()) {
		const name = String(input.accountSidSecret).trim()
		if (!ACCOUNT_SID_SECRET_PATTERN.test(name)) {
			throw new Error(
				'accountSidSecret must be twilioAccountSid or twilioAccountSid-<account>.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account
		? DEFAULT_ACCOUNT_SID_SECRET + '-' + account
		: DEFAULT_ACCOUNT_SID_SECRET
}

export function resolveAuthTokenSecretName(
	input: TwilioAuthOptions = {},
): string {
	if (input.authTokenSecret != null && String(input.authTokenSecret).trim()) {
		const name = String(input.authTokenSecret).trim()
		if (!AUTH_TOKEN_SECRET_PATTERN.test(name)) {
			throw new Error(
				'authTokenSecret must be twilioAuthToken or twilioAuthToken-<account>.',
			)
		}
		return name
	}
	const account = accountLabel(input.account)
	return account
		? DEFAULT_AUTH_TOKEN_SECRET + '-' + account
		: DEFAULT_AUTH_TOKEN_SECRET
}

export function secretPlaceholder(secretName: string): string {
	return '{{secret:' + secretName + '|scope=user}}'
}

export function accountSidSetupUrl(secretName = DEFAULT_ACCOUNT_SID_SECRET): string {
	return (
		'https://kody.codes/account/secrets/new?name=' +
		encodeURIComponent(secretName) +
		'&description=' +
		encodeURIComponent('Twilio Account SID (AC...)') +
		'&allowedHosts=api.twilio.com&scope=user'
	)
}

export function authTokenSetupUrl(secretName = DEFAULT_AUTH_TOKEN_SECRET): string {
	return (
		'https://kody.codes/account/secrets/new?name=' +
		encodeURIComponent(secretName) +
		'&description=' +
		encodeURIComponent('Twilio Auth Token') +
		'&allowedHosts=api.twilio.com&scope=user'
	)
}

export function assertE164(value: string, key: string): string {
	if (!E164_PATTERN.test(value)) {
		throw new Error(
			key +
				' must be an E.164 phone number such as +15555550100 (no personal numbers are baked into this package).',
		)
	}
	return value
}

export function optionalE164(
	input: InputRecord,
	key: string,
): string | undefined {
	const value = optionalString(input, key)
	if (!value) return undefined
	return assertE164(value, key)
}

export function assertMessagingServiceSid(value: string): string {
	if (!MESSAGING_SERVICE_SID_PATTERN.test(value)) {
		throw new Error(
			'messagingServiceSid must be a Twilio Messaging Service SID (MG followed by 32 hex characters).',
		)
	}
	return value
}

/**
 * Mutations default to dry-run. A live send 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 compactForm(fields: Record<string, unknown>): Record<string, string> {
	const body: Record<string, string> = {}
	for (const [key, value] of Object.entries(fields)) {
		if (value === undefined || value === null || value === '') continue
		body[key] = String(value)
	}
	return body
}