`)
+ return mailer.Message{To: to, ToName: toName, Subject: "[" + a.set.fromName + "] " + c.subject,
+ HTML: h.String(), Text: text.String(), Tag: tag}
+}
+
+func (a *authService) activationMail(u *users.User, token string) mailer.Message {
+ return a.render(u.Email, u.DisplayName, "activate", mailContent{
+ subject: "Activate your account", greeting: "Hello " + u.DisplayName + ",",
+ paragraphs: []string{"Confirm your address to activate your account. The link works once and expires in 48 hours.",
+ "When you open the link, enter the password you chose when you registered."},
+ actionLabel: "Activate my account", actionURL: a.link("activate", token)})
+}
+
+func (a *authService) resetMail(u *users.User, token string) mailer.Message {
+ return a.render(u.Email, u.DisplayName, "reset", mailContent{
+ subject: "Reset your password", greeting: "Hello " + u.DisplayName + ",",
+ paragraphs: []string{"Someone asked to reset the password of your account. The link works once and expires in 1 hour. Using it logs out all your devices."},
+ actionLabel: "Choose a new password", actionURL: a.link("reset", token)})
+}
+
+func (a *authService) registerNoticeMail(u *users.User) mailer.Message {
+ return a.render(u.Email, u.DisplayName, "register-notice", mailContent{
+ subject: "Registration attempt with your address", greeting: "Hello " + u.DisplayName + ",",
+ paragraphs: []string{"Someone tried to register a new account with your address, but you already have one.",
+ "If it was you, log in, or use \"forgot password\" if you lost it."},
+ actionLabel: "Log in", actionURL: a.set.baseURL.String() + "/#/account/login"})
+}
+
+func (a *authService) emailChangeConfirmMail(u *users.User, newEmail, token string) mailer.Message {
+ return a.render(newEmail, u.DisplayName, "email-change", mailContent{
+ subject: "Confirm your new address", greeting: "Hello " + u.DisplayName + ",",
+ paragraphs: []string{"Confirm that this address should replace the one on your account. The link expires in 24 hours."},
+ actionLabel: "Confirm new address", actionURL: a.link("confirm-email", token)})
+}
+
+func (a *authService) emailChangeNoticeMail(u *users.User, newEmail string) mailer.Message {
+ return a.render(u.Email, u.DisplayName, "email-change-notice", mailContent{
+ subject: "Address change requested", greeting: "Hello " + u.DisplayName + ",",
+ paragraphs: []string{"A change of your account address to " + newEmail + " was requested. It takes effect only when confirmed from the new address.",
+ "If this was not you, change your password now."}})
+}
+
+// addrRE matches anything around an @ up to whitespace or a character that
+// cannot appear unquoted in an address, so RFC 5322 atext specials and
+// UTF-8 local parts and domains are covered.
+var addrRE = regexp.MustCompile(`[^\s"<>()\[\],;:]+@[^\s"<>()\[\],;:]+`)
+
+// redactAddrs renders a mailer/provider error for the server log with every
+// email address replaced by : provider messages can echo the
+// recipient, and addresses belong only in the audit and mail tables.
+func redactAddrs(err error) string {
+ return addrRE.ReplaceAllString(err.Error(), "")
+}
+
+// sendMail sends msg and records it in the mail log. The error is logged
+// (without tokens) and returned so the caller can answer 503.
+func (a *authService) sendMail(ctx context.Context, u *users.User, purpose string, msg mailer.Message) error {
+ id, err := a.mail.Send(ctx, msg)
+ if err != nil {
+ log.Printf("[users] mail %s for user #%d failed: %s", purpose, u.ID, redactAddrs(err))
+ return err
+ }
+ if _, err := a.st.LogMail(idPtr(u.ID), msg.To, purpose, id); err != nil {
+ log.Printf("[users] mail log for user #%d: %v", u.ID, err)
+ }
+ return nil
+}
+
+// ingestMailEvents records provider events and flags undeliverable addresses.
+// Shared by the webhook and the admin "refresh" pull.
+func (a *authService) ingestMailEvents(evs []mailer.Event) {
+ for _, ev := range evs {
+ uid, found, err := a.st.RecordMailEvent(ev.MessageID, ev.Event, ev.At, ev.Reason)
+ if err != nil {
+ log.Printf("[users] record mail event: %v", err)
+ continue
+ }
+ if found && uid != nil && mailer.IsUndeliverable(ev.Event) {
+ if err := a.st.SetEmailBouncing(*uid, true); err != nil {
+ log.Printf("[users] flag bouncing for user #%d: %v", *uid, err)
+ }
+ }
+ }
+}
+
+// mailToken issues a one-time token for u, builds the mail around it and
+// sends it. If issuing or sending fails the purpose's outstanding tokens are
+// invalidated, so no usable link exists for a mail that never left; the
+// caller answers 503. newEmail is stored only for users.PurposeEmailChange.
+// mailPurpose is the label recorded in the mail log.
+func (a *authService) mailToken(ctx context.Context, u *users.User, tokenPurpose users.Purpose,
+ ttl time.Duration, newEmail, mailPurpose string, build func(token string) mailer.Message) error {
+ tok, err := a.st.IssueToken(u.ID, tokenPurpose, ttl, newEmail)
+ if err != nil {
+ log.Printf("[users] issue %s token for user #%d: %v", tokenPurpose, u.ID, err)
+ } else {
+ err = a.sendMail(ctx, u, mailPurpose, build(tok))
+ }
+ if err != nil {
+ if ierr := a.st.InvalidateTokens(u.ID, tokenPurpose); ierr != nil {
+ log.Printf("[users] invalidate %s tokens for user #%d: %v", tokenPurpose, u.ID, ierr)
+ }
+ return err
+ }
+ return nil
+}
diff --git a/cmd/server/auth_mail_test.go b/cmd/server/auth_mail_test.go
new file mode 100644
index 00000000..dad95fe2
--- /dev/null
+++ b/cmd/server/auth_mail_test.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "log"
+ "strings"
+ "testing"
+
+ "github.com/meshcore-analyzer/users"
+)
+
+func TestRedactAddrs(t *testing.T) {
+ cases := map[string]string{
+ `brevo 400 invalid_parameter: email "Bob.Smith+x@Mail.Example.org" is not valid`: `brevo 400 invalid_parameter: email "" is not valid`,
+ "to a@b.co and c_d@e-f.example.net failed": "to and failed",
+ "brevo 503 service unavailable": "brevo 503 service unavailable",
+ "rejected john.smith&co@example.org": "rejected ",
+ "rejected jörg.müller@x.de": "rejected ",
+ "rejected bob@bücher.de": "rejected ",
+ "rejected bob+newsletter@example.org": "rejected ",
+ "rejected o'neil!#$*/=?^{|}~@example.org, retry": "rejected , retry",
+ "recipient ; cc (bea@example.org)": "recipient <>; cc ()",
+ }
+ for in, want := range cases {
+ if got := redactAddrs(errors.New(in)); got != want {
+ t.Errorf("redactAddrs(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestSendMailLogHasNoAddress(t *testing.T) {
+ a, fake := newTestAuthService(t)
+ fake.SetSendErr(errors.New(`brevo 400: invalid email victim@example.org`))
+ var buf bytes.Buffer
+ prev := log.Writer()
+ log.SetOutput(&buf)
+ defer log.SetOutput(prev)
+ u := &users.User{ID: 7, Email: "victim@example.org", DisplayName: "Vic"}
+ if err := a.sendMail(context.Background(), u, "reset", a.resetMail(u, "tok")); err == nil {
+ t.Fatal("sendMail returned nil on a send error")
+ }
+ out := buf.String()
+ if strings.Contains(out, "victim@example.org") || !strings.Contains(out, "") || !strings.Contains(out, "#7") {
+ t.Fatalf("log line = %q", out)
+ }
+}
+
+// Activation needs the account password (handleActivate), so the mail says so.
+func TestActivationMailMentionsPassword(t *testing.T) {
+ a, _ := newTestAuthService(t)
+ u := &users.User{ID: 1, Email: "new@example.org", DisplayName: "New"}
+ m := a.activationMail(u, "tok")
+ for _, body := range []string{m.Text, m.HTML} {
+ if !strings.Contains(body, "enter the password you chose") {
+ t.Fatalf("activation mail does not mention the password: %q", body)
+ }
+ }
+}
diff --git a/cmd/server/auth_ratelimit.go b/cmd/server/auth_ratelimit.go
new file mode 100644
index 00000000..ca36a6f5
--- /dev/null
+++ b/cmd/server/auth_ratelimit.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "log"
+ "math"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+)
+
+// rateLimiter is a keyed token bucket: n requests per period, refilled
+// continuously. In-memory, so limits reset on restart. That is acceptable
+// for brute-force damping, not a quota system.
+type rateLimiter struct {
+ mu sync.Mutex
+ rate float64 // tokens per second
+ burst float64
+ buckets map[string]*rlBucket
+ now func() time.Time
+
+ maxBuckets int // cap on len(buckets); a field so tests can lower it
+ warnFull sync.Once
+}
+
+const rateLimiterMaxBuckets = 100000
+
+type rlBucket struct {
+ tokens float64
+ last time.Time
+}
+
+func newRateLimiter(n int, per time.Duration) *rateLimiter {
+ return &rateLimiter{rate: float64(n) / per.Seconds(), burst: float64(n),
+ buckets: map[string]*rlBucket{}, now: time.Now, maxBuckets: rateLimiterMaxBuckets}
+}
+
+// take consumes one token for key. When none is left it reports how long
+// until the next one.
+func (l *rateLimiter) take(key string) (bool, time.Duration) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ b := l.buckets[key]
+ if b == nil {
+ if len(l.buckets) >= l.maxBuckets {
+ l.sweepLocked(now)
+ if len(l.buckets) >= l.maxBuckets {
+ l.warnFull.Do(func() {
+ log.Printf("[users] rate limiter full (%d keys), refusing new keys until buckets refill", l.maxBuckets)
+ })
+ return false, time.Duration(1 / l.rate * float64(time.Second))
+ }
+ }
+ b = &rlBucket{tokens: l.burst, last: now}
+ l.buckets[key] = b
+ }
+ b.tokens = math.Min(l.burst, b.tokens+now.Sub(b.last).Seconds()*l.rate)
+ b.last = now
+ if b.tokens >= 1 {
+ b.tokens--
+ return true, 0
+ }
+ return false, time.Duration((1 - b.tokens) / l.rate * float64(time.Second))
+}
+
+// gc drops buckets that have refilled completely (they carry no state).
+func (l *rateLimiter) gc() {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ l.sweepLocked(l.now())
+}
+
+// sweepLocked deletes fully refilled buckets. The caller holds l.mu.
+func (l *rateLimiter) sweepLocked(now time.Time) {
+ for k, b := range l.buckets {
+ if b.tokens+now.Sub(b.last).Seconds()*l.rate >= l.burst {
+ delete(l.buckets, k)
+ }
+ }
+}
+
+// allow applies l to the client IP (only when IPs can be told apart; same
+// rule as the /ws limiter, see ws_limits.go) and to each extra key. It
+// writes 429 + Retry-After and returns false when any bucket is empty.
+func (a *authService) allow(w http.ResponseWriter, r *http.Request, l *rateLimiter, extraKeys ...string) bool {
+ keys := extraKeys
+ if ip, distinct := a.ipr.clientIP(r); distinct && ip != nil {
+ keys = append([]string{"ip:" + ip.String()}, extraKeys...)
+ } else {
+ a.warnIndistinct.Do(func() {
+ log.Printf("[users] rate limits by IP are off: requests arrive from a proxy and userManagement.trustedProxies is empty; per-address limits still apply, and the webhook limiter is off too")
+ })
+ }
+ for _, k := range keys {
+ if ok, wait := l.take(k); !ok {
+ secs := int(math.Ceil(wait.Seconds()))
+ if secs < 1 {
+ secs = 1
+ }
+ w.Header().Set("Retry-After", strconv.Itoa(secs))
+ writeError(w, http.StatusTooManyRequests, "too many attempts, try again later")
+ return false
+ }
+ }
+ return true
+}
diff --git a/cmd/server/auth_ratelimit_test.go b/cmd/server/auth_ratelimit_test.go
new file mode 100644
index 00000000..6e7db12e
--- /dev/null
+++ b/cmd/server/auth_ratelimit_test.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/meshcore-analyzer/users"
+)
+
+func TestRateLimiterBurstAndRefill(t *testing.T) {
+ now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
+ l := newRateLimiter(3, time.Minute)
+ l.now = func() time.Time { return now }
+ for i := 0; i < 3; i++ {
+ if ok, _ := l.take("k"); !ok {
+ t.Fatalf("take %d refused within burst", i)
+ }
+ }
+ ok, wait := l.take("k")
+ if ok || wait <= 0 || wait > 20*time.Second {
+ t.Fatalf("4th take = %v, wait %v", ok, wait)
+ }
+ if ok, _ := l.take("other"); !ok {
+ t.Fatal("keys are not independent")
+ }
+ now = now.Add(21 * time.Second) // one token per 20s
+ if ok, _ := l.take("k"); !ok {
+ t.Fatal("no refill after 21s")
+ }
+ now = now.Add(time.Hour)
+ l.gc()
+ l.mu.Lock()
+ n := len(l.buckets)
+ l.mu.Unlock()
+ if n != 0 {
+ t.Fatalf("gc kept %d full buckets", n)
+ }
+}
+
+func TestRateLimiterBucketCap(t *testing.T) {
+ now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
+ l := newRateLimiter(3, time.Minute)
+ l.now = func() time.Time { return now }
+ l.maxBuckets = 2
+ l.take("a")
+ l.take("b")
+ // both buckets are partly drained, so the inline sweep frees nothing
+ ok, wait := l.take("c")
+ if ok || wait <= 0 {
+ t.Fatalf("new key at the cap = %v, wait %v; want refused", ok, wait)
+ }
+ if ok, _ := l.take("a"); !ok {
+ t.Fatal("existing key refused at the cap")
+ }
+ // once the old buckets have refilled, the sweep makes room
+ now = now.Add(time.Hour)
+ if ok, _ := l.take("c"); !ok {
+ t.Fatal("new key refused after buckets refilled")
+ }
+ l.mu.Lock()
+ n := len(l.buckets)
+ l.mu.Unlock()
+ if n > 2 {
+ t.Fatalf("buckets = %d, cap 2", n)
+ }
+}
+
+func TestAllowKeysOnForwardedIP(t *testing.T) {
+ a, _ := newTestAuthService(t)
+ a.ipr.trustedProxies = parseCIDRList([]string{"10.0.0.0/8"}, "test")
+ l := newRateLimiter(1, time.Hour)
+ try := func(xff string) bool {
+ r := httptest.NewRequest("POST", "/x", nil)
+ r.RemoteAddr = "10.0.0.1:4000"
+ r.Header.Set("X-Forwarded-For", xff)
+ return a.allow(httptest.NewRecorder(), r, l)
+ }
+ if !try("198.51.100.7") {
+ t.Fatal("first request refused")
+ }
+ if try("198.51.100.7") {
+ t.Fatal("same forwarded IP not limited")
+ }
+ if !try("198.51.100.8") {
+ t.Fatal("different forwarded IP shares a bucket")
+ }
+}
+
+func TestCloseUserManagementWaitsForJanitor(t *testing.T) {
+ a, _ := newTestAuthService(t)
+ srv := &Server{auth: a}
+ release := make(chan struct{})
+ a.wg.Add(1)
+ go func() {
+ defer a.wg.Done()
+ <-a.stop
+ <-release // janitor still busy after stop was signalled
+ }()
+ done := make(chan struct{})
+ go func() { srv.closeUserManagement(); close(done) }()
+ select {
+ case <-done:
+ t.Fatal("close returned while the janitor was still running")
+ case <-time.After(100 * time.Millisecond):
+ }
+ close(release)
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("close did not return after the janitor exited")
+ }
+}
+
+func TestAdminRowConfigAdminNeedsAdminRole(t *testing.T) {
+ a, _ := newTestAuthService(t, "boss@example.org")
+ pending := users.User{Email: "boss@example.org", Role: users.RoleUser, Status: users.StatusPending}
+ if a.adminRow(pending, nil).ConfigAdmin {
+ t.Fatal("pending squatter on a config address reported as config admin")
+ }
+ admin := users.User{Email: "boss@example.org", Role: users.RoleAdmin, Status: users.StatusActive}
+ if !a.adminRow(admin, nil).ConfigAdmin {
+ t.Fatal("real config admin not reported")
+ }
+}
diff --git a/cmd/server/auth_routes.go b/cmd/server/auth_routes.go
new file mode 100644
index 00000000..33082b57
--- /dev/null
+++ b/cmd/server/auth_routes.go
@@ -0,0 +1,43 @@
+package main
+
+import "github.com/gorilla/mux"
+
+// e2eRoutes is set only by the e2etest build (auth_e2e.go).
+var e2eRoutes func(s *Server, r *mux.Router)
+
+// registerAuthRoutes adds every user-management route. Called by
+// RegisterRoutes only when s.auth != nil, so with the feature off these
+// paths are not registered and fall through to the SPA handler like any
+// unknown path.
+func (s *Server) registerAuthRoutes(r *mux.Router) {
+ r.HandleFunc("/api/auth/register", s.requireOrigin(s.handleRegister)).Methods("POST")
+ r.HandleFunc("/api/auth/activate", s.requireOrigin(s.handleActivate)).Methods("POST")
+ r.HandleFunc("/api/auth/login", s.requireOrigin(s.handleLogin)).Methods("POST")
+ r.HandleFunc("/api/auth/logout", s.requireOrigin(s.handleLogout)).Methods("POST")
+ r.HandleFunc("/api/auth/me", s.handleMe).Methods("GET")
+ r.HandleFunc("/api/auth/forgot", s.requireOrigin(s.handleForgot)).Methods("POST")
+ r.HandleFunc("/api/auth/reset", s.requireOrigin(s.handleReset)).Methods("POST")
+ r.HandleFunc("/api/account", s.withUser(s.handleAccountPatch)).Methods("PATCH")
+ r.HandleFunc("/api/account", s.withUser(s.handleAccountDelete)).Methods("DELETE")
+ r.HandleFunc("/api/account/password", s.withUser(s.handleAccountPassword)).Methods("POST")
+ r.HandleFunc("/api/account/email", s.withUser(s.handleAccountEmail)).Methods("POST")
+ r.HandleFunc("/api/account/confirm-email", s.requireOrigin(s.handleConfirmEmail)).Methods("POST")
+ r.HandleFunc("/api/account/sessions", s.withUser(s.handleAccountSessions)).Methods("GET")
+ r.HandleFunc("/api/account/sessions/{id}", s.withUser(s.handleAccountSessionDelete)).Methods("DELETE")
+ r.HandleFunc("/api/admin/users", s.withAdmin(s.handleAdminUsers)).Methods("GET")
+ r.HandleFunc("/api/admin/users/{id}", s.withAdmin(s.handleAdminUserDetail)).Methods("GET")
+ r.HandleFunc("/api/admin/users/{id}", s.withAdmin(s.handleAdminDelete)).Methods("DELETE")
+ r.HandleFunc("/api/admin/users/{id}/disable", s.withAdmin(s.handleAdminDisable)).Methods("POST")
+ r.HandleFunc("/api/admin/users/{id}/enable", s.withAdmin(s.handleAdminEnable)).Methods("POST")
+ r.HandleFunc("/api/admin/users/{id}/role", s.withAdmin(s.handleAdminRole)).Methods("POST")
+ r.HandleFunc("/api/admin/users/{id}/resend-activation", s.withAdmin(s.handleAdminResendActivation)).Methods("POST")
+ r.HandleFunc("/api/admin/users/{id}/activate", s.withAdmin(s.handleAdminActivate)).Methods("POST")
+ r.HandleFunc("/api/admin/users/{id}/mail/{mailId}/refresh", s.withAdmin(s.handleAdminMailRefresh)).Methods("POST")
+ // The webhook exists only when a secret is configured.
+ if s.auth.set.webhookSecret != "" {
+ r.HandleFunc("/api/mail/brevo/webhook", s.handleBrevoWebhook).Methods("POST")
+ }
+ if e2eRoutes != nil {
+ e2eRoutes(s, r)
+ }
+}
diff --git a/cmd/server/auth_service.go b/cmd/server/auth_service.go
new file mode 100644
index 00000000..4afbef61
--- /dev/null
+++ b/cmd/server/auth_service.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+ "log"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/meshcore-analyzer/mailer"
+ "github.com/meshcore-analyzer/users"
+)
+
+// authService is the optional user-management layer. Server.auth is nil
+// unless userManagement.enabled, and nothing in this file runs then.
+type authService struct {
+ st *users.Store
+ mail mailer.Mailer
+ set *userMgmtSettings
+ ipr *wsLimiter // only its clientIP rule is used
+
+ login *rateLimiter
+ signup *rateLimiter // register, forgot, self-service resend
+ hook *rateLimiter
+
+ warnIndistinct sync.Once
+ stop chan struct{}
+ stopOnce sync.Once
+ wg sync.WaitGroup // the janitor
+}
+
+func newAuthService(set *userMgmtSettings, st *users.Store, m mailer.Mailer) *authService {
+ return &authService{
+ st: st, mail: m, set: set,
+ ipr: &wsLimiter{trustedProxies: set.trustedProxies},
+ login: newRateLimiter(10, 15*time.Minute),
+ signup: newRateLimiter(5, time.Hour),
+ hook: newRateLimiter(600, time.Minute),
+ stop: make(chan struct{}),
+ }
+}
+
+// initUserManagement builds s.auth when the feature is on. Call it after
+// NewServer and before RegisterRoutes. measurementDBPath is passed to
+// users.Open as a forbidden path, so users.db can never be the analyzer DB.
+func (s *Server) initUserManagement(measurementDBPath string) error {
+ if !s.cfg.UserManagementEnabled() {
+ return nil
+ }
+ set, err := resolveUserManagement(s.cfg.UserManagement, measurementDBPath, os.Getenv)
+ if err != nil {
+ return err
+ }
+ st, err := users.Open(set.dbPath, measurementDBPath)
+ if err != nil {
+ return err
+ }
+ var m mailer.Mailer
+ if set.provider == "fake" {
+ m = &mailer.Fake{}
+ } else {
+ m = mailer.NewBrevo(set.brevoAPIKey, set.fromEmail, set.fromName)
+ }
+ s.auth = newAuthService(set, st, m)
+ s.auth.logStartup()
+ s.auth.wg.Add(1)
+ go func() {
+ defer s.auth.wg.Done()
+ s.auth.janitor(time.Hour)
+ }()
+ return nil
+}
+
+// closeUserManagement stops the janitor and closes users.db. Safe when off.
+func (s *Server) closeUserManagement() {
+ if s.auth == nil {
+ return
+ }
+ s.auth.stopOnce.Do(func() {
+ close(s.auth.stop)
+ s.auth.wg.Wait()
+ if err := s.auth.st.Close(); err != nil {
+ log.Printf("[users] close: %v", err)
+ }
+ })
+}
+
+func (a *authService) logStartup() {
+ log.Printf("[users] user management enabled: db=%s, %d config admin(s), webhook=%v",
+ a.set.dbPath, len(a.set.adminEmails), a.set.webhookSecret != "")
+ admins, err := a.st.List(users.ListFilter{Role: users.RoleAdmin})
+ if err != nil {
+ log.Printf("[users] list admins: %v", err)
+ return
+ }
+ for _, u := range admins {
+ if !a.isConfigAdmin(u.Email) {
+ log.Printf("[users] note: admin #%d is not in adminEmails (promoted in the UI, or removed from config)", u.ID)
+ }
+ }
+}
+
+func (a *authService) janitor(every time.Duration) {
+ t := time.NewTicker(every)
+ defer t.Stop()
+ for {
+ a.prune()
+ select {
+ case <-a.stop:
+ return
+ case <-t.C:
+ }
+ }
+}
+
+func (a *authService) prune() {
+ if _, err := a.st.PruneStalePending(48 * time.Hour); err != nil {
+ log.Printf("[users] prune pending: %v", err)
+ }
+ if _, err := a.st.PruneExpiredSessions(); err != nil {
+ log.Printf("[users] prune sessions: %v", err)
+ }
+ if _, err := a.st.PruneTokens(7 * 24 * time.Hour); err != nil {
+ log.Printf("[users] prune tokens: %v", err)
+ }
+ if _, err := a.st.PruneMail(90 * 24 * time.Hour); err != nil {
+ log.Printf("[users] prune mail log: %v", err)
+ }
+ a.login.gc()
+ a.signup.gc()
+ a.hook.gc()
+}
+
+func (a *authService) isConfigAdmin(email string) bool { return a.set.adminEmails[email] }
+
+// roleFor is the role an address gets on activation.
+func (a *authService) roleFor(email string) users.Role {
+ if a.isConfigAdmin(email) {
+ return users.RoleAdmin
+ }
+ return users.RoleUser
+}
+
+func (a *authService) audit(actor *int64, action string, target *int64, detail map[string]string) {
+ if err := a.st.Audit(actor, action, target, detail); err != nil {
+ log.Printf("[users] audit %s: %v", action, err)
+ }
+}
+
+func idPtr(id int64) *int64 { return &id }
+
+// invalidateTokens burns uid's outstanding links of each purpose. A failure
+// is logged (user id only) and returned; callers answer 500.
+func (a *authService) invalidateTokens(op string, uid int64, ps ...users.Purpose) error {
+ for _, p := range ps {
+ if err := a.st.InvalidateTokens(uid, p); err != nil {
+ log.Printf("[users] %s: invalidate %s tokens for user #%d: %v", op, p, uid, err)
+ return err
+ }
+ }
+ return nil
+}
diff --git a/cmd/server/auth_session.go b/cmd/server/auth_session.go
new file mode 100644
index 00000000..ce8f2187
--- /dev/null
+++ b/cmd/server/auth_session.go
@@ -0,0 +1,132 @@
+package main
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/meshcore-analyzer/users"
+)
+
+const (
+ sessionCookieName = "cs_session"
+ csrfHeader = "X-CS-CSRF"
+)
+
+func (a *authService) setSessionCookie(w http.ResponseWriter, raw string, expires time.Time) {
+ http.SetCookie(w, &http.Cookie{
+ Name: sessionCookieName, Value: raw, Path: "/",
+ Expires: expires, MaxAge: int(time.Until(expires).Seconds()),
+ HttpOnly: true, Secure: a.set.secureCookie, SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func (a *authService) clearSessionCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
+ HttpOnly: true, Secure: a.set.secureCookie, SameSite: http.SameSiteLaxMode})
+}
+
+// currentUser returns the active user behind the session cookie, or nils.
+// A session last seen more than a day ago is extended (sliding expiry)
+// and its cookie re-issued.
+func (a *authService) currentUser(w http.ResponseWriter, r *http.Request) (*users.User, *users.Session) {
+ c, err := r.Cookie(sessionCookieName)
+ if err != nil || c.Value == "" {
+ return nil, nil
+ }
+ sess, err := a.st.LookupSession(c.Value)
+ if err != nil {
+ return nil, nil
+ }
+ u, err := a.st.GetByID(sess.UserID)
+ if err != nil || u.Status != users.StatusActive {
+ return nil, nil
+ }
+ if time.Since(sess.LastSeenAt) > 24*time.Hour {
+ if err := a.st.ExtendSession(sess.ID, a.set.sessionTTL); err == nil {
+ a.setSessionCookie(w, c.Value, time.Now().Add(a.set.sessionTTL))
+ }
+ }
+ return u, sess
+}
+
+// originOK requires the request's Origin (or, if absent, Referer) to be the
+// configured publicBaseUrl's origin.
+func (a *authService) originOK(r *http.Request) bool {
+ want := a.set.baseURL.Scheme + "://" + a.set.baseURL.Host
+ if o := r.Header.Get("Origin"); o != "" {
+ return strings.EqualFold(o, want)
+ }
+ if ref := r.Header.Get("Referer"); ref != "" {
+ u, err := url.Parse(ref)
+ return err == nil && strings.EqualFold(u.Scheme+"://"+u.Host, want)
+ }
+ return false
+}
+
+func (a *authService) csrfOK(r *http.Request, sess *users.Session) bool {
+ return a.originOK(r) && constantTimeEqual(r.Header.Get(csrfHeader), sess.CSRFToken)
+}
+
+func isSafeMethod(m string) bool { return m == http.MethodGet || m == http.MethodHead }
+
+type authedHandler func(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session)
+
+// withUser requires a logged-in active user; state-changing methods must
+// also pass the origin + CSRF-token check.
+func (s *Server) withUser(h authedHandler) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, sess := s.auth.currentUser(w, r)
+ if u == nil {
+ writeError(w, http.StatusUnauthorized, "not logged in")
+ return
+ }
+ if !isSafeMethod(r.Method) && !s.auth.csrfOK(r, sess) {
+ writeError(w, http.StatusForbidden, "CSRF check failed")
+ return
+ }
+ h(w, r, u, sess)
+ }
+}
+
+// adminSessionOK is the one place that decides whether a session user may
+// act as admin: state-changing methods must pass the origin + CSRF check,
+// and the role must be admin. It writes the 403 itself when not.
+func (a *authService) adminSessionOK(w http.ResponseWriter, r *http.Request, u *users.User, sess *users.Session) bool {
+ if !isSafeMethod(r.Method) && !a.csrfOK(r, sess) {
+ writeError(w, http.StatusForbidden, "CSRF check failed")
+ return false
+ }
+ if u.Role != users.RoleAdmin {
+ writeError(w, http.StatusForbidden, "admin role required")
+ return false
+ }
+ return true
+}
+
+// withAdmin requires a logged-in admin (see adminSessionOK).
+func (s *Server) withAdmin(h authedHandler) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u, sess := s.auth.currentUser(w, r)
+ if u == nil {
+ writeError(w, http.StatusUnauthorized, "not logged in")
+ return
+ }
+ if !s.auth.adminSessionOK(w, r, u, sess) {
+ return
+ }
+ h(w, r, u, sess)
+ }
+}
+
+// requireOrigin guards unauthenticated state-changing endpoints (login CSRF).
+func (s *Server) requireOrigin(h http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if !s.auth.originOK(r) {
+ writeError(w, http.StatusForbidden, "request origin not allowed")
+ return
+ }
+ h(w, r)
+ }
+}
diff --git a/cmd/server/auth_types.go b/cmd/server/auth_types.go
new file mode 100644
index 00000000..23d1ee28
--- /dev/null
+++ b/cmd/server/auth_types.go
@@ -0,0 +1,206 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/meshcore-analyzer/users"
+)
+
+type okResponse struct {
+ OK bool `json:"ok"`
+ Message string `json:"message,omitempty"`
+}
+
+type meResponse struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ Role users.Role `json:"role"`
+ CSRFToken string `json:"csrfToken"`
+}
+
+type registerRequest struct {
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ Password string `json:"password"`
+}
+
+type loginRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type emailRequest struct {
+ Email string `json:"email"`
+}
+
+type tokenRequest struct {
+ Token string `json:"token"`
+}
+
+type activateRequest struct {
+ Token string `json:"token"`
+ Password string `json:"password"`
+}
+
+type resetRequest struct {
+ Token string `json:"token"`
+ Password string `json:"password"`
+}
+
+type profileRequest struct {
+ DisplayName string `json:"displayName"`
+}
+
+type passwordChangeRequest struct {
+ CurrentPassword string `json:"currentPassword"`
+ NewPassword string `json:"newPassword"`
+}
+
+type emailChangeRequest struct {
+ NewEmail string `json:"newEmail"`
+ CurrentPassword string `json:"currentPassword"`
+}
+
+type passwordConfirmRequest struct {
+ CurrentPassword string `json:"currentPassword"`
+}
+
+type roleRequest struct {
+ Role users.Role `json:"role"`
+}
+
+type sessionJSON struct {
+ ID int64 `json:"id"`
+ CreatedAt string `json:"createdAt"`
+ LastSeenAt string `json:"lastSeenAt"`
+ ExpiresAt string `json:"expiresAt"`
+ UserAgent string `json:"userAgent"`
+ Current bool `json:"current"`
+}
+
+type mailEventJSON struct {
+ Event string `json:"event"`
+ At string `json:"at"`
+ Reason string `json:"reason,omitempty"`
+}
+
+type mailJSON struct {
+ ID int64 `json:"id"`
+ Purpose string `json:"purpose"`
+ To string `json:"to"`
+ SentAt string `json:"sentAt"`
+ LastEvent string `json:"lastEvent"`
+ LastEventAt string `json:"lastEventAt"`
+ LastReason string `json:"lastReason,omitempty"`
+ Events []mailEventJSON `json:"events,omitempty"`
+}
+
+type adminUserJSON struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ Role users.Role `json:"role"`
+ Status users.Status `json:"status"`
+ CreatedAt string `json:"createdAt"`
+ ActivatedAt *string `json:"activatedAt"`
+ ActivatedManually bool `json:"activatedManually"`
+ ActivatedBy *int64 `json:"activatedBy"`
+ LastLoginAt *string `json:"lastLoginAt"`
+ EmailBouncing bool `json:"emailBouncing"`
+ ConfigAdmin bool `json:"configAdmin"`
+ LastMail *mailJSON `json:"lastMail"`
+}
+
+type auditJSON struct {
+ ID int64 `json:"id"`
+ At string `json:"at"`
+ ActorUserID *int64 `json:"actorUserId"`
+ Action string `json:"action"`
+ TargetUserID *int64 `json:"targetUserId"`
+ Detail map[string]string `json:"detail"`
+}
+
+type adminUserDetailJSON struct {
+ User adminUserJSON `json:"user"`
+ Sessions []sessionJSON `json:"sessions"`
+ Mail []mailJSON `json:"mail"`
+ Audit []auditJSON `json:"audit"`
+}
+
+func rfc3339(t time.Time) string { return t.UTC().Format(time.RFC3339) }
+
+func rfc3339Ptr(t *time.Time) *string {
+ if t == nil {
+ return nil
+ }
+ s := rfc3339(*t)
+ return &s
+}
+
+func meFrom(u *users.User, sess *users.Session) meResponse {
+ return meResponse{ID: u.ID, Email: u.Email, DisplayName: u.DisplayName, Role: u.Role, CSRFToken: sess.CSRFToken}
+}
+
+func sessionToJSON(s users.Session, currentID int64) sessionJSON {
+ return sessionJSON{ID: s.ID, CreatedAt: rfc3339(s.CreatedAt), LastSeenAt: rfc3339(s.LastSeenAt),
+ ExpiresAt: rfc3339(s.ExpiresAt), UserAgent: s.UserAgent, Current: s.ID == currentID}
+}
+
+func mailToJSON(m users.MailRecord) mailJSON {
+ out := mailJSON{ID: m.ID, Purpose: m.Purpose, To: m.ToEmail, SentAt: rfc3339(m.SentAt),
+ LastEvent: m.LastEvent, LastEventAt: rfc3339(m.LastEventAt), LastReason: m.LastReason}
+ for _, e := range m.Events {
+ out.Events = append(out.Events, mailEventJSON{Event: e.Event, At: rfc3339(e.At), Reason: e.Reason})
+ }
+ return out
+}
+
+func (a *authService) adminRow(u users.User, last map[int64]users.MailRecord) adminUserJSON {
+ row := adminUserJSON{ID: u.ID, Email: u.Email, DisplayName: u.DisplayName, Role: u.Role, Status: u.Status,
+ CreatedAt: rfc3339(u.CreatedAt), ActivatedAt: rfc3339Ptr(u.ActivatedAt), ActivatedManually: u.ActivatedBy != nil,
+ ActivatedBy: u.ActivatedBy, LastLoginAt: rfc3339Ptr(u.LastLoginAt), EmailBouncing: u.EmailBouncing,
+ ConfigAdmin: u.Role == users.RoleAdmin && a.isConfigAdmin(u.Email)}
+ if m, ok := last[u.ID]; ok {
+ mj := mailToJSON(m)
+ row.LastMail = &mj
+ }
+ return row
+}
+
+// decodeJSON reads a small JSON body into dst, rejecting unknown fields.
+func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
+ r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
+ dec := json.NewDecoder(r.Body)
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(dst); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return false
+ }
+ return true
+}
+
+// writeValidation writes 400 for a users.ValidationError and reports
+// whether it did.
+func writeValidation(w http.ResponseWriter, err error) bool {
+ var ve *users.ValidationError
+ if errors.As(err, &ve) {
+ writeError(w, http.StatusBadRequest, ve.Msg)
+ return true
+ }
+ return false
+}
+
+func writeTokenError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, users.ErrTokenExpired):
+ writeError(w, http.StatusGone, "this link has expired")
+ case errors.Is(err, users.ErrTokenInvalid):
+ writeError(w, http.StatusGone, "this link is invalid or was already used")
+ default:
+ writeError(w, http.StatusInternalServerError, "internal error")
+ }
+}
diff --git a/cmd/server/config.go b/cmd/server/config.go
index 591a9a55..bb5c4353 100644
--- a/cmd/server/config.go
+++ b/cmd/server/config.go
@@ -180,6 +180,11 @@ type Config struct {
// off; see ClientRfSamplesEnabled.
ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"`
+ // UserManagement gates optional accounts
+ // (docs/specs/2026-10-06-user-management-design.md). Absent/nil means off;
+ // see UserManagementEnabled.
+ UserManagement *UserManagementConfig `json:"userManagement,omitempty"`
+
ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"`
NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"`
diff --git a/cmd/server/go.mod b/cmd/server/go.mod
index 7518c678..a9955e08 100644
--- a/cmd/server/go.mod
+++ b/cmd/server/go.mod
@@ -44,3 +44,26 @@ require (
)
replace github.com/meshcore-analyzer/mbcapqueue => ../../internal/mbcapqueue
+
+require (
+ github.com/meshcore-analyzer/mailer v0.0.0-00010101000000-000000000000
+ github.com/meshcore-analyzer/users v0.0.0
+)
+
+replace github.com/meshcore-analyzer/users => ../../internal/users
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/sqlite v1.34.5 // indirect
+)
+
+replace github.com/meshcore-analyzer/mailer => ../../internal/mailer
diff --git a/cmd/server/go.sum b/cmd/server/go.sum
index 8c505bf8..74e587a2 100644
--- a/cmd/server/go.sum
+++ b/cmd/server/go.sum
@@ -1,8 +1,53 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
+golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
+golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
+modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/cmd/server/mail_webhook.go b/cmd/server/mail_webhook.go
new file mode 100644
index 00000000..6f7cedc3
--- /dev/null
+++ b/cmd/server/mail_webhook.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+ "io"
+ "log"
+ "net/http"
+
+ "github.com/meshcore-analyzer/mailer"
+)
+
+// handleBrevoWebhook ingests Brevo transactional events. Brevo is configured
+// with auth {"type":"bearer","token":}, so it sends
+// "Authorization: Bearer ". Unknown message ids get 200 so Brevo
+// does not retry forever.
+func (s *Server) handleBrevoWebhook(w http.ResponseWriter, r *http.Request) {
+ a := s.auth
+ if !a.allow(w, r, a.hook) {
+ return
+ }
+ if !constantTimeEqual(r.Header.Get("Authorization"), "Bearer "+a.set.webhookSecret) {
+ writeError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<10))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "body too large")
+ return
+ }
+ evs, err := mailer.ParseBrevoWebhook(body)
+ if err != nil {
+ // Authenticated but unusable: answer 200 so Brevo does not retry
+ // forever; log for the operator.
+ log.Printf("[users] brevo webhook: ignored payload: %s", redactAddrs(err))
+ writeJSON(w, okResponse{OK: true})
+ return
+ }
+ a.ingestMailEvents(evs)
+ writeJSON(w, okResponse{OK: true})
+}
diff --git a/cmd/server/mail_webhook_test.go b/cmd/server/mail_webhook_test.go
new file mode 100644
index 00000000..de6a3c01
--- /dev/null
+++ b/cmd/server/mail_webhook_test.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gorilla/mux"
+)
+
+func TestBrevoWebhookAuthAndIngest(t *testing.T) {
+ f, _, uma := adminFixture(t)
+ mails, _ := f.st.MailForUser(uma.me.ID, 10)
+ m := mails[0]
+ body := fmt.Sprintf(`{"event":"hard_bounce","email":"uma@example.org","message-id":%q,"ts_event":%d,"reason":"mailbox unavailable"}`,
+ m.ProviderMessageID, m.SentAt.Unix()+30)
+ post := func(auth, b string) int {
+ req := httptest.NewRequest("POST", "/api/mail/brevo/webhook", bytes.NewBufferString(b))
+ req.RemoteAddr = "203.0.113.50:443"
+ if auth != "" {
+ req.Header.Set("Authorization", auth)
+ }
+ w := httptest.NewRecorder()
+ f.router.ServeHTTP(w, req)
+ return w.Code
+ }
+ if c := post("", body); c != 401 {
+ t.Fatalf("no auth = %d", c)
+ }
+ if c := post("Bearer wrong-secret-xxxxxxxx", body); c != 401 {
+ t.Fatalf("wrong auth = %d", c)
+ }
+ if c := post("Bearer "+testHook, "garbage"); c != 200 { // authenticated junk: 200 so Brevo stops retrying
+ t.Fatalf("garbage = %d", c)
+ }
+ if c := post("Bearer "+testHook, body); c != 200 {
+ t.Fatalf("valid = %d", c)
+ }
+ rec, _ := f.st.MailByID(m.ID)
+ if rec.LastEvent != "hard_bounce" || rec.LastReason != "mailbox unavailable" {
+ t.Fatalf("mail record = %+v", rec)
+ }
+ if u, _ := f.st.GetByID(uma.me.ID); !u.EmailBouncing {
+ t.Fatal("bounce flag not set")
+ }
+ unknown := `{"event":"delivered","message-id":"","ts_event":1}`
+ if c := post("Bearer "+testHook, unknown); c != 200 {
+ t.Fatalf("unknown id = %d (must be 200 so Brevo stops retrying)", c)
+ }
+}
+
+func TestBrevoWebhookAbsentWithoutSecret(t *testing.T) {
+ f, _, _ := adminFixture(t)
+ f.srv.auth.set.webhookSecret = ""
+ r := newAuthFixtureRouterOnly(f)
+ req := httptest.NewRequest("POST", "/api/mail/brevo/webhook", bytes.NewBufferString("{}"))
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if w.Code != 404 {
+ t.Fatalf("no secret configured = %d, want 404", w.Code)
+ }
+}
+
+func newAuthFixtureRouterOnly(f *authFixture) *mux.Router {
+ r := mux.NewRouter()
+ f.srv.registerAuthRoutes(r)
+ return r
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 81718c7f..58a67982 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -367,6 +367,11 @@ func main() {
srv := NewServer(database, cfg, hub)
srv.configDir = configDir
srv.store = store
+ // Optional user management (off by default). Fails startup on a bad
+ // config rather than running with registration that cannot work.
+ if err := srv.initUserManagement(resolvedDB); err != nil {
+ log.Fatalf("[users] %v", err)
+ }
router := mux.NewRouter()
srv.RegisterRoutes(router)
@@ -544,6 +549,9 @@ func main() {
// 3. Close WebSocket hub
hub.Close()
+ // 3b. Close users.db (user management, opt-in; no-op when off).
+ srv.closeUserManagement()
+
// 4. Close database (release SQLite WAL lock)
if err := dbClose(); err != nil {
log.Printf("[server] DB close error: %v", err)
diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go
index 09aaad61..c9ef7749 100644
--- a/cmd/server/openapi.go
+++ b/cmd/server/openapi.go
@@ -12,10 +12,15 @@ import (
// routeMeta holds metadata for a single API route.
type routeMeta struct {
- Summary string `json:"summary"`
- Description string `json:"description,omitempty"`
- Tag string `json:"tag"`
- Auth bool `json:"auth,omitempty"`
+ Summary string `json:"summary"`
+ Description string `json:"description,omitempty"`
+ Tag string `json:"tag"`
+ Auth bool `json:"auth,omitempty"`
+ // Session marks routes that also accept the user-management session
+ // cookie (optional feature). With Auth too, either credential works.
+ // Set on the 3 documented requireAdmin routes (Auth: true) and on the
+ // user-management routes; other requireAdmin routes are undocumented.
+ Session bool `json:"session,omitempty"`
QueryParams []paramMeta `json:"queryParams,omitempty"`
// Response, when non-nil, is the OpenAPI schema object for the 200
// application/json response body. Routes without it fall back to the
@@ -48,10 +53,36 @@ func routeDescriptions() map[string]routeMeta {
"GET /api/stats": {Summary: "Network statistics", Description: "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.", Tag: "admin"},
"GET /api/perf": {Summary: "Performance statistics", Description: "Returns per-endpoint request timing and slow query log.", Tag: "admin"},
"GET /api/mqtt/status": {Summary: "MQTT source status", Description: "Returns per-MQTT-source connection state and counters (lastConnectUnix, lastPacketUnix, packetsTotal, etc.). Broker URL passwords are masked. Sourced from the ingestor stats file; empty list when unavailable. (#1043)", Tag: "admin"},
- "POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true},
+ "POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true, Session: true},
// "POST /api/admin/prune" removed in #1283 (ingestor owns prune).
- "GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true},
- "GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-.db.", Tag: "admin", Auth: true},
+ "GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true, Session: true},
+ "GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-.db.", Tag: "admin", Auth: true, Session: true},
+
+ // User management (optional; routes exist only when userManagement.enabled)
+ "POST /api/auth/register": {Summary: "Register an account", Description: "Creates a pending account and mails an activation link. The response is identical whether or not the address is already registered.", Tag: "users"},
+ "POST /api/auth/activate": {Summary: "Activate an account", Description: "Request body {token, password}: the mailed activation token and the account password chosen at (the newest) registration. A wrong password answers 401 and leaves the token usable; attempts are rate-limited per account. On success consumes the token, activates the account and starts a session.", Tag: "users"},
+ "POST /api/auth/login": {Summary: "Log in", Description: "Email + password. Sets the cs_session cookie. Rate-limited per IP and per address.", Tag: "users"},
+ "POST /api/auth/logout": {Summary: "Log out", Tag: "users"},
+ "GET /api/auth/me": {Summary: "Current user", Description: "Returns the logged-in user and the CSRF token, or 401.", Tag: "users", Session: true},
+ "POST /api/auth/forgot": {Summary: "Request a password reset", Tag: "users"},
+ "POST /api/auth/reset": {Summary: "Reset the password", Description: "Consumes the mailed reset token and ends all sessions of the user.", Tag: "users"},
+ "PATCH /api/account": {Summary: "Update profile", Tag: "users", Session: true},
+ "DELETE /api/account": {Summary: "Delete own account", Tag: "users", Session: true},
+ "POST /api/account/password": {Summary: "Change password", Tag: "users", Session: true},
+ "POST /api/account/email": {Summary: "Request an address change", Tag: "users", Session: true},
+ "POST /api/account/confirm-email": {Summary: "Confirm an address change", Tag: "users"},
+ "GET /api/account/sessions": {Summary: "List own sessions", Tag: "users", Session: true},
+ "DELETE /api/account/sessions/{id}": {Summary: "Revoke one own session", Tag: "users", Session: true},
+ "GET /api/admin/users": {Summary: "List users (admin)", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "status", Description: "pending | active | disabled", Type: "string"}, {Name: "role", Description: "user | admin", Type: "string"}, {Name: "q", Description: "Substring of email or display name", Type: "string"}}},
+ "GET /api/admin/users/{id}": {Summary: "User detail with sessions, mail log and audit (admin)", Tag: "users", Session: true},
+ "DELETE /api/admin/users/{id}": {Summary: "Delete a user (admin)", Tag: "users", Session: true},
+ "POST /api/admin/users/{id}/disable": {Summary: "Disable a user (admin)", Tag: "users", Session: true},
+ "POST /api/admin/users/{id}/enable": {Summary: "Enable a user (admin)", Tag: "users", Session: true},
+ "POST /api/admin/users/{id}/role": {Summary: "Change a user's role (admin)", Tag: "users", Session: true},
+ "POST /api/admin/users/{id}/resend-activation": {Summary: "Resend the activation mail (admin)", Tag: "users", Session: true},
+ "POST /api/admin/users/{id}/activate": {Summary: "Activate a pending user manually (admin)", Description: "For when mail keeps failing. The address stays unverified; recorded as activatedBy + audit row.", Tag: "users", Session: true},
+ "POST /api/admin/users/{id}/mail/{mailId}/refresh": {Summary: "Pull delivery events for one mail from the provider (admin)", Tag: "users", Session: true},
+ "POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer . Registered only when the secret is set.", Tag: "users"},
// Packets
"GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets",
@@ -366,10 +397,15 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{}
op["tags"] = []string{meta.Tag}
tagSet[meta.Tag] = true
}
+ var security []map[string][]string
if meta.Auth {
- op["security"] = []map[string]interface{}{
- {"ApiKeyAuth": []string{}},
- }
+ security = append(security, map[string][]string{"ApiKeyAuth": {}})
+ }
+ if meta.Session {
+ security = append(security, map[string][]string{"CookieAuth": {}})
+ }
+ if len(security) > 0 {
+ op["security"] = security
}
// Add query parameters
@@ -415,7 +451,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{}
}
// Build tags array (sorted)
- tagOrder := []string{"admin", "analytics", "channels", "config", "nodes", "observers", "packets"}
+ tagOrder := []string{"admin", "analytics", "channels", "config", "nodes", "observers", "packets", "users"}
tagDescriptions := map[string]string{
"admin": "Server administration and diagnostics",
"analytics": "Network analytics and statistics",
@@ -424,6 +460,7 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{}
"nodes": "Mesh node operations",
"observers": "Packet observer/gateway operations",
"packets": "Packet capture and decoding",
+ "users": "Optional user management (accounts, sessions, admin)",
}
var tags []interface{}
for _, t := range tagOrder {
@@ -454,6 +491,12 @@ func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{}
"in": "header",
"name": "X-API-Key",
},
+ "CookieAuth": map[string]string{
+ "type": "apiKey",
+ "in": "cookie",
+ "name": "cs_session",
+ "description": "User-management session (only when userManagement.enabled). Unsafe methods also need the X-CS-CSRF header from GET /api/auth/me.",
+ },
},
"schemas": componentSchemas(),
},
diff --git a/cmd/server/readonly_invariant_test.go b/cmd/server/readonly_invariant_test.go
index 0f671a2a..3e265655 100644
--- a/cmd/server/readonly_invariant_test.go
+++ b/cmd/server/readonly_invariant_test.go
@@ -248,3 +248,32 @@ func TestOpenDBRefusesMissingDatabase(t *testing.T) {
t.Errorf("OpenDB created %q; a read-only open must not write anything", e.Name())
}
}
+
+// TestUsersOpenIsTheOnlyServerWritePath pins the single exception to the
+// read-only invariant (user management, opt-in): cmd/server may open a
+// writable store only via users.Open, exactly once, and always passing the
+// measurement DB path as forbidden.
+func TestUsersOpenIsTheOnlyServerWritePath(t *testing.T) {
+ entries, err := os.ReadDir(".")
+ if err != nil {
+ t.Fatal(err)
+ }
+ call := regexp.MustCompile(`users\.Open\(`)
+ want := regexp.MustCompile(`users\.Open\(set\.dbPath, measurementDBPath\)`)
+ calls, good := 0, 0
+ for _, e := range entries {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+ src, err := os.ReadFile(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ calls += len(call.FindAll(src, -1))
+ good += len(want.FindAll(src, -1))
+ }
+ if calls != 1 || good != 1 {
+ t.Fatalf("users.Open calls in cmd/server: %d total, %d with the measurement DB forbidden; want exactly 1 and 1", calls, good)
+ }
+}
diff --git a/cmd/server/require_admin_test.go b/cmd/server/require_admin_test.go
new file mode 100644
index 00000000..51d710bb
--- /dev/null
+++ b/cmd/server/require_admin_test.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRequireAdminKeyOrAdminSession(t *testing.T) {
+ f, boss, uma := adminFixture(t)
+ p := "/api/test/admin-only"
+ f.router.Handle(p, f.srv.requireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, okResponse{OK: true})
+ }))).Methods("GET", "POST")
+
+ expectStatus(t, f.do("GET", p, nil), 401) // no credentials: the API-key gate answers
+ expectStatus(t, f.do("GET", p, nil, header("X-API-Key", testAPIKey)), 200)
+ expectStatus(t, f.do("GET", p, nil, as(uma)), 403) // user session
+ expectStatus(t, f.do("GET", p, nil, as(boss)), 200) // admin session, safe method
+ expectStatus(t, f.do("POST", p, nil, as(&client{cookie: boss.cookie})), 403) // no CSRF token
+ expectStatus(t, f.do("POST", p, nil, as(boss)), 200)
+ // A request carrying X-API-Key is judged on the key alone.
+ expectStatus(t, f.do("GET", p, nil, as(boss), header("X-API-Key", "wrong-key-wrong-key")), 401)
+}
+
+func TestRequireAdminWithoutUserManagementIsAPIKeyGate(t *testing.T) {
+ srv := &Server{cfg: &Config{APIKey: testAPIKey}, perfStats: NewPerfStats()}
+ h := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, okResponse{OK: true})
+ })
+ do := func(srv *Server, key string) int {
+ req := httptest.NewRequest("GET", "/x", nil)
+ if key != "" {
+ req.Header.Set("X-API-Key", key)
+ }
+ w := httptest.NewRecorder()
+ srv.requireAdmin(h).ServeHTTP(w, req)
+ return w.Code
+ }
+ if c := do(srv, ""); c != 401 {
+ t.Fatalf("no key = %d", c)
+ }
+ if c := do(srv, testAPIKey); c != 200 {
+ t.Fatalf("key = %d", c)
+ }
+ nokey := &Server{cfg: &Config{}, perfStats: NewPerfStats()}
+ if c := do(nokey, testAPIKey); c != 403 {
+ t.Fatalf("no apiKey configured = %d, want 403", c)
+ }
+}
+
+func TestPerfResetAcceptsAdminSession(t *testing.T) {
+ srv, router := setupTestServerWithAPIKey(t, testAPIKey)
+ f := newAuthFixture(t, "boss@example.org")
+ boss := f.registerAndActivate(t, "boss@example.org", "Boss", pw)
+ srv.auth = f.srv.auth // requireAdmin reads s.auth per request
+ req := httptest.NewRequest("POST", "/api/perf/reset", nil)
+ req.Header.Set("Origin", testBase)
+ req.AddCookie(boss.cookie)
+ req.Header.Set(csrfHeader, boss.csrf)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ expectStatus(t, w, http.StatusOK)
+}
diff --git a/cmd/server/routes.go b/cmd/server/routes.go
index 9b640c61..a286136f 100644
--- a/cmd/server/routes.go
+++ b/cmd/server/routes.go
@@ -127,6 +127,10 @@ type Server struct {
// Known-channels catalogue cache (issue #1323). Nil until configured;
// when nil the /api/known-channels endpoint returns an empty snapshot.
knownChannels *knownChannelsCache
+
+ // Optional user management (docs/specs/2026-10-06-user-management-design.md).
+ // Nil unless userManagement.enabled; see initUserManagement.
+ auth *authService
}
// PerfStats tracks request performance.
@@ -242,6 +246,11 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
// for /api/* — see docs/deployment-behind-cdn.md.
r.Use(cdnDetectionMiddleware)
+ // Optional user management: routes exist only when the feature is on.
+ if s.auth != nil {
+ s.registerAuthRoutes(r)
+ }
+
// Config endpoints
r.HandleFunc("/api/config/cache", s.handleConfigCache).Methods("GET")
r.HandleFunc("/api/config/client", s.handleConfigClient).Methods("GET")
@@ -251,7 +260,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET")
r.HandleFunc("/api/config/areas", s.handleConfigAreas).Methods("GET")
r.HandleFunc("/api/config/areas/polygons", s.handleConfigAreasPolygons).Methods("GET")
- r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT")
+ r.Handle("/api/config/geo-filter", s.requireAdmin(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT")
// Readiness endpoint (gated on background init completion)
r.HandleFunc("/api/healthz", s.handleHealthz).Methods("GET")
@@ -266,7 +275,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/perf/sqlite", s.handlePerfSqlite).Methods("GET")
r.HandleFunc("/api/perf/write-sources", s.handlePerfWriteSources).Methods("GET")
r.HandleFunc("/api/mqtt/status", s.handleMqttStatus).Methods("GET")
- r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
+ r.Handle("/api/perf/reset", s.requireAdmin(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
// /api/admin/prune removed in #1283 — pruning is owned by the
// ingestor process (scheduled tickers + startup pass). Operators
// who want an ad-hoc prune can restart the ingestor.
@@ -274,11 +283,11 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
// /api/admin/prune-geo-filter (#669 M4 / PR #738): server enqueues a
// marker file; the ingestor (which holds the writable DB handle)
// runs the DELETE. /status reports completion.
- r.Handle("/api/admin/prune-geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilter))).Methods("POST")
- r.Handle("/api/admin/prune-geo-filter/status", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilterStatus))).Methods("GET")
- r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
- r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET")
- r.Handle("/api/backup", s.requireAPIKey(http.HandlerFunc(s.handleBackup))).Methods("GET")
+ r.Handle("/api/admin/prune-geo-filter", s.requireAdmin(http.HandlerFunc(s.handlePruneGeoFilter))).Methods("POST")
+ r.Handle("/api/admin/prune-geo-filter/status", s.requireAdmin(http.HandlerFunc(s.handlePruneGeoFilterStatus))).Methods("GET")
+ r.Handle("/api/debug/affinity", s.requireAdmin(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
+ r.Handle("/api/dropped-packets", s.requireAdmin(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET")
+ r.Handle("/api/backup", s.requireAdmin(http.HandlerFunc(s.handleBackup))).Methods("GET")
// Packet endpoints
r.HandleFunc("/api/packets/observations", s.handleBatchObservations).Methods("POST")
@@ -453,6 +462,26 @@ func (s *Server) requireAPIKey(next http.Handler) http.Handler {
})
}
+// requireAdmin gates operator endpoints. It accepts a strong X-API-Key
+// (exactly requireAPIKey's rules) or, when user management is on, the
+// session of an admin; a cookie-authenticated unsafe method must also pass
+// the CSRF check. A request that sends X-API-Key is judged on the key alone.
+// With user management off this is requireAPIKey.
+func (s *Server) requireAdmin(next http.Handler) http.Handler {
+ keyGate := s.requireAPIKey(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if s.auth != nil && r.Header.Get("X-API-Key") == "" {
+ if u, sess := s.auth.currentUser(w, r); u != nil {
+ if s.auth.adminSessionOK(w, r, u, sess) {
+ next.ServeHTTP(w, r)
+ }
+ return
+ }
+ }
+ keyGate.ServeHTTP(w, r)
+ })
+}
+
// --- Config Handlers ---
func (s *Server) handleConfigCache(w http.ResponseWriter, r *http.Request) {
@@ -496,9 +525,17 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) {
ClientRxCoverage: s.cfg.ClientRxCoverageEnabled(),
ClientRfSamples: s.cfg.ClientRfSamplesEnabled(),
PathTrust: &pathTrust,
+ UserManagement: s.clientUserManagement(),
})
}
+func (s *Server) clientUserManagement() *ClientUserManagement {
+ if s.auth == nil {
+ return nil
+ }
+ return &ClientUserManagement{Enabled: true}
+}
+
func (s *Server) handleConfigAreas(w http.ResponseWriter, r *http.Request) {
type areaListEntry struct {
Key string `json:"key"`
diff --git a/cmd/server/types.go b/cmd/server/types.go
index f8efd466..318e3d9b 100644
--- a/cmd/server/types.go
+++ b/cmd/server/types.go
@@ -1084,6 +1084,14 @@ type ClientConfigResponse struct {
ClientRxCoverage bool `json:"clientRxCoverage"`
ClientRfSamples bool `json:"clientRfSamples"`
PathTrust *PathTrustConfig `json:"pathTrust,omitempty"`
+ // Present only when user management is on, so the payload is unchanged
+ // for every instance that leaves it off.
+ UserManagement *ClientUserManagement `json:"userManagement,omitempty"`
+}
+
+// ClientUserManagement tells the frontend that accounts exist.
+type ClientUserManagement struct {
+ Enabled bool `json:"enabled"`
}
// CustomizerClientConfig is the operator-side customizer-modal knobs that
diff --git a/cmd/server/user_mgmt_config.go b/cmd/server/user_mgmt_config.go
new file mode 100644
index 00000000..13b93f66
--- /dev/null
+++ b/cmd/server/user_mgmt_config.go
@@ -0,0 +1,137 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/meshcore-analyzer/users"
+)
+
+// UserManagementConfig is the "userManagement" block of config.json.
+type UserManagementConfig struct {
+ Enabled bool `json:"enabled"`
+ DBPath string `json:"dbPath,omitempty"`
+ AdminEmails []string `json:"adminEmails,omitempty"`
+ PublicBaseURL string `json:"publicBaseUrl,omitempty"`
+ SessionDays int `json:"sessionDays,omitempty"`
+ TrustedProxies []string `json:"trustedProxies,omitempty"`
+ Mail UserMailConfig `json:"mail"`
+}
+
+// UserMailConfig is userManagement.mail.
+type UserMailConfig struct {
+ Provider string `json:"provider,omitempty"`
+ BrevoAPIKey string `json:"brevoApiKey,omitempty"`
+ FromEmail string `json:"fromEmail,omitempty"`
+ FromName string `json:"fromName,omitempty"`
+ WebhookSecret string `json:"webhookSecret,omitempty"`
+}
+
+// UserManagementEnabled reports whether optional accounts are on. Nil config
+// or absent section means off (the default).
+func (c *Config) UserManagementEnabled() bool {
+ return c != nil && c.UserManagement != nil && c.UserManagement.Enabled
+}
+
+// userMgmtSettings is the validated, resolved form the auth service runs on.
+type userMgmtSettings struct {
+ dbPath string
+ adminEmails map[string]bool
+ baseURL *url.URL // no trailing slash, no query or fragment
+ secureCookie bool
+ sessionTTL time.Duration
+ trustedProxies []*net.IPNet
+ provider string // "brevo" or (e2etest builds only) "fake"
+ brevoAPIKey string
+ fromEmail string
+ fromName string
+ webhookSecret string
+}
+
+const defaultSessionDays = 30
+
+// fakeMailerAllowed is flipped only by the e2etest build (auth_e2e.go).
+var fakeMailerAllowed bool
+
+// resolveUserManagement validates the block and fills defaults. It refuses
+// configurations where nobody could activate an account, so the server
+// fails at startup instead of running half-working.
+func resolveUserManagement(u *UserManagementConfig, measurementDBPath string, getenv func(string) string) (*userMgmtSettings, error) {
+ set := &userMgmtSettings{adminEmails: map[string]bool{}}
+
+ set.dbPath = strings.TrimSpace(u.DBPath)
+ if set.dbPath == "" {
+ set.dbPath = filepath.Join(filepath.Dir(measurementDBPath), "users.db")
+ }
+ for _, raw := range u.AdminEmails {
+ e, err := users.NormalizeEmail(raw)
+ if err != nil {
+ return nil, fmt.Errorf("userManagement.adminEmails: %q is not a valid address", raw)
+ }
+ set.adminEmails[e] = true
+ }
+
+ base, err := url.Parse(strings.TrimSpace(u.PublicBaseURL))
+ if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
+ return nil, errors.New("userManagement.publicBaseUrl must be an absolute http(s) URL, e.g. https://corescope.example.org")
+ }
+ base.Path = strings.TrimRight(base.Path, "/")
+ base.RawQuery, base.Fragment = "", ""
+ set.baseURL = base
+ set.secureCookie = base.Scheme == "https"
+
+ days := u.SessionDays
+ if days <= 0 {
+ days = defaultSessionDays
+ }
+ if days > 365 {
+ days = 365
+ }
+ set.sessionTTL = time.Duration(days) * 24 * time.Hour
+ set.trustedProxies = parseCIDRList(u.TrustedProxies, "userManagement.trustedProxies")
+
+ set.provider = strings.ToLower(strings.TrimSpace(u.Mail.Provider))
+ if set.provider == "" {
+ set.provider = "brevo"
+ }
+ set.brevoAPIKey = envOrValue(getenv, "CORESCOPE_BREVO_API_KEY", u.Mail.BrevoAPIKey)
+ set.webhookSecret = envOrValue(getenv, "CORESCOPE_BREVO_WEBHOOK_SECRET", u.Mail.WebhookSecret)
+ from, err := users.NormalizeEmail(u.Mail.FromEmail)
+ if err != nil {
+ return nil, errors.New("userManagement.mail.fromEmail must be a valid address")
+ }
+ set.fromEmail = from
+ set.fromName = strings.TrimSpace(u.Mail.FromName)
+ if set.fromName == "" {
+ set.fromName = "CoreScope"
+ }
+
+ switch set.provider {
+ case "brevo":
+ if set.brevoAPIKey == "" {
+ return nil, errors.New("userManagement.mail: a Brevo API key is required (mail.brevoApiKey or CORESCOPE_BREVO_API_KEY)")
+ }
+ case "fake":
+ if !fakeMailerAllowed {
+ return nil, errors.New(`userManagement.mail.provider "fake" is only available in e2etest builds`)
+ }
+ default:
+ return nil, fmt.Errorf("userManagement.mail.provider %q is not supported (use \"brevo\")", u.Mail.Provider)
+ }
+ if set.webhookSecret != "" && len(set.webhookSecret) < 16 {
+ return nil, errors.New("userManagement.mail.webhookSecret must be at least 16 characters")
+ }
+ return set, nil
+}
+
+func envOrValue(getenv func(string) string, key, fallback string) string {
+ if v := strings.TrimSpace(getenv(key)); v != "" {
+ return v
+ }
+ return strings.TrimSpace(fallback)
+}
diff --git a/cmd/server/user_mgmt_config_test.go b/cmd/server/user_mgmt_config_test.go
new file mode 100644
index 00000000..150a742f
--- /dev/null
+++ b/cmd/server/user_mgmt_config_test.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func validUM() *UserManagementConfig {
+ return &UserManagementConfig{
+ Enabled: true,
+ AdminEmails: []string{" Boss@Example.org "},
+ PublicBaseURL: "https://scope.example.org/",
+ Mail: UserMailConfig{BrevoAPIKey: "xkeysib-abc", FromEmail: "noreply@example.org"},
+ }
+}
+
+func noEnv(string) string { return "" }
+
+func TestResolveUserManagementDefaults(t *testing.T) {
+ set, err := resolveUserManagement(validUM(), filepath.Join("data", "meshcore.db"), noEnv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if set.dbPath != filepath.Join("data", "users.db") {
+ t.Errorf("dbPath = %q", set.dbPath)
+ }
+ if !set.adminEmails["boss@example.org"] {
+ t.Errorf("adminEmails not normalized: %v", set.adminEmails)
+ }
+ if set.baseURL.String() != "https://scope.example.org" || !set.secureCookie {
+ t.Errorf("baseURL = %q secure=%v", set.baseURL, set.secureCookie)
+ }
+ if set.sessionTTL != 30*24*time.Hour || set.provider != "brevo" || set.fromName != "CoreScope" {
+ t.Errorf("defaults: ttl=%v provider=%q fromName=%q", set.sessionTTL, set.provider, set.fromName)
+ }
+}
+
+func TestResolveUserManagementEnvWins(t *testing.T) {
+ env := map[string]string{"CORESCOPE_BREVO_API_KEY": "from-env", "CORESCOPE_BREVO_WEBHOOK_SECRET": "env-secret-0123456789"}
+ set, err := resolveUserManagement(validUM(), "meshcore.db", func(k string) string { return env[k] })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if set.brevoAPIKey != "from-env" || set.webhookSecret != "env-secret-0123456789" {
+ t.Fatalf("env not applied: %q %q", set.brevoAPIKey, set.webhookSecret)
+ }
+}
+
+func TestResolveUserManagementErrors(t *testing.T) {
+ cases := map[string]func(u *UserManagementConfig){
+ "publicBaseUrl": func(u *UserManagementConfig) { u.PublicBaseURL = "scope.example.org" },
+ "fromEmail": func(u *UserManagementConfig) { u.Mail.FromEmail = "" },
+ "Brevo API key": func(u *UserManagementConfig) { u.Mail.BrevoAPIKey = "" },
+ "adminEmails": func(u *UserManagementConfig) { u.AdminEmails = []string{"not-an-address"} },
+ "not supported": func(u *UserManagementConfig) { u.Mail.Provider = "smtp" },
+ "e2etest builds": func(u *UserManagementConfig) { u.Mail.Provider = "fake" },
+ "webhookSecret": func(u *UserManagementConfig) { u.Mail.WebhookSecret = "short" },
+ }
+ for want, mutate := range cases {
+ if want == "e2etest builds" && fakeMailerAllowed {
+ continue // the e2etest build accepts the fake provider by design
+ }
+ u := validUM()
+ mutate(u)
+ _, err := resolveUserManagement(u, "meshcore.db", noEnv)
+ if err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("%s: err = %v", want, err)
+ }
+ }
+}
+
+func TestUserManagementEnabled(t *testing.T) {
+ var nilCfg *Config
+ if nilCfg.UserManagementEnabled() || (&Config{}).UserManagementEnabled() ||
+ (&Config{UserManagement: &UserManagementConfig{}}).UserManagementEnabled() {
+ t.Fatal("off states reported as enabled")
+ }
+ if !(&Config{UserManagement: &UserManagementConfig{Enabled: true}}).UserManagementEnabled() {
+ t.Fatal("enabled not reported")
+ }
+}
diff --git a/cmd/server/user_mgmt_off_test.go b/cmd/server/user_mgmt_off_test.go
new file mode 100644
index 00000000..29f1eff1
--- /dev/null
+++ b/cmd/server/user_mgmt_off_test.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/gorilla/mux"
+)
+
+func TestUserManagementOffIsUnchanged(t *testing.T) {
+ srv, router := setupTestServer(t)
+ if srv.auth != nil {
+ t.Fatal("auth built without config")
+ }
+ for _, p := range []string{"/api/auth/me", "/api/admin/users", "/api/account/sessions"} {
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest("GET", p, nil))
+ if w.Code != 404 {
+ t.Errorf("%s = %d with the feature off; want 404", p, w.Code)
+ }
+ }
+ base := httptest.NewRecorder()
+ router.ServeHTTP(base, httptest.NewRequest("GET", "/api/config/client", nil))
+ if strings.Contains(base.Body.String(), "userManagement") {
+ t.Fatal("client config mentions userManagement while off")
+ }
+ // An explicit {"enabled": false} block is byte-identical to no block.
+ dir := t.TempDir()
+ srv.cfg.UserManagement = &UserManagementConfig{Enabled: false}
+ if err := srv.initUserManagement(filepath.Join(dir, "meshcore.db")); err != nil || srv.auth != nil {
+ t.Fatalf("initUserManagement off: auth=%v err=%v", srv.auth, err)
+ }
+ off := httptest.NewRecorder()
+ router.ServeHTTP(off, httptest.NewRequest("GET", "/api/config/client", nil))
+ if off.Body.String() != base.Body.String() {
+ t.Fatal("client config differs between absent and disabled block")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "users.db")); !os.IsNotExist(err) {
+ t.Fatalf("users.db created next to the measurement DB with the feature off: %v", err)
+ }
+}
+
+func TestClientConfigAdvertisesUserManagement(t *testing.T) {
+ srv, router := setupTestServer(t)
+ a, _ := newTestAuthService(t)
+ srv.auth = a
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest("GET", "/api/config/client", nil))
+ if !strings.Contains(w.Body.String(), `"userManagement":{"enabled":true}`) {
+ t.Fatalf("client config = %s", w.Body.String())
+ }
+}
+
+// Through the real router (RegisterRoutes, feature on): auth responses must
+// never be cached by a CDN or browser.
+func TestAuthRoutesThroughRealRouterAreNoStore(t *testing.T) {
+ srv, _ := setupTestServer(t)
+ a, _ := newTestAuthService(t)
+ srv.auth = a
+ router := mux.NewRouter()
+ srv.RegisterRoutes(router)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest("GET", "/api/auth/me", nil))
+ if w.Code == 404 {
+ t.Fatalf("/api/auth/me = 404 with the feature on")
+ }
+ if got := w.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("Cache-Control = %q; want no-store", got)
+ }
+}
+
+func TestInitUserManagementRefusesMeasurementDB(t *testing.T) {
+ dir := t.TempDir()
+ measurement := filepath.Join(dir, "meshcore.db")
+ os.WriteFile(measurement, nil, 0o644)
+ srv := &Server{cfg: &Config{UserManagement: &UserManagementConfig{
+ Enabled: true, DBPath: measurement, PublicBaseURL: testBase,
+ Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"},
+ }}}
+ err := srv.initUserManagement(measurement)
+ if err == nil || !strings.Contains(err.Error(), "measurement database") {
+ t.Fatalf("err = %v", err)
+ }
+}
+
+func TestInitUserManagementCreatesUsersDB(t *testing.T) {
+ dir := t.TempDir()
+ srv := &Server{cfg: &Config{UserManagement: &UserManagementConfig{
+ Enabled: true, PublicBaseURL: testBase,
+ Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"},
+ }}}
+ if err := srv.initUserManagement(filepath.Join(dir, "meshcore.db")); err != nil {
+ t.Fatal(err)
+ }
+ defer srv.closeUserManagement()
+ if _, err := os.Stat(filepath.Join(dir, "users.db")); err != nil {
+ t.Fatalf("users.db not created next to the measurement DB: %v", err)
+ }
+}
diff --git a/config.example.json b/config.example.json
index b8a68410..f8823610 100644
--- a/config.example.json
+++ b/config.example.json
@@ -429,6 +429,22 @@
}
},
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache.",
+ "userManagement": {
+ "enabled": false,
+ "dbPath": "",
+ "adminEmails": ["operator@example.org"],
+ "publicBaseUrl": "https://corescope.example.org",
+ "sessionDays": 30,
+ "trustedProxies": [],
+ "mail": {
+ "provider": "brevo",
+ "brevoApiKey": "",
+ "fromEmail": "noreply@example.org",
+ "fromName": "CoreScope",
+ "webhookSecret": ""
+ }
+ },
+ "_comment_userManagement": "Optional accounts (off by default; see docs/user-guide/accounts.md). When enabled: visitors can register with email + password and activate through a mailed link; addresses in adminEmails become admins; admins manage users at #/admin/users and can use the operator endpoints without the apiKey. The dashboard stays public. dbPath defaults to users.db next to the analyzer database: a separate file, the server never writes measurement data. publicBaseUrl is required and is used for every link in every mail. Mail goes through Brevo's transactional API (a free account is enough); brevoApiKey and webhookSecret may instead come from CORESCOPE_BREVO_API_KEY and CORESCOPE_BREVO_WEBHOOK_SECRET. Set webhookSecret (16+ chars) and point a Brevo transactional webhook at /api/mail/brevo/webhook with bearer auth to see delivery status per user. trustedProxies (CIDRs) lets login rate limits use X-Forwarded-For behind a reverse proxy. Startup fails if enabled with an incomplete mail setup.",
"customizer": {
"disabledTabs": [],
"_comment_disabledTabs": "Issue #1508. List of customizer-modal tab ids to hide from end users. Useful when operators want to expose only daily-use viewer controls and keep one-shot admin chrome out of the way. Recognized ids: branding, theme, nodes, home, display, geofilter, export. Example: [\"branding\", \"geofilter\", \"export\"] hides the admin tabs and leaves theme/colors/home/display visible. Default [] keeps the legacy behavior (all tabs visible). Operators edit this in config.json directly — there is no in-app UI for this list (by design)."
diff --git a/docs/api-spec.md b/docs/api-spec.md
index 118ec625..0bc7481c 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -11,6 +11,7 @@
## Table of Contents
- [Conventions](#conventions)
+- [User management (optional)](#user-management-optional)
- [GET /api/stats](#get-apistats)
- [GET /api/health](#get-apihealth)
- [GET /api/perf](#get-apiperf)
@@ -100,6 +101,57 @@ They return `total` (the unfiltered/filtered count before pagination).
---
+## User management (optional)
+
+These routes are registered only when `userManagement.enabled` is true. When it is off they do not exist, and requests to them fall through to the SPA page (HTTP 200, HTML). The webhook route exists only when `mail.webhookSecret` is also set. Sessions use the `cs_session` cookie (HttpOnly, SameSite=Lax). Every state-changing request that authenticates with the cookie needs an `Origin` (or `Referer`) equal to `publicBaseUrl` and the header `X-CS-CSRF: `. Errors are `{"error": ""}`. Rate-limited calls answer `429` with `Retry-After`. Request bodies are JSON. On every route except the webhook they are limited to 16 KiB and unknown fields are rejected with `400`; the webhook takes up to 256 KiB of Brevo's payload as is.
+
+| Method and path | Auth | Body and response |
+|---|---|---|
+| `POST /api/auth/register` | origin | `{email, displayName, password}` -> `{ok, message}`, identical for known addresses. Registering a pending address again replaces its password and name and mails a new link |
+| `POST /api/auth/activate` | origin | `{token, password}` -> me + session cookie. `410` expired, used or already activated; `401` "wrong password for this account" (the link stays usable); `409` "account changed, try again" when the account changed between the password check and the activation (for example a re-register) |
+| `POST /api/auth/login` | origin | `{email, password}` -> me + cookie. `401` "incorrect email or password" for every failure, including pending and disabled accounts |
+| `POST /api/auth/logout` | origin | -> `{ok}` |
+| `GET /api/auth/me` | session | -> `{id, email, displayName, role, csrfToken}`. `401` when not logged in |
+| `POST /api/auth/forgot` | origin | `{email}` -> `{ok, message}` |
+| `POST /api/auth/reset` | origin | `{token, password}` -> `{ok, message}`. Ends all sessions and any pending email-change link. `410` expired or used |
+| `PATCH /api/account` | session | `{displayName}` -> me |
+| `POST /api/account/password` | session | `{currentPassword, newPassword}` -> `{ok, message}`. Ends the other sessions and outstanding reset and email-change links |
+| `POST /api/account/email` | session | `{newEmail, currentPassword}` -> `{ok, message}`. Rate-limited. A confirmation goes to the new address and a notice to the old one; the answer is the same when the new address is already in use |
+| `POST /api/account/confirm-email` | origin | `{token}` -> `{ok, message}`. `410` expired or used; `409` address now in use |
+| `GET /api/account/sessions` | session | -> `[{id, createdAt, lastSeenAt, expiresAt, userAgent, current}]` |
+| `DELETE /api/account/sessions/{id}` | session | -> `{ok}`. `404` not your session |
+| `DELETE /api/account` | session | `{currentPassword}` -> `{ok, message}`. `409` you are the last admin |
+| `GET /api/admin/users?status=&role=&q=` | admin | -> `[adminUser]`. `status` is `pending`, `active` or `disabled`; `role` is `user` or `admin` |
+| `GET /api/admin/users/{id}` | admin | -> `{user, sessions, mail, audit}` |
+| `POST /api/admin/users/{id}/disable` | admin | -> adminUser. Active accounts only; ends the user's sessions and links |
+| `POST /api/admin/users/{id}/enable` | admin | -> adminUser. Disabled accounts only |
+| `DELETE /api/admin/users/{id}` | admin | -> `{ok}` |
+| `POST /api/admin/users/{id}/role` | admin | `{role}` (`user` or `admin`) -> adminUser |
+| `POST /api/admin/users/{id}/resend-activation` | admin | -> adminUser. Pending accounts only |
+| `POST /api/admin/users/{id}/activate` | admin | -> adminUser. Pending accounts only; the address stays unverified |
+| `POST /api/admin/users/{id}/mail/{mailId}/refresh` | admin | -> mail record with `events`, pulled from the provider. `502` provider unavailable |
+| `POST /api/mail/brevo/webhook` | `Authorization: Bearer ` | Brevo transactional event payload -> `{ok}`. `401` on a wrong secret |
+
+`adminUser` = `{id, email, displayName, role, status, createdAt, activatedAt, activatedManually, activatedBy, lastLoginAt, emailBouncing, configAdmin, lastMail}`. `status` is `pending`, `active` or `disabled`.
+
+Error codes you can get on the routes above:
+
+| Code | Meaning |
+|---|---|
+| `400` | Validation failed (email, display name, password, role, filter, id) |
+| `401` | Not logged in, or wrong password on activate or login |
+| `403` | `"CSRF check failed"`, `"request origin not allowed"`, `"admin role required"`, or `"current password is incorrect"` (password change, email change, account delete) |
+| `404` | `"user not found"` (admin `{id}` routes), `"mail not found"` (mail refresh), `"session not found"` (`DELETE /api/account/sessions/{id}`) |
+| `409` | A state guard: `"only active accounts can be disabled; ..."`, role change on a pending account, disable or delete of yourself, disable, delete or demotion of a config admin or the last admin, enable of a non-disabled account, `"this mail has no provider message id"` (mail refresh), `"account changed, try again"` (activate) |
+| `410` | Link expired, invalid or already used |
+| `429` | Rate limit; see `Retry-After` |
+| `502` | `"mail provider unavailable"` (mail refresh) |
+| `503` | `"mail could not be sent, try again later"` |
+
+With user management on, every endpoint that needs `X-API-Key` also accepts an admin session (plus the CSRF header for unsafe methods). A request that sends `X-API-Key` is judged on the key alone. `GET /api/config/client` gains `"userManagement": {"enabled": true}` only when the feature is on.
+
+---
+
## GET /api/stats
Server-wide statistics. Lightweight, cached 10s.
diff --git a/docs/specs/2026-10-06-user-management-design.md b/docs/specs/2026-10-06-user-management-design.md
new file mode 100644
index 00000000..0a18a87e
--- /dev/null
+++ b/docs/specs/2026-10-06-user-management-design.md
@@ -0,0 +1,487 @@
+# Optional User Management (Sub-project A: Foundation), Design Spec
+
+**Date:** 2026-10-06
+**Status:** Approved (design), implemented on this branch.
+**Scope:** sub-project **A** of a five-part track (see *Roadmap*). B–E get their own
+spec → plan → implementation cycle and build on A.
+
+---
+
+## Problem
+
+CoreScope has exactly one notion of identity: a single shared `apiKey`, sent as
+`X-API-Key`, that gates a handful of operator endpoints (`requireAPIKey`,
+`cmd/server/routes.go:420`). Everything a visitor configures lives in their own
+browser's `localStorage` (≈80 keys: own nodes, favorites, customizer delta, filters,
+UI state). That model is deliberate and stays the default, but it repeatedly blocks
+features that need a *person*:
+
+- **Own nodes and favorites across devices.** #895 ("must manually claim and favorite
+ all the nodes again"), #1765/#1767 (My Repeaters shipped "frontend-only" because
+ there is no server-side starred set), #106, #381, #665, #2100.
+- **Per-user preferences vs operator defaults.** The customizer has three layers
+ (#502, #288). #1508 hides admin tabs, but that is cosmetic: "everything is
+ browser-local localStorage state … doesn't actually prevent state mutation."
+- **Alerts to a person.** #775 (alerting epic, closed unstaffed), #663 (battery
+ thresholds with no delivery), #664/#666, #730.
+- **Operator workflows in the UI.** #331 was closed because "auth model, audit log,
+ write-validation all need design BEFORE" a server-write UI. #819 deferred
+ server-side geofilter save for lack of auth. #2092 (suggested hashtag channels with
+ admin review) was declined upstream for now, pending a general user-management
+ design ("so that it is generally usable across CoreScope rather than built for one
+ feature").
+
+There is no session, cookie, CSRF, rate-limit or mail code anywhere (`public/app.js:1769`:
+"Login — removed, no auth yet").
+
+## Goals
+
+1. An **optional** user system, **off by default**. With it off, CoreScope behaves
+ and responds exactly as today. That includes routes, API payloads, UI and files
+ on disk.
+2. Self-registration with **email + password**, activated by a link sent to that
+ address; activated accounts get role `user`.
+3. Roles `user` and `admin`. Admins can manually activate, disable, enable, delete,
+ promote and demote users, and see the delivery status of the mails sent to them.
+4. A foundation that B–E build on without redesign: sessions, roles, an audit log,
+ a mailer.
+
+## Non-goals (this spec)
+
+- Syncing `localStorage` to the profile (sub-project B).
+- Admin dashboard or extra statistics (C), approval flows (D), alerts (E).
+- Requiring login to *view* the dashboard (#1835). The dashboard stays public for
+ anonymous visitors; logging in only adds features. Out of scope for this track.
+- Channel keys (PSKs). They never go to the server: "Server Must NEVER Handle User
+ Channel Keys" (#725). Changing that needs a separate, explicit design decision.
+- OAuth/SSO, 2FA, magic-link login. Possible later; the schema doesn't preclude them.
+
+## Decisions
+
+| # | Question | Decision |
+|---|---|---|
+| 1 | Login method | Email + password, activation by mailed link, password reset by mailed link. |
+| 2 | First admin | `adminEmails` in config: an account activated with a listed address becomes `admin`. Config admins can't be demoted, disabled or deleted from the UI. |
+| 3 | Existing `apiKey` | Keeps working. Every endpoint gated today accepts `X-API-Key` **or** an admin session. |
+| 4 | Dashboard visibility | Always public. Login is additive. |
+| 5 | Storage | A separate SQLite file `users.db`, opened read-write by the server only, only when the feature is on. The measurement DB stays read-only to the server. |
+| 6 | Profile | Email (private) plus a display name (shown to others in later sub-projects). |
+| 7 | Mail | Brevo transactional API by default, behind a `Mailer` interface. Delivery events are recorded and shown to admins. |
+
+### Why a separate `users.db` (decision 5)
+
+The server opens the measurement DB with `mode=ro` (`cmd/server/db.go:107`), and
+`cmd/server/readonly_invariant_test.go` enforces it (#1283). Three options were
+weighed:
+
+- **Ingestor-owned tables fed through a file queue** (like `internal/prunequeue`, or
+ #2092's design). This keeps the invariant literally, but login, activation and
+ password changes become asynchronous, and logging in would depend on the ingestor
+ running. Rejected.
+- **A JSON file** (like `SaveGeoFilter` rewriting `config.json`). No concurrency
+ control or indexes; it breaks down once B and E add per-user rows. Rejected.
+- **A separate SQLite file owned by the server.** Auth is synchronous, users are
+ isolated from measurement data, and backing up or resetting one doesn't touch the
+ other. Chosen.
+
+The invariant is restated rather than dropped: **the server never writes
+measurement data**. `users.db`, written only through `internal/users`, is the single
+exception, and the invariant test is extended to pin exactly that.
+
+## Configuration
+
+A new optional block. If the block is absent or `enabled` is false, the feature is
+off.
+
+```json
+"userManagement": {
+ "enabled": false,
+ "dbPath": "data/users.db",
+ "adminEmails": ["operator@example.org"],
+ "publicBaseUrl": "https://corescope.example.org",
+ "sessionDays": 30,
+ "trustedProxies": [],
+ "mail": {
+ "provider": "brevo",
+ "brevoApiKey": "",
+ "fromEmail": "noreply@example.org",
+ "fromName": "CoreScope",
+ "webhookSecret": ""
+ }
+}
+```
+
+- `dbPath` defaults to `users.db` in the same directory as the analyzer database when
+ empty.
+
+- The shape follows the existing opt-in pattern: a pointer sub-struct with
+ `Enabled bool`, plus a nil-safe accessor where nil means off (`ClientRxCoverage`,
+ `cmd/server/config.go:170,268-277`).
+- **Off:** no routes are registered (requests fall through to the SPA page, 200 HTML,
+ like any unknown path, rather than a router 404), `users.db` is
+ not opened or created, and `/api/config/client` does not contain the
+ `userManagement` field at all, so its payload is byte-identical to today.
+- **On:** startup fails with a clear error unless all of the following hold:
+ - `publicBaseUrl` is an absolute `http(s)` URL.
+ - `mail.fromEmail` is set.
+ - A Brevo API key is available.
+ - `dbPath` is writable.
+
+ A half-working setup where nobody can activate is worse than refusing to start.
+- `publicBaseUrl` builds every link in every mail. The request `Host` header is never
+ used, so a forged host can't poison a reset link.
+- Secrets can come from the environment instead of `config.json`:
+ `CORESCOPE_BREVO_API_KEY` and `CORESCOPE_BREVO_WEBHOOK_SECRET`. The environment
+ value wins.
+- `trustedProxies`: CIDRs whose `X-Forwarded-For` is trusted for rate limiting. When
+ it is empty, the TCP peer address is used, with the same rule as the `/ws` limiter
+ (`clientIP` in `cmd/server/ws_limits.go`): a loopback or private peer means clients
+ cannot be told apart, so per-IP limits are off (see *Security*).
+- `adminEmails` are compared case-insensitively after trimming.
+- `config.example.json` gets the block with a `_comment` in the file's existing
+ style.
+
+## Components
+
+Each unit has one job and can be tested on its own.
+
+| Unit | Responsibility | Depends on |
+|---|---|---|
+| `internal/users` | `users.db`: open, migrate, CRUD for users, sessions, tokens, audit log, mail log. Password hashing. No HTTP. | `modernc.org/sqlite`, `golang.org/x/crypto/argon2` |
+| `internal/mailer` | `Mailer` interface `Send(ctx, Message) (messageID string, err error)`. `Events(ctx, messageID) ([]Event, error)` (pull). A Brevo implementation and an in-memory fake for tests. | `net/http` |
+| `cmd/server/auth_*.go` | HTTP handlers, session middleware, role checks, CSRF, rate limiting, mail templates, the Brevo webhook. | `internal/users`, `internal/mailer` |
+| `public/account.js` | Login, register, activate, forgot, reset and "my account" views. The header account control. | existing helpers |
+| `public/admin-users.js` | The admin user table and actions. | existing helpers |
+
+New dependency: `golang.org/x/crypto`, a Go-team module. No new frontend
+dependencies and no build step (AGENTS.md).
+
+## Data model (`users.db`)
+
+The schema has its own `schema_version` and forward-only migrations, independent of
+`internal/dbschema`. It is opened in WAL mode with `busy_timeout`.
+
+- **`users`**:
+ - `id` INTEGER PK
+ - `email` TEXT UNIQUE (stored lowercase, trimmed)
+ - `display_name` TEXT (2–32 chars after trim, not unique, no control or bidi
+ characters)
+ - `password_hash` TEXT (argon2id, PHC string with parameters, so they can be
+ raised later)
+ - `role` (`user`|`admin`), `status` (`pending`|`active`|`disabled`)
+ - `created_at`, `activated_at`, `last_login_at`
+ - `activated_by` (nullable admin user id; null means activated by link)
+ - `email_bouncing` BOOLEAN, set on a hard bounce
+- **`sessions`**:
+ - `token_hash` PK (SHA-256 of a 256-bit random token; the raw token exists only in
+ the cookie)
+ - `user_id`, `csrf_token`
+ - `created_at`, `expires_at`, `last_seen_at`
+ - `user_agent` (truncated)
+- **`tokens`**: one-time links.
+ - `token_hash` PK, `user_id`
+ - `purpose` (`activate`|`reset`|`email_change`), `new_email` (for `email_change`)
+ - `expires_at`, `used_at`
+ - Lifetimes: activate 48 h, reset 1 h, email change 24 h.
+- **`audit_log`**:
+ - `id`, `at`, `actor_user_id` (nullable; null means the system or the API key)
+ - `action`, `target_user_id`, `detail` (JSON)
+ - Rows outlive deleted users; they then show as "deleted user #N".
+- **`mail_log`**:
+ - `id`, `user_id` (nullable after delete), `to_email`
+ - `purpose`, `provider_message_id`, `sent_at`
+ - `last_event`, `last_event_at`, `last_reason`
+ - It is pruned after 90 days.
+- **`mail_events`**:
+ - `mail_id`, `event`, `at`, `reason`
+ - The full history behind `mail_log.last_event`, pruned with it.
+
+## Flows
+
+All request and response bodies are JSON. Error responses use the server's existing
+error shape.
+
+### Register and activate
+
+1. `POST /api/auth/register {email, displayName, password}`. The server validates:
+ - Email syntax, at most 254 chars.
+ - Password: 10–128 chars, no composition rules (NIST 800-63B).
+ - Display name: per the rule above.
+2. A user is created with status `pending` and an activation mail is sent with
+ `{publicBaseUrl}/#/account/activate?token=…`.
+3. If the address is already registered, the response is **identical** to the
+ success case ("check your mail"). This prevents account enumeration. What gets
+ mailed depends on the account:
+ - An active or disabled account gets a mail: "someone tried to register with your
+ address; if it was you, log in or reset your password."
+ - A still-pending account stores the newest password and display name and gets a
+ fresh activation link instead (newest registration wins). A squatter therefore
+ cannot activate the owner into the squatter's password.
+4. `POST /api/auth/activate {token, password}`:
+ - The token is checked without being consumed. The password must match the pending
+ account's password. A wrong password answers 401, the link stays usable, and
+ attempts are rate-limited per IP and per account.
+ - Then, in one transaction, it consumes the token, sets the status to `active` and
+ sets the role to `admin` if the address is in `adminEmails`, and starts a session.
+ The transaction applies only while the account is still pending with the password
+ hash that was checked; if a re-register (or an admin) changed it in between, it
+ answers 409 "account changed, try again" and the link is not consumed.
+5. Pending accounts with an expired token are pruned periodically.
+6. The admin action "resend activation" issues a fresh token and invalidates the old
+ one.
+
+### Log in and log out
+
+- `POST /api/auth/login {email, password}`. It succeeds only for `active` users.
+ - Any failure (unknown address, wrong password, pending, disabled) returns the same
+ 401 message after a constant-cost hash comparison. For an unknown address the
+ server compares against a dummy hash.
+ - Success sets the cookie `cs_session`: `HttpOnly`, `SameSite=Lax`, `Path=/`.
+ `Secure` is set when `publicBaseUrl` is https.
+ - The session expires after `sessionDays`. It is extended (sliding) when
+ `last_seen_at` is older than one day.
+- `POST /api/auth/logout` (checked by `Origin` only, see *Security*) deletes the session row and clears the cookie.
+- `GET /api/auth/me` returns `{id, email, displayName, role, csrfToken}`, or 401.
+
+### Forgot and reset the password
+
+- `POST /api/auth/forgot {email}`. The response is always the same. A mail is sent
+ only if an `active` account exists.
+- `POST /api/auth/reset {token, password}` sets the password, consumes the token and
+ **ends all sessions** of that user. It also ends outstanding email-change links.
+
+### My account
+
+- `PATCH /api/account {displayName?}`.
+- `POST /api/account/password {currentPassword, newPassword}`. It ends all other
+ sessions and ends outstanding email-change and reset links.
+- `POST /api/account/email {newEmail, currentPassword}`. A confirmation link goes to
+ the new address, and the change applies only after the click. The request is
+ rate-limited per user and per new address. The old address is always told that a
+ change was requested, also when the new address is already taken (no enumeration).
+- `GET /api/account/sessions` lists the sessions as device and last seen.
+ `DELETE /api/account/sessions/{id}` revokes one.
+- `DELETE /api/account {currentPassword}` deletes the account, its sessions and
+ tokens. Mail-log rows keep only the hashed address. Config admins can delete their
+ account; activating again with the same address makes them admin again. The last
+ admin can't delete their own account (409); they promote someone else first.
+
+### Admin user management
+
+All of these require role `admin` and live under `/api/admin/users`.
+
+- `GET /api/admin/users?status=&role=&q=` returns users with their last mail status
+ (`last_event`, `last_reason`, `email_bouncing`).
+- `GET /api/admin/users/{id}` returns the user, their sessions (count, last seen),
+ their mail log with events, and the audit entries that concern them.
+- `POST …/{id}/disable` works only for active accounts (for a pending one, delete or
+ activate instead). It sets the status to `disabled`, ends all their sessions
+ immediately and ends all their outstanding links. `POST …/{id}/enable` reverses it.
+- `DELETE …/{id}` is a hard delete, the same as self-delete.
+- `POST …/{id}/role {role}`. These guards apply:
+ - A config admin (an account that is admin **and** listed in `adminEmails`) can't be
+ demoted, disabled or deleted. A pending account on a listed address can be deleted.
+ - A role change on a pending account answers 409.
+ - The last admin can't be demoted, disabled or deleted.
+ - An admin can't disable or delete themselves; they use "my account" for that.
+- `POST …/{id}/resend-activation` works only for pending users.
+- `POST …/{id}/activate` manually activates a pending user, for when mail keeps
+ failing (bounces, spam filters, Brevo outage).
+ - It sets the status to `active` and `activated_at`, applies the `adminEmails` role
+ rule exactly like link activation, and invalidates any outstanding activation
+ tokens.
+ - The address is then **not verified** by the user. The user row records
+ `activated_by` (the admin's id) and the audit log records
+ `user.activate.manual`, so this stays visible in the admin views.
+ - No session is created. The user logs in with the password they chose at
+ registration, or uses "forgot password" once mail works.
+- `POST …/{id}/mail/{mailId}/refresh` pulls the events for that message from the
+ provider API and writes the audit row `user.mail.refresh`.
+- Every action writes an `audit_log` row.
+
+### Mail delivery status (Brevo feedback)
+
+- On send, the provider's `messageId` is stored in `mail_log`.
+- **Webhook:** `POST /api/mail/brevo/webhook`. Brevo is configured to call it for
+ these transactional events: `delivered`, `opened`, `click`, `soft_bounce`,
+ `hard_bounce`, `invalid_email`, `deferred`, `spam`, `blocked`, `error`.
+ - Each event is appended to `mail_events` and updates `mail_log.last_event` and
+ `last_reason`.
+ - `hard_bounce` and `invalid_email` set `users.email_bouncing`. The account is not
+ disabled automatically; the admin decides.
+ - The endpoint is authenticated with `webhookSecret`. If it is unset, the endpoint
+ is not registered.
+ - Brevo's webhook object supports `"auth": {"type": "bearer", "token": …}`
+ (verified 2026-10-06), so the webhook is created with bearer auth. The server
+ checks `Authorization: Bearer ` in constant time. The secret
+ must be at least 16 characters.
+ - Unknown message IDs are ignored with 200, so Brevo doesn't retry forever. Bodies
+ are size-capped.
+- **Pull fallback:** for instances Brevo can't reach, the admin "refresh" action
+ calls Brevo's transactional events API for that `messageId`. The same ingestion
+ code updates `mail_events`.
+- **UI:** the admin table shows a status chip per user: sent, delivered, opened,
+ bounced, blocked or spam, with the reason in a tooltip. The detail view shows the
+ timeline. "Opened" carries a note: tracking pixels are auto-loaded by some clients
+ (Apple Mail Privacy Protection) and blocked by others, so the value is indicative
+ only.
+- Users never see the mail log. The webhook payload is not stored raw; only the
+ event, the time and the reason are kept.
+
+### Existing API-key endpoints
+
+`requireAPIKey` becomes `requireAdmin`, which accepts **either** a valid strong
+`X-API-Key` (exactly today's checks, `routes.go:420-437`) **or** a session with role
+`admin` that passes the CSRF check. When the feature is off, only the API-key path
+exists and the behavior is unchanged. In the UI, the customizer's geofilter tab hides
+its API-key field when an admin is logged in, and `perf.js` sends the CSRF token on
+`POST /api/perf/reset`. That fixes `perf.js:347`, which never sent credentials, for
+logged-in admins.
+
+## Security (cross-cutting)
+
+- **CSRF:** every state-changing request authenticated by cookie must carry:
+ - an `Origin` header (or, without one, a `Referer`) whose origin equals
+ `publicBaseUrl`'s origin, **and**
+ - a header `X-CS-CSRF` equal to the session's `csrf_token`.
+
+ The exception is logout: it is checked by `Origin` only, without `X-CS-CSRF`,
+ because a forced logout is low impact.
+
+ Requests authenticated by `X-API-Key` are exempt, because they carry no ambient
+ credential. CORS stays as it is: no credentialed CORS, and
+ `corsAllowedOrigins` doesn't widen auth.
+- **Rate limits:** in-memory token buckets keyed by client IP and by normalized
+ email.
+ - Login: 10 per 15 min.
+ - Register, forgot and resend: 5 per hour.
+ - Webhook: generous and per-IP only.
+
+ The client IP is taken from `X-Forwarded-For` only when the peer is in
+ `trustedProxies`. A limit returns 429 with `Retry-After`.
+
+ With `trustedProxies` empty, the rule of the `/ws` limiter applies
+ (`clientIP`, `cmd/server/ws_limits.go`). A loopback or private TCP peer means IPs
+ cannot be told apart, so per-IP limits are off. Per-address and per-account keys
+ still apply, and a startup warning says so. The webhook limiter is per-IP only, so
+ it is off too. The buckets are capped at 100000 keys. At the cap, new keys are
+ refused until buckets refill.
+- **Logs:** tokens, passwords, password hashes and cookies are never logged.
+ Addresses are logged only in audit and mail tables, not in the server log.
+ Mail-provider error texts are redacted (email addresses replaced) before logging.
+- **Headers:** auth responses send `Cache-Control: no-store`.
+- **Output:** all user-supplied strings (display name, email) are rendered through
+ the existing escape helpers (`test-xss-escape-sinks.js`, `test-preflight-xss-gate.js`).
+- **WebSocket:** unchanged and public. Sessions add nothing to `/ws` in A.
+
+## Frontend
+
+- **Feature detection:** `/api/config/client` gains `userManagement: {enabled: true}`
+ only when the feature is on. If the field is absent, the frontend adds no UI and
+ makes no extra requests. That makes "off" pixel-identical and keeps older servers
+ safe.
+- **Header control:** "Log in" at the right of the top nav. When logged in it shows
+ the display name with a menu: My account, Users (admins only), Log out. On phones
+ (<=768px) the top-bar control is hidden, so a conditional "Log in" / "My account"
+ entry is in the bottom-nav More sheet and the nav drawer. Icons are Phosphor, as elsewhere
+ (#1648).
+- **Routes:**
+ - `#/account/login`, `#/account/register`, `#/account/activate`, `#/account/forgot`,
+ `#/account/reset`, `#/account/confirm-email`
+ - `#/account`: profile, password, email, sessions, delete
+ - `#/admin/users`: a table ordered newest first by the server (no table-sort), with
+ status and role filters, search, a detail panel, and `confirm()` dialogs for
+ destructive actions. The filters and the open detail are deep-linked
+ (`#/admin/users?status=&role=&q=&id=`).
+- **State:** on load, if the feature is on, the frontend calls `GET /api/auth/me`
+ once. The result is kept in memory (`window.CS_USER`). A 401 from any authed call
+ clears it and shows a "you were logged out" toast. The rest of the page keeps
+ working anonymously.
+- **Language and mails:** the UI is English, like the rest of CoreScope. Mails are
+ English, sent as both HTML and text. The subject prefix and the sender
+ name come from `userManagement.mail.fromName` (default "CoreScope"), not the
+ branding config.
+- **Accessibility:** the new views must pass the existing axe and contrast tests and
+ the touch-target rules.
+
+## Error handling
+
+| Situation | Behavior |
+|---|---|
+| Brevo send fails at register | 503 "mail could not be sent, try again later". The user row is rolled back, so a retry works. The error is logged without the token. |
+| Brevo send fails at forgot, resend or email change | 503. The token is invalidated. |
+| `users.db` can't be opened or migrated | The server refuses to start, with the path and cause. Measurement data is untouched. |
+| Expired session, or the user was disabled or deleted | 401. The UI clears its user state and shows a toast. |
+| Expired or used token | 410 with a clear message and a "send a new link" action. |
+| Webhook with bad auth | 401. Nothing is written. |
+| Config admin removed from `adminEmails` | They keep the `admin` role they have, but lose the config protection. Startup logs this as a note. |
+
+## Testing
+
+- **`internal/users` (Go unit):**
+ - Argon2id hash round-trip and parameter parsing.
+ - The token lifecycle: expiry, single use, purpose mismatch.
+ - Status and role transitions, and the last-admin and config-admin guards.
+ - Migration from an empty file. Pruning.
+- **`internal/mailer`:** the Brevo client against an `httptest` server: the request
+ shape, the API-key header, error mapping, and events parsing. The fake mailer
+ records messages for the HTTP tests.
+- **HTTP (`cmd/server`, fake mailer):**
+ - The full register → mail → activate → login → me → logout flow.
+ - Identical enumeration responses for register and forgot.
+ - Reset ends sessions. Disable ends sessions.
+ - Manual activation: only for pending users, invalidates the activation token,
+ records `activated_by` and an audit row, and applies the `adminEmails` rule.
+ - Rate limits and 429, including `trustedProxies` handling.
+ - CSRF rejection: wrong origin, missing header, wrong token.
+ - The admin-only routes return 403 for `user` and 401 for anonymous requests.
+ - Gated endpoints accept the API key or an admin session.
+ - The webhook: auth, unknown IDs, and event ingestion setting `email_bouncing`.
+- **"Off is unchanged":**
+ - With the block absent or `enabled: false`: no new route is registered (the unit
+ test's API-only router answers 404; the real server falls through to the SPA
+ page) and no `users.db` file is created. `/api/config/client` has no
+ `userManagement` key, and
+ it is byte-identical between "block absent" and `enabled: false`.
+ - All existing tests pass unchanged.
+- **Read-only invariant:** `readonly_invariant_test.go` is extended. Write-capable
+ opens are allowed only inside `internal/users`, and only for the configured
+ `dbPath`. All existing prohibitions on the measurement DB stay.
+- **Playwright E2E** (server started with the feature on, the fake mailer, and a
+ test-only endpoint that returns the last mail's link, compiled in only under a
+ test build tag):
+ - Register, activate, log in and log out.
+ - An admin disables a user, and that user's next action shows "logged out".
+ - axe on the new views.
+ - With the feature off: no login control.
+
+## Documentation
+
+- `config.example.json`: the block plus `_comment`.
+- `docs/user-guide/accounts.md`:
+ - For operators: enabling the feature, a Brevo free account (sender domain
+ verification, API key, webhook setup), the first admin, and backing up
+ `users.db`.
+ - For users: registering, logging in, and deleting an account.
+- `docs/api-spec.md` and OpenAPI (`cmd/server/openapi.go`): the new endpoints, a
+ `cookieAuth` security scheme next to `apiKey`.
+- `AGENTS.md`: restate the invariant as "the server never writes measurement data;
+ `users.db` via `internal/users` is the single exception (user management, opt-in)".
+- `README.md` and `AGENTS.md` say "all API endpoints are public, no auth required".
+ Those lines describe the public live instance (analyzer.00id.net), which does not
+ enable the feature, so they stay unchanged.
+
+## Roadmap (after A)
+
+Each item gets its own spec. The order can change.
+
+- **B. Settings sync.** Profile-stored own nodes, favorites, the customizer delta and
+ selected filters and UI state. Merge rules for "local vs profile" on login. Explicit
+ exclusion of channel keys and decrypted-message caches (#725).
+- **C. Admin dashboard and statistics.** User stats, the audit-log viewer, and
+ admin-only views of perf, health and MQTT status. Server-enforced customizer tab
+ restrictions (#1508).
+- **D. Approval flows.** A generic proposal/review mechanism, with #2092 (suggested
+ hashtag channels) as its first consumer, attributing proposals to display names.
+- **E. Notifications.** Per-user subscriptions to node events (offline, battery,
+ foreign advert; #775, #663, #730), delivered via the mailer.
diff --git a/docs/user-guide/accounts.md b/docs/user-guide/accounts.md
new file mode 100644
index 00000000..95369c99
--- /dev/null
+++ b/docs/user-guide/accounts.md
@@ -0,0 +1,96 @@
+# Accounts (optional user management)
+
+CoreScope runs without accounts by default: every page is public and settings live in
+each visitor's browser. Operators can turn on **accounts**. The dashboard stays public;
+logging in only adds things.
+
+- Visitors can register with an email address, a display name and a password, and
+ activate the account through a link mailed to that address.
+- **Admins** manage users (activate, disable, delete, promote) and can use the operator
+ actions (geofilter save, prune, backup, perf reset) without the API key.
+
+With the feature off (the default) the account routes are not registered: requests to
+`/api/auth/*`, `/api/account/*` and `/api/admin/users/*` fall through to the normal page,
+and no login or account control appears in the interface.
+
+## For operators
+
+### 1. Get a Brevo account
+
+1. Create a free account at brevo.com. The free plan's daily volume is far more than
+ account mails need.
+2. Verify the sender address or domain you will send from (Brevo: *Senders, Domains &
+ Dedicated IPs*). Mail from an unverified sender is rejected or lands in spam.
+3. Create an API key (*SMTP & API, API Keys*).
+
+### 2. Configure
+
+Add to `config.json` (see `config.example.json` for every key):
+
+```json
+"userManagement": {
+ "enabled": true,
+ "adminEmails": ["you@example.org"],
+ "publicBaseUrl": "https://corescope.example.org",
+ "mail": { "fromEmail": "noreply@example.org", "fromName": "My CoreScope" }
+}
+```
+
+Provide the API key as `mail.brevoApiKey` or, better, the environment variable
+`CORESCOPE_BREVO_API_KEY`. Restart the server. It refuses to start if the mail setup is
+incomplete, and the log says what is missing.
+
+| Key | Meaning |
+|---|---|
+| `adminEmails` | Addresses that become admin on activation. They cannot be demoted, disabled or deleted from the UI. Remove an address here first. |
+| `publicBaseUrl` | The address visitors use. Every mail link is built from it, never from the request. It must match the browser origin, because state-changing requests from another origin are refused. |
+| `dbPath` | Where accounts are stored. Default: `users.db` next to the analyzer database. |
+| `sessionDays` | Login lifetime, extended while in use. Default 30, maximum 365. |
+| `trustedProxies` | CIDRs of your reverse proxy, so the per-IP login limits see real client IPs. Without it, behind a proxy every client shares the proxy's IP for the per-IP limits (they are switched off when that IP is loopback or private). Per-address and per-account limits apply either way. |
+| `mail.webhookSecret` | Enables delivery status (below). At least 16 characters. |
+
+### 3. The first admin
+
+Register at `#/account/register` with an address from `adminEmails`, click the activation
+link and enter the password you chose. You are now admin and can promote others in
+**Users**.
+
+### 4. Delivery status (optional)
+
+To see per user whether mails were delivered, bounced, blocked or marked as spam:
+
+1. Set `mail.webhookSecret` (16+ random characters), or `CORESCOPE_BREVO_WEBHOOK_SECRET`.
+ The webhook route exists only when a secret is set.
+2. In Brevo, create a **transactional** webhook to
+ `https:///api/mail/brevo/webhook` with these events: delivered,
+ opened, clicked, soft bounce, hard bounce, invalid email, deferred, spam, blocked,
+ error. Use **bearer** authentication with the same secret.
+
+If Brevo cannot reach your instance, use **Refresh status** in a user's details instead:
+CoreScope then asks Brevo directly. "Opened" is indicative only. Some mail apps load
+tracking pixels automatically, and others block them.
+
+### When mail fails
+
+- In **Users**, *Resend mail* sends a fresh activation link to a pending account.
+- *Activate* activates a pending account by hand. The address is then unverified; the
+ row shows "manual" and the audit log records who did it.
+
+### Backups
+
+`users.db` holds password hashes and addresses. Back it up together with the analyzer
+database, and protect it the same way. Deleting it removes all accounts and nothing else.
+
+## For users
+
+- **Register:** *Log in, Create an account*, then click the link in the mail within 48
+ hours and enter your password to finish. Registering a pending (not yet activated)
+ address again replaces the password and sends a new link. Registering an address that
+ is already activated changes nothing; its owner gets a notice mail instead.
+- **Forgot password:** *Log in, Forgot password?* The link works once, for one hour, and
+ logs out all your devices.
+- **My account:** change your display name, password or address, see your logged-in
+ devices, or delete your account. A new address is confirmed from a link sent to it, and
+ your old address gets a notice. Changing your password logs out your other devices.
+
+Admins can see your display name and email address in the Users list.
diff --git a/internal/mailer/brevo.go b/internal/mailer/brevo.go
new file mode 100644
index 00000000..3cf82c4b
--- /dev/null
+++ b/internal/mailer/brevo.go
@@ -0,0 +1,196 @@
+package mailer
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const brevoBaseURL = "https://api.brevo.com/v3"
+
+// Brevo sends through Brevo's transactional API.
+type Brevo struct {
+ APIKey string
+ FromEmail string
+ FromName string
+ BaseURL string // overridable for tests
+ HTTP *http.Client
+}
+
+func NewBrevo(apiKey, fromEmail, fromName string) *Brevo {
+ return &Brevo{APIKey: apiKey, FromEmail: fromEmail, FromName: fromName,
+ BaseURL: brevoBaseURL, HTTP: newBrevoHTTPClient()}
+}
+
+// newBrevoHTTPClient never follows redirects, so the api-key header cannot be
+// forwarded to another host.
+func newBrevoHTTPClient() *http.Client {
+ return &http.Client{
+ Timeout: 10 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+}
+
+type brevoAddress struct {
+ Email string `json:"email"`
+ Name string `json:"name,omitempty"`
+}
+
+type brevoSendRequest struct {
+ Sender brevoAddress `json:"sender"`
+ To []brevoAddress `json:"to"`
+ Subject string `json:"subject"`
+ HTMLContent string `json:"htmlContent,omitempty"`
+ TextContent string `json:"textContent,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+}
+
+type brevoError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func (b *Brevo) Send(ctx context.Context, m Message) (string, error) {
+ if m.HTML == "" && m.Text == "" {
+ return "", errors.New("brevo: send: message has no content")
+ }
+ req := brevoSendRequest{
+ Sender: brevoAddress{Email: b.FromEmail, Name: b.FromName},
+ To: []brevoAddress{{Email: m.To, Name: m.ToName}},
+ Subject: m.Subject,
+ HTMLContent: m.HTML,
+ TextContent: m.Text,
+ }
+ if m.Tag != "" {
+ req.Tags = []string{m.Tag}
+ }
+ body, err := json.Marshal(req)
+ if err != nil {
+ return "", err
+ }
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, b.BaseURL+"/smtp/email", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ data, status, err := b.do(httpReq)
+ if err != nil {
+ return "", fmt.Errorf("brevo: send: %w", err)
+ }
+ if status != http.StatusCreated && status != http.StatusAccepted {
+ return "", brevoErr("send", status, data)
+ }
+ var out struct {
+ MessageID string `json:"messageId"`
+ }
+ if err := json.Unmarshal(data, &out); err != nil || out.MessageID == "" {
+ return "", errors.New("brevo: send: response has no messageId")
+ }
+ return out.MessageID, nil
+}
+
+type brevoEventsResponse struct {
+ Events []struct {
+ Date string `json:"date"`
+ Email string `json:"email"`
+ Event string `json:"event"`
+ MessageID string `json:"messageId"`
+ Reason string `json:"reason"`
+ } `json:"events"`
+}
+
+func (b *Brevo) Events(ctx context.Context, messageID string) ([]Event, error) {
+ q := url.Values{"messageId": {messageID}, "limit": {"100"}, "sort": {"asc"}}
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, b.BaseURL+"/smtp/statistics/events?"+q.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+ data, status, err := b.do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("brevo: events: %w", err)
+ }
+ if status != http.StatusOK {
+ return nil, brevoErr("events", status, data)
+ }
+ var resp brevoEventsResponse
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, fmt.Errorf("brevo: events: %w", err)
+ }
+ out := make([]Event, 0, len(resp.Events))
+ for _, e := range resp.Events {
+ at, err := parseBrevoDate(e.Date)
+ if err != nil {
+ at = time.Now().UTC()
+ }
+ out = append(out, Event{MessageID: e.MessageID, Email: e.Email, Event: NormalizeBrevoEvent(e.Event), Reason: e.Reason, At: at})
+ }
+ return out, nil
+}
+
+func (b *Brevo) do(r *http.Request) ([]byte, int, error) {
+ r.Header.Set("api-key", b.APIKey)
+ r.Header.Set("Accept", "application/json")
+ client := b.HTTP
+ if client == nil {
+ client = newBrevoHTTPClient()
+ }
+ resp, err := client.Do(r)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 256<<10))
+ return data, resp.StatusCode, err
+}
+
+func brevoErr(op string, status int, body []byte) error {
+ var e brevoError
+ if json.Unmarshal(body, &e) == nil && e.Message != "" {
+ return fmt.Errorf("brevo: %s: HTTP %d %s: %s", op, status, e.Code, e.Message)
+ }
+ return fmt.Errorf("brevo: %s: HTTP %d", op, status)
+}
+
+func parseBrevoDate(s string) (time.Time, error) {
+ s = strings.TrimSpace(s)
+ for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05"} {
+ if t, err := time.Parse(layout, s); err == nil {
+ return t.UTC(), nil
+ }
+ }
+ return time.Time{}, fmt.Errorf("brevo: unrecognized date %q", s)
+}
+
+// brevoEventNames maps webhook and events-API spellings to canonical names.
+// "bounces" (events API, unspecified kind) is treated as soft so it never
+// flags an address on its own.
+var brevoEventNames = map[string]string{
+ "request": EventSent, "requests": EventSent,
+ "delivered": EventDelivered,
+ "opened": EventOpened, "unique_opened": EventOpened, "proxy_open": EventOpened,
+ "unique_proxy_open": EventOpened, "loadedbyproxy": EventOpened,
+ "click": EventClicked, "clicks": EventClicked,
+ "soft_bounce": EventSoftBounce, "softbounces": EventSoftBounce, "bounces": EventSoftBounce,
+ "hard_bounce": EventHardBounce, "hardbounces": EventHardBounce,
+ "invalid_email": EventInvalidEmail, "invalid": EventInvalidEmail,
+ "deferred": EventDeferred, "spam": EventSpam, "blocked": EventBlocked,
+ "error": EventError, "unsubscribed": EventUnsubscribed,
+}
+
+// NormalizeBrevoEvent maps a Brevo event name to CoreScope's canonical name.
+func NormalizeBrevoEvent(name string) string {
+ key := strings.ToLower(strings.TrimSpace(name))
+ if v, ok := brevoEventNames[key]; ok {
+ return v
+ }
+ return key
+}
diff --git a/internal/mailer/brevo_test.go b/internal/mailer/brevo_test.go
new file mode 100644
index 00000000..4ec8a8fa
--- /dev/null
+++ b/internal/mailer/brevo_test.go
@@ -0,0 +1,136 @@
+package mailer
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestBrevoSendRequestShape(t *testing.T) {
+ var gotPath, gotKey string
+ var body map[string]any
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotKey = r.URL.Path, r.Header.Get("api-key")
+ b, _ := io.ReadAll(r.Body)
+ json.Unmarshal(b, &body)
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte(`{"messageId":"<201798300811.5787683@relay.domain.com>"}`))
+ }))
+ defer srv.Close()
+ b := NewBrevo("xkeysib-test", "noreply@example.org", "CoreScope")
+ b.BaseURL = srv.URL
+
+ id, err := b.Send(context.Background(), Message{To: "a@example.org", ToName: "Alice", Subject: "S",
+ HTML: "
';
+ }
+ function fmtDate(iso) { try { return new Date(iso).toLocaleString(); } catch (_) { return iso; } }
+
+ // An expired or used link answers 410: offer the way to get a new one.
+ function renewLinkHtml(href, label) {
+ return '
');
+ say('This link is incomplete. Open the link from the mail again.', false);
+ return;
+ }
+ app.innerHTML = shell('Activate your account',
+ '' +
+ '
'); return; }
+ views[viewName(routeParam)](app);
+ });
+ }
+
+ // Logout (here, in the header or by a 401) leaves the profile for the
+ // login view; another user logging in re-renders it. A display-name save
+ // is the same user and keeps the form and its message.
+ window.addEventListener('cs-auth-changed', function (e) {
+ if (currentView() !== 'profile' || !shown.app) return;
+ var u = e.detail;
+ if (!u) { location.hash = '#/account/login'; return; }
+ if (u.id !== shown.userId) views.profile(shown.app);
+ });
+
+ registerPage('account', { init: init, destroy: function () {} });
+ window.CSAccount = { _test: { profileHtml: profileHtml, sessionsHtml: sessionsHtml, renewLinkHtml: renewLinkHtml, views: views, checkMailHtml: checkMailHtml } };
+})();
diff --git a/public/admin-users.js b/public/admin-users.js
new file mode 100644
index 00000000..eb4a69ef
--- /dev/null
+++ b/public/admin-users.js
@@ -0,0 +1,255 @@
+/* #/admin/users: admin user management (optional user management).
+ * Table with filters, per-row actions, and a detail panel with the mail
+ * delivery timeline and audit log. Every dynamic value goes through escapeHtml.
+ * Deep link: #/admin/users?status=&role=&q=&id= (read in init, written back
+ * with replaceState so filter changes add no history entries). */
+(function () {
+ 'use strict';
+ var filters = { status: '', role: '', q: '' };
+ var openId = null;
+ var loadSeq = 0;
+ // The page on screen and whether it was rendered for an admin, so an auth
+ // change can redirect or re-render (null app: page left).
+ var mounted = { app: null, admin: false };
+ var STATUSES = ['pending', 'active', 'disabled'];
+ var ROLES = ['user', 'admin'];
+
+ function errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); }
+ function fmt(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleString(); } catch (_) { return iso; } }
+ function say(text, ok) {
+ var el = document.getElementById('umMsg');
+ if (!el) return;
+ el.textContent = text;
+ el.classList.toggle('ok', !!ok);
+ el.classList.toggle('err', !ok);
+ }
+ function netErr() { say('Network error, try again.', false); }
+ function chip(event, reason) {
+ var e = String(event || 'sent');
+ return '' + escapeHtml(e.replace(/_/g, ' ')) + '';
+ }
+
+ // Only known filter values and a numeric id are taken from the address bar.
+ function readHash(hash) {
+ var p = new URLSearchParams(String(hash || '').split('?')[1] || '');
+ var pick = function (v, allowed) { return allowed.indexOf(v) !== -1 ? v : ''; };
+ var id = p.get('id') || '';
+ return { status: pick(p.get('status'), STATUSES), role: pick(p.get('role'), ROLES), q: p.get('q') || '', id: /^\d+$/.test(id) ? id : '' };
+ }
+ function hashFor(f, id) {
+ var p = new URLSearchParams();
+ if (f.status) p.set('status', f.status);
+ if (f.role) p.set('role', f.role);
+ if (f.q) p.set('q', f.q);
+ if (id) p.set('id', id);
+ var s = p.toString();
+ return '#/admin/users' + (s ? '?' + s : '');
+ }
+ function syncHash() {
+ var h = hashFor(filters, openId);
+ if (h !== location.hash) history.replaceState(null, '', h);
+ }
+
+ // Mirrors the server guards (admin_users_handlers.go): disable only active,
+ // enable only disabled, role change not for pending, no self/config-admin removal.
+ function actionsFor(u, me) {
+ me = me || {};
+ var b = function (act, label) {
+ return '';
+ };
+ var self = u.id === me.id;
+ var out = [b('detail', 'Details')];
+ if (u.status === 'pending') out.push(b('activate', 'Activate'), b('resend', 'Resend mail'));
+ if (u.status === 'active' && !self && !u.configAdmin) out.push(b('disable', 'Disable'));
+ if (u.status === 'disabled') out.push(b('enable', 'Enable'));
+ if (u.status !== 'pending' && !u.configAdmin) out.push(u.role === 'admin' ? b('demote', 'Make user') : b('promote', 'Make admin'));
+ if (!self && !u.configAdmin) out.push(b('delete', 'Delete'));
+ return '
' + out.join('') + '
';
+ }
+
+ function rowHtml(u, me) {
+ var mail = u.lastMail ? chip(u.lastMail.lastEvent, u.lastMail.lastReason) : '—';
+ if (u.emailBouncing) mail += ' bouncing';
+ return '
' + escapeHtml(String((d.sessions || []).length)) + ' active session(s). "Opened" depends on tracking pixels: some mail apps load them automatically, others block them.
';
+ });
+ return html + '';
+ }
+
+ function load() {
+ var q = new URLSearchParams();
+ if (filters.status) q.set('status', filters.status);
+ if (filters.role) q.set('role', filters.role);
+ if (filters.q) q.set('q', filters.q);
+ var seq = ++loadSeq;
+ return CSAuth.request('GET', '/api/admin/users?' + q.toString()).then(function (r) {
+ var body = document.getElementById('umBody');
+ if (!body || seq !== loadSeq) return;
+ if (!r.ok) { say(errText(r), false); return; }
+ var me = CSAuth.user();
+ var html = '';
+ // The server caps the list at 1000 rows, so a full tbody rebuild is bounded.
+ r.data.forEach(function (u) { html += rowHtml(u, me); });
+ body.innerHTML = html || '
No users match.
';
+ if (openId) showDetail(openId);
+ }).catch(netErr);
+ }
+
+ function closeDetail() {
+ openId = null;
+ var el = document.getElementById('umDetail');
+ if (el) el.hidden = true;
+ syncHash();
+ }
+
+ // focus: opened by the admin (not a deep link or a list reload), so move
+ // keyboard focus to the panel heading.
+ function showDetail(id, focus) {
+ openId = id;
+ syncHash();
+ return CSAuth.request('GET', '/api/admin/users/' + encodeURIComponent(id)).then(function (r) {
+ var el = document.getElementById('umDetail');
+ if (!el || openId !== id) return;
+ if (!r.ok) { say(errText(r), false); closeDetail(); return; }
+ el.innerHTML = detailHtml(r.data);
+ el.hidden = false;
+ if (focus) {
+ var h = el.querySelector('h3');
+ if (h) h.focus();
+ }
+ }).catch(netErr);
+ }
+
+ var confirmText = {
+ activate: 'Activate this account without mail confirmation? The address will stay unverified.',
+ disable: 'Disable this account? The user is logged out everywhere.',
+ delete: 'Delete this account permanently?',
+ demote: 'Remove admin rights from this user?'
+ };
+ var endpoints = {
+ activate: ['POST', '/activate'], resend: ['POST', '/resend-activation'], disable: ['POST', '/disable'],
+ enable: ['POST', '/enable'], delete: ['DELETE', ''], promote: ['POST', '/role', { role: 'admin' }],
+ demote: ['POST', '/role', { role: 'user' }]
+ };
+
+ function onAction(e) {
+ var btn = e.target.closest('button[data-act]');
+ if (!btn) return;
+ var act = btn.getAttribute('data-act');
+ var id = btn.getAttribute('data-id');
+ if (act === 'detail') { showDetail(id, true); return; }
+ if (act === 'close') {
+ var was = openId;
+ closeDetail();
+ // ids are numeric (server ids, readHash), safe in the selector.
+ var opener = was && document.querySelector('button[data-act="detail"][data-id="' + was + '"]');
+ if (opener) opener.focus();
+ return;
+ }
+ if (act === 'refresh') {
+ CSAuth.request('POST', '/api/admin/users/' + encodeURIComponent(id) + '/mail/' + encodeURIComponent(btn.getAttribute('data-mail')) + '/refresh')
+ .then(function (r) { say(r.ok ? 'Mail status refreshed.' : errText(r), r.ok); load(); }).catch(netErr);
+ return;
+ }
+ if (confirmText[act] && !confirm(confirmText[act])) return;
+ var ep = endpoints[act];
+ CSAuth.request(ep[0], '/api/admin/users/' + encodeURIComponent(id) + ep[1], ep[2]).then(function (r) {
+ say(r.ok ? 'Done.' : errText(r), r.ok);
+ if (act === 'delete' && r.ok && openId === id) closeDetail();
+ var me = CSAuth.user();
+ if (r.ok && (act === 'promote' || act === 'demote') && me && String(me.id) === id) {
+ // Own role changed: refresh so admin-only UI disappears.
+ return CSAuth.refreshMe().then(function () {
+ if (!CSAuth.isAdmin()) location.hash = '#/account';
+ else load();
+ });
+ }
+ return load();
+ }).catch(netErr);
+ }
+
+ function init(app, routeParam) {
+ app.innerHTML = '