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 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(call: AudibleCall): Promise { 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(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( method: string, path: string, query: Record | undefined, body: unknown, ): Promise { 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 { const device = await loadDevice() return device ? resolveLocale(device.locale).countryCode : null }