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>
This commit is contained in:
71
server/app/adapters/base.py
Normal file
71
server/app/adapters/base.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""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."""
|
||||
...
|
||||
Reference in New Issue
Block a user