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

16
server/.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
**/__pycache__
**/*.pyc
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
.git
.venv
venv
docs
landing
marketing
samples
test-cases
tests
logs
build

37
server/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.6
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --system --create-home --shell /usr/sbin/nologin --uid 10001 app
WORKDIR /app
COPY server/requirements.txt /app/requirements.txt
RUN pip install -r /app/requirements.txt
# Reused crypto / schema logic from the desktop app — single source of truth.
COPY src/license /app/datatools_license
COPY server/app /app/app
COPY server/alembic /app/alembic
COPY server/alembic.ini /app/alembic.ini
RUN chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD curl --fail --silent --show-error http://localhost:8000/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips", "*"]

38
server/alembic.ini Normal file
View File

@@ -0,0 +1,38 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

46
server/alembic/env.py Normal file
View File

@@ -0,0 +1,46 @@
"""Alembic environment.
Reads the runtime database URL from ``app.db`` (which resolves the
password from the secrets file), so ``alembic upgrade head`` Just
Works inside the API container with no extra env wiring.
"""
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from app.db import Base, engine
from app import models # noqa: F401 — imported for side-effect of registering models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=str(engine.url),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
with engine.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,80 @@
"""Initial schema — licenses + gumroad_events.
Revision ID: 0001_initial
Revises:
Create Date: 2026-05-14
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"licenses",
sa.Column("license_key", sa.String(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("email", sa.String(), nullable=False),
sa.Column("tier", sa.String(), nullable=False),
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("blob", sa.String(), nullable=False),
sa.Column("source", sa.String(), nullable=False),
sa.Column("source_order_id", sa.String(), nullable=True),
sa.Column("promotion", sa.String(), nullable=True),
sa.Column("amount_paid", sa.Numeric(10, 2), nullable=True),
sa.Column("currency", sa.String(length=3), server_default=sa.text("'USD'"), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("notes", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.UniqueConstraint("source", "source_order_id", name="uq_licenses_source_order"),
)
op.create_index(
"ix_licenses_email_lower",
"licenses",
[sa.text("lower(email)")],
)
op.create_index(
"ix_licenses_expires_active",
"licenses",
["expires_at"],
postgresql_where=sa.text("revoked_at IS NULL"),
)
op.create_table(
"gumroad_events",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("received_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("event_type", sa.String(), nullable=False),
sa.Column("order_id", sa.String(), nullable=True),
sa.Column("raw_payload", postgresql.JSONB(), nullable=False),
sa.Column("processed", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("error", sa.String(), nullable=True),
)
op.create_index("ix_gumroad_events_order_id", "gumroad_events", ["order_id"])
op.create_index(
"ix_gumroad_events_unprocessed",
"gumroad_events",
["received_at"],
postgresql_where=sa.text("processed = false"),
)
def downgrade() -> None:
op.drop_index("ix_gumroad_events_unprocessed", table_name="gumroad_events")
op.drop_index("ix_gumroad_events_order_id", table_name="gumroad_events")
op.drop_table("gumroad_events")
op.drop_index("ix_licenses_expires_active", table_name="licenses")
op.drop_index("ix_licenses_email_lower", table_name="licenses")
op.drop_table("licenses")

0
server/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,71 @@
"""Source-adapter interface.
The Mint API speaks only the normalized event types defined here.
Each storefront has its own adapter that:
- Verifies the storefront's webhook signature in its native format.
- Parses the storefront's payload into a :class:`SaleEvent` or
:class:`RefundEvent`.
- Maps the storefront's product/variant IDs to a license tier via
the per-source config in :mod:`app.adapters.config`.
Adding a new source (Lemon Squeezy, Stripe, Paddle) is one new
module that implements :class:`SourceAdapter`. The Mint API and DB
do not change.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Any, Optional, Protocol
@dataclass(frozen=True)
class SaleEvent:
"""A storefront sale, normalized.
The Mint API consumes this directly — it never reaches into the
raw storefront payload. Anything storefront-specific that's worth
keeping is preserved in :attr:`raw_payload` for audit.
"""
source: str # e.g. "gumroad", "manual"
source_order_id: Optional[str] # storefront's order ID; None for manual mints
buyer_name: str
buyer_email: str
tier: str # mapped from product/variant
years: int = 1
promotion: Optional[str] = None
amount_paid: Optional[Decimal] = None
currency: Optional[str] = "USD"
notes: Optional[str] = None
raw_payload: dict = field(default_factory=dict)
@dataclass(frozen=True)
class RefundEvent:
"""A storefront refund — marks an existing license revoked."""
source: str
source_order_id: str
reason: Optional[str] = None
raw_payload: dict = field(default_factory=dict)
class SourceAdapter(Protocol):
"""Interface every storefront adapter implements."""
source_name: str
def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool:
"""Return True iff the request came from the legitimate storefront."""
...
def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]:
"""Return a :class:`SaleEvent` if *payload* is a sale, else None."""
...
def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]:
"""Return a :class:`RefundEvent` if *payload* is a refund, else None."""
...

View File

@@ -0,0 +1,52 @@
"""Manual adapter — operator-initiated mints (comps, support replacements).
There is no webhook to verify and no payload to parse: the operator
hands us the buyer details directly via the CLI, and we construct a
:class:`SaleEvent` from them. ``source='manual'`` separates these
rows from storefront-driven mints in the DB.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, Optional
from app.adapters.base import RefundEvent, SaleEvent
class ManualAdapter:
source_name = "manual"
def verify_webhook(self, *, body: bytes, headers: dict[str, str]) -> bool:
return False # manual flows never come through webhooks
def parse_sale(self, payload: dict[str, Any]) -> Optional[SaleEvent]:
return self.build_sale(**payload)
def parse_refund(self, payload: dict[str, Any]) -> Optional[RefundEvent]:
return None
def build_sale(
self,
*,
name: str,
email: str,
tier: str,
years: int = 1,
promotion: Optional[str] = None,
amount_paid: Optional[Decimal] = None,
currency: Optional[str] = "USD",
notes: Optional[str] = None,
) -> SaleEvent:
return SaleEvent(
source=self.source_name,
source_order_id=None,
buyer_name=name,
buyer_email=email,
tier=tier,
years=years,
promotion=promotion,
amount_paid=amount_paid,
currency=currency,
notes=notes,
)

65
server/app/auth.py Normal file
View File

@@ -0,0 +1,65 @@
"""Auth guards for ``/internal/*``.
Active layer: Bearer token, presented by the operator's CLI and
matched against the value in the secrets dir. Token rotation =
update the file, restart the container.
:func:`require_localhost` is preserved but unused by default — it
fights the Docker bridge network model (the container sees the
gateway IP, not 127.0.0.1, regardless of where traffic originated).
Re-enable it only if the API runs in ``network_mode: host``.
"""
from __future__ import annotations
import hmac
from typing import Optional
from fastapi import HTTPException, Request, status
from app.config import get_settings
def require_localhost(request: Request) -> None:
"""Reject the request unless the connecting peer is loopback.
``request.client.host`` reflects the actual TCP peer (the nginx
upstream connecting from 127.0.0.1) when ``proxy_set_header`` is
used appropriately. We deliberately do NOT trust
``X-Forwarded-For`` here — we want the raw peer.
"""
peer = request.client.host if request.client else None
if peer not in {"127.0.0.1", "::1"}:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Not found",
)
def require_bearer_token(request: Request) -> None:
"""Verify ``Authorization: Bearer <admin_token>``.
Uses constant-time comparison so timing leaks don't reveal token
prefixes. The 401 deliberately doesn't echo the supplied token or
leak whether a token is configured at all — clients should treat
"no token configured" the same as "wrong token".
"""
settings = get_settings()
expected: Optional[str] = settings.resolve_admin_token()
if not expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Server not configured for internal access.",
)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Bearer token required.",
)
presented = auth.removeprefix("Bearer ").strip()
if not hmac.compare_digest(presented, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token.",
)

64
server/app/config.py Normal file
View File

@@ -0,0 +1,64 @@
"""Runtime configuration loaded from environment + secret files.
Secrets are read from files (``*_FILE`` env vars pointing at
``/run/secrets/<name>``) so they never appear in ``docker inspect``
or process environment dumps. Plain ``*`` vars are the fallback for
local development where mounting secret files is overkill.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = Field(
default="postgresql+psycopg://datatools_api@localhost:5432/datatools_licenses",
validation_alias="DATABASE_URL",
)
admin_token: Optional[str] = Field(default=None, validation_alias="DATATOOLS_ADMIN_TOKEN")
admin_token_file: Optional[Path] = Field(default=None, validation_alias="DATATOOLS_ADMIN_TOKEN_FILE")
license_privkey_hex: Optional[str] = Field(default=None, validation_alias="DATATOOLS_LICENSE_PRIVKEY")
license_privkey_file: Optional[Path] = Field(default=None, validation_alias="DATATOOLS_LICENSE_PRIVKEY_FILE")
license_pubkey_hex: Optional[str] = Field(default=None, validation_alias="DATATOOLS_LICENSE_PUBKEY")
postmark_token: Optional[str] = Field(default=None, validation_alias="POSTMARK_TOKEN")
postmark_token_file: Optional[Path] = Field(default=None, validation_alias="POSTMARK_TOKEN_FILE")
gumroad_secret: Optional[str] = Field(default=None, validation_alias="GUMROAD_WEBHOOK_SECRET")
gumroad_secret_file: Optional[Path] = Field(default=None, validation_alias="GUMROAD_WEBHOOK_SECRET_FILE")
def resolve_admin_token(self) -> Optional[str]:
return _resolve(self.admin_token, self.admin_token_file)
def resolve_license_privkey(self) -> Optional[str]:
return _resolve(self.license_privkey_hex, self.license_privkey_file)
def resolve_postmark_token(self) -> Optional[str]:
return _resolve(self.postmark_token, self.postmark_token_file)
def resolve_gumroad_secret(self) -> Optional[str]:
return _resolve(self.gumroad_secret, self.gumroad_secret_file)
def _resolve(inline: Optional[str], path: Optional[Path]) -> Optional[str]:
if inline:
return inline.strip()
if path and path.exists():
return path.read_text(encoding="utf-8").strip()
return None
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()

65
server/app/db.py Normal file
View File

@@ -0,0 +1,65 @@
"""SQLAlchemy engine + session factory.
The DB password lives in ``/run/secrets/pg_password``; we read it
from there (or ``$PG_PASSWORD`` for local dev) and splice it into
``DATABASE_URL`` so the password never has to be in plaintext in
``compose.yml`` or process environment listings.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Generator
from urllib.parse import quote_plus, urlparse, urlunparse
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
def _resolve_password() -> str | None:
inline = os.environ.get("PG_PASSWORD")
if inline:
return inline.strip()
path = os.environ.get("PG_PASSWORD_FILE")
if path and Path(path).exists():
return Path(path).read_text(encoding="utf-8").strip()
return None
def _build_url(base_url: str) -> str:
"""Inject the resolved password into ``base_url`` if absent."""
parsed = urlparse(base_url)
if parsed.password:
return base_url
pw = _resolve_password()
if pw is None:
return base_url
netloc = f"{parsed.username or ''}:{quote_plus(pw)}@{parsed.hostname}"
if parsed.port:
netloc += f":{parsed.port}"
return urlunparse(parsed._replace(netloc=netloc))
_settings = get_settings()
engine = create_engine(_build_url(_settings.database_url), pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
class Base(DeclarativeBase):
"""Declarative base for ORM models."""
def get_session() -> Generator[Session, None, None]:
"""FastAPI dependency. Commits on success, rolls back on exception."""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()

18
server/app/main.py Normal file
View File

@@ -0,0 +1,18 @@
"""FastAPI entry point for the DataTools license server."""
from __future__ import annotations
from fastapi import FastAPI
from app.routes import internal, public
app = FastAPI(
title="DataTools License Server",
version="0.1.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
app.include_router(public.router)
app.include_router(internal.router)

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

91
server/app/models.py Normal file
View File

@@ -0,0 +1,91 @@
"""ORM models for the licenses + gumroad_events tables.
Schema mirrors ``docs/LICENSE-SERVER.md``, generalized so any
``source`` can populate it. The ``(source, source_order_id)``
composite uniqueness key gives idempotent webhook retries — a
storefront firing the same sale twice maps to the same row.
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import (
JSON,
BigInteger,
DateTime,
Index,
Numeric,
String,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
# JSONB on Postgres (indexable, queryable), plain JSON elsewhere
# (SQLite for tests). Same Python interface either way.
_JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
from app.db import Base
class License(Base):
__tablename__ = "licenses"
license_key: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
email: Mapped[str] = mapped_column(String, nullable=False)
tier: Mapped[str] = mapped_column(String, nullable=False)
issued_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
blob: Mapped[str] = mapped_column(String, nullable=False)
source: Mapped[str] = mapped_column(String, nullable=False)
source_order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
promotion: Mapped[Optional[str]] = mapped_column(String, nullable=True)
amount_paid: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
currency: Mapped[Optional[str]] = mapped_column(String(3), nullable=True, server_default=text("'USD'"))
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
__table_args__ = (
UniqueConstraint("source", "source_order_id", name="uq_licenses_source_order"),
Index("ix_licenses_email_lower", func.lower(text("email"))),
Index("ix_licenses_expires_active", "expires_at", postgresql_where=text("revoked_at IS NULL")),
)
class GumroadEvent(Base):
"""Append-only audit log of every webhook delivery.
Stored regardless of processing outcome so we can replay failed
events, investigate disputes, and reconstruct the customer
record if the ``licenses`` table is ever corrupted.
"""
__tablename__ = "gumroad_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
event_type: Mapped[str] = mapped_column(String, nullable=False)
order_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
raw_payload: Mapped[dict] = mapped_column(_JSON_TYPE, nullable=False)
processed: Mapped[bool] = mapped_column(server_default=text("false"), nullable=False)
error: Mapped[Optional[str]] = mapped_column(String, nullable=True)
__table_args__ = (
Index("ix_gumroad_events_order_id", "order_id"),
Index("ix_gumroad_events_unprocessed", "received_at", postgresql_where=text("processed = false")),
)

View File

View File

@@ -0,0 +1,103 @@
"""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}

View File

@@ -0,0 +1,27 @@
"""Public (internet-facing) routes.
For PR 1: only ``/health``. The webhook receiver and renewal portal
land in PR 2 and PR 3 respectively.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.db import get_session
router = APIRouter()
@router.get("/health")
def health(session: Session = Depends(get_session)) -> dict:
"""Liveness + DB reachability. Cheap; safe to hit on a tight cadence."""
db_ok = True
try:
session.execute(text("SELECT 1"))
except SQLAlchemyError:
db_ok = False
return {"status": "ok" if db_ok else "degraded", "db": "ok" if db_ok else "error"}

54
server/app/schemas.py Normal file
View File

@@ -0,0 +1,54 @@
"""Pydantic request/response models for the HTTP layer."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class TierName(str, Enum):
lite = "lite"
core = "core"
pro = "pro"
enterprise = "enterprise"
class MintRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
email: EmailStr
tier: TierName
years: int = Field(default=1, ge=1, le=10)
source: str = Field(default="manual", min_length=1, max_length=40)
source_order_id: Optional[str] = Field(default=None, max_length=120)
promotion: Optional[str] = Field(default=None, max_length=60)
amount_paid: Optional[Decimal] = Field(default=None, ge=0, decimal_places=2)
currency: Optional[str] = Field(default="USD", min_length=3, max_length=3)
notes: Optional[str] = Field(default=None, max_length=2000)
class RevokeRequest(BaseModel):
license_key: str = Field(min_length=1, max_length=120)
reason: Optional[str] = Field(default=None, max_length=500)
class LicenseResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
license_key: str
name: str
email: str
tier: str
issued_at: datetime
expires_at: datetime
blob: str
source: str
source_order_id: Optional[str]
promotion: Optional[str]
amount_paid: Optional[Decimal]
currency: Optional[str]
revoked_at: Optional[datetime]
notes: Optional[str]

41
server/compose.test.yml Normal file
View File

@@ -0,0 +1,41 @@
# Smoke-test compose. Stands the API + Postgres up in isolation,
# exercises a mint, tears everything down (volume included). Never
# meant for production — for that see docs/SETUP-LICENSE-SERVER.md.
#
# Ports map to 127.0.0.1 only so it can run on a host that already
# binds 5432 / 8090 to something else.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: dt_test
POSTGRES_USER: dt_test
POSTGRES_PASSWORD: test_pw
ports:
- "127.0.0.1:15432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dt_test -d dt_test"]
interval: 2s
timeout: 2s
retries: 20
api:
build:
context: ..
dockerfile: server/Dockerfile
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: postgresql+psycopg://dt_test:test_pw@postgres:5432/dt_test
DATATOOLS_ADMIN_TOKEN: test-admin-token
# No DATATOOLS_LICENSE_PRIVKEY — falls back to the in-tree
# dev keypair, matching what the desktop dev build expects.
ports:
- "127.0.0.1:18090:8000"
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://localhost:8000/health"]
interval: 5s
timeout: 3s
retries: 10

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.3,<9
pytest-asyncio>=0.24,<1

10
server/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi>=0.115,<0.120
uvicorn[standard]>=0.32,<0.40
sqlalchemy>=2.0,<3
psycopg[binary]>=3.2,<4
alembic>=1.14,<2
pydantic>=2.9,<3
pydantic-settings>=2.6,<3
email-validator>=2.2,<3
cryptography>=43,<46
httpx>=0.27,<1

84
server/scripts/smoke.sh Executable file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# End-to-end smoke test for the license server.
#
# Builds the API image, brings up Postgres + API, runs the Alembic
# migration, mints a license through /internal/mint, verifies the
# resulting blob's Ed25519 signature against the dev pubkey, and
# confirms the row landed in the DB. Tears everything down at exit.
#
# Run from the server/ directory: ./scripts/smoke.sh
set -euo pipefail
cd "$(dirname "$0")/.."
PROJECT=dt-license-smoke
COMPOSE=(docker compose -p "$PROJECT" -f compose.test.yml)
cleanup() {
echo "--- Tearing down ---"
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo "--- Building image ---"
"${COMPOSE[@]}" build
echo "--- Starting stack ---"
"${COMPOSE[@]}" up -d
echo "--- Waiting for API health (max 60s) ---"
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:18090/health 2>/dev/null | grep -q '"status":"ok"'; then
echo "API up after ${i}s"
break
fi
sleep 1
done
echo "--- Running migrations ---"
"${COMPOSE[@]}" exec -T api alembic upgrade head
echo "--- Re-checking health post-migration ---"
curl -sf http://127.0.0.1:18090/health | tee /dev/stderr | grep -q '"db":"ok"'
echo "--- POST /internal/mint ---"
RESP=$(curl -s -w "\nHTTP=%{http_code}" -X POST http://127.0.0.1:18090/internal/mint \
-H "Authorization: Bearer test-admin-token" \
-H "Content-Type: application/json" \
-d '{"name":"Smoke Test","email":"smoke@example.com","tier":"core","source":"manual"}')
echo "$RESP"
HTTP_CODE=$(echo "$RESP" | tail -n1 | sed 's/HTTP=//')
RESP=$(echo "$RESP" | sed '$d')
if [ "$HTTP_CODE" != "201" ]; then
echo "MINT FAILED (HTTP $HTTP_CODE)"
"${COMPOSE[@]}" logs --tail 50 api
exit 1
fi
echo "$RESP" | python3 -m json.tool | head -8
BLOB=$(echo "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["blob"])')
echo "--- Verifying blob signature against host dev pubkey ---"
python3 - <<EOF
import sys
sys.path.insert(0, "..")
from src.license.crypto import decode_blob, verify
payload = decode_blob("$BLOB")
sig = payload.pop("signature")
assert verify(payload, sig), "signature must verify"
assert payload["name"] == "Smoke Test"
assert payload["email"] == "smoke@example.com"
assert payload["tier"] == "core"
print("OK: signature verifies, payload matches")
EOF
echo "--- Verifying DB row ---"
"${COMPOSE[@]}" exec -T postgres \
psql -U dt_test -d dt_test -t -c \
"SELECT license_key, email, tier, source FROM licenses;" \
| grep -q smoke@example.com
echo
echo "===================================="
echo " SMOKE TEST PASSED"
echo "===================================="

0
server/tests/__init__.py Normal file
View File

108
server/tests/conftest.py Normal file
View File

@@ -0,0 +1,108 @@
"""Shared pytest fixtures.
Tests run against in-memory SQLite — no docker, no Postgres install.
The cross-dialect type variants in :mod:`app.models` keep the schema
identical in behavior for everything PR 1 exercises (the JSONB
column on ``gumroad_events`` isn't touched until PR 2).
Auth: a fixed test token is wired into the settings cache before any
app modules import, and the ``client`` fixture overrides the
``require_localhost`` guard since Starlette's TestClient connects
from a synthetic ``testclient`` peer.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Set required env BEFORE importing anything from app.* so pydantic
# Settings (lru_cache'd) picks up these values on first access.
os.environ["DATATOOLS_ADMIN_TOKEN"] = "test-admin-token"
os.environ["DATABASE_URL"] = "sqlite+pysqlite:///:memory:"
# Make the desktop license module importable as `datatools_license`.
# In the Docker image this happens via `COPY src/license /app/datatools_license`;
# during local tests we simulate it by aliasing src.license.
_REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_REPO_ROOT))
import src.license as _dt_license_module
sys.modules.setdefault("datatools_license", _dt_license_module)
for _sub in ("crypto", "schema", "features", "_dev_keypair"):
sys.modules.setdefault(
f"datatools_license.{_sub}",
__import__(f"src.license.{_sub}", fromlist=[_sub]),
)
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import app.db as app_db
from app.db import Base
from app.main import app
@pytest.fixture(scope="session")
def engine():
eng = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
# Enforce foreign keys on SQLite (off by default).
@event.listens_for(eng, "connect")
def _fk_on(dbapi_conn, _):
dbapi_conn.execute("PRAGMA foreign_keys=ON")
Base.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture(autouse=True)
def _bind_app_engine(engine, monkeypatch):
"""Point the app's session factory at the test engine and wipe rows
between tests so order-of-execution can't leak state."""
TestSession = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
monkeypatch.setattr(app_db, "engine", engine)
monkeypatch.setattr(app_db, "SessionLocal", TestSession)
yield
with engine.begin() as conn:
for tbl in reversed(Base.metadata.sorted_tables):
conn.execute(tbl.delete())
@pytest.fixture
def db_session(engine):
"""Per-test session with rollback isolation."""
Session = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
sess = Session()
try:
yield sess
finally:
# Clean rows between tests rather than transaction-rolling (the
# mint code flushes mid-transaction and we want each test to
# see a clean licenses table).
sess.rollback()
for tbl in reversed(Base.metadata.sorted_tables):
sess.execute(tbl.delete())
sess.commit()
sess.close()
@pytest.fixture
def client():
"""Plain TestClient. Bearer-token check is live."""
with TestClient(app) as c:
yield c
@pytest.fixture
def admin_headers() -> dict[str, str]:
return {"Authorization": "Bearer test-admin-token"}

View File

@@ -0,0 +1,52 @@
"""ManualAdapter — building a SaleEvent from CLI-style kwargs."""
from __future__ import annotations
from decimal import Decimal
from app.adapters.manual import ManualAdapter
def test_build_sale_minimal_defaults():
a = ManualAdapter()
sale = a.build_sale(name="Jane Doe", email="jane@example.com", tier="core")
assert sale.source == "manual"
assert sale.source_order_id is None
assert sale.buyer_name == "Jane Doe"
assert sale.buyer_email == "jane@example.com"
assert sale.tier == "core"
assert sale.years == 1
assert sale.currency == "USD"
assert sale.promotion is None
assert sale.amount_paid is None
assert sale.notes is None
def test_build_sale_full_metadata():
a = ManualAdapter()
sale = a.build_sale(
name="Acme",
email="ops@acme.example",
tier="pro",
years=2,
promotion="LAUNCH50",
amount_paid=Decimal("249.00"),
currency="EUR",
notes="comp for beta tester",
)
assert sale.years == 2
assert sale.promotion == "LAUNCH50"
assert sale.amount_paid == Decimal("249.00")
assert sale.currency == "EUR"
assert sale.notes == "comp for beta tester"
def test_verify_webhook_always_false():
"""Manual flow never originates from a webhook."""
a = ManualAdapter()
assert a.verify_webhook(body=b"{}", headers={}) is False
def test_parse_refund_returns_none():
a = ManualAdapter()
assert a.parse_refund({"any": "payload"}) is None

102
server/tests/test_mint.py Normal file
View File

@@ -0,0 +1,102 @@
"""Mint core — signing, persistence, idempotency, revoke."""
from __future__ import annotations
from decimal import Decimal
import pytest
from app.adapters.base import SaleEvent
from app.mint import mint_from_sale, revoke_license
from app.models import License
from datatools_license.crypto import decode_blob, verify
def _sale(**overrides) -> SaleEvent:
base = dict(
source="manual",
source_order_id=None,
buyer_name="Jane Doe",
buyer_email="jane@example.com",
tier="core",
years=1,
promotion=None,
amount_paid=None,
currency="USD",
notes=None,
)
base.update(overrides)
return SaleEvent(**base)
def test_mint_persists_and_signs_verifiably(db_session):
row = mint_from_sale(db_session, _sale())
db_session.commit()
assert row.license_key.startswith("DT1-CORE-")
assert row.tier == "core"
assert row.source == "manual"
assert row.blob.startswith("DTLIC2:")
assert row.revoked_at is None
payload = decode_blob(row.blob)
sig = payload.pop("signature")
assert verify(payload, sig), "minted blob must verify against the dev pubkey"
assert payload["name"] == "Jane Doe"
assert payload["email"] == "jane@example.com"
assert payload["tier"] == "core"
def test_mint_idempotent_on_source_order_id(db_session):
"""A second mint with the same (source, source_order_id) returns
the existing row — webhook retries cannot double-mint."""
first = mint_from_sale(
db_session,
_sale(source="gumroad", source_order_id="GUM-1001"),
)
db_session.commit()
second = mint_from_sale(
db_session,
_sale(source="gumroad", source_order_id="GUM-1001", buyer_name="Different Name"),
)
db_session.commit()
assert first.license_key == second.license_key
assert second.name == "Jane Doe", "existing row is returned unchanged"
def test_manual_mints_never_dedup(db_session):
"""source_order_id=None means each manual mint creates a new row."""
a = mint_from_sale(db_session, _sale())
db_session.commit()
b = mint_from_sale(db_session, _sale())
db_session.commit()
assert a.license_key != b.license_key
def test_mint_records_commercial_metadata(db_session):
row = mint_from_sale(
db_session,
_sale(promotion="LAUNCH50", amount_paid=Decimal("79.00"), currency="USD"),
)
db_session.commit()
assert row.promotion == "LAUNCH50"
assert Decimal(str(row.amount_paid)) == Decimal("79.00")
assert row.currency == "USD"
def test_revoke_marks_row(db_session):
row = mint_from_sale(db_session, _sale())
db_session.commit()
revoked = revoke_license(db_session, license_key=row.license_key, reason="refund")
db_session.commit()
assert revoked is not None
assert revoked.revoked_at is not None
assert "refund" in (revoked.notes or "")
def test_revoke_unknown_returns_none(db_session):
assert revoke_license(db_session, license_key="DT1-CORE-no-such-key") is None

130
server/tests/test_routes.py Normal file
View File

@@ -0,0 +1,130 @@
"""HTTP route tests — auth, mint, revoke, list, health."""
from __future__ import annotations
def test_health_is_public(client):
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_internal_requires_bearer(client):
r = client.get("/internal/ping")
assert r.status_code == 401
def test_internal_rejects_wrong_bearer(client):
r = client.get("/internal/ping", headers={"Authorization": "Bearer nope"})
assert r.status_code == 401
def test_internal_ping_ok_with_token(client, admin_headers):
r = client.get("/internal/ping", headers=admin_headers)
assert r.status_code == 200
assert r.json() == {"ok": True}
def test_mint_creates_license(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={
"name": "Jane Doe",
"email": "jane@example.com",
"tier": "core",
"years": 1,
"source": "manual",
},
)
assert r.status_code == 201, r.text
body = r.json()
assert body["tier"] == "core"
assert body["source"] == "manual"
assert body["blob"].startswith("DTLIC2:")
def test_mint_rejects_non_manual_source(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={
"name": "x", "email": "x@example.com", "tier": "core",
"source": "gumroad",
},
)
assert r.status_code == 400
assert "not wired" in r.json()["detail"]
def test_mint_rejects_bad_email(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "x", "email": "not-an-email", "tier": "core"},
)
assert r.status_code == 422
def test_mint_rejects_unknown_tier(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "x", "email": "x@example.com", "tier": "platinum"},
)
assert r.status_code == 422
def test_list_licenses_filters_email_case_insensitive(client, admin_headers):
# Pydantic EmailStr normalizes the domain to lowercase per RFC.
for email in ("alice@example.com", "Bob@Example.com", "carol@other.test"):
client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "User", "email": email, "tier": "core"},
)
r = client.get(
"/internal/licenses?email=example.com",
headers=admin_headers,
)
assert r.status_code == 200
emails = {row["email"].lower() for row in r.json()}
assert "alice@example.com" in emails
assert "bob@example.com" in emails
assert "carol@other.test" not in emails
def test_revoke_then_excluded_by_default(client, admin_headers):
r = client.post(
"/internal/mint",
headers=admin_headers,
json={"name": "x", "email": "x@example.com", "tier": "lite"},
)
key = r.json()["license_key"]
r2 = client.post(
"/internal/revoke",
headers=admin_headers,
json={"license_key": key, "reason": "refund"},
)
assert r2.status_code == 200
assert r2.json()["revoked_at"] is not None
listed = client.get("/internal/licenses", headers=admin_headers).json()
assert all(row["license_key"] != key for row in listed)
listed_all = client.get(
"/internal/licenses?include_revoked=true",
headers=admin_headers,
).json()
assert any(row["license_key"] == key for row in listed_all)
def test_revoke_unknown_returns_404(client, admin_headers):
r = client.post(
"/internal/revoke",
headers=admin_headers,
json={"license_key": "DT1-CORE-doesnot-exist"},
)
assert r.status_code == 404