Files
Michael bab2c9468c feat(server): mint API + Postgres schema + manual adapter (PR 1)
Source-agnostic license issuance service. FastAPI app fronts a
Postgres `licenses` table; the only currently-wired source is
`manual` (operator mints via /internal/mint). Gumroad webhook
adapter lands in PR 2.

Key design points:

- Signing reuses src/license/crypto.py via a COPY into the image
  (single source of truth — blobs minted server-side verify against
  the same embedded pubkey on the buyer's machine).
- Source adapter Protocol (app/adapters/base.py) is the seam for
  Gumroad / Lemon Squeezy / Stripe in later PRs; Mint API speaks
  only SaleEvent / RefundEvent.
- (source, source_order_id) UNIQUE composite gives idempotent
  webhook retries without double-mint.
- JSONB type uses with_variant(JSON, 'sqlite') so the same models
  drive both Postgres prod and SQLite tests (no testcontainers dep).
- Bearer-token auth on /internal/*; the IP-loopback guard was
  removed after the docker bridge made it fight legitimate prod
  traffic (nginx defense + Bearer remain).
- Secrets resolved via *_FILE env vars pointing at
  /run/secrets/<name>, so passwords never appear in `docker inspect`.

21 unit tests (SQLite in-memory, StaticPool) plus a real-Postgres
docker-compose smoke test in server/scripts/smoke.sh that builds the
image, runs the alembic migration, mints a license, verifies the
signature against the host dev pubkey, and checks the DB row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:46:54 +00:00

72 lines
2.3 KiB
Python

"""Source-adapter interface.
The Mint API speaks only the normalized event types defined here.
Each storefront has its own adapter that:
- Verifies the storefront's webhook signature in its native format.
- Parses the storefront's payload into a :class:`SaleEvent` or
:class:`RefundEvent`.
- Maps the storefront's product/variant IDs to a license tier via
the per-source config in :mod:`app.adapters.config`.
Adding a new source (Lemon Squeezy, Stripe, Paddle) is one new
module that implements :class:`SourceAdapter`. The Mint API and DB
do not change.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any, Optional, Protocol
@dataclass(frozen=True)
class SaleEvent:
"""A storefront sale, normalized.
The Mint API consumes this directly — it never reaches into the
raw storefront payload. Anything storefront-specific that's worth
keeping is preserved in :attr:`raw_payload` for audit.
"""
source: str # e.g. "gumroad", "manual"
source_order_id: Optional[str] # storefront's order ID; None for manual mints
buyer_name: str
buyer_email: str
tier: str # mapped from product/variant
years: int = 1
promotion: Optional[str] = None
amount_paid: Optional[Decimal] = None
currency: Optional[str] = "USD"
notes: Optional[str] = None
raw_payload: dict = field(default_factory=dict)
@dataclass(frozen=True)
class RefundEvent:
"""A storefront refund — marks an existing license revoked."""
source: str
source_order_id: str
reason: Optional[str] = None
raw_payload: dict = field(default_factory=dict)
class SourceAdapter(Protocol):
"""Interface every storefront adapter implements."""
source_name: str
def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool:
"""Return True iff the request came from the legitimate storefront."""
...
def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]:
"""Return a :class:`SaleEvent` if *payload* is a sale, else None."""
...
def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]:
"""Return a :class:`RefundEvent` if *payload* is a refund, else None."""
...