diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..d252d0a --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,16 @@ +**/__pycache__ +**/*.pyc +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +.git +.venv +venv +docs +landing +marketing +samples +test-cases +tests +logs +build diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..8c718e5 --- /dev/null +++ b/server/Dockerfile @@ -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", "*"] diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..a97945f --- /dev/null +++ b/server/alembic.ini @@ -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 diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..3f37d14 --- /dev/null +++ b/server/alembic/env.py @@ -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() diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..6f2cb5c --- /dev/null +++ b/server/alembic/script.py.mako @@ -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"} diff --git a/server/alembic/versions/0001_initial.py b/server/alembic/versions/0001_initial.py new file mode 100644 index 0000000..3ea6d6c --- /dev/null +++ b/server/alembic/versions/0001_initial.py @@ -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") diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/adapters/__init__.py b/server/app/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/adapters/base.py b/server/app/adapters/base.py new file mode 100644 index 0000000..7e13a73 --- /dev/null +++ b/server/app/adapters/base.py @@ -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.""" + ... diff --git a/server/app/adapters/manual.py b/server/app/adapters/manual.py new file mode 100644 index 0000000..9dbed35 --- /dev/null +++ b/server/app/adapters/manual.py @@ -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, + ) diff --git a/server/app/auth.py b/server/app/auth.py new file mode 100644 index 0000000..e7193a9 --- /dev/null +++ b/server/app/auth.py @@ -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 ``. + + 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.", + ) diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..661dc91 --- /dev/null +++ b/server/app/config.py @@ -0,0 +1,64 @@ +"""Runtime configuration loaded from environment + secret files. + +Secrets are read from files (``*_FILE`` env vars pointing at +``/run/secrets/``) 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() diff --git a/server/app/db.py b/server/app/db.py new file mode 100644 index 0000000..6a9f1f5 --- /dev/null +++ b/server/app/db.py @@ -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() diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..994949f --- /dev/null +++ b/server/app/main.py @@ -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) diff --git a/server/app/mint.py b/server/app/mint.py new file mode 100644 index 0000000..2946574 --- /dev/null +++ b/server/app/mint.py @@ -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 diff --git a/server/app/models.py b/server/app/models.py new file mode 100644 index 0000000..cc36e57 --- /dev/null +++ b/server/app/models.py @@ -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")), + ) diff --git a/server/app/routes/__init__.py b/server/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/routes/internal.py b/server/app/routes/internal.py new file mode 100644 index 0000000..68ac43d --- /dev/null +++ b/server/app/routes/internal.py @@ -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} diff --git a/server/app/routes/public.py b/server/app/routes/public.py new file mode 100644 index 0000000..e30b6b2 --- /dev/null +++ b/server/app/routes/public.py @@ -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"} diff --git a/server/app/schemas.py b/server/app/schemas.py new file mode 100644 index 0000000..f692228 --- /dev/null +++ b/server/app/schemas.py @@ -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] diff --git a/server/compose.test.yml b/server/compose.test.yml new file mode 100644 index 0000000..6fc5fdf --- /dev/null +++ b/server/compose.test.yml @@ -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 diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt new file mode 100644 index 0000000..f9f1a9f --- /dev/null +++ b/server/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.3,<9 +pytest-asyncio>=0.24,<1 diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..67bccef --- /dev/null +++ b/server/requirements.txt @@ -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 diff --git a/server/scripts/smoke.sh b/server/scripts/smoke.sh new file mode 100755 index 0000000..4e310de --- /dev/null +++ b/server/scripts/smoke.sh @@ -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 - < dict[str, str]: + return {"Authorization": "Bearer test-admin-token"} diff --git a/server/tests/test_adapters.py b/server/tests/test_adapters.py new file mode 100644 index 0000000..ebfeff0 --- /dev/null +++ b/server/tests/test_adapters.py @@ -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 diff --git a/server/tests/test_mint.py b/server/tests/test_mint.py new file mode 100644 index 0000000..9a6cb14 --- /dev/null +++ b/server/tests/test_mint.py @@ -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 diff --git a/server/tests/test_routes.py b/server/tests/test_routes.py new file mode 100644 index 0000000..d640997 --- /dev/null +++ b/server/tests/test_routes.py @@ -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