docs(license): design proposal for online issuance & record-keeping
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) <noreply@anthropic.com>
This commit is contained in:
255
docs/LICENSE-SERVER.md
Normal file
255
docs/LICENSE-SERVER.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user