From 52e04f63a9c2498284bb96aacd241f7ac21a7192 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 13 May 2026 22:26:24 +0000 Subject: [PATCH] docs(license): design proposal for online issuance & record-keeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward-looking design doc — not implemented. Describes the smallest useful server that replaces the manual mint-and-paste workflow: Gumroad webhook → Mint API (KMS-held private key) → Postgres licenses table, plus a self-service renewal/re-delivery portal. The desktop app is deliberately untouched across all three migration phases: activation stays fully offline and continues to verify blobs against the embedded pubkey, preserving the DECISIONS.md §9b promise that buyer machines never phone home. Schema is intentionally a superset of the local issuance JSONL log (ADMIN.md), so Phase 1 migration is a flat INSERT per row. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/LICENSE-SERVER.md | 255 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/LICENSE-SERVER.md diff --git a/docs/LICENSE-SERVER.md b/docs/LICENSE-SERVER.md new file mode 100644 index 0000000..36bd01f --- /dev/null +++ b/docs/LICENSE-SERVER.md @@ -0,0 +1,255 @@ +# 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.