feat(license): registration + 1-year licenses + tier scaffolding

A complete offline licensing layer (no internet at any step):

Core
- src/license/ — schema (License, Tier, FeatureFlag), HMAC crypto,
  JSON storage, LicenseManager singleton with activate/renew/
  deactivate/issue_trial. Tier-scaffolded so future SKUs can carve
  per-tool feature sets without consumer-code edits.
- scripts/generate_license.py — creator-only key generator. Mints a
  DTLIC1: blob the buyer pastes into the activation page.

GUI
- New activation form component (src/gui/components/activation.py).
- hide_streamlit_chrome() now inline-renders the activation form when
  no valid license is present (every page short-circuits to the form
  until activated).
- Sidebar shows tier + days remaining; renewal warning under 30 days.
- New pages/_Activate.py for revisiting the form after activation.

CLI
- src/license_cli.py — activate / renew / status / trial / deactivate
  commands. Exempt from the guard.
- src/cli_license_guard.py — drop-in guard call added to every tool
  CLI's main(). Lets --help through; respects DATATOOLS_DEV_MODE.

i18n
- New activation.* and license.* keys in en.json + es.json
  (page title, form labels, status badges, renewal warnings, error
  messages). Pack parity test stays green.

Test infrastructure
- tests/conftest.py autouse fixture sets DATATOOLS_DEV_MODE=1 so the
  existing 1916 tests continue to pass.
- isolated_license_path / activated_license_manager /
  unactivated_license_manager fixtures for tests that want to drive
  the real check.

Tests (+79)
- tests/test_license.py (40): schema, crypto roundtrip, blob
  encode/decode, tier→feature mapping, activation flow, name/email
  mismatch rejection, tamper detection, expiration, renewal,
  dev-mode bypass.
- tests/test_license_cli.py (26): every license_cli command +
  subprocess tests confirming every tool CLI refuses to run without
  a license, --help always works, DEV_MODE bypasses.
- tests/gui/test_activation.py (13): gate blocks without license,
  passes with trial, activation form submission unlocks the gate,
  sidebar status, renewal warning, i18n.

Total: 1916 → 1995 tests. All pass under the strict warning filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 16:54:23 +00:00
parent b2c7b94fe9
commit e435103113
27 changed files with 2798 additions and 6 deletions

470
src/license/manager.py Normal file
View File

@@ -0,0 +1,470 @@
"""LicenseManager — the public face of the license layer.
Singleton-by-default (``get_manager()`` returns a process-wide
instance), but tests can construct standalone managers via the
constructor for full isolation.
Lifecycle::
mgr = get_manager()
if not mgr.is_activated():
mgr.activate_from_blob(blob, name, email)
mgr.require_feature(FeatureFlag.DEDUPLICATOR)
state = mgr.current_state() # snapshot for the sidebar / CLI status
"""
from __future__ import annotations
import os
import re
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from . import crypto, storage
from .errors import (
ExpiredLicenseError,
InvalidLicenseError,
LicenseError,
NotActivatedError,
UnsupportedFeatureError,
)
from .features import all_features_for_tier
from .schema import FeatureFlag, License, Tier, default_expiry_iso, _utcnow_iso
# ---------------------------------------------------------------------------
# State snapshot
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class LicenseState:
"""A read-only snapshot for status widgets / CLI ``--status`` JSON.
Always safe to render — even when no license is activated the
dataclass is populated with explanatory defaults so the GUI never
needs to None-check before formatting.
"""
activated: bool
valid: bool # activated AND not expired AND signature OK
name: str
email: str
tier: str
license_key: str
issued_at: str
expires_at: str
days_remaining: int
features: tuple[str, ...]
error_kind: str # "", "not_activated", "expired", "invalid"
error_message: str
def as_dict(self) -> dict:
from dataclasses import asdict
d = asdict(self)
d["features"] = list(self.features)
return d
_EMPTY_STATE = LicenseState(
activated=False, valid=False, name="", email="", tier="",
license_key="", issued_at="", expires_at="", days_remaining=0,
features=(),
error_kind="not_activated",
error_message="No license activated.",
)
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class LicenseManager:
"""Read/write license state. Cheap to construct; the singleton at
module level just avoids reload churn.
Storage path defaults to :func:`storage.default_license_path` —
pass ``path=`` to override for tests.
"""
def __init__(self, *, path: Optional[Path] = None) -> None:
self._path = path
self._cached: Optional[License] = None
self._dev_mode: Optional[bool] = None
# --- Dev bypass ---------------------------------------------------------
@property
def dev_mode(self) -> bool:
"""``DATATOOLS_DEV_MODE=1`` short-circuits every check.
Cached on the instance so a test that sets the env after
construction still picks it up (re-read on each access).
"""
return _truthy_env("DATATOOLS_DEV_MODE")
# --- Load / save --------------------------------------------------------
def load(self) -> Optional[License]:
"""Read + verify the on-disk license. Returns ``None`` when no
file exists. Raises :class:`InvalidLicenseError` on signature
mismatch / tampering."""
raw = storage.read_raw(self._path)
if raw is None:
self._cached = None
return None
lic = License.from_dict(raw)
# Verify signature against the canonical payload.
if not crypto.verify(lic.to_canonical_dict(), lic.signature):
raise InvalidLicenseError(
"License signature does not verify. The file may have "
"been tampered with, or it was issued by a different "
"build. Re-paste the original license blob to recover."
)
self._cached = lic
return lic
def save(self, lic: License) -> Path:
"""Persist *lic* to the configured path. Caller is responsible
for having signed the license already; this function does
NOT re-sign."""
path = storage.write_raw(lic.to_dict(), self._path)
self._cached = lic
return path
def deactivate(self) -> bool:
"""Remove the on-disk license. Returns whether a file was
removed (False if nothing was active)."""
self._cached = None
return storage.remove(self._path)
# --- Activation ---------------------------------------------------------
def activate_from_blob(
self,
blob: str,
*,
name: str,
email: str,
) -> License:
"""Verify *blob* and write the activated license to disk.
The buyer pastes the blob; the page collects their *name* and
*email* separately. We require both registered values to
match the values embedded in the signed blob — defends
against blob-sharing between buyers.
"""
_validate_registration(name, email)
try:
payload = crypto.decode_blob(blob)
except ValueError as e:
raise InvalidLicenseError(str(e)) from e
signature = payload.get("signature", "")
if not signature:
raise InvalidLicenseError(
"License blob is missing the ``signature`` field. "
"The blob may have been truncated when pasted."
)
canonical = {k: v for k, v in payload.items() if k != "signature"}
if not crypto.verify(canonical, signature):
raise InvalidLicenseError(
"License blob signature did not verify. The blob may "
"be corrupt, intended for a different product build, "
"or modified after issue."
)
# Reconstruct the License dataclass after verification so the
# canonical dict we hashed matches the on-disk JSON.
lic = License.from_dict(payload)
# Personal-name and email matching is a soft attestation. We
# enforce case-insensitive equality after stripping whitespace,
# so " jane@Example.com " matches the embedded canonical
# form without surprising the user about case.
if name.strip().casefold() != lic.name.casefold() or (
email.strip().casefold() != lic.email.casefold()
):
raise InvalidLicenseError(
"Registered name / email do not match the values "
"embedded in the license blob. Contact support if you "
"believe this is in error."
)
if lic.is_expired():
raise ExpiredLicenseError(
f"License expired on {lic.expires_at}. "
"Paste a renewal blob to extend access."
)
self.save(lic)
return lic
def issue_trial(self, *, name: str, email: str, years: int = 1) -> License:
"""Self-sign a 1-year trial license. The seller's
``scripts/generate_license.py`` produces these for buyers; the
same code path is reused at activation time as a fallback
when a buyer wants to evaluate without a key.
Trial licenses are functionally identical to CORE in v1; only
the tier label differs (so the sidebar can say "TRIAL" if we
ever want to nudge a conversion).
"""
_validate_registration(name, email)
return self._mint(name=name, email=email, tier=Tier.TRIAL, years=years)
def renew(self, blob: str) -> License:
"""Renew an existing license using a fresh blob.
Verification: the blob must verify, its name+email must match
the currently-active license, and its expiry must be in the
future. We allow tier changes during renewal (upgrade path).
"""
current = self._cached or self.load()
if current is None:
raise NotActivatedError(
"No active license to renew. Use ``activate`` instead "
"of ``renew`` for first-time setup."
)
try:
payload = crypto.decode_blob(blob)
except ValueError as e:
raise InvalidLicenseError(str(e)) from e
signature = payload.get("signature", "")
canonical = {k: v for k, v in payload.items() if k != "signature"}
if not crypto.verify(canonical, signature):
raise InvalidLicenseError("Renewal blob signature did not verify.")
lic = License.from_dict(payload)
if (
lic.name.casefold() != current.name.casefold()
or lic.email.casefold() != current.email.casefold()
):
raise InvalidLicenseError(
"Renewal blob is for a different name/email than the "
"currently-active license."
)
if lic.is_expired():
raise ExpiredLicenseError(
"Renewal blob is itself expired. Generate a new one."
)
self.save(lic)
return lic
# --- Inspection ---------------------------------------------------------
def is_activated(self) -> bool:
if self._cached is not None:
return True
return storage.read_raw(self._path) is not None
def is_valid(self) -> bool:
if self.dev_mode:
return True
try:
lic = self._cached or self.load()
except LicenseError:
return False
if lic is None:
return False
return not lic.is_expired()
def current_state(self) -> LicenseState:
if self.dev_mode:
return LicenseState(
activated=True, valid=True,
name="dev", email="dev@local",
tier=Tier.ENTERPRISE.value,
license_key="DEV-BYPASS",
issued_at=_utcnow_iso(),
expires_at=default_expiry_iso(years=99),
days_remaining=36500,
features=all_features_for_tier(Tier.ENTERPRISE),
error_kind="",
error_message="",
)
try:
lic = self._cached or self.load()
except InvalidLicenseError as e:
return _EMPTY_STATE.__class__(
activated=True, valid=False,
name="", email="", tier="", license_key="",
issued_at="", expires_at="", days_remaining=0,
features=(),
error_kind="invalid",
error_message=str(e),
)
if lic is None:
return _EMPTY_STATE
if lic.is_expired():
return LicenseState(
activated=True, valid=False,
name=lic.name, email=lic.email, tier=lic.tier.value,
license_key=lic.license_key,
issued_at=lic.issued_at, expires_at=lic.expires_at,
days_remaining=lic.days_remaining(),
features=lic.features,
error_kind="expired",
error_message=(
f"License expired on {lic.expires_at}. "
"Paste a renewal blob to extend access."
),
)
return LicenseState(
activated=True, valid=True,
name=lic.name, email=lic.email, tier=lic.tier.value,
license_key=lic.license_key,
issued_at=lic.issued_at, expires_at=lic.expires_at,
days_remaining=max(lic.days_remaining(), 0),
features=lic.features,
error_kind="",
error_message="",
)
def require_feature(self, feature: str | FeatureFlag) -> License:
"""Raise the right error if *feature* isn't accessible.
Returns the active :class:`License` on success so callers can
log the tier / days-remaining alongside their own work.
"""
if self.dev_mode:
# Synthesize a dev license so callers expecting a return
# value don't blow up. The dev license unlocks every flag.
return License(
name="dev", email="dev@local",
license_key="DEV-BYPASS",
tier=Tier.ENTERPRISE,
features=all_features_for_tier(Tier.ENTERPRISE),
issued_at=_utcnow_iso(),
expires_at=default_expiry_iso(years=99),
signature="",
)
try:
lic = self._cached or self.load()
except InvalidLicenseError:
raise
if lic is None:
raise NotActivatedError(
"DataTools is not activated. Run "
"``datatools-license activate <blob>`` or use the "
"Activate page in the GUI."
)
if lic.is_expired():
raise ExpiredLicenseError(
f"License expired on {lic.expires_at}. "
"Renew before continuing."
)
if not lic.has_feature(feature):
tier_name = lic.tier.value if isinstance(lic.tier, Tier) else lic.tier
raise UnsupportedFeatureError(
f"Feature {feature!r} is not enabled on the active "
f"{tier_name!r} license."
)
return lic
# --- Internals ---------------------------------------------------------
def _mint(
self,
*,
name: str,
email: str,
tier: Tier,
years: int = 1,
license_key: Optional[str] = None,
) -> License:
"""Self-sign a new license. Used by ``issue_trial`` and by
the seller-side key generation utility (which calls the
same code via the bare manager)."""
now = _utcnow_iso()
exp = default_expiry_iso(years=years)
features = all_features_for_tier(tier)
key = license_key or _generate_license_key(tier)
unsigned = License(
name=name, email=email, license_key=key, tier=tier,
features=features, issued_at=now, expires_at=exp,
signature="",
)
sig = crypto.sign(unsigned.to_canonical_dict())
signed = License(
name=unsigned.name, email=unsigned.email,
license_key=unsigned.license_key, tier=unsigned.tier,
features=unsigned.features, issued_at=unsigned.issued_at,
expires_at=unsigned.expires_at, signature=sig,
)
self.save(signed)
return signed
def _generate_license_key(tier: Tier) -> str:
"""Human-readable but unguessable key id.
Format: ``DT1-{TIER}-{8 hex}-{8 hex}``. The two random hex blocks
come from a single UUID4 so the key has 64 bits of entropy. Not
used as the cryptographic identity — that's the signature — but
it's a stable handle for support emails.
"""
rid = uuid.uuid4().hex
return f"DT1-{tier.value.upper()}-{rid[:8]}-{rid[8:16]}"
def _validate_registration(name: str, email: str) -> None:
"""Reject obviously-bad inputs before touching crypto.
The activation page should call this too so the error surfaces
immediately instead of from inside the verifier.
"""
if not name or not name.strip():
raise InvalidLicenseError("Name is required for registration.")
if not email or not _EMAIL_RE.match(email.strip()):
raise InvalidLicenseError(
f"{email!r} is not a valid email address. "
"Expected: ``local@domain.tld``."
)
def _truthy_env(name: str) -> bool:
v = os.environ.get(name, "")
return v.strip().lower() in {"1", "true", "yes", "on"}
# ---------------------------------------------------------------------------
# Singleton + module-level convenience
# ---------------------------------------------------------------------------
_singleton: Optional[LicenseManager] = None
def get_manager() -> LicenseManager:
"""Return the process-wide :class:`LicenseManager`.
Re-uses the same instance across imports so the GUI's sidebar,
the chrome gate, and the CLI guard share one cached license read.
Tests that need isolation should construct their own manager
instead.
"""
global _singleton
if _singleton is None:
_singleton = LicenseManager()
return _singleton
def reset_singleton_for_tests() -> None:
"""Drop the cached singleton. Used by the test fixture so each
test session starts with a fresh manager pointed at its tmp
license path."""
global _singleton
_singleton = None
def current_state() -> LicenseState:
return get_manager().current_state()
def require_feature(feature: str | FeatureFlag) -> License:
return get_manager().require_feature(feature)