import { kody } from 'kody:runtime' const BASE = 'https://app.hey.com' const TOKEN_URL = 'https://app.hey.com/oauth/tokens' const MAX_REDIRECTS = 5 const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) // Fetch request-body-header names, plus Content-Length which a rewritten // GET must not replay from the original init. const REQUEST_BODY_HEADERS = [ 'Content-Encoding', 'Content-Language', 'Content-Location', 'Content-Type', 'Content-Length', ] // Public OAuth client id of the official HEY CLI (PKCE public client, no // client secret). It identifies the app, not the user; every user brings // their own tokens through the heyToken / heyRefreshToken user secrets. const CLIENT_ID = 'khMWSVDVSq78oyKA3KtxmYRv' /** * Resolve a request path against app.hey.com and refuse any other origin. * Attachment URLs come from untrusted email HTML, so the first credentialed * request must stay on app.hey.com. Redirect hops are checked separately: * Workers forwards Authorization when redirect mode is "follow". * * @param {string} path * @returns {string} */ function resolveHeyUrl(path) { const raw = String(path || '') const url = raw.startsWith('http') ? new URL(raw) : new URL(`${BASE}${raw.startsWith('/') ? '' : '/'}${raw}`) if (url.origin !== BASE) { throw new Error(`Refusing to send HEY credentials to ${url.origin}`) } url.username = '' url.password = '' return url.toString() } /** * Decide where a 3xx Location may go. * Same-origin hops keep the bearer. Cross-origin hops are either refused * (JSON and token refresh) or followed with no Authorization (PDF storage). * * @param {string} currentUrl * @param {string} location * @param {{ allowCrossOrigin?: boolean }} [opts] * @returns {{ url: string, authenticated: boolean }} */ function resolveRedirect(currentUrl, location, opts = {}) { let next try { next = new URL(location, currentUrl) } catch { throw new Error('HEY redirect had an invalid Location') } next.username = '' next.password = '' next.hash = '' if (next.protocol !== 'https:') { throw new Error(`Refusing to follow HEY redirect to ${next.origin}`) } if (next.origin === BASE) { return { url: next.toString(), authenticated: true } } if (!opts.allowCrossOrigin) { throw new Error(`Refusing to follow HEY redirect to ${next.origin}`) } return { url: next.toString(), authenticated: false } } function stripRequestBodyHeaders(headers) { for (const name of REQUEST_BODY_HEADERS) headers.delete(name) } /** * Fetch redirect method/body rules for same-origin hops: * 301/302 rewrite POST to GET; 303 rewrites every method except GET/HEAD. * PUT and HEAD keep their method on 301/302. 307/308 always replay. * https://fetch.spec.whatwg.org/#http-redirect-fetch * * @param {number} status * @param {string} method */ function shouldRewriteToGet(status, method) { if (status === 301 || status === 302) return method === 'POST' if (status === 303) return method !== 'GET' && method !== 'HEAD' return false } function httpError(message, status, extra = {}) { const err = new Error(message) err.status = status for (const [key, value] of Object.entries(extra)) err[key] = value return err } function tokenPersistSecrets() { return [ { name: 'heyToken', scope: 'user' }, { name: 'heyRefreshToken', scope: 'user' }, ] } const PERSIST_ATTEMPTS = 2 function requireSecretSetMany() { if (typeof kody.secretSetMany === 'function') return throw httpError( 'HEY token refresh requires kody.secretSetMany to assert and persist heyToken / heyRefreshToken atomically. This Kody runtime does not expose it.', undefined, { code: 'HEY_TOKEN_PERSIST_UNSUPPORTED' } ) } /** * Fail before HEY consumes the refresh grant when this package cannot write * the rotated secrets. secretSetMany({ assertOnly: true }) is the supported * authorization-only check; user-secret secretSet rejects a missing value. */ async function assertCanPersistRotatedTokens() { requireSecretSetMany() try { await kody.secretSetMany({ assertOnly: true, secrets: tokenPersistSecrets(), }) } catch (err) { if (err?.code === 'HEY_TOKEN_PERSIST_UNSUPPORTED') throw err throw httpError( 'HEY token refresh aborted: this package cannot write heyToken / heyRefreshToken. Ask the owner to approve write access on the link kody.secretLock returns. The refresh grant was not consumed.', err?.status, { cause: err } ) } } function rotationUnsavedError(cause) { return httpError( 'HEY token refresh succeeded at the token endpoint but could not persist the rotated heyToken / heyRefreshToken. The refresh grant was already consumed. Re-authenticate HEY and update both user secrets; retrying with the saved refresh token will fail.', cause?.status, { cause, code: 'HEY_TOKEN_ROTATION_UNSAVED' } ) } /** * Persist the token pair HEY already returned. Retry the same batch without * another OAuth exchange so a transient secretSetMany failure does not spend * a second refresh grant. If saving still fails, the in-memory pair is lost * and the saved grant is already consumed. * * @param {{ access_token: string, refresh_token?: string }} tokens */ async function persistRotatedTokens(tokens) { requireSecretSetMany() const accessSecret = { name: 'heyToken', scope: 'user', value: tokens.access_token, } const refreshSecret = tokens.refresh_token ? { name: 'heyRefreshToken', scope: 'user', value: tokens.refresh_token, description: 'HEY OAuth refresh token (auto-rotated on 401)', } : null const secrets = refreshSecret ? [refreshSecret, accessSecret] : [accessSecret] let lastErr for (let attempt = 0; attempt < PERSIST_ATTEMPTS; attempt++) { try { await kody.secretSetMany({ secrets }) return } catch (err) { lastErr = err } } throw rotationUnsavedError(lastErr) } /** * Exchange the heyRefreshToken user secret for a fresh access token and * persist it back to the heyToken user secret (rotating the refresh token * too when HEY returns a new one). Called automatically on HEY 401. * * HEY's token endpoint requires the CLI's install_id alongside the refresh * grant; it lives in the heyInstallId user secret (read-only for the package). * Writing the rotated tokens back needs the package's allowed_packages grant * on heyToken / heyRefreshToken; the owner approves that once on the link * kody.secretLock returns. * * @returns {Promise} the new access token */ async function refreshAccessToken() { await assertCanPersistRotatedTokens() const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: '{{secret:heyRefreshToken}}', client_id: CLIENT_ID, install_id: '{{secret:heyInstallId}}', }) const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'User-Agent': 'kody-hey-email', }, body: body.toString(), redirect: 'manual', }) if (res.status >= 300 && res.status < 400) { throw new Error( `HEY token refresh refused to follow a redirect (HTTP ${res.status}).` ) } if (!res.ok) { throw httpError( `HEY token refresh failed (HTTP ${res.status}). The saved HEY refresh token is invalid or expired; re-authenticate HEY and update the heyToken / heyRefreshToken user secrets.`, res.status ) } const tokens = await res.json() if (!tokens || !tokens.access_token) { throw new Error('HEY token refresh returned no access_token') } await persistRotatedTokens(tokens) return tokens.access_token } function shouldRefreshHeyToken(err) { return Boolean(err && err.status === 401 && err.authenticated === true) } /** * Run fn once; on a credentialed HEY 401, refresh the access token and run * it exactly once more. A storage 401 (unauthenticated hop) does not rotate * HEY tokens. A second 401 propagates instead of looping. * * @param {() => Promise} fn */ async function withAutoRefresh(fn) { try { return await fn() } catch (err) { if (shouldRefreshHeyToken(err)) { await refreshAccessToken() return await fn() } throw err } } /** * Fetch with redirect: "manual" so the bearer cannot ride a cross-origin hop. * Workers forwards Authorization, Cookie, and the body when mode is "follow". * Same-origin hops follow the Fetch method/body rules. PDF downloads may * continue off app.hey.com, but only as an unauthenticated GET. * * @param {string} url * @param {RequestInit} init * @param {{ binary?: boolean }} [opts] * @returns {Promise<{ response: Response, authenticated: boolean }>} */ async function fetchHey(url, init, opts = {}) { let current = url let authenticated = true let method = String(init.method || 'GET').toUpperCase() let body = init.body const headers = new Headers(init.headers || {}) for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { if (authenticated) { if (!headers.has('Authorization')) { headers.set('Authorization', 'Bearer {{secret:heyToken}}') } } else { headers.delete('Authorization') headers.delete('Cookie') } if (!headers.has('Accept')) { headers.set('Accept', opts.binary ? '*/*' : 'application/json') } const response = await fetch(current, { ...init, method, body, headers: new Headers(headers), redirect: 'manual', }) if (!REDIRECT_STATUSES.has(response.status)) { return { response, authenticated } } if (hop === MAX_REDIRECTS) throw new Error('Too many HEY redirects') const loc = response.headers.get('location') if (!loc) { throw new Error(`HEY redirect (HTTP ${response.status}) missing Location`) } try { await response.body?.cancel() } catch { // ignore cancel failures } const next = resolveRedirect(current, loc, { allowCrossOrigin: Boolean(opts.binary) }) // Off-origin binary hops are always an anonymous GET, including 307/308. // Same-origin hops keep Fetch method/body semantics (PUT/HEAD stay put // on 301/302; only POST rewrites there; 303 rewrites to GET). if (!next.authenticated) { method = 'GET' body = undefined stripRequestBodyHeaders(headers) headers.delete('Authorization') headers.delete('Cookie') } else if (shouldRewriteToGet(response.status, method)) { method = 'GET' body = undefined stripRequestBodyHeaders(headers) } current = next.url authenticated = next.authenticated } throw new Error('Too many HEY redirects') } async function doJsonRequest(url, path, init) { const { response, authenticated } = await fetchHey(url, init) const text = await response.text() let data = text try { data = text ? JSON.parse(text) : null } catch { // keep text } if (!response.ok) { throw httpError(`HEY ${response.status} ${path}`, response.status, { authenticated, body: typeof data === 'string' ? data.slice(0, 500) : data, }) } return { status: response.status, headers: response.headers, data } } /** * Authenticated fetch to app.hey.com using the heyToken secret. * On HEY 401 the access token is refreshed automatically via the * heyRefreshToken secret and the request is retried once. * @param {string} path * @param {RequestInit} [init] */ export async function heyFetch(path, init = {}) { const { data } = await heyRequest(path, init) return data } /** * Same as heyFetch but keeps status + response headers (Location on draft create). * Auto-refreshes on HEY 401 like heyFetch. * @param {string} path * @param {RequestInit} [init] * @returns {Promise<{ status: number, headers: Headers, data: any }>} */ export async function heyRequest(path, init = {}) { const url = resolveHeyUrl(path) return withAutoRefresh(() => doJsonRequest(url, path, init)) } function binaryTooLarge(rawSize, maxBytes, path) { return httpError(`HEY binary exceeded ${maxBytes} bytes ${path}`, undefined, { code: 'HEY_BINARY_TOO_LARGE', rawSize, maxBytes, }) } async function readBodyCapped(response, maxBytes, path) { const reader = response.body?.getReader() if (!reader) { const buf = await response.arrayBuffer() if (buf.byteLength > maxBytes) throw binaryTooLarge(buf.byteLength, maxBytes, path) return new Uint8Array(buf) } const chunks = [] let total = 0 while (true) { const { done, value } = await reader.read() if (done) break const next = total + value.byteLength if (next > maxBytes) { try { await reader.cancel() } catch { // ignore cancel failures } throw binaryTooLarge(next, maxBytes, path) } chunks.push(value) total = next } const out = new Uint8Array(total) let offset = 0 for (const chunk of chunks) { out.set(chunk, offset) offset += chunk.byteLength } return out } async function doBinaryRequest(url, path, init, maxBytes) { const { response, authenticated } = await fetchHey(url, init, { binary: true }) if (!response.ok) { let body = null try { body = (await response.text()).slice(0, 500) } catch { body = null } throw httpError(`HEY binary ${response.status} ${path}`, response.status, { authenticated, body, }) } if (Number.isFinite(maxBytes) && maxBytes >= 0) { const lengthHeader = response.headers.get('content-length') if (lengthHeader != null && lengthHeader !== '') { const contentLength = Number(lengthHeader) if (Number.isFinite(contentLength) && contentLength > maxBytes) { try { await response.body?.cancel() } catch { // ignore cancel failures } throw binaryTooLarge(contentLength, maxBytes, path) } } return readBodyCapped(response, maxBytes, path) } const buf = await response.arrayBuffer() return new Uint8Array(buf) } /** * Authenticated binary fetch (PDF attachments). * Cross-origin redirects are followed without the bearer token. * Auto-refreshes on HEY 401 like heyRequest. * Pass maxBytes to refuse before buffering a body over that size: * Content-Length on the final response, then a stream cancel at maxBytes + 1. * @param {string} path * @param {RequestInit & { maxBytes?: number }} [init] * @returns {Promise} */ export async function heyFetchBinary(path, init = {}) { const { maxBytes, ...requestInit } = init const url = resolveHeyUrl(path) return withAutoRefresh(() => doBinaryRequest(url, path, requestInit, maxBytes)) } export { BASE, resolveHeyUrl, resolveRedirect }