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>
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
- Automate fulfillment. Gumroad sale → buyer gets a blob in their inbox within seconds. No creator intervention.
- 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.
- Self-service renewal & re-delivery. Buyer enters their email → gets a fresh blob or a copy of their existing one. Cuts support load.
- 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 §9bstands. - 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 → setsrevoked_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 runningscripts/generate_license.pyagainst 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
- Stand up Postgres + Mint API in a small VPS / Fly.io / Render box.
- 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.mdRecovery). - Run a one-shot script: read
~/.datatools-creator/issued.jsonl,INSERT … ON CONFLICT (license_key) DO NOTHINGeach row. - Add a creator-only CLI command
datatools-admin mintthat callsPOST /internal/mintinstead 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
- Wire the Gumroad webhook. New buyers get automated fulfillment.
- Manual mints (friends, comps, support replacements) still go
through
datatools-admin mint, which writes to the same DB. - Old local script is deprecated but kept (read-only) as a break-glass tool if the server is down.
Phase 3 — self-service
- Ship the renewal portal.
- 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.142box alongside the*.invixiom.comservices. Runbook inSETUP-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_eventsrows 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 →
licensestable 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.