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