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-`). 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; events?: Array; folderIds?: Array 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: "" or revealSigningSecret: true. Nothing was created.') } const res = await granolaRequest>(p.acct, { method: 'POST', path: '/v1/webhook-endpoints', json: body, retries: 1 }) const { signing_secret: signingSecret, ...endpoint } = res.data ?? {} let secretResult: Record = { 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 } }