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/webhooks/create.ts

81 lines · 5.1 KB · TypeScript
Raw
import { kody } from 'kody:runtime'
import { mutationSpec, parseWithAccount } from '../lib/api.ts'
import { granolaRequest } from '../lib/client.ts'
import { checkWebhookUrl, echo, mutationMode, preview } from '../lib/guard.ts'
import { FOLDER_ID_PATTERN, SECRET_NAME_PATTERN } from '../lib/validate.ts'
import { checkEnumList, WEBHOOK_EVENTS, WEBHOOK_SCOPES } from '../lib/webhooks.ts'

/**
 * Register an HTTPS URL to receive Granola note events (`POST /v1/webhook-endpoints`). Events:
 * `note.generated`, `note.edited`, `note.access_granted` (omit `events` for all). Payloads carry
 * only ids — fetch content with ./notes/get. Deliveries are signed (Standard Webhooks).
 * WRITE: previews unless `confirm: true`. Granola returns the endpoint's signing secret ONCE, so
 * a real create needs either `saveSigningSecretAs` (stores it as a Kody user secret; needs the
 * package to be allowed to write that secret) or `revealSigningSecret: true` (returns it — hand it
 * to the user, never log it). If saving fails, the secret is returned with `saveError` so it is not lost.
 *
 * @param input.account - Account name (secret `granola-<account>`). Required. @param input.secretName - Secret override.
 * @param input.url - Public HTTPS URL (private/local hosts refused). Required.
 * @param input.scopes - Which notes: `personal`, `public`, `workspace` (match the folder's visibility). Required.
 * @param input.events - Subset of the three events (default all).
 * @param input.folderIds - Only notes in these `fol_…` folders (and subfolders).
 * @param input.saveSigningSecretAs - Kody user secret name for the signing secret.
 * @param input.revealSigningSecret - Return the signing secret in the result.
 * @param input.dryRun - Preview only. @param input.confirm - `true` to create.
 * @returns Preview `{ dryRun: true, request }`, or `{ executed: true, webhookEndpoint, signingSecret: { savedAs } | { value } }`
 *
 * @example
 * import createWebhook from 'kody:@charlesmatte/granola/webhooks/create'
 *
 * const preview = await createWebhook({ account: 'work', url: 'https://example.com/granola', scopes: ['personal'] })
 */
export default async function createGranolaWebhook(input: unknown) {
	const p = parseWithAccount<{
		url: string; scopes: Array<string>; events?: Array<string>; folderIds?: Array<string>
		saveSigningSecretAs?: string; revealSigningSecret?: boolean; dryRun?: boolean; confirm?: boolean
	}>(
		input,
		{
			url: { type: 'string', required: true, maxLength: 2000 },
			scopes: { type: 'stringArray', required: true, maxItems: 3 },
			events: { type: 'stringArray', maxItems: 3 },
			folderIds: { type: 'stringArray', maxItems: 100 },
			saveSigningSecretAs: { type: 'string', pattern: SECRET_NAME_PATTERN },
			revealSigningSecret: { type: 'boolean' },
			...mutationSpec,
		},
		'webhooks/create',
	)
	const url = checkWebhookUrl(p.url)
	if (p.scopes.length === 0) throw new Error('webhooks/create: "scopes" needs at least one of personal, public, workspace.')
	checkEnumList('scopes', p.scopes, WEBHOOK_SCOPES)
	checkEnumList('events', p.events, WEBHOOK_EVENTS)
	const badFolder = (p.folderIds ?? []).find((f) => !FOLDER_ID_PATTERN.test(f))
	if (badFolder) throw new Error(`webhooks/create: folder id "${badFolder}" is not a fol_… id (see ./folders/list).`)
	const body = { url, scopes: p.scopes, ...(p.events ? { events: p.events } : {}), ...(p.folderIds ? { folder_ids: p.folderIds } : {}) }
	const mode = mutationMode(p)
	const target = `webhook → ${url}`
	const request = { method: 'POST', path: '/v1/webhook-endpoints', body }
	if (!mode.execute) {
		return preview(p.acct, 'webhooks/create', target, mode, request, {
			signingSecretHandling: p.saveSigningSecretAs ? `save as Kody secret "${p.saveSigningSecretAs}"` : p.revealSigningSecret ? 'returned in result' : 'MISSING: pass saveSigningSecretAs or revealSigningSecret: true before confirming',
		})
	}
	if (!p.saveSigningSecretAs && !p.revealSigningSecret) {
		throw new Error('webhooks/create: Granola shows the signing secret only once. Pass saveSigningSecretAs: "<kody secret name>" or revealSigningSecret: true. Nothing was created.')
	}
	const res = await granolaRequest<Record<string, any>>(p.acct, { method: 'POST', path: '/v1/webhook-endpoints', json: body, retries: 1 })
	const { signing_secret: signingSecret, ...endpoint } = res.data ?? {}
	let secretResult: Record<string, unknown> = { returned: false }
	if (typeof signingSecret === 'string') {
		if (p.saveSigningSecretAs) {
			try {
				await kody.secretSet({ name: p.saveSigningSecretAs, scope: 'user', value: signingSecret, description: `Granola webhook signing secret for ${endpoint.id ?? url}` })
				secretResult = { savedAs: p.saveSigningSecretAs }
			} catch (error) {
				secretResult = { value: signingSecret, saveError: String(error instanceof Error ? error.message : error).slice(0, 300), warning: 'Saving failed; this is the only copy. Store it now.' }
			}
		} else secretResult = { value: signingSecret, warning: 'Shown only once. Store it securely; do not log it.' }
	}
	return { dryRun: false, executed: true, echo: echo(p.acct, 'webhooks/create', target, mode), webhookEndpoint: endpoint, signingSecret: secretResult }
}