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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user