New operator CLI at src/admin_cli.py: mint, list, revoke, ping — talks to the server's /internal/* endpoints over a local SSH tunnel. Stdlib-only on the desktop side (urllib + typer), no new top-level deps. Auth via $DATATOOLS_ADMIN_TOKEN. scripts/generate_license.py is now annotated as a break-glass tool for when the server is unreachable — routine work goes through the new CLI so the authoritative `licenses` row is created. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
216 lines
6.7 KiB
Python
216 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Mint a signed license blob for a buyer (LOCAL, break-glass).
|
|
|
|
.. warning::
|
|
|
|
This script mints **locally**, without going through the license
|
|
server. Prefer :mod:`src.admin_cli` (``datatools-admin mint``)
|
|
for routine work — it writes to the authoritative ``licenses``
|
|
Postgres table and emits the same blob.
|
|
|
|
Reach for this script only when the server is unreachable and a
|
|
buyer needs a license *right now*. Mints from here land in the
|
|
local issuance JSONL log; you'll need to reconcile them into the
|
|
server's DB afterwards.
|
|
|
|
Creator-only tool. Signs with the Ed25519 private key from
|
|
``$DATATOOLS_LICENSE_PRIVKEY`` (production) or the in-tree dev key
|
|
(local development).
|
|
|
|
Every successful mint also appends a record to the issuance log at
|
|
``~/.datatools-creator/issued.jsonl`` (override with
|
|
``$DATATOOLS_ISSUANCE_LOG``). That log is the creator-side system of
|
|
record for "who has a license" — useful for re-delivery, support, and
|
|
as the seed for the future server-side ``licenses`` table.
|
|
|
|
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
|
|
|
|
Mint with the production key (CI / manual fulfillment)::
|
|
|
|
DATATOOLS_LICENSE_PRIVKEY=<prod-private-hex> \\
|
|
python scripts/generate_license.py --name ... --email ...
|
|
|
|
The output is a single base64-encoded token starting with ``DTLIC2:``
|
|
— paste this whole string into the buyer's delivery email or
|
|
deliver as an attached ``.dtlic`` file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
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 default_issuance_log() -> Path:
|
|
"""Path to the local issuance log (creator-side ledger).
|
|
|
|
Resolution order:
|
|
|
|
1. ``$DATATOOLS_ISSUANCE_LOG`` (absolute path; useful for tests
|
|
and for pointing at a shared / encrypted volume).
|
|
2. ``~/.datatools-creator/issued.jsonl`` — separate from the
|
|
buyer-facing ``~/.datatools/`` dir so it never gets bundled
|
|
into a shipped install.
|
|
"""
|
|
override = os.environ.get("DATATOOLS_ISSUANCE_LOG")
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
return Path.home() / ".datatools-creator" / "issued.jsonl"
|
|
|
|
|
|
def append_issuance_log(record: dict, *, path: Path | None = None) -> Path | None:
|
|
"""Best-effort append of *record* to the issuance log.
|
|
|
|
Returns the resolved path on success, ``None`` on IO failure
|
|
(with a warning printed to stderr). We intentionally do not raise:
|
|
the blob has already been minted by the time this runs, and losing
|
|
one ledger row is strictly better than aborting after a successful
|
|
mint and leaving the creator unsure whether to re-mint.
|
|
"""
|
|
p = path or default_issuance_log()
|
|
try:
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
with p.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record, sort_keys=True) + "\n")
|
|
try:
|
|
p.chmod(0o600)
|
|
except OSError:
|
|
pass
|
|
return p
|
|
except OSError as e:
|
|
print(
|
|
f"WARNING: could not write issuance log at {p}: {e}\n"
|
|
" The blob above is still valid — record the mint "
|
|
"manually.",
|
|
file=sys.stderr,
|
|
)
|
|
return None
|
|
|
|
|
|
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).",
|
|
)
|
|
p.add_argument(
|
|
"--no-log",
|
|
action="store_true",
|
|
help=(
|
|
"Skip writing to the issuance log. Use for one-off test "
|
|
"mints; do NOT use for real buyer fulfillment."
|
|
),
|
|
)
|
|
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 not args.no_log:
|
|
log_path = append_issuance_log({
|
|
"license_key": lic.license_key,
|
|
"name": lic.name,
|
|
"email": lic.email,
|
|
"tier": lic.tier.value,
|
|
"issued_at": lic.issued_at,
|
|
"expires_at": lic.expires_at,
|
|
"blob": blob,
|
|
})
|
|
else:
|
|
log_path = None
|
|
|
|
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,
|
|
)
|
|
if log_path:
|
|
print(f" logged: {log_path}", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|