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>
104 lines
3.6 KiB
Python
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}
|