Skip to content

src/policy.ts

61 lines · 2.3 KB · TypeScript
Raw
/** Pure rules for which Audible calls this package makes on a caller's behalf. */

const ASIN = '[A-Z0-9]{10}'

const ALLOWED: Array<{ method: string; path: RegExp }> = [
	{ method: 'GET', path: /^\/1\.0\/library$/ },
	{ method: 'GET', path: new RegExp(`^/1\\.0/library/${ASIN}$`) },
	{ method: 'GET', path: /^\/1\.0\/wishlist$/ },
	{ method: 'POST', path: /^\/1\.0\/wishlist$/ },
	{ method: 'DELETE', path: new RegExp(`^/1\\.0/wishlist/${ASIN}$`) },
]

export type QueryValue = string | number | boolean | undefined | null

/** True when the method and path match one allowed Audible call. */
export function isAllowedCall(method: string, path: string): boolean {
	const upper = method.toUpperCase()
	return ALLOWED.some((rule) => rule.method === upper && rule.path.test(path))
}

export function isAsin(value: unknown): value is string {
	return typeof value === 'string' && new RegExp(`^${ASIN}$`).test(value)
}

/** A Wishlist add sends exactly `{ asin }`; every other allowed call sends no body. */
export function isAllowedBody(method: string, path: string, body: unknown): boolean {
	if (method.toUpperCase() === 'POST' && path === '/1.0/wishlist') {
		if (!body || typeof body !== 'object') return false
		const keys = Object.keys(body)
		return keys.length === 1 && keys[0] === 'asin' && isAsin((body as { asin: unknown }).asin)
	}
	return body === undefined || body === null
}

/** Encode query values; keys must be plain Audible parameter names. */
export function buildQuery(query: Record<string, QueryValue> | undefined): string {
	if (!query) return ''
	const params = new URLSearchParams()
	for (const [key, value] of Object.entries(query)) {
		if (!/^[a-z_]+$/.test(key)) throw new Error(`Query key "${key}" is not allowed.`)
		if (value === undefined || value === null || value === '') continue
		params.set(key, String(value))
	}
	const text = params.toString()
	return text ? `?${text}` : ''
}

/**
 * Form body for an access-token refresh. `sourceToken` is an opaque Kody
 * placeholder, so it is appended as-is: URL-encoding it would stop Kody from
 * recognising it.
 */
export function refreshFormBody(sourceToken: string): string {
	return [
		'app_name=Audible',
		'app_version=3.56.2',
		'requested_token_type=access_token',
		'source_token_type=refresh_token',
		`source_token=${sourceToken}`,
	].join('&')
}