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:
2026-05-14 00:46:54 +00:00
parent 4179cb5156
commit bab2c9468c
29 changed files with 1519 additions and 0 deletions

136
server/app/mint.py Normal file
View File

@@ -0,0 +1,136 @@
"""Core mint + revoke logic.
Bridges the source-adapter layer (:mod:`app.adapters`) to the DB
layer (:mod:`app.models`), reusing the desktop app's signing /
encoding primitives from ``datatools_license.crypto`` so blobs minted
here verify against the same embedded pubkey on the buyer's machine.
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.adapters.base import SaleEvent
from app.config import get_settings
from app.models import License
def _init_key_env() -> None:
"""Resolve secret-file pointers into env vars before importing crypto.
``datatools_license.crypto`` looks for ``DATATOOLS_LICENSE_PRIVKEY``
/ ``DATATOOLS_LICENSE_PUBKEY`` in ``os.environ``. When those come
from secret files (``*_FILE`` env vars), we read them once at
module import and stash so crypto can pick them up without
changes.
"""
settings = get_settings()
priv = settings.resolve_license_privkey()
if priv:
os.environ.setdefault("DATATOOLS_LICENSE_PRIVKEY", priv)
pub = settings.license_pubkey_hex
if pub:
os.environ.setdefault("DATATOOLS_LICENSE_PUBKEY", pub)
_init_key_env()
# Imported after env init so the crypto module reads the correct key.
from datatools_license.crypto import encode_blob, sign # noqa: E402
from datatools_license.features import all_features_for_tier # noqa: E402
from datatools_license.schema import ( # noqa: E402
License as LicenseDataclass,
Tier,
_utcnow_iso,
default_expiry_iso,
)
def _generate_license_key(tier: str) -> str:
rid = uuid.uuid4().hex
return f"DT1-{tier.upper()}-{rid[:8]}-{rid[8:16]}"
def _iso_to_dt(iso: str) -> datetime:
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
def mint_from_sale(session: Session, sale: SaleEvent) -> License:
"""Idempotently mint a license for *sale*.
If a row with the same ``(source, source_order_id)`` already
exists, return it untouched — Gumroad retrying a webhook does not
produce a second blob with a different signature. Manual mints
(``source_order_id is None``) skip the dedup check and always
produce a new row.
"""
if sale.source_order_id is not None:
existing = session.execute(
select(License).where(
License.source == sale.source,
License.source_order_id == sale.source_order_id,
)
).scalar_one_or_none()
if existing is not None:
return existing
tier_enum = Tier(sale.tier)
license_key = _generate_license_key(sale.tier)
issued_iso = _utcnow_iso()
expires_iso = default_expiry_iso(years=sale.years)
unsigned = LicenseDataclass(
name=sale.buyer_name,
email=sale.buyer_email,
license_key=license_key,
tier=tier_enum,
features=all_features_for_tier(tier_enum),
issued_at=issued_iso,
expires_at=expires_iso,
signature="",
)
signature = sign(unsigned.to_canonical_dict())
payload = unsigned.to_canonical_dict()
payload["signature"] = signature
blob = encode_blob(payload)
row = License(
license_key=license_key,
name=sale.buyer_name,
email=sale.buyer_email,
tier=sale.tier,
issued_at=_iso_to_dt(issued_iso),
expires_at=_iso_to_dt(expires_iso),
blob=blob,
source=sale.source,
source_order_id=sale.source_order_id,
promotion=sale.promotion,
amount_paid=sale.amount_paid,
currency=sale.currency,
notes=sale.notes,
)
session.add(row)
session.flush()
return row
def revoke_license(
session: Session,
*,
license_key: str,
reason: Optional[str] = None,
) -> Optional[License]:
row = session.get(License, license_key)
if row is None:
return None
row.revoked_at = datetime.now(timezone.utc)
if reason:
suffix = f"\nRevoked: {reason}"
row.notes = ((row.notes or "") + suffix).strip()
return row