docs: CircuitForge license server design doc
RS256 JWT, FastAPI + SQLite, multi-product schema, offline-capable client integration. Covers server, Peregrine client, deployment, admin workflow, and testing strategy.
This commit is contained in:
parent
f08f1b16d0
commit
bd326162f1
1 changed files with 367 additions and 0 deletions
367
docs/plans/2026-02-25-circuitforge-license-design.md
Normal file
367
docs/plans/2026-02-25-circuitforge-license-design.md
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# CircuitForge License Server — Design Document
|
||||
|
||||
**Date:** 2026-02-25
|
||||
**Status:** Approved — ready for implementation
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Build a self-hosted licensing server for Circuit Forge LLC products. v1 serves Peregrine; schema is multi-product from day one. Enforces free / paid / premium / ultra tier gates with offline-capable JWT validation, 30-day refresh cycle, 7-day grace period, seat tracking, usage telemetry, and a content violation flagging foundation.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ circuitforge-license (Heimdall:8600) │
|
||||
│ FastAPI + SQLite + RS256 JWT │
|
||||
│ │
|
||||
│ Public API (/v1/…): │
|
||||
│ POST /v1/activate → issue JWT │
|
||||
│ POST /v1/refresh → renew JWT │
|
||||
│ POST /v1/deactivate → free a seat │
|
||||
│ POST /v1/usage → record usage event │
|
||||
│ POST /v1/flag → report violation │
|
||||
│ │
|
||||
│ Admin API (/admin/…, bearer token): │
|
||||
│ POST/GET /admin/keys → CRUD keys │
|
||||
│ DELETE /admin/keys/{id} → revoke │
|
||||
│ GET /admin/activations → audit │
|
||||
│ GET /admin/usage → telemetry │
|
||||
│ GET/PATCH /admin/flags → flag review │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↑ HTTPS via Caddy (license.circuitforge.com)
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Peregrine (user's machine) │
|
||||
│ scripts/license.py │
|
||||
│ │
|
||||
│ activate(key) → POST /v1/activate │
|
||||
│ writes config/license.json │
|
||||
│ verify_local() → validates JWT offline │
|
||||
│ using embedded public key │
|
||||
│ refresh_if_needed() → called on app startup │
|
||||
│ effective_tier() → tier string for can_use() │
|
||||
│ report_usage(…) → fire-and-forget telemetry │
|
||||
│ report_flag(…) → fire-and-forget violation │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key properties:**
|
||||
- Peregrine verifies tier **offline** on every check — RS256 public key embedded at build time
|
||||
- Network required only at activation and 30-day refresh
|
||||
- Revoked keys stop working at next refresh cycle (≤30 day lag — acceptable for v1)
|
||||
- `config/license.json` gitignored; missing = free tier
|
||||
|
||||
---
|
||||
|
||||
## Crypto: RS256 (asymmetric JWT)
|
||||
|
||||
- **Private key** — lives only on the license server (`keys/private.pem`, gitignored)
|
||||
- **Public key** — committed to both the license server repo and Peregrine (`scripts/license_public_key.pem`)
|
||||
- Peregrine can verify JWT authenticity without ever knowing the private key
|
||||
- A stolen JWT cannot be forged without the private key
|
||||
- Revocation: server refuses refresh; old JWT valid until expiry then grace period expires
|
||||
|
||||
**Key generation (one-time, on Heimdall):**
|
||||
```bash
|
||||
openssl genrsa -out keys/private.pem 2048
|
||||
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
|
||||
# copy keys/public.pem → peregrine/scripts/license_public_key.pem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE license_keys (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
key_display TEXT UNIQUE NOT NULL, -- CFG-PRNG-XXXX-XXXX-XXXX
|
||||
product TEXT NOT NULL, -- peregrine | falcon | osprey | …
|
||||
tier TEXT NOT NULL, -- paid | premium | ultra
|
||||
seats INTEGER DEFAULT 1,
|
||||
valid_until TEXT, -- ISO date or NULL (perpetual)
|
||||
revoked INTEGER DEFAULT 0,
|
||||
customer_email TEXT, -- proper field, not buried in notes
|
||||
source TEXT DEFAULT 'manual', -- manual | beta | promo | stripe
|
||||
trial INTEGER DEFAULT 0, -- 1 = time-limited trial key
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE activations (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL REFERENCES license_keys(id),
|
||||
machine_id TEXT NOT NULL, -- sha256(hostname + MAC)
|
||||
app_version TEXT, -- Peregrine version at last refresh
|
||||
platform TEXT, -- linux | macos | windows | docker
|
||||
activated_at TEXT NOT NULL,
|
||||
last_refresh TEXT NOT NULL,
|
||||
deactivated_at TEXT -- NULL = still active
|
||||
);
|
||||
|
||||
CREATE TABLE usage_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL REFERENCES license_keys(id),
|
||||
machine_id TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, -- cover_letter_generated |
|
||||
-- company_research | email_sync |
|
||||
-- interview_prep | survey | etc.
|
||||
metadata TEXT, -- JSON blob for context
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE flags (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_id TEXT NOT NULL REFERENCES license_keys(id),
|
||||
machine_id TEXT,
|
||||
product TEXT NOT NULL,
|
||||
flag_type TEXT NOT NULL, -- content_violation | tos_violation |
|
||||
-- abuse | manual
|
||||
details TEXT, -- JSON: prompt snippet, output excerpt
|
||||
status TEXT DEFAULT 'open', -- open | reviewed | dismissed | actioned
|
||||
created_at TEXT NOT NULL,
|
||||
reviewed_at TEXT,
|
||||
action_taken TEXT -- none | warned | revoked
|
||||
);
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL, -- key | activation | flag
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL, -- created | revoked | activated |
|
||||
-- deactivated | flag_actioned
|
||||
actor TEXT, -- admin identifier (future multi-admin)
|
||||
details TEXT, -- JSON
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
**Flags scope (v1):** Schema and `POST /v1/flag` endpoint capture data. No admin enforcement UI in v1 — query DB directly. Build review UI in v2 when there's data to act on.
|
||||
|
||||
---
|
||||
|
||||
## JWT Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "CFG-PRNG-A1B2-C3D4-E5F6",
|
||||
"product": "peregrine",
|
||||
"tier": "paid",
|
||||
"seats": 2,
|
||||
"machine": "a3f9c2…",
|
||||
"notice": "Version 1.1 available — see circuitforge.com/update",
|
||||
"iat": 1740000000,
|
||||
"exp": 1742592000
|
||||
}
|
||||
```
|
||||
|
||||
`notice` is optional — set via a server config value; included in refresh responses so Peregrine can surface it as a banner. No DB table needed.
|
||||
|
||||
---
|
||||
|
||||
## Key Format
|
||||
|
||||
`CFG-PRNG-A1B2-C3D4-E5F6`
|
||||
|
||||
- `CFG` — Circuit Forge
|
||||
- `PRNG` / `FLCN` / `OSPY` / … — 4-char product code
|
||||
- Three random 4-char alphanumeric segments
|
||||
- Human-readable, easy to copy/paste into a support email
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Reference
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| POST | `/v1/activate` | none | Issue JWT for key + machine |
|
||||
| POST | `/v1/refresh` | JWT bearer | Renew JWT before expiry |
|
||||
| POST | `/v1/deactivate` | JWT bearer | Free a seat |
|
||||
| POST | `/v1/usage` | JWT bearer | Record usage event (fire-and-forget) |
|
||||
| POST | `/v1/flag` | JWT bearer | Report content/ToS violation |
|
||||
| POST | `/admin/keys` | admin token | Create a new key |
|
||||
| GET | `/admin/keys` | admin token | List all keys + activation counts |
|
||||
| DELETE | `/admin/keys/{id}` | admin token | Revoke a key |
|
||||
| GET | `/admin/activations` | admin token | Full activation audit |
|
||||
| GET | `/admin/usage` | admin token | Usage breakdown per key/product/event |
|
||||
| GET | `/admin/flags` | admin token | List flags (open by default) |
|
||||
| PATCH | `/admin/flags/{id}` | admin token | Update flag status + action |
|
||||
|
||||
---
|
||||
|
||||
## Peregrine Client (`scripts/license.py`)
|
||||
|
||||
**Public API:**
|
||||
```python
|
||||
def activate(key: str) -> dict # POST /v1/activate, writes license.json
|
||||
def verify_local() -> dict | None # validates JWT offline; None = free tier
|
||||
def refresh_if_needed() -> None # silent; called on app startup
|
||||
def effective_tier() -> str # "free"|"paid"|"premium"|"ultra"
|
||||
def report_usage(event_type: str, # fire-and-forget; failures silently dropped
|
||||
metadata: dict = {}) -> None
|
||||
def report_flag(flag_type: str, # fire-and-forget
|
||||
details: dict) -> None
|
||||
```
|
||||
|
||||
**`effective_tier()` decision tree:**
|
||||
```
|
||||
license.json missing or unreadable → "free"
|
||||
JWT signature invalid → "free"
|
||||
JWT product != "peregrine" → "free"
|
||||
JWT not expired → tier from payload
|
||||
JWT expired, within grace period → tier from payload + show banner
|
||||
JWT expired, grace period expired → "free" + show banner
|
||||
```
|
||||
|
||||
**`config/license.json` (gitignored):**
|
||||
```json
|
||||
{
|
||||
"jwt": "eyJ…",
|
||||
"key_display": "CFG-PRNG-A1B2-C3D4-E5F6",
|
||||
"tier": "paid",
|
||||
"valid_until": "2026-03-27",
|
||||
"machine_id": "a3f9c2…",
|
||||
"last_refresh": "2026-02-25T12:00:00Z",
|
||||
"grace_until": null
|
||||
}
|
||||
```
|
||||
|
||||
**Integration point in `tiers.py`:**
|
||||
```python
|
||||
def effective_tier(profile) -> str:
|
||||
from scripts.license import effective_tier as _license_tier
|
||||
if profile.dev_tier_override: # dev override still works in dev mode
|
||||
return profile.dev_tier_override
|
||||
return _license_tier()
|
||||
```
|
||||
|
||||
**Settings License tab** (new tab in `app/pages/2_Settings.py`):
|
||||
- Text input: enter license key → calls `activate()` → shows result
|
||||
- If active: tier badge, key display string, expiry date, seat count
|
||||
- Grace period: amber banner with days remaining
|
||||
- "Deactivate this machine" button → `/v1/deactivate`, deletes `license.json`
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
**Repo:** `git.opensourcesolarpunk.com/pyr0ball/circuitforge-license` (private)
|
||||
|
||||
**Repo layout:**
|
||||
```
|
||||
circuitforge-license/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI app
|
||||
│ ├── db.py # SQLite helpers, schema init
|
||||
│ ├── models.py # Pydantic models
|
||||
│ ├── crypto.py # RSA sign/verify helpers
|
||||
│ └── routes/
|
||||
│ ├── public.py # /v1/* endpoints
|
||||
│ └── admin.py # /admin/* endpoints
|
||||
├── data/ # SQLite DB (named volume)
|
||||
├── keys/
|
||||
│ ├── private.pem # gitignored
|
||||
│ └── public.pem # committed
|
||||
├── scripts/
|
||||
│ └── issue-key.sh # curl wrapper for key issuance
|
||||
├── tests/
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
**`docker-compose.yml` (on Heimdall):**
|
||||
```yaml
|
||||
services:
|
||||
license:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8600:8600"
|
||||
volumes:
|
||||
- license_data:/app/data
|
||||
- ./keys:/app/keys:ro
|
||||
env_file: .env
|
||||
|
||||
volumes:
|
||||
license_data:
|
||||
```
|
||||
|
||||
**`.env` (gitignored):**
|
||||
```
|
||||
ADMIN_TOKEN=<long random string>
|
||||
JWT_PRIVATE_KEY_PATH=/app/keys/private.pem
|
||||
JWT_PUBLIC_KEY_PATH=/app/keys/public.pem
|
||||
JWT_EXPIRY_DAYS=30
|
||||
GRACE_PERIOD_DAYS=7
|
||||
```
|
||||
|
||||
**Caddy block (add to Heimdall Caddyfile):**
|
||||
```caddy
|
||||
license.circuitforge.com {
|
||||
reverse_proxy localhost:8600
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin Workflow (v1)
|
||||
|
||||
All operations via `curl` or `scripts/issue-key.sh`:
|
||||
|
||||
```bash
|
||||
# Issue a key
|
||||
./scripts/issue-key.sh --product peregrine --tier paid --seats 2 \
|
||||
--email user@example.com --notes "Beta — manual payment 2026-02-25"
|
||||
# → CFG-PRNG-A1B2-C3D4-E5F6 (email to customer)
|
||||
|
||||
# List all keys
|
||||
curl https://license.circuitforge.com/admin/keys \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||
|
||||
# Revoke a key
|
||||
curl -X DELETE https://license.circuitforge.com/admin/keys/{id} \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**License server:**
|
||||
- pytest with in-memory SQLite and generated test keypair
|
||||
- All endpoints tested: activate, refresh, deactivate, usage, flag, admin CRUD
|
||||
- Seat limit enforcement, expiry, revocation all unit tested
|
||||
|
||||
**Peregrine client:**
|
||||
- `verify_local()` tested with pre-signed test JWT using test keypair
|
||||
- `activate()` / `refresh()` tested with `httpx` mocks
|
||||
- `effective_tier()` tested across all states: valid, expired, grace, revoked, missing
|
||||
|
||||
**Integration smoke test:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
# create test key via admin API
|
||||
# call /v1/activate with test key
|
||||
# verify JWT signature with public key
|
||||
# verify /v1/refresh extends expiry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decisions Log
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| RS256 over HS256 | Public key embeddable in client; private key never leaves server |
|
||||
| SQLite over Postgres | Matches Peregrine's SQLite-first philosophy; trivially backupable |
|
||||
| 30-day JWT lifetime | Standard SaaS pattern; invisible to users in normal operation |
|
||||
| 7-day grace period | Covers travel, network outages, server maintenance |
|
||||
| Flags v1: capture only | No volume to justify review UI yet; add in v2 |
|
||||
| No payment integration | Manual issuance until customer volume justifies automation |
|
||||
| Multi-product schema | Adding a column now vs migrating a live DB later |
|
||||
| Separate repo | License server is infrastructure, not part of Peregrine's BSL scope |
|
||||
Loading…
Reference in a new issue