Sweep follow-up to 93e43fc. Display labels now consistent across docs,
landing pages, CLI output, code comments, docstrings, and test prose.
Five parallel surfaces touched:
- docs (EN + ES): README, USER-GUIDE, CLI-REFERENCE, and 11 internal
design/planning docs
- landing pages: index + bookkeeper/revops/shopify-pet
- src: CLI module docstrings, _TOOL_DISPLAY dicts in cli_analyze.py
and gui/components/_legacy.py, core module headers, every tool
page's module docstring
- tests: class/method/module docstrings and section-header comments
- test-cases READMEs
Page slugs (1_Deduplicator etc.), tool_id strings (01_deduplicator
etc.), Python class names (TestDeduplicatorWorkflow, FeatureFlag.*),
URL paths, anchor IDs, CSS classes, and asset filenames were left
intact since they're code identifiers / structural references.
All 2033 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
ADMIN — Internal license operations
Creator/operator-only reference. End users should read USER-GUIDE.md instead.
This doc covers everything the creator does that buyers never see: minting through the live server, where state lives on the box, how to rotate secrets, generating the signing keypair, the dev vs. production key story, and how to recover from key loss.
For the end-to-end system + tech stack diagrams, see ARCHITECTURE.md.
Live deployment (PR 1)
The license server is running at:
| URL | What it serves |
|---|---|
https://datatools.unalogix.com/ |
Marketing site (placeholder — "DataTools — coming soon") |
https://licenses.datatools.unalogix.com/health |
Liveness + DB reachability probe |
https://licenses.datatools.unalogix.com/internal/* |
nginx-blocked on the public side — accessible only via SSH tunnel |
Postgres @ 127.0.0.1:5433 (localhost) |
DB containing the authoritative licenses table |
Host: 46.225.166.142 (Ubuntu 24.04), nginx 1.24, Postgres 16-alpine + FastAPI in Docker.
Cert: Let's Encrypt, covers both subdomains, expires 2026-08-12, auto-renews via certbot.timer.
On-box state
| Path | Contents |
|---|---|
/srv/datatools-license/ |
Deploy root, mode 750, owned by datatools-api |
/srv/datatools-license/compose.yml |
Production docker-compose definition |
/srv/datatools-license/app/ |
Git clone of this repo (re-clone or git pull to update) |
/srv/datatools-license/secrets/ |
Mode 750 dir holding pg_password, admin_token. Files are mode 400, owned UID 10001 (container app user) |
/srv/datatools-license/backups/ |
Postgres dumps land here (cron not yet wired — see §"Backups" below) |
/etc/nginx/sites-available/unalogix |
nginx config for both subdomains |
/etc/letsencrypt/live/datatools.unalogix.com/ |
TLS cert + key |
Container names: datatools-api, datatools-postgres. Both use
restart: unless-stopped.
Get the admin token
ssh michael@46.225.166.142 'sudo cat /srv/datatools-license/secrets/admin_token'
The token is never in git, in environment-variable dumps, or in
docker inspect. It lives on disk under mode 400 / UID 10001 (so only
root and the container app user can read it).
Rotate the admin token
Any time it's been shown somewhere it shouldn't, or as routine hygiene:
cd /srv/datatools-license
openssl rand -hex 32 > secrets/admin_token
chown 10001:10001 secrets/admin_token
chmod 400 secrets/admin_token
docker compose restart api # ~3 seconds; old token stops working immediately
Mint a license from your laptop
# 1. Open the SSH tunnel (leave running in a background terminal)
ssh -L 8090:127.0.0.1:8090 michael@46.225.166.142 -N &
# 2. Set the auth env
export DATATOOLS_ADMIN_TOKEN="$(ssh michael@46.225.166.142 'sudo cat /srv/datatools-license/secrets/admin_token')"
export DATATOOLS_ADMIN_URL=http://127.0.0.1:8090
# 3. Mint
python3 -m src.admin_cli mint \
--name "Buyer Name" \
--email buyer@example.com \
--tier core
# 4. (optional) List or revoke
python3 -m src.admin_cli list --email buyer@example.com
python3 -m src.admin_cli revoke DT1-CORE-xxxx-yyyy --reason "refund"
The blob lands in the response (and in the licenses table). Deliver it
to the buyer however suits — copy-paste into email, attach as .dtlic.
Inspect / debug
# Container status + recent logs
ssh michael@46.225.166.142 'cd /srv/datatools-license && docker compose ps && docker compose logs api --tail 30'
# Query the licenses table directly
ssh michael@46.225.166.142 'cd /srv/datatools-license && docker compose exec -T postgres \
psql -U datatools_api -d datatools_licenses -c "SELECT license_key, email, tier, source, expires_at FROM licenses ORDER BY created_at DESC LIMIT 20;"'
# Public-side health
curl https://licenses.datatools.unalogix.com/health
Bring it down / back up / rebuild
cd /srv/datatools-license
# Restart just the API (e.g. after rotating a secret)
docker compose restart api
# Restart everything
docker compose restart
# Bring down (DB volume PRESERVED)
docker compose down
# Bring up
docker compose up -d
# Rebuild the image after a git pull
cd app && git pull
cd ..
docker compose build && docker compose up -d
docker compose exec api alembic upgrade head # if new migrations
Backups (not yet automated)
Postgres state is the system of record for the customer list — once PR 2 auto-mints from Gumroad webhooks, losing the DB would mean losing every buyer record. Schedule a daily dump:
# /etc/cron.daily/datatools-license-backup — see SETUP-LICENSE-SERVER.md §9
Until that's in place, dump manually before any risky operation:
docker compose exec -T postgres \
pg_dump -U datatools_api datatools_licenses \
| gzip > backups/db-$(date -u +%Y%m%dT%H%M%SZ).sql.gz
Production signing key (not yet rotated)
The server currently signs with the in-tree dev keypair (no
DATATOOLS_LICENSE_PRIVKEY_FILE configured → falls back to
src/license/_dev_keypair.py). That matches what the desktop currently
verifies against, so existing buyers continue to work.
Before shipping v1.0 to paying buyers, rotate to a production keypair:
python scripts/generate_keypair.py(on a trusted machine).- Save the private hex to
/srv/datatools-license/secrets/license_privkey, chmod 400, chown 10001:10001. - Bake the public hex into the PyInstaller build's
DATATOOLS_LICENSE_PUBKEYenv. - Wire
DATATOOLS_LICENSE_PRIVKEY_FILE+DATATOOLS_LICENSE_PUBKEYinto compose.yml'sapi.environmentand addlicense_privkeyto the secrets block. docker compose restart api.
What's deployed (PR 1) vs queued (PR 2 / 3)
| Capability | Status |
|---|---|
| Mint API + Postgres + auth | Live |
datatools-admin CLI (manual mints) |
Live |
licenses.datatools.unalogix.com/health public |
Live |
| Gumroad webhook receiver | PR 2 — code merged, deploy pending |
| Postmark transactional email | PR 2 — code merged, deploy pending |
| Buyer renewal / re-delivery portal | PR 3 |
| Cloudflare in front (DDoS / WAF) | Deferred (DNS at supercp/cPanel) |
| Production signing keypair | Deferred (still using dev key) |
| Automated DB backups | Pending — see §"Backups" |
Running a Gumroad webhook (PR 2)
Once PR 2 is deployed, sales fire POST to
https://licenses.datatools.unalogix.com/webhooks/gumroad?secret=<gumroad_secret>.
Auth is the URL secret (Gumroad's recommended pattern). The handler
audit-logs the raw payload, mints idempotently keyed on sale_id,
sends the buyer their blob via Postmark, and returns 200 (always —
non-2xx would trigger 3-day retry storms).
Adding a new SKU:
- Create the product in Gumroad and copy its
product_id. - Edit
/srv/datatools-license/app/server/config/products.yaml, add a row undergumroad:with that ID + the tier you sold. cd /srv/datatools-license && docker compose restart api— the config is read at startup and cached.
Inspecting webhook activity:
# Recent webhook deliveries (all storefronts share this table)
ssh michael@46.225.166.142 'cd /srv/datatools-license && docker compose exec -T postgres \
psql -U datatools_api -d datatools_licenses -c \
"SELECT received_at, order_id, processed, error FROM gumroad_events ORDER BY received_at DESC LIMIT 20;"'
# Failures only (replay candidates)
ssh michael@46.225.166.142 'cd /srv/datatools-license && docker compose exec -T postgres \
psql -U datatools_api -d datatools_licenses -c \
"SELECT id, received_at, order_id, error FROM gumroad_events WHERE processed=false ORDER BY received_at DESC;"'
Replaying a failed webhook (after fixing the products.yaml mapping
or whatever surfaced the error): the safest path is to ask the buyer
to re-trigger via Gumroad's "Send Test Ping" button in their order
record, or mint manually via datatools-admin mint --source manual
and add a note linking to the original gumroad_events.id.
Testing without buyers: Gumroad's seller dashboard has a "Send
Test Ping" button. It sets test=true in the payload; the adapter
tags the resulting license with notes='gumroad test ping' so it's
trivially filterable later.
TL;DR — I just need a license for my dev machine
You're running from source, so the repo's embedded dev keypair signs and verifies. No env vars needed.
python scripts/generate_license.py \
--name "Michael Dombaugh" \
--email michael.dombaugh@gmail.com \
--tier core
Copy the DTLIC2:… blob from stdout, then activate:
python -m src.license_cli activate "DTLIC2:..." \
--name "Michael Dombaugh" \
--email michael.dombaugh@gmail.com
Verify:
python -m src.license_cli status
License lands at ~/.datatools/license.json, valid 1 year.
The
--name/activatemust match the values the blob was minted with — they're part of the signed payload.
Key model (Ed25519, asymmetric)
| Key | Lives where | Used for |
|---|---|---|
| Private (32 bytes hex) | Creator's password manager / KMS only | Signing license blobs |
| Public (32 bytes hex) | Baked into the shipped binary | Verifying blobs at activation |
The split is the whole point: an attacker with a copy of the binary still can't mint blobs — they'd need the private key, which never ships.
There's also an in-tree dev keypair (src/license/_dev_keypair.py)
derived deterministically from a seed. It's used when no env vars are set,
so devs/tests can sign and verify locally without juggling secrets. Frozen
builds that still use it are rejected at startup by
assert_production_safe — see src/license/crypto.py:84.
Blob format prefix: DTLIC2: (v1 was HMAC; v2 is Ed25519).
One-time setup — generating the production keypair
Run once, before the first paid release.
python scripts/generate_keypair.py --output keypair.env
You'll get:
DATATOOLS_LICENSE_PRIVKEY=<64 hex chars> # KEEP SECRET
DATATOOLS_LICENSE_PUBKEY=<64 hex chars> # BAKE INTO BUILD
Then:
- Stash the private key in a password manager / KMS / hardware token. Losing it means no more renewals — see "Recovery" below.
- Delete
keypair.envfrom disk once stored. - Set the public key as
DATATOOLS_LICENSE_PUBKEYin the PyInstaller build environment. The shipped binary embeds it via the env at freeze time.
Minting a buyer license (production)
With the production private key loaded:
export DATATOOLS_LICENSE_PRIVKEY=<your-private-hex>
python scripts/generate_license.py \
--name "Buyer Name" \
--email buyer@example.com \
--tier core \
--years 1 \
--output buyer.dtlic
Flags:
| Flag | Default | Notes |
|---|---|---|
--name |
required | Buyer's full name. Goes into signed payload. |
--email |
required | Buyer's email. Goes into signed payload. |
--tier |
core |
One of: lite, core, pro |
--years |
1 |
Lifetime in years |
--key |
random | Override the auto-generated license key |
--output / -o |
stdout | Write blob to file instead of printing |
Deliver the blob to the buyer either inline in the purchase email or as
the attached .dtlic file.
Tiers
| Tier | Features |
|---|---|
| lite | Find Duplicates, Clean Text, Standardize Formats |
| core | All 9 tools |
| pro | All 9 tools + future Pro-only features |
Source of truth: src/license/features.py::all_features_for_tier.
Useful one-liners
Mint a free internal/team license (dev key, no env needed):
python scripts/generate_license.py --name "QA Bot" --email qa@datatools.app --tier core --years 5
Mint with a stable, human-readable key:
python scripts/generate_license.py --name "Acme Corp" --email ops@acme.com \
--tier pro --key "DT1-PRO-ACME-2026"
Renew an existing buyer (just re-mint with the same email; they paste the new blob):
python -m src.license_cli renew "DTLIC2:..."
Check what's active locally:
python -m src.license_cli status
Wipe a local license (move to a new machine, debug a buyer issue):
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:
{
"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:
# 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 shipped binary still verifies them). What breaks:
- Renewals — you can't mint a new blob for an existing buyer.
- New sales — you can't mint anything.
Path forward:
- Generate a new keypair (
scripts/generate_keypair.py). - Ship a new build with the new public key.
- Re-issue every active buyer a new blob signed by the new private key.
- Communicate the upgrade path to buyers.
Treat the private key like a code-signing cert — back it up to two independent secure locations.
Files & code pointers
| Path | Purpose |
|---|---|
scripts/generate_keypair.py |
One-time keypair generation |
scripts/generate_license.py |
Mint a signed blob |
src/license/crypto.py |
Sign / verify / dev-key detection |
src/license/_dev_keypair.py |
In-tree dev keypair (never ships in prod) |
src/license/manager.py |
assert_production_safe startup check |
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 |
docs/SETUP-LICENSE-SERVER.md |
Self-hosted server install runbook (DNS, Docker, nginx, TLS, backups) |