diff --git a/src/lib/ghost.ts b/src/lib/ghost.ts index 0edffbc..ec80a92 100644 --- a/src/lib/ghost.ts +++ b/src/lib/ghost.ts @@ -2,6 +2,7 @@ // Site-scoped: every function takes the Site it's operating on. Cached clients // live in a Map keyed by site.id so we don't reconstruct on every call. +import { createHmac } from 'node:crypto'; import GhostAdminAPI from '@tryghost/admin-api'; import type { Site } from './db.js'; @@ -129,6 +130,45 @@ export interface WebhookInstallSummary { targetUrl: string; } +// @tryghost/admin-api special-cases the webhooks resource to expose only +// add/edit/delete (no browse). We need browse for idempotency, so we make the +// list call ourselves with the same JWT auth the lib uses internally. +function makeAdminJwt(adminApiKey: string): string { + const colon = adminApiKey.indexOf(':'); + if (colon === -1) throw new Error('Ghost Admin API key must be key_id:hex_secret'); + const keyId = adminApiKey.slice(0, colon); + const secret = adminApiKey.slice(colon + 1); + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: keyId })).toString('base64url'); + const payload = Buffer.from(JSON.stringify({ iat: now, exp: now + 300, aud: '/admin/' })).toString('base64url'); + const signingInput = `${header}.${payload}`; + const sig = createHmac('sha256', Buffer.from(secret, 'hex')).update(signingInput).digest('base64url'); + return `${signingInput}.${sig}`; +} + +interface GhostWebhookRow { + id: string; + event: string; + target_url: string; + integration_id?: string; +} + +async function listWebhooks(site: Site): Promise { + const jwt = makeAdminJwt(site.ghost_admin_api_key); + const url = `${site.ghost_url.replace(/\/$/, '')}/ghost/api/admin/webhooks/`; + const resp = await fetch(url, { + headers: { + Authorization: `Ghost ${jwt}`, + 'Accept-Version': 'v5.0', + }, + }); + if (!resp.ok) { + throw new Error(`Ghost /admin/webhooks/ returned ${resp.status}`); + } + const body = await resp.json() as { webhooks?: GhostWebhookRow[] }; + return body.webhooks ?? []; +} + export async function ensureWebhooks( site: Site, bridgeUrl: string, @@ -136,10 +176,7 @@ export async function ensureWebhooks( const client = getClient(site); const targetUrl = `${bridgeUrl.replace(/\/$/, '')}/webhooks/ghost/${site.slug}`; - // Ghost's webhooks.browse with integration-key auth returns just the - // webhooks owned by THIS integration. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const existing: any[] = await client.webhooks.browse({ limit: 'all' }); + const existing = await listWebhooks(site); const summary: WebhookInstallSummary = { created: [], existing: [], failed: [], targetUrl };