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

@@ -38,12 +38,22 @@ from . import _legacy as _legacy # noqa: F401 (keep for direct access)
# Names exported from _legacy.py that pages currently use. Kept here as
# the canonical public list so a removal from _legacy is a visible
# breaking change instead of a silent drop.
from .activation import ( # noqa: F401 re-exported
render_activation_form,
render_license_status_sidebar,
require_license_or_render_activation,
)
__all__ = [
# Shared chrome / pickup / gate
"hide_streamlit_chrome",
"quit_button",
"pickup_or_upload",
"require_normalization_gate",
# License gate + activation form
"render_activation_form",
"render_license_status_sidebar",
"require_license_or_render_activation",
# Dedup widgets
"config_panel",
"match_group_card",

View File

@@ -72,13 +72,21 @@ footer {
"""
def hide_streamlit_chrome() -> None:
def hide_streamlit_chrome(*, gate_license: bool = True) -> None:
"""Inject CSS to hide Streamlit's default header, menu, and footer.
Also renders the sidebar language selector, since every entrypoint
that hides the default chrome wants the picker visible in the
same place. Pages that want a clean chrome without the selector can
inject ``_HIDE_CHROME_CSS`` themselves instead of calling this.
Also renders the sidebar language selector + license status badge,
since every entrypoint that hides the default chrome wants those
visible in the same place. Pages that want a clean chrome without
them can inject ``_HIDE_CHROME_CSS`` themselves instead of calling
this.
When *gate_license* is True (the default) the function calls
:func:`require_license_or_render_activation` after the sidebar
widgets render. If no valid license is present, the activation
form replaces the page body and the page short-circuits via
``st.stop()``. The Activate page itself passes ``False`` so it
can render its own form without recursion.
"""
st.markdown(_HIDE_CHROME_CSS, unsafe_allow_html=True)
# Imported lazily so this module stays importable in environments
@@ -86,6 +94,14 @@ def hide_streamlit_chrome() -> None:
# individual legacy helpers).
from src.i18n import render_language_selector
render_language_selector()
# License chrome: sidebar status badge + inline gate.
from .activation import (
render_license_status_sidebar,
require_license_or_render_activation,
)
render_license_status_sidebar()
if gate_license:
require_license_or_render_activation()
# ---------------------------------------------------------------------------

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()