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

59
src/license/__init__.py Normal file
View File

@@ -0,0 +1,59 @@
"""License module — registration, activation, expiration, feature gating.
Public API the rest of the app uses:
- :func:`get_manager` — singleton :class:`LicenseManager` instance.
- :func:`current_state` — quick snapshot for status badges / tests.
- :func:`require_feature` — raise :class:`LicenseError` if a feature
isn't unlocked by the active license.
- :class:`License`, :class:`Tier`, :class:`FeatureFlag` — schema.
- :class:`LicenseError` and subclasses — typed failures the UI can
branch on (not yet activated vs. expired vs. tampered).
The license model is:
1. The seller (creator) runs ``scripts/generate_license.py`` to mint a
signed **license blob** keyed to a buyer's name + email.
2. The buyer pastes the blob into the activation page on first launch.
3. The app verifies the HMAC signature locally (no internet), then
writes a canonical ``~/.datatools/license.json`` and the app
unlocks.
The signature is HMAC-SHA256 with a build-time secret. Combined with
the 30-day refund policy, this is honor-system DRM — see
``docs/DECISIONS.md`` for the trade-off discussion.
"""
from __future__ import annotations
from .errors import (
ExpiredLicenseError,
InvalidLicenseError,
LicenseError,
NotActivatedError,
UnsupportedFeatureError,
)
from .features import FEATURES_BY_TIER, all_features_for_tier
from .manager import LicenseManager, current_state, get_manager, require_feature
from .schema import FeatureFlag, License, Tier
__all__ = [
# Manager
"LicenseManager",
"current_state",
"get_manager",
"require_feature",
# Schema
"FeatureFlag",
"License",
"Tier",
# Feature registry
"FEATURES_BY_TIER",
"all_features_for_tier",
# Errors
"LicenseError",
"NotActivatedError",
"ExpiredLicenseError",
"InvalidLicenseError",
"UnsupportedFeatureError",
]