From 23c51fd75903298e00293faae487530925f258bd Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 13 May 2026 22:25:19 +0000 Subject: [PATCH] feat(license): local issuance log for minted blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/ADMIN.md | 69 ++++++++++++++++++++++++++++ scripts/generate_license.py | 89 ++++++++++++++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/docs/ADMIN.md b/docs/ADMIN.md index aedc344..61dfc91 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -165,6 +165,73 @@ python -m src.license_cli deactivate --- +## Customer record-keeping — the issuance log + +Every successful `scripts/generate_license.py` run appends one JSON +line to a local **issuance log**. This is the creator-side system of +record for "who has a license" until the server-side flow in +`docs/LICENSE-SERVER.md` lands. + +**Path:** `~/.datatools-creator/issued.jsonl` (override with +`$DATATOOLS_ISSUANCE_LOG`). Mode 600. Outside the buyer-facing +`~/.datatools/` dir so it never gets bundled into a shipped install. + +**Format** — one record per line: + +```json +{ + "license_key": "DT1-CORE-5dd8e1db-d90c4656", + "name": "Michael Dombaugh", + "email": "michael.dombaugh@gmail.com", + "tier": "core", + "issued_at": "2026-05-13T22:10:27Z", + "expires_at": "2031-05-13T22:10:27Z", + "blob": "DTLIC2:..." +} +``` + +The full blob is stored so you can re-deliver to a buyer who lost +their email without re-minting (the re-minted blob would have a +different signature and would invalidate any device they'd already +activated against the old one). + +**Useful operations:** + +```bash +# Full list of issued licenses +cat ~/.datatools-creator/issued.jsonl | jq + +# Find by buyer email +jq -r 'select(.email == "buyer@example.com")' ~/.datatools-creator/issued.jsonl + +# Count by tier +jq -r .tier ~/.datatools-creator/issued.jsonl | sort | uniq -c + +# Licenses expiring in the next 30 days +jq -r 'select(.expires_at < "'"$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"'") | .email' \ + ~/.datatools-creator/issued.jsonl + +# Re-deliver a buyer's blob +jq -r 'select(.email == "buyer@example.com") | .blob' \ + ~/.datatools-creator/issued.jsonl +``` + +**Skipping the log** for test mints: pass `--no-log`. Never use this +for real buyer fulfillment — an unlogged mint is invisible to every +future query and to the eventual server-side migration. + +**Backup:** treat this file like a small business ledger. Copy it +into your password manager / encrypted cloud sync alongside the +private key. Losing it doesn't break anything cryptographically (you +can still mint new licenses) but it does lose the customer list. + +**Migrating to the server:** the JSONL schema is intentionally close +to the planned `licenses` table in `docs/LICENSE-SERVER.md`. Once the +server is up, a one-shot import script will read the JSONL and +insert each row. + +--- + ## Recovery — what if the private key is lost? Existing licenses keep working until they expire (the public key in the @@ -197,3 +264,5 @@ independent secure locations. | `src/license/features.py` | Tier → features mapping | | `src/license_cli.py` | End-user `activate` / `status` / `renew` / `deactivate` | | `~/.datatools/license.json` | Where activated licenses are stored on each machine | +| `~/.datatools-creator/issued.jsonl` | Creator-side issuance log (one JSON line per mint) | +| `docs/LICENSE-SERVER.md` | Design for the future online issuance + record-keeping system | diff --git a/scripts/generate_license.py b/scripts/generate_license.py index 1e5357e..057bf14 100644 --- a/scripts/generate_license.py +++ b/scripts/generate_license.py @@ -1,9 +1,15 @@ #!/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. +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 -------- @@ -19,12 +25,12 @@ Mint a 2-year PRO license and write the blob to a file:: --name "Acme Corp" --email ops@acme.com --tier pro \\ --years 2 --output acme.dtlic -Re-sign with a custom secret (useful for staged rollouts):: +Mint with the production key (CI / manual fulfillment):: - DATATOOLS_LICENSE_SECRET=shipping-secret-2026 \\ + DATATOOLS_LICENSE_PRIVKEY= \\ python scripts/generate_license.py --name ... --email ... -The output is a single base64-encoded token starting with ``DTLIC1:`` +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. """ @@ -32,6 +38,8 @@ deliver as an attached ``.dtlic`` file. from __future__ import annotations import argparse +import json +import os import sys import uuid from pathlib import Path @@ -51,6 +59,52 @@ from src.license.schema import ( # noqa: E402 ) +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.", @@ -82,6 +136,14 @@ def build_args() -> argparse.ArgumentParser: 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 @@ -106,6 +168,19 @@ def main(argv: list[str] | None = None) -> int: 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) @@ -119,6 +194,8 @@ def main(argv: list[str] | None = None) -> int: f" expires: {lic.expires_at}", file=sys.stderr, ) + if log_path: + print(f" logged: {log_path}", file=sys.stderr) return 0