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:
136
server/app/mint.py
Normal file
136
server/app/mint.py
Normal 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
|
||||
Reference in New Issue
Block a user