Admin console backend spine (#33): /v1/me, staff management, custom-key minting #35

Merged
Cordy merged 4 commits from feat/admin-backend-33 into main 2026-08-25 17:50:38 +00:00
Owner

First, self-contained slice of the #33 staff-console backend — the real auth gate plus the two headline staff capabilities (add admins, mint custom keys), all session-gated.

Endpoints (all gated on a staff account session)

  • GET /v1/me{email, admin} — the real gate admin.html checks (any signed-in account; admin.html's @swisscairn.ch check is only a UI hint).
  • GET /v1/admin/admins → staff roster.
  • POST /v1/admin/admins → add / re-role / remove staff. Owner-only; enforces @swisscairn.ch, last-owner protection, and no self-change. Keyed off role (a present role upserts, empty/null removes) to match admin.html, which sends admin:false even for analyst adds.
  • POST /v1/admin/mint → mint a signed custom license (unlimited/perpetual/custom-expiry/air-gapped/free) + audit record. Owner and admin may mint; analyst may not.
  • GET /v1/admin/audit → append-only audit trail (newest first).

Design

  • admin.go owns two SQLite tables (admins, audit) directly via the store's *sql.DB. It touches neither the store save()/load() rewrite path nor the account struct — the change is purely additive.
  • Staff allowlist seeds from ADMIN_ACCOUNTS (comma-separated, default no-reply@swisscairn.ch) as role owner. Roles: owner (manage staff + mint), admin (mint), analyst (read-only).
  • The signed license format is fixed, so mint maps "unlimited" → a large seat sentinel and "perpetual" → far-future expiry; un-sealable metadata (air-gapped, free, reason, note, feature overrides) is recorded in the audit trail, which is where admin.html surfaces it.
  • One line added to main() to register the routes (via a self-removing one-shot workflow).

Deliberately deferred (Phase 2)

The analytics that need per-request event capture — /v1/admin/{overview,accounts,heatmap/geo,funnel} and per-account timeline — plus offline GeoIP. Those endpoints are not registered here, so admin.html keeps its client-side fixtures for them rather than showing a fabricated-empty dashboard. Funnel will be fed from the existing Paddle webhook; geo from login-IP → offline GeoIP.

Tests

admin_test.go: /v1/me gate (staff vs non-staff), staff management (add admin+analyst, domain rejection, self-change block, non-owner cannot manage, removal), and minting (full custom key → 201 + audit, missing-reason 400, perpetual+expiry 400, analyst-cannot-mint 403).

First, self-contained slice of the #33 staff-console backend — the real auth gate plus the two headline staff capabilities (add admins, mint custom keys), all session-gated. ## Endpoints (all gated on a staff account session) - `GET /v1/me` → `{email, admin}` — the real gate admin.html checks (any signed-in account; admin.html's `@swisscairn.ch` check is only a UI hint). - `GET /v1/admin/admins` → staff roster. - `POST /v1/admin/admins` → add / re-role / remove staff. Owner-only; enforces `@swisscairn.ch`, last-owner protection, and no self-change. Keyed off `role` (a present role upserts, empty/null removes) to match admin.html, which sends `admin:false` even for analyst adds. - `POST /v1/admin/mint` → mint a signed custom license (unlimited/perpetual/custom-expiry/air-gapped/free) + audit record. Owner and admin may mint; analyst may not. - `GET /v1/admin/audit` → append-only audit trail (newest first). ## Design - `admin.go` owns two SQLite tables (`admins`, `audit`) directly via the store's `*sql.DB`. It touches neither the store `save()`/`load()` rewrite path nor the `account` struct — the change is purely additive. - Staff allowlist seeds from `ADMIN_ACCOUNTS` (comma-separated, default `no-reply@swisscairn.ch`) as role `owner`. Roles: owner (manage staff + mint), admin (mint), analyst (read-only). - The signed license format is fixed, so mint maps "unlimited" → a large seat sentinel and "perpetual" → far-future expiry; un-sealable metadata (air-gapped, free, reason, note, feature overrides) is recorded in the audit trail, which is where admin.html surfaces it. - One line added to `main()` to register the routes (via a self-removing one-shot workflow). ## Deliberately deferred (Phase 2) The analytics that need per-request event capture — `/v1/admin/{overview,accounts,heatmap/geo,funnel}` and per-account timeline — plus offline GeoIP. Those endpoints are **not** registered here, so admin.html keeps its client-side fixtures for them rather than showing a fabricated-empty dashboard. Funnel will be fed from the existing Paddle webhook; geo from login-IP → offline GeoIP. ## Tests `admin_test.go`: `/v1/me` gate (staff vs non-staff), staff management (add admin+analyst, domain rejection, self-change block, non-owner cannot manage, removal), and minting (full custom key → 201 + audit, missing-reason 400, perpetual+expiry 400, analyst-cannot-mint 403).
Self-contained: owns its own `admins` and `audit` tables via the store's *sql.DB,
so it touches neither the store save/load rewrite nor the account struct. The
@swisscairn.ch check is a convenience hint; the real gate is the staff role in
the admins table (seeded from ADMIN_ACCOUNTS, default no-reply@swisscairn.ch).

Analytics that need per-request event capture (geo heatmap, checkout funnel,
per-account timeline, overview/accounts rollups) are a documented follow-up
phase; those endpoints are intentionally not registered yet, so admin.html keeps
its client-side fixtures for them and never shows fabricated-empty analytics.
admin: register /v1/me and /v1/admin/* routes in main() (#33)
All checks were successful
ci / test-and-build (pull_request) Successful in 15s
97e2feda8d
Cordy merged commit 86aa5c1208 into main 2026-08-25 17:50:38 +00:00
Sign in to join this conversation.
No reviewers
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#35
No description provided.