# License Backend Self-hosted, multi-product license server for WordPress plugins. Stateless API + SQLite on a persistent volume. No prices anywhere — tiers are expressed purely as **activation limits** (1 / 3 / -1 = unlimited). Pricing lives on your website, not here. ## Architecture ``` PayPal (one-time payment) │ webhook ▼ n8n workflow │ POST /api/v1/licenses (X-Admin-Token) ← generate key ▼ License Backend (this container) ──► SQLite @ /data (Docker volume, survives redeploy) │ returns { key } ▼ n8n → e-mail key to customer + trigger invoice separately ``` The plugin later calls the **public** endpoints (`/activate`, `/validate`, `/deactivate`) with the key, its product slug and the site domain. ## Run ```bash cp .env.example .env # edit .env: set a strong ADMIN_API_TOKEN (openssl rand -hex 32) docker compose up -d --build ``` Put a TLS-terminating reverse proxy (Caddy / nginx / Traefik) in front and point `hub.lucas-orth.de` at it. The container speaks plain HTTP on its port. ### Data persistence The SQLite DB lives in the named volume `license-data` mounted at `/data`. `docker compose down && up` or a fresh image build keeps all keys and activations. Only `docker compose down -v` would wipe it. Back up with: ```bash docker run --rm -v license-data:/data -v "$PWD":/backup alpine \ sh -c "cp /data/license.db /backup/license-backup-$(date +%F).db" ``` ## Adding a new plugin (extensibility) Each plugin = one **product slug**. Two ways to add one: 1. Edit `SEED_PRODUCTS` in `.env` (`gdpr-content-blocker:Content Blocker,my-next-plugin:My Next Plugin`) and restart — idempotent, existing data untouched. 2. At runtime: `POST /api/v1/products` (admin) with `{ "slug": "...", "name": "..." }`. Keys are always issued **per product**, so one backend serves all your plugins. ## API ### Admin (header `X-Admin-Token: `) **Generate a key — this is what n8n calls:** ```bash curl -X POST https://hub.lucas-orth.de/api/v1/licenses \ -H "X-Admin-Token: $ADMIN_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "product": "gdpr-content-blocker", "max_activations": 1, "email": "kunde@example.com" }' # → 201 { "ok": true, "key": "ABCD-EFGH-JKMN-PQRS", ... } ``` `max_activations`: `1` (single site), `3` (three sites), `-1` (unlimited). Optional: `email`, `note`, `expires_at` (ISO date; omit for lifetime). | Method | Path | Purpose | |---|---|---| | POST | `/api/v1/licenses` | generate a key | | GET | `/api/v1/licenses/:key` | inspect (status + bound domains) | | POST | `/api/v1/licenses/:key/disable` | revoke | | POST | `/api/v1/licenses/:key/enable` | un-revoke | | GET | `/api/v1/products` | list products | | POST | `/api/v1/products` | add a product | | POST | `/api/v1/releases?product=&version=` | upload a plugin ZIP (raw body) | | GET | `/api/v1/releases/:product` | list releases | **Upload a new plugin version** (raw `.zip` as the body — no multipart): ```bash curl -X POST "https://hub.lucas-orth.de/api/v1/releases?product=gdpr-content-blocker&version=1.1.0" \ -H "X-Admin-Token: $ADMIN_API_TOKEN" \ -H "Content-Type: application/zip" \ -H "X-Changelog: Fixes XYZ" \ -H "X-Tested: 6.7" -H "X-Requires-PHP: 8.1" \ --data-binary @gdpr-content-blocker.zip ``` The ZIP **must** contain a top-level `gdpr-content-blocker/` folder (so WordPress extracts it into the right plugin directory). Stored under `/data/releases//.zip` (persists across redeploys). ### Public (called by the plugin) | Method | Path | Body | Purpose | |---|---|---|---| | POST | `/api/v1/activate` | `{ key, product, domain }` | bind a domain, enforce limit | | POST | `/api/v1/validate` | `{ key, product, domain }` | daily re-check | | POST | `/api/v1/deactivate` | `{ key, product, domain }` | free a slot | | POST | `/api/v1/scan` | `{ key, product, domain, urls? }` | visit the site, list embedded third-party resources | **Scan** is SSRF-guarded on three levels: (1) the requesting domain must be an **activated** slot of the license; (2) every target URL's host must equal that domain; (3) the host is DNS-resolved and refused if it points at a private, loopback or link-local IP (e.g. `169.254.169.254` cloud metadata). Redirects are **not** followed (`redirect: manual`) so a 30x can't bounce the fetch into an internal host. `urls` is optional — defaults to `https:///`; max 10 URLs, 10 s timeout, 2 MB per page. Residual risk: DNS-rebinding (TOCTOU between resolve and fetch) is not fully closed; run the backend in a network segment without access to sensitive internal services. Response: ```json { "ok": true, "scanned": [{ "url": "https://kunde.de/", "error": null }], "findings": [ { "host": "google.com", "third_party": true, "types": ["iframe"], "count": 1, "sample_urls": ["https://www.google.com/maps/embed?..."], "suggested_pattern": "google.com" } ] } ``` Success: `{ "ok": true, "status": "valid", "activations_used": 1, "max_activations": 1 }` Failure: `{ "ok": false, "error": "Maximale Anzahl an Domains für diese Lizenz erreicht" }` **Update flow** (called by the plugin's updater): | Method | Path | Body / Query | Purpose | |---|---|---|---| | POST | `/api/v1/update` | `{ key, product, domain, version }` | is there a newer version? returns a signed package URL | | GET | `/api/v1/download` | `?token=…` | stream the ZIP; token is HMAC-signed, 7-day TTL, license re-checked live | `/update` returns either `{ ok:true, update_available:false }` or `{ ok:true, update_available:true, version, package, changelog, requires, tested, requires_php }`. The `package` URL embeds a signed token (key+product+version+expiry); the download endpoint verifies the signature **and** re-checks that the license is still active before streaming — so a leaked URL stops working once the license is revoked. `GET /healthz` → `{ ok: true }` (used by the Docker healthcheck). ## Shipping a release from Gitea Tag a release in your plugin repo and let Gitea Actions build the ZIP and push it here. Example `.gitea/workflows/release.yml` lives in the **plugin** repo; it runs `on: push: tags: ['v*']`, zips the `gdpr-content-blocker/` folder, and `curl`s it to `POST /api/v1/releases`. Store `ADMIN_API_TOKEN` and the backend URL as Gitea **secrets**. You can also just run that `curl` by hand for a one-off release. ## n8n workflow (outline) 1. **Webhook / PayPal trigger** — receives the IPN/webhook of a completed payment. 2. **Map item → tier**: set `max_activations` from the purchased item (your website knows which PayPal button = which tier; the backend never sees a price). 3. **HTTP Request** → `POST {backend}/api/v1/licenses` with `X-Admin-Token`, body `{ product, max_activations, email }`. 4. **Send e-mail** with `{{$json.key}}` to the customer. 5. **Trigger invoice** (separate node / sub-workflow). ## Security notes - `ADMIN_API_TOKEN` is required; the server refuses to boot without it. - Admin auth uses a timing-safe comparison. - Run only behind HTTPS. Restrict the admin endpoints at the proxy to n8n's IP if possible. - The container runs as the non-root `node` user. ```