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>
260 lines
11 KiB
Markdown
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.
|