# 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.
