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>
3 files changed,
+89
−5
db.go+9 −0main.go+20 −5roles_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) |
| + } |
| +} |