New ARCHITECTURE.md pulls the desktop app (TECHNICAL.md) and the
license server (LICENSE-SERVER.md) into a single picture — the two
were never reconciled into an end-to-end view before.
Contents:
§1. System diagram (ASCII) showing operator laptop, license
server stack (nginx → FastAPI → Postgres), Postmark, Gumroad,
and the buyer's machine — with the three primary flows
(sale, manual mint, offline activation) traced through it.
§2. Tech stack diagram, layered: desktop / server / operator /
external SaaS, with version pins.
§3. Trust + isolation boundaries table — what crosses each one
and what the threat model is.
§4. "Where things are stored" — paths, tables, files.
§5. Pointers to the deeper per-component docs.
ASCII over Mermaid since the repo's Gitea version is unknown and
plain text renders in every viewer / IDE / raw `cat`.
LICENSE-SERVER.md status flipped from "design proposal, not built"
to "deployed (PR 1 + PR 2 code merged)" — that was stale since
the PR 1 deploy yesterday.
TECHNICAL.md and ADMIN.md gain one-line pointers to ARCHITECTURE.md
so people land at the unified view when looking for "how does it
all fit together".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
482 lines
16 KiB
Markdown
482 lines
16 KiB
Markdown
# 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** | 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) |
|