Admin dashboard: login heatmap, signup/checkout funnel, custom-key minting #33

Closed
opened 2026-08-25 02:07:24 +00:00 by Cordy · 2 comments
Owner

Internal analytics + admin surface for swisscairn operators. Frontend v1 is being drafted in Claude Design; this tracks the backend + wiring. Full rationale in ADR-0002.

Where it lives

Extend cairn-license-server — it already owns accounts, sees every login, receives the Paddle webhooks, and now has a SQLite store with room for an events table. No separate analytics service (single-binary ethos; low volume; aggregation is just SQL now that #28 landed).

How it's reached

  • New GET /v1/me{email, admin: bool}. account.html shows an "Admin" nav item only when admin is true.
  • Separate admin.html on www.swisscairn.ch (same origin as account.html, so it shares the sessionStorage bearer token — no second login). Kept a separate file so the admin bundle isn't shipped to customers.
  • admin.html calls /v1/admin/{overview,heatmap,funnel,mint}.

Authorization (the crux — server-side, not a frontend check)

Every /v1/admin/* call is gated on the session account against an explicit allowlist env ADMIN_ACCOUNTS=nikola@swisscairn.ch,manuel@swisscairn.ch (ship this). A domain rule (@swisscairn.ch) is acceptable only combined with email verification (#31) or Google-OAuth-proven domain control — otherwise open registration lets anyone claim the domain. The existing static ADMIN_TOKEN stays valid for machine/manual use; admin endpoints accept either.

Data model

One events table in the SQLite store: id, ts, type, account_email (nullable), country, meta JSON.

  • Login heatmap — capture the real client IP on /v1/login success + OAuth callback (Caddy must forward X-Forwarded-For; app trusts only Caddy), resolve to a country via an offline GeoIP DB on the VPS (MaxMind GeoLite2-Country or DB-IP lite). Store country only, never the raw IP. Map = SELECT country, count(*) GROUP BY country.
  • Funnelaccount_created at /v1/register; checkout_started from the Paddle transaction.created webhook (Paddle auto-creates a draft txn when a checkout opens — confirmed); purchase_completed from transaction.completed (already handled). Derives: created→purchased vs created→never, and checkout_started→not completed (abandoned) in a window. Only server change: subscribe/record transaction.created (today it's acknowledge-only).
  • Custom keys — extend POST /v1/admin/licenses (already issueFor(years)) to take explicit seats + interval/years + optional exact expiry + a label. A 5-year key is years: 5. These are non-subscription keys (no PaddleSub) → they simply don't auto-renew; the account UI already handles "not subscription-backed."

Privacy posture (decided with Nikola, 2026-08-25)

  • Stores only the email + resolved country for analytics — no raw IP, no fingerprint, no behavioural trail.
  • Short-term retention only; raw events pruned quickly, keep aggregates.
  • No backups of the analytics data (it is not a true loss if gone).
  • Offline resolution — nothing leaves the VPS; the VPS is in Switzerland, so no analytics data leaves Switzerland.
  • #31 email verification (also underpins domain-based admin auth if chosen).
  • #32 Altcha CAPTCHA.

Sequencing

Hold the backend build until the Claude Design v1 lands so endpoints match the UI. Can stub events, /v1/me, and the admin allowlist now if we want plumbing ready.

Internal analytics + admin surface for swisscairn operators. Frontend v1 is being drafted in Claude Design; this tracks the backend + wiring. Full rationale in ADR-0002. ## Where it lives Extend `cairn-license-server` — it already owns accounts, sees every login, receives the Paddle webhooks, and now has a SQLite store with room for an `events` table. No separate analytics service (single-binary ethos; low volume; aggregation is just SQL now that #28 landed). ## How it's reached - New `GET /v1/me` → `{email, admin: bool}`. `account.html` shows an "Admin" nav item only when `admin` is true. - Separate `admin.html` on `www.swisscairn.ch` (same origin as `account.html`, so it shares the `sessionStorage` bearer token — no second login). Kept a separate file so the admin bundle isn't shipped to customers. - `admin.html` calls `/v1/admin/{overview,heatmap,funnel,mint}`. ## Authorization (the crux — server-side, not a frontend check) Every `/v1/admin/*` call is gated on the session account against an **explicit allowlist** env `ADMIN_ACCOUNTS=nikola@swisscairn.ch,manuel@swisscairn.ch` (ship this). A domain rule (`@swisscairn.ch`) is acceptable **only** combined with email verification (#31) or Google-OAuth-proven domain control — otherwise open registration lets anyone claim the domain. The existing static `ADMIN_TOKEN` stays valid for machine/manual use; admin endpoints accept either. ## Data model One `events` table in the SQLite store: `id, ts, type, account_email (nullable), country, meta JSON`. - **Login heatmap** — capture the real client IP on `/v1/login` success + OAuth callback (Caddy must forward `X-Forwarded-For`; app trusts only Caddy), resolve to a **country** via an **offline** GeoIP DB on the VPS (MaxMind GeoLite2-Country or DB-IP lite). Store **country only**, never the raw IP. Map = `SELECT country, count(*) GROUP BY country`. - **Funnel** — `account_created` at `/v1/register`; `checkout_started` from the Paddle **`transaction.created`** webhook (Paddle auto-creates a draft txn when a checkout opens — confirmed); `purchase_completed` from `transaction.completed` (already handled). Derives: created→purchased vs created→never, and checkout_started→not completed (abandoned) in a window. Only server change: subscribe/record `transaction.created` (today it's acknowledge-only). - **Custom keys** — extend `POST /v1/admin/licenses` (already `issueFor(years)`) to take explicit seats + interval/years + optional exact expiry + a label. A 5-year key is `years: 5`. These are non-subscription keys (no `PaddleSub`) → they simply don't auto-renew; the account UI already handles "not subscription-backed." ## Privacy posture (decided with Nikola, 2026-08-25) - Stores **only the email + resolved country** for analytics — no raw IP, no fingerprint, no behavioural trail. - **Short-term retention only**; raw events pruned quickly, keep aggregates. - **No backups of the analytics data** (it is not a true loss if gone). - **Offline resolution** — nothing leaves the VPS; the VPS is in **Switzerland**, so no analytics data leaves Switzerland. ## Related hardening - #31 email verification (also underpins domain-based admin auth if chosen). - #32 Altcha CAPTCHA. ## Sequencing Hold the backend build until the Claude Design v1 lands so endpoints match the UI. Can stub `events`, `/v1/me`, and the admin allowlist now if we want plumbing ready.
Author
Owner

Auth decision (Nikola, 2026-08-25): explicit allowlist, seeded from env with no-reply@swisscairn.ch for now (the only swisscairn mailbox that exists today).

Refinement: the admin panel itself will include an "admins" section to promote/demote admin users (backed by an is_admin flag on the account, persisted in SQLite). So the env allowlist is just the bootstrap seed — once you're in, you add other admins through the UI. This means Google OAuth domain-proof is not needed; drop it from scope for now. (Email verification, #31, still lands first and remains the right default for all accounts.)

Net auth model:

  • ADMIN_ACCOUNTS env = bootstrap seed (currently no-reply@swisscairn.ch).
  • accounts.is_admin = source of truth thereafter, managed in the admin panel.
  • Every /v1/admin/* call checks the session account is admin (seed OR flag). Static ADMIN_TOKEN still valid for machine use.
Auth decision (Nikola, 2026-08-25): **explicit allowlist**, seeded from env with **`no-reply@swisscairn.ch`** for now (the only swisscairn mailbox that exists today). Refinement: the admin panel itself will include an **"admins" section to promote/demote admin users** (backed by an `is_admin` flag on the account, persisted in SQLite). So the env allowlist is just the **bootstrap seed** — once you're in, you add other admins through the UI. This means **Google OAuth domain-proof is not needed**; drop it from scope for now. (Email verification, #31, still lands first and remains the right default for all accounts.) Net auth model: - `ADMIN_ACCOUNTS` env = bootstrap seed (currently `no-reply@swisscairn.ch`). - `accounts.is_admin` = source of truth thereafter, managed in the admin panel. - Every `/v1/admin/*` call checks the session account is admin (seed OR flag). Static `ADMIN_TOKEN` still valid for machine use.
Author
Owner

Closing — the tracked scope (backend + wiring) is complete and deployed:

  • Console spineGET /v1/me, staff allowlist/gate (ADMIN_ACCOUNTS seed + admins table), promote/demote, custom-key minting (/v1/admin/{admins,mint,audit}) — PR #35.
  • Analyticsevents table, overview/accounts/timeline rollups, Paddle checkout funnel (recordPaddleEvent) — PR #36.
  • Geography — offline IP→country (DB-IP Lite, X-Forwarded-For trusted behind the 127.0.0.1 bind) + login heatmap — PR #37, with the .mmdb placed on the VPS + monthly refresh timer (ops commit).

Auth landed as the explicit-allowlist model from the 2026-08-25 decision, so it's self-contained (not gated on #22/#31). Frontend admin.html is the separate Claude Design track. Remaining hardening lives in its own issues (#31/#34 email verification, #32 CAPTCHA, #38 onboard first owner).

Closing — the tracked scope (backend + wiring) is complete and deployed: - **Console spine** — `GET /v1/me`, staff allowlist/gate (`ADMIN_ACCOUNTS` seed + `admins` table), promote/demote, custom-key minting (`/v1/admin/{admins,mint,audit}`) — PR #35. - **Analytics** — `events` table, overview/accounts/timeline rollups, Paddle checkout funnel (`recordPaddleEvent`) — PR #36. - **Geography** — offline IP→country (DB-IP Lite, X-Forwarded-For trusted behind the 127.0.0.1 bind) + login heatmap — PR #37, with the `.mmdb` placed on the VPS + monthly refresh timer (ops commit). Auth landed as the explicit-allowlist model from the 2026-08-25 decision, so it's self-contained (not gated on #22/#31). Frontend `admin.html` is the separate Claude Design track. Remaining hardening lives in its own issues (#31/#34 email verification, #32 CAPTCHA, #38 onboard first owner).
Cordy closed this issue 2026-08-26 17:55:11 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-license-server#33
No description provided.