import { GRANOLA_HOST, GRANOLA_ORIGIN, type Account } from './account.ts' import { DOCS, hostApprovalUrl, secretSetupUrl } from './urls.ts' export type Query = Record export type GranolaResponse = { ok: boolean; status: number; url: string; data: T | null; errorCode: string | null } export class GranolaRequestError extends Error { readonly status: number readonly url: string readonly code: string | null readonly data: unknown constructor(message: string, status: number, url: string, code: string | null, data: unknown) { super(message) this.name = 'GranolaRequestError' this.status = status this.url = url this.code = code this.data = data } } /** Build `https://public-api.granola.ai?query`. Only API-relative `/v1/...` paths are accepted. */ export function buildUrl(path: string, query?: Query): string { if (!/^\/v1\//.test(path)) throw new Error(`Granola helpers only call ${GRANOLA_ORIGIN}/v1/... (got ${path}).`) const params = new URLSearchParams() for (const [key, value] of Object.entries(query ?? {})) { if (value === undefined || value === null || value === '') continue params.append(key, String(value)) } const qs = params.toString() return GRANOLA_ORIGIN + path + (qs ? `?${qs}` : '') } /** Error code + message from a Granola error body (shape varies: `{error:{code,message}}`, `{code,message}`, …). */ export function errorDetail(data: unknown): { code: string | null; message: string } { if (data && typeof data === 'object') { const d = data as Record const e = d.error && typeof d.error === 'object' ? d.error : d const code = typeof e.code === 'string' ? e.code : typeof d.code === 'string' ? d.code : null const parts = [e.message, typeof d.error === 'string' ? d.error : undefined, d.detail, d.title] .filter((p) => typeof p === 'string' && p.trim()) .map(String) if (e.details !== undefined) parts.push(`details: ${JSON.stringify(e.details).slice(0, 800)}`) return { code, message: parts.join('; ').slice(0, 1500) } } if (typeof data === 'string' && !/-audit.` ) } if ((status === 403 || status === 404) && area === 'legalHolds' && !/^\/v1\/legal-holds\/lgh_/.test(path)) { return ( 'This key cannot use legal holds. They need a Legal hold API key (Enterprise, enabled by Granola; a workspace admin creates it in Settings → Connectors → API → Legal hold API keys, ' + `${DOCS.legalHolds}). Save it under its own account name, e.g. granola--legal.` ) } switch (status) { case 401: return ( `The API key in Kody secret "${a.secretName}" is invalid, revoked or expired (personal keys can expire). ` + `Create a new key in the Granola desktop app (Settings → Connectors → API keys, ${DOCS.apiKeys}) and save it at ${secretSetupUrl(a.secretName)}.` ) case 403: return ( 'The key is not allowed to do this. The Granola API needs a Business or Enterprise plan; on Enterprise an admin chooses which ' + 'scopes members may use; webhook and audit access depend on the key type and role; legal holds need a separate "Legal hold API key" (Enterprise, enabled by Granola).' ) case 404: return ( (area === 'webhooks' ? 'Not found, or this key cannot manage webhook endpoints (a personal key sees only the endpoints it created; audit and legal-hold keys see none).' : area === 'legalHolds' ? 'No such legal hold or custodian for this key. If this secret is not a Legal hold API key, every legal-hold endpoint returns 404 (Enterprise, enabled by Granola; https://docs.granola.ai/legal-holds).' : 'Not found, or not visible to this key. Notes appear only after their AI summary and transcript are generated, and only if they match the key\'s access scopes ' + '(personal notes / public notes / API-enabled spaces). If this secret holds an Audit or Legal hold API key, it cannot read notes or folders at all.') ) case 409: return 'Conflict (for legal holds: a live hold already has this name, or the hold is released).' case 413: return code === 'TRANSCRIPT_TOO_LARGE' || !code ? 'Transcript too large to return inline; fetch it in pages with ./notes/transcript.' : 'Payload too large.' case 429: return 'Rate limited (Granola allows bursts of 25 requests, then 5 requests/second). Wait and retry.' default: return status >= 300 && status < 400 ? 'Unexpected redirect (not followed).' : '' } } function wrapFetchError(a: Account, url: string, error: unknown): Error { const message = error instanceof Error ? error.message : String(error) if (/host approval|not approved|allowed host|not allowed/i.test(message)) { return new Error( `Granola account "${a.account}": Kody has not approved host ${GRANOLA_HOST} for secret "${a.secretName}". ` + `The Kody account owner must open ${hostApprovalUrl(a.secretName)} and click Allow, then retry. (${message})`, ) } if (/secret/i.test(message) && /(not found|missing|unknown|does not exist)/i.test(message)) { return new Error( `Granola account "${a.account}": Kody secret "${a.secretName}" does not exist. Create a Granola API key ` + `(Settings → Connectors → API keys, ${DOCS.apiKeys}) and save it at ${secretSetupUrl(a.secretName)} — never paste keys into chat. (${message})`, ) } return new Error(`Granola request to ${url} failed for account "${a.account}": ${message}`) } export type RequestOptions = { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' path: string query?: Query json?: unknown /** Return non-2xx responses instead of throwing (default: throw). */ throwOnError?: boolean /** Retries on 429 / 503 (default 3; writes default 0 except 429). */ retries?: number } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) /** Seconds from Retry-After, else exponential backoff starting at 1s; capped at 10s. */ export function retryDelayMs(retryAfter: string | null, attempt: number): number { const seconds = retryAfter ? Number(retryAfter) : Number.NaN if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 10_000) return Math.min(1000 * 2 ** attempt, 10_000) } /** Injected by tests/harness; production uses the global fetch (Kody swaps the secret placeholder). */ export const transport: { fetch: typeof fetch } = { fetch: (input, init) => fetch(input, init) } /** * One authenticated call to the Granola public API. The key is sent as * `Authorization: Bearer {{secret:|scope=user}}`; Kody substitutes the value only for * an approved host, so the key never enters this isolate. Redirects are never followed. */ export async function granolaRequest(a: Account, options: RequestOptions): Promise> { const method = options.method ?? 'GET' const url = buildUrl(options.path, options.query) const headers = new Headers() headers.set('Accept', 'application/json') headers.set('User-Agent', 'kody-granola') headers.set('Authorization', `Bearer {{secret:${a.secretName}|scope=user}}`) let body: string | undefined if (options.json !== undefined) { headers.set('Content-Type', 'application/json') body = JSON.stringify(options.json) } const maxRetries = options.retries ?? 3 for (let attempt = 0; ; attempt++) { let response: Response try { response = await transport.fetch(url, { method, headers, body, redirect: 'manual' }) } catch (error) { throw wrapFetchError(a, url, error) } // 429 means "not processed", so retrying is safe even for writes. const retriable = response.status === 429 || (response.status === 503 && method === 'GET') if (retriable && attempt < maxRetries) { await sleep(retryDelayMs(response.headers.get('retry-after'), attempt)) continue } const text = await response.text() let data: unknown = null if (text) { try { data = JSON.parse(text) } catch { data = text } } const ok = response.status >= 200 && response.status < 300 const { code, message } = ok ? { code: null, message: '' } : errorDetail(data) if (!ok && options.throwOnError !== false) { const hint = statusHint(response.status, a, code, new URL(url).pathname) throw new GranolaRequestError( `Granola ${response.status}${code ? ` ${code}` : ''} on ${method} ${url.replace(GRANOLA_ORIGIN, '')} (account "${a.account}")` + `${message ? `: ${message}` : ''}.${hint ? ` ${hint}` : ''}`, response.status, url, code, data, ) } return { ok, status: response.status, url, data: data as T | null, errorCode: code } } }