Files
datatools-dev/docs/ADMIN.md
Michael db5ec084da docs+code: rename tool labels everywhere
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>
2026-05-16 19:50:09 +00:00

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:

  1. python scripts/generate_keypair.py (on a trusted machine).
  2. Save the private hex to /srv/datatools-license/secrets/license_privkey, chmod 400, chown 10001:10001.
  3. Bake the public hex into the PyInstaller build's DATATOOLS_LICENSE_PUBKEY env.
  4. Wire DATATOOLS_LICENSE_PRIVKEY_FILE + DATATOOLS_LICENSE_PUBKEY into compose.yml's api.environment and add license_privkey to the secrets block.
  5. 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:

  1. Create the product in Gumroad and copy its product_id.
  2. Edit /srv/datatools-license/app/server/config/products.yaml, add a row under gumroad: with that ID + the tier you sold.
  3. 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 / --email you pass to activate must 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:

  1. Stash the private key in a password manager / KMS / hardware token. Losing it means no more renewals — see "Recovery" below.
  2. Delete keypair.env from disk once stored.
  3. Set the public key as DATATOOLS_LICENSE_PUBKEY in 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:

  1. Generate a new keypair (scripts/generate_keypair.py).
  2. Ship a new build with the new public key.
  3. Re-issue every active buyer a new blob signed by the new private key.
  4. 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)