Skip to content

src/request.ts

113 lines · 3.6 KB · TypeScript
Raw
import { ACCESS_TOKEN_SECRET } from './config.ts'
import { loadDevice, requireDevice, saveDevice, type DeviceRecord } from './device.ts'
import { resolveLocale } from './locales.ts'
import { buildQuery, isAllowedBody, isAllowedCall, type QueryValue } from './policy.ts'
import { secretRef } from './secrets.ts'
import { freshDevice, refreshAccessToken } from './tokens.ts'

export type AudibleCall = {
	method?: string
	path: string
	query?: Record<string, QueryValue>
	body?: unknown
}

export class AudibleApiError extends Error {
	status: number
	body: unknown
	constructor(status: number, body: unknown, message: string) {
		super(message)
		this.name = 'AudibleApiError'
		this.status = status
		this.body = body
	}
}

async function send(device: DeviceRecord, method: string, url: string, bodyText: string) {
	const ref = await secretRef(ACCESS_TOKEN_SECRET)
	// No client-id header: Audible rejects this device's bearer token when one is sent.
	return fetch(url, {
		method,
		headers: {
			Accept: 'application/json',
			Authorization: `Bearer ${ref}`,
			...(bodyText ? { 'Content-Type': 'application/json' } : {}),
		},
		body: bodyText || undefined,
	})
}

/**
 * Make one allowed Audible call with the stored bearer token: Library list or
 * item, Wishlist list, add, or remove. Refreshes the token when needed. Never
 * returns credentials; any other call throws.
 *
 * @param call - method, path (no query string), query, and body
 * @returns The parsed Audible JSON response
 *
 * @example
 * import audibleRequest from 'kody:@danj/audible-auth/request'
 *
 * const page = await audibleRequest({ path: '/1.0/library', query: { num_results: 20 } })
 */
export default async function audibleRequest<T = unknown>(call: AudibleCall): Promise<T> {
	const method = (call.method ?? 'GET').toUpperCase()
	const path = call.path
	if (!isAllowedCall(method, path)) {
		throw new Error(`The Audible credential package does not allow ${method} ${path}.`)
	}
	if (!isAllowedBody(method, path, call.body)) {
		throw new Error(`The Audible credential package does not allow that body for ${method} ${path}.`)
	}
	return sendAudible<T>(method, path, call.query, call.body)
}

/**
 * Send one Audible call with the stored bearer token, refreshing it when
 * needed. Skips the caller allowlist: only this package's own fixed calls,
 * such as `./credits`, use it directly.
 */
export async function sendAudible<T = unknown>(
	method: string,
	path: string,
	query: Record<string, QueryValue> | undefined,
	body: unknown,
): Promise<T> {
	const pathWithQuery = `${path}${buildQuery(query)}`
	const bodyText = body == null ? '' : JSON.stringify(body)

	let device = await freshDevice(await requireDevice())
	const url = `https://${resolveLocale(device.locale).apiHost}${pathWithQuery}`
	let response = await send(device, method, url, bodyText)
	if (response.status === 401) {
		device = await refreshAccessToken(device)
		response = await send(device, method, url, bodyText)
	}

	const text = await response.text()
	let parsed: unknown = null
	if (text) {
		try {
			parsed = JSON.parse(text)
		} catch {
			parsed = text
		}
	}
	if (response.ok && !device.verifiedAt) {
		await saveDevice({ ...device, verifiedAt: new Date().toISOString() })
	}
	if (!response.ok) {
		throw new AudibleApiError(
			response.status,
			parsed,
			`Audible API ${method} ${pathWithQuery} → HTTP ${response.status}`,
		)
	}
	return parsed as T
}

/** Country code of the connected marketplace, or null before Connect. */
export async function connectedLocale(): Promise<string | null> {
	const device = await loadDevice()
	return device ? resolveLocale(device.locale).countryCode : null
}