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
}