From a4d11f0806545dc10478f92da33f22d19fd96a40 Mon Sep 17 00:00:00 2001 From: "Richard J." Date: Wed, 22 Jul 2026 09:28:12 +0100 Subject: [PATCH] impl: build marketplace webhook --- README.md | 5 +- github-app-manifest.yml | 1 + src/marketplace.ts | 56 ++++++++++++++++ src/server.ts | 142 ++++++++++++++++++++++++++-------------- src/types.ts | 44 +++++++++++++ src/webhook.ts | 78 ++++++++++++++++++++++ 6 files changed, 275 insertions(+), 51 deletions(-) create mode 100644 src/marketplace.ts create mode 100644 src/webhook.ts diff --git a/README.md b/README.md index 54cbfb7..4d10bd8 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ A self-hosted GitHub App version of `prnote-action`. On `pull_request.opened`, i In GitHub, open **Settings → Developer settings → GitHub Apps → New GitHub App** and configure: - Webhook URL: `https://your-host/api/github/webhooks` +- Marketplace webhook URL (Payload URL): `https://your-host/webhooks/marketplace` - Webhook secret: a long random value also used as `GITHUB_WEBHOOK_SECRET` - Repository permissions: **Contents: Read-only**, **Pull requests: Read and write**, **Issues: Read and write** only when `PRNOTE_COMMENT=true` -- Subscribe to: **Pull request** +- Subscribe to: **Pull request** on the main webhook URL, and **Marketplace purchase** on the marketplace webhook URL - Where can this app be installed?: choose the scope you need After creating it, note the App ID, generate a private key, and install the app on the desired repositories. @@ -42,7 +43,7 @@ npm run build npm start ``` -Or use the included Dockerfile. The service exposes `GET /health` and expects GitHub webhooks at `POST /api/github/webhooks`. Run one instance for this MVP: webhook work is performed in-process after the HTTP acknowledgment, with no durable job queue or delivery deduplication. Commit a generated `package-lock.json` and switch deployment installs to `npm ci` when dependencies can be downloaded in your environment. +Or use the included Dockerfile. The service exposes `GET /health`, expects GitHub pull request webhooks at `POST /api/github/webhooks`, and GitHub Marketplace purchase events at `POST /webhooks/marketplace`. Run one instance for this MVP: webhook work is performed in-process after the HTTP acknowledgment, with no durable job queue or delivery deduplication. Commit a generated `package-lock.json` and switch deployment installs to `npm ci` when dependencies can be downloaded in your environment. ## Security notes diff --git a/github-app-manifest.yml b/github-app-manifest.yml index 94bbf6a..409a148 100644 --- a/github-app-manifest.yml +++ b/github-app-manifest.yml @@ -11,3 +11,4 @@ default_permissions: issues: write default_events: - pull_request + - marketplace_purchase diff --git a/src/marketplace.ts b/src/marketplace.ts new file mode 100644 index 0000000..7991bf9 --- /dev/null +++ b/src/marketplace.ts @@ -0,0 +1,56 @@ +import type { MarketplacePurchasePayload } from "./types.js"; + +function describePurchase(payload: MarketplacePurchasePayload): string { + const purchase = payload.marketplace_purchase; + const account = purchase.account; + const plan = purchase.plan; + const billing = purchase.billing_cycle ?? "unknown"; + const units = + purchase.unit_count === undefined ? "" : `, units=${purchase.unit_count}`; + const trial = purchase.on_free_trial ? ", free_trial=true" : ""; + return `${account.type} ${account.login} on plan ${plan.name} (${plan.id}, ${billing}${units}${trial})`; +} + +export async function processMarketplacePurchase( + payload: MarketplacePurchasePayload, +): Promise { + const summary = describePurchase(payload); + console.info(`Marketplace ${payload.action}: ${summary}.`); + + switch (payload.action) { + case "purchased": + if (payload.effective_date) { + console.info(`Marketplace purchase effective ${payload.effective_date}.`); + } + return; + case "cancelled": + if (payload.effective_date) { + console.info(`Marketplace cancellation effective ${payload.effective_date}.`); + } + return; + case "changed": + if (payload.previous_marketplace_purchase) { + const previous = payload.previous_marketplace_purchase; + console.info( + `Marketplace plan changed from ${previous.plan.name} (${previous.plan.id}) to ${payload.marketplace_purchase.plan.name} (${payload.marketplace_purchase.plan.id}).`, + ); + } + if (payload.effective_date) { + console.info(`Marketplace change effective ${payload.effective_date}.`); + } + return; + case "pending_change": + if (payload.previous_marketplace_purchase) { + const previous = payload.previous_marketplace_purchase; + console.info( + `Marketplace pending change from ${previous.plan.name} (${previous.plan.id}) to ${payload.marketplace_purchase.plan.name} (${payload.marketplace_purchase.plan.id}).`, + ); + } + return; + case "pending_change_cancelled": + console.info("Marketplace pending plan change cancelled."); + return; + default: + console.warn(`Unhandled marketplace action: ${payload.action}.`); + } +} diff --git a/src/server.ts b/src/server.ts index e9de061..13ec851 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,17 @@ -import { createServer } from "node:http"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { loadEnvFile } from "node:process"; -import { verifyWebhook } from "./auth.js"; import { readConfig } from "./config.js"; +import { processMarketplacePurchase } from "./marketplace.js"; import { processPullRequest } from "./processor.js"; -import type { PullRequestOpenedPayload } from "./types.js"; +import type { + MarketplacePurchasePayload, + PullRequestOpenedPayload, +} from "./types.js"; +import { + ackWebhook, + parseGitHubWebhook, + parseWebhookJson, +} from "./webhook.js"; try { loadEnvFile(); @@ -13,66 +21,102 @@ try { } const config = readConfig(); -const maxBodyBytes = 25 * 1024 * 1024; -const server = createServer(async (request, response) => { - if (request.method === "GET" && request.url === "/health") { - response - .writeHead(200, { "Content-Type": "application/json" }) - .end(JSON.stringify({ ok: true })); - return; - } - if (request.method !== "POST" || request.url !== "/api/github/webhooks") { - response.writeHead(404).end("Not found"); - return; - } +async function handlePullRequestWebhook( + request: IncomingMessage, + response: ServerResponse, +): Promise { + const webhook = await parseGitHubWebhook( + request, + response, + config.webhookSecret, + ); + if (!webhook) return; + + const { body, event, delivery } = webhook; + const payload = parseWebhookJson( + body, + delivery, + response, + ); + if (!payload) return; - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of request) { - const buffer = Buffer.from(chunk as Uint8Array); - size += buffer.length; - if (size > maxBodyBytes) { - response.writeHead(413).end("Payload too large"); - request.destroy(); - return; - } - chunks.push(buffer); + console.info( + `Received ${String(event)} webhook ${delivery ?? "unknown"} (action: ${payload.action ?? "none"}).`, + ); + ackWebhook(response, delivery); + if (event !== "pull_request") return; + + try { + await processPullRequest(payload, config); + console.info(`Processed webhook ${delivery ?? "unknown"}.`); + } catch (error) { + console.error(`Webhook ${delivery ?? "unknown"} failed.`, error); } - const body = Buffer.concat(chunks); - const signature = request.headers["x-hub-signature-256"] as - string | undefined; - if (!verifyWebhook(body, signature, config.webhookSecret)) { - response.writeHead(401).end("Invalid signature"); +} + +async function handleMarketplaceWebhook( + request: IncomingMessage, + response: ServerResponse, +): Promise { + const webhook = await parseGitHubWebhook( + request, + response, + config.webhookSecret, + ); + if (!webhook) return; + + const { body, event, delivery } = webhook; + const payload = parseWebhookJson( + body, + delivery, + response, + ); + if (!payload) return; + + console.info( + `Received ${String(event)} webhook ${delivery ?? "unknown"} (action: ${payload.action ?? "none"}).`, + ); + ackWebhook(response, delivery); + if (event !== "marketplace_purchase") { + console.warn( + `Ignoring ${String(event)} webhook ${delivery ?? "unknown"} on /webhooks/marketplace.`, + ); return; } - const event = request.headers["x-github-event"]; - const delivery = request.headers["x-github-delivery"]; - let payload: PullRequestOpenedPayload | undefined; try { - payload = JSON.parse(body.toString("utf8")) as PullRequestOpenedPayload; + await processMarketplacePurchase(payload); + console.info(`Processed marketplace webhook ${delivery ?? "unknown"}.`); } catch (error) { console.error( - `Webhook ${delivery ?? "unknown"} contains invalid JSON.`, + `Marketplace webhook ${delivery ?? "unknown"} failed.`, error, ); - response.writeHead(400).end("Invalid JSON"); + } +} + +const server = createServer(async (request, response) => { + const path = request.url?.split("?")[0]; + + if (request.method === "GET" && path === "/health") { + response + .writeHead(200, { "Content-Type": "application/json" }) + .end(JSON.stringify({ ok: true })); return; } - console.info( - `Received ${String(event)} webhook ${delivery ?? "unknown"} (action: ${payload.action ?? "none"}).`, - ); - response - .writeHead(202, { "Content-Type": "application/json" }) - .end(JSON.stringify({ accepted: true, delivery })); - if (event !== "pull_request") return; - try { - await processPullRequest(payload, config); - console.info(`Processed webhook ${delivery ?? "unknown"}.`); - } catch (error) { - console.error(`Webhook ${delivery ?? "unknown"} failed.`, error); + + if (request.method === "POST" && path === "/api/github/webhooks") { + await handlePullRequestWebhook(request, response); + return; + } + + if (request.method === "POST" && path === "/webhooks/marketplace") { + await handleMarketplaceWebhook(request, response); + return; } + + response.writeHead(404).end("Not found"); }); server.listen(config.port, () => diff --git a/src/types.ts b/src/types.ts index 55e1a53..89357e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,3 +68,47 @@ export interface PullRequestOpenedPayload { head: { ref: string }; }; } + +export type MarketplacePurchaseAction = + | "purchased" + | "cancelled" + | "changed" + | "pending_change" + | "pending_change_cancelled"; + +export interface MarketplaceAccount { + id: number; + type: string; + login: string; + organization_billing_email?: string; +} + +export interface MarketplacePlan { + id: number; + name: string; + description?: string; + monthly_price_in_cents?: number; + yearly_price_in_cents?: number; + price_model?: string; + has_free_trial?: boolean; + unit_name?: string | null; + bullets?: string[]; +} + +export interface MarketplacePurchase { + account: MarketplaceAccount; + billing_cycle?: string; + unit_count?: number; + on_free_trial?: boolean; + free_trial_ends_on?: string | null; + next_billing_date?: string | null; + plan: MarketplacePlan; +} + +export interface MarketplacePurchasePayload { + action: MarketplacePurchaseAction | string; + effective_date?: string; + marketplace_purchase: MarketplacePurchase; + previous_marketplace_purchase?: MarketplacePurchase; + sender?: { login: string; id: number }; +} diff --git a/src/webhook.ts b/src/webhook.ts new file mode 100644 index 0000000..50f9e1a --- /dev/null +++ b/src/webhook.ts @@ -0,0 +1,78 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { verifyWebhook } from "./auth.js"; + +export const maxBodyBytes = 25 * 1024 * 1024; + +export interface GitHubWebhookContext { + body: Buffer; + event: string | undefined; + delivery: string | undefined; +} + +export async function readRequestBody( + request: IncomingMessage, + response: ServerResponse, +): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.from(chunk as Uint8Array); + size += buffer.length; + if (size > maxBodyBytes) { + response.writeHead(413).end("Payload too large"); + request.destroy(); + return undefined; + } + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +export async function parseGitHubWebhook( + request: IncomingMessage, + response: ServerResponse, + webhookSecret: string, +): Promise { + const body = await readRequestBody(request, response); + if (!body) return undefined; + + const signature = request.headers["x-hub-signature-256"] as + | string + | undefined; + if (!verifyWebhook(body, signature, webhookSecret)) { + response.writeHead(401).end("Invalid signature"); + return undefined; + } + + return { + body, + event: request.headers["x-github-event"] as string | undefined, + delivery: request.headers["x-github-delivery"] as string | undefined, + }; +} + +export function ackWebhook( + response: ServerResponse, + delivery: string | undefined, +): void { + response + .writeHead(202, { "Content-Type": "application/json" }) + .end(JSON.stringify({ accepted: true, delivery })); +} + +export function parseWebhookJson( + body: Buffer, + delivery: string | undefined, + response: ServerResponse, +): T | undefined { + try { + return JSON.parse(body.toString("utf8")) as T; + } catch (error) { + console.error( + `Webhook ${delivery ?? "unknown"} contains invalid JSON.`, + error, + ); + response.writeHead(400).end("Invalid JSON"); + return undefined; + } +}