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

View File

@@ -0,0 +1,218 @@
"""Activation page rendering + license gate inline-renderer.
Two public callables:
- :func:`render_activation_form` — the activation page body. Used by
``pages/_Activate.py`` for the explicit nav entry and by
:func:`require_license_or_render_activation` for the gate-injected
inline form.
- :func:`require_license_or_render_activation` — call after
``hide_streamlit_chrome`` from every page that needs a valid
license. When the license is missing, expired, or invalid it
renders the activation form in place of the page body and calls
``st.stop()`` so the rest of the page doesn't execute.
- :func:`render_license_status_sidebar` — small chrome widget the
sidebar uses to show "Core · 327 days left" or "🛑 Expired".
The component is kept dependency-free of the page modules so any
page can import these helpers without circular imports through
``components.__init__``.
"""
from __future__ import annotations
from typing import Optional
import streamlit as st
from src.i18n import t as _t
from src.license import (
ExpiredLicenseError,
InvalidLicenseError,
LicenseError,
NotActivatedError,
get_manager,
)
# ---------------------------------------------------------------------------
# Sidebar status widget
# ---------------------------------------------------------------------------
def render_license_status_sidebar() -> None:
"""Render the per-page sidebar license badge.
Cheap to call — the manager caches the parsed license, so multiple
pages mounting the same chrome don't pay multiple disk reads.
"""
state = get_manager().current_state()
target = st.sidebar
if not state.activated:
target.caption(f"🔒 {_t('license.status_not_activated')}")
return
if state.error_kind == "invalid":
target.caption(f"⚠️ {_t('license.status_invalid')}")
return
if not state.valid:
# Activated but expired.
target.caption(f"🛑 {_t('license.status_expired')}")
return
tier_label = _t(f"license.tier_{state.tier}") or state.tier.title()
if state.tier == "trial":
label = _t("license.status_trial", days=state.days_remaining)
else:
label = _t(
"license.status_active",
tier=tier_label,
days=state.days_remaining,
)
target.caption(label)
if 0 < state.days_remaining <= 30:
target.warning(
_t("license.renewal_warning_30", days=state.days_remaining)
)
# ---------------------------------------------------------------------------
# Activation page body
# ---------------------------------------------------------------------------
def render_activation_form(*, key_prefix: str = "act") -> None:
"""Render the full activation page body.
*key_prefix* is appended to every Streamlit widget key so the
form can be embedded inline by ``require_license_or_render_activation``
without colliding with an instance that's mounted by the
explicit ``_Activate.py`` page.
"""
st.title(_t("activation.title"))
st.caption(_t("activation.intro"))
mgr = get_manager()
state = mgr.current_state()
# Surface the current state explicitly so a user with an expired /
# invalid license sees what's wrong before they paste anything.
if state.activated and state.error_kind == "expired":
st.warning(
_t("license.renewal_warning_expired", date=state.expires_at[:10])
)
elif state.activated and state.error_kind == "invalid":
st.error(f"⚠️ {state.error_message}")
st.divider()
with st.form(key=f"{key_prefix}_form"):
name = st.text_input(
_t("activation.name_label"),
value=state.name,
help=_t("activation.name_help"),
key=f"{key_prefix}_name",
)
email = st.text_input(
_t("activation.email_label"),
value=state.email,
help=_t("activation.email_help"),
key=f"{key_prefix}_email",
)
blob = st.text_area(
_t("activation.blob_label"),
help=_t("activation.blob_help"),
key=f"{key_prefix}_blob",
height=120,
)
col_activate, col_trial = st.columns(2)
with col_activate:
is_renewal = state.activated
label = (
_t("activation.renew_button") if is_renewal
else _t("activation.activate_button")
)
submit = st.form_submit_button(label, type="primary")
with col_trial:
trial = st.form_submit_button(
_t("activation.trial_button"),
help=_t("activation.trial_help"),
)
# Process the submission.
if submit:
if not blob.strip():
st.error(_t("activation.errors_heading") + ": license blob is empty.")
return
try:
if is_renewal:
lic = mgr.renew(blob)
st.success(_t("activation.renewed", expires=lic.expires_at[:10]))
else:
lic = mgr.activate_from_blob(blob, name=name, email=email)
st.success(_t(
"activation.success",
name=lic.name, expires=lic.expires_at[:10],
))
# Force a clean re-render so the gate sees the new state.
st.rerun()
except LicenseError as e:
st.error(f"{_t('activation.errors_heading')}: {e}")
if trial:
try:
lic = mgr.issue_trial(name=name, email=email)
st.success(_t(
"activation.success",
name=lic.name, expires=lic.expires_at[:10],
))
st.rerun()
except LicenseError as e:
st.error(f"{_t('activation.errors_heading')}: {e}")
# Deactivate (only useful when already activated).
if state.activated:
st.divider()
with st.expander(_t("activation.deactivate_button")):
st.caption(_t("activation.deactivate_help"))
if st.button(
_t("activation.deactivate_button"),
key=f"{key_prefix}_deactivate",
type="secondary",
):
mgr.deactivate()
st.rerun()
# ---------------------------------------------------------------------------
# Gate
# ---------------------------------------------------------------------------
def require_license_or_render_activation(*, feature: Optional[str] = None) -> None:
"""Page-level guard. Call after ``hide_streamlit_chrome``.
If a valid license exists (and *feature*, when supplied, is
unlocked), this is a no-op. Otherwise it renders the activation
form in place of the page body and calls ``st.stop()``.
The chrome helper invokes this automatically so individual pages
don't have to call it; the explicit *feature* form is provided
for tools that want to bypass the global gate but enforce a tier
check (future SKU work).
"""
mgr = get_manager()
if mgr.dev_mode:
return
if mgr.is_valid():
# When feature gating gets enabled per-tool, this is the hook.
if feature is not None:
try:
mgr.require_feature(feature)
except LicenseError as e:
st.error(f"⚠️ {e}")
st.stop()
return
# Otherwise: render the activation form inline and stop the page.
render_activation_form(key_prefix="gate")
st.stop()