# 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 ```bash 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: ```bash 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 ```bash # 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 ```bash # 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 ```bash 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: ```bash # /etc/cron.daily/datatools-license-backup — see SETUP-LICENSE-SERVER.md §9 ``` Until that's in place, dump manually before any risky operation: ```bash 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=`. 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:** ```bash # 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. ```bash python scripts/generate_license.py \ --name "Michael Dombaugh" \ --email michael.dombaugh@gmail.com \ --tier core ``` Copy the `DTLIC2:…` blob from stdout, then activate: ```bash python -m src.license_cli activate "DTLIC2:..." \ --name "Michael Dombaugh" \ --email michael.dombaugh@gmail.com ``` Verify: ```bash 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. ```bash 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: ```bash export DATATOOLS_LICENSE_PRIVKEY= 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** | Deduplicator, Text Cleaner, Format Standardizer | | **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): ```bash python scripts/generate_license.py --name "QA Bot" --email qa@datatools.app --tier core --years 5 ``` Mint with a stable, human-readable key: ```bash 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): ```bash python -m src.license_cli renew "DTLIC2:..." ``` Check what's active locally: ```bash python -m src.license_cli status ``` Wipe a local license (move to a new machine, debug a buyer issue): ```bash 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 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) |