From b40dda3b9153c756d83ef5f7a085749293539ce8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 25 Feb 2026 15:21:07 -0800 Subject: [PATCH] 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. --- .../2026-02-25-circuitforge-license-design.md | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 docs/plans/2026-02-25-circuitforge-license-design.md diff --git a/docs/plans/2026-02-25-circuitforge-license-design.md b/docs/plans/2026-02-25-circuitforge-license-design.md new file mode 100644 index 0000000..78ecb36 --- /dev/null +++ b/docs/plans/2026-02-25-circuitforge-license-design.md @@ -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= +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 |