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:
16
server/.dockerignore
Normal file
16
server/.dockerignore
Normal 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
37
server/Dockerfile
Normal 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
38
server/alembic.ini
Normal 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
46
server/alembic/env.py
Normal 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()
|
||||
26
server/alembic/script.py.mako
Normal file
26
server/alembic/script.py.mako
Normal 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"}
|
||||
80
server/alembic/versions/0001_initial.py
Normal file
80
server/alembic/versions/0001_initial.py
Normal 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
0
server/app/__init__.py
Normal file
0
server/app/adapters/__init__.py
Normal file
0
server/app/adapters/__init__.py
Normal file
71
server/app/adapters/base.py
Normal file
71
server/app/adapters/base.py
Normal 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."""
|
||||
...
|
||||
52
server/app/adapters/manual.py
Normal file
52
server/app/adapters/manual.py
Normal 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
65
server/app/auth.py
Normal 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
64
server/app/config.py
Normal 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
65
server/app/db.py
Normal 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
18
server/app/main.py
Normal 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
136
server/app/mint.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Core mint + revoke logic.
|
||||
|
||||
Bridges the source-adapter layer (:mod:`app.adapters`) to the DB
|
||||
layer (:mod:`app.models`), reusing the desktop app's signing /
|
||||
encoding primitives from ``datatools_license.crypto`` so blobs minted
|
||||
here verify against the same embedded pubkey on the buyer's machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.adapters.base import SaleEvent
|
||||
from app.config import get_settings
|
||||
from app.models import License
|
||||
|
||||
|
||||
def _init_key_env() -> None:
|
||||
"""Resolve secret-file pointers into env vars before importing crypto.
|
||||
|
||||
``datatools_license.crypto`` looks for ``DATATOOLS_LICENSE_PRIVKEY``
|
||||
/ ``DATATOOLS_LICENSE_PUBKEY`` in ``os.environ``. When those come
|
||||
from secret files (``*_FILE`` env vars), we read them once at
|
||||
module import and stash so crypto can pick them up without
|
||||
changes.
|
||||
"""
|
||||
settings = get_settings()
|
||||
priv = settings.resolve_license_privkey()
|
||||
if priv:
|
||||
os.environ.setdefault("DATATOOLS_LICENSE_PRIVKEY", priv)
|
||||
pub = settings.license_pubkey_hex
|
||||
if pub:
|
||||
os.environ.setdefault("DATATOOLS_LICENSE_PUBKEY", pub)
|
||||
|
||||
|
||||
_init_key_env()
|
||||
|
||||
# Imported after env init so the crypto module reads the correct key.
|
||||
from datatools_license.crypto import encode_blob, sign # noqa: E402
|
||||
from datatools_license.features import all_features_for_tier # noqa: E402
|
||||
from datatools_license.schema import ( # noqa: E402
|
||||
License as LicenseDataclass,
|
||||
Tier,
|
||||
_utcnow_iso,
|
||||
default_expiry_iso,
|
||||
)
|
||||
|
||||
|
||||
def _generate_license_key(tier: str) -> str:
|
||||
rid = uuid.uuid4().hex
|
||||
return f"DT1-{tier.upper()}-{rid[:8]}-{rid[8:16]}"
|
||||
|
||||
|
||||
def _iso_to_dt(iso: str) -> datetime:
|
||||
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def mint_from_sale(session: Session, sale: SaleEvent) -> License:
|
||||
"""Idempotently mint a license for *sale*.
|
||||
|
||||
If a row with the same ``(source, source_order_id)`` already
|
||||
exists, return it untouched — Gumroad retrying a webhook does not
|
||||
produce a second blob with a different signature. Manual mints
|
||||
(``source_order_id is None``) skip the dedup check and always
|
||||
produce a new row.
|
||||
"""
|
||||
if sale.source_order_id is not None:
|
||||
existing = session.execute(
|
||||
select(License).where(
|
||||
License.source == sale.source,
|
||||
License.source_order_id == sale.source_order_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
tier_enum = Tier(sale.tier)
|
||||
license_key = _generate_license_key(sale.tier)
|
||||
issued_iso = _utcnow_iso()
|
||||
expires_iso = default_expiry_iso(years=sale.years)
|
||||
|
||||
unsigned = LicenseDataclass(
|
||||
name=sale.buyer_name,
|
||||
email=sale.buyer_email,
|
||||
license_key=license_key,
|
||||
tier=tier_enum,
|
||||
features=all_features_for_tier(tier_enum),
|
||||
issued_at=issued_iso,
|
||||
expires_at=expires_iso,
|
||||
signature="",
|
||||
)
|
||||
signature = sign(unsigned.to_canonical_dict())
|
||||
payload = unsigned.to_canonical_dict()
|
||||
payload["signature"] = signature
|
||||
blob = encode_blob(payload)
|
||||
|
||||
row = License(
|
||||
license_key=license_key,
|
||||
name=sale.buyer_name,
|
||||
email=sale.buyer_email,
|
||||
tier=sale.tier,
|
||||
issued_at=_iso_to_dt(issued_iso),
|
||||
expires_at=_iso_to_dt(expires_iso),
|
||||
blob=blob,
|
||||
source=sale.source,
|
||||
source_order_id=sale.source_order_id,
|
||||
promotion=sale.promotion,
|
||||
amount_paid=sale.amount_paid,
|
||||
currency=sale.currency,
|
||||
notes=sale.notes,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def revoke_license(
|
||||
session: Session,
|
||||
*,
|
||||
license_key: str,
|
||||
reason: Optional[str] = None,
|
||||
) -> Optional[License]:
|
||||
row = session.get(License, license_key)
|
||||
if row is None:
|
||||
return None
|
||||
row.revoked_at = datetime.now(timezone.utc)
|
||||
if reason:
|
||||
suffix = f"\nRevoked: {reason}"
|
||||
row.notes = ((row.notes or "") + suffix).strip()
|
||||
return row
|
||||
91
server/app/models.py
Normal file
91
server/app/models.py
Normal 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")),
|
||||
)
|
||||
0
server/app/routes/__init__.py
Normal file
0
server/app/routes/__init__.py
Normal file
103
server/app/routes/internal.py
Normal file
103
server/app/routes/internal.py
Normal 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}
|
||||
27
server/app/routes/public.py
Normal file
27
server/app/routes/public.py
Normal 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
54
server/app/schemas.py
Normal 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
41
server/compose.test.yml
Normal 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
|
||||
3
server/requirements-dev.txt
Normal file
3
server/requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.3,<9
|
||||
pytest-asyncio>=0.24,<1
|
||||
10
server/requirements.txt
Normal file
10
server/requirements.txt
Normal 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
84
server/scripts/smoke.sh
Executable 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
0
server/tests/__init__.py
Normal file
108
server/tests/conftest.py
Normal file
108
server/tests/conftest.py
Normal 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"}
|
||||
52
server/tests/test_adapters.py
Normal file
52
server/tests/test_adapters.py
Normal 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
102
server/tests/test_mint.py
Normal 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
130
server/tests/test_routes.py
Normal 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
|
||||
Reference in New Issue
Block a user