← 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 · TypeScriptimport { 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 }
}