diff --git a/bun.lock b/bun.lock index c34c5bf..f741bcf 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@corbits/granola", "dependencies": { + "@intx/log": "0.2.2", "arktype": "^2.1.29", }, "devDependencies": { @@ -19,6 +20,12 @@ "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], + "@intx/log": ["@intx/log@0.2.2", "", { "dependencies": { "@logtape/hono": "^2.0.2", "@logtape/logtape": "^2.0.2" }, "peerDependencies": { "hono": "^4.0.0" }, "optionalPeers": ["hono"] }, "sha512-Rlkd4pvyXwlqkm6hui97VwuTytqgOmenvbdaHAFdYr1e6DHHhGu7ZodUEsLiF4993qJmWNHGlNbHEgQY1LNPrQ=="], + + "@logtape/hono": ["@logtape/hono@2.3.0", "", { "peerDependencies": { "@logtape/logtape": "^2.3.0", "hono": "^4.0.0" } }, "sha512-+aYoPfhEeOVW1hiQwx7Tv2fbNSbOPQhx5EBZ/8JXAXt9j1w0h63X0M/MjyhjBh6Mp22gnOdNLTF6l5Tg2RIcJw=="], + + "@logtape/logtape": ["@logtape/logtape@2.3.0", "", {}, "sha512-s/pxCgf9Gg75ypTV/bRUq355Dy/JP/zUJWfgJQPO5pTkXC23X2AXBcmBBgFiWH+bSi4yiX/G06qGV8CdAWD3SA=="], + "@types/bun": ["@types/bun@1.1.14", "", { "dependencies": { "bun-types": "1.1.37" } }, "sha512-opVYiFGtO2af0dnWBdZWlioLBoxSdDO5qokaazLhq8XQtGZbY4pY3/JxY8Zdf/hEwGubbp7ErZXoN1+h2yesxA=="], "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], @@ -31,6 +38,8 @@ "bun-types": ["bun-types@1.1.37", "", { "dependencies": { "@types/node": "~20.12.8", "@types/ws": "~8.5.10" } }, "sha512-C65lv6eBr3LPJWFZ2gswyrGZ82ljnH8flVE03xeXxKhi2ZGtFiO4isRKTKnitbSqtRAcaqYSR6djt1whI66AbA=="], + "hono": ["hono@4.12.33", "", {}, "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ=="], + "typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="], "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], diff --git a/package.json b/package.json index 7e5486e..adf39b0 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "test:coverage": "bun test --coverage src" }, "dependencies": { + "@intx/log": "0.2.2", "arktype": "^2.1.29" }, "devDependencies": { diff --git a/src/ingress/binding-store.test.ts b/src/ingress/binding-store.test.ts new file mode 100644 index 0000000..0a30cba --- /dev/null +++ b/src/ingress/binding-store.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; + +import { createGranolaBindingStore } from "./binding-store"; +import type { GranolaBindingsPort } from "./bindings-port"; +import type { GranolaBucket } from "../tools/types"; + +const TENANT = "tenant_1"; +const PRINCIPAL = "principal_1"; + +const SEED_BUCKETS: GranolaBucket[] = [ + { folderId: "fol_seed", type: "diligence", channel: "C_SEED" }, +]; + +type StoredBindings = { bindings: GranolaBucket[]; version: number }; + +function fakePort(initial?: StoredBindings): { + port: GranolaBindingsPort; + saveCalls: { tenantId: string; principalId: string; bindings: GranolaBucket[] }[]; + loadCalls: { tenantId: string }[]; +} { + const saveCalls: { tenantId: string; principalId: string; bindings: GranolaBucket[] }[] = []; + const loadCalls: { tenantId: string }[] = []; + let stored = initial; + + const port: GranolaBindingsPort = { + async load(args) { + loadCalls.push(args); + return stored; + }, + async save(args) { + saveCalls.push(args); + stored = { bindings: args.bindings, version: (stored?.version ?? 0) + 1 }; + }, + }; + + return { port, saveCalls, loadCalls }; +} + +describe("createGranolaBindingStore", () => { + test("falls back to seed bindings, without writing, when no artifact is persisted yet", async () => { + const { port, saveCalls, loadCalls } = fakePort(); + const store = createGranolaBindingStore({ + port, + tenantId: TENANT, + principalId: PRINCIPAL, + seedBindings: SEED_BUCKETS, + }); + + const bindings = await store.list(); + + expect(bindings).toEqual(SEED_BUCKETS); + expect(loadCalls).toHaveLength(1); + expect(saveCalls).toHaveLength(0); + }); + + test("prefers a persisted artifact's bindings over the seed once one exists", async () => { + const persistedBucket: GranolaBucket = { + folderId: "fol_persisted", + type: "internal", + channel: "C_PERSISTED", + }; + const { port } = fakePort({ + bindings: [persistedBucket], + version: 1, + }); + const store = createGranolaBindingStore({ + port, + tenantId: TENANT, + principalId: PRINCIPAL, + seedBindings: SEED_BUCKETS, + }); + + const bindings = await store.list(); + + expect(bindings).toEqual([persistedBucket]); + }); + + test("caches list() across calls, and only hits the port once", async () => { + const { port, loadCalls } = fakePort(); + const store = createGranolaBindingStore({ + port, + tenantId: TENANT, + principalId: PRINCIPAL, + seedBindings: SEED_BUCKETS, + }); + + await store.list(); + await store.list(); + await store.list(); + + expect(loadCalls).toHaveLength(1); + }); + + test("replaceAll persists the new binding set and invalidates the cache so the next list() reflects it", async () => { + const { port, saveCalls, loadCalls } = fakePort(); + const store = createGranolaBindingStore({ + port, + tenantId: TENANT, + principalId: PRINCIPAL, + seedBindings: SEED_BUCKETS, + }); + + await store.list(); + expect(loadCalls).toHaveLength(1); + + const newBindings: GranolaBucket[] = [ + { folderId: "fol_new", type: "diligence", channel: "C_NEW" }, + ]; + await store.replaceAll(newBindings); + + expect(saveCalls).toHaveLength(1); + expect(saveCalls[0]?.bindings).toEqual(newBindings); + + const listed = await store.list(); + expect(listed).toEqual(newBindings); + // Cache was invalidated by replaceAll, so this list() had to re-query. + expect(loadCalls).toHaveLength(2); + }); + + test("replaceAll invokes the onChange hook with the new bindings", async () => { + const { port } = fakePort(); + const onChangeCalls: GranolaBucket[][] = []; + const store = createGranolaBindingStore({ + port, + tenantId: TENANT, + principalId: PRINCIPAL, + seedBindings: SEED_BUCKETS, + onChange: (bindings) => { + onChangeCalls.push(bindings); + }, + }); + + const newBindings: GranolaBucket[] = [ + { folderId: "fol_new", type: "internal", channel: "C_NEW" }, + ]; + await store.replaceAll(newBindings); + + expect(onChangeCalls).toEqual([newBindings]); + }); +}); diff --git a/src/ingress/binding-store.ts b/src/ingress/binding-store.ts new file mode 100644 index 0000000..a2758ff --- /dev/null +++ b/src/ingress/binding-store.ts @@ -0,0 +1,121 @@ +/** + * Durable, mutable Granola bucket-binding store: folder id -> workflow type + * -> chat channel bindings. Persistence goes through `GranolaBindingsPort` + * (`./bindings-port.ts`) — the package declares the seam, the host + * implements it against its own storage (a config artifact, a database row, + * a file — this module doesn't know or care), which is what keeps this file + * free of any dependency on a specific host or product. + * + * A host's own seed configuration is demoted to a fallback: `list()` returns + * the persisted bindings when any exist, and only falls back to + * `seedBindings` when nothing has ever been saved for the tenant. Reading + * never writes — a host that has never touched bindings stays on the seed + * fallback indefinitely; the persisted set is created only by an explicit + * `replaceAll` call. This is the "seed-on-first-write, no silent + * auto-migration" behavior: a read-triggered persist would make the seed + * fallback disappear the moment anything called `list()`, with no operator + * action behind it. + * + * An in-memory cache avoids round-tripping to the port on every ingested + * Granola event; `replaceAll` invalidates it so the next `list()` reflects + * the write it just made. + */ +import { getLogger } from "@intx/log"; +import { type } from "arktype"; + +import type { GranolaBindingsPort } from "./bindings-port.js"; +import { GranolaBucketsArray, type GranolaBucket } from "../tools/types.js"; + +type Logger = ReturnType; + +const log = getLogger(["corbits", "granola", "binding-store"]); + +export type GranolaBindingStore = { + /** Current bindings for the tenant — from the persisted store, or the seed-config fallback. */ + list(): Promise; + /** Persists a whole new binding set as the next version of the tenant's bindings. */ + replaceAll(bindings: GranolaBucket[]): Promise; +}; + +export type CreateGranolaBindingStoreOptions = { + port: GranolaBindingsPort; + tenantId: string; + principalId: string; + /** Seed bindings, used only while nothing has been persisted for this tenant. */ + seedBindings: GranolaBucket[]; + log?: Logger; + /** Invoked with the new binding set after every successful `replaceAll` — e.g. to reconcile a webhook subscription's folder scope. */ + onChange?: (bindings: GranolaBucket[]) => void | Promise; +}; + +export function createGranolaBindingStore( + options: CreateGranolaBindingStoreOptions, +): GranolaBindingStore { + const { port, tenantId, principalId, seedBindings } = options; + const logger = options.log ?? log; + + let cache: GranolaBucket[] | undefined; + + async function load(): Promise { + const found = await port.load({ tenantId }); + if (found !== undefined) { + const validated = GranolaBucketsArray(found.bindings); + if (validated instanceof type.errors) { + logger.error( + "Granola bindings for tenant {tenantId} exist but failed validation — falling back to {count} seed binding(s): {summary}", + { tenantId, count: seedBindings.length, summary: validated.summary }, + ); + return seedBindings; + } + logger.info( + "Granola bindings for tenant {tenantId} resolved from the persisted store — {count} binding(s), version {version}", + { tenantId, count: validated.length, version: found.version }, + ); + return validated; + } + + logger.info( + "No persisted Granola bindings for tenant {tenantId} — falling back to {count} seed binding(s) until replaceAll persists a binding set", + { tenantId, count: seedBindings.length }, + ); + return seedBindings; + } + + // Single-process cache: invalidation happens only via replaceAll on THIS + // store instance. Running more than one host replica means a binding edit + // on one process is invisible to the others until restart — revisit with + // a TTL or notification channel before a host scales horizontally. + return { + async list() { + if (cache === undefined) { + cache = await load(); + } + // Defensive copy: callers must not be able to mutate the cached set. + return [...cache]; + }, + async replaceAll(bindings) { + await port.save({ + tenantId, + principalId, + bindings, + }); + logger.info( + "Granola bindings for tenant {tenantId} replaced — {count} binding(s) written", + { + tenantId, + count: bindings.length, + }, + ); + cache = undefined; + try { + await options.onChange?.(bindings); + } catch (cause) { + // The binding write already succeeded — a convergence hook failure + // must never make it look failed to the caller. + logger.error("Granola binding onChange hook failed: {error}", { + error: cause instanceof Error ? cause.message : String(cause), + }); + } + }, + }; +} diff --git a/src/ingress/index.ts b/src/ingress/index.ts index d645095..6c704cb 100644 --- a/src/ingress/index.ts +++ b/src/ingress/index.ts @@ -19,3 +19,15 @@ export type { GranolaEventType, } from "./webhook.js"; export type { GranolaBindingsPort, GranolaBindingsLoadResult } from "./bindings-port.js"; + +export { createGranolaBindingStore } from "./binding-store.js"; +export type { + GranolaBindingStore, + CreateGranolaBindingStoreOptions, +} from "./binding-store.js"; + +export { + ensureGranolaWebhook, + reconcileGranolaWebhookFolders, +} from "./webhook-registration.js"; +export type { EnsureGranolaWebhookOptions } from "./webhook-registration.js"; diff --git a/src/ingress/webhook-registration.test.ts b/src/ingress/webhook-registration.test.ts new file mode 100644 index 0000000..1b218a6 --- /dev/null +++ b/src/ingress/webhook-registration.test.ts @@ -0,0 +1,499 @@ +import { describe, expect, test } from "bun:test"; + +import { + ensureGranolaWebhook, + reconcileGranolaWebhookFolders, +} from "./webhook-registration"; +import type { GranolaBindingStore } from "./binding-store"; +import type { GranolaBucket } from "../tools/types"; + +const BASE_URL = "https://api.granola.ai/v1"; +const PUBLIC_URL = "https://hub.example.com"; +const TARGET_URL = "https://hub.example.com/api/granola/webhook"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function fakeBindingStore(folderIds: string[]): GranolaBindingStore { + const bindings: GranolaBucket[] = folderIds.map((folderId) => ({ + folderId, + type: "diligence", + channel: "C_TEST", + })); + return { + async list() { + return bindings; + }, + async replaceAll() { + throw new Error("not exercised in this suite"); + }, + }; +} + +describe("ensureGranolaWebhook", () => { + test("returns undefined immediately when no public URL is configured", async () => { + const calls: string[] = []; + const fetchImpl = (async (input: string | URL | Request) => { + calls.push(String(input)); + return jsonResponse({ webhook_endpoints: [] }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: undefined, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: undefined, + fetchImpl, + }); + + expect(secret).toBeUndefined(); + expect(calls).toHaveLength(0); + }); + + test("falls back to envSecret when no public URL is configured but a static secret exists", async () => { + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: undefined, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: "static-secret", + }); + + expect(secret).toBe("static-secret"); + }); + + const ALL_EVENTS = [ + "note.generated", + "note.regenerated", + "note.edited", + "note.access_granted", + ]; + + test("matching endpoint with env secret and same folder_ids/events reuses the secret without updating", async () => { + let patchCalled = false; + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const href = String(input); + if (init?.method === "PATCH") { + patchCalled = true; + return jsonResponse({}); + } + expect(href).toContain("/webhook-endpoints"); + return jsonResponse({ + webhook_endpoints: [ + { + id: "whe_1", + url: TARGET_URL, + folder_ids: ["fol_a"], + events: ALL_EVENTS, + }, + ], + }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: "existing-secret", + fetchImpl, + }); + + expect(secret).toBe("existing-secret"); + expect(patchCalled).toBe(false); + }); + + test("matching endpoint with env secret and drifted folder_ids updates then reuses the secret", async () => { + let patchBody: unknown; + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "PATCH") { + patchBody = JSON.parse(String(init.body)); + return jsonResponse({}); + } + return jsonResponse({ + webhook_endpoints: [ + { + id: "whe_1", + url: TARGET_URL, + folder_ids: ["fol_old"], + events: ALL_EVENTS, + }, + ], + }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_new"]), + envSecret: "existing-secret", + fetchImpl, + }); + + expect(secret).toBe("existing-secret"); + expect(patchBody).toEqual({ folder_ids: ["fol_new"] }); + }); + + test("matching endpoint with env secret and drifted events updates the events list", async () => { + let patchBody: unknown; + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "PATCH") { + patchBody = JSON.parse(String(init.body)); + return jsonResponse({}); + } + return jsonResponse({ + webhook_endpoints: [ + { + id: "whe_1", + url: TARGET_URL, + folder_ids: ["fol_a"], + events: ["note.generated"], + }, + ], + }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: "existing-secret", + fetchImpl, + }); + + expect(secret).toBe("existing-secret"); + expect(patchBody).toEqual({ events: ALL_EVENTS }); + }); + + test("matching endpoint but no env secret does NOT delete/recreate — logs an error and returns undefined", async () => { + let deleteCalled = false; + let createCalled = false; + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "DELETE") { + deleteCalled = true; + return jsonResponse({}); + } + if (init?.method === "POST") { + createCalled = true; + return jsonResponse({ + id: "whe_2", + url: TARGET_URL, + folder_ids: ["fol_a"], + signing_secret: "fresh-secret", + }); + } + return jsonResponse({ + webhook_endpoints: [ + { id: "whe_1", url: TARGET_URL, folder_ids: ["fol_a"] }, + ], + }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: undefined, + fetchImpl, + }); + + expect(secret).toBeUndefined(); + expect(deleteCalled).toBe(false); + expect(createCalled).toBe(false); + }); + + test("a url_redacted endpoint with no exact match blocks creation and falls back to envSecret", async () => { + let createCalled = false; + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "POST") { + createCalled = true; + return jsonResponse({ + id: "whe_new", + url: TARGET_URL, + folder_ids: ["fol_a"], + signing_secret: "brand-new-secret", + }); + } + return jsonResponse({ + webhook_endpoints: [ + { + id: "whe_other", + url: "redacted", + url_redacted: true, + folder_ids: ["fol_a"], + }, + ], + }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: "fallback-secret", + fetchImpl, + }); + + expect(secret).toBe("fallback-secret"); + expect(createCalled).toBe(false); + }); + + test("a url_redacted endpoint with no exact match and no envSecret returns undefined without creating", async () => { + const fetchImpl = (async () => + jsonResponse({ + webhook_endpoints: [ + { + id: "whe_other", + url: "redacted", + url_redacted: true, + folder_ids: [], + }, + ], + })) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: undefined, + fetchImpl, + }); + + expect(secret).toBeUndefined(); + }); + + test("passes an AbortSignal with a timeout on every request", async () => { + let sawSignal = false; + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.signal instanceof AbortSignal) sawSignal = true; + return jsonResponse({ webhook_endpoints: [] }); + }) as unknown as typeof fetch; + + await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: "some-secret", + fetchImpl, + }); + + expect(sawSignal).toBe(true); + }); + + test("tolerates a 204/empty-body response instead of throwing", async () => { + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "PATCH") { + return new Response(null, { status: 204 }); + } + return jsonResponse({ + webhook_endpoints: [ + { + id: "whe_1", + url: TARGET_URL, + folder_ids: ["fol_old"], + events: ALL_EVENTS, + }, + ], + }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_new"]), + envSecret: "existing-secret", + fetchImpl, + }); + + expect(secret).toBe("existing-secret"); + }); + + test("no matching endpoint creates one and returns the fresh secret", async () => { + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "POST") { + return jsonResponse({ + id: "whe_new", + url: TARGET_URL, + folder_ids: ["fol_a"], + signing_secret: "brand-new-secret", + }); + } + return jsonResponse({ webhook_endpoints: [] }); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: "existing-secret-but-no-match", + fetchImpl, + }); + + expect(secret).toBe("brand-new-secret"); + }); + + test("falls back to envSecret when the Granola API call fails", async () => { + const fetchImpl = (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: "fallback-secret", + fetchImpl, + }); + + expect(secret).toBe("fallback-secret"); + }); + + test("returns undefined when the Granola API call fails and there is no envSecret", async () => { + const fetchImpl = (async () => + jsonResponse({}, 500)) as unknown as typeof fetch; + + const secret = await ensureGranolaWebhook({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + bindingStore: fakeBindingStore(["fol_a"]), + envSecret: undefined, + fetchImpl, + }); + + expect(secret).toBeUndefined(); + }); +}); + +describe("reconcileGranolaWebhookFolders", () => { + test("updates folder_ids on the matching endpoint when they drift", async () => { + let patchBody: unknown; + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "PATCH") { + patchBody = JSON.parse(String(init.body)); + return jsonResponse({}); + } + return jsonResponse({ + webhook_endpoints: [ + { id: "whe_1", url: TARGET_URL, folder_ids: ["fol_old"] }, + ], + }); + }) as unknown as typeof fetch; + + await reconcileGranolaWebhookFolders({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + folderIds: ["fol_new"], + fetchImpl, + }); + + expect(patchBody).toEqual({ folder_ids: ["fol_new"] }); + }); + + test("does nothing when folder_ids already match", async () => { + let patchCalled = false; + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "PATCH") { + patchCalled = true; + return jsonResponse({}); + } + return jsonResponse({ + webhook_endpoints: [ + { id: "whe_1", url: TARGET_URL, folder_ids: ["fol_a"] }, + ], + }); + }) as unknown as typeof fetch; + + await reconcileGranolaWebhookFolders({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + folderIds: ["fol_a"], + fetchImpl, + }); + + expect(patchCalled).toBe(false); + }); + + test("no matching endpoint does not throw and does not create one", async () => { + let createCalled = false; + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + if (init?.method === "POST") createCalled = true; + return jsonResponse({ webhook_endpoints: [] }); + }) as unknown as typeof fetch; + + await reconcileGranolaWebhookFolders({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + folderIds: ["fol_a"], + fetchImpl, + }); + + expect(createCalled).toBe(false); + }); + + test("an API failure is caught and logged, never thrown", async () => { + const fetchImpl = (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + + await expect( + reconcileGranolaWebhookFolders({ + apiKey: "key", + baseUrl: BASE_URL, + publicUrl: PUBLIC_URL, + folderIds: ["fol_a"], + fetchImpl, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/ingress/webhook-registration.ts b/src/ingress/webhook-registration.ts new file mode 100644 index 0000000..b670d33 --- /dev/null +++ b/src/ingress/webhook-registration.ts @@ -0,0 +1,364 @@ +/** + * Reconciles a host's Granola webhook subscription against the Granola API + * at boot, so a fresh deploy needs no manual "go create a webhook in the + * Granola dashboard" step. + * + * Docs: https://docs.granola.ai/api-reference — create/list/update + * webhook-endpoint. Bearer API-key auth, same as `../tools/client.ts`. + * `signing_secret` is returned only from the create call and cannot be + * retrieved later, which drives the cases below: + * + * - A matching endpoint exists and `GRANOLA_WEBHOOK_SECRET` is set: the + * secret is already known, so just keep `folder_ids`/`events` in sync and + * reuse it. + * - A matching endpoint exists but no env secret is set: the secret behind + * that endpoint is unrecoverable. Rather than delete-and-recreate (which + * would loop on every restart until an operator persists the secret), this + * logs a prominent error naming the fix — set GRANOLA_WEBHOOK_SECRET to the + * original value, or delete the endpoint manually — and returns + * `undefined`, leaving the webhook unmounted. + * - No matching endpoint: create one. This is the only path that calls + * create-webhook-endpoint, so the one-time signing_secret log line only + * ever fires here. + * - The list response can report `url_redacted: true` instead of the real + * URL for an endpoint whose owner differs, which defeats exact-URL + * matching. When no exact match exists but some listed endpoint is + * redacted, reconciliation cannot tell whether that redacted endpoint is + * actually ours, so it does not create (which could produce a duplicate) + * and falls back to the env secret instead. + * + * Never sets a User-Agent header. Every Granola API call carries a 10s + * timeout so a hung public-api.granola.ai cannot wedge hub boot (`ensureGranolaWebhook` + * is awaited there). No Granola API call is allowed to crash hub boot — any + * failure is logged and this falls back to the env secret (or `undefined`, + * leaving the webhook unmounted) exactly as if reconciliation had never run. + */ +import { type } from "arktype"; +import { getLogger } from "@intx/log"; + +import type { GranolaBindingStore } from "./binding-store"; + +const log = getLogger(["corbits", "granola", "webhook-registration"]); + +const WEBHOOK_PATH = "/api/granola/webhook"; +const ALL_NOTE_EVENTS = [ + "note.generated", + "note.regenerated", + "note.edited", + "note.access_granted", +] as const; +const GRANOLA_REQUEST_TIMEOUT_MS = 10_000; + +const WebhookEndpoint = type({ + id: "string", + url: "string", + folder_ids: "string[]", + "events?": "string[]", + "url_redacted?": "boolean", +}); +type WebhookEndpoint = typeof WebhookEndpoint.infer; + +const ListWebhookEndpointsResponse = type({ + webhook_endpoints: WebhookEndpoint.array(), +}); + +const CreateWebhookEndpointResponse = type({ + id: "string", + url: "string", + folder_ids: "string[]", + signing_secret: "string", +}); + +export type EnsureGranolaWebhookOptions = { + apiKey: string; + /** Granola REST API base URL, e.g. `https://public-api.granola.ai/v1`. */ + baseUrl: string; + /** Public HTTPS origin the hub is reachable at; `undefined` disables reconciliation. */ + publicUrl: string | undefined; + /** + * Source of the folder ids the webhook should be restricted to. Read from + * the binding store rather than a static list so a binding change made + * after boot (via `replaceAll`) is reflected the next time this runs. + */ + bindingStore: GranolaBindingStore; + /** Pre-provisioned secret from `GRANOLA_WEBHOOK_SECRET`, if set. */ + envSecret: string | undefined; + /** Injected for tests; defaults to the global `fetch`. */ + fetchImpl?: typeof fetch; +}; + +function sameIdSet(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((id, index) => id === sortedB[index]); +} + +/** + * Sends a Granola API request with a 10s timeout (so a hung public-api.granola.ai + * cannot wedge hub boot) and tolerates an empty/204 response body — + * `Response.json()` throws on empty input, which would otherwise be silently + * swallowed by the outer catch and leave a caller mid-reconciliation with no + * indication anything went wrong. + */ +async function granolaRequest( + fetchImpl: typeof fetch, + apiKey: string, + url: string, + init: RequestInit, +): Promise { + const response = await fetchImpl(url, { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: AbortSignal.timeout(GRANOLA_REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Granola webhook-endpoint request failed: ${String(response.status)} ${response.statusText} ${body}`, + ); + } + if (response.status === 204) return undefined; + const text = await response.text(); + if (text.length === 0) return undefined; + return JSON.parse(text); +} + +async function listWebhookEndpoints( + fetchImpl: typeof fetch, + apiKey: string, + baseUrl: string, +): Promise { + const raw = await granolaRequest( + fetchImpl, + apiKey, + `${baseUrl}/webhook-endpoints`, + { + method: "GET", + }, + ); + const validated = ListWebhookEndpointsResponse(raw); + if (validated instanceof type.errors) { + throw new Error( + `Granola list-webhook-endpoints response is malformed: ${validated.summary}`, + ); + } + return validated.webhook_endpoints; +} + +async function createWebhookEndpoint( + fetchImpl: typeof fetch, + apiKey: string, + baseUrl: string, + args: { url: string; folderIds: string[] }, +): Promise<{ id: string; secret: string }> { + const raw = await granolaRequest( + fetchImpl, + apiKey, + `${baseUrl}/webhook-endpoints`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: args.url, + scopes: ["public"], + events: ALL_NOTE_EVENTS, + folder_ids: args.folderIds, + }), + }, + ); + const validated = CreateWebhookEndpointResponse(raw); + if (validated instanceof type.errors) { + throw new Error( + `Granola create-webhook-endpoint response is malformed: ${validated.summary}`, + ); + } + return { id: validated.id, secret: validated.signing_secret }; +} + +async function updateWebhookEndpoint( + fetchImpl: typeof fetch, + apiKey: string, + baseUrl: string, + endpointId: string, + body: { folder_ids?: string[]; events?: readonly string[] }, +): Promise { + await granolaRequest( + fetchImpl, + apiKey, + `${baseUrl}/webhook-endpoints/${encodeURIComponent(endpointId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +function logFreshSecretWarning(targetUrl: string): void { + log.warn( + "Granola webhook endpoint at {url} was created and its signing_secret is unrecoverable after this boot — persist GRANOLA_WEBHOOK_SECRET now with the value logged below", + { url: targetUrl }, + ); +} + +function logUnrecoverableSecretError( + targetUrl: string, + endpointId: string, +): void { + log.error( + "Granola webhook endpoint {endpointId} at {url} already exists but GRANOLA_WEBHOOK_SECRET is unset — its signing_secret cannot be retrieved from the Granola API. Set GRANOLA_WEBHOOK_SECRET to the original value, or delete this endpoint manually via the Granola API/dashboard so a fresh one can be created. The webhook will stay unmounted until then.", + { endpointId, url: targetUrl }, + ); +} + +function logRedactedStateUnknown(targetUrl: string): void { + log.error( + "Granola webhook-endpoint list contains at least one url_redacted entry and none exactly matches {url} — reconciliation cannot determine whether a redacted endpoint is already ours, so it will not create a new one (which could duplicate it). Falling back to GRANOLA_WEBHOOK_SECRET if set.", + { url: targetUrl }, + ); +} + +/** + * Reconciles the hub's Granola webhook-endpoint registration against the + * Granola API and returns the secret to mount the local webhook route with. + * + * Returns `undefined` when there is nothing to mount with: no `publicUrl` + * and no `envSecret`, or an API failure with no `envSecret` to fall back to. + */ +export async function ensureGranolaWebhook( + options: EnsureGranolaWebhookOptions, +): Promise { + const { apiKey, baseUrl, publicUrl, bindingStore, envSecret } = options; + const fetchImpl = options.fetchImpl ?? fetch; + + if (publicUrl === undefined) { + log.info( + "GRANOLA_PUBLIC_URL/SCOUT_PORTAL_ORIGIN unset — cannot reconcile the Granola webhook endpoint; falling back to GRANOLA_WEBHOOK_SECRET if set", + ); + return envSecret; + } + + const bindings = await bindingStore.list(); + const folderIds = bindings.map((bucket) => bucket.folderId); + const targetUrl = `${publicUrl}${WEBHOOK_PATH}`; + + try { + const endpoints = await listWebhookEndpoints(fetchImpl, apiKey, baseUrl); + const existing = endpoints.find((endpoint) => endpoint.url === targetUrl); + + if (existing !== undefined && envSecret !== undefined) { + const foldersDrifted = !sameIdSet(existing.folder_ids, folderIds); + const eventsDrifted = !sameIdSet(existing.events ?? [], ALL_NOTE_EVENTS); + if (foldersDrifted || eventsDrifted) { + log.info( + "Granola webhook endpoint drifted from expected config ({drift}) — updating {url}", + { + drift: [foldersDrifted && "folder_ids", eventsDrifted && "events"] + .filter(Boolean) + .join(", "), + url: targetUrl, + }, + ); + await updateWebhookEndpoint(fetchImpl, apiKey, baseUrl, existing.id, { + ...(foldersDrifted ? { folder_ids: folderIds } : {}), + ...(eventsDrifted ? { events: ALL_NOTE_EVENTS } : {}), + }); + } + return envSecret; + } + + if (existing !== undefined) { + logUnrecoverableSecretError(targetUrl, existing.id); + return undefined; + } + + if (endpoints.some((endpoint) => endpoint.url_redacted === true)) { + logRedactedStateUnknown(targetUrl); + return envSecret; + } + + const created = await createWebhookEndpoint(fetchImpl, apiKey, baseUrl, { + url: targetUrl, + folderIds, + }); + logFreshSecretWarning(targetUrl); + // Deliberately at warn level, not the info-level default for this + // module: the secret is recoverable only from this one log line, this + // one time, so it is logged plainly rather than redacted. + log.warn( + "Granola webhook signing_secret (persist as GRANOLA_WEBHOOK_SECRET): {secret}", + { + secret: created.secret, + }, + ); + return created.secret; + } catch (cause) { + log.error("Granola webhook-endpoint reconciliation failed: {error}", { + error: cause instanceof Error ? cause.message : String(cause), + }); + return envSecret; + } +} + +/** + * Updates only the existing webhook endpoint's `folder_ids` to match + * `folderIds` — the narrow slice of `ensureGranolaWebhook`'s reconciliation + * this needs after a binding change: unlike boot, there is always already an + * endpoint by this point (or reconciliation was never possible), so there is + * nothing to create and no signing secret to mint or return. No-ops (logged) + * when no matching endpoint exists to update, or a redacted entry makes + * "is this ours?" undecidable — mirrors `ensureGranolaWebhook`'s same + * fail-safe reasoning for those cases. Never throws: an API failure here + * leaves the endpoint's folder_ids stale until the next boot or explicit + * re-reconcile, which is safe (over-permissive at worst until it converges), + * unlike a crash. + */ +export async function reconcileGranolaWebhookFolders(options: { + apiKey: string; + baseUrl: string; + publicUrl: string; + folderIds: string[]; + fetchImpl?: typeof fetch; +}): Promise { + const { apiKey, baseUrl, publicUrl, folderIds } = options; + const fetchImpl = options.fetchImpl ?? fetch; + const targetUrl = `${publicUrl}${WEBHOOK_PATH}`; + + try { + const endpoints = await listWebhookEndpoints(fetchImpl, apiKey, baseUrl); + const existing = endpoints.find((endpoint) => endpoint.url === targetUrl); + + if (existing === undefined) { + if (endpoints.some((endpoint) => endpoint.url_redacted === true)) { + logRedactedStateUnknown(targetUrl); + } else { + log.info( + "No Granola webhook endpoint at {url} to update folder_ids for — reconciliation skipped", + { url: targetUrl }, + ); + } + return; + } + + if (sameIdSet(existing.folder_ids, folderIds)) return; + + log.info( + "Granola webhook endpoint folder_ids drifted from bindings — updating {url}", + { + url: targetUrl, + }, + ); + await updateWebhookEndpoint(fetchImpl, apiKey, baseUrl, existing.id, { + folder_ids: folderIds, + }); + } catch (cause) { + log.error("Granola webhook folder_ids re-reconciliation failed: {error}", { + error: cause instanceof Error ? cause.message : String(cause), + }); + } +} diff --git a/src/tools/client.test.ts b/src/tools/client.test.ts index b9b2396..3b9d1ff 100644 --- a/src/tools/client.test.ts +++ b/src/tools/client.test.ts @@ -1,14 +1,181 @@ import { describe, expect, test } from "bun:test"; -import { GranolaClient } from "./client.js"; -describe("GranolaClient", () => { - test("defaults baseUrl when not given", () => { - const client = new GranolaClient({ apiKey: "test-key" }); - expect(client.baseUrl).toBe("https://api.granola.ai"); +import { createGranolaClient, GranolaApiError, transcriptText } from "./client"; + +describe("createGranolaClient", () => { + test("fetches a note with transcript by id", async () => { + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const href = String(input); + expect(href).toContain("/notes/not_abc12345678901"); + expect(href).toContain("include=transcript"); + const headers = init?.headers as Record; + expect(headers.Authorization).toBe("Bearer test-key"); + return new Response( + JSON.stringify({ + id: "not_abc12345678901", + title: "Diligence call", + // Real live shape: object speakers with source/attribution and + // segment timestamps; a legacy string speaker stays accepted. + transcript: [ + { + speaker: { source: "microphone", attribution: "Alice" }, + text: "Let's start.", + start_time: "2026-08-01T14:40:50.831Z", + end_time: "2026-08-01T14:41:04.431Z", + }, + { speaker: { source: "system" }, text: "Sounds good." }, + { speaker: "Bob", text: "Wrapping up." }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const client = createGranolaClient({ apiKey: "test-key", fetchImpl }); + const note = await client.getNote("not_abc12345678901", { + includeTranscript: true, + }); + + expect(note.id).toBe("not_abc12345678901"); + expect(transcriptText(note)).toBe( + "Alice: Let's start.\nsystem: Sounds good.\nBob: Wrapping up.", + ); + }); + + test("lists notes in a folder", async () => { + const fetchImpl = (async (input: string | URL | Request) => { + const href = String(input); + expect(href).toContain("/notes?"); + expect(href).toContain("folder_id=fol_abc12345678901"); + expect(href).toContain("page_size=30"); + return new Response( + JSON.stringify({ + notes: [{ id: "not_abc12345678901", title: "Call 1" }], + hasMore: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const client = createGranolaClient({ apiKey: "test-key", fetchImpl }); + const result = await client.listNotes({ folderId: "fol_abc12345678901" }); + + expect(result.hasMore).toBe(false); + expect(result.notes).toHaveLength(1); + expect(result.notes[0]?.id).toBe("not_abc12345678901"); + }); + + test("lists folders, paginated, mapping the API's real shape (name, not title)", async () => { + const fetchImpl = (async (input: string | URL | Request) => { + const href = String(input); + const url = new URL(href); + expect(href).toContain("/folders?"); + const pageSize = Number(url.searchParams.get("page_size")); + expect(pageSize).toBeLessThanOrEqual(30); + // Real Granola response shape: `folders[].name` (not `title`), and a + // `cursor` field present as `null` (not absent) when there's no more. + return new Response( + JSON.stringify({ + folders: [ + { + object: "folder", + id: "fol_abc12345678901", + name: "Scout: Diligence #dd-acme", + parent_folder_id: null, + space_id: "spa_abc12345678901", + }, + { object: "folder", id: "fol_def12345678901", name: "Unrelated notes" }, + ], + hasMore: false, + cursor: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const client = createGranolaClient({ apiKey: "test-key", fetchImpl }); + const result = await client.listFolders(); + + expect(result.hasMore).toBe(false); + expect(result.cursor).toBeUndefined(); + expect(result.folders).toHaveLength(2); + expect(result.folders[0]?.id).toBe("fol_abc12345678901"); + expect(result.folders[0]?.title).toBe("Scout: Diligence #dd-acme"); + }); + + test("terminates listFolders pagination on a null cursor", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ folders: [], hasMore: false, cursor: null }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )) as unknown as typeof fetch; + + const client = createGranolaClient({ apiKey: "test-key", fetchImpl }); + const result = await client.listFolders(); + + expect(result.cursor).toBeUndefined(); + expect(result.hasMore).toBe(false); + }); + + test("keeps a string cursor for continued pagination", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ folders: [], hasMore: true, cursor: "next-page" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )) as unknown as typeof fetch; + + const client = createGranolaClient({ apiKey: "test-key", fetchImpl }); + const result = await client.listFolders(); + + expect(result.cursor).toBe("next-page"); + expect(result.hasMore).toBe(true); + }); + + test("surfaces non-2xx responses as a typed error carrying status and body", async () => { + const fetchImpl = (async () => + new Response("unauthorized", { + status: 401, + statusText: "Unauthorized", + })) as unknown as typeof fetch; + + const client = createGranolaClient({ apiKey: "bad-key", fetchImpl }); + + await expect(client.getNote("not_abc12345678901")).rejects.toThrow( + GranolaApiError, + ); + try { + await client.getNote("not_abc12345678901"); + throw new Error("expected getNote to throw"); + } catch (err) { + expect(err).toBeInstanceOf(GranolaApiError); + const apiErr = err as GranolaApiError; + expect(apiErr.status).toBe(401); + expect(apiErr.body).toBe("unauthorized"); + } + }); + + test("rejects a malformed response shape", async () => { + const fetchImpl = (async () => + new Response(JSON.stringify({ notTheRightShape: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as unknown as typeof fetch; + + const client = createGranolaClient({ apiKey: "test-key", fetchImpl }); + await expect(client.getNote("not_abc12345678901")).rejects.toThrow( + /malformed/, + ); }); - test("honors an explicit baseUrl", () => { - const client = new GranolaClient({ apiKey: "test-key", baseUrl: "https://example.test" }); - expect(client.baseUrl).toBe("https://example.test"); + test("transcriptText returns empty string when no transcript is present", () => { + expect( + transcriptText({ + id: "not_abc12345678901", + title: "No transcript", + }), + ).toBe(""); }); }); diff --git a/src/tools/client.ts b/src/tools/client.ts index 1718238..0705692 100644 --- a/src/tools/client.ts +++ b/src/tools/client.ts @@ -1,19 +1,258 @@ -// Granola API client. Skeleton only — no endpoints implemented yet. -// -// This module and everything else under src/tools/ must stay free of any -// hub, mounting, extension or webhook dependency: it is meant to be -// importable and grantable as a plain Interchange tool by any agent, on its -// own, with nothing hub-shaped attached. - -export interface GranolaClientOptions { +/** + * Granola REST API client — fetch a note (with transcript) by id, and list + * notes in a folder. Bearer-auth, plain fetch, arktype-validated responses. + * + * Docs: https://docs.granola.ai (OpenAPI at + * https://docs.granola.ai/api-reference/openapi.json). Base URL defaults to + * `https://public-api.granola.ai/v1`; override via `baseUrl` if the exact + * base path differs from what these docs describe. + * + * Never sets a User-Agent header. This module and everything else under + * src/tools/ must stay free of any hub, mounting, extension or webhook + * dependency: it is meant to be importable and grantable as a plain + * Interchange tool by any agent, on its own, with nothing hub-shaped + * attached. + */ +import { type } from "arktype"; + +import { GranolaNote, type GranolaTranscriptSpeaker } from "./types.js"; +export { GranolaNote }; + +/** Flattens a segment's speaker to a readable label for transcript lines. */ +export function speakerLabel(speaker: GranolaTranscriptSpeaker): string { + if (typeof speaker === "string") return speaker; + return speaker.attribution ?? speaker.source ?? "Speaker"; +} + +const GranolaNoteSummary = type({ + id: "string", + title: "string", + "owner?": "unknown", + "created_at?": "string", + "updated_at?": "string", + "folder_membership?": "unknown", +}); + +export type GranolaNoteSummary = typeof GranolaNoteSummary.infer; + +const GranolaListNotesResponseRaw = type({ + notes: GranolaNoteSummary.array(), + hasMore: "boolean", + "cursor?": "string | null", +}); + +export type GranolaListNotesResponse = { + notes: GranolaNoteSummary[]; + hasMore: boolean; + cursor?: string; +}; + +/** Non-2xx response from the Granola API — carries the status and raw body text. */ +export class GranolaApiError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, statusText: string, body: string) { + super(`Granola API request failed: ${String(status)} ${statusText}`); + this.name = "GranolaApiError"; + this.status = status; + this.body = body; + } +} + +export type GetNoteOptions = { + includeTranscript?: boolean; +}; + +export type ListNotesOptions = { + folderId: string; + cursor?: string; + pageSize?: number; +}; + +// The live API returns `name`, not `title`, and no `parent_folder_id`/ +// `space_id` this client cares about — tolerate them rather than reject. +const GranolaFolderRaw = type({ + id: "string", + name: "string", +}); + +/** Public shape — `title` here (not the API's `name`) to match `GranolaNote`/`GranolaNoteSummary`'s field naming. */ +export type GranolaFolder = { + id: string; + title: string; +}; + +const GranolaListFoldersResponseRaw = type({ + folders: GranolaFolderRaw.array(), + hasMore: "boolean", + "cursor?": "string | null", +}); + +export type GranolaListFoldersResponse = { + folders: GranolaFolder[]; + hasMore: boolean; + cursor?: string; +}; + +export type ListFoldersOptions = { + cursor?: string; + pageSize?: number; +}; + +export type GranolaClient = { + getNote(noteId: string, options?: GetNoteOptions): Promise; + listNotes(options: ListNotesOptions): Promise; + /** Docs: https://docs.granola.ai/api-reference/list-folders.md. Paginated like `listNotes`. */ + listFolders( + options?: ListFoldersOptions, + ): Promise; +}; + +export type CreateGranolaClientOptions = { apiKey: string; + /** Defaults to `https://public-api.granola.ai/v1`. */ baseUrl?: string; -} + /** Injected for tests; defaults to the global `fetch`. */ + fetchImpl?: typeof fetch; +}; -export class GranolaClient { - constructor(private readonly options: GranolaClientOptions) {} +const DEFAULT_BASE_URL = "https://public-api.granola.ai/v1"; - get baseUrl(): string { - return this.options.baseUrl ?? "https://api.granola.ai"; +// A hung Granola API call must never hold the ingestion pipeline's per-note +// in-flight guard open indefinitely — bound every request the same way the +// webhook-registration client does. +const REQUEST_TIMEOUT_MS = 30_000; + +async function requestJSON( + fetchImpl: typeof fetch, + apiKey: string, + url: string, +): Promise { + let response: Response; + try { + response = await fetchImpl(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + // The Granola API (mirrored from workbench's proven client) expects + // this even on GET; without it some routes answer with non-JSON. + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (cause) { + throw new Error( + `Granola API request failed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); } + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new GranolaApiError(response.status, response.statusText, body); + } + + const raw = await response.text(); + try { + return JSON.parse(raw) as unknown; + } catch (cause) { + // Surface what actually came back (truncated) — a bare "invalid JSON + // body" is undebuggable from logs alone. + const snippet = raw.slice(0, 200).replace(/\s+/g, " "); + throw new Error( + `Granola API request failed: invalid JSON body (status ${String(response.status)}, body starts: ${JSON.stringify(snippet)})`, + { cause }, + ); + } +} + +/** + * Flattens a note's transcript segments into `Speaker: text` lines, one per + * segment, suitable for feeding a workflow. Returns an empty string when the + * note has no transcript (e.g. fetched without `includeTranscript`). + */ +export function transcriptText(note: GranolaNote): string { + if (note.transcript === undefined) return ""; + return note.transcript + .map((segment) => `${speakerLabel(segment.speaker)}: ${segment.text}`) + .join("\n"); +} + +export function createGranolaClient( + options: CreateGranolaClientOptions, +): GranolaClient { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + const fetchImpl = options.fetchImpl ?? fetch; + const apiKey = options.apiKey; + + return { + async getNote(noteId, getOptions = {}) { + const url = new URL(`${baseUrl}/notes/${encodeURIComponent(noteId)}`); + if (getOptions.includeTranscript === true) { + url.searchParams.set("include", "transcript"); + } + + const raw = await requestJSON(fetchImpl, apiKey, url.toString()); + const validated = GranolaNote(raw); + if (validated instanceof type.errors) { + throw new Error( + `Granola API response for note ${noteId} is malformed: ${validated.summary}`, + ); + } + return validated; + }, + + async listNotes(listOptions) { + const url = new URL(`${baseUrl}/notes`); + url.searchParams.set("folder_id", listOptions.folderId); + url.searchParams.set("page_size", String(listOptions.pageSize ?? 30)); + if (listOptions.cursor !== undefined) { + url.searchParams.set("cursor", listOptions.cursor); + } + + const raw = await requestJSON(fetchImpl, apiKey, url.toString()); + const validated = GranolaListNotesResponseRaw(raw); + if (validated instanceof type.errors) { + throw new Error( + `Granola API response for folder ${listOptions.folderId} is malformed: ${validated.summary}`, + ); + } + return { + notes: validated.notes, + hasMore: validated.hasMore, + // A `null` cursor (the live API's "no more pages" spelling) means + // the same as an absent one — normalize so callers only ever check + // for `undefined`. + ...(validated.cursor !== undefined && + validated.cursor !== null && { cursor: validated.cursor }), + }; + }, + + async listFolders(listOptions = {}) { + const url = new URL(`${baseUrl}/folders`); + url.searchParams.set("page_size", String(listOptions.pageSize ?? 30)); + if (listOptions.cursor !== undefined) { + url.searchParams.set("cursor", listOptions.cursor); + } + + const raw = await requestJSON(fetchImpl, apiKey, url.toString()); + const validated = GranolaListFoldersResponseRaw(raw); + if (validated instanceof type.errors) { + throw new Error( + `Granola API response for folders is malformed: ${validated.summary}`, + ); + } + return { + folders: validated.folders.map((folder) => ({ + id: folder.id, + title: folder.name, + })), + hasMore: validated.hasMore, + ...(validated.cursor !== undefined && + validated.cursor !== null && { cursor: validated.cursor }), + }; + }, + }; } diff --git a/src/tools/index.ts b/src/tools/index.ts index 9f476e2..3119766 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,8 +5,34 @@ // Interchange tool, standalone, with nothing hub-shaped attached. The // webhook extension lives at @corbits/granola/ingress and depends on this // module — never the reverse. See ARCHITECTURE.md. -export { GranolaClient } from "./client.js"; -export type { GranolaClientOptions } from "./client.js"; +export { + createGranolaClient, + transcriptText, + speakerLabel, + GranolaApiError, + GranolaNote, +} from "./client.js"; +export type { + GranolaClient, + CreateGranolaClientOptions, + GetNoteOptions, + ListNotesOptions, + GranolaNoteSummary, + GranolaListNotesResponse, + GranolaFolder, + GranolaListFoldersResponse, + ListFoldersOptions, +} from "./client.js"; + +export { + GranolaBucketType, + GranolaBucketsArray, +} from "./types.js"; +export type { + GranolaTranscriptSegment, + GranolaTranscriptSpeaker, + GranolaBucket, +} from "./types.js"; export { fetchNoteTool, searchNotesTool, GRANOLA_TOOL_DEFINITIONS } from "./tools.js"; export type { GranolaToolDefinition } from "./tools.js"; diff --git a/src/tools/types.ts b/src/tools/types.ts new file mode 100644 index 0000000..df142e6 --- /dev/null +++ b/src/tools/types.ts @@ -0,0 +1,62 @@ +/** + * Granola API data shapes — the note payload and the folder-binding record + * — with zero behavior of their own. Zero dependency on anything hub-, + * mounting-, or product-specific: any host wiring this package in supplies + * its own tenant/channel-binding plumbing around these shapes. + */ +import { type } from "arktype"; + +export const GranolaNote = type({ + id: "string", + title: "string", + "owner?": "unknown", + "created_at?": "string", + "updated_at?": "string", + "web_url?": "string", + "calendar_event?": "unknown", + "attendees?": "unknown", + "folder_membership?": "unknown", + "summary_text?": "string", + "summary_markdown?": "string", + "transcript?": type({ + // Live API shape (verified 2026-08-01): `speaker` is an object like + // `{source: "microphone", attribution: "me"}`, not a string. The + // string form stays accepted defensively. + speaker: type({ + "source?": "string", + "attribution?": "string", + }).or("string"), + text: "string", + "start_time?": "string", + "end_time?": "string", + }).array(), +}); + +export type GranolaNote = typeof GranolaNote.infer; + +/** One transcript segment of a `GranolaNote` — derived, not redeclared. */ +export type GranolaTranscriptSegment = NonNullable[number]; + +/** A segment's speaker — derived, not redeclared. */ +export type GranolaTranscriptSpeaker = GranolaTranscriptSegment["speaker"]; + +/** Workflow type a Granola folder is bound to. */ +export const GranolaBucketType = type("'diligence' | 'internal'"); +export type GranolaBucketType = typeof GranolaBucketType.infer; + +const GranolaBucketEntry = type({ + folderId: "string", + type: GranolaBucketType, + channel: "string", +}); + +/** + * Exported so `src/ingress/binding-store.ts` can validate a persisted + * bindings record (read back as an opaque JSON value via the host's + * `GranolaBindingsPort`) against the same shape this module validates a + * host's own seed configuration against. + */ +export const GranolaBucketsArray = GranolaBucketEntry.array(); + +/** One folder -> workflow-type -> chat-channel binding. */ +export type GranolaBucket = typeof GranolaBucketEntry.infer;