diff --git a/docs/paystack-webhook.md b/docs/paystack-webhook.md new file mode 100644 index 0000000..86acccb --- /dev/null +++ b/docs/paystack-webhook.md @@ -0,0 +1,107 @@ +# Paystack webhook + +Server-side credit granting for payments where the browser callback +(`POST /api/verify-payment`) never runs — e.g. the buyer pays and then +closes the tab. Previously such credits were only recoverable manually via +Restore; the webhook grants them automatically. + +## Endpoint + +``` +POST https:///api/paystack-webhook +Content-Type: application/json +x-paystack-signature: +``` + +- The route is mounted with a raw-body parser **before** `express.json()`, + for this route only, so the exact bytes Paystack signed are verified. +- Signature = `HMAC-SHA512(rawBody, PAYSTACK_SECRET_KEY)`, compared with a + timing-safe compare (`server/secretCompare.js#timingSafeEqualString`). +- Missing or invalid signature → `400`. No `PAYSTACK_SECRET_KEY` configured → `503`. + +## Events handled + +| Paystack event | Behaviour | +|---|---| +| `charge.success` | Grant (see below) | +| `invoice.payment_succeeded` | Grant (see below) | +| any other event | `200 {ok:true, ignored:true}` — acknowledged so Paystack stops retrying | + +For grantable events the handler: + +1. Extracts `data.reference` and only accepts ContractCheq references + (`CC-…`, same pattern as `/api/verify-payment`); anything else is + `200 {ok:true, ignored:true}`. +2. **Re-verifies server-side** via the existing `verifyPaystack()` call + (`GET https://api.paystack.co/transaction/verify/`). + The webhook payload alone is never trusted. +3. Requires `data.status === 'success'`, `amount === PRICES[metadata.product]` + (single R19 / pack3 R39 / pack10 R99, in cents), and `currency === 'ZAR'`; + otherwise `402` and no grant. +4. Calls `store.saveGrant(reference, …)` with the verified customer email, + product, amount and credit count (1 / 3 / 10). `saveGrant` is write-once, + so replays never refill spent credits. +5. Returns `200 {ok:true, granted:true, grantId, product, credits, amount}`. + +Transient failures (Paystack verify error, storage unavailable on Vercel) +return `502`/`503` so Paystack retries delivery; permanent validation +failures return `400`/`402`. + +## Dedupe + +Each delivery is logged as `[paystack-webhook] {"event":…,"eventId":…,"reference":…}` +(event id / reference only — never bodies, emails, or signatures). +An in-memory set of processed event ids (falling back to +`:`, capped at 1000 entries) short-circuits exact replays +with `200 {ok:true, duplicate:true}`. This is best-effort on serverless +platforms (it does not survive cold starts); durability comes from the +write-once `saveGrant` described above. + +## Paystack dashboard setup (manual, once) + +> Do not point this at a live dashboard from automated tooling. Steps for a +> human with dashboard access: + +1. Paystack Dashboard → **Settings → API Keys & Webhooks** (or + **Developers → Webhooks**, wording varies by dashboard version). +2. **Add a new endpoint** with the webhook URL — the production origin + above, incl. the `/api/paystack-webhook` path. Use the live/test URL + matching the mode (`PAYSTACK_SECRET_KEY`) configured on the deployment. +3. Select events: **`charge.success`** and **`invoice.payment_succeeded`** + (leave others unselected unless needed). +4. Save. Confirm the endpoint shows as active. +5. Make a real (or Paystack test-mode) payment with a `CC-…` reference and + confirm the grant appears without the browser callback — e.g. complete + payment in a private window, close it immediately, then check + `GET /api/credits` with the `x-payment-reference` header, or retry + `/api/verify-payment` (Restore) and confirm the credit count is unchanged + (the webhook already granted it, exactly once). +6. If the endpoint ever shows repeated failures, check server logs for + `[paystack-webhook]` lines and the `Invalid webhook signature` `400`s — + those mean the deployment's `PAYSTACK_SECRET_KEY` does not match the + Paystack environment the endpoint was registered in. + +## Local smoke test (no Paystack interaction) + +Generate a signature over an arbitrary payload with the test secret and +POST it locally; a wrong-secret signature must yield `400`: + +```bash +SECRET=test-only +BODY='{"event":"charge.success","data":{"reference":"CC-local-smoke-0001"}}' +SIG=$(printf '%s' "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $NF}') +curl -i -X POST http://localhost:3000/api/paystack-webhook \ + -H 'Content-Type: application/json' -H "x-paystack-signature: $SIG" -d "$BODY" +``` + +With a non-matching deployment secret this returns `400` +`Invalid webhook signature`. A matching secret proceeds to server-side +Paystack verification (which will fail/`404` for a fake reference) — that +failure proves the signature check passed and the raw-body path is wired. + +## Tests + +`server/paystackWebhook.test.js` covers: valid signature grants, exact +replay dedupe, replay past dedupe cannot refill a spent credit, invalid +signature `400`, missing signature `400`, wrong amount `402` with no grant, +unsupported event ignored, and `invoice.payment_succeeded` granting pack3. diff --git a/server/index.js b/server/index.js index db9f544..2e5987e 100644 --- a/server/index.js +++ b/server/index.js @@ -16,7 +16,8 @@ const { validatePerspective } = require('./reportContext'); const DEMO_SAMPLES = require('./demoSamples'); const store = require('./store'); const { SITE_ORIGIN, canonicalHost } = require('./siteConfig'); -const { isBearerAuthorized } = require('./secretCompare'); +const { isBearerAuthorized, timingSafeEqualString } = require('./secretCompare'); +const crypto = require('node:crypto'); const PORT = Number(process.env.PORT) || 3000; const MAX_FILE_MB = process.env.VERCEL ? 4 : (Number(process.env.MAX_FILE_MB) || 12); @@ -38,6 +39,9 @@ app.use(canonicalHost); app.use(helmet({ contentSecurityPolicy: false })); app.use((_req,res,next)=>{res.set(require('./securityHeaders').securityHeaders());next();}); app.use('/api', (_req,res,next)=>{res.set('Cache-Control','no-store');next();}); +// Paystack webhook needs the exact raw bytes for HMAC-SHA512 verification, so +// mount the raw parser for this route only, BEFORE express.json consumes it. +app.use('/api/paystack-webhook', express.raw({ type: 'application/json', limit: '1mb' })); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: true })); @@ -461,6 +465,57 @@ app.post('/api/verify-payment', payLimiter, async (req,res,next)=> { } catch (e) { next(e); } }); +// In-memory webhook event dedupe (best-effort on serverless: the write-once +// store.saveGrant below is the durable idempotency guarantee across restarts). +const seenWebhookEvents = new Set(); +const MAX_SEEN_WEBHOOK_EVENTS = 1000; + +app.post('/api/paystack-webhook', async (req,res,next)=> { + try { + if (!PAYSTACK_SECRET) return res.status(503).json({ error: 'Payments are not configured.' }); + const signature = String(req.headers['x-paystack-signature'] || ''); + if (!signature) return res.status(400).json({ error: 'Missing webhook signature' }); + const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body || '')); + const expected = crypto.createHmac('sha512', PAYSTACK_SECRET).update(raw).digest('hex'); + if (!timingSafeEqualString(signature, expected)) return res.status(400).json({ error: 'Invalid webhook signature' }); + let payload; + try { payload = JSON.parse(raw.toString('utf8')); } catch (_) { return res.status(400).json({ error: 'Invalid webhook payload' }); } + const event = payload && typeof payload.event === 'string' ? payload.event : ''; + const data = payload && typeof payload.data === 'object' && payload.data ? payload.data : {}; + const eventId = payload && (payload.id ?? data.id) != null ? String(payload.id ?? data.id) : null; + const rawReference = String(data.reference || data.metadata?.reference || payload?.reference || ''); + // Log event id only (never payload bodies, emails, or signatures) for dedupe/audit. + console.log('[paystack-webhook]', JSON.stringify({ event, eventId, reference: rawReference || null })); + if (!['charge.success', 'invoice.payment_succeeded'].includes(event)) { + return res.json({ ok: true, ignored: true, event: event || null }); + } + const dedupeKey = eventId || (rawReference ? `${event}:${rawReference}` : null); + if (dedupeKey && seenWebhookEvents.has(dedupeKey)) return res.json({ ok: true, duplicate: true }); + if (!/^CC-[A-Za-z0-9-]{10,100}$/.test(rawReference)) { + return res.json({ ok: true, ignored: true, reason: 'non-contractcheck reference' }); + } + const payProbe = await storageProbe(); + if (!payProbe.writable && process.env.VERCEL) return res.status(503).json({ error: 'Payments are unavailable until report storage is restored. Please try again later.' }); + // Never trust the webhook payload alone: re-verify server-side with Paystack. + const body = await verifyPaystack(rawReference); + const verified = body.data || {}; + if (verified.status !== 'success') return res.status(402).json({ error: 'Payment not successful' }); + const purchased = verified.metadata?.product; + if (!Object.hasOwn(PRICES, purchased) || Number(verified.amount) !== PRICES[purchased] || verified.currency !== 'ZAR') { + return res.status(402).json({ error: 'Payment product mismatch' }); + } + const customer = String(verified.customer?.email || '').toLowerCase().trim(); + if (!customer) return res.status(402).json({ error: 'Payment customer email missing' }); + const grant = await store.saveGrant(rawReference, { email: customer, product: purchased, amount: Number(verified.amount), credits: purchased === 'pack10' ? 10 : purchased === 'pack3' ? 3 : 1 }); + if (dedupeKey) { + seenWebhookEvents.add(dedupeKey); + if (seenWebhookEvents.size > MAX_SEEN_WEBHOOK_EVENTS) seenWebhookEvents.delete(seenWebhookEvents.values().next().value); + } + await trackSafely('payment_verified', { product: purchased, amount: Number(verified.amount) }); + res.json({ ok: true, granted: true, grantId: rawReference, product: purchased, credits: grant.remaining, amount: Number(verified.amount) }); + } catch (e) { next(e); } +}); + app.post('/api/analyze', uploadLimiter, upload.single('file'), async (req, res) => { const file = req.file; if (!file) return res.status(400).json({ error: 'No file uploaded' }); diff --git a/server/paystackWebhook.test.js b/server/paystackWebhook.test.js new file mode 100644 index 0000000..35826fc --- /dev/null +++ b/server/paystackWebhook.test.js @@ -0,0 +1,113 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +for (const key of ['VERCEL', 'BLOB_STORE_ID', 'BLOB_READ_WRITE_TOKEN', 'SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_TABLE']) delete process.env[key]; +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-webhook-')); +process.env.PAYSTACK_SECRET_KEY = 'test-webhook-secret'; +const app = require('./index'); +const store = require('./store'); + +const SECRET = 'test-webhook-secret'; +const sign = (raw) => crypto.createHmac('sha512', SECRET).update(raw).digest('hex'); + +test('Paystack webhook: HMAC-verified, idempotent grants', async () => { + const server = app.listen(0); + await new Promise((r) => server.once('listening', r)); + const base = 'http://127.0.0.1:' + server.address().port; + const realFetch = global.fetch; + // Mutable verified-transaction fixture so the wrong-amount case can diverge. + let verified = { + status: 'success', amount: 1900, currency: 'ZAR', + customer: { email: 'buyer@example.test' }, metadata: { product: 'single' }, + }; + let verifyCalls = 0; + const post = async (raw, signature) => { + const headers = { 'Content-Type': 'application/json' }; + if (signature !== undefined) headers['x-paystack-signature'] = signature; + const r = await realFetch(base + '/api/paystack-webhook', { method: 'POST', headers, body: raw }); + return { status: r.status, body: await r.json() }; + }; + const payloadFor = (reference, event = 'charge.success') => JSON.stringify({ + event, data: { reference, amount: 1900, currency: 'ZAR', customer: { email: 'buyer@example.test' }, metadata: { product: 'single' } }, + }); + try { + global.fetch = async (url, options) => { + if (String(url).startsWith('https://api.paystack.co/')) { + verifyCalls++; + return { ok: true, json: async () => ({ status: true, data: verified }) }; + } + return realFetch(url, options); + }; + + // 1. Valid signature grants credits. + const goodRaw = payloadFor('CC-webhook-grant-0001'); + const good = await post(goodRaw, sign(goodRaw)); + assert.equal(good.status, 200, JSON.stringify(good.body)); + assert.equal(good.body.granted, true); + assert.equal(good.body.credits, 1); + assert.equal(good.body.amount, 1900); + assert.equal((await store.getGrant('CC-webhook-grant-0001')).remaining, 1); + + // 2. Replay (same event) is idempotent: duplicate flag, no refill, no extra verify call needed for grant. + const callsBeforeReplay = verifyCalls; + const replay = await post(goodRaw, sign(goodRaw)); + assert.equal(replay.status, 200); + assert.equal(replay.body.duplicate, true); + assert.equal((await store.getGrant('CC-webhook-grant-0001')).remaining, 1); + assert.equal(verifyCalls, callsBeforeReplay, 'dedupe should short-circuit before re-verify'); + + // 2b. Replay past the in-memory dedupe (fresh event id, same reference) still + // cannot refill: spend the credit first, then the write-once saveGrant must + // return the existing grant with remaining=0, not a fresh 1. + await store.useCredit('CC-webhook-grant-0001', 'report-spend-1'); + const replay2Raw = JSON.stringify({ event: 'charge.success', id: 999001, data: { reference: 'CC-webhook-grant-0001', amount: 1900, currency: 'ZAR', metadata: { product: 'single' } } }); + const replay2 = await post(replay2Raw, sign(replay2Raw)); + assert.equal(replay2.status, 200); + assert.equal(replay2.body.granted, true); + assert.equal(replay2.body.credits, 0, 'write-once grant must not refill a spent credit'); + assert.equal((await store.getGrant('CC-webhook-grant-0001')).remaining, 0); + + // 3. Invalid signature -> 400, no grant. + const badRaw = payloadFor('CC-webhook-badsig-0001'); + const bad = await post(badRaw, '0'.repeat(128)); + assert.equal(bad.status, 400); + assert.equal(await store.getGrant('CC-webhook-badsig-0001'), null); + + // 4. Missing signature -> 400, no grant. + const missing = await post(payloadFor('CC-webhook-nosig-0001')); + assert.equal(missing.status, 400); + assert.equal(await store.getGrant('CC-webhook-nosig-0001'), null); + + // 5. Wrong amount (server-side verify disagrees with PRICES) -> 402, no grant. + verified = { ...verified, amount: 100 }; + const wrongRaw = payloadFor('CC-webhook-wrongamt-01'); + const wrong = await post(wrongRaw, sign(wrongRaw)); + assert.equal(wrong.status, 402); + assert.match(wrong.body.error, /product mismatch/); + assert.equal(await store.getGrant('CC-webhook-wrongamt-01'), null); + verified = { status: 'success', amount: 1900, currency: 'ZAR', customer: { email: 'buyer@example.test' }, metadata: { product: 'single' } }; + + // 6. Unsupported event is acknowledged (200, ignored) so Paystack stops retrying it. + const otherRaw = payloadFor('CC-webhook-other-ev-001', 'transfer.success'); + const other = await post(otherRaw, sign(otherRaw)); + assert.equal(other.status, 200); + assert.equal(other.body.ignored, true); + assert.equal(await store.getGrant('CC-webhook-other-ev-001'), null); + + // 7. invoice.payment_succeeded also grants (pack3 pricing via server verify). + verified = { status: 'success', amount: 3900, currency: 'ZAR', customer: { email: 'buyer@example.test' }, metadata: { product: 'pack3' } }; + const invRaw = JSON.stringify({ event: 'invoice.payment_succeeded', data: { reference: 'CC-webhook-invoice-001', amount: 3900, currency: 'ZAR', metadata: { product: 'pack3' } } }); + const inv = await post(invRaw, sign(invRaw)); + assert.equal(inv.status, 200, JSON.stringify(inv.body)); + assert.equal(inv.body.granted, true); + assert.equal(inv.body.credits, 3); + } finally { + global.fetch = realFetch; + server.close(); + fs.rmSync(process.env.DATA_DIR, { recursive: true, force: true }); + } +});