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>
72 lines
2.3 KiB
Python
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."""
|
|
...
|