Files
datatools-dev/server/app/routes/internal.py
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

104 lines
3.6 KiB
Python

"""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}