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

11 KiB

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

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

  1. Wire the Gumroad webhook. New buyers get automated fulfillment.
  2. Manual mints (friends, comps, support replacements) still go through datatools-admin mint, which writes to the same DB.
  3. Old local script is deprecated but kept (read-only) as a break-glass tool if the server is down.

Phase 3 — self-service

  1. Ship the renewal portal.
  2. 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.