/** * The Connect page. It is the only place that creates an Amazon login link, * so the link always comes from this locked package's reviewed code. */ import { packageContext } from 'kody:runtime' import completeAuth from './complete-auth.ts' import { disconnect } from './disconnect.ts' import { listLocaleCodes, resolveLocale } from './locales.ts' import startAuth from './start-auth.ts' import authStatus from './status.ts' type Status = Awaited> /** What the page shows: the current status, a login in progress, or an outcome. */ type View = | { step: 'status' } | { step: 'login'; loginUrl: string; authSessionId: string } | { step: 'done'; message: string; previousDevice: string } | { step: 'disconnected'; deviceRemoved: boolean; secretsDeleted: boolean; locale: string | null } | { step: 'error'; message: string } function paths() { const base = packageContext?.appBasePath || '/packages/audible-auth' const hosted = packageContext?.hostedUrl || `https://kody.kody.run${base}` const mount = base.endsWith('/') ? base : `${base}/` return { base, mount, origin: new URL(hosted).origin } } /** Amazon's Manage Your Content and Devices list, where old virtual devices can be removed. */ function amazonDevicesUrl(locale: string | null): string { return `https://www.amazon.${resolveLocale(locale ?? 'us').domain}/hz/mycd/digital-console/alldevices` } function esc(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } /** A "back to the app" link, accepted only for an app on this same Kody host. */ function returnUrl(raw: unknown, origin: string): string | null { if (typeof raw !== 'string' || !raw) return null try { const url = new URL(raw) return url.origin === origin && url.pathname.startsWith('/packages/') ? url.toString() : null } catch { return null } } function render(status: Status, view: View, back: string | null): Response { const { mount } = paths() const hidden = back ? `` : '' const locales = listLocaleCodes() .map((code) => ``) .join('') const state = !status.connected ? 'Not connected.' : status.ready ? `Connected (${esc(status.locale)}).` : `Connected (${esc(status.locale)}). One step left: allow the tokens’ hosts below.` const approvals = status.connected && !status.ready && status.hostApprovals.length ? `

Allow each token’s hosts

Each button opens Kody with the secret and its hosts filled in. Click Allow there, then reload this page.

` : '' const flow = view.step === 'login' ? `

2. Sign in on Amazon

Open Amazon sign-in

Amazon ends on an error page at …/ap/maplanding?…. That page is expected. Copy its full address.

${hidden}
` : `
${hidden}

1. Choose your marketplace

` const flash = view.step === 'done' ? `

${esc(view.message)} ${view.previousDevice === 'removed' ? 'The previous virtual device was removed from your Amazon account.' : view.previousDevice === 'not-removed' ? 'The previous virtual device could not be removed; remove it in your Amazon account.' : ''}

` : view.step === 'disconnected' ? `

Disconnected. ${view.deviceRemoved ? 'The virtual device was removed from your Amazon account, so its tokens no longer work.' : 'The virtual device could not be removed: remove it in your Amazon account’s device list.'} ${view.secretsDeleted ? 'Both token secrets were deleted from Kody.' : 'Delete the token secrets in Kody yourself.'}

` : view.step === 'error' ? `

${esc(view.message)}

` : '' const html = ` Connect Audible

Connect Audible

This page belongs to the locked package that holds your Audible credentials. Start Connect only here: a login link from anywhere else may belong to someone else. Credentials never appear on this page.

${flash}

${state}

${approvals} ${flow} ${status.connected ? `

NAS archive: ${status.archiveEnabled ? 'on' : 'off'}

NAS archive lets an app save Books you own to your NAS. Audible allows those downloads only with this device's signing key.

${status.archiveEnabled ? status.archiveReady ? 'This setup keeps the signing key, so apps can save Books to your NAS.' : 'The feature is on, but this device has no signing key yet. Tap Connect again to get one.' : 'This setup keeps no signing key, so no app can save Books to your NAS. To turn the feature on, ask your agent to follow “Turning NAS archive on” in this package’s README.'}

` : ''} ${status.connected || view.step === 'disconnected' ? `

Your Amazon devices

Each Connect adds a virtual device named like ${esc(status.deviceName ?? 'Your Audible for iPhone')}. ${status.connected ? `Keep ${esc(status.deviceName ?? 'the newest one')}; remove older “Audible for iPhone” devices from earlier setups.` : 'Remove any “Audible for iPhone” device left from this setup.'}

Open your Amazon devices

` : ''} ${status.connected && view.step !== 'login' ? `
${hidden}

Disconnect

Removes the virtual device from your Amazon account and deletes both token secrets.

` : ''} ${back ? `

Back to the app

` : ''} ` return new Response(html, { status: view.step === 'error' ? 400 : 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, }) } async function readForm(request: Request): Promise> { const form = await request.formData() const fields: Record = {} for (const [key, value] of form) if (typeof value === 'string') fields[key] = value return fields } export default { async fetch(request: Request): Promise { const { base, origin } = paths() const url = new URL(request.url) let path = url.pathname.startsWith(base) ? url.pathname.slice(base.length) : url.pathname path = path.replace(/\/+$/, '') || '/' const method = request.method.toUpperCase() if (method === 'POST') { // The app session cookie rides along with browser posts. Refuse cross-site forms. const from = request.headers.get('origin') const site = request.headers.get('sec-fetch-site') if ((from && from !== url.origin) || (site && site !== 'same-origin' && site !== 'none')) { return new Response('Cross-origin POST denied', { status: 403 }) } } if (method === 'GET' && path === '/') { return render(await authStatus(), { step: 'status' }, returnUrl(url.searchParams.get('return'), origin)) } if (method === 'POST' && (path === '/start' || path === '/complete' || path === '/disconnect')) { const form = await readForm(request) const back = returnUrl(form.return, origin) try { if (path === '/disconnect') { const { locale } = await authStatus() const result = await disconnect() return render(await authStatus(), { step: 'disconnected', ...result, locale }, back) } if (path === '/start') { const started = await startAuth({ locale: form.locale }) return render(await authStatus(), { step: 'login', loginUrl: started.loginUrl, authSessionId: started.authSessionId }, back) } const result = await completeAuth({ redirectUrl: form.redirectUrl ?? '', authSessionId: form.authSessionId }) return render(result.status, { step: 'done', message: result.message, previousDevice: result.previousDevice }, back) } catch (error) { const message = error instanceof Error ? error.message : String(error) return render(await authStatus(), { step: 'error', message }, back) } } return new Response('Not found', { status: 404 }) }, }