Files
datatools-dev/scripts/generate_license.py
Michael 23c51fd759 feat(license): local issuance log for minted blobs
generate_license.py now appends every minted license to
~/.datatools-creator/issued.jsonl (overridable via env). This is the
creator-side system of record until the server-side flow lands.

The full blob is stored alongside name/email/tier/expiry so buyers
who lose their delivery email can be re-served without re-minting.
File is created mode 600 and lives outside the buyer-facing
~/.datatools/ dir so it never gets bundled into a shipped install.

Log failures are non-fatal (warning to stderr) — the mint already
succeeded by the time we try to log, and forcing a re-mint after a
log error would invalidate any device the buyer had activated. Pass
--no-log for test mints.

ADMIN.md adds a "Customer record-keeping" section with the path,
schema, jq one-liners, and migration note pointing at the upcoming
LICENSE-SERVER.md design doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:25:19 +00:00

204 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Mint a signed license blob for a buyer.
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())