import { ARCHIVE_ENABLED } from './config.ts' import { loadArchiveSigning, requireDevice } from './device.ts' import { resolveLocale } from './locales.ts' import { isAsin } from './policy.ts' import { signRequest } from './sign.ts' import { decryptVoucher } from './voucher.ts' export class ArchiveDisabledError extends Error { constructor(message: string) { super(message) this.name = 'ArchiveDisabledError' } } export type LicenseBody = { drm_type?: string supported_drm_types?: string[] quality?: 'High' | 'Normal' consumption_type?: 'Download' | 'Offline' | 'Streaming' response_groups?: string chapter_titles_type?: string } const BODY_KEYS = new Set([ 'drm_type', 'supported_drm_types', 'quality', 'consumption_type', 'response_groups', 'chapter_titles_type', ]) /** * Request an AAXC content license for one owned ASIN and decrypt its voucher. * Throws ArchiveDisabledError while NAS archive is off in this package. The * returned voucher holds per-title content keys, not account credentials. * * @param input - ASIN and the licenserequest body * @returns contentLicense (without the encrypted voucher) and the decrypted voucher * * @example * import requestLicense from 'kody:@danj/audible-auth/license' * * const { contentLicense, voucher } = await requestLicense({ asin, body: { drm_type: 'Adrm' } }) */ export default async function requestLicense(input: { asin: string; body: LicenseBody }) { if (!ARCHIVE_ENABLED) { throw new ArchiveDisabledError( 'NAS archive is off, so Books cannot be saved to the NAS. To turn it on, ask your agent to follow “Turning NAS archive on” in the README of the package that holds your Audible credentials.', ) } if (!isAsin(input.asin)) throw new Error('asin must be a 10-character Audible ASIN.') for (const key of Object.keys(input.body ?? {})) { if (!BODY_KEYS.has(key)) throw new Error(`License body key "${key}" is not allowed.`) } const device = await requireDevice() const signing = await loadArchiveSigning() if (!signing) { throw new ArchiveDisabledError('NAS archive is on, but this device has no signing key yet. Connect again on the Connect page.') } const path = `/1.0/content/${input.asin}/licenserequest` const bodyText = JSON.stringify(input.body) const signed = await signRequest({ method: 'POST', path, body: bodyText, adpToken: signing.adpToken, devicePrivateKey: signing.devicePrivateKey, }) const response = await fetch(`https://${resolveLocale(device.locale).apiHost}${path}`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...signed }, body: bodyText, }) const parsed = (await response.json().catch(() => null)) as { content_license?: Record } | null if (!response.ok) { throw new Error(`Audible licenserequest for ${input.asin} → HTTP ${response.status}`) } const { license_response: encrypted, ...contentLicense } = parsed?.content_license ?? {} let voucher: { key?: string; iv?: string; rules?: unknown } | undefined if (typeof encrypted === 'string') { try { if (!device.customerId) throw new Error('This device has no customer id.') voucher = await decryptVoucher({ deviceType: device.deviceType, serial: device.serial, customerId: device.customerId, asin: String(contentLicense.asin ?? input.asin), encryptedVoucher: encrypted, }) } catch (error) { voucher = { rules: { decryptError: String((error as Error)?.message ?? error) } } } } else if (encrypted && typeof encrypted === 'object') { const plain = encrypted as Record voucher = { key: typeof plain.key === 'string' ? plain.key : undefined, iv: typeof plain.iv === 'string' ? plain.iv : undefined, rules: plain.rules, } } return { contentLicense, voucher, locale: resolveLocale(device.locale).countryCode, apiHost: resolveLocale(device.locale).apiHost, } }