eleven / messenger Public

server: /api/me reports sole_admin to admins + answers CORS for the home origin (#458)

An admin's /api/me now includes sole_admin (true when they hold the only
admin role on the instance), and the endpoint grants CORS to exactly the
configured home origin — so the home page can ask "is this the last admin
key?" with a saved session token before letting its holder delete the
recovery copy. Members never see the admin headcount.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b73eaef967d9c48d920ecb013e97597f500c63d3 parent 47d61db
3 files changed, +89 −5
  • db.go +9 −0
  • main.go +20 −5
  • roles_test.go +60 −0
diff --git a/db.go b/db.go
index 79eb4d2..da6b6ac 100644
--- a/db.go
+++ b/db.go
@@ -1043,6 +1043,15 @@ func (db *DB) userRole(id string) string {
return r
}
+// adminCount reports how many members hold the admin role. Removed users are
+// hard-deleted rows, so this is the live count — 1 means the caller (if admin)
+// holds the only admin key on the instance.
+func (db *DB) adminCount() int {
+ var n int
+ db.sql.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin'").Scan(&n)
+ return n
+}
+
func (db *DB) setUserRole(id, role string) error {
_, err := db.sql.Exec("UPDATE users SET role = ? WHERE id = ?", role, id)
return err
diff --git a/main.go b/main.go
index 2993e11..3654cc5 100644
--- a/main.go
+++ b/main.go
@@ -892,10 +892,20 @@ func (s *Server) getRoster(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"roster": entries})
}
-// getMe reports facts about the caller's own account — currently just how many
-// devices they have signed in, so the client can warn before signing out the last
-// one (which loses their keys, and the chats encrypted to them, for good).
+// getMe reports facts about the caller's own account — how many devices they
+// have signed in (so the client can warn before signing out the last one, which
+// loses their keys, and the chats encrypted to them, for good), and for admins
+// whether they are the SOLE admin (so the home server's client can refuse to
+// drop the last recovery copy of the only admin key without a teardown).
+//
+// The home page runs on a different origin, so this one endpoint answers CORS
+// for exactly that origin — the response reveals nothing without a valid device
+// token, and only the caller's own facts with one.
func (s *Server) getMe(w http.ResponseWriter, r *http.Request) {
+ if o := r.Header.Get("Origin"); o != "" && o == s.homeOrigin {
+ w.Header().Set("Access-Control-Allow-Origin", o)
+ w.Header().Set("Vary", "Origin")
+ }
token := r.URL.Query().Get("token")
if !s.tokenOK(token) {
httpError(w, http.StatusForbidden, "not a member")
@@ -907,7 +917,7 @@ func (s *Server) getMe(w http.ResponseWriter, r *http.Request) {
return
}
pinned, _ := s.db.userPresenceInfo(uid)
- writeJSON(w, http.StatusOK, map[string]any{
+ me := map[string]any{
"user_id": uid,
"device_count": s.db.deviceCount(uid),
"role": s.db.userRole(uid),
@@ -917,7 +927,12 @@ func (s *Server) getMe(w http.ResponseWriter, r *http.Request) {
// setup_complete gates the first-run setup conversation. It's instance
// state (not a secret), but only an admin's client acts on it.
"setup_complete": s.db.setupComplete(),
- })
+ }
+ // Only admins learn the admin headcount fact — members have no use for it.
+ if me["role"] == "admin" {
+ me["sole_admin"] = s.db.adminCount() == 1
+ }
+ writeJSON(w, http.StatusOK, me)
}
// signout revokes the caller's own device token (sign out on this device) and drops
diff --git a/roles_test.go b/roles_test.go
index 40675ed..e3ae473 100644
--- a/roles_test.go
+++ b/roles_test.go
@@ -263,3 +263,63 @@ func TestAvatarAltBlob(t *testing.T) {
}
}
}
+
+// /api/me tells an admin whether they hold the ONLY admin key (sole_admin), so
+// the home server's client can refuse to delete the last recovery copy without
+// a teardown — and answers CORS for exactly the home origin, which is the only
+// cross-origin caller that legitimately holds a device token.
+func TestMeSoleAdmin(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ adminUID, _ := srv.db.createUser("")
+ srv.db.createKeyInvite("adm", adminUID, "", testClaimHash, 0)
+ srv.db.setInviteGrantsAdmin("adm")
+ adminTok, _, _ := srv.db.claimKeyInvite("adm", "", "", testClaimSecret)
+
+ memberUID, _ := srv.db.createUser("")
+ srv.db.createKeyInvite("mem", memberUID, "", testClaimHash, 0)
+ memberTok, _, _ := srv.db.claimKeyInvite("mem", "", "", testClaimSecret)
+
+ me := func(tok, origin string) (map[string]any, *http.Response) {
+ req, _ := http.NewRequest("GET", ts.URL+"/api/me?token="+tok, nil)
+ if origin != "" {
+ req.Header.Set("Origin", origin)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request: %v", err)
+ }
+ var m map[string]any
+ json.NewDecoder(resp.Body).Decode(&m)
+ resp.Body.Close()
+ return m, resp
+ }
+
+ // One admin on the instance → sole_admin true, and the home origin gets CORS.
+ m, resp := me(adminTok, defaultHomeOrigin)
+ if m["role"] != "admin" || m["sole_admin"] != true {
+ t.Fatalf("single admin: want role=admin sole_admin=true, got %+v", m)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Origin"); got != defaultHomeOrigin {
+ t.Fatalf("home-origin CORS: want %q, got %q", defaultHomeOrigin, got)
+ }
+
+ // Any other origin gets no CORS grant.
+ _, resp = me(adminTok, "https://evil.example")
+ if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
+ t.Fatalf("foreign origin must get no CORS grant, got %q", got)
+ }
+
+ // Members never see the admin-headcount fact.
+ m, _ = me(memberTok, "")
+ if _, present := m["sole_admin"]; present || m["role"] != "member" {
+ t.Fatalf("member: want role=member and no sole_admin, got %+v", m)
+ }
+
+ // A second admin appears → no longer sole.
+ srv.db.setUserRole(memberUID, "admin")
+ m, _ = me(adminTok, "")
+ if m["sole_admin"] != false {
+ t.Fatalf("two admins: want sole_admin=false, got %+v", m)
+ }
+}