Files
datatools-dev/docs/LICENSE-SERVER.md
Michael 624f99653e docs(arch): end-to-end system + tech-stack diagrams
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>
2026-05-14 01:59:05 +00:00

260 lines
11 KiB
Markdown

# LICENSE-SERVER — online issuance & record-keeping
**Status:** **deployed (PR 1 + PR 2 code merged)**. Live at
`licenses.datatools.unalogix.com`. See `ADMIN.md §"Live deployment"`
for day-2 operations, and `ARCHITECTURE.md` for the end-to-end
diagram including the desktop and storefronts.
This doc describes the smallest useful server we could build to
replace the manual mint-and-paste workflow, without compromising the
"your data never leaves your computer" promise to buyers (see
`DECISIONS.md §9b`).
---
## Goals
1. **Automate fulfillment.** Gumroad sale → buyer gets a blob in
their inbox within seconds. No creator intervention.
2. **Authoritative customer list.** A queryable record of who has
what tier, when it expires, what they paid. Replaces the JSONL
log as the system of record.
3. **Self-service renewal & re-delivery.** Buyer enters their email
→ gets a fresh blob or a copy of their existing one. Cuts support
load.
4. **Move the private key off the founder's laptop.** Today the prod
private key has to be loaded as an env var to mint anything;
that's a security hazard. Server-side, it lives in a KMS and the
laptop never touches it.
## Non-goals
- **No phone-home from the desktop app.** Activation stays offline.
The shipped binary still verifies blobs against the embedded
pubkey with no network call. `DECISIONS.md §9b` stands.
- **No per-machine activation limits enforced server-side.** v1
treats one license = one buyer, used on as many of their machines
as they want. Revisit only if abuse appears.
- **No telemetry.** The server only knows what the buyer or Gumroad
tells it (purchase events, renewal requests). It does not learn
anything from desktop installations.
---
## Architecture
```
┌─────────────────┐
│ Gumroad │
└────────┬────────┘
│ webhook (sale, refund)
┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ Buyer email │◄──────│ Mint API │──────►│ licenses │
│ (SMTP send) │ │ (Python web) │ │ (Postgres) │
└──────────────┘ └───────┬───────┘ └──────────────┘
│ sign() via
┌─────────────────┐
│ KMS / HSM │
│ (private key) │
└─────────────────┘
┌─────────────────────────────────────────┐
│ Renewal / re-delivery portal │
│ - buyer enters email │
│ - signed magic link │
│ - sees current license + "resend" │
└─────────────────────────────────────────┘
```
---
## Components
### 1. Mint API
Thin Python web service (FastAPI or Flask — Streamlit isn't appropriate
here). Two internal endpoints:
- `POST /internal/mint` — name, email, tier, years → blob + DB row.
Auth: shared HMAC header from the webhook receiver only.
- `POST /internal/revoke` — license_key → sets `revoked_at`. Auth: same.
The mint endpoint is the **only** place that calls `crypto.sign()`.
It pulls the private key from the KMS at request time; the key
material never lives in the API process's environment.
### 2. Webhook receiver
Public endpoint `POST /webhooks/gumroad`. Verifies Gumroad's
signature, maps the payload to a `mint` call, returns 200. Stores
the raw payload to a `gumroad_events` table for audit.
Refunds: webhook → `POST /internal/revoke` keyed on
`gumroad_order_id`. The desktop app doesn't currently honor
revocations (no online check), but future buyers won't be able to
renew a revoked license, and the row remains as evidence if a
dispute escalates.
### 3. Renewal portal
Single-page form, public. Buyer enters email → server emails a
signed magic link → click → page shows their license (tier, expiry,
"resend blob" button, "renew" button).
Renew flow: button → `POST /internal/mint` with the same name/email
and a fresh expiry → buyer gets the new blob → pastes into desktop
app via existing `license_cli.py renew`. No code change in the
desktop app.
### 4. Database
Postgres (small — a few thousand rows for the foreseeable future).
Single source of truth for the customer list.
---
## Schema
```sql
CREATE TABLE licenses (
license_key text PRIMARY KEY, -- DT1-{TIER}-xxxx-xxxx
name text NOT NULL,
email text NOT NULL,
tier text NOT NULL, -- lite | core | pro | enterprise
issued_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
blob text NOT NULL, -- DTLIC2:...
gumroad_order_id text UNIQUE, -- null for manual mints
revoked_at timestamptz, -- null = active
notes text -- free-form support notes
);
CREATE INDEX idx_licenses_email ON licenses (lower(email));
CREATE INDEX idx_licenses_expires ON licenses (expires_at) WHERE revoked_at IS NULL;
CREATE INDEX idx_licenses_gumroad ON licenses (gumroad_order_id);
CREATE TABLE gumroad_events (
id bigserial PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now(),
event_type text NOT NULL, -- sale | refund | dispute | ...
order_id text,
raw_payload jsonb NOT NULL,
processed boolean NOT NULL DEFAULT false,
error text -- non-null if processing failed
);
```
The `licenses` schema is the JSONL log fields plus
`gumroad_order_id`, `revoked_at`, `notes`. The migration script from
JSONL → Postgres is therefore a flat insert.
---
## Security
- **Private key**: AWS KMS, GCP KMS, or HashiCorp Vault. Mint API
has IAM permission to *use* the key (sign operation), not to
*export* it. Rotating to a new key still requires a new desktop
build (the pubkey is embedded); plan a 90-day overlap window where
both keys are accepted.
- **Webhook secret**: Gumroad's HMAC signature, verified before
touching the body.
- **Internal endpoints**: not reachable from the public internet —
bind to localhost or a private subnet, fronted by the webhook
receiver and the renewal portal.
- **PII**: name + email + Gumroad order ID. Standard customer-data
hygiene — DB backups encrypted at rest, no PII in application
logs, GDPR delete-on-request supported via a `DELETE FROM
licenses WHERE email = ?` (the desktop activation still works
until the license expires; the buyer just won't appear in our
records anymore).
- **Mint API access**: short-lived signed tokens for any creator
CLI that talks to it. The CLI is a thin wrapper around the same
`POST /internal/mint`; the days of running
`scripts/generate_license.py` against the prod private key on a
laptop are over once the server exists.
---
## Migration plan
Three phases, each independently revertable.
### Phase 0 (done)
- Ed25519 signing with prod key on creator's laptop.
- Local JSONL issuance log at `~/.datatools-creator/issued.jsonl`.
### Phase 1 — server stands up, no behavior change
1. Stand up Postgres + Mint API in a small VPS / Fly.io / Render box.
2. Provision a KMS-held keypair; **the public key must match the one
already embedded in the shipped binary** — i.e., import the
existing prod private key into KMS, do not generate a new one. If
the existing key is laptop-only and can't be imported, plan a
build-with-new-pubkey + buyer-side rotation cycle (see
`ADMIN.md` Recovery).
3. Run a one-shot script: read `~/.datatools-creator/issued.jsonl`,
`INSERT … ON CONFLICT (license_key) DO NOTHING` each row.
4. Add a creator-only CLI command `datatools-admin mint` that calls
`POST /internal/mint` instead of running the local script. Local
script stays as a fallback.
At this point: nothing buyer-facing has changed. The creator now has
two ways to mint (server or local) and a real DB.
### Phase 2 — automation
5. Wire the Gumroad webhook. New buyers get automated fulfillment.
6. Manual mints (friends, comps, support replacements) still go
through `datatools-admin mint`, which writes to the same DB.
7. Old local script is deprecated but kept (read-only) as a break-glass
tool if the server is down.
### Phase 3 — self-service
8. Ship the renewal portal.
9. Replace "email support to lose-my-blob" with a self-service form.
Each phase ships independently. The desktop app sees no change
across any of them — that's the whole point.
---
## Open questions
- **Hosting choice.** *Decided: self-hosted* on the existing
`46.225.166.142` box alongside the `*.invixiom.com` services.
Runbook in `SETUP-LICENSE-SERVER.md`. Operator owns uptime,
backups, TLS renewal, and key custody — see that doc's
"Operational concerns" section.
- **Per-seat or per-device limits?** v1 says no. Revisit if/when
abuse is observable.
- **Email delivery.** Postmark or SES — both fine. Pick whichever the
rest of the stack uses. Avoid Gmail SMTP for transactional mail.
- **Audit log retention.** `gumroad_events` rows are unbounded growth
but trivially small. Default to forever; partition by year if it
ever exceeds a few GB.
- **Existing Gumroad customers.** Before any of this lands, every
buyer is already in Gumroad's records. A one-shot import from
Gumroad's CSV export → `licenses` table would catch anyone whose
blob the JSONL log doesn't have (e.g., if the creator's laptop
was lost before this design lands).
---
## Code pointers (current state, for the future implementer)
| File | What it does now | What changes |
|------|------------------|--------------|
| `scripts/generate_license.py` | Sign locally, append JSONL | Becomes a CLI client of the Mint API |
| `src/license/crypto.py` | `sign()` reads `$DATATOOLS_LICENSE_PRIVKEY` | `sign()` calls KMS; the env var stays as a fallback for local dev |
| `src/license_cli.py` | Activate / status / renew — already buyer-facing | **No change.** Still verifies offline against embedded pubkey |
| `src/license/manager.py` | Verify, persist | **No change.** |
The desktop app is deliberately decoupled from any of this. The
server is a fulfillment + record-keeping layer wrapped around the
existing, frozen, offline activation flow.