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:
126
scripts/generate_license.py
Normal file
126
scripts/generate_license.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mint a signed license blob for a buyer.
|
||||
|
||||
Creator-only tool. Reads the active HMAC secret from the environment
|
||||
(``$DATATOOLS_LICENSE_SECRET``) — point it at the same secret baked
|
||||
into the shipped binary or the result will fail to verify.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Mint a 1-year CORE license for Jane Doe::
|
||||
|
||||
python scripts/generate_license.py \\
|
||||
--name "Jane Doe" --email jane@example.com --tier core
|
||||
|
||||
Mint a 2-year PRO license and write the blob to a file::
|
||||
|
||||
python scripts/generate_license.py \\
|
||||
--name "Acme Corp" --email ops@acme.com --tier pro \\
|
||||
--years 2 --output acme.dtlic
|
||||
|
||||
Re-sign with a custom secret (useful for staged rollouts)::
|
||||
|
||||
DATATOOLS_LICENSE_SECRET=shipping-secret-2026 \\
|
||||
python scripts/generate_license.py --name ... --email ...
|
||||
|
||||
The output is a single base64-encoded token starting with ``DTLIC1:``
|
||||
— paste this whole string into the buyer's delivery email or
|
||||
deliver as an attached ``.dtlic`` file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
# Make ``src.license`` importable when run from the repo root.
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_PROJECT_ROOT))
|
||||
|
||||
from src.license import Tier # noqa: E402
|
||||
from src.license.crypto import encode_blob, sign # noqa: E402
|
||||
from src.license.features import all_features_for_tier # noqa: E402
|
||||
from src.license.schema import ( # noqa: E402
|
||||
License,
|
||||
_utcnow_iso,
|
||||
default_expiry_iso,
|
||||
)
|
||||
|
||||
|
||||
def build_args() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Mint a signed DataTools license blob.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
p.add_argument("--name", required=True, help="Buyer's full name.")
|
||||
p.add_argument("--email", required=True, help="Buyer's email.")
|
||||
p.add_argument(
|
||||
"--tier",
|
||||
default=Tier.CORE.value,
|
||||
choices=[t.value for t in Tier],
|
||||
help="License tier (default: %(default)s).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--years",
|
||||
type=int,
|
||||
default=1,
|
||||
help="License lifetime in years (default: %(default)s).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--key",
|
||||
default=None,
|
||||
help="Override the auto-generated license key (default: random).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Write the blob to this file (default: print to stdout).",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_args().parse_args(argv)
|
||||
tier = Tier(args.tier)
|
||||
rid = uuid.uuid4().hex
|
||||
key = args.key or f"DT1-{tier.value.upper()}-{rid[:8]}-{rid[8:16]}"
|
||||
|
||||
lic = License(
|
||||
name=args.name,
|
||||
email=args.email,
|
||||
license_key=key,
|
||||
tier=tier,
|
||||
features=all_features_for_tier(tier),
|
||||
issued_at=_utcnow_iso(),
|
||||
expires_at=default_expiry_iso(years=args.years),
|
||||
signature="",
|
||||
)
|
||||
signature = sign(lic.to_canonical_dict())
|
||||
payload = lic.to_canonical_dict()
|
||||
payload["signature"] = signature
|
||||
blob = encode_blob(payload)
|
||||
|
||||
if args.output:
|
||||
args.output.write_text(blob + "\n", encoding="utf-8")
|
||||
print(f"Wrote license to {args.output}", file=sys.stderr)
|
||||
else:
|
||||
print(blob)
|
||||
print(
|
||||
f" name: {lic.name}\n"
|
||||
f" email: {lic.email}\n"
|
||||
f" tier: {lic.tier.value}\n"
|
||||
f" key: {lic.license_key}\n"
|
||||
f" expires: {lic.expires_at}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user