Skip to content

src/status.ts

87 lines · 3.0 KB · TypeScript
Raw
import { ACCESS_TOKEN_SECRET, ARCHIVE_ENABLED, REFRESH_TOKEN_SECRET } from './config.ts'
import { loadArchiveSigning, loadDevice, type DeviceRecord } from './device.ts'
import { resolveLocale } from './locales.ts'
import audibleRequest from './request.ts'
import { secretStates } from './secrets.ts'
import { requiredHosts } from './tokens.ts'

const KODY_ORIGIN = 'https://kody.codes'

export type HostApproval = {
	/** Secret that needs the hosts. */
	secret: string
	/** Hosts this secret may be sent to. */
	hosts: string[]
	/** Kody's Allow page with the secret and hosts filled in; null without a package id. */
	url: string | null
}

function approvalUrl(secret: string, hosts: string[], packageId: string | undefined) {
	if (!packageId) return null
	const params = new URLSearchParams({
		name: secret,
		hosts: hosts.join(','),
		scope: 'package',
		packageId,
	})
	return `${KODY_ORIGIN}/connect/secrets?${params}`
}

/**
 * Report whether Audible is connected and usable. Returns metadata only:
 * no token, key, or secret value.
 *
 * @returns connection state, the host approvals still needed with one-click links, and archive state
 *
 * @example
 * import authStatus from 'kody:@danj/audible-auth/status'
 *
 * const status = await authStatus()
 * // { connected: true, ready: true, locale: 'ca', hostApprovals: [], archiveEnabled: false, … }
 */
export default async function authStatus() {
	let device = await loadDevice()
	const states = await secretStates([ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET])
	if (device && !states && !device.verifiedAt) device = await verifyHosts(device)
	const required: Record<string, string[]> = device ? requiredHosts(device) : {}
	const packageId = device?.packageId

	// Without secret metadata, a successful Audible call is the proof of approval.
	const hostsKnown = Boolean(states) || Boolean(device?.verifiedAt)
	const hostApprovals: HostApproval[] = []
	for (const [secret, hosts] of Object.entries(required)) {
		const missing = states
			? hosts.filter((host) => !(states[secret]?.hosts ?? []).includes(host))
			: device?.verifiedAt
				? []
				: hosts
		if (missing.length) {
			hostApprovals.push({ secret, hosts: missing, url: approvalUrl(secret, missing, packageId) })
		}
	}
	const connected = Boolean(device)
	return {
		ok: true as const,
		connected,
		ready: connected && hostsKnown && hostApprovals.length === 0,
		locale: device ? resolveLocale(device.locale).countryCode : null,
		deviceName: device?.deviceName ?? null,
		connectedAt: device?.connectedAt ?? null,
		accessTokenExpiresAt: device
			? new Date(device.accessTokenExpiresAt * 1000).toISOString()
			: null,
		hostApprovals,
		archiveEnabled: ARCHIVE_ENABLED,
		archiveReady: ARCHIVE_ENABLED && Boolean(await loadArchiveSigning()),
	}
}

/** Where secret metadata is unavailable, one small Library call proves the hosts are approved. */
async function verifyHosts(device: DeviceRecord): Promise<DeviceRecord> {
	try {
		await audibleRequest({ path: '/1.0/library', query: { num_results: 1 } })
		return (await loadDevice()) ?? device
	} catch {
		return device
	}
}