diff --git a/.env.example b/.env.example index 1cf16f0..d787eb9 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,12 @@ COINPAY_BUSINESS_ID=00000000-0000-0000-0000-000000000000 COINPAY_WEBHOOK_SECRET=whsecret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx COINPAY_API_URL=https://coinpayportal.com +# --- Optional: GitHub Marketplace listing webhook --- +# Set on the listing's Webhook page (Payload URL + Secret), not in the App's +# developer settings. Must match the Secret field byte for byte. +# Generate with: openssl rand -hex 32 +GITHUB_MARKETPLACE_WEBHOOK_SECRET= + # ─── Optional: Web Push (VAPID keys for PWA push notifications) ─── # Generate with: npx web-push generate-vapid-keys NEXT_PUBLIC_VAPID_PUBLIC_KEY= diff --git a/apps/web/src/app/admin/admin-content.tsx b/apps/web/src/app/admin/admin-content.tsx index 1fb28c9..a3ab32d 100644 --- a/apps/web/src/app/admin/admin-content.tsx +++ b/apps/web/src/app/admin/admin-content.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useAuth } from "@/lib/auth-context"; import { authHeaders } from "@/lib/auth-client"; +import MarketplacePanel from "./marketplace-panel"; type Kind = "outrank" | "crawlproof"; @@ -168,7 +169,9 @@ export default function AdminContent() {

Admin

-

Blog publishing webhooks (Crawlproof, Outrank)

+

+ Blog publishing webhooks (Crawlproof, Outrank) and the GitHub Marketplace listing +

{error && (
@@ -315,6 +318,8 @@ export default function AdminContent() { )} + +
); diff --git a/apps/web/src/app/admin/marketplace-panel.tsx b/apps/web/src/app/admin/marketplace-panel.tsx new file mode 100644 index 0000000..edcfe67 --- /dev/null +++ b/apps/web/src/app/admin/marketplace-panel.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { authHeaders } from "@/lib/auth-client"; + +const WEBHOOK_PATH = "/api/webhooks/github/marketplace"; + +const DOCS_HREF = + "https://docs.github.com/en/apps/github-marketplace/listing-an-app-on-github-marketplace/configuring-a-webhook-to-notify-you-of-plan-changes"; + +type Subscription = { + id: string; + github_account_id: number; + github_account_login: string; + github_account_type: string | null; + plan_name: string | null; + plan_monthly_price_cents: number | null; + billing_cycle: string | null; + unit_count: number | null; + on_free_trial: boolean; + free_trial_ends_on: string | null; + next_billing_date: string | null; + status: string; + pending_plan_name: string | null; + pending_effective_date: string | null; + last_action: string | null; + updated_at: string; +}; + +type Delivery = { + id: string; + delivery_id: string | null; + action: string; + github_account_login: string | null; + applied: boolean; + skip_reason: string | null; + received_at: string; +}; + +type MarketplaceData = { + configured: boolean; + migrationApplied: boolean; + subscriptions: Subscription[]; + events: Delivery[]; +}; + +function fmtDate(value: string | null): string { + if (!value) return "—"; + const ms = Date.parse(value); + if (Number.isNaN(ms)) return "—"; + return new Date(ms).toISOString().slice(0, 10); +} + +function fmtPrice(cents: number | null): string { + if (typeof cents !== "number") return "—"; + return cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)}`; +} + +export default function MarketplacePanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const webhookUrl = + typeof window === "undefined" ? WEBHOOK_PATH : `${window.location.origin}${WEBHOOK_PATH}`; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/admin/marketplace", { headers: authHeaders() }); + if (!res.ok) { + setError(res.status === 403 ? "Forbidden" : "Failed to load Marketplace data"); + return; + } + setData((await res.json()) as MarketplaceData); + } catch { + setError("Network error"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const copy = () => { + navigator.clipboard?.writeText(webhookUrl); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + const subs = data?.subscriptions ?? []; + const events = data?.events ?? []; + const active = subs.filter((s) => s.status === "active").length; + + return ( +
+
+

+ GitHub Marketplace + + docs ↗ + +

+ +
+ + {error && ( +
+ {error} +
+ )} + +

+ Paste this as the Payload URL on the listing's Webhook page. Content type{" "} + application/json, and set a Secret. +

+ +
+ + {webhookUrl} + + +
+ +
+
+ + {data?.configured ? "●" : "○"} + + + {data?.configured + ? "Secret configured (GITHUB_MARKETPLACE_WEBHOOK_SECRET)" + : "No secret set. Deliveries are rejected with 503 until GITHUB_MARKETPLACE_WEBHOOK_SECRET is set on the service."} + +
+ {data && !data.migrationApplied && ( +
+ + + Tables missing. Apply{" "} + + supabase/migrations/20260821170000_github_marketplace.sql + + . + +
+ )} +
+ +

+ Subscriptions{" "} + + ({active} active of {subs.length}) + +

+ + {subs.length === 0 ? ( +

+ No purchases recorded yet. GitHub does not resend failed deliveries, so if a customer + reports a purchase that is missing here, replay it from the listing's delivery log. +

+ ) : ( +
+ + + + + + + + + + + + + {subs.map((s) => ( + + + + + + + + + ))} + +
AccountPlanCycleUnitsStatusNext billing
+ + {s.github_account_login} + + + {s.github_account_type === "Organization" ? "org" : "user"} + + + {s.plan_name ?? "—"} + + {fmtPrice(s.plan_monthly_price_cents)} + + {s.pending_plan_name && ( +
+ → {s.pending_plan_name} on {fmtDate(s.pending_effective_date)} +
+ )} +
{s.billing_cycle ?? "—"}{s.unit_count ?? "—"} + + {s.status} + + {s.on_free_trial && ( + + trial → {fmtDate(s.free_trial_ends_on)} + + )} + {fmtDate(s.next_billing_date)}
+
+ )} + +

Recent deliveries

+ {events.length === 0 ? ( +

Nothing delivered yet.

+ ) : ( +
    + {events.map((e) => ( +
  • + + {e.applied ? "✓" : "·"} + + {e.action} + {e.github_account_login ?? "unknown"} + + {new Date(e.received_at).toISOString().replace("T", " ").slice(0, 16)} + + {e.skip_reason && ( + skipped: {e.skip_reason} + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/api/admin/marketplace/route.ts b/apps/web/src/app/api/admin/marketplace/route.ts new file mode 100644 index 0000000..46b53ed --- /dev/null +++ b/apps/web/src/app/api/admin/marketplace/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-guard"; +import { getSupabaseAdmin } from "@/lib/supabase"; + +export const runtime = "nodejs"; + +/** + * Admin view of the GitHub Marketplace listing's webhook. + * + * Reports whether the secret is configured, the current subscription per + * GitHub account, and the most recent deliveries. GitHub does not resend + * failed deliveries, so the delivery list is the thing to look at when a + * customer says they paid and nothing happened. + * + * The secret itself is never returned, only whether one is set. + */ +export async function GET(req: NextRequest) { + const guard = await requireAdmin(req); + if (guard instanceof NextResponse) return guard; + + const supabase = getSupabaseAdmin(); + + const [subs, events] = await Promise.all([ + supabase + .from("github_marketplace_purchases") + .select( + "id, github_account_id, github_account_login, github_account_type, plan_id, plan_name, plan_monthly_price_cents, billing_cycle, unit_count, on_free_trial, free_trial_ends_on, next_billing_date, status, pending_plan_name, pending_effective_date, effective_date, last_action, updated_at", + ) + .order("updated_at", { ascending: false }) + .limit(200), + supabase + .from("github_marketplace_events") + .select( + "id, delivery_id, action, github_account_login, effective_date, applied, skip_reason, received_at", + ) + .order("received_at", { ascending: false }) + .limit(50), + ]); + + // A missing table is the expected state until the migration is applied + // by hand, so say that plainly instead of returning a bare 500. + const missingTable = + subs.error?.code === "42P01" || events.error?.code === "42P01"; + if (missingTable) { + return NextResponse.json({ + configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET), + webhookPath: "/api/webhooks/github/marketplace", + migrationApplied: false, + subscriptions: [], + events: [], + }); + } + + if (subs.error || events.error) { + return NextResponse.json({ error: "Failed to load marketplace data" }, { status: 500 }); + } + + return NextResponse.json({ + configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET), + webhookPath: "/api/webhooks/github/marketplace", + migrationApplied: true, + subscriptions: subs.data ?? [], + events: events.data ?? [], + }); +} diff --git a/apps/web/src/app/api/webhooks/github/__tests__/marketplace-route.test.ts b/apps/web/src/app/api/webhooks/github/__tests__/marketplace-route.test.ts new file mode 100644 index 0000000..abfc6ba --- /dev/null +++ b/apps/web/src/app/api/webhooks/github/__tests__/marketplace-route.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import { createHmac } from "node:crypto"; + +const originalEnv = { ...process.env }; + +const SECRET = "test-marketplace-secret"; + +/** + * In-memory stand-in for the two tables. `state` is reset per test so the + * dedupe and out-of-order cases can set up their own starting point. + */ +const state = { + existingPurchase: null as { id: string; effective_date: string | null } | null, + eventInsertReturns: [{ id: "event-1" }] as { id: string }[], + upsertedPurchases: [] as Record[], + eventUpdates: [] as Record[], +}; + +vi.mock("@/lib/supabase", () => ({ + getSupabaseAdmin: () => ({ + from: (table: string) => { + if (table === "github_marketplace_events") { + return { + upsert: () => ({ + select: async () => ({ data: state.eventInsertReturns, error: null }), + }), + update: (patch: Record) => { + state.eventUpdates.push(patch); + return { eq: async () => ({ error: null }) }; + }, + }; + } + // github_marketplace_purchases + return { + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ data: state.existingPurchase, error: null }), + }), + }), + upsert: async (row: Record) => { + state.upsertedPurchases.push(row); + return { error: null }; + }, + }; + }, + }), +})); + +import { POST, GET } from "@/app/api/webhooks/github/marketplace/route"; + +const PAYLOAD = { + action: "purchased", + effective_date: "2026-08-21T00:00:00+00:00", + sender: { login: "octocat" }, + marketplace_purchase: { + account: { type: "Organization", id: 18404712, login: "acme-corp" }, + billing_cycle: "monthly", + unit_count: 1, + on_free_trial: false, + next_billing_date: "2026-09-21T00:00:00+00:00", + plan: { id: 435, name: "Pro Plan", monthly_price_in_cents: 999 }, + }, +}; + +function makeRequest( + body: string, + { + signature, + event = "marketplace_purchase", + delivery = "delivery-1", + contentType = "application/json", + }: { + signature?: string | null; + event?: string; + delivery?: string | null; + contentType?: string; + } = {}, +) { + const headers: Record = { "Content-Type": contentType }; + const sig = + signature === undefined + ? `sha256=${createHmac("sha256", SECRET).update(body, "utf8").digest("hex")}` + : signature; + if (sig) headers["X-Hub-Signature-256"] = sig; + if (event) headers["X-GitHub-Event"] = event; + if (delivery) headers["X-GitHub-Delivery"] = delivery; + + return new Request("http://localhost/api/webhooks/github/marketplace", { + method: "POST", + headers, + body, + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/webhooks/github/marketplace", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.existingPurchase = null; + state.eventInsertReturns = [{ id: "event-1" }]; + state.upsertedPurchases = []; + state.eventUpdates = []; + process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET = SECRET; + process.env.NEXT_PUBLIC_SUPABASE_URL = "https://test.supabase.co"; + process.env.SUPABASE_SERVICE_ROLE_KEY = "test-service-key"; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("accepts and applies a correctly signed purchase", async () => { + const body = JSON.stringify(PAYLOAD); + const res = await POST(makeRequest(body)); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ received: true, applied: true }); + expect(state.upsertedPurchases).toHaveLength(1); + expect(state.upsertedPurchases[0]).toMatchObject({ + github_account_id: 18404712, + plan_name: "Pro Plan", + status: "active", + }); + }); + + it("rejects an unsigned delivery with 401 and writes nothing", async () => { + const res = await POST(makeRequest(JSON.stringify(PAYLOAD), { signature: null })); + expect(res.status).toBe(401); + expect(state.upsertedPurchases).toHaveLength(0); + }); + + it("rejects a delivery signed with the wrong secret", async () => { + const body = JSON.stringify(PAYLOAD); + const wrong = `sha256=${createHmac("sha256", "not-the-secret").update(body).digest("hex")}`; + const res = await POST(makeRequest(body, { signature: wrong })); + expect(res.status).toBe(401); + expect(state.upsertedPurchases).toHaveLength(0); + }); + + it("rejects a body altered after signing", async () => { + const body = JSON.stringify(PAYLOAD); + const signature = `sha256=${createHmac("sha256", SECRET).update(body).digest("hex")}`; + const tampered = JSON.stringify({ ...PAYLOAD, action: "cancelled" }); + const res = await POST(makeRequest(tampered, { signature })); + expect(res.status).toBe(401); + expect(state.upsertedPurchases).toHaveLength(0); + }); + + it("refuses with 503 when no secret is configured, rather than accepting", async () => { + delete process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET; + const res = await POST(makeRequest(JSON.stringify(PAYLOAD))); + expect(res.status).toBe(503); + expect(state.upsertedPurchases).toHaveLength(0); + }); + + it("answers the ping GitHub sends when the hook is saved", async () => { + const body = JSON.stringify({ zen: "Keep it logically awesome." }); + const res = await POST(makeRequest(body, { event: "ping" })); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ pong: true }); + }); + + it("ignores a signed event that is not marketplace_purchase", async () => { + const body = JSON.stringify(PAYLOAD); + const res = await POST(makeRequest(body, { event: "installation" })); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ignored: "installation" }); + expect(state.upsertedPurchases).toHaveLength(0); + }); + + it("treats a repeated delivery id as a duplicate and does not re-apply it", async () => { + // ignoreDuplicates means the upsert returns no row for a delivery we + // have already seen. + state.eventInsertReturns = []; + const res = await POST(makeRequest(JSON.stringify(PAYLOAD))); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ duplicate: true }); + expect(state.upsertedPurchases).toHaveLength(0); + }); + + it("does not let an older event overwrite newer state", async () => { + state.existingPurchase = { id: "p1", effective_date: "2026-09-01T00:00:00.000Z" }; + const stale = { ...PAYLOAD, effective_date: "2026-08-01T00:00:00+00:00" }; + const res = await POST(makeRequest(JSON.stringify(stale))); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ stale: true, applied: false }); + expect(state.upsertedPurchases).toHaveLength(0); + expect(state.eventUpdates[0]).toMatchObject({ applied: false }); + }); + + it("accepts the urlencoded content type as well", async () => { + const body = `payload=${encodeURIComponent(JSON.stringify(PAYLOAD))}`; + const res = await POST( + makeRequest(body, { contentType: "application/x-www-form-urlencoded" }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ applied: true }); + }); + + it("ignores an unknown action without failing the delivery", async () => { + const body = JSON.stringify({ ...PAYLOAD, action: "exploded" }); + const res = await POST(makeRequest(body)); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ignored: "exploded" }); + expect(state.upsertedPurchases).toHaveLength(0); + }); +}); + +describe("GET /api/webhooks/github/marketplace", () => { + it("reports configuration without leaking the secret", async () => { + process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET = SECRET; + const res = await GET(); + const body = await res.json(); + expect(body.configured).toBe(true); + expect(JSON.stringify(body)).not.toContain(SECRET); + }); +}); diff --git a/apps/web/src/app/api/webhooks/github/marketplace/route.ts b/apps/web/src/app/api/webhooks/github/marketplace/route.ts new file mode 100644 index 0000000..9a935d5 --- /dev/null +++ b/apps/web/src/app/api/webhooks/github/marketplace/route.ts @@ -0,0 +1,191 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSupabaseAdmin } from "@/lib/supabase"; +import { + buildPurchaseRow, + isMarketplaceAction, + isStaleEvent, + parseWebhookBody, + verifyGithubSignature, +} from "@/lib/github-marketplace"; + +/** + * GitHub Marketplace plan-change webhook. + * + * Configured on the Marketplace listing page (not in the App's developer + * settings, which is where every other event is configured): + * + * Payload URL https://threatcrush.com/api/webhooks/github/marketplace + * Content type application/json + * Secret GITHUB_MARKETPLACE_WEBHOOK_SECRET + * + * GitHub does NOT resend failed deliveries. A 500 from here loses the + * event permanently, so the raw payload is written to + * github_marketplace_events before anything else can fail, and the + * listing's delivery log is the only other copy. + */ + +// node:crypto and the raw-body read both need the Node runtime. +export const runtime = "nodejs"; + +/** + * Webhook fields land in log lines, and a value containing CRLF can forge + * a whole extra entry. Strip the control characters and cap the length so + * a field can only ever be one token. + */ +function logSafe(value: unknown): string { + const collapsed = String(value ?? "") + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f]/g, " ") + .slice(0, 200); + return collapsed.replace(/\n|\r/g, " "); +} + +export async function POST(req: NextRequest) { + // Must be the raw bytes: the signature is over the body as sent. + const rawBody = await req.text(); + + const secret = process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET; + if (!secret) { + // Refuse rather than accept-and-ignore. An unconfigured deployment + // that returns 200 looks healthy in the delivery log while silently + // dropping every purchase. + console.error("[gh marketplace] GITHUB_MARKETPLACE_WEBHOOK_SECRET is not set"); + return NextResponse.json({ error: "Webhook not configured" }, { status: 503 }); + } + + const signature = req.headers.get("x-hub-signature-256"); + if (!verifyGithubSignature(rawBody, signature, secret)) { + console.warn("[gh marketplace] rejected delivery with invalid signature"); + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + const event = req.headers.get("x-github-event") ?? ""; + const deliveryId = req.headers.get("x-github-delivery"); + + // The listing only sends marketplace_purchase, but `ping` arrives when + // the hook is first saved and must be answered 200 or the UI shows the + // hook as broken. + if (event === "ping") { + return NextResponse.json({ received: true, pong: true }); + } + if (event && event !== "marketplace_purchase") { + return NextResponse.json({ received: true, ignored: event }); + } + + const payload = parseWebhookBody(rawBody, req.headers.get("content-type")); + if (!payload) { + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + const action = payload.action; + if (!isMarketplaceAction(action)) { + console.warn(`[gh marketplace] unknown action: ${logSafe(action)}`); + return NextResponse.json({ received: true, ignored: String(action ?? "") }); + } + + const now = new Date().toISOString(); + const row = buildPurchaseRow(action, payload, now); + + let supabase; + try { + supabase = getSupabaseAdmin(); + } catch (e) { + console.error("[gh marketplace] supabase unavailable:", e); + // 500 so the delivery shows red in GitHub's log and can be replayed + // by hand from the payload GitHub still holds. + return NextResponse.json({ error: "Storage unavailable" }, { status: 500 }); + } + + // Record the delivery first. `delivery_id` is unique, so a replay of the + // same delivery inserts nothing and we can stop early. + const eventRow = { + delivery_id: deliveryId, + action, + github_account_id: row?.github_account_id ?? null, + github_account_login: row?.github_account_login ?? null, + effective_date: row?.effective_date ?? null, + applied: false, + skip_reason: null as string | null, + payload: payload as unknown as Record, + received_at: now, + }; + + const { data: insertedEvents, error: eventErr } = await supabase + .from("github_marketplace_events") + .upsert(eventRow, { onConflict: "delivery_id", ignoreDuplicates: true }) + .select("id"); + + if (eventErr) { + console.error("[gh marketplace] failed to log delivery:", eventErr); + return NextResponse.json({ error: "Storage failed" }, { status: 500 }); + } + + const eventId = insertedEvents?.[0]?.id as string | undefined; + if (deliveryId && !eventId) { + // Already processed this exact delivery. + return NextResponse.json({ received: true, duplicate: true }); + } + + const finish = async (applied: boolean, skipReason?: string) => { + if (!eventId) return; + await supabase + .from("github_marketplace_events") + .update({ applied, skip_reason: skipReason ?? null }) + .eq("id", eventId); + }; + + if (!row) { + await finish(false, "no account id in payload"); + return NextResponse.json({ received: true, applied: false }); + } + + const { data: existing, error: readErr } = await supabase + .from("github_marketplace_purchases") + .select("id, effective_date") + .eq("github_account_id", row.github_account_id) + .maybeSingle(); + + if (readErr) { + console.error("[gh marketplace] failed to read purchase:", readErr); + return NextResponse.json({ error: "Storage failed" }, { status: 500 }); + } + + if (existing && isStaleEvent(existing.effective_date as string | null, row.effective_date)) { + console.warn( + `[gh marketplace] ignoring out-of-order ${logSafe(action)} for ${logSafe(row.github_account_login)}`, + ); + await finish(false, "older than the applied event"); + return NextResponse.json({ received: true, applied: false, stale: true }); + } + + const { error: upsertErr } = await supabase + .from("github_marketplace_purchases") + .upsert(row, { onConflict: "github_account_id" }); + + if (upsertErr) { + console.error("[gh marketplace] failed to upsert purchase:", upsertErr); + return NextResponse.json({ error: "Storage failed" }, { status: 500 }); + } + + await finish(true); + + console.log( + `[gh marketplace] ${logSafe(action)} ${logSafe(row.github_account_login)} -> ${logSafe(row.plan_name)}`, + ); + + return NextResponse.json({ received: true, applied: true, action }); +} + +/** + * A GET here is almost always a human checking the URL is live before + * pasting it into the listing. Answer usefully and never leak the secret. + */ +export async function GET() { + return NextResponse.json({ + endpoint: "github-marketplace-webhook", + method: "POST", + event: "marketplace_purchase", + contentType: "application/json", + configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET), + }); +} diff --git a/apps/web/src/lib/__tests__/github-marketplace.test.ts b/apps/web/src/lib/__tests__/github-marketplace.test.ts new file mode 100644 index 0000000..674fe88 --- /dev/null +++ b/apps/web/src/lib/__tests__/github-marketplace.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; +import { createHmac } from "node:crypto"; +import { + buildPurchaseRow, + currentPurchaseFor, + isMarketplaceAction, + isStaleEvent, + parseWebhookBody, + verifyGithubSignature, + type MarketplaceEventPayload, +} from "../github-marketplace"; + +const SECRET = "s3cret-webhook-token"; + +function sign(body: string, secret = SECRET): string { + return `sha256=${createHmac("sha256", secret).update(body, "utf8").digest("hex")}`; +} + +// Shaped after the example payload in GitHub's docs. +const PURCHASED: MarketplaceEventPayload = { + action: "purchased", + effective_date: "2026-08-21T00:00:00+00:00", + sender: { login: "octocat" }, + marketplace_purchase: { + account: { + type: "Organization", + id: 18404712, + node_id: "MDEyOk9yZ2FuaXphdGlvbjE4NDA0NzEy", + login: "acme-corp", + organization_billing_email: "billing@acme.test", + }, + billing_cycle: "monthly", + unit_count: 3, + on_free_trial: false, + free_trial_ends_on: null, + next_billing_date: "2026-09-21T00:00:00+00:00", + plan: { + id: 435, + name: "Pro Plan", + monthly_price_in_cents: 999, + yearly_price_in_cents: 9999, + }, + }, +}; + +describe("verifyGithubSignature", () => { + it("accepts a correctly signed body", () => { + const body = JSON.stringify(PURCHASED); + expect(verifyGithubSignature(body, sign(body), SECRET)).toBe(true); + }); + + it("rejects a body signed with a different secret", () => { + const body = JSON.stringify(PURCHASED); + expect(verifyGithubSignature(body, sign(body, "wrong"), SECRET)).toBe(false); + }); + + it("rejects a tampered body", () => { + const body = JSON.stringify(PURCHASED); + const signature = sign(body); + expect(verifyGithubSignature(body + " ", signature, SECRET)).toBe(false); + }); + + it("rejects when the secret is missing, rather than passing", () => { + const body = JSON.stringify(PURCHASED); + expect(verifyGithubSignature(body, sign(body), undefined)).toBe(false); + expect(verifyGithubSignature(body, sign(body), "")).toBe(false); + }); + + it("rejects a missing or malformed signature header without throwing", () => { + const body = JSON.stringify(PURCHASED); + expect(verifyGithubSignature(body, null, SECRET)).toBe(false); + expect(verifyGithubSignature(body, "sha256=short", SECRET)).toBe(false); + expect(verifyGithubSignature(body, "garbage", SECRET)).toBe(false); + }); +}); + +describe("parseWebhookBody", () => { + it("parses application/json", () => { + const body = JSON.stringify(PURCHASED); + expect(parseWebhookBody(body, "application/json")?.action).toBe("purchased"); + }); + + it("parses the urlencoded form, where the JSON arrives in a payload field", () => { + const body = `payload=${encodeURIComponent(JSON.stringify(PURCHASED))}`; + const parsed = parseWebhookBody(body, "application/x-www-form-urlencoded"); + expect(parsed?.action).toBe("purchased"); + expect(parsed?.marketplace_purchase?.account?.login).toBe("acme-corp"); + }); + + it("returns null on malformed JSON instead of throwing", () => { + expect(parseWebhookBody("{not json", "application/json")).toBeNull(); + expect(parseWebhookBody("", "application/json")).toBeNull(); + }); +}); + +describe("isMarketplaceAction", () => { + it("accepts the five documented actions and nothing else", () => { + for (const a of [ + "purchased", + "changed", + "cancelled", + "pending_change", + "pending_change_cancelled", + ]) { + expect(isMarketplaceAction(a)).toBe(true); + } + expect(isMarketplaceAction("deleted")).toBe(false); + expect(isMarketplaceAction(undefined)).toBe(false); + }); +}); + +describe("buildPurchaseRow", () => { + const now = "2026-08-21T12:00:00.000Z"; + + it("maps a purchase onto the stored row", () => { + const row = buildPurchaseRow("purchased", PURCHASED, now); + expect(row).not.toBeNull(); + expect(row!.github_account_id).toBe(18404712); + expect(row!.github_account_login).toBe("acme-corp"); + expect(row!.github_account_type).toBe("Organization"); + expect(row!.plan_id).toBe(435); + expect(row!.plan_name).toBe("Pro Plan"); + expect(row!.plan_monthly_price_cents).toBe(999); + expect(row!.billing_cycle).toBe("monthly"); + expect(row!.unit_count).toBe(3); + expect(row!.status).toBe("active"); + expect(row!.sender_login).toBe("octocat"); + expect(row!.effective_date).toBe("2026-08-21T00:00:00.000Z"); + }); + + it("returns null when the payload carries no account id", () => { + expect(buildPurchaseRow("purchased", { action: "purchased" }, now)).toBeNull(); + }); + + it("marks a cancellation cancelled", () => { + const row = buildPurchaseRow("cancelled", { ...PURCHASED, action: "cancelled" }, now); + expect(row!.status).toBe("cancelled"); + }); + + it("keeps the CURRENT plan on a pending downgrade and records the future one", () => { + // The regression this guards: reading marketplace_purchase on a + // pending_change downgrades the customer immediately instead of at the + // end of their billing cycle. + const pending: MarketplaceEventPayload = { + action: "pending_change", + effective_date: "2026-09-21T00:00:00+00:00", + sender: { login: "octocat" }, + marketplace_purchase: { + account: PURCHASED.marketplace_purchase!.account, + billing_cycle: "monthly", + unit_count: 1, + plan: { id: 1, name: "Free Plan", monthly_price_in_cents: 0 }, + }, + previous_marketplace_purchase: PURCHASED.marketplace_purchase, + }; + + const row = buildPurchaseRow("pending_change", pending, now); + expect(row!.plan_name).toBe("Pro Plan"); + expect(row!.plan_id).toBe(435); + expect(row!.status).toBe("active"); + expect(row!.pending_plan_name).toBe("Free Plan"); + expect(row!.pending_plan_id).toBe(1); + expect(row!.pending_effective_date).toBe("2026-09-21T00:00:00.000Z"); + }); + + it("clears a pending change on any non-pending action", () => { + const row = buildPurchaseRow("changed", { ...PURCHASED, action: "changed" }, now); + expect(row!.pending_plan_id).toBeNull(); + expect(row!.pending_plan_name).toBeNull(); + expect(row!.pending_effective_date).toBeNull(); + }); + + it("treats a free trial as active and keeps the trial end date", () => { + const trial: MarketplaceEventPayload = { + ...PURCHASED, + marketplace_purchase: { + ...PURCHASED.marketplace_purchase!, + on_free_trial: true, + free_trial_ends_on: "2026-09-04T00:00:00+00:00", + }, + }; + const row = buildPurchaseRow("purchased", trial, now); + expect(row!.on_free_trial).toBe(true); + expect(row!.free_trial_ends_on).toBe("2026-09-04T00:00:00.000Z"); + expect(row!.status).toBe("active"); + }); + + it("survives a payload with no plan or dates", () => { + const bare: MarketplaceEventPayload = { + action: "purchased", + marketplace_purchase: { account: { id: 7, login: "solo" } }, + }; + const row = buildPurchaseRow("purchased", bare, now); + expect(row!.plan_id).toBeNull(); + expect(row!.effective_date).toBeNull(); + expect(row!.next_billing_date).toBeNull(); + expect(row!.on_free_trial).toBe(false); + }); +}); + +describe("currentPurchaseFor", () => { + it("falls back to marketplace_purchase when there is no previous one", () => { + const p = currentPurchaseFor("pending_change", PURCHASED); + expect(p.plan?.name).toBe("Pro Plan"); + }); +}); + +describe("isStaleEvent", () => { + it("rejects an event older than what was applied", () => { + expect(isStaleEvent("2026-08-21T00:00:00Z", "2026-08-20T00:00:00Z")).toBe(true); + }); + + it("allows a newer or equal event", () => { + expect(isStaleEvent("2026-08-21T00:00:00Z", "2026-08-22T00:00:00Z")).toBe(false); + // purchased and changed can share a timestamp; the later arrival wins. + expect(isStaleEvent("2026-08-21T00:00:00Z", "2026-08-21T00:00:00Z")).toBe(false); + }); + + it("does not block when either side is missing or unparseable", () => { + expect(isStaleEvent(null, "2026-08-21T00:00:00Z")).toBe(false); + expect(isStaleEvent("2026-08-21T00:00:00Z", null)).toBe(false); + expect(isStaleEvent("nonsense", "2026-08-21T00:00:00Z")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/github-marketplace.ts b/apps/web/src/lib/github-marketplace.ts new file mode 100644 index 0000000..9cdc269 --- /dev/null +++ b/apps/web/src/lib/github-marketplace.ts @@ -0,0 +1,231 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * GitHub Marketplace `marketplace_purchase` webhook. + * + * Docs: https://docs.github.com/en/apps/github-marketplace/listing-an-app-on-github-marketplace/configuring-a-webhook-to-notify-you-of-plan-changes + * + * Everything here is pure so it can be tested without a request or a + * database. The route is a thin shell over these functions. + */ + +export const MARKETPLACE_ACTIONS = [ + "purchased", + "changed", + "cancelled", + "pending_change", + "pending_change_cancelled", +] as const; + +export type MarketplaceAction = (typeof MARKETPLACE_ACTIONS)[number]; + +export function isMarketplaceAction(value: unknown): value is MarketplaceAction { + return (MARKETPLACE_ACTIONS as readonly string[]).includes(String(value)); +} + +export type MarketplacePlan = { + id?: number; + name?: string; + monthly_price_in_cents?: number; + yearly_price_in_cents?: number; +}; + +export type MarketplaceAccount = { + id?: number; + login?: string; + type?: string; + node_id?: string; + organization_billing_email?: string | null; +}; + +export type MarketplacePurchase = { + account?: MarketplaceAccount; + billing_cycle?: string | null; + unit_count?: number | null; + on_free_trial?: boolean | null; + free_trial_ends_on?: string | null; + next_billing_date?: string | null; + plan?: MarketplacePlan; +}; + +export type MarketplaceEventPayload = { + action?: string; + effective_date?: string; + sender?: { login?: string }; + marketplace_purchase?: MarketplacePurchase; + previous_marketplace_purchase?: MarketplacePurchase; +}; + +/** + * Verify X-Hub-Signature-256. + * + * The header is `sha256=` over the RAW request body, so the body must + * be read as text before any JSON parsing. Re-serialising a parsed object + * changes the bytes and the signature will never match. + * + * Returns false rather than throwing on every failure mode, including a + * missing secret: an unconfigured deployment must reject deliveries, not + * accept them. + */ +export function verifyGithubSignature( + rawBody: string, + signatureHeader: string | null | undefined, + secret: string | null | undefined, +): boolean { + if (!rawBody || !signatureHeader || !secret) return false; + + const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`; + + const a = Buffer.from(signatureHeader); + const b = Buffer.from(expected); + // timingSafeEqual throws on a length mismatch, which would itself leak + // the expected length through an exception path. Check first. + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Parse the body for either content type. + * + * JSON is what we configure and what GitHub recommends. The urlencoded + * form sends the same JSON as a single `payload` field, and is accepted + * here so that a mis-set content type in the listing UI degrades to a + * working webhook instead of a silent parse failure. + */ +export function parseWebhookBody( + rawBody: string, + contentType: string | null | undefined, +): MarketplaceEventPayload | null { + const type = (contentType ?? "").toLowerCase(); + try { + if (type.includes("application/x-www-form-urlencoded")) { + const encoded = new URLSearchParams(rawBody).get("payload"); + if (!encoded) return null; + return JSON.parse(encoded) as MarketplaceEventPayload; + } + return JSON.parse(rawBody) as MarketplaceEventPayload; + } catch { + return null; + } +} + +function toIso(value: unknown): string | null { + if (typeof value !== "string" || !value) return null; + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : new Date(ms).toISOString(); +} + +export type PurchaseRow = { + github_account_id: number; + github_account_login: string; + github_account_type: string | null; + github_account_node_id: string | null; + organization_billing_email: string | null; + plan_id: number | null; + plan_name: string | null; + plan_monthly_price_cents: number | null; + plan_yearly_price_cents: number | null; + billing_cycle: string | null; + unit_count: number | null; + on_free_trial: boolean; + free_trial_ends_on: string | null; + next_billing_date: string | null; + status: string; + pending_plan_id: number | null; + pending_plan_name: string | null; + pending_effective_date: string | null; + sender_login: string | null; + effective_date: string | null; + last_action: string; + updated_at: string; +}; + +/** + * Which purchase object describes the plan the customer is on RIGHT NOW. + * + * For `pending_change` the top-level marketplace_purchase is the plan that + * will take effect later, so the current plan is the previous one. Reading + * the wrong one here downgrades a customer the moment they schedule a + * downgrade, rather than at the end of their billing cycle. + */ +export function currentPurchaseFor( + action: MarketplaceAction, + payload: MarketplaceEventPayload, +): MarketplacePurchase { + if (action === "pending_change") { + return payload.previous_marketplace_purchase ?? payload.marketplace_purchase ?? {}; + } + return payload.marketplace_purchase ?? {}; +} + +/** + * Map a validated event onto the row we store. Returns null when the + * payload has no usable account id, which is the only field we cannot + * work without. + */ +export function buildPurchaseRow( + action: MarketplaceAction, + payload: MarketplaceEventPayload, + now: string, +): PurchaseRow | null { + const current = currentPurchaseFor(action, payload); + const account = current.account ?? payload.marketplace_purchase?.account ?? {}; + const accountId = typeof account.id === "number" ? account.id : null; + if (accountId === null) return null; + + const plan = current.plan ?? {}; + const effective = toIso(payload.effective_date); + + // pending_change carries the future plan at the top level; every other + // action either has no pending change or resolves one. + const incoming = payload.marketplace_purchase ?? {}; + const isPending = action === "pending_change"; + + return { + github_account_id: accountId, + github_account_login: account.login ?? String(accountId), + github_account_type: account.type ?? null, + github_account_node_id: account.node_id ?? null, + organization_billing_email: account.organization_billing_email ?? null, + plan_id: typeof plan.id === "number" ? plan.id : null, + plan_name: plan.name ?? null, + plan_monthly_price_cents: + typeof plan.monthly_price_in_cents === "number" ? plan.monthly_price_in_cents : null, + plan_yearly_price_cents: + typeof plan.yearly_price_in_cents === "number" ? plan.yearly_price_in_cents : null, + billing_cycle: current.billing_cycle ?? null, + unit_count: typeof current.unit_count === "number" ? current.unit_count : null, + on_free_trial: current.on_free_trial === true, + free_trial_ends_on: toIso(current.free_trial_ends_on), + next_billing_date: toIso(current.next_billing_date), + // A pending downgrade does not end the subscription, so only an actual + // `cancelled` event moves the row out of active. + status: action === "cancelled" ? "cancelled" : "active", + pending_plan_id: isPending && typeof incoming.plan?.id === "number" ? incoming.plan.id : null, + pending_plan_name: isPending ? (incoming.plan?.name ?? null) : null, + pending_effective_date: isPending ? effective : null, + sender_login: payload.sender?.login ?? null, + effective_date: effective, + last_action: action, + updated_at: now, + }; +} + +/** + * GitHub does not resend failed deliveries, but it does not guarantee + * order either, and a retry driven by us can arrive late. An event whose + * effective_date predates what we already applied must not overwrite it. + * + * Equal timestamps are allowed through: `purchased` and `changed` can + * share a second, and the later-arriving one is the one we want. + */ +export function isStaleEvent( + storedEffectiveDate: string | null | undefined, + incomingEffectiveDate: string | null | undefined, +): boolean { + if (!storedEffectiveDate || !incomingEffectiveDate) return false; + const stored = Date.parse(storedEffectiveDate); + const incoming = Date.parse(incomingEffectiveDate); + if (Number.isNaN(stored) || Number.isNaN(incoming)) return false; + return incoming < stored; +} diff --git a/supabase/migrations/20260821170000_github_marketplace.sql b/supabase/migrations/20260821170000_github_marketplace.sql new file mode 100644 index 0000000..838d46e --- /dev/null +++ b/supabase/migrations/20260821170000_github_marketplace.sql @@ -0,0 +1,100 @@ +-- GitHub Marketplace plan-change webhook. +-- +-- The Marketplace listing posts `marketplace_purchase` events to +-- /api/webhooks/github/marketplace whenever a customer buys, upgrades, +-- downgrades or cancels a plan. See +-- https://docs.github.com/en/apps/github-marketplace/listing-an-app-on-github-marketplace/configuring-a-webhook-to-notify-you-of-plan-changes +-- +-- Two tables, on purpose: +-- +-- github_marketplace_purchases the CURRENT subscription per GitHub +-- account. One row per account, upserted. +-- github_marketplace_events every delivery, raw. GitHub does not +-- resend failed deliveries, so the raw log +-- is the only way to replay a bad deploy. +-- +-- Both are written exclusively by the service role from the webhook route. +-- RLS is enabled with no policies, so anon and authenticated cannot read a +-- customer's billing state even if a query slips into client code. + +create table if not exists public.github_marketplace_purchases ( + id uuid primary key default gen_random_uuid(), + + -- account.id is GitHub's stable numeric id. The login can be renamed, + -- so the id is the key and the login is a display convenience. + github_account_id bigint not null unique, + github_account_login text not null, + github_account_type text, + github_account_node_id text, + organization_billing_email text, + + plan_id bigint, + plan_name text, + plan_monthly_price_cents integer, + plan_yearly_price_cents integer, + billing_cycle text, + unit_count integer, + on_free_trial boolean not null default false, + free_trial_ends_on timestamptz, + next_billing_date timestamptz, + + -- active | cancelled | pending_change + status text not null default 'active', + + -- Set by pending_change, cleared by pending_change_cancelled or by the + -- `changed` event that actually applies it. + pending_plan_id bigint, + pending_plan_name text, + pending_effective_date timestamptz, + + sender_login text, + + -- The effective_date of the most recent event applied to this row. + -- Guards against out-of-order delivery: an older event must not + -- overwrite a newer state. + effective_date timestamptz, + last_action text, + + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_gh_marketplace_purchases_login + on public.github_marketplace_purchases (github_account_login); + +create index if not exists idx_gh_marketplace_purchases_status + on public.github_marketplace_purchases (status); + +create table if not exists public.github_marketplace_events ( + id uuid primary key default gen_random_uuid(), + + -- X-GitHub-Delivery. Unique so a retry of the same delivery is a no-op + -- rather than a duplicate row. + delivery_id text unique, + + action text not null, + github_account_id bigint, + github_account_login text, + effective_date timestamptz, + + -- Whether this delivery changed the purchases row, and why not if it did + -- not. Makes "the webhook fired but nothing happened" answerable. + applied boolean not null default false, + skip_reason text, + + payload jsonb not null, + received_at timestamptz not null default now() +); + +create index if not exists idx_gh_marketplace_events_received + on public.github_marketplace_events (received_at desc); + +create index if not exists idx_gh_marketplace_events_account + on public.github_marketplace_events (github_account_id); + +alter table public.github_marketplace_purchases enable row level security; +alter table public.github_marketplace_events enable row level security; + +-- No policies: service role only. Deliberate. +revoke all on public.github_marketplace_purchases from anon, authenticated; +revoke all on public.github_marketplace_events from anon, authenticated;