|
|
||
|---|---|---|
| .forgejo/workflows | ||
| docs | ||
| .gitignore | ||
| accounts.go | ||
| accounts_test.go | ||
| admin.go | ||
| admin_test.go | ||
| analytics.go | ||
| analytics_test.go | ||
| cancel.go | ||
| cancel_test.go | ||
| checkin_history_test.go | ||
| checkin_unavailable_test.go | ||
| DEPLOY.md | ||
| email.go | ||
| email_orgs_test.go | ||
| geoip.go | ||
| geoip_test.go | ||
| go.mod | ||
| go.sum | ||
| main.go | ||
| oauth.go | ||
| oauth_test.go | ||
| org_name.go | ||
| org_name_test.go | ||
| orgs.go | ||
| paddle.go | ||
| paddle_test.go | ||
| README.md | ||
| receipts.go | ||
| receipts_test.go | ||
| renewal_test.go | ||
| reset.go | ||
| seats.go | ||
| seats_test.go | ||
| store_sqlite_test.go | ||
| verify.go | ||
| verify_test.go | ||
| webhook_seats_test.go | ||
cairn-license-server
Issuance + check-in backend for Cairn's fair-code licensing (Cairn #24/#25) and the account backend for the cairn-site customer area (#26).
Live in production at https://license.swisscairn.ch (Infomaniak VPS
behind Caddy auto-TLS). Stdlib-only, single Go binary, atomic-JSON store —
proven end-to-end: Paddle sandbox checkout → signed webhook → license issued →
key visible in the customer account.
Endpoints
| Endpoint | What |
|---|---|
POST /v1/checkin {licenseId, seatsUsed} |
200 ok / 403 revoked-expired-unknown (Cairn drops to free tier) |
POST /v1/paddle/webhook |
signature-verified (HMAC-SHA256, ±5min); transaction.completed → 1-year license, seats = Σ quantities, idempotent by txn id, buyer email recorded from checkout custom_data; a subscription renewal refreshes the existing license (same id, extended expiry) rather than issuing a duplicate |
POST /v1/register {email, password} |
201 {token}; PBKDF2-HMAC-SHA256 600k iters, ≥10-char password, 409 on duplicate |
POST /v1/login / POST /v1/logout |
bearer sessions (memory, 24h TTL); constant-time verify, no enumeration timing oracle, per-IP rate limit |
PATCH /v1/licenses/{id} (Bearer) |
set a human-readable label on one of the account’s licenses |
POST /v1/account/password (Bearer) |
change password (current password required); other sessions revoked |
POST /v1/password-reset {email} |
always 204 — no account enumeration; when SMTP is configured, emails a single-use, 1-hour reset link (www.swisscairn.ch/reset.html?token=…) |
POST /v1/password-reset/confirm {token, password} |
consumes the token, sets the new password, revokes all sessions |
GET /v1/org (Bearer) |
the caller’s organization — name, members and their roles, pending invites |
POST /v1/org/name (Bearer) |
rename the organization |
POST /v1/org/invites {email, role} (Bearer) |
invite a member; when SMTP is configured, emails a 7-day accept link (account.html?invite=…) |
DELETE /v1/org/invites/{email} (Bearer) |
revoke a pending invite |
POST /v1/invites/accept {token} (Bearer) |
accept an invite and join that org; a now-empty former org is cleaned up |
POST /v1/org/members/{email}/role {role} (Bearer) |
change a member’s role; the org must keep at least one admin |
DELETE /v1/org/members/{email} (Bearer) |
remove a member (or leave); the last admin cannot be removed |
GET /v1/receipts (Bearer) |
the org’s Paddle transactions, newest first (date, amount, status, invoice number) |
GET /v1/receipts/{id}/invoice (Bearer) |
a short-lived Paddle invoice-PDF link, authorized against the org’s own transactions |
GET /v1/licenses (Bearer) |
all licenses whose buyer email matches the account — key included |
POST /v1/admin/licenses {org, seats, years} |
mints a signed CAIRN1.… key (Bearer ADMIN_TOKEN; manual sales/testing) |
POST /v1/admin/licenses/{id}/revoke |
flips the record; subsequent check-ins 403 |
CORS is wildcard-open — auth is bearer-only (no cookies), and the static site calls the API cross-origin.
Configuration (env — lives in /etc/cairn-license/env on the VPS)
LICENSE_SIGNING_KEY_PEM— path to the Ed25519 private key PEM. Exists only on the VPS and in Nikola's custody; the matching public key is embedded in cairnd. Without it the server runs check-in-only.ADMIN_TOKEN— bearer token for the admin endpoints.PADDLE_WEBHOOK_SECRET— notification-endpoint secret for signature checks.PADDLE_API_KEY— read-scoped Paddle API key for the receipts endpoints (transaction history + invoice links). Unset ⇒ receipts unavailable.SMTP_HOST/SMTP_PORT(465 implicit-TLS default, or 587 STARTTLS) /SMTP_USER/SMTP_PASS(orSMTP_PASSWORD) /SMTP_FROM(orMAIL_FROM) — outbound mail for the password-reset and org-invite links. WithSMTP_HOSTunset, those flows still return success but send nothing (a warning is logged).DATA_PATH(defaultlicenses.json),LISTEN(default:7421).
Deploy (as built)
Everything runs through auditable Forgejo runner workflows — no hand-SSH:
provision.yml— full VPS setup: binary,cairnlicsystem user, hardened systemd unit (ProtectSystem=strict), env placeholders, Caddy install, and the Caddyfile (license API +www.swisscairn.chstatic site + apex redirect). Idempotent; safe to re-run.deploy.yml— build (CGO off, amd64) + ship + restart, with health check.- CI mirrors Cairn's: vet + test + cross-build on the shared runner.
The license key format lives in lockstep with Cairn's internal/license;
change one, change both. Store is a JSON file by design — volume is tiny, and
a database can slot in behind the same store shape if ever justified.
Data model
One record per license: {license, key, revoked, email, accountId, paddleTxn, paddleSub, lastSeen, seatsUsed} plus an accounts map (email → PBKDF2
credentials). Licenses attach to accounts by buyer email — the checkout
passes it in custom_data, the webhook records it, the account area filters
by it. No linking step, nothing to desync. Organizations and their invites live alongside the license records: an orgs map (organization → members with roles) and an invites map (single-use token → pending invite); each license record also carries a label and its orgId.