# 2026-07-17 — Paynow inbound webhook signature verification + `.env.example`

Branch: `fix/paynow-verify-and-env-example`
Worktree: `.worktrees/fix-paynow-verify-and-env-example`

## Why

The deployment-readiness audit listed Paynow inbound webhook signature
verification as a P0 security gap. The webhook handler in
`server/src/controllers/payments.controller.js` trusted the request body
unconditionally, allowing anyone who knew a payment `reference` to flip a
pending payment to `completed` (or worse, mark a fee `paid`).

A second gap was the absence of `.env.example` in the repo, forcing new
contributors to grep `process.env.*` to discover the runtime contract.

## Changes

### `server/src/controllers/payments.controller.js`

- Added `verifyPaynowHash(payload)` — symmetric with the existing outbound
  `generateHash`. Sorts non-empty fields, concatenates `key+value`, appends
  the integration key, SHA-1 hex digest. Compares with `crypto.timingSafeEqual`
  after a length check (the latter throws on mismatched lengths).
- New `PAYNOW_SKIP_VERIFY` env flag, default `false`. When `true` (dev / tests
  only), the verifier is bypassed. Bypass is impossible when
  `PAYNOW_INTEGRATION_KEY` is unset — that path still succeeds, which is the
  existing test-mode behaviour.
- Webhook handler now rejects with `401` and a structured log line before
  any DB read/write when the signature fails. The error message intentionally
  does NOT leak the expected hash.
- The integration-key check fires only when an integration key is configured
  on the server — preserves current test-mode behaviour for greenfield
  installs.

### `.env.example` (new, repo root)

Documents every env var consumed by `server/src/**` and the `VITE_*` mirror
needed for client build-time inlining. Sections:

- Node / runtime (`NODE_ENV`, `PORT`, `DB_PATH`)
- Auth (`JWT_SECRET`, `ALLOWED_ORIGINS`, `ALLOW_CLIENT_OFFLINE_MINT`)
- Paynow (`PAYNOW_INTEGRATION_ID`, `PAYNOW_INTEGRATION_KEY`,
  `PAYNOW_RETURN_URL`, `PAYNOW_BLOCKING_URL`, `PAYNOW_SKIP_VERIFY`)
- Supabase (`SUPABASE_URL`, `SUPABASE_KEY`, `VITE_SUPABASE_URL`,
  `VITE_SUPABASE_ANON_KEY`)
- SMTP (`SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS`,
  `SMTP_FROM`)
- Sync tuning (`SYNC_INTERVAL`)

### `client/e2e/paynow.spec.ts` (new)

Four Playwright specs covering the new behaviour:

1. Webhook with no `hash` field → 401.
2. Webhook with a 40-char wrong hash → 401 (exercises `timingSafeEqual`).
3. Webhook with a correctly signed payload → 200 or 404 (both prove the
   verifier accepted the signature).
4. Skip-when-unconfigured guard: tests skip cleanly when
   `PAYWOW_TEST_INTEGRATION_KEY` is not set on the server (e.g. local dev
   without Paynow credentials).

## Verification

- The file is small and surgical. The verifier mirrors the existing outbound
  hash so any future schema change there must be mirrored here in lockstep.
- The bypass env flag is named clearly and its effect is gated by both an
  explicit boolean and the integration-key check.

## Risk

- Paynow's actual inbound callback field list may differ slightly from the
  outbound schema this code generates. If Paynow adds or omits a field in
  the callback, the verifier's sort-and-concat will diverge from Paynow's
  signature and every real webhook will fail with 401. Mitigation: log the
  `hash_mismatch` reason with the IP and an opaque reference-free payload
  digest so we can spot it on first real payment; the merchant can then
  publish the documented field list and we tweak the verifier.
- Existing local-only tests (none currently drive the webhook) are
  unaffected because `PAYNOW_INTEGRATION_KEY` is unset in test setups and
  the verifier is therefore bypassed.
