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