# LICENSE-SERVER — Future online issuance & record-keeping **Status:** design proposal. Not built. The current system is fully offline (see `ADMIN.md`). 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.** A managed Postgres + small Python app is well under $20/mo at the expected volume. Fly.io and Render are the obvious candidates; AWS is overkill at this scale. - **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.