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>
This commit is contained in:
2026-05-13 22:25:19 +00:00
parent 65e17e0a70
commit 23c51fd759
2 changed files with 152 additions and 6 deletions

View File

@@ -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? ## Recovery — what if the private key is lost?
Existing licenses keep working until they expire (the public key in the 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/features.py` | Tier → features mapping |
| `src/license_cli.py` | End-user `activate` / `status` / `renew` / `deactivate` | | `src/license_cli.py` | End-user `activate` / `status` / `renew` / `deactivate` |
| `~/.datatools/license.json` | Where activated licenses are stored on each machine | | `~/.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 |

View File

@@ -1,9 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Mint a signed license blob for a buyer. """Mint a signed license blob for a buyer.
Creator-only tool. Reads the active HMAC secret from the environment Creator-only tool. Signs with the Ed25519 private key from
(``$DATATOOLS_LICENSE_SECRET``) — point it at the same secret baked ``$DATATOOLS_LICENSE_PRIVKEY`` (production) or the in-tree dev key
into the shipped binary or the result will fail to verify. (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 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 \\ --name "Acme Corp" --email ops@acme.com --tier pro \\
--years 2 --output acme.dtlic --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=<prod-private-hex> \\
python scripts/generate_license.py --name ... --email ... 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 — paste this whole string into the buyer's delivery email or
deliver as an attached ``.dtlic`` file. deliver as an attached ``.dtlic`` file.
""" """
@@ -32,6 +38,8 @@ deliver as an attached ``.dtlic`` file.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os
import sys import sys
import uuid import uuid
from pathlib import Path 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: def build_args() -> argparse.ArgumentParser:
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
description="Mint a signed DataTools license blob.", description="Mint a signed DataTools license blob.",
@@ -82,6 +136,14 @@ def build_args() -> argparse.ArgumentParser:
default=None, default=None,
help="Write the blob to this file (default: print to stdout).", 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 return p
@@ -106,6 +168,19 @@ def main(argv: list[str] | None = None) -> int:
payload["signature"] = signature payload["signature"] = signature
blob = encode_blob(payload) 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: if args.output:
args.output.write_text(blob + "\n", encoding="utf-8") args.output.write_text(blob + "\n", encoding="utf-8")
print(f"Wrote license to {args.output}", file=sys.stderr) 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}", f" expires: {lic.expires_at}",
file=sys.stderr, file=sys.stderr,
) )
if log_path:
print(f" logged: {log_path}", file=sys.stderr)
return 0 return 0