Skip to content
← Public packages

@charlesmatte/granola

Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.

src/verify.ts

86 lines · 4.3 KB · TypeScript
Raw
import { parseWithAccount } from './lib/api.ts'
import { granolaRequest, KEY_TYPES_HINT } from './lib/client.ts'

type Probe = { status: number; ok: boolean; errorCode?: string | null; detail?: string }

/**
 * Check that an account's API key works and what it can reach. Makes up to five read-only GETs
 * (notes, folders, webhook endpoints, audit log, legal holds; one item each) and reports each
 * status — the Granola API has no "who am I" endpoint. Never returns note content beyond counts
 * and one sample id.
 *
 * @param input.account - Account name (secret `granola-<account>`). Required. @param input.secretName - Secret override.
 * @param input.probes - Subset of `notes`, `folders`, `webhooks`, `audit`, `legalHolds` (default all).
 * @returns `{ account, secretName, keyWorks, keyKind, probes: { notes: { status, ok, hasMore, sampleNoteId }, … }, summary }`
 *
 * @example
 * import verify from 'kody:@charlesmatte/granola/verify'
 *
 * const r = await verify({ account: 'work' })
 */
export default async function verifyGranolaAccount(input: unknown) {
	const all = ['notes', 'folders', 'webhooks', 'audit', 'legalHolds'] as const
	const p = parseWithAccount<{ probes?: Array<string> }>(input, { probes: { type: 'stringArray', maxItems: 5 } }, 'verify')
	const wanted = p.probes ?? [...all]
	const bad = wanted.filter((w) => !(all as ReadonlyArray<string>).includes(w))
	if (bad.length) throw new Error(`verify: unknown probe(s) ${bad.join(', ')}; allowed: ${all.join(', ')}.`)
	const paths: Record<string, string> = { notes: '/v1/notes', folders: '/v1/folders', webhooks: '/v1/webhook-endpoints', audit: '/v1/audit', legalHolds: '/v1/legal-holds' }
	const probes: Record<string, Probe & Record<string, unknown>> = {}
	for (const name of wanted) {
		const res = await granolaRequest<Record<string, any>>(p.acct, {
			path: paths[name] as string,
			query: name === 'webhooks' ? undefined : { page_size: 1 },
			throwOnError: false,
		})
		const probe: Probe & Record<string, unknown> = { status: res.status, ok: res.ok }
		if (!res.ok) {
			probe.errorCode = res.errorCode
			const d = res.data as Record<string, any> | string | null
			const msg = typeof d === 'string' ? d : (d?.error?.message ?? d?.message)
			if (msg) probe.detail = String(msg).slice(0, 200)
		} else if (name === 'notes') {
			probe.hasMore = Boolean(res.data?.hasMore)
			probe.sampleNoteId = res.data?.notes?.[0]?.id ?? null
			probe.visibleNotes = (res.data?.notes?.length ?? 0) > 0
		} else if (name === 'folders') {
			probe.anyFolders = (res.data?.folders?.length ?? 0) > 0
		} else if (name === 'webhooks') {
			probe.count = res.data?.webhook_endpoints?.length ?? 0
		} else if (name === 'legalHolds') {
			probe.count = res.data?.legal_holds?.length ?? 0
		}
		probes[name] = probe
	}
	const okNames = Object.entries(probes).filter(([, v]) => v.ok).map(([k]) => k)
	const all401 = Object.values(probes).every((v) => v.status === 401)
	const keyKind = all401
		? 'invalid'
		: probes.legalHolds?.ok && !probes.notes?.ok
			? 'legal-hold key'
			: probes.audit?.ok && !probes.notes?.ok
				? 'audit key'
				: probes.notes?.ok
					? probes.audit?.ok
						? 'notes key with audit access'
						: 'personal or workspace API key'
					: 'unknown'
	const KEY_KIND_NOTES: Record<string, string> = {
		'personal or workspace API key': 'Reads notes, transcripts and folders (and manages its own webhooks). The audit log needs a separate Audit API key; legal holds need a Legal hold API key.',
		'audit key': 'Reads the audit log only (./audit/list). Notes, folders and webhooks need a personal or workspace API key.',
		'legal-hold key': 'Manages legal holds only (./legal-holds/*). Notes need a personal or workspace API key.',
		'notes key with audit access': 'Reads notes and the audit log.',
		invalid: 'Create a new key and update the Kody secret.',
		unknown: KEY_TYPES_HINT,
	}
	return {
		account: p.acct.account,
		secretName: p.acct.secretName,
		keyWorks: okNames.length > 0,
		keyKind,
		keyKindNote: KEY_KIND_NOTES[keyKind],
		probes,
		summary: all401
			? 'Every probe returned 401: the key is invalid, revoked or expired. Create a new one (Settings → Connectors → API keys) and update the Kody secret.'
			: `Reachable: ${okNames.join(', ') || 'nothing'}. Others returned the statuses shown; 403/404 there is expected when the key type cannot use that endpoint (see keyKindNote).`,
	}
}