← Public packages
@danj/audible-auth
Locked Audible credential gateway: Connect, token refresh, and narrow Audible calls for other Kody packages.
src/request.ts
113 lines · 3.6 KB · TypeScriptimport { 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
}