# v0.26.0 — Cutover guide

> **Audience:** consumer agents (and humans relaying for them)
> upgrading to `mgf-common` 0.26.0.
> **What this is:** the canonical migration document for this
> release. Per [`RELEASING.md`](../standards/RELEASING.md) **REL-09**
> + the cutover-guide pattern, individual per-entry notification
> paragraphs are pointers into this guide.

This release closes one paper: **PAPER-26 — Svix-shape HMAC webhook
verification primitive**. Filed by PlasmaMapper after duplicating the
verification dance across `MockAuthProvider` + `ClerkAuthProvider` in
its Phase-3 work; promoted to mgf-common before three web consumers
diverge on the implementation.

The release also lands the v0.25.0-deferred recipe index at
[`docs/recipes/README.md`](../recipes/README.md).

---

## At-a-glance

| Entry | Severity | What landed | Code or docs? |
|---|---|---|---|
| [PAPER-26](#paper-26--hmac-webhook-verifier) | 🟡 | New `mgf.common.fastapi.webhooks` subpackage. New `HmacVerificationError(HttpUnauthorized)` exception. Recipe at `docs/recipes/webhooks.md`. | code + docs |

**Pin bump:** `mgf-common = ">=0.26,<0.27"` per the 0.x window
discipline (one minor at a time; AP-03).

**Public-Python-surface change:** four new public names under
`mgf.common.fastapi.webhooks` (`WebhookHeaderSchema`, `SVIX_SCHEMA`,
`HmacWebhookVerifier`, `verify_request`). One new public name in
`mgf.common.exceptions` (`HmacVerificationError`).

**Behaviour change:** none. Strictly additive — every existing call
site is unaffected.

**No deprecation cycles introduced.**

---

## PAPER-26 — HMAC webhook verifier

Every web consumer of mgf-common writes the same Svix-style HMAC
verification: read `<svix-id>` / `<svix-timestamp>` /
`<svix-signature>`, validate the timestamp against a 5-minute
replay window, compute `HMAC-SHA256(<id>.<ts>.<body>)`, base64,
constant-time-compare. v0.26 ships it once.

### What changed

New public surface in `mgf.common.fastapi.webhooks`:

```python
from mgf.common.fastapi.webhooks import (
    HmacWebhookVerifier,    # transport-agnostic core
    WebhookHeaderSchema,    # frozen dataclass naming the 3 headers
    SVIX_SCHEMA,            # pre-built schema for Svix headers
    verify_request,         # async FastAPI Request adapter
)
```

New public exception in `mgf.common.exceptions`:

```python
from mgf.common.exceptions import HmacVerificationError
# inherits HttpUnauthorized → http_status = 401
```

### The minimum working integration

```python
from fastapi import FastAPI, Request
from mgf.common.fastapi import ExceptionTranslationMiddleware
from mgf.common.fastapi.webhooks import HmacWebhookVerifier, verify_request

app = FastAPI()
app.add_middleware(ExceptionTranslationMiddleware)

verifier = HmacWebhookVerifier(secret=settings.webhook_secret)

@app.post("/webhooks/clerk")
async def clerk_webhook(request: Request) -> dict:
    event_id, ts = await verify_request(request, verifier)
    payload = await request.json()
    # ... process the (now-trusted) payload ...
    return {"ok": True}
```

`HmacVerificationError` inherits `HttpUnauthorized` so
`ExceptionTranslationMiddleware` (post-PAPER-24) maps it to 401 with
no explicit `status_map` entry.

### Migration — replacing a per-consumer verifier

For PlasmaMapper specifically (filing consumer):

**Before** — `src/plasmamapper/providers/auth/clerk.py`:

```python
def _verify_svix_signature(self, headers, body):
    sig_header = headers.get("svix-signature")
    if not sig_header:
        raise InvalidWebhookSignatureError("svix-signature header missing")
    ts_header = headers.get("svix-timestamp")
    if not ts_header:
        raise InvalidWebhookSignatureError("svix-timestamp header missing")
    msg_id = headers.get("svix-id")
    if not msg_id:
        raise InvalidWebhookSignatureError("svix-id header missing")
    # ... ~40 more lines of replay-window check, hmac compute,
    #     base64 encode, compare_digest, multi-version handling ...
```

**After** — same file:

```python
from mgf.common.fastapi.webhooks import HmacWebhookVerifier

class ClerkAuthProvider(...):
    def __init__(self, *, webhook_secret: bytes | str, ...):
        self._webhook_verifier = HmacWebhookVerifier(secret=webhook_secret)

    def _verify_svix_signature(self, headers, body):
        # Throws HmacVerificationError on any failure; caller catches
        # and rebrands to InvalidWebhookSignatureError if it wants the
        # legacy exception type, or just lets it propagate (recommended —
        # HTTP-01 + ExceptionTranslationMiddleware map to 401 cleanly).
        return self._webhook_verifier.verify(headers, body)
```

`MockAuthProvider`'s parallel verifier deletes the same way. The two
copies stop drifting because there's now one implementation under the
mgf-common closed-box discipline (security review, exhaustive failure-
mode tests, constant-time comparison).

### Multi-version signatures (key rotation)

Svix sends `v1,sigA v1,sigB` space-separated during key rotation. The
verifier accepts any candidate matching the configured prefix —
existing callers don't need to opt in:

```python
# This works the same as a single-version sig:
headers = {"svix-signature": "v1,old_sig v1,new_sig", ...}
verifier.verify(headers, body)   # accepts if EITHER matches
```

### Custom schemas

`SVIX_SCHEMA` is the only built-in. Stripe / GitHub / Slack use
related-but-distinct shapes (combined headers, hex encoding, alternate
prefixes); they're not shipped at v0.26. If you have a non-Svix
consumer with similar shape, build a custom schema:

```python
from mgf.common.fastapi.webhooks import HmacWebhookVerifier, WebhookHeaderSchema

CUSTOM_SCHEMA = WebhookHeaderSchema(
    id_header="x-event-id",
    timestamp_header="x-event-ts",
    signature_header="x-event-sig",
)

verifier = HmacWebhookVerifier(
    secret=secret,
    schema=CUSTOM_SCHEMA,
    signature_prefix="v1=",   # if your provider uses ``v1=`` not ``v1,``
)
```

If your provider's shape doesn't fit (combined timestamp+sig in one
header, hex instead of base64, no version prefix at all), file a
follow-up paper — that's the signal we're waiting for to ship the
next preset.

### What's deliberately NOT in v0.26

Filed for follow-up papers when a second consumer asks:

- **Replay-cache hook** — Stripe recommends rejecting same-event-id
  within the replay window (defense beyond the timestamp check).
  Adds a pluggable storage interface.
- **Stripe / GitHub / Slack presets** — different signature shapes;
  need design work before they fit one schema.
- **`HmacVerificationError` reason codes** — the message gives the
  proximate cause as prose; structured fields (per rule EH-08)
  could let callers distinguish "missing header" from "stale ts" from
  "sig mismatch" without parsing the message.

---

## Per-consumer migration notes

### vm-vmanager-app (Qt desktop)

**No changes required.** This release is web-domain; doesn't touch
your project's surface. Bump pin to `mgf-common = ">=0.26,<0.27"`.

### inthewords (Django web)

**No code-side migration.** Django webhook integrations don't go
through FastAPI middleware, but `HmacWebhookVerifier` itself is
transport-agnostic — drop the import into a Django view if you ever
add a webhook endpoint. Bump pin to `mgf-common = ">=0.26,<0.27"`.

### PlasmaMapper (FastAPI scientific platform — filing consumer)

**You are the migration consumer.** Phase-3 webhook code in
`src/plasmamapper/providers/auth/clerk.py` + `mock.py` deduplicates
to use `HmacWebhookVerifier`. Steps:

1. Bump pin to `mgf-common = ">=0.26,<0.27"`.
2. In each provider's `__init__`, replace the secret-bytes-stash with
   `self._webhook_verifier = HmacWebhookVerifier(secret=webhook_secret)`.
3. Replace `_verify_svix_signature` body with
   `return self._webhook_verifier.verify(headers, body)`.
4. Decide on the exception story: keep the
   `InvalidWebhookSignatureError → HmacVerificationError` translation
   in your provider, or delete it and let HTTP-01 +
   `ExceptionTranslationMiddleware` give you 401 directly.
5. Remove the bespoke replay-window / hmac / base64 / compare_digest
   code (~40 lines per provider).
6. Re-run your golden-paths Playwright suite — the wire shape
   doesn't change so this should be green.

If your migration surfaces a missing knob on `HmacWebhookVerifier`,
file as a follow-up paper.

---

## Engineering note

PAPER-26 is the first **security-primitive** paper closure. The
shape (transport-agnostic core + thin framework adapter; force-
lowercase header lookup; constant-time comparison; multi-version
support; replay-window; structured error inheriting HTTP-01) is
the template for future security primitives — likely PAPER-27
(`IpAllowlist`) and any future `JwtVerifier` etc. The closed-box
test surface (25 tests covering every failure mode independently)
is what makes mgf-common worth using over the consumer's
copy-pasted version.
