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:
0
server/app/routes/__init__.py
Normal file
0
server/app/routes/__init__.py
Normal file
103
server/app/routes/internal.py
Normal file
103
server/app/routes/internal.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Internal (operator-only) routes.
|
||||
|
||||
Two defense layers protect this path:
|
||||
|
||||
1. **nginx** blocks ``/internal/*`` at the public server-block level
|
||||
(``location /internal/ { return 404; }`` in
|
||||
``docs/SETUP-LICENSE-SERVER.md``).
|
||||
2. **Bearer token** authenticates the operator's CLI.
|
||||
|
||||
An earlier draft also enforced a peer-IP loopback check here, but
|
||||
that fights the Docker bridge network model: the container always
|
||||
sees the gateway IP (172.x.0.1) regardless of whether traffic
|
||||
originated from nginx on the host or from outside. The check is
|
||||
preserved as :func:`app.auth.require_localhost` for future use
|
||||
(e.g. if the API ever runs in ``network_mode: host``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.adapters.manual import ManualAdapter
|
||||
from app.auth import require_bearer_token
|
||||
from app.db import get_session
|
||||
from app.mint import mint_from_sale, revoke_license
|
||||
from app.models import License
|
||||
from app.schemas import LicenseResponse, MintRequest, RevokeRequest
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/internal",
|
||||
dependencies=[Depends(require_bearer_token)],
|
||||
)
|
||||
|
||||
_MANUAL = ManualAdapter()
|
||||
|
||||
|
||||
@router.post("/mint", response_model=LicenseResponse, status_code=status.HTTP_201_CREATED)
|
||||
def mint(req: MintRequest, session: Session = Depends(get_session)) -> License:
|
||||
"""Mint a license blob and persist the row.
|
||||
|
||||
PR 1 only wires the ``manual`` source through this endpoint. Real
|
||||
storefront sales (Gumroad et al.) arrive via per-source webhook
|
||||
handlers in PR 2 and bypass this route entirely.
|
||||
"""
|
||||
if req.source != "manual":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"Source {req.source!r} is not wired for direct mints. "
|
||||
"Storefront sales arrive via /webhooks/* (PR 2)."
|
||||
),
|
||||
)
|
||||
sale = _MANUAL.build_sale(
|
||||
name=req.name,
|
||||
email=req.email,
|
||||
tier=req.tier.value,
|
||||
years=req.years,
|
||||
promotion=req.promotion,
|
||||
amount_paid=req.amount_paid,
|
||||
currency=req.currency,
|
||||
notes=req.notes,
|
||||
)
|
||||
return mint_from_sale(session, sale)
|
||||
|
||||
|
||||
@router.post("/revoke", response_model=LicenseResponse)
|
||||
def revoke(req: RevokeRequest, session: Session = Depends(get_session)) -> License:
|
||||
row = revoke_license(session, license_key=req.license_key, reason=req.reason)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="License not found")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/licenses", response_model=list[LicenseResponse])
|
||||
def list_licenses(
|
||||
email: Optional[str] = Query(default=None, description="Case-insensitive email substring."),
|
||||
tier: Optional[str] = Query(default=None),
|
||||
source: Optional[str] = Query(default=None),
|
||||
include_revoked: bool = Query(default=False),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[License]:
|
||||
stmt = select(License).order_by(License.created_at.desc()).limit(limit).offset(offset)
|
||||
if email:
|
||||
stmt = stmt.where(func.lower(License.email).contains(email.lower()))
|
||||
if tier:
|
||||
stmt = stmt.where(License.tier == tier)
|
||||
if source:
|
||||
stmt = stmt.where(License.source == source)
|
||||
if not include_revoked:
|
||||
stmt = stmt.where(License.revoked_at.is_(None))
|
||||
return list(session.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def ping() -> dict:
|
||||
"""Sanity-check both guards from inside an SSH tunnel."""
|
||||
return {"ok": True}
|
||||
27
server/app/routes/public.py
Normal file
27
server/app/routes/public.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Public (internet-facing) routes.
|
||||
|
||||
For PR 1: only ``/health``. The webhook receiver and renewal portal
|
||||
land in PR 2 and PR 3 respectively.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import get_session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(session: Session = Depends(get_session)) -> dict:
|
||||
"""Liveness + DB reachability. Cheap; safe to hit on a tight cadence."""
|
||||
db_ok = True
|
||||
try:
|
||||
session.execute(text("SELECT 1"))
|
||||
except SQLAlchemyError:
|
||||
db_ok = False
|
||||
return {"status": "ok" if db_ok else "degraded", "db": "ok" if db_ok else "error"}
|
||||
Reference in New Issue
Block a user