# 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=<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=<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):

```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) |
