License server for Cairn: customer accounts, purchases, license issuance (Ed25519-signed), monthly check-in API. Go + SQLite, deployed on the VPS.
Find a file
2026-09-06 03:16:12 +00:00
.forgejo/workflows #40: log every check-in, persist refusal history, 503 when the store cannot answer 2026-09-06 03:12:42 +00:00
docs docs: swisscairn.ch + license server system overview diagram 2026-08-25 12:33:29 +00:00
.gitignore Email verification (#31), ported onto main — supersedes #34 (#39) 2026-08-26 21:06:38 +00:00
accounts.go #40: log every check-in, persist refusal history, 503 when the store cannot answer 2026-09-06 03:12:42 +00:00
accounts_test.go test: rename helpers to avoid collision with paddle_test.go 2026-07-29 01:31:01 +00:00
admin.go geoip: wire initGeoIP + heatmap route + login events; vendor maxminddb (#33) 2026-08-25 18:36:15 +00:00
admin_test.go admin: tests for /v1/me gate, staff management, and custom-key minting 2026-08-25 17:46:45 +00:00
analytics.go analytics: overview/accounts/timeline endpoints + Paddle checkout-funnel events (#33 phase 2) 2026-08-25 18:02:20 +00:00
analytics_test.go analytics: tests for overview rollups, checkout funnel events, accounts + timeline 2026-08-25 18:06:11 +00:00
cancel.go Subscription cancellation: cancel at period end + resume (#23) 2026-08-24 01:05:26 +00:00
cancel_test.go Wire cancellation: CancelAt field, checkin reason, refreshWith clear, licView, routes 2026-08-24 01:07:33 +00:00
checkin_history_test.go #40 red (phase B): refusal history persisted on the record 2026-09-06 03:10:26 +00:00
checkin_unavailable_test.go #40 red (phase A): 503 on empty store + every check-in outcome logged 2026-09-06 03:10:02 +00:00
DEPLOY.md docs: DEPLOY.md — VPS deploy kit (systemd, Caddy, secrets, verification, backup) 2026-07-26 19:53:34 +00:00
email.go feat: minimal SMTP mailer (stdlib only) for reset + invite mail (#5) 2026-07-30 00:02:10 +00:00
email_orgs_test.go test: password reset + organizations flows (#5, #6) 2026-07-30 00:04:55 +00:00
geoip.go geoip: offline country lookup + login geography heatmap (#33) 2026-08-25 18:33:12 +00:00
geoip_test.go geoip: tests for X-Forwarded-For parsing, name normalisation, heatmap aggregation 2026-08-25 18:34:37 +00:00
go.mod geoip: wire initGeoIP + heatmap route + login events; vendor maxminddb (#33) 2026-08-25 18:36:15 +00:00
go.sum geoip: wire initGeoIP + heatmap route + login events; vendor maxminddb (#33) 2026-08-25 18:36:15 +00:00
main.go #40: log every check-in, persist refusal history, 503 when the store cannot answer 2026-09-06 03:12:42 +00:00
oauth.go Wire OAuth sign-in: account OAuth identities + registerOAuth in main 2026-08-24 00:03:59 +00:00
oauth_test.go OAuth tests: create/link/refuse paths, state single-use, nonce, MS verification rules 2026-08-24 00:01:04 +00:00
org_name.go fix: heal prefers the per-license label over the account org (#17 follow-up) 2026-07-31 10:56:30 +00:00
org_name_test.go test: label wins the heal; account org only as fallback (#17 follow-up) 2026-07-31 10:57:04 +00:00
orgs.go feat: organizations — roles, email invites, last-admin rail (#6) 2026-07-30 00:03:58 +00:00
paddle.go analytics: create events table, register overview/accounts/timeline routes, hook Paddle funnel (#33) 2026-08-25 18:04:01 +00:00
paddle_test.go #25: first 50 seats always free - Paddle carries paid seats, key carries the total cap. Webhook adds freeSeats to paid quantity; seat changes floor at 51 and send seats-freeSeats to Paddle. Tests updated (170/110/15) plus paid-seat mint and floor tests. 2026-08-24 12:39:57 +00:00
README.md docs: README covers orgs, invites, password reset, receipts, SMTP env, renewal (#80) 2026-07-30 15:08:05 +00:00
receipts.go feat: billing receipts via Paddle API (#4) 2026-07-30 00:42:42 +00:00
receipts_test.go test: receipts listing + invoice authorization (#4) 2026-07-30 00:43:18 +00:00
renewal_test.go test: renewal refresh, cycle-based validity, checkin key delivery, self-service 2026-07-29 22:47:41 +00:00
reset.go feat: forgot-password reset flow — always-204 request, 1h single-use tokens (#5) 2026-07-30 00:02:43 +00:00
seats.go #25 follow-up: below-floor 400 explains the way back to the free tier (cancel + let the license expire) 2026-08-25 00:31:21 +00:00
seats_test.go #25: first 50 seats always free - Paddle carries paid seats, key carries the total cap. Webhook adds freeSeats to paid quantity; seat changes floor at 51 and send seats-freeSeats to Paddle. Tests updated (170/110/15) plus paid-seat mint and floor tests. 2026-08-24 12:39:57 +00:00
store_sqlite_test.go #28: tests for legacy-JSON import + SQLite persistence round-trip 2026-08-25 01:41:04 +00:00
verify.go Email verification (#31), ported onto main — supersedes #34 (#39) 2026-08-26 21:06:38 +00:00
verify_test.go Email verification (#31), ported onto main — supersedes #34 (#39) 2026-08-26 21:06:38 +00:00
webhook_seats_test.go #25: first 50 seats always free - Paddle carries paid seats, key carries the total cap. Webhook adds freeSeats to paid quantity; seat changes floor at 51 and send seats-freeSeats to Paddle. Tests updated (170/110/15) plus paid-seat mint and floor tests. 2026-08-24 12:39:57 +00:00

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 accounts 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 callers 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 members 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 orgs 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 orgs 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 (or SMTP_PASSWORD) / SMTP_FROM (or MAIL_FROM) — outbound mail for the password-reset and org-invite links. With SMTP_HOST unset, those flows still return success but send nothing (a warning is logged).
  • DATA_PATH (default licenses.json), LISTEN (default :7421).

Deploy (as built)

Everything runs through auditable Forgejo runner workflows — no hand-SSH:

  • provision.yml — full VPS setup: binary, cairnlic system user, hardened systemd unit (ProtectSystem=strict), env placeholders, Caddy install, and the Caddyfile (license API + www.swisscairn.ch static 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.