Просмотр исходного кода

Add kick/ban API

 - PATCH the /user/{screenname}/account endpoint to ban or unban (suspend) the user
 - DELETE the /session/{screenname} endpoint to kick (disconnect) the user
 - Find the user's remote IP address in the /session endpoint, helpful for firewall blocking
Josh Knight 1 год назад
Родитель
Сommit
f348bf7aed

+ 81 - 14
api.yml

@@ -27,6 +27,9 @@ paths:
                     is_icq:
                       type: boolean
                       description: If true, indicates an ICQ user instead of an AIM user.
+                    suspended_status:
+                      type: string
+                      description: User's suspended status
     post:
       summary: Create a new user
       description: Create a new AIM or ICQ user account.
@@ -77,11 +80,12 @@ paths:
       summary: Get account details for a specific screen name.
       description: Retrieve account details for a specific screen name.
       parameters:
-        - name: screenname
-          in: path
+        - in: path
+          name: screenname
+          schema:
+            type: string
           description: User's AIM screen name or ICQ UIN.
           required: true
-          type: string
       responses:
         '200':
           description: Successful response containing account details
@@ -103,24 +107,59 @@ paths:
                     type: string
                     description: User's email address
                   confirmed:
-                    type: bool
+                    type: boolean
                     description: User's account confirmation status
                   is_icq:
                     type: boolean
                     description: If true, indicates an ICQ user instead of an AIM user.
+                  suspended_status:
+                    type: string
+                    description: User's suspended status
         '404':
           description: User not found.
+    patch:
+      summary: Update a user account
+      description: Update attributes for a user account
+      parameters:
+        - in: path
+          name: screenname
+          schema:
+            type: string
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+      responses:
+        '204':
+          description: Successfully updated user account
+        '304':
+          description: Did not modify user account
+        '400':
+          description: Bad request when modifying user account
+        '404':
+          description: User not found
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                suspended_status:
+                    type: string
+                    nullable: true
+                    enum: [deleted, expired, suspended, suspended_age]
+                    description: The suspended status of the account
 
   /user/{screenname}/icon:
     get:
       summary: Get AIM buddy icon for a screen name
       description: Retrieve account buddy icon for a specific screen name.
       parameters:
-        - name: screenname
-          in: path
+        - in: path
+          name: screenname
+          schema:
+            type: string
           description: User's AIM screen name or ICQ UIN.
           required: true
-          type: string
       responses:
         '200':
           description: Successful response containing buddy icon bytes
@@ -171,28 +210,35 @@ paths:
                           type: string
                           description: User's AIM screen name or ICQ UIN.
                         online_seconds:
-                          type: float
+                          type: number
                           description: Number of seconds this user session has been online.
                         away_message:
                           type: string
                           description: User's AIM away message HTML. Empty if the user is not away.
                         idle_seconds:
-                          type: float
+                          type: number
                           description: Number of seconds this user session has been idle. 0 if not idle.
                         is_icq:
                           type: boolean
                           description: If true, indicates an ICQ user instead of an AIM user.
+                        remote_addr:
+                          type: string
+                          description: Remote IP address of the user's connection to BOS or TOC
+                        remote_port:
+                          type: integer
+                          description: Remote port number of the user's connection to BOS or TOC
 
   /session/{screenname}:
     get:
       summary: Get active sessions for a given screen name or UIN.
       description: Retrieve a list of active sessions of a specific logged in user.
       parameters:
-        - name: screenname
-          in: path
+        - in: path
+          name: screenname
+          schema:
+            type: string
           description: User's AIM screen name or ICQ UIN.
           required: true
-          type: string
       responses:
         '200':
           description: Successful response containing a list of active sessions for the given screen name
@@ -216,19 +262,40 @@ paths:
                           type: string
                           description: User's AIM screen name or ICQ UIN.
                         online_seconds:
-                          type: float
+                          type: number
                           description: Number of seconds this user session has been online.
                         away_message:
                           type: string
                           description: User's AIM away message HTML. Empty if the user is not away.
                         idle_seconds:
-                          type: float
+                          type: number
                           description: Number of seconds this user session has been idle. 0 if not idle.
                         is_icq:
                           type: boolean
                           description: If true, indicates an ICQ user instead of an AIM user.
+                        remote_addr:
+                          type: string
+                          description: Remote IP address of the user's connection to BOS or TOC
+                        remote_port:
+                          type: integer
+                          description: Remote port number of the user's connection to BOS or TOC
         '404':
           description: User not found.
+    delete:
+      summary: Delete active sessions for a given screen name or UIN.
+      description: Disconnect any active sessions of a specific logged in user.
+      parameters:
+        - in: path
+          name: screenname
+          schema:
+            type: string
+          description: User's AIM screen name or ICQ UIN.
+          required: true
+      responses:
+        '204':
+          description: Session deleted successfully
+        '404':
+          description: Session not found
 
   /user/password:
     put:

+ 5 - 4
cmd/server/factory.go

@@ -270,10 +270,11 @@ func BOS(deps Container) oscar.BOSServer {
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 
 	return oscar.BOSServer{
-		AuthService:       authService,
-		BuddyListRegistry: deps.sqLiteUserStore,
-		Config:            deps.cfg,
-		DepartureNotifier: buddyService,
+		AuthService:        authService,
+		BuddyListRegistry:  deps.sqLiteUserStore,
+		Config:             deps.cfg,
+		DepartureNotifier:  buddyService,
+		ChatSessionManager: deps.chatSessionManager,
 		Handler: handler.NewBOSRouter(handler.Handlers{
 			AlertHandler:      handler.NewAlertHandler(logger),
 			BARTHandler:       handler.NewBARTHandler(logger, bartService),

+ 6 - 1
foodgroup/auth.go

@@ -112,7 +112,7 @@ func (s AuthService) RegisterBOSSession(ctx context.Context, authCookie []byte)
 		return nil, fmt.Errorf("AddSession: %w", err)
 	}
 
-	// Set the unconfirmed user info flag if this account is unconfirmed
+	// set the unconfirmed user info flag if this account is unconfirmed
 	if confirmed, err := s.accountManager.ConfirmStatusByName(sess.IdentScreenName()); err != nil {
 		return nil, fmt.Errorf("error setting unconfirmed user flag: %w", err)
 	} else if !confirmed {
@@ -356,6 +356,11 @@ func (s AuthService) login(
 		return loginFailureResponse(props, loginErr), nil
 	}
 
+	// check if suspended status should prevent login
+	if user.SuspendedStatus > 0x0 {
+		return loginFailureResponse(props, user.SuspendedStatus), nil
+	}
+
 	if s.config.DisableAuth {
 		// user exists, but don't validate
 		return s.loginSuccessResponse(props)

+ 41 - 0
foodgroup/auth_test.go

@@ -231,6 +231,47 @@ func TestAuthService_BUCPLoginRequest(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "AIM account is suspended",
+			cfg: config.Config{
+				OSCARHost: "127.0.0.1",
+				BOSPort:   "1234",
+			},
+			inputSNAC: wire.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LoginTLVTagsPasswordHash, []byte("password")),
+						wire.NewTLVBE(wire.LoginTLVTagsScreenName, []byte("suspended_screen_name")),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("suspended_screen_name"),
+							result: &state.User{
+								SuspendedStatus: wire.LoginErrSuspendedAccount,
+							},
+						},
+					},
+				},
+			},
+			expectOutput: wire.SNACMessage{
+				Frame: wire.SNACFrame{
+					FoodGroup: wire.BUCP,
+					SubGroup:  wire.BUCPLoginResponse,
+				},
+				Body: wire.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: wire.TLVRestBlock{
+						TLVList: []wire.TLV{
+							wire.NewTLVBE(wire.LoginTLVTagsScreenName, state.NewIdentScreenName("suspended_screen_name")),
+							wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, wire.LoginErrSuspendedAccount),
+						},
+					},
+				},
+			},
+		},
 		{
 			name: "ICQ account doesn't exist, login fails",
 			cfg: config.Config{

+ 145 - 13
server/http/mgmt_api.go

@@ -33,7 +33,7 @@ func NewManagementAPI(
 	messageRelayer MessageRelayer,
 	bartRetriever BARTRetriever,
 	feedbagRetriever FeedBagRetriever,
-	accountRetriever AccountRetriever,
+	accountManager AccountManager,
 	profileRetriever ProfileRetriever,
 	logger *slog.Logger,
 ) *Server {
@@ -62,7 +62,10 @@ func NewManagementAPI(
 
 	// Handlers for '/user/{screenname}/account' route
 	mux.HandleFunc("GET /user/{screenname}/account", func(w http.ResponseWriter, r *http.Request) {
-		getUserAccountHandler(w, r, userManager, accountRetriever, profileRetriever, logger)
+		getUserAccountHandler(w, r, userManager, accountManager, profileRetriever, logger)
+	})
+	mux.HandleFunc("PATCH /user/{screenname}/account", func(w http.ResponseWriter, r *http.Request) {
+		patchUserAccountHandler(w, r, userManager, accountManager, logger)
 	})
 
 	// Handlers for '/user/{screenname}/icon' route
@@ -79,6 +82,9 @@ func NewManagementAPI(
 	mux.HandleFunc("GET /session/{screenname}", func(w http.ResponseWriter, r *http.Request) {
 		getSessionHandler(w, r, sessionRetriever, time.Since)
 	})
+	mux.HandleFunc("DELETE /session/{screenname}", func(w http.ResponseWriter, r *http.Request) {
+		deleteSessionHandler(w, r, sessionRetriever)
+	})
 
 	// Handlers for '/chat/room/public' route
 	mux.HandleFunc("GET /chat/room/public", func(w http.ResponseWriter, r *http.Request) {
@@ -261,6 +267,12 @@ func getSessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever
 			IdleSeconds:   idleSeconds,
 			IsICQ:         s.UIN() > 0,
 		}
+		ra := s.RemoteAddr()
+		if ra != nil {
+			ou.Sessions[i].RemoteAddr = ra.Addr().String()
+			ou.Sessions[i].RemotePort = ra.Port()
+		}
+
 	}
 
 	if err := json.NewEncoder(w).Encode(ou); err != nil {
@@ -269,6 +281,21 @@ func getSessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever
 	}
 }
 
+// deleteSessionHandler handles DELETE /session/{screenname}
+func deleteSessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever SessionRetriever) {
+	w.Header().Set("Content-Type", "application/json")
+
+	if screenName := r.PathValue("screenname"); screenName != "" {
+		session := sessionRetriever.RetrieveSession(state.NewIdentScreenName(screenName))
+		if session == nil {
+			errorMsg(w, "session not found", http.StatusNotFound)
+			return
+		}
+		session.Close()
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
 // getUserHandler handles the GET /user endpoint.
 func getUserHandler(w http.ResponseWriter, userManager UserManager, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")
@@ -282,10 +309,17 @@ func getUserHandler(w http.ResponseWriter, userManager UserManager, logger *slog
 
 	out := make([]userHandle, len(users))
 	for i, u := range users {
+		suspendedStatus, err := getSuspendedStatusErrCodeToText(u.SuspendedStatus)
+		if err != nil {
+			logger.Error("error getting suspended status in GET /user", "err", err.Error())
+			http.Error(w, "internal server error", http.StatusInternalServerError)
+			return
+		}
 		out[i] = userHandle{
-			ID:         u.IdentScreenName.String(),
-			ScreenName: u.DisplayScreenName.String(),
-			IsICQ:      u.IsICQ,
+			ID:              u.IdentScreenName.String(),
+			ScreenName:      u.DisplayScreenName.String(),
+			IsICQ:           u.IsICQ,
+			SuspendedStatus: suspendedStatus,
 		}
 	}
 
@@ -595,7 +629,7 @@ func getUserBuddyIconHandler(w http.ResponseWriter, r *http.Request, u UserManag
 }
 
 // getUserAccountHandler handles the GET /user/{screenname}/account endpoint.
-func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, a AccountRetriever, p ProfileRetriever, logger *slog.Logger) {
+func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, a AccountManager, p ProfileRetriever, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")
 
 	screenName := r.PathValue("screenname")
@@ -636,14 +670,20 @@ func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager U
 		return
 	}
 
+	suspendedStatusText, err := getSuspendedStatusErrCodeToText(user.SuspendedStatus)
+	if err != nil {
+		logger.Error("error in GET /user/{screenname}/account", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+	}
 	out := userAccountHandle{
-		ID:           user.IdentScreenName.String(),
-		ScreenName:   user.DisplayScreenName.String(),
-		EmailAddress: emailAddress,
-		RegStatus:    regStatus,
-		Confirmed:    confirmStatus,
-		Profile:      profile,
-		IsICQ:        user.IsICQ,
+		ID:              user.IdentScreenName.String(),
+		ScreenName:      user.DisplayScreenName.String(),
+		EmailAddress:    emailAddress,
+		RegStatus:       regStatus,
+		Confirmed:       confirmStatus,
+		Profile:         profile,
+		IsICQ:           user.IsICQ,
+		SuspendedStatus: suspendedStatusText,
 	}
 
 	if err := json.NewEncoder(w).Encode(out); err != nil {
@@ -652,6 +692,98 @@ func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager U
 	}
 }
 
+// patchUserAccountHandler handles the PATCH /user/{screenname}/account endpoint.
+func patchUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, a AccountManager, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	screenName := r.PathValue("screenname")
+	user, err := userManager.User(state.NewIdentScreenName(screenName))
+	if err != nil {
+		logger.Error("error in PATCH /user/{screenname}/account", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if user == nil {
+		http.Error(w, "user not found", http.StatusNotFound)
+		return
+	}
+
+	input := userAccountPatch{}
+	d := json.NewDecoder(r.Body)
+	d.DisallowUnknownFields()
+	if err := d.Decode(&input); err != nil {
+		errorMsg(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	modifiedUser := false
+
+	if input.SuspendedStatusText != nil {
+		switch *input.SuspendedStatusText {
+		case
+			"", "deleted", "expired",
+			"suspended", "suspended_age":
+			suspendedStatus, err := getSuspendedStatusTextToErrCode(*input.SuspendedStatusText)
+			if err != nil {
+				logger.Error("error in PATCH /user/{screenname}/account", "err", err.Error())
+				http.Error(w, "internal server error", http.StatusInternalServerError)
+				return
+			}
+			if suspendedStatus != user.SuspendedStatus {
+				err := a.UpdateSuspendedStatus(suspendedStatus, user.IdentScreenName)
+				if err != nil {
+					logger.Error("error in PATCH /user/{screenname}/account", "err", err.Error())
+					http.Error(w, "internal server error", http.StatusInternalServerError)
+					return
+				}
+				modifiedUser = true
+			}
+		default:
+			errorMsg(w, "suspended_status must be empty str or one of deleted,expired,suspended,suspended_age", http.StatusBadRequest)
+			return
+		}
+	}
+
+	if !modifiedUser {
+		w.WriteHeader(http.StatusNotModified)
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// getSuspendedStatusTextToErrCode maps the given suspendedStatusText to
+// the appropriate error code, or 0x0 for none.
+func getSuspendedStatusTextToErrCode(suspendedStatusText string) (uint16, error) {
+	suspendedStatusTextMap := map[string]uint16{
+		"":              0x0,
+		"deleted":       wire.LoginErrDeletedAccount,
+		"expired":       wire.LoginErrExpiredAccount,
+		"suspended":     wire.LoginErrSuspendedAccount,
+		"suspended_age": wire.LoginErrSuspendedAccountAge,
+	}
+	suspendedStatus, ok := suspendedStatusTextMap[suspendedStatusText]
+	if !ok {
+		return 0x0, errors.New("unable to map suspendedText to error code")
+	}
+	return suspendedStatus, nil
+}
+
+// getSuspendedStatusErrCodeToText maps the given suspendedStatus to
+// the appropriate text, or "" for none.
+func getSuspendedStatusErrCodeToText(suspendedStatus uint16) (string, error) {
+	suspendedStatusTextMap := map[uint16]string{
+		0x0:                              "",
+		wire.LoginErrDeletedAccount:      "deleted",
+		wire.LoginErrExpiredAccount:      "expired",
+		wire.LoginErrSuspendedAccount:    "suspended",
+		wire.LoginErrSuspendedAccountAge: "suspended_age",
+	}
+	st, ok := suspendedStatusTextMap[suspendedStatus]
+	if !ok {
+		return "", errors.New("unable to map error code to suspendedText")
+	}
+	return st, nil
+}
+
 // getVersionHandler handles the GET /version endpoint.
 func getVersionHandler(w http.ResponseWriter, bld config.Build) {
 	w.Header().Set("Content-Type", "application/json")

+ 294 - 13
server/http/mgmt_api_test.go

@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/mail"
+	"net/netip"
 	"strings"
 	"testing"
 	"time"
@@ -27,6 +28,8 @@ func TestSessionHandler_GET(t *testing.T) {
 		sess.SetIdentScreenName(state.NewIdentScreenName(screenName))
 		sess.SetDisplayScreenName(state.DisplayScreenName(screenName))
 		sess.SetUIN(uin)
+		ip, _ := netip.ParseAddrPort("1.2.3.4:1234")
+		sess.SetRemoteAddr(&ip)
 		return sess
 	}
 	tt := []struct {
@@ -52,7 +55,7 @@ func TestSessionHandler_GET(t *testing.T) {
 		},
 		{
 			name:          "with sessions",
-			want:          `{"count":3,"sessions":[{"id":"usera","screen_name":"userA","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":false},{"id":"userb","screen_name":"userB","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":false},{"id":"100003","screen_name":"100003","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":true}]}`,
+			want:          `{"count":3,"sessions":[{"id":"usera","screen_name":"userA","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":false,"remote_addr":"1.2.3.4","remote_port":1234},{"id":"userb","screen_name":"userB","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":false,"remote_addr":"1.2.3.4","remote_port":1234},{"id":"100003","screen_name":"100003","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":true,"remote_addr":"1.2.3.4","remote_port":1234}]}`,
 			statusCode:    http.StatusOK,
 			timeSinceFunc: func(t time.Time) time.Duration { t0 := time.Now(); return t0.Sub(t0) },
 			mockParams: mockParams{
@@ -102,6 +105,8 @@ func TestSessionHandlerScreenname_GET(t *testing.T) {
 		sess.SetIdentScreenName(state.NewIdentScreenName(screenName))
 		sess.SetDisplayScreenName(state.DisplayScreenName(screenName))
 		sess.SetUIN(uin)
+		ip, _ := netip.ParseAddrPort("1.2.3.4:1234")
+		sess.SetRemoteAddr(&ip)
 		return sess
 	}
 	tt := []struct {
@@ -133,7 +138,7 @@ func TestSessionHandlerScreenname_GET(t *testing.T) {
 		{
 			name:              "active session found for screenname",
 			requestScreenName: state.NewIdentScreenName("userA"),
-			want:              `{"count":1,"sessions":[{"id":"usera","screen_name":"userA","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":false}]}`,
+			want:              `{"count":1,"sessions":[{"id":"usera","screen_name":"userA","online_seconds":0,"away_message":"","idle_seconds":0,"is_icq":false,"remote_addr":"1.2.3.4","remote_port":1234}]}`,
 			statusCode:        http.StatusOK,
 			timeSinceFunc:     func(t time.Time) time.Duration { t0 := time.Now(); return t0.Sub(t0) },
 			mockParams: mockParams{
@@ -175,6 +180,75 @@ func TestSessionHandlerScreenname_GET(t *testing.T) {
 	}
 }
 
+func TestSessionHandlerScreenname_DELETE(t *testing.T) {
+	fnNewSess := func(screenName string) *state.Session {
+		sess := state.NewSession()
+		sess.SetIdentScreenName(state.NewIdentScreenName(screenName))
+		sess.SetDisplayScreenName(state.DisplayScreenName(screenName))
+		ip, _ := netip.ParseAddrPort("1.2.3.4:1234")
+		sess.SetRemoteAddr(&ip)
+		return sess
+	}
+	tt := []struct {
+		name              string
+		session           *state.Session
+		requestScreenName state.IdentScreenName
+		statusCode        int
+		mockParams        mockParams
+	}{
+		{
+			name:              "delete an active session",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNoContent,
+			mockParams: mockParams{
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     fnNewSess("userA"),
+						},
+					},
+				},
+			},
+		},
+		{
+			name:              "delete a non-existent session",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNotFound,
+			mockParams: mockParams{
+				sessionRetrieverParams: sessionRetrieverParams{
+					retrieveSessionByNameParams: retrieveSessionByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodDelete, "/session/"+tc.requestScreenName.String(), nil)
+			request.SetPathValue("screenname", tc.requestScreenName.String())
+			responseRecorder := httptest.NewRecorder()
+
+			sessionRetriever := newMockSessionRetriever(t)
+			for _, params := range tc.mockParams.sessionRetrieverParams.retrieveSessionByNameParams {
+				sessionRetriever.EXPECT().
+					RetrieveSession(params.screenName).
+					Return(params.result)
+			}
+
+			deleteSessionHandler(responseRecorder, request, sessionRetriever)
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+		})
+	}
+}
+
 func TestUserAccountHandler_GET(t *testing.T) {
 	tt := []struct {
 		name              string
@@ -202,7 +276,7 @@ func TestUserAccountHandler_GET(t *testing.T) {
 		{
 			name:              "valid aim account",
 			requestScreenName: state.NewIdentScreenName("userA"),
-			want:              `{"id":"usera","screen_name":"userA","profile":"My Profile Text","email_address":"\u003cuserA@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false}`,
+			want:              `{"id":"usera","screen_name":"userA","profile":"My Profile Text","email_address":"\u003cuserA@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false,"suspended_status":""}`,
 			statusCode:        http.StatusOK,
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{
@@ -212,11 +286,12 @@ func TestUserAccountHandler_GET(t *testing.T) {
 							result: &state.User{
 								DisplayScreenName: "userA",
 								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   0x0,
 							},
 						},
 					},
 				},
-				accountRetrieverParams: accountRetrieverParams{
+				accountManagerParams: accountManagerParams{
 					emailAddressByNameParams: emailAddressByNameParams{
 						{
 							screenName: state.NewIdentScreenName("userA"),
@@ -248,6 +323,56 @@ func TestUserAccountHandler_GET(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:              "suspended aim account",
+			requestScreenName: state.NewIdentScreenName("userB"),
+			want:              `{"id":"userb","screen_name":"userB","profile":"My Profile Text","email_address":"\u003cuserB@aol.com\u003e","reg_status":2,"confirmed":true,"is_icq":false,"suspended_status":"suspended"}`,
+			statusCode:        http.StatusOK,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userB"),
+							result: &state.User{
+								DisplayScreenName: "userB",
+								IdentScreenName:   state.NewIdentScreenName("userB"),
+								SuspendedStatus:   wire.LoginErrSuspendedAccount,
+							},
+						},
+					},
+				},
+				accountManagerParams: accountManagerParams{
+					emailAddressByNameParams: emailAddressByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userB"),
+							result: &mail.Address{
+								Address: "userB@aol.com",
+							},
+						},
+					},
+					regStatusByNameParams: regStatusByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userB"),
+							result:     uint16(0x02),
+						},
+					},
+					confirmStatusByNameParams: confirmStatusByNameParams{
+						{
+							screenName: state.NewIdentScreenName("userB"),
+							result:     true,
+						},
+					},
+				},
+				profileRetrieverParams: profileRetrieverParams{
+					retrieveProfileParams: retrieveProfileParams{
+						{
+							screenName: state.NewIdentScreenName("userB"),
+							result:     "My Profile Text",
+						},
+					},
+				},
+			},
+		},
 	}
 
 	for _, tc := range tt {
@@ -263,19 +388,19 @@ func TestUserAccountHandler_GET(t *testing.T) {
 					Return(params.result, params.err)
 			}
 
-			accountRetriever := newMockAccountRetriever(t)
-			for _, params := range tc.mockParams.accountRetrieverParams.emailAddressByNameParams {
-				accountRetriever.EXPECT().
+			accountManager := newMockAccountManager(t)
+			for _, params := range tc.mockParams.accountManagerParams.emailAddressByNameParams {
+				accountManager.EXPECT().
 					EmailAddressByName(params.screenName).
 					Return(params.result, params.err)
 			}
-			for _, params := range tc.mockParams.accountRetrieverParams.regStatusByNameParams {
-				accountRetriever.EXPECT().
+			for _, params := range tc.mockParams.accountManagerParams.regStatusByNameParams {
+				accountManager.EXPECT().
 					RegStatusByName(params.screenName).
 					Return(params.result, params.err)
 			}
-			for _, params := range tc.mockParams.accountRetrieverParams.confirmStatusByNameParams {
-				accountRetriever.EXPECT().
+			for _, params := range tc.mockParams.accountManagerParams.confirmStatusByNameParams {
+				accountManager.EXPECT().
 					ConfirmStatusByName(params.screenName).
 					Return(params.result, params.err)
 			}
@@ -287,7 +412,163 @@ func TestUserAccountHandler_GET(t *testing.T) {
 					Return(params.result, params.err)
 			}
 
-			getUserAccountHandler(responseRecorder, request, userManager, accountRetriever, profileRetriever, slog.Default())
+			getUserAccountHandler(responseRecorder, request, userManager, accountManager, profileRetriever, slog.Default())
+
+			if responseRecorder.Code != tc.statusCode {
+				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
+			}
+
+			if strings.TrimSpace(responseRecorder.Body.String()) != tc.want {
+				t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body)
+			}
+		})
+	}
+}
+
+func TestUserAccountHandler_PATCH(t *testing.T) {
+	tt := []struct {
+		name              string
+		requestScreenName state.IdentScreenName
+		want              string
+		body              string
+		statusCode        int
+		mockParams        mockParams
+	}{
+		{
+			name:              "suspending a non-existent account",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			body:              `{"suspended_status":"suspended"}`,
+			want:              `user not found`,
+			statusCode:        http.StatusNotFound,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:              "patching with invalid suspended_status value",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			body:              `{"suspended_status":"thisisinvalid"}`,
+			want:              `{"message":"suspended_status must be empty str or one of deleted,expired,suspended,suspended_age"}`,
+			statusCode:        http.StatusBadRequest,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result:     &state.User{},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:              "suspending an active aim account",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNoContent,
+			body:              `{"suspended_status":"suspended"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &state.User{
+								DisplayScreenName: "userA",
+								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   0x0,
+							},
+						},
+					},
+				},
+				accountManagerParams: accountManagerParams{
+					updateSuspendedStatusParams: updateSuspendedStatusParams{
+						{
+							suspendedStatus: wire.LoginErrSuspendedAccount,
+							screenName:      state.NewIdentScreenName("userA"),
+							err:             nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:              "unsuspending a suspended aim account",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNoContent,
+			body:              `{"suspended_status":""}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &state.User{
+								DisplayScreenName: "userA",
+								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   wire.LoginErrSuspendedAccount,
+							},
+						},
+					},
+				},
+				accountManagerParams: accountManagerParams{
+					updateSuspendedStatusParams: updateSuspendedStatusParams{
+						{
+							suspendedStatus: 0x0,
+							screenName:      state.NewIdentScreenName("userA"),
+							err:             nil,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:              "suspending an already suspended aim account",
+			requestScreenName: state.NewIdentScreenName("userA"),
+			statusCode:        http.StatusNotModified,
+			body:              `{"suspended_status":"suspended"}`,
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: state.NewIdentScreenName("userA"),
+							result: &state.User{
+								DisplayScreenName: "userA",
+								IdentScreenName:   state.NewIdentScreenName("userA"),
+								SuspendedStatus:   wire.LoginErrSuspendedAccount,
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range tt {
+		t.Run(tc.name, func(t *testing.T) {
+			request := httptest.NewRequest(http.MethodPatch, "/user/"+tc.requestScreenName.String()+"/account", strings.NewReader(tc.body))
+			request.SetPathValue("screenname", tc.requestScreenName.String())
+			responseRecorder := httptest.NewRecorder()
+
+			userManager := newMockUserManager(t)
+			for _, params := range tc.mockParams.userManagerParams.getUserParams {
+				userManager.EXPECT().
+					User(params.screenName).
+					Return(params.result, params.err)
+			}
+
+			accountManager := newMockAccountManager(t)
+			for _, params := range tc.mockParams.accountManagerParams.updateSuspendedStatusParams {
+				accountManager.EXPECT().
+					UpdateSuspendedStatus(params.suspendedStatus, params.screenName).
+					Return(params.err)
+			}
+
+			patchUserAccountHandler(responseRecorder, request, userManager, accountManager, slog.Default())
 
 			if responseRecorder.Code != tc.statusCode {
 				t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code)
@@ -590,7 +871,7 @@ func TestUserHandler_GET(t *testing.T) {
 		},
 		{
 			name:       "user store containing 3 users",
-			want:       `[{"id":"usera","screen_name":"userA","is_icq":false},{"id":"userb","screen_name":"userB","is_icq":false},{"id":"100003","screen_name":"100003","is_icq":true}]`,
+			want:       `[{"id":"usera","screen_name":"userA","is_icq":false,"suspended_status":""},{"id":"userb","screen_name":"userB","is_icq":false,"suspended_status":""},{"id":"100003","screen_name":"100003","is_icq":true,"suspended_status":""}]`,
 			statusCode: http.StatusOK,
 			mockParams: mockParams{
 				userManagerParams: userManagerParams{

+ 254 - 0
server/http/mock_AccountManager.go

@@ -0,0 +1,254 @@
+// Code generated by mockery v2.52.1. DO NOT EDIT.
+
+package http
+
+import (
+	mail "net/mail"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockAccountManager is an autogenerated mock type for the AccountManager type
+type mockAccountManager struct {
+	mock.Mock
+}
+
+type mockAccountManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockAccountManager) EXPECT() *mockAccountManager_Expecter {
+	return &mockAccountManager_Expecter{mock: &_m.Mock}
+}
+
+// ConfirmStatusByName provides a mock function with given fields: screenName
+func (_m *mockAccountManager) ConfirmStatusByName(screenName state.IdentScreenName) (bool, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ConfirmStatusByName")
+	}
+
+	var r0 bool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (bool, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) bool); ok {
+		r0 = rf(screenName)
+	} else {
+		r0 = ret.Get(0).(bool)
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountManager_ConfirmStatusByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfirmStatusByName'
+type mockAccountManager_ConfirmStatusByName_Call struct {
+	*mock.Call
+}
+
+// ConfirmStatusByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) ConfirmStatusByName(screenName interface{}) *mockAccountManager_ConfirmStatusByName_Call {
+	return &mockAccountManager_ConfirmStatusByName_Call{Call: _e.mock.On("ConfirmStatusByName", screenName)}
+}
+
+func (_c *mockAccountManager_ConfirmStatusByName_Call) Run(run func(screenName state.IdentScreenName)) *mockAccountManager_ConfirmStatusByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_ConfirmStatusByName_Call) Return(_a0 bool, _a1 error) *mockAccountManager_ConfirmStatusByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountManager_ConfirmStatusByName_Call) RunAndReturn(run func(state.IdentScreenName) (bool, error)) *mockAccountManager_ConfirmStatusByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// EmailAddressByName provides a mock function with given fields: screenName
+func (_m *mockAccountManager) EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for EmailAddressByName")
+	}
+
+	var r0 *mail.Address
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (*mail.Address, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) *mail.Address); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*mail.Address)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountManager_EmailAddressByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EmailAddressByName'
+type mockAccountManager_EmailAddressByName_Call struct {
+	*mock.Call
+}
+
+// EmailAddressByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) EmailAddressByName(screenName interface{}) *mockAccountManager_EmailAddressByName_Call {
+	return &mockAccountManager_EmailAddressByName_Call{Call: _e.mock.On("EmailAddressByName", screenName)}
+}
+
+func (_c *mockAccountManager_EmailAddressByName_Call) Run(run func(screenName state.IdentScreenName)) *mockAccountManager_EmailAddressByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_EmailAddressByName_Call) Return(_a0 *mail.Address, _a1 error) *mockAccountManager_EmailAddressByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountManager_EmailAddressByName_Call) RunAndReturn(run func(state.IdentScreenName) (*mail.Address, error)) *mockAccountManager_EmailAddressByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegStatusByName provides a mock function with given fields: screenName
+func (_m *mockAccountManager) RegStatusByName(screenName state.IdentScreenName) (uint16, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegStatusByName")
+	}
+
+	var r0 uint16
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (uint16, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) uint16); ok {
+		r0 = rf(screenName)
+	} else {
+		r0 = ret.Get(0).(uint16)
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountManager_RegStatusByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegStatusByName'
+type mockAccountManager_RegStatusByName_Call struct {
+	*mock.Call
+}
+
+// RegStatusByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) RegStatusByName(screenName interface{}) *mockAccountManager_RegStatusByName_Call {
+	return &mockAccountManager_RegStatusByName_Call{Call: _e.mock.On("RegStatusByName", screenName)}
+}
+
+func (_c *mockAccountManager_RegStatusByName_Call) Run(run func(screenName state.IdentScreenName)) *mockAccountManager_RegStatusByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_RegStatusByName_Call) Return(_a0 uint16, _a1 error) *mockAccountManager_RegStatusByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountManager_RegStatusByName_Call) RunAndReturn(run func(state.IdentScreenName) (uint16, error)) *mockAccountManager_RegStatusByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UpdateSuspendedStatus provides a mock function with given fields: suspendedStatus, screenName
+func (_m *mockAccountManager) UpdateSuspendedStatus(suspendedStatus uint16, screenName state.IdentScreenName) error {
+	ret := _m.Called(suspendedStatus, screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UpdateSuspendedStatus")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(uint16, state.IdentScreenName) error); ok {
+		r0 = rf(suspendedStatus, screenName)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockAccountManager_UpdateSuspendedStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateSuspendedStatus'
+type mockAccountManager_UpdateSuspendedStatus_Call struct {
+	*mock.Call
+}
+
+// UpdateSuspendedStatus is a helper method to define mock.On call
+//   - suspendedStatus uint16
+//   - screenName state.IdentScreenName
+func (_e *mockAccountManager_Expecter) UpdateSuspendedStatus(suspendedStatus interface{}, screenName interface{}) *mockAccountManager_UpdateSuspendedStatus_Call {
+	return &mockAccountManager_UpdateSuspendedStatus_Call{Call: _e.mock.On("UpdateSuspendedStatus", suspendedStatus, screenName)}
+}
+
+func (_c *mockAccountManager_UpdateSuspendedStatus_Call) Run(run func(suspendedStatus uint16, screenName state.IdentScreenName)) *mockAccountManager_UpdateSuspendedStatus_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(uint16), args[1].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateSuspendedStatus_Call) Return(_a0 error) *mockAccountManager_UpdateSuspendedStatus_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockAccountManager_UpdateSuspendedStatus_Call) RunAndReturn(run func(uint16, state.IdentScreenName) error) *mockAccountManager_UpdateSuspendedStatus_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockAccountManager creates a new instance of mockAccountManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockAccountManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockAccountManager {
+	mock := &mockAccountManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 16 - 7
server/http/test_helpers.go

@@ -8,7 +8,7 @@ import (
 )
 
 type mockParams struct {
-	accountRetrieverParams
+	accountManagerParams
 	bartRetrieverParams
 	chatRoomRetrieverParams
 	chatSessionRetrieverParams
@@ -19,16 +19,17 @@ type mockParams struct {
 	userManagerParams
 }
 
-// accountRetrieverParams is a helper struct that contains mock parameters for
-// accountRetriever methods
-type accountRetrieverParams struct {
+// accountManagerParams is a helper struct that contains mock parameters for
+// accountManager methods
+type accountManagerParams struct {
 	emailAddressByNameParams
 	regStatusByNameParams
 	confirmStatusByNameParams
+	updateSuspendedStatusParams
 }
 
 // emailAddressByNameParams is the list of parameters passed at the mock
-// accountRetriever.EmailAddressByName call site
+// accountManager.EmailAddressByName call site
 type emailAddressByNameParams []struct {
 	screenName state.IdentScreenName
 	result     *mail.Address
@@ -36,7 +37,7 @@ type emailAddressByNameParams []struct {
 }
 
 // regStatusByNameParams is the list of parameters passed at the mock
-// accountRetriever.RegStatusByName call site
+// accountManager.RegStatusByName call site
 type regStatusByNameParams []struct {
 	screenName state.IdentScreenName
 	result     uint16
@@ -44,13 +45,21 @@ type regStatusByNameParams []struct {
 }
 
 // confirmStatusByNameParams is the list of parameters passed at the mock
-// accountRetriever.ConfirmStatusByName call site
+// accountManager.ConfirmStatusByName call site
 type confirmStatusByNameParams []struct {
 	screenName state.IdentScreenName
 	result     bool
 	err        error
 }
 
+// updateSuspendedStatus is the list of parameters passed at the mock
+// accountManager.updateSuspendedStatus call site
+type updateSuspendedStatusParams []struct {
+	suspendedStatus uint16
+	screenName      state.IdentScreenName
+	err             error
+}
+
 // bartRetrieverParams is a helper struct that contains mock parameters for
 // BARTRetriever methods
 type bartRetrieverParams struct {

+ 21 - 12
server/http/types.go

@@ -38,10 +38,11 @@ type MessageRelayer interface {
 	RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
 }
 
-type AccountRetriever interface {
+type AccountManager interface {
 	EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error)
 	RegStatusByName(screenName state.IdentScreenName) (uint16, error)
-	ConfirmStatusByName(screnName state.IdentScreenName) (bool, error)
+	ConfirmStatusByName(screenName state.IdentScreenName) (bool, error)
+	UpdateSuspendedStatus(suspendedStatus uint16, screenName state.IdentScreenName) error
 }
 
 type BARTRetriever interface {
@@ -76,9 +77,10 @@ type onlineUsers struct {
 }
 
 type userHandle struct {
-	ID         string `json:"id"`
-	ScreenName string `json:"screen_name"`
-	IsICQ      bool   `json:"is_icq"`
+	ID              string `json:"id"`
+	ScreenName      string `json:"screen_name"`
+	IsICQ           bool   `json:"is_icq"`
+	SuspendedStatus string `json:"suspended_status"`
 }
 
 type aimChatUserHandle struct {
@@ -87,13 +89,18 @@ type aimChatUserHandle struct {
 }
 
 type userAccountHandle struct {
-	ID           string `json:"id"`
-	ScreenName   string `json:"screen_name"`
-	Profile      string `json:"profile"`
-	EmailAddress string `json:"email_address"`
-	RegStatus    uint16 `json:"reg_status"`
-	Confirmed    bool   `json:"confirmed"`
-	IsICQ        bool   `json:"is_icq"`
+	ID              string `json:"id"`
+	ScreenName      string `json:"screen_name"`
+	Profile         string `json:"profile"`
+	EmailAddress    string `json:"email_address"`
+	RegStatus       uint16 `json:"reg_status"`
+	Confirmed       bool   `json:"confirmed"`
+	IsICQ           bool   `json:"is_icq"`
+	SuspendedStatus string `json:"suspended_status"`
+}
+
+type userAccountPatch struct {
+	SuspendedStatusText *string `json:"suspended_status"`
 }
 
 type sessionHandle struct {
@@ -103,6 +110,8 @@ type sessionHandle struct {
 	AwayMessage   string  `json:"away_message"`
 	IdleSeconds   float64 `json:"idle_seconds"`
 	IsICQ         bool    `json:"is_icq"`
+	RemoteAddr    string  `json:"remote_addr,omitempty"`
+	RemotePort    uint16  `json:"remote_port,omitempty"`
 }
 
 type chatRoomCreate struct {

+ 3 - 0
server/oscar/admin.go

@@ -91,6 +91,9 @@ func (rt AdminServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteC
 	}
 
 	sess, err := rt.RetrieveBOSSession(authCookie)
+	if err != nil {
+		return err
+	}
 	if sess == nil {
 		return errors.New("session not found")
 	}

+ 20 - 0
server/oscar/bos.go

@@ -7,6 +7,7 @@ import (
 	"io"
 	"log/slog"
 	"net"
+	"net/netip"
 	"sync"
 	"time"
 
@@ -37,6 +38,12 @@ type DepartureNotifier interface {
 	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
 }
 
+// ChatSessionManager is the interface for closing chat sessions
+// when a client disconnects.
+type ChatSessionManager interface {
+	RemoveUserFromAllChats(user state.IdentScreenName)
+}
+
 // BOSServer provides client connection lifecycle management for the BOS
 // service.
 type BOSServer struct {
@@ -48,6 +55,7 @@ type BOSServer struct {
 	Logger     *slog.Logger
 	OnlineNotifier
 	config.Config
+	ChatSessionManager *state.InMemoryChatSessionManager
 }
 
 // Start starts a TCP server and listens for connections. The initial
@@ -173,6 +181,9 @@ func (rt BOSServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteClo
 				rt.Logger.ErrorContext(ctx, "error removing buddy list entry", "err", err.Error())
 			}
 		}
+		if rt.ChatSessionManager != nil {
+			rt.ChatSessionManager.RemoveUserFromAllChats(sess.IdentScreenName())
+		}
 		rt.Signout(ctx, sess)
 	}()
 
@@ -183,5 +194,14 @@ func (rt BOSServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteClo
 		return err
 	}
 
+	remoteAddr, ok := ctx.Value("ip").(string)
+	if ok {
+		ip, err := netip.ParseAddrPort(remoteAddr)
+		if err != nil {
+			return errors.New("unable to parse ip addr")
+		}
+		sess.SetRemoteAddr(&ip)
+	}
+
 	return dispatchIncomingMessages(ctx, sess, flapc, rwc, rt.Logger, rt.Handler)
 }

+ 1 - 1
server/oscar/connection.go

@@ -105,7 +105,7 @@ func dispatchIncomingMessages(ctx context.Context, sess *state.Session, flapc *w
 		case <-sess.Closed():
 			block := wire.TLVRestBlock{}
 			// error code indicating user signed in a different location
-			block.Append(wire.NewTLVBE(0x0009, uint8(0x01)))
+			block.Append(wire.NewTLVBE(0x0009, wire.OServiceDiscErrNewLogin))
 			// "more info" button
 			block.Append(wire.NewTLVBE(0x000b, "https://github.com/mk6i/retro-aim-server"))
 			if err := flapc.SendSignoffFrame(block); err != nil {

+ 172 - 0
server/oscar/mock_BuddyListRegistry.go

@@ -0,0 +1,172 @@
+// Code generated by mockery v2.52.1. DO NOT EDIT.
+
+package oscar
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockBuddyListRegistry is an autogenerated mock type for the BuddyListRegistry type
+type mockBuddyListRegistry struct {
+	mock.Mock
+}
+
+type mockBuddyListRegistry_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyListRegistry) EXPECT() *mockBuddyListRegistry_Expecter {
+	return &mockBuddyListRegistry_Expecter{mock: &_m.Mock}
+}
+
+// ClearBuddyListRegistry provides a mock function with no fields
+func (_m *mockBuddyListRegistry) ClearBuddyListRegistry() error {
+	ret := _m.Called()
+
+	if len(ret) == 0 {
+		panic("no return value specified for ClearBuddyListRegistry")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func() error); ok {
+		r0 = rf()
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyListRegistry_ClearBuddyListRegistry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearBuddyListRegistry'
+type mockBuddyListRegistry_ClearBuddyListRegistry_Call struct {
+	*mock.Call
+}
+
+// ClearBuddyListRegistry is a helper method to define mock.On call
+func (_e *mockBuddyListRegistry_Expecter) ClearBuddyListRegistry() *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	return &mockBuddyListRegistry_ClearBuddyListRegistry_Call{Call: _e.mock.On("ClearBuddyListRegistry")}
+}
+
+func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) Run(run func()) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run()
+	})
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) Return(_a0 error) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_ClearBuddyListRegistry_Call) RunAndReturn(run func() error) *mockBuddyListRegistry_ClearBuddyListRegistry_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegisterBuddyList provides a mock function with given fields: user
+func (_m *mockBuddyListRegistry) RegisterBuddyList(user state.IdentScreenName) error {
+	ret := _m.Called(user)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegisterBuddyList")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) error); ok {
+		r0 = rf(user)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyListRegistry_RegisterBuddyList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterBuddyList'
+type mockBuddyListRegistry_RegisterBuddyList_Call struct {
+	*mock.Call
+}
+
+// RegisterBuddyList is a helper method to define mock.On call
+//   - user state.IdentScreenName
+func (_e *mockBuddyListRegistry_Expecter) RegisterBuddyList(user interface{}) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	return &mockBuddyListRegistry_RegisterBuddyList_Call{Call: _e.mock.On("RegisterBuddyList", user)}
+}
+
+func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) Run(run func(user state.IdentScreenName)) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) Return(_a0 error) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_RegisterBuddyList_Call) RunAndReturn(run func(state.IdentScreenName) error) *mockBuddyListRegistry_RegisterBuddyList_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UnregisterBuddyList provides a mock function with given fields: user
+func (_m *mockBuddyListRegistry) UnregisterBuddyList(user state.IdentScreenName) error {
+	ret := _m.Called(user)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UnregisterBuddyList")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) error); ok {
+		r0 = rf(user)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyListRegistry_UnregisterBuddyList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnregisterBuddyList'
+type mockBuddyListRegistry_UnregisterBuddyList_Call struct {
+	*mock.Call
+}
+
+// UnregisterBuddyList is a helper method to define mock.On call
+//   - user state.IdentScreenName
+func (_e *mockBuddyListRegistry_Expecter) UnregisterBuddyList(user interface{}) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	return &mockBuddyListRegistry_UnregisterBuddyList_Call{Call: _e.mock.On("UnregisterBuddyList", user)}
+}
+
+func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) Run(run func(user state.IdentScreenName)) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) Return(_a0 error) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyListRegistry_UnregisterBuddyList_Call) RunAndReturn(run func(state.IdentScreenName) error) *mockBuddyListRegistry_UnregisterBuddyList_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBuddyListRegistry creates a new instance of mockBuddyListRegistry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBuddyListRegistry(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyListRegistry {
+	mock := &mockBuddyListRegistry{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 68 - 0
server/oscar/mock_ChatSessionManager.go

@@ -0,0 +1,68 @@
+// Code generated by mockery v2.52.1. DO NOT EDIT.
+
+package oscar
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockChatSessionManager is an autogenerated mock type for the ChatSessionManager type
+type mockChatSessionManager struct {
+	mock.Mock
+}
+
+type mockChatSessionManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockChatSessionManager) EXPECT() *mockChatSessionManager_Expecter {
+	return &mockChatSessionManager_Expecter{mock: &_m.Mock}
+}
+
+// RemoveUserFromAllChats provides a mock function with given fields: user
+func (_m *mockChatSessionManager) RemoveUserFromAllChats(user state.IdentScreenName) {
+	_m.Called(user)
+}
+
+// mockChatSessionManager_RemoveUserFromAllChats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveUserFromAllChats'
+type mockChatSessionManager_RemoveUserFromAllChats_Call struct {
+	*mock.Call
+}
+
+// RemoveUserFromAllChats is a helper method to define mock.On call
+//   - user state.IdentScreenName
+func (_e *mockChatSessionManager_Expecter) RemoveUserFromAllChats(user interface{}) *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	return &mockChatSessionManager_RemoveUserFromAllChats_Call{Call: _e.mock.On("RemoveUserFromAllChats", user)}
+}
+
+func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Run(run func(user state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) Return() *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockChatSessionManager_RemoveUserFromAllChats_Call) RunAndReturn(run func(state.IdentScreenName)) *mockChatSessionManager_RemoveUserFromAllChats_Call {
+	_c.Run(run)
+	return _c
+}
+
+// newMockChatSessionManager creates a new instance of mockChatSessionManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockChatSessionManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockChatSessionManager {
+	mock := &mockChatSessionManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 84 - 0
server/oscar/mock_DepartureNotifier.go

@@ -0,0 +1,84 @@
+// Code generated by mockery v2.52.1. DO NOT EDIT.
+
+package oscar
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockDepartureNotifier is an autogenerated mock type for the DepartureNotifier type
+type mockDepartureNotifier struct {
+	mock.Mock
+}
+
+type mockDepartureNotifier_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockDepartureNotifier) EXPECT() *mockDepartureNotifier_Expecter {
+	return &mockDepartureNotifier_Expecter{mock: &_m.Mock}
+}
+
+// BroadcastBuddyDeparted provides a mock function with given fields: ctx, sess
+func (_m *mockDepartureNotifier) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
+	ret := _m.Called(ctx, sess)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastBuddyDeparted")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
+		r0 = rf(ctx, sess)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockDepartureNotifier_BroadcastBuddyDeparted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyDeparted'
+type mockDepartureNotifier_BroadcastBuddyDeparted_Call struct {
+	*mock.Call
+}
+
+// BroadcastBuddyDeparted is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockDepartureNotifier_Expecter) BroadcastBuddyDeparted(ctx interface{}, sess interface{}) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	return &mockDepartureNotifier_BroadcastBuddyDeparted_Call{Call: _e.mock.On("BroadcastBuddyDeparted", ctx, sess)}
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) Return(_a0 error) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockDepartureNotifier_BroadcastBuddyDeparted_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockDepartureNotifier_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockDepartureNotifier creates a new instance of mockDepartureNotifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockDepartureNotifier(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockDepartureNotifier {
+	mock := &mockDepartureNotifier{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 91 - 0
server/oscar/mock_HandlerFunc.go

@@ -0,0 +1,91 @@
+// Code generated by mockery v2.52.1. DO NOT EDIT.
+
+package oscar
+
+import (
+	context "context"
+	io "io"
+
+	mock "github.com/stretchr/testify/mock"
+
+	state "github.com/mk6i/retro-aim-server/state"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockHandlerFunc is an autogenerated mock type for the HandlerFunc type
+type mockHandlerFunc struct {
+	mock.Mock
+}
+
+type mockHandlerFunc_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockHandlerFunc) EXPECT() *mockHandlerFunc_Expecter {
+	return &mockHandlerFunc_Expecter{mock: &_m.Mock}
+}
+
+// Execute provides a mock function with given fields: ctx, sess, inFrame, r, rw
+func (_m *mockHandlerFunc) Execute(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter) error {
+	ret := _m.Called(ctx, sess, inFrame, r, rw)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Execute")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, wire.SNACFrame, io.Reader, ResponseWriter) error); ok {
+		r0 = rf(ctx, sess, inFrame, r, rw)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockHandlerFunc_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute'
+type mockHandlerFunc_Execute_Call struct {
+	*mock.Call
+}
+
+// Execute is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame wire.SNACFrame
+//   - r io.Reader
+//   - rw ResponseWriter
+func (_e *mockHandlerFunc_Expecter) Execute(ctx interface{}, sess interface{}, inFrame interface{}, r interface{}, rw interface{}) *mockHandlerFunc_Execute_Call {
+	return &mockHandlerFunc_Execute_Call{Call: _e.mock.On("Execute", ctx, sess, inFrame, r, rw)}
+}
+
+func (_c *mockHandlerFunc_Execute_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, r io.Reader, rw ResponseWriter)) *mockHandlerFunc_Execute_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACFrame), args[3].(io.Reader), args[4].(ResponseWriter))
+	})
+	return _c
+}
+
+func (_c *mockHandlerFunc_Execute_Call) Return(_a0 error) *mockHandlerFunc_Execute_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockHandlerFunc_Execute_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACFrame, io.Reader, ResponseWriter) error) *mockHandlerFunc_Execute_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockHandlerFunc creates a new instance of mockHandlerFunc. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockHandlerFunc(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockHandlerFunc {
+	mock := &mockHandlerFunc{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 93 - 0
server/oscar/mock_SessionManager.go

@@ -0,0 +1,93 @@
+// Code generated by mockery v2.52.1. DO NOT EDIT.
+
+package oscar
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockSessionManager is an autogenerated mock type for the SessionManager type
+type mockSessionManager struct {
+	mock.Mock
+}
+
+type mockSessionManager_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockSessionManager) EXPECT() *mockSessionManager_Expecter {
+	return &mockSessionManager_Expecter{mock: &_m.Mock}
+}
+
+// RetrieveBOSSession provides a mock function with given fields: authCookie
+func (_m *mockSessionManager) RetrieveBOSSession(authCookie []byte) (*state.Session, error) {
+	ret := _m.Called(authCookie)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveBOSSession")
+	}
+
+	var r0 *state.Session
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) (*state.Session, error)); ok {
+		return rf(authCookie)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) *state.Session); ok {
+		r0 = rf(authCookie)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*state.Session)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(authCookie)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockSessionManager_RetrieveBOSSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveBOSSession'
+type mockSessionManager_RetrieveBOSSession_Call struct {
+	*mock.Call
+}
+
+// RetrieveBOSSession is a helper method to define mock.On call
+//   - authCookie []byte
+func (_e *mockSessionManager_Expecter) RetrieveBOSSession(authCookie interface{}) *mockSessionManager_RetrieveBOSSession_Call {
+	return &mockSessionManager_RetrieveBOSSession_Call{Call: _e.mock.On("RetrieveBOSSession", authCookie)}
+}
+
+func (_c *mockSessionManager_RetrieveBOSSession_Call) Run(run func(authCookie []byte)) *mockSessionManager_RetrieveBOSSession_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockSessionManager_RetrieveBOSSession_Call) Return(_a0 *state.Session, _a1 error) *mockSessionManager_RetrieveBOSSession_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockSessionManager_RetrieveBOSSession_Call) RunAndReturn(run func([]byte) (*state.Session, error)) *mockSessionManager_RetrieveBOSSession_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockSessionManager creates a new instance of mockSessionManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockSessionManager(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockSessionManager {
+	mock := &mockSessionManager{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 10 - 0
server/toc/server.go

@@ -10,6 +10,7 @@ import (
 	"log/slog"
 	"net"
 	"net/http"
+	"net/netip"
 	"sync"
 	"time"
 
@@ -186,6 +187,15 @@ func (rt Server) dispatchFLAP(ctx context.Context, conn net.Conn) error {
 
 	ctx = context.WithValue(ctx, "screenName", sessBOS.IdentScreenName())
 
+	remoteAddr, ok := ctx.Value("ip").(string)
+	if ok {
+		ip, err := netip.ParseAddrPort(remoteAddr)
+		if err != nil {
+			return errors.New("unable to parse ip addr")
+		}
+		sessBOS.SetRemoteAddr(&ip)
+	}
+
 	defer rt.BOSProxy.Signout(ctx, sessBOS)
 
 	// messages from TOC client

+ 259 - 0
state/migrations/0012_suspended_status.down.sql

@@ -0,0 +1,259 @@
+ALTER TABLE users
+    RENAME TO users_old;
+
+CREATE TABLE users
+(
+    identScreenName                  VARCHAR(16) PRIMARY KEY,
+    displayScreenName                TEXT,
+    authKey                          TEXT,
+    strongMD5Pass                    TEXT,
+    weakMD5Pass                      TEXT,
+    confirmStatus                    BOOLEAN               DEFAULT FALSE,
+    emailAddress                     VARCHAR(320) NOT NULL DEFAULT '',
+    regStatus                        INT          NOT NULL DEFAULT 3,
+    isICQ                            BOOLEAN      NOT NULL DEFAULT false,
+    aim_firstName                    TEXT         NOT NULL DEFAULT '',
+    aim_lastName                     TEXT         NOT NULL DEFAULT '',
+    aim_middleName                   TEXT         NOT NULL DEFAULT '',
+    aim_maidenName                   TEXT         NOT NULL DEFAULT '',
+    aim_country                      TEXT         NOT NULL DEFAULT '',
+    aim_state                        TEXT         NOT NULL DEFAULT '',
+    aim_city                         TEXT         NOT NULL DEFAULT '',
+    aim_nickName                     TEXT         NOT NULL DEFAULT '',
+    aim_zipCode                      TEXT         NOT NULL DEFAULT '',
+    aim_address                      TEXT         NOT NULL DEFAULT '',
+    aim_keyword1                     INTEGER,
+    aim_keyword2                     INTEGER,
+    aim_keyword3                     INTEGER,
+    aim_keyword4                     INTEGER,
+    aim_keyword5                     INTEGER,
+    icq_affiliations_currentCode1    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentCode2    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentCode3    INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_currentKeyword1 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_currentKeyword2 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_currentKeyword3 TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastCode1       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastCode2       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastCode3       INTEGER      NOT NULL DEFAULT 0,
+    icq_affiliations_pastKeyword1    TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastKeyword2    TEXT         NOT NULL DEFAULT '',
+    icq_affiliations_pastKeyword3    TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_address            TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_cellPhone          TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_city               TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_countryCode        INTEGER      NOT NULL DEFAULT 0,
+    icq_basicInfo_emailAddress       TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_fax                TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_firstName          TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_gmtOffset          INTEGER      NOT NULL DEFAULT 0,
+    icq_basicInfo_lastName           TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_nickName           TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_phone              TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_publishEmail       BOOLEAN      NOT NULL DEFAULT false,
+    icq_basicInfo_state              TEXT         NOT NULL DEFAULT '',
+    icq_basicInfo_zipCode            TEXT         NOT NULL DEFAULT '',
+    icq_interests_code1              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code2              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code3              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_code4              INTEGER      NOT NULL DEFAULT 0,
+    icq_interests_keyword1           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword2           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword3           TEXT         NOT NULL DEFAULT '',
+    icq_interests_keyword4           TEXT         NOT NULL DEFAULT '',
+    icq_moreInfo_birthDay            INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_birthMonth          INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_birthYear           INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_gender              INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_homePageAddr        TEXT         NOT NULL DEFAULT '',
+    icq_moreInfo_lang1               INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_lang2               INTEGER      NOT NULL DEFAULT 0,
+    icq_moreInfo_lang3               INTEGER      NOT NULL DEFAULT 0,
+    icq_notes                        TEXT         NOT NULL DEFAULT '',
+    icq_permissions_authRequired     BOOLEAN      NOT NULL DEFAULT false,
+    icq_workInfo_address             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_city                TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_company             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_countryCode         INTEGER      NOT NULL DEFAULT 0,
+    icq_workInfo_department          TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_fax                 TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_occupationCode      INTEGER      NOT NULL DEFAULT 0,
+    icq_workInfo_phone               TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_position            TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_state               TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_webPage             TEXT         NOT NULL DEFAULT '',
+    icq_workInfo_zipCode             TEXT         NOT NULL DEFAULT '',
+    tocConfig                        TEXT         NOT NULL DEFAULT '';
+
+    FOREIGN KEY (aim_keyword1) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword2) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword3) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword4) REFERENCES aimKeyword (id),
+    FOREIGN KEY (aim_keyword5) REFERENCES aimKeyword (id)
+);
+
+INSERT INTO users (identScreenName,
+                   displayScreenName,
+                   authKey,
+                   strongMD5Pass,
+                   weakMD5Pass,
+                   confirmStatus,
+                   emailAddress,
+                   regStatus,
+                   isICQ,
+                   aim_firstName,
+                   aim_lastName,
+                   aim_middleName,
+                   aim_maidenName,
+                   aim_country,
+                   aim_state,
+                   aim_city,
+                   aim_nickName,
+                   aim_zipCode,
+                   aim_address,
+                   aim_keyword1,
+                   aim_keyword2,
+                   aim_keyword3,
+                   aim_keyword4,
+                   aim_keyword5,
+                   icq_affiliations_currentCode1,
+                   icq_affiliations_currentCode2,
+                   icq_affiliations_currentCode3,
+                   icq_affiliations_currentKeyword1,
+                   icq_affiliations_currentKeyword2,
+                   icq_affiliations_currentKeyword3,
+                   icq_affiliations_pastCode1,
+                   icq_affiliations_pastCode2,
+                   icq_affiliations_pastCode3,
+                   icq_affiliations_pastKeyword1,
+                   icq_affiliations_pastKeyword2,
+                   icq_affiliations_pastKeyword3,
+                   icq_basicInfo_address,
+                   icq_basicInfo_cellPhone,
+                   icq_basicInfo_city,
+                   icq_basicInfo_countryCode,
+                   icq_basicInfo_emailAddress,
+                   icq_basicInfo_fax,
+                   icq_basicInfo_firstName,
+                   icq_basicInfo_gmtOffset,
+                   icq_basicInfo_lastName,
+                   icq_basicInfo_nickName,
+                   icq_basicInfo_phone,
+                   icq_basicInfo_publishEmail,
+                   icq_basicInfo_state,
+                   icq_basicInfo_zipCode,
+                   icq_interests_code1,
+                   icq_interests_code2,
+                   icq_interests_code3,
+                   icq_interests_code4,
+                   icq_interests_keyword1,
+                   icq_interests_keyword2,
+                   icq_interests_keyword3,
+                   icq_interests_keyword4,
+                   icq_moreInfo_birthDay,
+                   icq_moreInfo_birthMonth,
+                   icq_moreInfo_birthYear,
+                   icq_moreInfo_gender,
+                   icq_moreInfo_homePageAddr,
+                   icq_moreInfo_lang1,
+                   icq_moreInfo_lang2,
+                   icq_moreInfo_lang3,
+                   icq_notes,
+                   icq_permissions_authRequired,
+                   icq_workInfo_address,
+                   icq_workInfo_city,
+                   icq_workInfo_company,
+                   icq_workInfo_countryCode,
+                   icq_workInfo_department,
+                   icq_workInfo_fax,
+                   icq_workInfo_occupationCode,
+                   icq_workInfo_phone,
+                   icq_workInfo_position,
+                   icq_workInfo_state,
+                   icq_workInfo_webPage,
+                   icq_workInfo_zipCode,
+                   tocConfig)
+SELECT identScreenName,
+       displayScreenName,
+       authKey,
+       strongMD5Pass,
+       weakMD5Pass,
+       confirmStatus,
+       emailAddress,
+       regStatus,
+       isICQ,
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       '',
+       NULL,
+       NULL,
+       NULL,
+       NULL,
+       NULL,
+       icq_affiliations_currentCode1,
+       icq_affiliations_currentCode2,
+       icq_affiliations_currentCode3,
+       icq_affiliations_currentKeyword1,
+       icq_affiliations_currentKeyword2,
+       icq_affiliations_currentKeyword3,
+       icq_affiliations_pastCode1,
+       icq_affiliations_pastCode2,
+       icq_affiliations_pastCode3,
+       icq_affiliations_pastKeyword1,
+       icq_affiliations_pastKeyword2,
+       icq_affiliations_pastKeyword3,
+       icq_basicInfo_address,
+       icq_basicInfo_cellPhone,
+       icq_basicInfo_city,
+       icq_basicInfo_countryCode,
+       icq_basicInfo_emailAddress,
+       icq_basicInfo_fax,
+       icq_basicInfo_firstName,
+       icq_basicInfo_gmtOffset,
+       icq_basicInfo_lastName,
+       icq_basicInfo_nickName,
+       icq_basicInfo_phone,
+       icq_basicInfo_publishEmail,
+       icq_basicInfo_state,
+       icq_basicInfo_zipCode,
+       icq_interests_code1,
+       icq_interests_code2,
+       icq_interests_code3,
+       icq_interests_code4,
+       icq_interests_keyword1,
+       icq_interests_keyword2,
+       icq_interests_keyword3,
+       icq_interests_keyword4,
+       icq_moreInfo_birthDay,
+       icq_moreInfo_birthMonth,
+       icq_moreInfo_birthYear,
+       icq_moreInfo_gender,
+       icq_moreInfo_homePageAddr,
+       icq_moreInfo_lang1,
+       icq_moreInfo_lang2,
+       icq_moreInfo_lang3,
+       icq_notes,
+       icq_permissions_authRequired,
+       icq_workInfo_address,
+       icq_workInfo_city,
+       icq_workInfo_company,
+       icq_workInfo_countryCode,
+       icq_workInfo_department,
+       icq_workInfo_fax,
+       icq_workInfo_occupationCode,
+       icq_workInfo_phone,
+       icq_workInfo_position,
+       icq_workInfo_state,
+       icq_workInfo_webPage,
+       icq_workInfo_zipCode,
+       tocConfig
+FROM users_old;
+
+DROP TABLE users_old;

+ 2 - 0
state/migrations/0012_suspended_status.up.sql

@@ -0,0 +1,2 @@
+ALTER TABLE users
+	ADD COLUMN suspendedStatus int NOT NULL DEFAULT 0;

+ 16 - 0
state/session.go

@@ -1,6 +1,7 @@
 package state
 
 import (
+	"net/netip"
 	"sync"
 	"time"
 
@@ -42,6 +43,7 @@ type Session struct {
 	userInfoBitmask   uint16
 	userStatusBitmask uint32
 	clientID          string
+	remoteAddr        *netip.AddrPort
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
@@ -58,6 +60,20 @@ func NewSession() *Session {
 	}
 }
 
+// SetRemoteAddr sets the user's remote IP address
+func (s *Session) SetRemoteAddr(remoteAddr *netip.AddrPort) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.remoteAddr = remoteAddr
+}
+
+// RemoteAddrs returns user's remote IP address
+func (s *Session) RemoteAddr() (remoteAddr *netip.AddrPort) {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.remoteAddr
+}
+
 // SetUserInfoFlag sets a flag to and returns UserInfoBitmask
 func (s *Session) SetUserInfoFlag(flag uint16) (flags uint16) {
 	s.mutex.Lock()

+ 14 - 0
state/session_manager.go

@@ -236,6 +236,20 @@ func (s *InMemoryChatSessionManager) RemoveSession(sess *Session) {
 	}
 }
 
+// RemoveUserFromAllChats removes a user's session from all chat rooms.
+func (s *InMemoryChatSessionManager) RemoveUserFromAllChats(user IdentScreenName) {
+	s.mapMutex.Lock()
+	defer s.mapMutex.Unlock()
+
+	for _, sessionManager := range s.store {
+		userSess := sessionManager.RetrieveSession(user)
+		if userSess != nil {
+			userSess.Close()
+			sessionManager.RemoveSession(userSess)
+		}
+	}
+}
+
 // AllSessions returns all chat room participants. Returns
 // ErrChatRoomNotFound if the room does not exist.
 func (s *InMemoryChatSessionManager) AllSessions(cookie string) []*Session {

+ 23 - 0
state/session_manager_test.go

@@ -458,3 +458,26 @@ func TestInMemoryChatSessionManager_RemoveSession_DoubleLogin(t *testing.T) {
 
 	assert.Len(t, sm.AllSessions("chat-room-1"), 1)
 }
+
+func TestInMemoryChatSessionManager_RemoveUserFromAllChats(t *testing.T) {
+	sm := NewInMemoryChatSessionManager(slog.Default())
+
+	user1 := NewIdentScreenName("user-screen-name-1")
+	user1sess, err := sm.AddSession(context.Background(), "chat-room-1", "user-screen-name-1")
+	assert.NoError(t, err)
+	user2sess, err := sm.AddSession(context.Background(), "chat-room-1", "user-screen-name-2")
+	assert.NoError(t, err)
+
+	assert.Len(t, sm.AllSessions("chat-room-1"), 2)
+
+	sm.RemoveUserFromAllChats(user1)
+
+	lookup := make(map[*Session]bool)
+	for _, session := range sm.AllSessions("chat-room-1") {
+		lookup[session] = true
+	}
+
+	assert.False(t, lookup[user1sess])
+	assert.True(t, lookup[user2sess])
+
+}

+ 9 - 0
state/session_test.go

@@ -1,6 +1,7 @@
 package state
 
 import (
+	"net/netip"
 	"sync"
 	"testing"
 	"time"
@@ -66,6 +67,14 @@ func TestSession_SetAndGetClientID(t *testing.T) {
 	assert.Equal(t, clientID, s.ClientID())
 }
 
+func TestSession_SetAndGetRemoteAddr(t *testing.T) {
+	s := NewSession()
+	assert.Empty(t, s.RemoteAddr())
+	remoteAddr, _ := netip.ParseAddrPort("1.2.3.4:1234")
+	s.SetRemoteAddr(&remoteAddr)
+	assert.Equal(t, &remoteAddr, s.RemoteAddr())
+}
+
 func TestSession_TLVUserInfo(t *testing.T) {
 	tests := []struct {
 		name           string

+ 2 - 0
state/user.go

@@ -176,6 +176,8 @@ type User struct {
 	//  2: limit disclosure
 	//  3: full disclosure
 	RegStatus int
+	// SuspendedStatus is the account suspended status
+	SuspendedStatus uint16
 	// EmailAddress is the email address set by the AIM client.
 	EmailAddress string
 	// ICQAffiliations holds information about the user's affiliations,

+ 13 - 0
state/user_store.go

@@ -356,6 +356,7 @@ func (f SQLiteUserStore) queryUsers(whereClause string, queryParams []any) ([]Us
 			weakMD5Pass,
 			confirmStatus,
 			regStatus,
+			suspendedStatus,
 			isICQ,
 			icq_affiliations_currentCode1,
 			icq_affiliations_currentCode2,
@@ -447,6 +448,7 @@ func (f SQLiteUserStore) queryUsers(whereClause string, queryParams []any) ([]Us
 			&u.WeakMD5Pass,
 			&u.ConfirmStatus,
 			&u.RegStatus,
+			&u.SuspendedStatus,
 			&u.IsICQ,
 			&u.ICQAffiliations.CurrentCode1,
 			&u.ICQAffiliations.CurrentCode2,
@@ -1278,6 +1280,17 @@ func (f SQLiteUserStore) ConfirmStatusByName(screenName IdentScreenName) (bool,
 	return confirmStatus, nil
 }
 
+// UpdateSuspendedStatus updates the user's suspended status
+func (f SQLiteUserStore) UpdateSuspendedStatus(suspendedStatus uint16, screenName IdentScreenName) error {
+	q := `
+		UPDATE users
+		SET suspendedStatus = ?
+		WHERE identScreenName = ?
+	`
+	_, err := f.db.Exec(q, suspendedStatus, screenName.String())
+	return err
+}
+
 // SetWorkInfo updates the work-related information for an ICQ user.
 func (f SQLiteUserStore) SetWorkInfo(name IdentScreenName, data ICQWorkInfo) error {
 	q := `

+ 31 - 0
state/user_store_test.go

@@ -3208,3 +3208,34 @@ func TestSQLiteUserStore_PermitDenyTransitionIntegration(t *testing.T) {
 	}
 	assert.ElementsMatch(t, relationships, expect)
 }
+
+func TestSQLiteUserStore_UpdateSuspendedStatus(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	screenName := NewIdentScreenName("userA")
+
+	insertedUser := &User{
+		IdentScreenName:   screenName,
+		DisplayScreenName: DisplayScreenName("usera"),
+		AuthKey:           "theauthkey",
+		StrongMD5Pass:     []byte("thepasshash"),
+		RegStatus:         3,
+		SuspendedStatus:   wire.LoginErrSuspendedAccount,
+	}
+	err = f.InsertUser(*insertedUser)
+	assert.NoError(t, err)
+
+	err = f.UpdateSuspendedStatus(wire.LoginErrSuspendedAccountAge, screenName)
+	assert.NoError(t, err)
+
+	user, err := f.User(screenName)
+	assert.NoError(t, err)
+
+	assert.Equal(t, user.SuspendedStatus, wire.LoginErrSuspendedAccountAge)
+
+}

+ 14 - 1
wire/snacs.go

@@ -94,7 +94,17 @@ const (
 const (
 	LoginErrInvalidUsernameOrPassword uint16 = 0x0001
 	LoginErrInvalidPassword           uint16 = 0x0005 // invalid password
-	LoginErrICQUserErr                uint16 = 0x0008 // ICQ user doesn't exist
+	LoginErrInvalidAccount            uint16 = 0x0007
+	LoginErrDeletedAccount            uint16 = 0x0008
+	LoginErrExpiredAccount            uint16 = 0x0009
+	LoginErrSuspendedAccount          uint16 = 0x0011 // suspended account
+	LoginErrTooHeavilyWarned          uint16 = 0x0019
+	LoginErrRateLimitExceeded         uint16 = 0x001D
+	LoginErrInvalidSecureID           uint16 = 0x0020
+	LoginErrSuspendedAccountAge       uint16 = 0x0022 // suspended due to age (age < 13 years)
+
+	LoginErrICQUserErr uint16 = 0x0008 // ICQ user doesn't exist
+
 )
 
 //
@@ -188,6 +198,9 @@ const (
 	OServiceTLVTagsGroupID       uint16 = 0x0D
 	OServiceTLVTagsSSLCertName   uint16 = 0x8D
 	OServiceTLVTagsSSLState      uint16 = 0x8E
+
+	OServiceDiscErrNewLogin   uint8 = 0x01
+	OServiceDiscErrAccDeleted uint8 = 0x02
 )
 
 type SNAC_0x01_0x02_OServiceClientOnline struct {