negative-support

0.2.0

Negative-space 3D print support generator for STEP and mesh files

License
Unknown license
Published
March 15, 2026
16h ago
Package Registry
README badge Customize →
License Sources
SourceLicenseClass
Licensie (detected)
Pending-
PyPI (reported)
Not reported-

License detection is still in progress for this version.

Loading dependencies…
License File
"""License management for negative-support.

Requires a valid license token (ns_live_*) to run. Tokens are issued
when you sign in at https://negative.support. Free tokens get 10 runs,
paid tokens get unlimited.
"""

from __future__ import annotations

import datetime
import getpass
import hashlib
import json
import os
import platform
import sys
import uuid
from pathlib import Path
from urllib import request, error as urlerror

# ── Configuration ─────────────────────────────────────────────────────

FREE_RUNS = 10
CONFIG_DIR = Path.home() / ".negative-support"
LICENSE_FILE = CONFIG_DIR / "license.json"
TOKEN_PREFIX = "ns_live_"
API_BASE = os.environ.get("NS_API_BASE", "https://negative.support")
GRACE_DAYS = 7  # allow offline usage for this many days after last validation


# ── Machine fingerprint ───────────────────────────────────────────────

def _get_machine_id() -> str:
    """Generate a stable machine fingerprint.

    SHA-256 of hostname + MAC address + OS + username.
    Survives reinstalls but changes if user switches machines.
    """
    parts = [
        platform.node(),           # hostname
        str(uuid.getnode()),       # MAC address as int
        platform.system(),         # OS
        platform.machine(),        # architecture
        getpass.getuser(),         # username
    ]
    raw = "|".join(parts)
    return hashlib.sha256(raw.encode()).hexdigest()


# ── Local storage ─────────────────────────────────────────────────────

def _ensure_config_dir() -> None:
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)


def _read_json(path: Path) -> dict | None:
    try:
        return json.loads(path.read_text())
    except (FileNotFoundError, json.JSONDecodeError):
        return None


def _write_json(path: Path, data: dict) -> None:
    _ensure_config_dir()
    path.write_text(json.dumps(data, indent=2) + "\n")


# ── Server communication ─────────────────────────────────────────────

def _api_post(endpoint: str, body: dict, timeout: float = 5.0) -> dict | None:
    """POST JSON to the license server. Returns parsed response or None on failure."""
    url = f"{API_BASE}{endpoint}"
    data = json.dumps(body).encode()
    req = request.Request(
        url,
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read())
    except (urlerror.URLError, OSError, json.JSONDecodeError, TimeoutError):
        return None


# ── Token validation ─────────────────────────────────────────────────

def _is_valid_token_format(token: str) -> bool:
    """Check if token matches expected format: ns_live_<32 hex chars>."""
    if not token.startswith(TOKEN_PREFIX):
        return False
    hex_part = token[len(TOKEN_PREFIX):]
    return len(hex_part) == 32 and all(c in "0123456789abcdef" for c in hex_part)


def _validate_token_server(token: str) -> dict | None:
    """Validate token with server. Returns response or None if unreachable."""
    return _api_post("/api/validate", {"token": token})


def _validate_license() -> tuple[bool, str]:
    """Check if a valid license exists.

    Returns (is_valid, message).
    """
    lic = _read_json(LICENSE_FILE)
    if lic is None or "token" not in lic:
        return False, ""

    token = lic["token"]
    if not _is_valid_token_format(token):
        return False, "Invalid token format."

    # Try server validation
    resp = _validate_token_server(token)
    if resp is not None:
        if resp.get("valid"):
            # Update last validated timestamp
            lic["last_validated"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
            lic["plan"] = resp.get("plan", "lifetime")
            lic["runs_used"] = resp.get("runs_used", 0)
            _write_json(LICENSE_FILE, lic)
            plan = lic["plan"]
            if plan == "lifetime":
                return True, "Licensed (lifetime)"
            else:
                remaining = resp.get("free_remaining", FREE_RUNS - resp.get("runs_used", 0))
                return True, f"Free tier ({remaining} run{'s' if remaining != 1 else ''} remaining)"
        else:
            error = resp.get("error", "Token is no longer valid.")
            if "exhausted" in str(error).lower() or resp.get("runs_used", 0) >= FREE_RUNS:
                return False, "exhausted"
            return False, error

    # Server unreachable — check grace period
    last_validated = lic.get("last_validated")
    if last_validated:
        try:
            last_dt = datetime.datetime.fromisoformat(last_validated)
            now = datetime.datetime.now(datetime.timezone.utc)
            days_offline = (now - last_dt).days
            if days_offline <= GRACE_DAYS:
                return True, f"Licensed (offline, validated {days_offline}d ago)"
            else:
                return False, (
                    f"License not validated in {days_offline} days. "
                    "Connect to the internet to re-validate."
                )
        except (ValueError, TypeError):
            pass

    return False, "Cannot validate license (server unreachable)."


# ── Public API ────────────────────────────────────────────────────────

def check_license() -> tuple[bool, str]:
    """Check if the user is allowed to run.

    Returns (allowed, message).
    Requires a valid ns_live_* token. No anonymous free tier.
    """
    has_license, msg = _validate_license()
    if has_license:
        return True, msg

    if msg == "exhausted":
        return False, "exhausted"

    # No token found
    return False, "no_token"


def activate_token(token: str) -> tuple[bool, str]:
    """Activate a license token.

    Returns (success, message).
    """
    token = token.strip()
    if not _is_valid_token_format(token):
        return False, (
            f"Invalid token format. Expected: {TOKEN_PREFIX}<32 hex characters>\n"
            f"Example: {TOKEN_PREFIX}{'a1b2c3d4' * 4}"
        )

    # Try server activation
    machine_id = _get_machine_id()
    resp = _api_post("/api/activate", {"token": token, "machine_id": machine_id})

    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    lic = {
        "token": token,
        "activated_at": now,
        "last_validated": now,
        "plan": "lifetime",
    }

    if resp is not None:
        if resp.get("ok"):
            lic["plan"] = resp.get("plan", "lifetime")
            _write_json(LICENSE_FILE, lic)
            return True, f"License activated! Plan: {lic['plan']}"
        else:
            return False, resp.get("error", "Server rejected this token.")
    else:
        # Server unreachable — save locally, validate later
        _write_json(LICENSE_FILE, lic)
        return True, (
            "Token saved. Could not reach server for validation — "
            "it will be verified on next online run."
        )


def get_status() -> str:
    """Get a human-readable license status string."""
    lines = []

    lic = _read_json(LICENSE_FILE)
    if lic and "token" in lic:
        token = lic["token"]
        masked = token[:8] + "..." + token[-4:] if len(token) > 12 else token
        lines.append(f"Token:      {masked}")
        lines.append(f"Plan:       {lic.get('plan', 'unknown')}")
        lines.append(f"Activated:  {lic.get('activated_at', 'unknown')}")
        lines.append(f"Validated:  {lic.get('last_validated', 'never')}")

        valid, msg = _validate_license()
        lines.append(f"Status:     {'valid' if valid else 'invalid'} — {msg}")
    else:
        lines.append("Token:      none")
        lines.append("")
        lines.append("Sign in at https://negative.support to get your token,")
        lines.append("then activate it:")
        lines.append("  negative-support --activate <your-token>")

    return "\n".join(lines)


def print_no_token_message() -> None:
    """Print the message shown when no token is configured."""
    print()
    print("  No license token found.")
    print()
    print("  1. Sign in at https://negative.support")
    print("  2. Copy your token from the user menu")
    print("  3. Run: negative-support --activate <your-token>")
    print()
    print("  Free accounts get 10 runs. Buy a lifetime license for unlimited use.")
    print()


def print_exhausted_message() -> None:
    """Print the message shown when free runs are exhausted."""
    print()
    print(f"  Free tier exhausted ({FREE_RUNS}/{FREE_RUNS} runs used).")
    print()
    print("  To continue, buy a lifetime license at:")
    print("    https://negative.support/#pricing")
    print()
    print("  Your existing token will be upgraded — no re-activation needed.")
    print()
Versions
3 versions
VersionLicensePublishedStatus
0.2.0 Latest Viewing-Mar 15, 2026 Pending
0.1.1 MITMar 15, 2026 Scanned
0.1.0 MITMar 15, 2026 Scanned