/** 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 | 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('&') }